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 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3991 Some(merged)
3992 }
3993 Err(error) => {
3994 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3995 environment_overlay
3996 }
3997 }
3998 }
3999 (SessionMode::Pty, Some(_), None, _) => {
4000 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4001 environment_overlay
4002 }
4003 _ => environment_overlay,
4004 };
4005 let mut instance_overlay = environment_overlay
4006 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4007 .transpose()?
4008 .flatten();
4009 Ok(PreparedSpawn {
4010 instance_overlay,
4011 environment_selection,
4012 materialization_guard,
4013 harness_mcp_overlay,
4014 #[cfg(feature = "dig2-station-probe")]
4015 browser_station_lease_guard,
4016 materialization_id,
4017 })
4018 }
4019}
4020
4021struct SessionRecords {
4022 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4023}
4024
4025struct SpawnIdempotencyCache {
4026 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4027}
4028
4029struct SpawnIdempotencyEntry {
4030 value: SpawnIdempotencyValue,
4031 expires_at: Instant,
4032}
4033
4034enum SpawnIdempotencyValue {
4035 Standard {
4036 spec: SpawnSpec,
4037 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4038 },
4039 Managed {
4040 request: ManagedWorktreeSpawnRequest,
4041 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4042 },
4043 HarnessMcp,
4044}
4045
4046enum DurableManagedSpawnReplayDecision {
4047 Reserved,
4048 PendingLinked,
4049 Committed(ManagedWorktreeSpawnReceipt),
4050}
4051
4052impl SpawnIdempotencyValue {
4053 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4054 match self {
4055 Self::Standard { result, .. } => result
4056 .as_ref()
4057 .ok()
4058 .and_then(|receipt| receipt.context.as_ref()),
4059 Self::Managed { result, .. } => result
4060 .as_ref()
4061 .ok()
4062 .and_then(|receipt| receipt.spawn.context.as_ref()),
4063 Self::HarnessMcp => None,
4064 }
4065 .is_some_and(|receipt| &receipt.id == context_id)
4066 }
4067}
4068
4069#[derive(Clone, Copy)]
4070struct ControllerLease {
4071 connection_id: u64,
4072 expires_at: Instant,
4073}
4074
4075struct NodeEventHistory {
4076 last_sequence: u64,
4077 replay_floor_sequence: u64,
4078 events: VecDeque<NodeEventEnvelope>,
4079 record_providers: BTreeMap<SessionRecordId, AgentId>,
4080 removed_record_providers: BTreeMap<u64, AgentId>,
4081}
4082
4083impl NodeEventHistory {
4084 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4085 Self {
4086 last_sequence: 0,
4087 replay_floor_sequence: 1,
4088 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4089 record_providers,
4090 removed_record_providers: BTreeMap::new(),
4091 }
4092 }
4093}
4094
4095impl NodeShared {
4096 fn session_grok_leader_socket(
4100 &self,
4101 provider: &AgentId,
4102 mode: SessionMode,
4103 instance_id: AgentInstanceId,
4104 ) -> Option<std::path::PathBuf> {
4105 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4106 return None;
4107 }
4108 let root = self
4109 .m4a_accounts
4110 .as_ref()
4111 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4112 .unwrap_or_else(std::env::temp_dir);
4113 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4114 if let Some(previous) = self
4115 .grok_leaders
4116 .lock()
4117 .unwrap_or_else(|p| p.into_inner())
4118 .insert(instance_id, socket.clone())
4119 {
4120 crate::grok_leader::retire(&previous);
4121 }
4122 Some(socket)
4123 }
4124
4125 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4127 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4128 if let Some(socket) = socket {
4129 crate::grok_leader::retire(&socket);
4130 }
4131 }
4132
4133 fn new_with_incarnation(
4134 handle: Gate4AgentHandle,
4135 access_token: String,
4136 node_id: NodeId,
4137 incarnation_id: NodeIncarnationId,
4138 workspaces: Vec<WorkspaceConfig>,
4139 enabled_providers: Vec<AgentId>,
4140 provider_runtime_statuses: ProviderRuntimeStatuses,
4141 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4142 provider_contracts: Vec<ProviderContractSupport>,
4143 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4144 spawn_profiles: SpawnProfileRegistry,
4145 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4146 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4147 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4148 state_path: Option<PathBuf>,
4149 records: Vec<ManagedSessionRecord>,
4150 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4151 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4152 materializations: Vec<MaterializationOwnershipRecord>,
4153 persistence_warning: Option<String>,
4154 input_settle_timeout_ms: u64,
4155 ) -> Self {
4156 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4157 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4158 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4159 let (harness_mcp_proxy_event_tx, _) =
4160 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4161 let record_providers = records
4162 .iter()
4163 .map(|record| (record.record_id.clone(), record.provider.clone()))
4164 .collect();
4165 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4166 .iter()
4167 .map(|workspace| {
4168 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4169 })
4170 .collect();
4171 for lease in &managed_worktrees {
4172 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4173 }
4174 let managed_worktree_profiles = workspaces.iter()
4175 .map(|workspace| (
4176 workspace.workspace_id.clone(),
4177 workspace.managed_worktree_profiles.clone(),
4178 ))
4179 .collect();
4180 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4181 managed_worktrees,
4182 managed_worktree_tombstones,
4183 ).expect("durable managed worktree registry was validated while loading");
4184 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4185 managed_worktrees.reattach_record_holders(
4186 &managed_worktree_record_holders(&records),
4187 unix_time_ms(),
4188 );
4189 Self {
4190 handle,
4191 access_token,
4192 node_id,
4193 incarnation_id,
4194 started_at_unix_ms: std::time::SystemTime::now()
4195 .duration_since(std::time::UNIX_EPOCH)
4196 .unwrap_or_default()
4197 .as_millis()
4198 .min(u64::MAX as u128) as u64,
4199 workspaces: RwLock::new(
4200 workspaces
4201 .into_iter()
4202 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4203 .collect(),
4204 ),
4205 worktree_service_modes,
4206 managed_worktree_profiles,
4207 managed_worktrees: Mutex::new(managed_worktrees),
4208 enabled_providers,
4209 provider_runtime_statuses,
4210 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4211 provider_runtime_monitor,
4212 provider_contracts,
4213 provider_adapter_contracts,
4214 spawn_profiles,
4215 environment_profiles: RwLock::new(BTreeMap::new()),
4216 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4217 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4218 #[cfg(feature = "dig2-station-probe")]
4219 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4220 bundle_catalog: RwLock::new(BundleCatalog::default()),
4221 delivery_store: Mutex::new(None),
4222 context_pack_store: Mutex::new(None),
4223 context_catalog: RwLock::new(ContextPackCatalog::default()),
4224 native_launch_profile_control,
4225 harness_mcp_registry: None,
4226 native_session_catalog,
4227 session_environment_materializer,
4228 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4229 m4a_live: Mutex::new(BTreeMap::new()),
4230 grok_leaders: Mutex::new(BTreeMap::new()),
4231 #[cfg(unix)]
4232 signer_proxy: std::sync::OnceLock::new(),
4233 materializations: Mutex::new(
4234 materializations
4235 .into_iter()
4236 .map(|record| (record.id().clone(), record))
4237 .collect(),
4238 ),
4239 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4240 entries: BTreeMap::new(),
4241 }),
4242 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4243 controller: Mutex::new(None),
4244 provisional_spawns: Mutex::new(BTreeMap::new()),
4245 pending_acp_options: Mutex::new(BTreeMap::new()),
4246 history: Mutex::new(NodeEventHistory::new(record_providers)),
4247 event_tx,
4248 terminal_event_tx,
4249 agent_stream_event_tx,
4250 harness_mcp_proxy_event_tx,
4251 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4252 pty_input_wake: tokio::sync::Notify::new(),
4253 pty_input_marks: Mutex::new(BTreeMap::new()),
4254 next_connection_id: AtomicU64::new(1),
4255 next_instance_id: AtomicU64::new(1),
4256 next_command_id: AtomicU64::new(1),
4257 shutdown: AtomicBool::new(false),
4258 shutdown_notify: Notify::new(),
4259 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4260 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4261 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4262 mutation_gate: AsyncMutex::new(()),
4263 session_bindings: Mutex::new(BTreeMap::new()),
4264 session_records: Mutex::new(SessionRecords {
4265 records: records
4266 .into_iter()
4267 .map(|record| (record.record_id.clone(), record))
4268 .collect(),
4269 }),
4270 state_path,
4271 persistence_error: RwLock::new(
4272 persistence_warning
4273 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4274 ),
4275 state_transaction: Mutex::new(()),
4276 input_settle_timeout_ms,
4277 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4278 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4279 shell_efficiency_profile: OnceLock::new(),
4280 native_session_gauge: AtomicUsize::new(0),
4281 connection_loop_iterations: AtomicU64::new(0),
4282 connection_events_sent: AtomicU64::new(0),
4283 rejected_commands_total: AtomicU64::new(0),
4284 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4285 drive_loop_iterations_total: AtomicU64::new(0),
4286 drive_loop_iterations_idle: AtomicU64::new(0),
4287 session_record_retention: SessionRecordRetentionConfig::default(),
4288 retired_records_total: AtomicU64::new(0),
4289 #[cfg(feature = "fixture")]
4290 fixture_semantic_hook_policy: false,
4291 #[cfg(feature = "fixture")]
4292 fixture_spawn_managed_worktree_v2_failure_probe:
4293 SpawnManagedWorktreeV2FailureProbe::default(),
4294 }
4295 }
4296
4297 #[cfg(test)]
4298 fn new(
4299 handle: Gate4AgentHandle,
4300 access_token: String,
4301 node_id: NodeId,
4302 workspaces: Vec<WorkspaceConfig>,
4303 enabled_providers: Vec<AgentId>,
4304 ) -> Self {
4305 Self::new_with_incarnation(
4306 handle,
4307 access_token,
4308 node_id,
4309 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4310 workspaces,
4311 enabled_providers,
4312 ProviderRuntimeStatuses::default(),
4313 None,
4314 Vec::new(),
4315 Vec::new(),
4316 SpawnProfileRegistry::default(),
4317 None,
4318 None,
4319 None,
4320 None,
4321 Vec::new(),
4322 Vec::new(),
4323 Vec::new(),
4324 Vec::new(),
4325 None,
4326 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4327 )
4328 }
4329
4330 fn persistence_error(&self) -> Option<String> {
4331 self.persistence_error
4332 .read()
4333 .unwrap_or_else(|poisoned| poisoned.into_inner())
4334 .clone()
4335 }
4336
4337 async fn admit_provider_runtime(
4338 &self,
4339 provider: &AgentId,
4340 requirement: ProviderRuntimeRequirement,
4341 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4342 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4343 let pipe_transport = monitor.supports_pipe_transport(provider);
4344 let acp_transport = monitor.supports_acp_transport(provider);
4345 let provider = provider.clone();
4346 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4347 let (status, admission) = timeout(
4348 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4349 refresh,
4350 )
4351 .await
4352 .map_err(|_| failure(
4353 NodeFailureCode::BackendBusy,
4354 "provider runtime probe exceeded its bounded deadline",
4355 ))?
4356 .map_err(|_| failure(
4357 NodeFailureCode::BackendOperationFailed,
4358 "provider runtime probe task failed",
4359 ))?;
4360 if let Some(status) = status {
4361 self
4362 .provider_runtime_status_updates
4363 .write()
4364 .unwrap_or_else(|poisoned| poisoned.into_inner())
4365 .insert(status.provider().clone(), status);
4366 }
4367 admission.and_then(|policy| {
4368 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4369 })
4370 } else {
4371 crate::provider_runtime::admit_status(
4377 &self.provider_runtime_statuses,
4378 provider,
4379 false,
4380 false,
4381 requirement,
4382 )
4383 };
4384 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4385 admission.map_err(|error| match error {
4386 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4387 NodeFailureCode::BackendOperationFailed,
4388 "provider launcher is unavailable",
4389 ),
4390 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4391 NodeFailureCode::UnsupportedCapability,
4392 "provider semantic capability is not verified",
4393 ),
4394 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4395 NodeFailureCode::BackendBusy,
4396 "provider runtime probe is already in progress",
4397 ),
4398 })
4399 }
4400
4401 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4407 match self.provider_runtime_monitor.as_deref() {
4408 Some(monitor) => monitor.supports_pipe_transport(provider),
4409 None => false,
4410 }
4411 }
4412
4413 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4419 match self.provider_runtime_monitor.as_deref() {
4420 Some(monitor) => monitor.supports_acp_transport(provider),
4421 None => false,
4422 }
4423 }
4424
4425 fn admit_declared_hook_semantics(
4441 &self,
4442 provider: &AgentId,
4443 policy: ProviderRuntimePolicy,
4444 ) -> ProviderRuntimePolicy {
4445 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4446 return policy;
4447 }
4448 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4449 contract.provider == *provider && contract.family == AdapterFamily::Hook
4450 });
4451 if !declares_hook {
4452 return policy;
4453 }
4454 ProviderRuntimePolicy::new(
4455 policy.raw_pty_lifecycle,
4456 policy.semantic_readiness,
4457 policy.structured_prompt,
4458 policy.provider_session_identity,
4459 policy.semantic_resume,
4460 true,
4461 )
4462 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4463 }
4464
4465 fn resolve_network_allowlist_overlay_args(
4480 &self,
4481 provider: &AgentId,
4482 mode: SessionMode,
4483 approval_level: ApprovalLevel,
4484 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4485 provider_home: Option<&Path>,
4486 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4487 let Some(allowlist_id) = network_allowlist else {
4488 return Ok(Vec::new());
4489 };
4490 let catalog = self
4491 .network_allowlist_catalog
4492 .read()
4493 .unwrap_or_else(|poisoned| poisoned.into_inner());
4494 let Some(entry) = catalog.get(allowlist_id) else {
4495 return Err(failure(
4496 NodeFailureCode::UnknownNetworkAllowlist,
4497 "spawn network allowlist is unavailable on this node",
4498 ));
4499 };
4500
4501 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4503 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4504 |message| {
4505 failure(
4506 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4507 &message,
4508 )
4509 },
4510 )?;
4511 if !matches!(mode, SessionMode::Pty) {
4512 return Err(failure(
4513 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4514 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4515 ));
4516 }
4517 let Some(home) = provider_home else {
4518 return Err(failure(
4519 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4520 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4521 ));
4522 };
4523 let domains =
4524 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4525 if domains.is_empty() {
4526 return Err(failure(
4527 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4528 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4529 ));
4530 }
4531 let settings_path = home.join(
4532 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4533 );
4534 let json =
4535 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4536 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4537 |_| {
4538 failure(
4539 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4540 "Claude station network settings sidecar could not be written under ProviderHome",
4541 )
4542 },
4543 )?;
4544 let overlay =
4545 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4546 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4547 }
4548
4549 let Some(native) = entry.provider_native.as_ref() else {
4550 return Ok(Vec::new());
4551 };
4552 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4553 provider.as_str(),
4554 approval_level,
4555 native,
4556 )
4557 .map_err(|message| {
4558 failure(
4559 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4560 &message,
4561 )
4562 })?;
4563 if overlay.is_empty() {
4564 return Ok(Vec::new());
4565 }
4566 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4567 return Err(failure(
4568 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4569 "Codex network_access -c overlay requires PTY or ACP transport",
4570 ));
4571 }
4572 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4573 }
4574
4575 fn resolve_codex_network_access_overlay_args(
4577 &self,
4578 provider: &AgentId,
4579 mode: SessionMode,
4580 approval_level: ApprovalLevel,
4581 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4582 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4583 self.resolve_network_allowlist_overlay_args(
4584 provider,
4585 mode,
4586 approval_level,
4587 network_allowlist,
4588 None,
4589 )
4590 }
4591
4592 fn resolve_environment_profile(
4593 &self,
4594 resolved: &ResolvedSpawnSpec,
4595 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4596 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4602 let catalog = self
4603 .network_allowlist_catalog
4604 .read()
4605 .unwrap_or_else(|poisoned| poisoned.into_inner());
4606 let Some(entry) = catalog.get(allowlist_id) else {
4607 return Err(failure(
4608 NodeFailureCode::UnknownNetworkAllowlist,
4609 "spawn network allowlist is unavailable on this node",
4610 ));
4611 };
4612 if let Some(native) = entry.provider_native.as_ref() {
4615 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4616 resolved.provider.as_str(),
4617 resolved.approval_level,
4618 native,
4619 ) {
4620 return Err(failure(
4621 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4622 &message,
4623 ));
4624 }
4625 }
4626 }
4627 #[cfg(feature = "dig2-station-probe")]
4636 if resolved.browser_profile_id.is_some() {
4637 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4638 match probe_station_reachable() {
4639 Ok(()) => {}
4640 Err(StationProbeError::UnsupportedPlatform)
4641 | Err(StationProbeError::InvalidPipeSuffix) => {
4642 return Err(failure(
4643 NodeFailureCode::BrowserStationProbeUnavailable,
4644 "dig2browser station probe unavailable on this platform",
4645 ));
4646 }
4647 Err(StationProbeError::Unreachable) => {
4648 return Err(failure(
4649 NodeFailureCode::BrowserStationUnreachable,
4650 "dig2browser station named-pipe is unreachable",
4651 ));
4652 }
4653 }
4654 }
4655 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4656 return Ok(None);
4657 };
4658 let profiles = self
4659 .environment_profiles
4660 .read()
4661 .unwrap_or_else(|poisoned| poisoned.into_inner());
4662 let binding = profiles.get(profile_id).ok_or_else(|| {
4663 failure(
4664 NodeFailureCode::UnknownEnvironmentProfile,
4665 "spawn environment profile is unavailable on this node",
4666 )
4667 })?;
4668 if binding.provider != resolved.provider
4669 || binding.native_profile_id(resolved.mode).is_none()
4670 {
4671 return Err(failure(
4672 NodeFailureCode::EnvironmentProfileBindingMismatch,
4673 "spawn environment profile does not match the provider and mode",
4674 ));
4675 }
4676 Ok(Some(ResolvedEnvironmentProfileReceipt {
4680 profile_id: binding.id.clone(),
4681 profile_revision: binding.revision.clone(),
4682 network_allowlist: resolved.network_allowlist.clone(),
4683 browser_profile_id: resolved.browser_profile_id.clone(),
4684 }))
4685 }
4686
4687 fn resolve_bundle(
4688 &self,
4689 resolved: &ResolvedSpawnSpec,
4690 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4691 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4692 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4693 return Ok(None);
4694 };
4695 let catalog = self.bundle_catalog
4696 .read()
4697 .unwrap_or_else(|poisoned| poisoned.into_inner());
4698 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4699 failure(
4700 NodeFailureCode::UnknownBundle,
4701 "spawn bundle is unavailable on this node",
4702 )
4703 })?;
4704 self.bundle_layout(
4705 &resolved.provider,
4706 resolved.mode,
4707 environment_profile,
4708 bundle,
4709 )?;
4710 Ok(Some(bundle.receipt()))
4711 }
4712
4713 fn begin_delivery_stage(
4714 &self,
4715 manifest: DeliveryBundleManifestV2,
4716 ) -> Result<NodeResponse, NodeFailure> {
4717 let mut store = self
4718 .delivery_store
4719 .lock()
4720 .unwrap_or_else(|poisoned| poisoned.into_inner());
4721 let store = store.as_mut().ok_or_else(|| {
4722 failure(
4723 NodeFailureCode::DeliveryStageStorageFailed,
4724 "node delivery store is unavailable",
4725 )
4726 })?;
4727 let begun = store.begin(manifest).map_err(delivery_failure)?;
4728 Ok(NodeResponse::DeliveryStageBegun {
4729 stage_id: begun.stage_id,
4730 manifest_digest: begun.manifest_digest,
4731 missing_blobs: begun.missing_blobs,
4732 })
4733 }
4734
4735 fn put_delivery_blob_chunk(
4736 &self,
4737 stage_id: DeliveryStageId,
4738 blob_digest: DeliveryBlobDigestV1,
4739 offset: u64,
4740 chunk_hex: DeliveryBlobChunkHexV1,
4741 ) -> Result<NodeResponse, NodeFailure> {
4742 let mut store = self
4743 .delivery_store
4744 .lock()
4745 .unwrap_or_else(|poisoned| poisoned.into_inner());
4746 let store = store.as_mut().ok_or_else(|| {
4747 failure(
4748 NodeFailureCode::DeliveryStageStorageFailed,
4749 "node delivery store is unavailable",
4750 )
4751 })?;
4752 let accepted = store
4753 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4754 .map_err(delivery_failure)?;
4755 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4756 stage_id: accepted.stage_id,
4757 blob_digest: accepted.blob_digest,
4758 next_offset: accepted.next_offset,
4759 })
4760 }
4761
4762 fn commit_delivery_stage(
4763 &self,
4764 stage_id: DeliveryStageId,
4765 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4766 let mut store = self
4767 .delivery_store
4768 .lock()
4769 .unwrap_or_else(|poisoned| poisoned.into_inner());
4770 let store = store.as_mut().ok_or_else(|| {
4771 failure(
4772 NodeFailureCode::DeliveryStageStorageFailed,
4773 "node delivery store is unavailable",
4774 )
4775 })?;
4776 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4777 let mut catalog = self
4778 .bundle_catalog
4779 .write()
4780 .unwrap_or_else(|poisoned| poisoned.into_inner());
4781 let mut next_catalog = catalog.clone();
4782 next_catalog
4783 .insert_idempotent(prepared.bundle().clone())
4784 .map_err(|_| {
4785 failure(
4786 NodeFailureCode::DeliveryStageConflict,
4787 "delivery bundle identity conflicts with installed content",
4788 )
4789 })?;
4790 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4791 *catalog = next_catalog;
4792 Ok(receipt)
4793 }
4794
4795 fn abort_delivery_stage(
4796 &self,
4797 stage_id: &DeliveryStageId,
4798 ) -> Result<(), NodeFailure> {
4799 let mut store = self
4800 .delivery_store
4801 .lock()
4802 .unwrap_or_else(|poisoned| poisoned.into_inner());
4803 let store = store.as_mut().ok_or_else(|| {
4804 failure(
4805 NodeFailureCode::DeliveryStageStorageFailed,
4806 "node delivery store is unavailable",
4807 )
4808 })?;
4809 store.abort(stage_id).map_err(delivery_failure)
4810 }
4811
4812 fn resolve_context(
4813 &self,
4814 resolved: &ResolvedSpawnSpec,
4815 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4816 let Some(context_id) = resolved.context_id.as_ref() else {
4817 return Ok(None);
4818 };
4819 self.context_catalog
4820 .read()
4821 .unwrap_or_else(|poisoned| poisoned.into_inner())
4822 .get(context_id)
4823 .map(|context| context.receipt().clone())
4824 .ok_or_else(|| {
4825 failure(
4826 NodeFailureCode::UnknownContextPack,
4827 "spawn context pack is unavailable on this node",
4828 )
4829 })
4830 .map(Some)
4831 }
4832
4833 fn bundle_layout(
4834 &self,
4835 provider: &AgentId,
4836 mode: SessionMode,
4837 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4838 bundle: &NodeBundle,
4839 ) -> Result<BundleProviderLayout, NodeFailure> {
4840 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4841 failure(
4842 NodeFailureCode::BundleBindingMismatch,
4843 "spawn bundle does not match the provider and mode",
4844 )
4845 })?;
4846 if layout != BundleProviderLayout::Codex {
4847 return Ok(layout);
4848 }
4849 let profile = environment_profile.and_then(|receipt| {
4850 let profiles = self
4851 .environment_profiles
4852 .read()
4853 .unwrap_or_else(|poisoned| poisoned.into_inner());
4854 let binding = profiles.get(&receipt.profile_id)?;
4855 if binding.revision != receipt.profile_revision
4856 || &binding.provider != provider
4857 || binding.native_profile_id(mode).is_none()
4858 {
4859 return None;
4860 }
4861 self.environment_materialization_profiles
4862 .read()
4863 .unwrap_or_else(|poisoned| poisoned.into_inner())
4864 .get(&receipt.profile_id)
4865 .cloned()
4866 });
4867 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4868 return Err(failure(
4869 NodeFailureCode::BundleBindingMismatch,
4870 "Codex bundle requires an exact isolated CODEX_HOME profile",
4871 ));
4872 }
4873 Ok(layout)
4874 }
4875
4876 fn select_environment_profile(
4877 &self,
4878 instance_id: AgentInstanceId,
4879 provider: &AgentId,
4880 mode: SessionMode,
4881 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4882 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4883 let Some(environment_profile) = environment_profile else {
4884 return Ok(None);
4885 };
4886 let native_profile_id = {
4887 let profiles = self
4888 .environment_profiles
4889 .read()
4890 .unwrap_or_else(|poisoned| poisoned.into_inner());
4891 let binding = profiles
4892 .get(&environment_profile.profile_id)
4893 .ok_or_else(|| {
4894 failure(
4895 NodeFailureCode::UnknownEnvironmentProfile,
4896 "session environment profile is unavailable on this node",
4897 )
4898 })?;
4899 if &binding.revision != &environment_profile.profile_revision
4900 || &binding.provider != provider
4901 {
4902 return Err(failure(
4903 NodeFailureCode::EnvironmentProfileBindingMismatch,
4904 "session environment profile revision or provider changed",
4905 ));
4906 }
4907 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4908 failure(
4909 NodeFailureCode::EnvironmentProfileBindingMismatch,
4910 "session environment profile does not support this mode",
4911 )
4912 })?
4913 };
4914 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4915 failure(
4916 NodeFailureCode::BackendOperationFailed,
4917 "native environment profile controller is unavailable",
4918 )
4919 })?;
4920 control
4921 .select_native_launch_profile(instance_id, native_profile_id)
4922 .map_err(|_| {
4923 failure(
4924 NodeFailureCode::BackendOperationFailed,
4925 "native environment profile selection failed",
4926 )
4927 })?;
4928 Ok(Some(NativeEnvironmentSelectionGuard {
4929 control: Some(control),
4930 instance_id,
4931 }))
4932 }
4933
4934 fn install_prepared_launch_overlay(
4935 &self,
4936 instance_id: AgentInstanceId,
4937 overlay: PreparedNativeLaunchOverlay,
4938 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4939 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4940 failure(
4941 NodeFailureCode::BackendOperationFailed,
4942 "native launch profile controller is unavailable",
4943 )
4944 })?;
4945 match overlay {
4946 PreparedNativeLaunchOverlay::Environment(overlay) => {
4947 control
4948 .install_native_launch_environment_overlay(instance_id, overlay)
4949 .map_err(|_| failure(
4950 NodeFailureCode::BackendOperationFailed,
4951 "session environment overlay installation failed",
4952 ))?;
4953 Ok(None)
4954 }
4955 PreparedNativeLaunchOverlay::Instance(overlay) => {
4956 control
4957 .install_native_instance_launch_overlay(instance_id, overlay)
4958 .map_err(|_| failure(
4959 NodeFailureCode::BundleBindingMismatch,
4960 "session bundle launch overlay installation failed",
4961 ))?;
4962 Ok(Some(NativeInstanceOverlayGuard {
4963 control: Some(control),
4964 instance_id,
4965 }))
4966 }
4967 }
4968 }
4969
4970 fn environment_materialization_profile(
4971 &self,
4972 environment_profile: &ResolvedEnvironmentProfileReceipt,
4973 ) -> Option<NodeSessionMaterializationProfile> {
4974 self.environment_materialization_profiles
4975 .read()
4976 .unwrap_or_else(|poisoned| poisoned.into_inner())
4977 .get(&environment_profile.profile_id)
4978 .cloned()
4979 }
4980
4981 fn materialization_profile(
4982 &self,
4983 provider: &AgentId,
4984 mode: SessionMode,
4985 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4986 bundle: Option<&ResolvedBundleReceipt>,
4987 context: Option<&ResolvedContextPackReceipt>,
4988 ) -> Result<Option<(
4989 NodeSessionMaterializationProfile,
4990 bool,
4991 Option<BundleProviderLayout>,
4992 )>, NodeFailure> {
4993 let environment = environment_profile
4994 .and_then(|receipt| self.environment_materialization_profile(receipt));
4995 let (bundle, bundle_layout) = match bundle {
4996 Some(receipt) => {
4997 let catalog = self.bundle_catalog.read()
4998 .unwrap_or_else(|poisoned| poisoned.into_inner());
4999 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5000 NodeFailureCode::UnknownBundle,
5001 "session bundle is unavailable on this node",
5002 ))?;
5003 if installed.receipt() != *receipt {
5004 return Err(failure(
5005 NodeFailureCode::BundleBindingMismatch,
5006 "session bundle revision or digest changed",
5007 ));
5008 }
5009 let layout = self.bundle_layout(
5010 provider,
5011 mode,
5012 environment_profile,
5013 installed,
5014 )?;
5015 (Some(installed.clone()), Some(layout))
5016 }
5017 None => (None, None),
5018 };
5019 let has_environment_overlay = environment.is_some() || context.is_some();
5020 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5021 (None, None, None) => None,
5022 (Some(profile), None, None) => Some(Ok(profile)),
5023 (None, Some(bundle), Some(layout)) => {
5024 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5025 }
5026 (Some(profile), Some(bundle), Some(layout)) => {
5027 Some(profile.with_bundle(bundle, layout))
5028 }
5029 _ => {
5030 return Err(failure(
5031 NodeFailureCode::BundleBindingMismatch,
5032 "session bundle provider layout is unavailable",
5033 ));
5034 }
5035 };
5036 let profile = profile
5037 .transpose()
5038 .map_err(|_| failure(
5039 NodeFailureCode::BundleMaterializationFailed,
5040 "session bundle materialization profile is invalid",
5041 ))?;
5042 let profile = match (profile, context) {
5043 (Some(profile), Some(receipt)) => {
5044 let catalog = self.context_catalog
5045 .read()
5046 .unwrap_or_else(|poisoned| poisoned.into_inner());
5047 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5048 NodeFailureCode::UnknownContextPack,
5049 "session context pack is unavailable on this node",
5050 ))?;
5051 if pack.receipt() != receipt {
5052 return Err(failure(
5053 NodeFailureCode::ContextPackMaterializationFailed,
5054 "session context pack identity changed",
5055 ));
5056 }
5057 profile.with_context(pack)
5058 }
5059 (None, Some(receipt)) => {
5060 let catalog = self.context_catalog
5061 .read()
5062 .unwrap_or_else(|poisoned| poisoned.into_inner());
5063 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5064 NodeFailureCode::UnknownContextPack,
5065 "session context pack is unavailable on this node",
5066 ))?;
5067 if pack.receipt() != receipt {
5068 return Err(failure(
5069 NodeFailureCode::ContextPackMaterializationFailed,
5070 "session context pack identity changed",
5071 ));
5072 }
5073 NodeSessionMaterializationProfile::from_context(pack)
5074 }
5075 (Some(profile), None) => Ok(profile),
5076 (None, None) => return Ok(None),
5077 }
5078 .map_err(|_| failure(
5079 NodeFailureCode::ContextPackMaterializationFailed,
5080 "session context pack materialization profile is invalid",
5081 ))?;
5082 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5083 }
5084
5085 fn prepare_session_materialization<'a>(
5086 &'a self,
5087 address: &SessionAddress,
5088 provider: &AgentId,
5089 mode: SessionMode,
5090 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5091 bundle: Option<&ResolvedBundleReceipt>,
5092 context: Option<&ResolvedContextPackReceipt>,
5093 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5094 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5095 let Some((profile, has_environment_overlay, bundle_layout)) =
5096 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5097 else {
5098 return Ok(None);
5099 };
5100 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5101 failure(
5102 NodeFailureCode::BackendOperationFailed,
5103 "session environment materializer is unavailable",
5104 )
5105 })?;
5106 let materialization_failure_code = if context.is_some() {
5107 NodeFailureCode::ContextPackMaterializationFailed
5108 } else if bundle.is_some() {
5109 NodeFailureCode::BundleMaterializationFailed
5110 } else {
5111 NodeFailureCode::BackendOperationFailed
5112 };
5113 let id = MaterializationId::new(format!(
5114 "mat-{}-{}-{}",
5115 self.incarnation_id,
5116 address.session.instance_id.0,
5117 address.session.generation.0,
5118 ))
5119 .map_err(|_| {
5120 failure(
5121 NodeFailureCode::BackendOperationFailed,
5122 "session environment materialization identity failed",
5123 )
5124 })?;
5125 let owner = MaterializationOwner::Session {
5126 incarnation_id: self.incarnation_id,
5127 instance_id: address.session.instance_id,
5128 generation: address.session.generation,
5129 };
5130 let mut ownership = materializer
5131 .begin(
5132 id.clone(),
5133 environment_profile.cloned(),
5134 bundle.cloned(),
5135 context.cloned(),
5136 owner,
5137 managed_lease_id,
5138 &profile,
5139 unix_time_ms(),
5140 )
5141 .map_err(|_| {
5142 failure(
5143 materialization_failure_code,
5144 "session materialization preparation failed",
5145 )
5146 })?;
5147 {
5148 let _transaction = self
5149 .state_transaction
5150 .lock()
5151 .unwrap_or_else(|poisoned| poisoned.into_inner());
5152 let duplicate = self
5153 .materializations
5154 .lock()
5155 .unwrap_or_else(|poisoned| poisoned.into_inner())
5156 .insert(id.clone(), ownership.clone());
5157 if duplicate.is_some() {
5158 return Err(failure(
5159 NodeFailureCode::BackendOperationFailed,
5160 "session environment materialization identity conflict",
5161 ));
5162 }
5163 if let Err(error) = self.persist_state_locked() {
5164 self.materializations
5165 .lock()
5166 .unwrap_or_else(|poisoned| poisoned.into_inner())
5167 .remove(&id);
5168 return Err(persistence_failure(error));
5169 }
5170 }
5171 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5172 Ok(environment) => environment,
5173 Err(_) => {
5174 let transaction = self
5175 .state_transaction
5176 .lock()
5177 .unwrap_or_else(|poisoned| poisoned.into_inner());
5178 self.materializations
5179 .lock()
5180 .unwrap_or_else(|poisoned| poisoned.into_inner())
5181 .insert(id.clone(), ownership.clone());
5182 if self.persist_state_locked().is_err() {
5183 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5184 }
5185 drop(transaction);
5186 if ownership.state() == MaterializationState::CleanupRequired {
5187 let _ = self.cleanup_materialization(&id);
5188 }
5189 return Err(failure(
5190 materialization_failure_code,
5191 "session materialization failed",
5192 ));
5193 }
5194 };
5195 {
5196 let _transaction = self
5197 .state_transaction
5198 .lock()
5199 .unwrap_or_else(|poisoned| poisoned.into_inner());
5200 self.materializations
5201 .lock()
5202 .unwrap_or_else(|poisoned| poisoned.into_inner())
5203 .insert(id.clone(), ownership.clone());
5204 if let Err(error) = self.persist_state_locked() {
5205 drop(_transaction);
5206 let _ = self.cleanup_materialization(&id);
5207 return Err(persistence_failure(error));
5208 }
5209 }
5210 let guard = SessionMaterializationGuard {
5211 shared: self,
5212 id: Some(id),
5213 provider_home: Some(ownership.provider_home().to_path_buf()),
5214 };
5215 let bundle_arguments = match bundle {
5216 Some(receipt) => {
5217 let catalog = self.bundle_catalog.read()
5218 .unwrap_or_else(|poisoned| poisoned.into_inner());
5219 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5220 NodeFailureCode::UnknownBundle,
5221 "session bundle is unavailable on this node",
5222 ))?;
5223 if installed.receipt() != *receipt {
5224 return Err(failure(
5225 NodeFailureCode::BundleBindingMismatch,
5226 "session bundle revision or digest changed",
5227 ));
5228 }
5229 let layout = bundle_layout.ok_or_else(|| failure(
5230 NodeFailureCode::BundleBindingMismatch,
5231 "materialized bundle provider layout is unavailable",
5232 ))?;
5233 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5234 failure(
5235 NodeFailureCode::BundleMaterializationFailed,
5236 "session bundle byte revalidation failed",
5237 )
5238 })?;
5239 Some(bundle_launch_arguments(
5240 layout,
5241 ownership.bundle_root(),
5242 ownership.provider_home(),
5243 ).map_err(|_| failure(
5244 NodeFailureCode::BundleBindingMismatch,
5245 "session bundle launch binding is invalid",
5246 ))?)
5247 }
5248 None => None,
5249 };
5250 if let Some(receipt) = context {
5251 materializer
5252 .revalidate_context(&ownership, receipt)
5253 .map_err(|_| failure(
5254 NodeFailureCode::ContextPackMaterializationFailed,
5255 "session context pack byte revalidation failed",
5256 ))?;
5257 }
5258 let transport = match mode {
5259 SessionMode::Pty => TransportKind::Pty,
5260 SessionMode::Inline => TransportKind::Pipe,
5261 SessionMode::Acp => TransportKind::Acp,
5262 };
5263 let overlay = if let Some(extra_args) = bundle_arguments {
5264 Some(PreparedNativeLaunchOverlay::Instance(
5265 NativeInstanceLaunchOverlay::new(
5266 provider.clone(),
5267 transport,
5268 if has_environment_overlay {
5269 environment
5270 } else {
5271 Vec::new()
5272 },
5273 extra_args,
5274 )
5275 .map_err(|_| {
5276 failure(
5277 NodeFailureCode::BundleBindingMismatch,
5278 "session bundle launch overlay is invalid",
5279 )
5280 })?,
5281 ))
5282 } else if has_environment_overlay {
5283 let context_only = environment_profile.is_none()
5284 && bundle.is_none()
5285 && context.is_some();
5286 let overlay = if context_only {
5287 NativeLaunchEnvironmentOverlay::new_context_root(
5288 provider.clone(),
5289 transport,
5290 environment,
5291 )
5292 .map_err(|_| {
5293 failure(
5294 NodeFailureCode::ContextPackMaterializationFailed,
5295 "context-only launch overlay is invalid",
5296 )
5297 })?
5298 } else {
5299 NativeLaunchEnvironmentOverlay::new(
5300 provider.clone(),
5301 transport,
5302 environment,
5303 )
5304 .map_err(|_| {
5305 failure(
5306 NodeFailureCode::BackendOperationFailed,
5307 "session environment overlay is invalid",
5308 )
5309 })?
5310 };
5311 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5312 } else {
5313 None
5314 };
5315 Ok(Some((overlay, guard)))
5316 }
5317
5318 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5319 let Some(mut ownership) = self
5320 .materializations
5321 .lock()
5322 .unwrap_or_else(|poisoned| poisoned.into_inner())
5323 .get(id)
5324 .cloned()
5325 else {
5326 return Ok(());
5327 };
5328 if ownership.state() != MaterializationState::CleanupRequired {
5329 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5330 failure(
5331 NodeFailureCode::BackendOperationFailed,
5332 "session environment cleanup state is invalid",
5333 )
5334 })?;
5335 }
5336 {
5337 let _transaction = self
5338 .state_transaction
5339 .lock()
5340 .unwrap_or_else(|poisoned| poisoned.into_inner());
5341 let previous = self
5342 .materializations
5343 .lock()
5344 .unwrap_or_else(|poisoned| poisoned.into_inner())
5345 .insert(id.clone(), ownership.clone());
5346 if let Err(error) = self.persist_state_locked() {
5347 let mut materializations = self
5348 .materializations
5349 .lock()
5350 .unwrap_or_else(|poisoned| poisoned.into_inner());
5351 if let Some(previous) = previous {
5352 materializations.insert(id.clone(), previous);
5353 }
5354 return Err(persistence_failure(error));
5355 }
5356 }
5357 let cleanup = self
5358 .session_environment_materializer
5359 .as_ref()
5360 .ok_or(())
5361 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5362 if cleanup.is_err() {
5363 let _ = ownership.mark_recovery_required(unix_time_ms());
5364 self.materializations
5365 .lock()
5366 .unwrap_or_else(|poisoned| poisoned.into_inner())
5367 .insert(id.clone(), ownership);
5368 if self.persist_state().is_err() {
5369 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5370 }
5371 return Err(failure(
5372 NodeFailureCode::BackendOperationFailed,
5373 "session environment cleanup requires recovery",
5374 ));
5375 }
5376 let _transaction = self
5377 .state_transaction
5378 .lock()
5379 .unwrap_or_else(|poisoned| poisoned.into_inner());
5380 self.materializations
5381 .lock()
5382 .unwrap_or_else(|poisoned| poisoned.into_inner())
5383 .remove(id);
5384 if let Err(error) = self.persist_state_locked() {
5385 self.materializations
5386 .lock()
5387 .unwrap_or_else(|poisoned| poisoned.into_inner())
5388 .insert(id.clone(), ownership);
5389 return Err(persistence_failure(error));
5390 }
5391 Ok(())
5392 }
5393
5394 fn resolve_record_materialization(
5395 &self,
5396 record_id: &SessionRecordId,
5397 provider: &AgentId,
5398 mode: SessionMode,
5399 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5400 bundle: Option<&ResolvedBundleReceipt>,
5401 context: Option<&ResolvedContextPackReceipt>,
5402 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5403 let ownership = {
5404 let materializations = self
5405 .materializations
5406 .lock()
5407 .unwrap_or_else(|poisoned| poisoned.into_inner());
5408 let mut matches = materializations.values().filter(|record| {
5409 record.owner()
5410 == &MaterializationOwner::Record {
5411 record_id: record_id.clone(),
5412 }
5413 });
5414 let ownership = matches.next().cloned();
5415 if matches.next().is_some() {
5416 return Err(failure(
5417 NodeFailureCode::BackendOperationFailed,
5418 "managed session has conflicting environment ownership",
5419 ));
5420 }
5421 ownership
5422 };
5423 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5424 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5425 failure(
5426 NodeFailureCode::ContextPackMaterializationFailed,
5427 "session context materializer is unavailable",
5428 )
5429 })?;
5430 let pack = materializer
5431 .revalidate_context(ownership, receipt)
5432 .map_err(|_| {
5433 self.mark_materialization_recovery_required(ownership.id());
5434 failure(
5435 NodeFailureCode::ContextPackMaterializationFailed,
5436 "managed session context pack revalidation failed",
5437 )
5438 })?;
5439 self.context_catalog
5440 .write()
5441 .unwrap_or_else(|poisoned| poisoned.into_inner())
5442 .insert(pack)
5443 .map_err(|_| failure(
5444 NodeFailureCode::ContextPackMaterializationFailed,
5445 "managed session context pack catalog restore failed",
5446 ))?;
5447 }
5448 let materialization = self.materialization_profile(
5449 provider,
5450 mode,
5451 environment_profile,
5452 bundle,
5453 context,
5454 )?;
5455 match (ownership, materialization) {
5456 (None, None) => Ok(None),
5457 (None, Some(_)) | (Some(_), None) => Err(failure(
5458 NodeFailureCode::BackendOperationFailed,
5459 "managed session materialization is unavailable",
5460 )),
5461 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5462 if ownership.environment_profile() != environment_profile
5463 || ownership.bundle() != bundle
5464 || ownership.context() != context
5465 || ownership.state() != MaterializationState::Ready
5466 {
5467 self.mark_materialization_recovery_required(ownership.id());
5468 return Err(failure(
5469 NodeFailureCode::BackendOperationFailed,
5470 "managed session environment ownership requires recovery",
5471 ));
5472 }
5473 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5474 failure(
5475 NodeFailureCode::BackendOperationFailed,
5476 "session environment materializer is unavailable",
5477 )
5478 })?;
5479 if materializer.revalidate(&ownership).is_err() {
5480 self.mark_materialization_recovery_required(ownership.id());
5481 return Err(failure(
5482 NodeFailureCode::BackendOperationFailed,
5483 "managed session environment revalidation failed",
5484 ));
5485 }
5486 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5487 let catalog = self.bundle_catalog.read()
5488 .unwrap_or_else(|poisoned| poisoned.into_inner());
5489 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5490 return Err(failure(
5491 NodeFailureCode::UnknownBundle,
5492 "managed session bundle is unavailable",
5493 ));
5494 };
5495 let layout = bundle_layout.ok_or_else(|| failure(
5496 NodeFailureCode::BundleBindingMismatch,
5497 "record bundle provider layout is unavailable",
5498 ))?;
5499 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5500 drop(catalog);
5501 self.mark_materialization_recovery_required(ownership.id());
5502 return Err(failure(
5503 NodeFailureCode::BundleMaterializationFailed,
5504 "managed session bundle revalidation failed",
5505 ));
5506 }
5507 Some(bundle_launch_arguments(
5508 layout,
5509 ownership.bundle_root(),
5510 ownership.provider_home(),
5511 ).map_err(|_| failure(
5512 NodeFailureCode::BundleBindingMismatch,
5513 "managed session bundle launch binding is invalid",
5514 ))?)
5515 } else {
5516 None
5517 };
5518 if !has_environment_materialization {
5519 let overlay = bundle_arguments.map(|extra_args| {
5520 NativeInstanceLaunchOverlay::new(
5521 provider.clone(),
5522 TransportKind::Pty,
5523 Vec::new(),
5524 extra_args,
5525 )
5526 .map(PreparedNativeLaunchOverlay::Instance)
5527 .map_err(|_| failure(
5528 NodeFailureCode::BundleBindingMismatch,
5529 "managed session bundle launch overlay is invalid",
5530 ))
5531 }).transpose()?;
5532 return Ok(Some((ownership.id().clone(), overlay)));
5533 }
5534 let environment = match materializer.resolve_environment(&ownership, &profile) {
5535 Ok(environment) => environment,
5536 Err(
5537 SessionEnvironmentMaterializeError::SecretUnavailable
5538 | SessionEnvironmentMaterializeError::SecretDenied
5539 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5540 ) => {
5541 return Err(failure(
5542 NodeFailureCode::BackendOperationFailed,
5543 "managed session environment resolution failed",
5544 ));
5545 }
5546 Err(
5547 SessionEnvironmentMaterializeError::InvalidRoot
5548 | SessionEnvironmentMaterializeError::OwnershipMismatch
5549 | SessionEnvironmentMaterializeError::Filesystem(_)
5550 | SessionEnvironmentMaterializeError::Record(_),
5551 ) => {
5552 self.mark_materialization_recovery_required(ownership.id());
5553 return Err(failure(
5554 NodeFailureCode::BackendOperationFailed,
5555 "managed session environment resolution failed",
5556 ));
5557 }
5558 };
5559 let transport = match mode {
5560 SessionMode::Pty => TransportKind::Pty,
5561 SessionMode::Inline => TransportKind::Pipe,
5562 SessionMode::Acp => TransportKind::Acp,
5563 };
5564 let overlay = match bundle_arguments {
5565 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5566 NativeInstanceLaunchOverlay::new(
5567 provider.clone(),
5568 transport,
5569 environment,
5570 extra_args,
5571 )
5572 .map_err(|_| failure(
5573 NodeFailureCode::BundleBindingMismatch,
5574 "managed session bundle launch overlay is invalid",
5575 ))?,
5576 ),
5577 None => PreparedNativeLaunchOverlay::Environment(
5578 NativeLaunchEnvironmentOverlay::new(
5579 provider.clone(),
5580 transport,
5581 environment,
5582 )
5583 .map_err(|_| {
5584 failure(
5585 NodeFailureCode::BackendOperationFailed,
5586 "managed session environment overlay is invalid",
5587 )
5588 })?,
5589 ),
5590 };
5591 Ok(Some((ownership.id().clone(), Some(overlay))))
5592 }
5593 }
5594 }
5595
5596 fn destroy_record_materialization(
5597 &self,
5598 record_id: &SessionRecordId,
5599 ) -> Result<bool, NodeFailure> {
5600 let ids = self
5601 .materializations
5602 .lock()
5603 .unwrap_or_else(|poisoned| poisoned.into_inner())
5604 .values()
5605 .filter_map(|record| {
5606 (record.owner()
5607 == &MaterializationOwner::Record {
5608 record_id: record_id.clone(),
5609 })
5610 .then(|| record.id().clone())
5611 })
5612 .collect::<Vec<_>>();
5613 if ids.len() > 1 {
5614 return Err(failure(
5615 NodeFailureCode::BackendOperationFailed,
5616 "managed session has conflicting environment ownership",
5617 ));
5618 }
5619 let Some(id) = ids.first().cloned() else {
5620 return Ok(false);
5621 };
5622 let (terminal_record, mut cleanup_ownership) = {
5623 let _transaction = self
5624 .state_transaction
5625 .lock()
5626 .unwrap_or_else(|poisoned| poisoned.into_inner());
5627 let original_record = self.record(record_id)?;
5628 let mut terminal_record = original_record.clone();
5629 terminal_record.provider_session = None;
5630 terminal_record.active_session = None;
5631 terminal_record.state = ManagedSessionState::Unavailable;
5632 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5633 terminal_record.updated_at_unix_ms = unix_time_ms();
5634 let original_ownership = self
5635 .materializations
5636 .lock()
5637 .unwrap_or_else(|poisoned| poisoned.into_inner())
5638 .get(&id)
5639 .cloned()
5640 .ok_or_else(|| {
5641 failure(
5642 NodeFailureCode::BackendOperationFailed,
5643 "managed session environment ownership disappeared during cleanup",
5644 )
5645 })?;
5646 let mut cleanup_ownership = original_ownership.clone();
5647 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5648 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5649 failure(
5650 NodeFailureCode::BackendOperationFailed,
5651 "managed session environment cleanup state is invalid",
5652 )
5653 })?;
5654 }
5655 self.session_records
5656 .lock()
5657 .unwrap_or_else(|poisoned| poisoned.into_inner())
5658 .records
5659 .insert(record_id.clone(), terminal_record.clone());
5660 self.materializations
5661 .lock()
5662 .unwrap_or_else(|poisoned| poisoned.into_inner())
5663 .insert(id.clone(), cleanup_ownership.clone());
5664 if let Err(error) = self.persist_state_locked() {
5665 self.session_records
5666 .lock()
5667 .unwrap_or_else(|poisoned| poisoned.into_inner())
5668 .records
5669 .insert(record_id.clone(), original_record);
5670 self.materializations
5671 .lock()
5672 .unwrap_or_else(|poisoned| poisoned.into_inner())
5673 .insert(id.clone(), original_ownership);
5674 return Err(persistence_failure(error));
5675 }
5676 (terminal_record, cleanup_ownership)
5677 };
5678 let cleanup = self
5679 .session_environment_materializer
5680 .as_ref()
5681 .ok_or(())
5682 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5683 if cleanup.is_err() {
5684 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5685 self.materializations
5686 .lock()
5687 .unwrap_or_else(|poisoned| poisoned.into_inner())
5688 .insert(id.clone(), cleanup_ownership);
5689 if self.persist_state().is_err() {
5690 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5691 }
5692 return Err(failure(
5693 NodeFailureCode::BackendOperationFailed,
5694 "managed session environment cleanup requires recovery",
5695 ));
5696 }
5697 let _transaction = self
5698 .state_transaction
5699 .lock()
5700 .unwrap_or_else(|poisoned| poisoned.into_inner());
5701 self.remove_record_memory(record_id);
5702 self.materializations
5703 .lock()
5704 .unwrap_or_else(|poisoned| poisoned.into_inner())
5705 .remove(&id);
5706 if let Err(error) = self.persist_state_locked() {
5707 self.session_records
5708 .lock()
5709 .unwrap_or_else(|poisoned| poisoned.into_inner())
5710 .records
5711 .insert(record_id.clone(), terminal_record);
5712 self.materializations
5713 .lock()
5714 .unwrap_or_else(|poisoned| poisoned.into_inner())
5715 .insert(id, cleanup_ownership);
5716 return Err(persistence_failure(error));
5717 }
5718 Ok(true)
5719 }
5720
5721 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5722 let changed = {
5723 let mut materializations = self
5724 .materializations
5725 .lock()
5726 .unwrap_or_else(|poisoned| poisoned.into_inner());
5727 let Some(ownership) = materializations.get_mut(id) else {
5728 return;
5729 };
5730 ownership.mark_recovery_required(unix_time_ms()).is_ok()
5731 };
5732 if changed && self.persist_state().is_err() {
5733 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5734 }
5735 }
5736
5737 fn reconcile_materializations(&self) {
5738 let records = self
5739 .materializations
5740 .lock()
5741 .unwrap_or_else(|poisoned| poisoned.into_inner())
5742 .values()
5743 .cloned()
5744 .collect::<Vec<_>>();
5745 for mut ownership in records {
5746 let result = match (ownership.state(), ownership.owner()) {
5747 (MaterializationState::Preparing, _)
5748 | (MaterializationState::CleanupRequired, _) => {
5749 self.cleanup_materialization(ownership.id())
5750 }
5751 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5752 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5753 self.materializations
5754 .lock()
5755 .unwrap_or_else(|poisoned| poisoned.into_inner())
5756 .insert(ownership.id().clone(), ownership.clone());
5757 self.persist_state().map_err(persistence_failure)
5758 } else {
5759 Err(failure(
5760 NodeFailureCode::BackendOperationFailed,
5761 "session environment reconciliation failed",
5762 ))
5763 }
5764 }
5765 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5766 .session_environment_materializer
5767 .as_ref()
5768 .ok_or_else(|| {
5769 failure(
5770 NodeFailureCode::BackendOperationFailed,
5771 "session environment materializer is unavailable",
5772 )
5773 })
5774 .and_then(|materializer| {
5775 materializer.revalidate(&ownership).map_err(|_| failure(
5776 NodeFailureCode::BackendOperationFailed,
5777 "session materialization revalidation failed",
5778 ))?;
5779 let record = self.record(record_id)?;
5780 if record.environment_profile.as_ref() != ownership.environment_profile()
5781 || record.bundle.as_ref() != ownership.bundle()
5782 || record.context.as_ref() != ownership.context()
5783 {
5784 return Err(failure(
5785 NodeFailureCode::BackendOperationFailed,
5786 "session materialization ownership changed after restart",
5787 ));
5788 }
5789 if let Some(receipt) = ownership.bundle() {
5790 let catalog = self.bundle_catalog.read()
5791 .unwrap_or_else(|poisoned| poisoned.into_inner());
5792 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5793 NodeFailureCode::UnknownBundle,
5794 "session bundle is unavailable after restart",
5795 ))?;
5796 if bundle.receipt() != *receipt {
5797 return Err(failure(
5798 NodeFailureCode::BundleBindingMismatch,
5799 "session bundle changed after restart",
5800 ));
5801 }
5802 let layout = self.bundle_layout(
5803 &record.provider,
5804 record.mode,
5805 record.environment_profile.as_ref(),
5806 bundle,
5807 )?;
5808 materializer
5809 .revalidate_bundle(&ownership, bundle, layout)
5810 .map_err(|_| failure(
5811 NodeFailureCode::BundleMaterializationFailed,
5812 "session bundle revalidation failed",
5813 ))?;
5814 }
5815 if let Some(receipt) = ownership.context() {
5816 let pack = materializer
5817 .revalidate_context(&ownership, receipt)
5818 .map_err(|_| {
5819 failure(
5820 NodeFailureCode::ContextPackMaterializationFailed,
5821 "session context revalidation failed",
5822 )
5823 })?;
5824 self.context_catalog
5825 .write()
5826 .unwrap_or_else(|poisoned| poisoned.into_inner())
5827 .insert(pack)
5828 .map_err(|_| {
5829 failure(
5830 NodeFailureCode::ContextPackMaterializationFailed,
5831 "session context catalog restore failed",
5832 )
5833 })?;
5834 }
5835 Ok(())
5836 }),
5837 (MaterializationState::RecoveryRequired, _) => Ok(()),
5838 };
5839 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5840 let _ = ownership.mark_recovery_required(unix_time_ms());
5841 self.materializations
5842 .lock()
5843 .unwrap_or_else(|poisoned| poisoned.into_inner())
5844 .insert(ownership.id().clone(), ownership);
5845 if self.persist_state().is_err() {
5846 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5847 }
5848 }
5849 }
5850 let materialized_profiles = self
5851 .environment_materialization_profiles
5852 .read()
5853 .unwrap_or_else(|poisoned| poisoned.into_inner())
5854 .keys()
5855 .cloned()
5856 .collect::<Vec<_>>();
5857 let ownership = self
5858 .materializations
5859 .lock()
5860 .unwrap_or_else(|poisoned| poisoned.into_inner())
5861 .values()
5862 .cloned()
5863 .collect::<Vec<_>>();
5864 let changed = {
5865 let mut session_records = self
5866 .session_records
5867 .lock()
5868 .unwrap_or_else(|poisoned| poisoned.into_inner());
5869 let mut changed = false;
5870 for record in session_records.records.values_mut() {
5871 let environment_profile = record.environment_profile.as_ref();
5872 if environment_profile.is_none()
5873 && record.bundle.is_none()
5874 && record.context.is_none()
5875 {
5876 continue;
5877 }
5878 if record.bundle.is_none()
5879 && environment_profile.is_some_and(|profile| {
5880 !materialized_profiles.contains(&profile.profile_id)
5881 })
5882 {
5883 continue;
5884 }
5885 let has_exact_ownership = ownership.iter().any(|candidate| {
5886 candidate.state() == MaterializationState::Ready
5887 && candidate.environment_profile() == environment_profile
5888 && candidate.bundle() == record.bundle.as_ref()
5889 && candidate.context() == record.context.as_ref()
5890 && candidate.owner()
5891 == &MaterializationOwner::Record {
5892 record_id: record.record_id.clone(),
5893 }
5894 });
5895 if !has_exact_ownership {
5896 record.provider_session = None;
5897 record.active_session = None;
5898 record.state = ManagedSessionState::Unavailable;
5899 record.last_error = Some(if record.bundle.is_some() {
5900 "bundle-unavailable".to_owned()
5901 } else if record.context.is_some() {
5902 "context-unavailable".to_owned()
5903 } else {
5904 "environment-profile-unavailable".to_owned()
5905 });
5906 record.updated_at_unix_ms = unix_time_ms();
5907 changed = true;
5908 }
5909 }
5910 changed
5911 };
5912 if changed && self.persist_state().is_err() {
5913 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5914 }
5915 }
5916
5917 fn replay_spawn_spec(
5918 &self,
5919 spec: &SpawnSpec,
5920 now: Instant,
5921 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5922 let mut cache = self
5923 .spawn_idempotency
5924 .lock()
5925 .unwrap_or_else(|poisoned| poisoned.into_inner());
5926 cache.entries.retain(|_, entry| entry.expires_at > now);
5927 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5928 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5929 return Err(failure(
5930 NodeFailureCode::SpawnIdempotencyConflict,
5931 "spawn idempotency key was reused across spawn kinds",
5932 ));
5933 };
5934 if existing != spec {
5935 return Err(failure(
5936 NodeFailureCode::SpawnIdempotencyConflict,
5937 "spawn idempotency key was reused with a different specification",
5938 ));
5939 }
5940 return Ok(Some(result.clone()));
5941 }
5942 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5943 return Err(failure(
5944 NodeFailureCode::SpawnIdempotencyCapacity,
5945 "spawn idempotency cache reached its bounded capacity",
5946 ));
5947 }
5948 Ok(None)
5949 }
5950
5951 fn remember_spawn_spec(
5952 &self,
5953 spec: SpawnSpec,
5954 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5955 accepted_at: Instant,
5956 ) {
5957 self
5958 .spawn_idempotency
5959 .lock()
5960 .unwrap_or_else(|poisoned| poisoned.into_inner())
5961 .entries
5962 .insert(
5963 spec.idempotency_key.clone(),
5964 SpawnIdempotencyEntry {
5965 value: SpawnIdempotencyValue::Standard { spec, result },
5966 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5967 },
5968 );
5969 }
5970
5971 fn claim_harness_mcp_spawn(
5972 &self,
5973 _reservation_id: &HarnessMcpReservationId,
5974 _activation_digest: &HarnessMcpActivationDigest,
5975 spec: &SpawnSpec,
5976 now: Instant,
5977 ) -> Result<(), NodeFailure> {
5978 let mut cache = self.spawn_idempotency.lock()
5979 .unwrap_or_else(|poisoned| poisoned.into_inner());
5980 cache.entries.retain(|_, entry| entry.expires_at > now);
5981 if cache.entries.contains_key(&spec.idempotency_key) {
5982 return Err(failure(
5983 NodeFailureCode::SpawnIdempotencyConflict,
5984 "spawn idempotency key was reused across spawn kinds",
5985 ));
5986 }
5987 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5988 return Err(failure(
5989 NodeFailureCode::SpawnIdempotencyCapacity,
5990 "spawn idempotency cache reached its bounded capacity",
5991 ));
5992 }
5993 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5994 value: SpawnIdempotencyValue::HarnessMcp,
5995 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5996 });
5997 Ok(())
5998 }
5999
6000 fn replay_managed_spawn(
6001 &self,
6002 request: &ManagedWorktreeSpawnRequest,
6003 now: Instant,
6004 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6005 let mut cache = self.spawn_idempotency.lock()
6006 .unwrap_or_else(|poisoned| poisoned.into_inner());
6007 cache.entries.retain(|_, entry| entry.expires_at > now);
6008 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6009 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6010 return Err(failure(
6011 NodeFailureCode::SpawnIdempotencyConflict,
6012 "spawn idempotency key was reused across spawn kinds",
6013 ));
6014 };
6015 if existing != request {
6016 return Err(failure(
6017 NodeFailureCode::SpawnIdempotencyConflict,
6018 "spawn idempotency key was reused with a different managed specification",
6019 ));
6020 }
6021 return Ok(Some(result.clone()));
6022 }
6023 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6024 return Err(failure(
6025 NodeFailureCode::SpawnIdempotencyCapacity,
6026 "spawn idempotency cache reached its bounded capacity",
6027 ));
6028 }
6029 Ok(None)
6030 }
6031
6032 fn remember_managed_spawn(
6033 &self,
6034 request: ManagedWorktreeSpawnRequest,
6035 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6036 accepted_at: Instant,
6037 ) {
6038 self.spawn_idempotency.lock()
6039 .unwrap_or_else(|poisoned| poisoned.into_inner())
6040 .entries
6041 .insert(
6042 request.spawn_spec.idempotency_key.clone(),
6043 SpawnIdempotencyEntry {
6044 value: SpawnIdempotencyValue::Managed { request, result },
6045 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6046 },
6047 );
6048 }
6049
6050 fn remember_managed_spawn_attempt(
6051 &self,
6052 durable_v2_key: Option<&SpawnIdempotencyKey>,
6053 request: ManagedWorktreeSpawnRequest,
6054 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6055 accepted_at: Instant,
6056 ) {
6057 if durable_v2_key.is_none() {
6058 self.remember_managed_spawn(request, result, accepted_at);
6059 }
6060 }
6061
6062 fn acquire_durable_managed_spawn_v2(
6063 &self,
6064 request: &ManagedWorktreeSpawnRequestV2,
6065 request_digest: String,
6066 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6067 if self.state_path.is_none() {
6068 return Err(failure(
6069 NodeFailureCode::BackendOperationFailed,
6070 "managed worktree V2 requires durable node state",
6071 ));
6072 }
6073 let _transaction = self.state_transaction
6074 .lock()
6075 .unwrap_or_else(|poisoned| poisoned.into_inner());
6076 let key = request.spawn_spec.idempotency_key.clone();
6077 let mut previous = None;
6078 {
6079 let mut replays = self.managed_spawn_replays
6080 .lock()
6081 .unwrap_or_else(|poisoned| poisoned.into_inner());
6082 if let Some(record) = replays.get_mut(&key) {
6083 if record.request_digest != request_digest {
6084 return Err(failure(
6085 NodeFailureCode::SpawnIdempotencyConflict,
6086 "managed worktree V2 idempotency key was reused with a different request",
6087 ));
6088 }
6089 match &record.state {
6090 ManagedWorktreeSpawnReplayStateV10::Pending {
6091 owner_incarnation_id,
6092 lease_id: None,
6093 } if owner_incarnation_id != &self.incarnation_id => {
6094 previous = Some(record.clone());
6095 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6096 owner_incarnation_id: self.incarnation_id,
6097 lease_id: None,
6098 };
6099 record.updated_at_unix_ms = unix_time_ms();
6100 }
6101 ManagedWorktreeSpawnReplayStateV10::Pending {
6102 lease_id: None,
6103 ..
6104 } => {
6105 return Err(failure(
6106 NodeFailureCode::BackendBusy,
6107 "managed worktree V2 reservation is already active",
6108 ));
6109 }
6110 ManagedWorktreeSpawnReplayStateV10::Pending {
6111 lease_id: Some(_),
6112 ..
6113 } => {
6114 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6115 }
6116 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6117 return Ok(DurableManagedSpawnReplayDecision::Committed(
6118 receipt.clone(),
6119 ));
6120 }
6121 }
6122 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6123 return Err(failure(
6124 NodeFailureCode::SpawnIdempotencyCapacity,
6125 "durable managed worktree replay ledger reached its bounded capacity",
6126 ));
6127 } else {
6128 let now = unix_time_ms();
6129 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6130 idempotency_key: key.clone(),
6131 request_digest,
6132 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6133 profile_id: request.worktree_profile_id.clone(),
6134 expected_profile_revision: request.expected_profile_revision.clone(),
6135 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6136 owner_incarnation_id: self.incarnation_id,
6137 lease_id: None,
6138 },
6139 created_at_unix_ms: now,
6140 updated_at_unix_ms: now,
6141 });
6142 }
6143 }
6144 if let Err(error) = self.persist_state_locked() {
6145 let mut replays = self.managed_spawn_replays
6146 .lock()
6147 .unwrap_or_else(|poisoned| poisoned.into_inner());
6148 match previous {
6149 Some(previous) => {
6150 replays.insert(key, previous);
6151 }
6152 None => {
6153 replays.remove(&key);
6154 }
6155 }
6156 return Err(persistence_failure(error));
6157 }
6158 Ok(DurableManagedSpawnReplayDecision::Reserved)
6159 }
6160
6161 fn link_durable_managed_spawn_v2_lease_locked(
6162 &self,
6163 key: &SpawnIdempotencyKey,
6164 lease_id: &ManagedWorktreeLeaseId,
6165 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6166 let mut replays = self.managed_spawn_replays
6167 .lock()
6168 .unwrap_or_else(|poisoned| poisoned.into_inner());
6169 let record = replays.get_mut(key).ok_or_else(|| {
6170 failure(
6171 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6172 "managed worktree V2 reservation disappeared before allocation",
6173 )
6174 })?;
6175 let previous = record.clone();
6176 match &mut record.state {
6177 ManagedWorktreeSpawnReplayStateV10::Pending {
6178 lease_id: current,
6179 ..
6180 }
6181 if current.is_none() =>
6182 {
6183 *current = Some(lease_id.clone());
6184 record.updated_at_unix_ms = unix_time_ms();
6185 Ok(previous)
6186 }
6187 _ => Err(failure(
6188 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6189 "managed worktree V2 reservation is not allocation-ready",
6190 )),
6191 }
6192 }
6193
6194 fn restore_durable_managed_spawn_v2_locked(
6195 &self,
6196 key: &SpawnIdempotencyKey,
6197 previous: ManagedWorktreeSpawnReplayRecordV10,
6198 ) {
6199 self.managed_spawn_replays
6200 .lock()
6201 .unwrap_or_else(|poisoned| poisoned.into_inner())
6202 .insert(key.clone(), previous);
6203 }
6204
6205 fn commit_durable_managed_spawn_v2(
6206 &self,
6207 request: &ManagedWorktreeSpawnRequestV2,
6208 receipt: ManagedWorktreeSpawnReceipt,
6209 ) -> Result<(), NodeFailure> {
6210 let _transaction = self.state_transaction
6211 .lock()
6212 .unwrap_or_else(|poisoned| poisoned.into_inner());
6213 let key = &request.spawn_spec.idempotency_key;
6214 let previous = {
6215 let mut replays = self.managed_spawn_replays
6216 .lock()
6217 .unwrap_or_else(|poisoned| poisoned.into_inner());
6218 let record = replays.get_mut(key).ok_or_else(|| {
6219 failure(
6220 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6221 "managed worktree V2 reservation disappeared before commit",
6222 )
6223 })?;
6224 let previous = record.clone();
6225 match &record.state {
6226 ManagedWorktreeSpawnReplayStateV10::Pending {
6227 lease_id: Some(lease_id),
6228 ..
6229 } if lease_id == &receipt.lease.lease_id => {}
6230 _ => {
6231 return Err(failure(
6232 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6233 "managed worktree V2 reservation is not linked to the spawned lease",
6234 ));
6235 }
6236 }
6237 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6238 record.updated_at_unix_ms = unix_time_ms();
6239 previous
6240 };
6241 if let Err(error) = self.persist_state_locked() {
6242 self.restore_durable_managed_spawn_v2_locked(key, previous);
6243 return Err(persistence_failure(error));
6244 }
6245 Ok(())
6246 }
6247
6248 fn remove_unallocated_durable_managed_spawn_v2(
6249 &self,
6250 request: &ManagedWorktreeSpawnRequestV2,
6251 ) -> Result<(), NodeFailure> {
6252 let _transaction = self.state_transaction
6253 .lock()
6254 .unwrap_or_else(|poisoned| poisoned.into_inner());
6255 let key = &request.spawn_spec.idempotency_key;
6256 let removed = {
6257 let mut replays = self.managed_spawn_replays
6258 .lock()
6259 .unwrap_or_else(|poisoned| poisoned.into_inner());
6260 match replays.get(key).map(|record| &record.state) {
6261 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6262 lease_id: None,
6263 ..
6264 }) => {
6265 replays.remove(key)
6266 }
6267 _ => None,
6268 }
6269 };
6270 let Some(removed) = removed else {
6271 return Ok(());
6272 };
6273 if let Err(error) = self.persist_state_locked() {
6274 self.managed_spawn_replays
6275 .lock()
6276 .unwrap_or_else(|poisoned| poisoned.into_inner())
6277 .insert(key.clone(), removed);
6278 return Err(persistence_failure(error));
6279 }
6280 Ok(())
6281 }
6282
6283 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6284 if spec.target.node_id != self.node_id {
6285 return Err(failure(
6286 NodeFailureCode::SpawnTargetMismatch,
6287 "spawn target does not match this node",
6288 ));
6289 }
6290 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6291 failure(
6292 NodeFailureCode::UnknownSpawnProfile,
6293 "spawn profile is unavailable on this node",
6294 )
6295 })?;
6296 let resolved = spec.resolve(defaults).map_err(|error| match error {
6297 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6298 NodeFailureCode::SpawnProfileRevisionMismatch,
6299 "spawn profile revision does not match the loaded profile",
6300 ),
6301 _ => failure(
6302 NodeFailureCode::InvalidRequest,
6303 "spawn specification could not be resolved",
6304 ),
6305 })?;
6306 if resolved.target.worktree_id.is_some() {
6307 self.require_worktree_service(&resolved.target.workspace_id)?;
6308 }
6309 let environment_profile = self.resolve_environment_profile(&resolved)?;
6310 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6311 self.resolve_context(&resolved)?;
6312 Ok(resolved)
6313 }
6314
6315 async fn resolve_spawn_workspace(
6316 &self,
6317 resolved: &ResolvedSpawnSpec,
6318 deadline: Instant,
6319 ) -> Result<WorkspaceId, NodeFailure> {
6320 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6321 return Ok(resolved.target.workspace_id.clone());
6322 };
6323 if self.managed_worktrees.lock()
6324 .unwrap_or_else(|poisoned| poisoned.into_inner())
6325 .lease_for_workspace(worktree_id)
6326 .is_some()
6327 {
6328 return Err(failure(
6329 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6330 "managed worktree targets may only be spawned through their lease request",
6331 ));
6332 }
6333 self.require_worktree_service(&resolved.target.workspace_id)?;
6334 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6335 let selected_root = self.workspace_root(worktree_id)?;
6336 let remaining = spawn_deadline_remaining(deadline)?;
6337 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6338 .await
6339 .map_err(|_| {
6340 failure(
6341 NodeFailureCode::SpawnDeadlineExceeded,
6342 "Git worktree selection exceeded the spawn deadline",
6343 )
6344 })?
6345 .map_err(git_worktree_failure)?;
6346 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6347 Ok(worktree_id.clone())
6348 }
6349
6350 async fn spawn_from_spec(
6351 &self,
6352 spec: SpawnSpec,
6353 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6354 self.spawn_from_spec_with_proxy(spec, None).await
6355 }
6356
6357 async fn spawn_from_spec_with_proxy(
6358 &self,
6359 spec: SpawnSpec,
6360 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6361 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6362 let accepted_at = Instant::now();
6363 if harness_mcp.is_none() {
6364 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6365 return replayed;
6366 }
6367 }
6368 let resolved = self.resolve_spawn_spec(&spec)?;
6369 let environment_profile = self.resolve_environment_profile(&resolved)?;
6370 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6371 let context = self.resolve_context(&resolved)?;
6372 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6373 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6374 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6375 let spawn_workspace_id = match spawn_workspace_id {
6376 Ok(workspace_id) => workspace_id,
6377 Err(error) => {
6378 let result = Err(error);
6379 if harness_mcp.is_none() {
6380 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6381 }
6382 return result;
6383 }
6384 };
6385 if self.managed_worktrees.lock()
6386 .unwrap_or_else(|poisoned| poisoned.into_inner())
6387 .lease_for_workspace(&spawn_workspace_id)
6388 .is_some()
6389 {
6390 let result = Err(failure(
6391 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6392 "managed worktree targets may only be spawned through their lease request",
6393 ));
6394 if harness_mcp.is_none() {
6395 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6396 }
6397 return result;
6398 }
6399 let result = self
6400 .spawn_session_with_deadline(
6401 spawn_workspace_id,
6402 resolved.provider.clone(),
6403 resolved.mode,
6404 resolved.terminal_size,
6405 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6406 environment_profile.clone(),
6407 bundle.clone(),
6408 context.clone(),
6409 None,
6410 None,
6411 SpawnRecordPolicy::Always,
6412 Some(deadline),
6413 &required_capabilities,
6414 harness_mcp,
6415 resolved.approval_level,
6416 resolved.network_allowlist.clone(),
6417 resolved.browser_profile_id.clone(),
6418 )
6419 .await
6420 .map(|(session, _runtime_policy)| {
6421 resolved.receipt_with_materialization(
6422 self.incarnation_id,
6423 session,
6424 environment_profile,
6425 bundle,
6426 context,
6427 )
6428 });
6429 if harness_mcp.is_none() {
6430 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6431 }
6432 result
6433 }
6434
6435 async fn spawn_from_spec_with_harness_mcp(
6436 &self,
6437 reservation_id: HarnessMcpReservationId,
6438 activation_digest: HarnessMcpActivationDigest,
6439 spec: SpawnSpec,
6440 deadline_unix_ms: u64,
6441 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6442 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6443 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6444 })?;
6445 if let Some(receipt) = registry
6446 .replay_spawn(&reservation_id, &activation_digest, &spec)
6447 .map_err(harness_mcp_failure)?
6448 {
6449 return Ok(receipt);
6450 }
6451 let resolved = self.resolve_spawn_spec(&spec)?;
6452 let prepared = registry
6453 .prepare_spawn(
6454 &reservation_id,
6455 &activation_digest,
6456 &spec,
6457 deadline_unix_ms,
6458 resolved.provider.clone(),
6459 )
6460 .map_err(harness_mcp_failure)?;
6461 self.claim_harness_mcp_spawn(
6462 &reservation_id,
6463 &activation_digest,
6464 &spec,
6465 Instant::now(),
6466 )?;
6467 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6468 let mut receipt = match spawn {
6469 Ok(receipt) => receipt,
6470 Err(error) => {
6471 let _ = registry.abort(&reservation_id, &activation_digest);
6472 return Err(error);
6473 }
6474 };
6475 let record_id = self
6476 .session_bindings
6477 .lock()
6478 .unwrap_or_else(|poisoned| poisoned.into_inner())
6479 .get(&receipt.session.session.instance_id)
6480 .and_then(|binding| binding.record_id.clone());
6481 let record_id = match record_id {
6482 Some(record_id) => record_id,
6483 None => {
6484 let rollback = self.remove_session(&receipt.session).await;
6485 let _ = registry.abort(&reservation_id, &activation_digest);
6486 if let Err(rollback_error) = rollback {
6487 return Err(rollback_error);
6488 }
6489 return Err(failure(
6490 NodeFailureCode::BindingMismatch,
6491 "harness MCP spawn did not retain its exact managed record",
6492 ));
6493 }
6494 };
6495 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6496 reservation_id: reservation_id.clone(),
6497 activation_digest: activation_digest.clone(),
6498 });
6499 if let Err(error) = registry.mark_spawned(
6500 &prepared,
6501 receipt.session.clone(),
6502 record_id,
6503 receipt.clone(),
6504 ) {
6505 let rollback = self.remove_session(&receipt.session).await;
6506 let _ = registry.abort(&reservation_id, &activation_digest);
6507 if let Err(rollback_error) = rollback {
6508 return Err(rollback_error);
6509 }
6510 return Err(harness_mcp_failure(error));
6511 }
6512 Ok(receipt)
6513 }
6514
6515 async fn spawn_managed_worktree(
6516 &self,
6517 request: ManagedWorktreeSpawnRequest,
6518 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6519 self.spawn_managed_worktree_inner(request, None).await
6520 }
6521
6522 async fn spawn_managed_worktree_inner(
6523 &self,
6524 request: ManagedWorktreeSpawnRequest,
6525 durable_v2_key: Option<&SpawnIdempotencyKey>,
6526 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6527 let accepted_at = Instant::now();
6528 if durable_v2_key.is_none() {
6529 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6530 return replayed;
6531 }
6532 }
6533 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6534 if request.spawn_spec.target.worktree_id.is_some() {
6535 let result = Err(failure(
6536 NodeFailureCode::InvalidRequest,
6537 "managed worktree spawn must not select a caller-provided worktree",
6538 ));
6539 self.remember_managed_spawn_attempt(
6540 durable_v2_key,
6541 request,
6542 result.clone(),
6543 accepted_at,
6544 );
6545 return result;
6546 }
6547 let environment_profile = self.resolve_environment_profile(&resolved)?;
6548 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6549 let context = self.resolve_context(&resolved)?;
6550 let source_workspace_id = resolved.target.workspace_id.clone();
6551 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6552 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6553 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6554 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6555 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6556 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6557 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6558 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6559 };
6560 let runtime_policy = timeout(
6561 spawn_deadline_remaining(deadline)?,
6562 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6563 ).await.map_err(|_| failure(
6564 NodeFailureCode::SpawnDeadlineExceeded,
6565 "provider admission exceeded the managed spawn deadline",
6566 ))??;
6567 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6568 return Err(failure(
6569 NodeFailureCode::UnsupportedSpawnCapability,
6570 "provider runtime does not admit a required spawn capability",
6571 ));
6572 }
6573 self.ensure_binding_capacity()?;
6574 let source_root = self.workspace_root(&source_workspace_id)?;
6575 profile.validate_for_workspace(&source_root)
6576 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6577 let base_timeout_ms = u64::try_from(
6578 spawn_deadline_remaining(deadline)?.as_millis(),
6579 ).unwrap_or(u64::MAX).max(1);
6580 let base_commit = resolve_base_commit_with_timeout(
6581 &source_root,
6582 profile.base(),
6583 base_timeout_ms,
6584 ).await.map_err(|error| {
6585 if Instant::now() >= deadline || error.message.contains("timed out") {
6586 failure(
6587 NodeFailureCode::SpawnDeadlineExceeded,
6588 "managed worktree base resolution exceeded the spawn deadline",
6589 )
6590 } else {
6591 managed_git_worktree_failure(error)
6592 }
6593 })?;
6594
6595 let lease = {
6596 let _transaction = self.state_transaction.lock()
6597 .unwrap_or_else(|poisoned| poisoned.into_inner());
6598 let previous = self.managed_worktrees.lock()
6599 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6600 let lease = self.managed_worktrees.lock()
6601 .unwrap_or_else(|poisoned| poisoned.into_inner())
6602 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6603 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6604 let previous_replay = match durable_v2_key
6605 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6606 .transpose()
6607 {
6608 Ok(previous) => previous,
6609 Err(error) => {
6610 *self.managed_worktrees.lock()
6611 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6612 return Err(error);
6613 }
6614 };
6615 if let Err(error) = self.persist_state_locked() {
6616 *self.managed_worktrees.lock()
6617 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6618 if let (Some(key), Some(previous_replay)) =
6619 (durable_v2_key, previous_replay)
6620 {
6621 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6622 }
6623 return Err(persistence_failure(error));
6624 }
6625 lease
6626 };
6627 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6628 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6629 self.managed_worktrees.lock()
6630 .unwrap_or_else(|poisoned| poisoned.into_inner())
6631 .remove_unmutated(&lease.lease_id);
6632 if self.persist_state().is_err() {
6633 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6634 }
6635 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6636 self.remember_managed_spawn_attempt(
6637 durable_v2_key,
6638 request,
6639 result.clone(),
6640 accepted_at,
6641 );
6642 return result;
6643 }
6644 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6645 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6646 Err(error) => {
6647 self.reconcile_failed_allocation(&lease, &source_root).await;
6648 let result = Err(error);
6649 self.remember_managed_spawn_attempt(
6650 durable_v2_key,
6651 request,
6652 result.clone(),
6653 accepted_at,
6654 );
6655 return result;
6656 }
6657 };
6658 let created = match create_git_worktree_with_timeout(
6659 &source_root,
6660 &lease.target_root,
6661 &lease.branch,
6662 Some(&base_commit),
6663 mutation_timeout_ms,
6664 ).await {
6665 Ok(created) => created,
6666 Err(error) => {
6667 self.reconcile_failed_allocation(&lease, &source_root).await;
6668 let result = Err(if Instant::now() >= deadline
6669 || error.message.contains("timed out")
6670 || error.message.contains("deadline")
6671 {
6672 failure(
6673 NodeFailureCode::SpawnDeadlineExceeded,
6674 "managed worktree creation exceeded the spawn deadline",
6675 )
6676 } else {
6677 managed_git_worktree_failure(error)
6678 });
6679 self.remember_managed_spawn_attempt(
6680 durable_v2_key,
6681 request,
6682 result.clone(),
6683 accepted_at,
6684 );
6685 return result;
6686 }
6687 };
6688 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6689 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6690 let result = Err(failure(
6691 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6692 "created worktree identity did not match its durable lease",
6693 ));
6694 self.remember_managed_spawn_attempt(
6695 durable_v2_key,
6696 request,
6697 result.clone(),
6698 accepted_at,
6699 );
6700 return result;
6701 }
6702 {
6703 let mut registry = self.managed_worktrees.lock()
6704 .unwrap_or_else(|poisoned| poisoned.into_inner());
6705 let current = registry.get_mut(&lease.lease_id)
6706 .expect("allocated managed worktree lease remains present");
6707 current.expected_head = Some(created.head.clone());
6708 current.state = ManagedWorktreeLeaseState::Ready;
6709 current.updated_at_unix_ms = unix_time_ms();
6710 }
6711 if let Err(error) = self.persist_state() {
6712 self.mark_managed_recovery_required(
6713 &lease.lease_id,
6714 ManagedWorktreeCleanupFailure::Backend,
6715 );
6716 let result = Err(persistence_failure(error));
6717 self.remember_managed_spawn_attempt(
6718 durable_v2_key,
6719 request,
6720 result.clone(),
6721 accepted_at,
6722 );
6723 return result;
6724 }
6725 let ready = self.managed_worktrees.lock()
6726 .unwrap_or_else(|poisoned| poisoned.into_inner())
6727 .get(&lease.lease_id)
6728 .expect("ready managed worktree lease remains present")
6729 .snapshot();
6730 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6731 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6732 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6733 let result = Err(error);
6734 self.remember_managed_spawn_attempt(
6735 durable_v2_key,
6736 request,
6737 result.clone(),
6738 accepted_at,
6739 );
6740 return result;
6741 }
6742 let spawn = self.spawn_session_with_deadline(
6743 lease.workspace_id.clone(),
6744 resolved.provider.clone(),
6745 resolved.mode,
6746 resolved.terminal_size,
6747 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6748 environment_profile.clone(),
6749 bundle.clone(),
6750 context.clone(),
6751 Some(lease.lease_id.clone()),
6752 Some(runtime_policy),
6753 SpawnRecordPolicy::Always,
6754 Some(deadline),
6755 &required_capabilities,
6756 None,
6757 resolved.approval_level,
6758 resolved.network_allowlist.clone(),
6759 resolved.browser_profile_id.clone(),
6760 ).await;
6761 let (session, effective_runtime_policy) = match spawn {
6762 Ok(spawned) => spawned,
6763 Err(error) => {
6764 if self
6765 .cleanup_managed_worktree(&lease.lease_id, true)
6766 .await
6767 .is_err()
6768 {
6769 self.mark_managed_recovery_required(
6770 &lease.lease_id,
6771 ManagedWorktreeCleanupFailure::Backend,
6772 );
6773 }
6774 let result = Err(error);
6775 self.remember_managed_spawn_attempt(
6776 durable_v2_key,
6777 request,
6778 result.clone(),
6779 accepted_at,
6780 );
6781 return result;
6782 }
6783 };
6784 let (record_id, raw_inventory_record_id) = {
6785 let mut bindings = self.session_bindings.lock()
6786 .unwrap_or_else(|poisoned| poisoned.into_inner());
6787 let binding = bindings.get_mut(&session.session.instance_id)
6788 .expect("successful spawn retains its session binding");
6789 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6790 managed_spawn_record_ownership(binding, effective_runtime_policy)
6791 };
6792 let bound = self.managed_worktrees.lock()
6793 .unwrap_or_else(|poisoned| poisoned.into_inner())
6794 .bind_session(
6795 &lease.lease_id,
6796 ManagedWorktreeSessionHolder {
6797 incarnation_id: self.incarnation_id,
6798 instance_id: session.session.instance_id,
6799 generation: session.session.generation,
6800 },
6801 record_id,
6802 unix_time_ms(),
6803 )
6804 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6805 let snapshot = match bound {
6806 Ok(snapshot) => snapshot,
6807 Err(error) => {
6808 if raw_inventory_record_id
6809 .as_ref()
6810 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6811 {
6812 self.mark_managed_recovery_required(
6813 &lease.lease_id,
6814 ManagedWorktreeCleanupFailure::Backend,
6815 );
6816 }
6817 let _ = self.remove_session(&session).await;
6818 self.mark_managed_recovery_required(
6819 &lease.lease_id,
6820 ManagedWorktreeCleanupFailure::Backend,
6821 );
6822 let result = Err(error);
6823 self.remember_managed_spawn_attempt(
6824 durable_v2_key,
6825 request,
6826 result.clone(),
6827 accepted_at,
6828 );
6829 return result;
6830 }
6831 };
6832 if let Err(error) = self.persist_state() {
6833 if raw_inventory_record_id
6834 .as_ref()
6835 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6836 {
6837 self.mark_managed_recovery_required(
6838 &lease.lease_id,
6839 ManagedWorktreeCleanupFailure::Backend,
6840 );
6841 }
6842 let _ = self.remove_session(&session).await;
6843 if self
6844 .cleanup_managed_worktree(&lease.lease_id, true)
6845 .await
6846 .is_err()
6847 {
6848 self.mark_managed_recovery_required(
6849 &lease.lease_id,
6850 ManagedWorktreeCleanupFailure::Backend,
6851 );
6852 }
6853 let result = Err(persistence_failure(error));
6854 self.remember_managed_spawn_attempt(
6855 durable_v2_key,
6856 request,
6857 result.clone(),
6858 accepted_at,
6859 );
6860 return result;
6861 }
6862 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6863 let receipt = managed_spawn_receipt(
6864 &resolved,
6865 self.incarnation_id,
6866 session,
6867 snapshot,
6868 environment_profile,
6869 bundle,
6870 context,
6871 );
6872 let result = Ok(receipt);
6873 self.remember_managed_spawn_attempt(
6874 durable_v2_key,
6875 request,
6876 result.clone(),
6877 accepted_at,
6878 );
6879 result
6880 }
6881
6882 async fn spawn_managed_worktree_v2(
6883 &self,
6884 request: ManagedWorktreeSpawnRequestV2,
6885 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6886 if request.spawn_spec.target.node_id != self.node_id {
6887 return Err(failure(
6888 NodeFailureCode::SpawnTargetMismatch,
6889 "spawn target does not match this node",
6890 ));
6891 }
6892 if request.spawn_spec.target.worktree_id.is_some() {
6893 return Err(failure(
6894 NodeFailureCode::InvalidRequest,
6895 "managed worktree spawn must not select a caller-provided worktree",
6896 ));
6897 }
6898 let request_digest = managed_spawn_request_digest_v2(&request)?;
6899 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6900 match replay {
6901 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6902 DurableManagedSpawnReplayDecision::PendingLinked => {
6903 return Err(failure(
6904 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6905 "managed worktree V2 reservation has an incomplete durable allocation",
6906 ));
6907 }
6908 DurableManagedSpawnReplayDecision::Reserved => {}
6909 }
6910 let validated = (|| {
6911 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6912 let profile = self.managed_profile(
6913 &resolved.target.workspace_id,
6914 &request.worktree_profile_id,
6915 )?;
6916 if profile.revision() != &request.expected_profile_revision {
6917 return Err(failure(
6918 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6919 "managed worktree profile revision does not match the loaded profile",
6920 ));
6921 }
6922 Ok(())
6923 })();
6924 match validated {
6925 Ok(()) => {}
6926 Err(error) => {
6927 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6928 return Err(error);
6929 }
6930 }
6931 let legacy = ManagedWorktreeSpawnRequest {
6932 spawn_spec: request.spawn_spec.clone(),
6933 worktree_profile_id: request.worktree_profile_id.clone(),
6934 };
6935 match self
6936 .spawn_managed_worktree_inner(
6937 legacy,
6938 Some(&request.spawn_spec.idempotency_key),
6939 )
6940 .await
6941 {
6942 Ok(receipt) => {
6943 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6944 Ok(receipt)
6945 }
6946 Err(error) => {
6947 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6948 Err(error)
6949 }
6950 }
6951 }
6952
6953 fn managed_profile(
6954 &self,
6955 workspace_id: &WorkspaceId,
6956 profile_id: &WorktreeProfileId,
6957 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6958 if self.worktree_service_modes.get(workspace_id).copied()
6959 != Some(WorktreeServiceMode::Managed)
6960 {
6961 return Err(failure(
6962 NodeFailureCode::UnsupportedSpawnCapability,
6963 "workspace does not enable managed worktrees",
6964 ));
6965 }
6966 self.managed_worktree_profiles.get(workspace_id)
6967 .and_then(|profiles| profiles.get(profile_id))
6968 .cloned()
6969 .ok_or_else(|| failure(
6970 NodeFailureCode::InvalidRequest,
6971 "managed worktree profile is unavailable for this workspace",
6972 ))
6973 }
6974
6975 async fn reconcile_failed_allocation(
6976 &self,
6977 lease: &ManagedWorktreeLeaseRecord,
6978 source_root: &str,
6979 ) {
6980 match list_git_worktrees(source_root).await {
6981 Ok(worktrees) => {
6982 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6983 None => {
6984 match std::fs::symlink_metadata(&lease.target_root) {
6985 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6986 self.managed_worktrees.lock()
6987 .unwrap_or_else(|poisoned| poisoned.into_inner())
6988 .remove_unmutated(&lease.lease_id);
6989 let _ = self.persist_state();
6990 }
6991 _ => self.mark_managed_recovery_required(
6992 &lease.lease_id,
6993 ManagedWorktreeCleanupFailure::OwnershipConflict,
6994 ),
6995 }
6996 }
6997 Some(item) if exact_owned_worktree(
6998 lease,
6999 &item.path,
7000 item.branch.as_deref(),
7001 &item.head,
7002 ) => {
7003 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7004 }
7005 Some(_) => self.mark_managed_recovery_required(
7006 &lease.lease_id,
7007 ManagedWorktreeCleanupFailure::OwnershipConflict,
7008 ),
7009 }
7010 }
7011 Err(_) => self.mark_managed_recovery_required(
7012 &lease.lease_id,
7013 ManagedWorktreeCleanupFailure::Backend,
7014 ),
7015 }
7016 }
7017
7018 async fn reconcile_managed_worktrees(&self) {
7019 let leases = self.managed_worktrees.lock()
7020 .unwrap_or_else(|poisoned| poisoned.into_inner())
7021 .active_records()
7022 .cloned()
7023 .collect::<Vec<_>>();
7024 for lease in leases {
7025 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7026 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7027 _ => {
7028 self.mark_managed_recovery_required(
7029 &lease.lease_id,
7030 ManagedWorktreeCleanupFailure::OwnershipConflict,
7031 );
7032 continue;
7033 }
7034 };
7035 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7036 self.mark_managed_recovery_required(
7037 &lease.lease_id,
7038 ManagedWorktreeCleanupFailure::OwnershipConflict,
7039 );
7040 continue;
7041 }
7042 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7043 Ok(root) => root,
7044 Err(_) => {
7045 self.mark_managed_recovery_required(
7046 &lease.lease_id,
7047 ManagedWorktreeCleanupFailure::OwnershipConflict,
7048 );
7049 continue;
7050 }
7051 };
7052 let worktrees = match list_git_worktrees(&source_root).await {
7053 Ok(worktrees) => worktrees,
7054 Err(_) => {
7055 self.mark_managed_recovery_required(
7056 &lease.lease_id,
7057 ManagedWorktreeCleanupFailure::Backend,
7058 );
7059 continue;
7060 }
7061 };
7062 let listed = worktrees.iter()
7063 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7064 let Some(listed) = listed else {
7065 match std::fs::symlink_metadata(&lease.target_root) {
7066 Err(error) if error.kind() == io::ErrorKind::NotFound
7067 && lease.state == ManagedWorktreeLeaseState::Allocating
7068 && !lease.has_holders() => {
7069 let removed = self.managed_worktrees.lock()
7070 .unwrap_or_else(|poisoned| poisoned.into_inner())
7071 .tombstone(&lease.lease_id, unix_time_ms());
7072 if self.persist_state().is_ok() && removed.is_some() {
7073 self.publish(NodeEvent::ManagedWorktreeRemoved {
7074 lease_id: lease.lease_id.clone(),
7075 });
7076 }
7077 }
7078 _ => self.mark_managed_recovery_required(
7079 &lease.lease_id,
7080 ManagedWorktreeCleanupFailure::OwnershipConflict,
7081 ),
7082 }
7083 continue;
7084 };
7085 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7086 &lease,
7087 &listed.path,
7088 listed.branch.as_deref(),
7089 &listed.head,
7090 ) {
7091 self.mark_managed_recovery_required(
7092 &lease.lease_id,
7093 ManagedWorktreeCleanupFailure::OwnershipConflict,
7094 );
7095 continue;
7096 }
7097 if !self.workspaces.read()
7098 .unwrap_or_else(|poisoned| poisoned.into_inner())
7099 .contains_key(&lease.workspace_id)
7100 {
7101 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7102 self.mark_managed_recovery_required(
7103 &lease.lease_id,
7104 ManagedWorktreeCleanupFailure::Backend,
7105 );
7106 continue;
7107 }
7108 }
7109 let snapshot = {
7110 let mut registry = self.managed_worktrees.lock()
7111 .unwrap_or_else(|poisoned| poisoned.into_inner());
7112 let current = registry.get_mut(&lease.lease_id)
7113 .expect("reconciled managed lease remains present");
7114 current.expected_head = Some(listed.head.clone());
7115 current.cleanup_failure = None;
7116 current.state = if current.has_holders() {
7117 ManagedWorktreeLeaseState::InUse
7118 } else if current.retention == ManagedWorktreeRetention::Retain {
7119 ManagedWorktreeLeaseState::Retained
7120 } else {
7121 ManagedWorktreeLeaseState::Ready
7122 };
7123 current.updated_at_unix_ms = unix_time_ms();
7124 current.snapshot()
7125 };
7126 if self.persist_state().is_err() {
7127 self.mark_managed_recovery_required(
7128 &lease.lease_id,
7129 ManagedWorktreeCleanupFailure::Backend,
7130 );
7131 continue;
7132 }
7133 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7134 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7135 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7136 }
7137 }
7138 }
7139
7140 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7160 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7161 .session_records
7162 .lock()
7163 .unwrap_or_else(|poisoned| poisoned.into_inner())
7164 .records
7165 .values()
7166 .filter(|record| {
7167 record.state == ManagedSessionState::Live
7168 && record.exported_context.is_none()
7169 && record.provider_session.is_some()
7170 })
7171 .filter_map(|record| {
7172 record
7173 .active_session
7174 .clone()
7175 .map(|session| (record.record_id.clone(), session))
7176 })
7177 .collect();
7178 for (record_id, session) in candidates {
7179 let shared = Arc::clone(self);
7180 tokio::spawn(async move {
7181 shared
7182 .reconcile_context_pack_export_for_record(&record_id, session)
7183 .await;
7184 });
7185 }
7186 }
7187
7188 async fn reconcile_context_pack_export_for_record(
7207 &self,
7208 record_id: &SessionRecordId,
7209 session: SessionAddress,
7210 ) {
7211 let Ok(record) = self.record(record_id) else {
7212 return;
7213 };
7214 if !matches!(
7215 record.state,
7216 ManagedSessionState::Live | ManagedSessionState::Dormant
7217 ) || record.exported_context.is_some()
7218 || record.provider_session.is_none()
7219 {
7220 return;
7221 }
7222 let Ok(receipt) = self
7229 .export_context_pack_for_session_record(&record.record_id, &session, true)
7230 .await
7231 else {
7232 return;
7233 };
7234 self.set_exported_context(&record.record_id, receipt);
7235 }
7236
7237 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7242 let transaction = self
7243 .state_transaction
7244 .lock()
7245 .unwrap_or_else(|poisoned| poisoned.into_inner());
7246 let (previous, updated) = {
7247 let mut records = self
7248 .session_records
7249 .lock()
7250 .unwrap_or_else(|poisoned| poisoned.into_inner());
7251 let Some(record) = records.records.get_mut(record_id) else {
7252 return;
7253 };
7254 if record.exported_context.is_some() {
7255 return;
7256 }
7257 let previous = record.clone();
7258 record.exported_context = Some(receipt);
7259 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7260 (previous, record.clone())
7261 };
7262 if self.persist_state_locked().is_err() {
7263 self.session_records
7264 .lock()
7265 .unwrap_or_else(|poisoned| poisoned.into_inner())
7266 .records
7267 .insert(record_id.clone(), previous);
7268 return;
7269 }
7270 drop(transaction);
7271 self.publish_record(updated);
7272 }
7273
7274 fn managed_record_export_target_for_instance(
7284 &self,
7285 instance_id: AgentInstanceId,
7286 generation: SessionGeneration,
7287 ) -> Option<(SessionRecordId, SessionAddress)> {
7288 let bindings = self
7289 .session_bindings
7290 .lock()
7291 .unwrap_or_else(|poisoned| poisoned.into_inner());
7292 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7293 let record_id = binding.record_id.clone()?;
7294 Some((
7295 record_id,
7296 SessionAddress {
7297 workspace_id: binding.workspace_id.clone(),
7298 session: SessionKey { instance_id, generation },
7299 },
7300 ))
7301 }
7302
7303 fn mark_managed_recovery_required(
7304 &self,
7305 lease_id: &ManagedWorktreeLeaseId,
7306 failure_kind: ManagedWorktreeCleanupFailure,
7307 ) {
7308 let snapshot = {
7309 let mut registry = self.managed_worktrees.lock()
7310 .unwrap_or_else(|poisoned| poisoned.into_inner());
7311 let Some(lease) = registry.get_mut(lease_id) else { return };
7312 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7313 lease.cleanup_failure = Some(failure_kind);
7314 lease.updated_at_unix_ms = unix_time_ms();
7315 lease.snapshot()
7316 };
7317 let _ = self.persist_state();
7318 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7319 }
7320
7321 fn set_persistence_error(&self, error: Option<String>) {
7322 *self
7323 .persistence_error
7324 .write()
7325 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7326 .map(|message| session_registry::sanitized_persistence_summary(&message));
7327 }
7328
7329 fn persist_state(&self) -> io::Result<()> {
7330 let _transaction = self
7331 .state_transaction
7332 .lock()
7333 .unwrap_or_else(|poisoned| poisoned.into_inner());
7334 self.persist_state_locked()
7335 }
7336
7337 fn persist_state_locked(&self) -> io::Result<()> {
7338 let workspaces = self
7339 .workspaces
7340 .read()
7341 .unwrap_or_else(|poisoned| poisoned.into_inner())
7342 .clone();
7343 let records = self
7344 .session_records
7345 .lock()
7346 .unwrap_or_else(|poisoned| poisoned.into_inner())
7347 .records
7348 .values()
7349 .cloned()
7350 .collect::<Vec<_>>();
7351 let mut managed = self.managed_worktrees
7352 .lock()
7353 .unwrap_or_else(|poisoned| poisoned.into_inner());
7354 managed.reattach_record_holders(
7355 &managed_worktree_record_holders(&records),
7356 unix_time_ms(),
7357 );
7358 let materializations = self
7359 .materializations
7360 .lock()
7361 .unwrap_or_else(|poisoned| poisoned.into_inner())
7362 .values()
7363 .cloned()
7364 .collect::<Vec<_>>();
7365 let managed_spawn_replays = self.managed_spawn_replays
7366 .lock()
7367 .unwrap_or_else(|poisoned| poisoned.into_inner())
7368 .values()
7369 .cloned()
7370 .collect::<Vec<_>>();
7371 let result = session_registry::save_v10(
7372 self.state_path.as_deref(),
7373 &self.node_id,
7374 &workspaces,
7375 &records,
7376 &managed.records(),
7377 &managed.tombstones(),
7378 &materializations,
7379 &managed_spawn_replays,
7380 );
7381 match &result {
7382 Ok(warning) => self.set_persistence_error(warning.clone()),
7383 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7384 }
7385 result.map(|_| ())
7386 }
7387
7388 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7389 let _mutation_guard = self.mutation_gate.lock().await;
7390 self.begin_shutdown_locked().await
7391 }
7392
7393 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7394 if self.shutdown.swap(true, Ordering::AcqRel) {
7395 return Ok(());
7396 }
7397 SessionLifecycleHook::on_shutdown(self);
7398 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7399 let mut first_error = None;
7400 for session in &self.handle.snapshot().sessions {
7401 let remaining = deadline.saturating_duration_since(Instant::now());
7402 if remaining.is_zero() {
7403 first_error.get_or_insert_with(|| {
7404 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7405 });
7406 break;
7407 }
7408 if let Err(error) = self
7409 .dispatch_bounded(
7410 ControlCommand::Stop {
7411 instance_id: session.instance_id,
7412 force: true,
7413 },
7414 remaining,
7415 )
7416 .await
7417 {
7418 first_error.get_or_insert(error);
7419 }
7420 }
7421 self.shutdown_notify.notify_waiters();
7422 match first_error {
7423 Some(error) => Err(error),
7424 None => Ok(()),
7425 }
7426 }
7427
7428 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7429 let bindings = self
7430 .session_bindings
7431 .lock()
7432 .unwrap_or_else(|poisoned| poisoned.into_inner());
7433 if bindings.len() >= CONTROL_SESSIONS_MAX {
7434 Err(failure(
7435 NodeFailureCode::BackendBusy,
7436 "node session-binding capacity is full; remove an existing session first",
7437 ))
7438 } else {
7439 Ok(())
7440 }
7441 }
7442
7443 fn ensure_workspace_environment_profile_compatible(
7450 &self,
7451 workspace_id: &WorkspaceId,
7452 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7453 ) -> Result<(), NodeFailure> {
7454 let bindings = self
7455 .session_bindings
7456 .lock()
7457 .unwrap_or_else(|poisoned| poisoned.into_inner());
7458 for binding in bindings.values() {
7459 if &binding.workspace_id != workspace_id {
7460 continue;
7461 }
7462 if binding.environment_profile.as_ref() != environment_profile {
7463 return Err(failure(
7464 NodeFailureCode::WorkspaceBusy,
7465 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7466 ));
7467 }
7468 }
7469 Ok(())
7470 }
7471
7472 #[cfg(test)]
7473 fn bind_session(
7474 &self,
7475 instance_id: AgentInstanceId,
7476 workspace_id: WorkspaceId,
7477 generation: SessionGeneration,
7478 ) {
7479 self.bind_session_with_policy(
7480 &SessionAddress {
7481 workspace_id,
7482 session: SessionKey { instance_id, generation },
7483 },
7484 ProviderRuntimePolicy::raw_pty(),
7485 None,
7486 );
7487 }
7488
7489 #[cfg(test)]
7490 fn bind_session_with_policy(
7491 &self,
7492 address: &SessionAddress,
7493 runtime_policy: ProviderRuntimePolicy,
7494 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7495 ) {
7496 self.bind_session_with_materialization(
7497 address,
7498 runtime_policy,
7499 environment_profile,
7500 None,
7501 None,
7502 None,
7503 );
7504 }
7505
7506 fn bind_session_with_materialization(
7507 &self,
7508 address: &SessionAddress,
7509 runtime_policy: ProviderRuntimePolicy,
7510 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7511 bundle: Option<ResolvedBundleReceipt>,
7512 context: Option<ResolvedContextPackReceipt>,
7513 materialization_id: Option<MaterializationId>,
7514 ) {
7515 let mut bindings = self
7516 .session_bindings
7517 .lock()
7518 .unwrap_or_else(|poisoned| poisoned.into_inner());
7519 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7520 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7521 bindings.insert(address.session.instance_id, SessionBinding {
7522 workspace_id: address.workspace_id.clone(),
7523 generation: address.session.generation,
7524 runtime_policy,
7525 pending_resume: None,
7526 record_id: None,
7527 managed_worktree_lease_id: None,
7528 environment_profile,
7529 bundle,
7530 context,
7531 materialization_id,
7532 });
7533 }
7534
7535 #[cfg(test)]
7536 fn bind_managed_session(
7537 &self,
7538 address: &SessionAddress,
7539 record_id: SessionRecordId,
7540 runtime_policy: ProviderRuntimePolicy,
7541 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7542 ) {
7543 self.bind_managed_session_with_materialization(
7544 address,
7545 record_id,
7546 runtime_policy,
7547 environment_profile,
7548 None,
7549 None,
7550 None,
7551 );
7552 }
7553
7554 fn bind_managed_session_with_materialization(
7555 &self,
7556 address: &SessionAddress,
7557 record_id: SessionRecordId,
7558 runtime_policy: ProviderRuntimePolicy,
7559 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7560 bundle: Option<ResolvedBundleReceipt>,
7561 context: Option<ResolvedContextPackReceipt>,
7562 materialization_id: Option<MaterializationId>,
7563 ) {
7564 let mut bindings = self
7565 .session_bindings
7566 .lock()
7567 .unwrap_or_else(|poisoned| poisoned.into_inner());
7568 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7569 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7570 bindings.insert(
7571 address.session.instance_id,
7572 SessionBinding {
7573 workspace_id: address.workspace_id.clone(),
7574 generation: address.session.generation,
7575 runtime_policy,
7576 pending_resume: None,
7577 record_id: Some(record_id),
7578 managed_worktree_lease_id: None,
7579 environment_profile,
7580 bundle,
7581 context,
7582 materialization_id,
7583 },
7584 );
7585 }
7586
7587 #[cfg(test)]
7588 fn bind_spawn_session(
7589 &self,
7590 address: &SessionAddress,
7591 provider: AgentId,
7592 mode: SessionMode,
7593 runtime_policy: ProviderRuntimePolicy,
7594 record_policy: SpawnRecordPolicy,
7595 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7596 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7597 self.bind_spawn_session_with_materialization(
7598 address,
7599 provider,
7600 mode,
7601 runtime_policy,
7602 record_policy,
7603 environment_profile,
7604 None,
7605 None,
7606 None,
7607 )
7608 }
7609
7610 fn bind_spawn_session_with_materialization(
7611 &self,
7612 address: &SessionAddress,
7613 provider: AgentId,
7614 mode: SessionMode,
7615 runtime_policy: ProviderRuntimePolicy,
7616 record_policy: SpawnRecordPolicy,
7617 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7618 bundle: Option<ResolvedBundleReceipt>,
7619 context: Option<ResolvedContextPackReceipt>,
7620 materialization_id: Option<MaterializationId>,
7621 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7622 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7623 && !runtime_policy.provider_session_identity
7624 {
7625 self.bind_session_with_materialization(
7626 address,
7627 runtime_policy,
7628 environment_profile,
7629 bundle,
7630 context,
7631 materialization_id,
7632 );
7633 return Ok(None);
7634 }
7635 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7636 let record = self.new_record(
7637 address,
7638 provider,
7639 mode,
7640 active_record_state(runtime_policy.provider_session_identity, false),
7641 None,
7642 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7643 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7644 record_owns_lifecycle.then(|| context.clone()).flatten(),
7645 )?;
7646 let record_id = record.record_id.clone();
7647 let transaction = self
7648 .state_transaction
7649 .lock()
7650 .unwrap_or_else(|poisoned| poisoned.into_inner());
7651 self.insert_record(record.clone())?;
7652 let previous_materialization = if record_owns_lifecycle {
7653 if let Some(materialization_id) = materialization_id.as_ref() {
7654 let mut materializations = self
7655 .materializations
7656 .lock()
7657 .unwrap_or_else(|poisoned| poisoned.into_inner());
7658 let Some(ownership) = materializations.get_mut(materialization_id) else {
7659 drop(materializations);
7660 self.remove_record_memory(&record_id);
7661 return Err(failure(
7662 NodeFailureCode::BackendOperationFailed,
7663 "session environment ownership disappeared before binding",
7664 ));
7665 };
7666 let previous = ownership.clone();
7667 if ownership
7668 .transfer_to_record(record_id.clone(), unix_time_ms())
7669 .is_err()
7670 {
7671 drop(materializations);
7672 self.remove_record_memory(&record_id);
7673 return Err(failure(
7674 NodeFailureCode::BackendOperationFailed,
7675 "session environment ownership transfer failed",
7676 ));
7677 }
7678 Some(previous)
7679 } else {
7680 None
7681 }
7682 } else {
7683 None
7684 };
7685 self.bind_managed_session_with_materialization(
7686 address,
7687 record_id.clone(),
7688 runtime_policy,
7689 environment_profile,
7690 bundle,
7691 context,
7692 materialization_id.clone(),
7693 );
7694 if let Err(error) = self.persist_state_locked() {
7695 self.remove_binding(address);
7696 self.remove_record_memory(&record_id);
7697 if let (Some(materialization_id), Some(previous)) =
7698 (materialization_id.as_ref(), previous_materialization)
7699 {
7700 self.materializations
7701 .lock()
7702 .unwrap_or_else(|poisoned| poisoned.into_inner())
7703 .insert(materialization_id.clone(), previous);
7704 }
7705 return Err(persistence_failure(error));
7706 }
7707 drop(transaction);
7708 self.publish_record(record);
7709 Ok(Some(record_id))
7710 }
7711
7712 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7713 for _ in 0..8 {
7714 let bytes = random_nonce().map_err(|error| {
7715 failure(
7716 NodeFailureCode::BackendOperationFailed,
7717 &format!("session record identity generation failed: {error}"),
7718 )
7719 })?;
7720 let mut value = String::with_capacity(27);
7721 value.push_str("sr-");
7722 for byte in &bytes[..12] {
7723 use std::fmt::Write as _;
7724 let _ = write!(&mut value, "{byte:02x}");
7725 }
7726 let record_id = SessionRecordId::new(value).map_err(|error| {
7727 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7728 })?;
7729 let records = self
7730 .session_records
7731 .lock()
7732 .unwrap_or_else(|poisoned| poisoned.into_inner());
7733 if !records.records.contains_key(&record_id) {
7734 return Ok(record_id);
7735 }
7736 }
7737 Err(failure(
7738 NodeFailureCode::BackendBusy,
7739 "could not allocate a unique session record ID",
7740 ))
7741 }
7742
7743 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7744 for _ in 0..8 {
7745 let bytes = random_nonce().map_err(|error| {
7746 failure(
7747 NodeFailureCode::BackendOperationFailed,
7748 &format!("task identity generation failed: {error}"),
7749 )
7750 })?;
7751 let mut nonce = [0_u8; 12];
7752 nonce.copy_from_slice(&bytes[..12]);
7753 let task_id = TaskId::from_nonce(nonce);
7754 let records = self
7755 .session_records
7756 .lock()
7757 .unwrap_or_else(|poisoned| poisoned.into_inner());
7758 if !records.records.values().any(|record| {
7759 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7760 == Some(&task_id)
7761 }) {
7762 return Ok(task_id);
7763 }
7764 }
7765 Err(failure(
7766 NodeFailureCode::BackendBusy,
7767 "could not allocate a unique task ID",
7768 ))
7769 }
7770
7771 fn default_record_name(&self, provider: &AgentId) -> String {
7772 let records = self
7773 .session_records
7774 .lock()
7775 .unwrap_or_else(|poisoned| poisoned.into_inner());
7776 let ordinal = records
7777 .records
7778 .values()
7779 .filter(|record| &record.provider == provider)
7780 .count()
7781 .saturating_add(1);
7782 format!("{} #{ordinal}", provider.as_str())
7783 }
7784
7785 fn new_record(
7786 &self,
7787 address: &SessionAddress,
7788 provider: AgentId,
7789 mode: SessionMode,
7790 state: ManagedSessionState,
7791 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7792 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7793 bundle: Option<ResolvedBundleReceipt>,
7794 context: Option<ResolvedContextPackReceipt>,
7795 ) -> Result<ManagedSessionRecord, NodeFailure> {
7796 let canonical_root = self.workspace_root(&address.workspace_id)?;
7797 let now = unix_time_ms();
7798 Ok(ManagedSessionRecord {
7799 record_id: self.allocate_record_id()?,
7800 display_name: self.default_record_name(&provider),
7801 provider,
7802 mode,
7803 state,
7804 workspace_id: address.workspace_id.clone(),
7805 canonical_root: opaque_windows_path(canonical_root),
7806 provider_session,
7807 active_session: Some(address.clone()),
7808 environment_profile,
7809 bundle,
7810 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7811 context,
7812 exported_context: None,
7813 task_binding: None,
7814 created_at_unix_ms: now,
7815 updated_at_unix_ms: now,
7816 last_error: None,
7817 })
7818 }
7819
7820 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7821 let mut records = self
7822 .session_records
7823 .lock()
7824 .unwrap_or_else(|poisoned| poisoned.into_inner());
7825 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7826 return Err(failure(
7827 NodeFailureCode::BackendBusy,
7828 "managed session record capacity is full; forget an inactive record first",
7829 ));
7830 }
7831 if let Some(identity) = record.provider_session.as_ref() {
7832 if records.records.values().any(|candidate| {
7833 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7834 session_registry::same_provider_session(
7835 &candidate.provider,
7836 candidate_identity,
7837 &record.provider,
7838 identity,
7839 )
7840 })
7841 }) {
7842 return Err(failure(
7843 NodeFailureCode::SessionRecordConflict,
7844 "provider session is already represented by another managed record",
7845 ));
7846 }
7847 }
7848 match records.records.entry(record.record_id.clone()) {
7849 std::collections::btree_map::Entry::Vacant(entry) => {
7850 entry.insert(record);
7851 }
7852 std::collections::btree_map::Entry::Occupied(_) => {
7853 return Err(failure(
7854 NodeFailureCode::SessionRecordConflict,
7855 "session record ID collision",
7856 ));
7857 }
7858 }
7859 Ok(())
7860 }
7861
7862 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7863 self.session_records
7864 .lock()
7865 .unwrap_or_else(|poisoned| poisoned.into_inner())
7866 .records
7867 .get(record_id)
7868 .cloned()
7869 .ok_or_else(|| {
7870 failure(
7871 NodeFailureCode::UnknownSessionRecord,
7872 "managed session record does not exist",
7873 )
7874 })
7875 }
7876
7877 fn revalidate_session_record_preview(
7878 &self,
7879 expected: &ManagedSessionRecord,
7880 identity: &ProviderSessionIdentity,
7881 ) -> Result<(), NodeFailure> {
7882 let current = self.record(&expected.record_id)?;
7883 let current_root = self.workspace_root(¤t.workspace_id)?;
7884 if current.provider != expected.provider
7885 || current.workspace_id != expected.workspace_id
7886 || current.canonical_root != expected.canonical_root
7887 || current.provider_session.as_ref() != Some(identity)
7888 || !platform::roots_equal(
7889 ¤t_root,
7890 windows_path_text(¤t.canonical_root),
7891 )
7892 {
7893 return Err(failure(
7894 NodeFailureCode::SessionRecordConflict,
7895 "managed session changed while its preview was loading",
7896 ));
7897 }
7898 Ok(())
7899 }
7900
7901 fn revalidate_session_record_context_export(
7902 &self,
7903 expected: &ManagedSessionRecord,
7904 identity: Option<&ProviderSessionIdentity>,
7905 session: &SessionAddress,
7906 allow_clean_detachment: bool,
7907 ) -> Result<(), NodeFailure> {
7908 let current = self.record(&expected.record_id)?;
7909 let current_root = self.workspace_root(¤t.workspace_id)?;
7910 let snapshot = self.internal_session_snapshot(session)?;
7911 let binding = self
7912 .session_bindings
7913 .lock()
7914 .unwrap_or_else(|poisoned| poisoned.into_inner())
7915 .get(&session.session.instance_id)
7916 .cloned()
7917 .ok_or_else(|| {
7918 failure(
7919 NodeFailureCode::SessionRecordConflict,
7920 "managed session runtime binding disappeared during context export",
7921 )
7922 })?;
7923 if !session_record_context_export_binding_is_exact(
7924 expected,
7925 ¤t,
7926 identity,
7927 session,
7928 &binding,
7929 &snapshot.agent_id,
7930 ¤t_root,
7931 allow_clean_detachment,
7932 ) || !session_record_context_export_source_is_usable(
7933 ¤t,
7934 &snapshot.status,
7935 allow_clean_detachment,
7936 ) {
7937 return Err(failure(
7938 NodeFailureCode::SessionRecordConflict,
7939 "managed session binding changed during context export",
7940 ));
7941 }
7942 Ok(())
7943 }
7944
7945 fn revalidate_loaded_history(
7946 &self,
7947 session: &SessionAddress,
7948 candidate_id: &str,
7949 loaded: &HistorySessionRecord,
7950 ) -> Result<(), NodeFailure> {
7951 let snapshot = self.internal_session_snapshot(session)?;
7952 if snapshot.history.pending.is_some()
7953 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7954 || snapshot.history.loaded.as_ref() != Some(loaded)
7955 {
7956 return Err(failure(
7957 NodeFailureCode::SessionRecordConflict,
7958 "managed session history changed during context export",
7959 ));
7960 }
7961 Ok(())
7962 }
7963
7964 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7965 self.session_records
7966 .lock()
7967 .unwrap_or_else(|poisoned| poisoned.into_inner())
7968 .records
7969 .remove(record_id)
7970 }
7971
7972 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7973 if self.destroy_record_materialization(record_id)? {
7974 self.publish(NodeEvent::SessionRecordRemoved {
7975 record_id: record_id.clone(),
7976 });
7977 return Ok(());
7978 }
7979 let transaction = self
7980 .state_transaction
7981 .lock()
7982 .unwrap_or_else(|poisoned| poisoned.into_inner());
7983 let Some(removed) = self.remove_record_memory(record_id) else {
7984 return Ok(());
7985 };
7986 if let Err(error) = self.persist_state_locked() {
7987 let _ = self.insert_record(removed);
7988 return Err(persistence_failure(error));
7989 }
7990 drop(transaction);
7991 self.publish(NodeEvent::SessionRecordRemoved {
7992 record_id: record_id.clone(),
7993 });
7994 Ok(())
7995 }
7996
7997 fn publish_record(&self, record: ManagedSessionRecord) {
7998 self.publish(NodeEvent::SessionRecordUpserted { record });
7999 }
8000
8001 fn mark_record_error(
8002 &self,
8003 record_id: &SessionRecordId,
8004 message: &str,
8005 ) -> Result<(), NodeFailure> {
8006 let transaction = self
8007 .state_transaction
8008 .lock()
8009 .unwrap_or_else(|poisoned| poisoned.into_inner());
8010 let previous = {
8011 let mut records = self
8012 .session_records
8013 .lock()
8014 .unwrap_or_else(|poisoned| poisoned.into_inner());
8015 let Some(record) = records.records.get_mut(record_id) else {
8016 return Ok(());
8017 };
8018 let previous = record.clone();
8019 record.state = ManagedSessionState::Unavailable;
8020 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8021 record.updated_at_unix_ms = unix_time_ms();
8022 previous
8023 };
8024 if let Err(error) = self.persist_state_locked() {
8025 self.session_records
8026 .lock()
8027 .unwrap_or_else(|poisoned| poisoned.into_inner())
8028 .records
8029 .insert(record_id.clone(), previous);
8030 return Err(persistence_failure(error));
8031 }
8032 let record = self.record(record_id)?;
8033 drop(transaction);
8034 self.publish_record(record);
8035 Ok(())
8036 }
8037
8038 fn rename_session_record(
8039 &self,
8040 record_id: &SessionRecordId,
8041 display_name: String,
8042 ) -> Result<ManagedSessionRecord, NodeFailure> {
8043 validate_display_name(&display_name)
8044 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8045 let _transaction = self
8046 .state_transaction
8047 .lock()
8048 .unwrap_or_else(|poisoned| poisoned.into_inner());
8049 let previous = {
8050 let mut records = self
8051 .session_records
8052 .lock()
8053 .unwrap_or_else(|poisoned| poisoned.into_inner());
8054 let record = records.records.get_mut(record_id).ok_or_else(|| {
8055 failure(
8056 NodeFailureCode::UnknownSessionRecord,
8057 "managed session record does not exist",
8058 )
8059 })?;
8060 let previous = record.clone();
8061 record.display_name = display_name;
8062 record.updated_at_unix_ms = unix_time_ms();
8063 previous
8064 };
8065 if let Err(error) = self.persist_state_locked() {
8066 self.session_records
8067 .lock()
8068 .unwrap_or_else(|poisoned| poisoned.into_inner())
8069 .records
8070 .insert(record_id.clone(), previous);
8071 return Err(persistence_failure(error));
8072 }
8073 let updated = self.record(record_id)?;
8074 self.publish_record(updated.clone());
8075 Ok(updated)
8076 }
8077
8078 fn set_session_task(
8079 &self,
8080 record_id: &SessionRecordId,
8081 expected_revision: u64,
8082 target: SessionTaskTargetV1,
8083 ) -> Result<ManagedSessionRecord, NodeFailure> {
8084 let transaction = self
8085 .state_transaction
8086 .lock()
8087 .unwrap_or_else(|poisoned| poisoned.into_inner());
8088 let current = self.record(record_id)?;
8089 let current_task_id = current
8090 .task_binding
8091 .as_ref()
8092 .and_then(|binding| binding.task_id.as_ref());
8093 let current_revision = current
8094 .task_binding
8095 .as_ref()
8096 .map_or(0, |binding| binding.revision);
8097 if expected_revision != current_revision {
8098 return Err(failure(
8099 NodeFailureCode::SessionRecordConflict,
8100 "managed session task binding revision changed",
8101 ));
8102 }
8103 let exact = match &target {
8104 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8105 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8106 SessionTaskTargetV1::New => false,
8107 };
8108 if exact {
8109 return Ok(current);
8110 }
8111 let revision = current_revision.checked_add(1).ok_or_else(|| {
8112 failure(
8113 NodeFailureCode::SessionRecordConflict,
8114 "managed session task binding revision is exhausted",
8115 )
8116 })?;
8117 let task_id = match target {
8118 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8119 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8120 SessionTaskTargetV1::Clear => None,
8121 };
8122 let changed_at_unix_ms = unix_time_ms()
8123 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8124 failure(
8125 NodeFailureCode::SessionRecordConflict,
8126 "managed session record timestamp is exhausted",
8127 )
8128 })?);
8129 let updated = {
8130 let mut records = self
8131 .session_records
8132 .lock()
8133 .unwrap_or_else(|poisoned| poisoned.into_inner());
8134 let record = records.records.get_mut(record_id).ok_or_else(|| {
8135 failure(
8136 NodeFailureCode::UnknownSessionRecord,
8137 "managed session record does not exist",
8138 )
8139 })?;
8140 record.task_binding = Some(SessionTaskBindingV1 {
8141 revision,
8142 task_id,
8143 changed_at_unix_ms,
8144 });
8145 record.updated_at_unix_ms = changed_at_unix_ms;
8146 record.clone()
8147 };
8148 if let Err(error) = self.persist_state_locked() {
8149 self.session_records
8150 .lock()
8151 .unwrap_or_else(|poisoned| poisoned.into_inner())
8152 .records
8153 .insert(record_id.clone(), current);
8154 return Err(persistence_failure(error));
8155 }
8156 drop(transaction);
8157 self.publish_record(updated.clone());
8158 Ok(updated)
8159 }
8160
8161 fn index_provider_session(
8162 &self,
8163 workspace_id: WorkspaceId,
8164 provider: AgentId,
8165 identity: ProviderSessionIdentity,
8166 display_name: String,
8167 ) -> Result<ManagedSessionRecord, NodeFailure> {
8168 self.index_provider_session_with_policy(
8169 workspace_id,
8170 provider,
8171 identity,
8172 display_name,
8173 false,
8174 )
8175 }
8176
8177 fn index_provider_session_with_policy(
8178 &self,
8179 workspace_id: WorkspaceId,
8180 provider: AgentId,
8181 identity: ProviderSessionIdentity,
8182 display_name: String,
8183 allow_validated_transcript_path: bool,
8184 ) -> Result<ManagedSessionRecord, NodeFailure> {
8185 validate_display_name(&display_name)
8186 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8187 identity
8188 .validate()
8189 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8190 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8191 return Err(failure(
8192 NodeFailureCode::InvalidRequest,
8193 "provider session reference index v1 does not accept transcript paths",
8194 ));
8195 }
8196 let canonical_root = self.workspace_root(&workspace_id)?;
8197 if !self.enabled_providers.contains(&provider) {
8198 return Err(failure(
8199 NodeFailureCode::InvalidRequest,
8200 "provider is not enabled on this node",
8201 ));
8202 }
8203
8204 let transaction = self
8205 .state_transaction
8206 .lock()
8207 .unwrap_or_else(|poisoned| poisoned.into_inner());
8208 {
8209 let records = self
8210 .session_records
8211 .lock()
8212 .unwrap_or_else(|poisoned| poisoned.into_inner());
8213 if let Some(existing) = records.records.values().find(|candidate| {
8214 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8215 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8216 })
8217 }) {
8218 if existing.provider == provider && existing.workspace_id == workspace_id {
8219 return Ok(existing.clone());
8220 }
8221 return Err(failure(
8222 NodeFailureCode::SessionRecordConflict,
8223 "provider session reference conflicts with another provider or workspace",
8224 ));
8225 }
8226 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8227 return Err(failure(
8228 NodeFailureCode::BackendBusy,
8229 "managed session record capacity is full; forget an inactive record first",
8230 ));
8231 }
8232 }
8233
8234 let now = unix_time_ms();
8235 let record = ManagedSessionRecord {
8236 record_id: self.allocate_record_id()?,
8237 display_name,
8238 provider,
8239 mode: SessionMode::Pty,
8240 state: ManagedSessionState::Dormant,
8241 workspace_id,
8242 canonical_root: opaque_windows_path(canonical_root),
8243 provider_session: Some(identity),
8244 active_session: None,
8245 environment_profile: None,
8246 bundle: None,
8247 context_id: None,
8248 context: None,
8249 exported_context: None,
8250 task_binding: None,
8251 created_at_unix_ms: now,
8252 updated_at_unix_ms: now,
8253 last_error: None,
8254 };
8255 self.insert_record(record.clone())?;
8256 if let Err(error) = self.persist_state_locked() {
8257 self.remove_record_memory(&record.record_id);
8258 return Err(persistence_failure(error));
8259 }
8260 drop(transaction);
8261 self.publish_record(record.clone());
8262 Ok(record)
8263 }
8264
8265 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8266 self.session_bindings
8267 .lock()
8268 .unwrap_or_else(|poisoned| poisoned.into_inner())
8269 .iter()
8270 .find_map(|(instance_id, binding)| {
8271 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8272 workspace_id: binding.workspace_id.clone(),
8273 session: SessionKey {
8274 instance_id: *instance_id,
8275 generation: binding.generation,
8276 },
8277 })
8278 })
8279 }
8280
8281 async fn forget_session_record(
8282 &self,
8283 record_id: &SessionRecordId,
8284 ) -> Result<(), NodeFailure> {
8285 let record = self.record(record_id)?;
8286 if record.active_session.is_some()
8287 || matches!(
8288 record.state,
8289 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8290 )
8291 {
8292 return Err(failure(
8293 NodeFailureCode::SessionRecordBusy,
8294 "stop the managed session before forgetting it",
8295 ));
8296 }
8297 if let Some(address) = self.bound_address_for_record(record_id) {
8298 self.remove_session(&address).await?;
8299 }
8300 self.remove_managed_session_record(record_id, &record.workspace_id).await
8301 }
8302
8303 async fn remove_managed_session_record(
8318 &self,
8319 record_id: &SessionRecordId,
8320 workspace_id: &WorkspaceId,
8321 ) -> Result<(), NodeFailure> {
8322 let managed_lease_id = self.managed_worktrees.lock()
8323 .unwrap_or_else(|poisoned| poisoned.into_inner())
8324 .lease_for_workspace(workspace_id);
8325 if !self.destroy_record_materialization(record_id)? {
8326 let transaction = self
8327 .state_transaction
8328 .lock()
8329 .unwrap_or_else(|poisoned| poisoned.into_inner());
8330 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8331 failure(
8332 NodeFailureCode::UnknownSessionRecord,
8333 "managed session record does not exist",
8334 )
8335 })?;
8336 if let Err(error) = self.persist_state_locked() {
8337 let _ = self.insert_record(removed);
8338 return Err(persistence_failure(error));
8339 }
8340 drop(transaction);
8341 }
8342 self.publish(NodeEvent::SessionRecordRemoved {
8343 record_id: record_id.clone(),
8344 });
8345 if let Some(lease_id) = managed_lease_id {
8346 if let Some(snapshot) = self.managed_worktrees.lock()
8347 .unwrap_or_else(|poisoned| poisoned.into_inner())
8348 .get(&lease_id)
8349 .map(ManagedWorktreeLeaseRecord::snapshot)
8350 {
8351 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8352 }
8353 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8354 }
8355 Ok(())
8356 }
8357
8358 async fn retire_unavailable_session_records(&self) {
8371 let policy = self.session_record_retention;
8372 let age_enabled = policy.age_ms > 0;
8373 let keep_enabled = policy.keep_per_workspace > 0;
8374 if !age_enabled && !keep_enabled {
8375 return;
8376 }
8377 let now = unix_time_ms();
8378 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8379 self.session_records
8380 .lock()
8381 .unwrap_or_else(|poisoned| poisoned.into_inner())
8382 .records
8383 .values()
8384 .filter(|record| record.state == ManagedSessionState::Unavailable)
8385 .map(|record| {
8386 (
8387 record.record_id.clone(),
8388 record.workspace_id.clone(),
8389 record.provider.clone(),
8390 record.updated_at_unix_ms,
8391 )
8392 })
8393 .collect()
8394 };
8395 if candidates.is_empty() {
8396 return;
8397 }
8398 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8399 BTreeMap::new();
8400 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8401 by_workspace
8402 .entry(workspace_id)
8403 .or_default()
8404 .push((record_id, provider, updated_at_unix_ms));
8405 }
8406 let reason = if age_enabled && keep_enabled {
8407 "age+keep-n"
8408 } else if age_enabled {
8409 "age"
8410 } else {
8411 "keep-n"
8412 };
8413 let mut retired: u64 = 0;
8414 for (workspace_id, mut records) in by_workspace {
8415 records.sort_by(|left, right| right.2.cmp(&left.2));
8418 for (index, (record_id, provider, updated_at_unix_ms)) in
8419 records.into_iter().enumerate()
8420 {
8421 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8422 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8423 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8424 let should_retire = if age_enabled && keep_enabled {
8425 age_hit && keep_hit
8426 } else {
8427 age_hit || keep_hit
8428 };
8429 if !should_retire {
8430 continue;
8431 }
8432 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8433 Ok(()) => {
8434 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8435 retired += 1;
8436 tracing::info!(
8437 node_id = %self.node_id,
8438 record_id = %record_id,
8439 workspace_id = %workspace_id,
8440 provider = %provider,
8441 age_ms = record_age_ms,
8442 reason,
8443 "managed session record retired"
8444 );
8445 }
8446 Err(error) => tracing::warn!(
8447 node_id = %self.node_id,
8448 record_id = %record_id,
8449 workspace_id = %workspace_id,
8450 code = ?error.code,
8451 cause = %error.message,
8452 "session record retention sweep failed to retire a record"
8453 ),
8454 }
8455 }
8456 }
8457 if retired > 0 {
8458 let remaining_unavailable = self
8459 .session_records
8460 .lock()
8461 .unwrap_or_else(|poisoned| poisoned.into_inner())
8462 .records
8463 .values()
8464 .filter(|record| record.state == ManagedSessionState::Unavailable)
8465 .count();
8466 tracing::info!(
8467 node_id = %self.node_id,
8468 retired,
8469 remaining_unavailable,
8470 "session record retention sweep completed"
8471 );
8472 }
8473 }
8474
8475 async fn resume_session_record(
8476 &self,
8477 record_id: &SessionRecordId,
8478 terminal_size: gate4agent_types::TerminalSize,
8479 initial_prompt: Option<String>,
8480 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8481 let record = self.record(record_id)?;
8482 if record.active_session.is_some()
8483 || matches!(
8484 record.state,
8485 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8486 )
8487 {
8488 return Err(failure(
8489 NodeFailureCode::SessionRecordBusy,
8490 "managed session already has a live runtime binding",
8491 ));
8492 }
8493 let identity = record.provider_session.clone().ok_or_else(|| {
8494 failure(
8495 NodeFailureCode::SessionRecordNotResumable,
8496 "managed session has no verified provider session identity",
8497 )
8498 })?;
8499 let working_directory = self.workspace_root(&record.workspace_id)?;
8500 if !platform::roots_equal(
8501 &working_directory,
8502 windows_path_text(&record.canonical_root),
8503 ) {
8504 return Err(failure(
8505 NodeFailureCode::SessionRecordConflict,
8506 "managed session workspace root changed; refusing to resume in another directory",
8507 ));
8508 }
8509 {
8510 let records = self
8511 .session_records
8512 .lock()
8513 .unwrap_or_else(|poisoned| poisoned.into_inner());
8514 if records.records.values().any(|candidate| {
8515 candidate.record_id != *record_id
8516 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8517 session_registry::same_provider_session(
8518 &candidate.provider,
8519 candidate_identity,
8520 &record.provider,
8521 &identity,
8522 )
8523 })
8524 && (candidate.active_session.is_some()
8525 || matches!(
8526 candidate.state,
8527 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8528 ))
8529 }) {
8530 return Err(failure(
8531 NodeFailureCode::SessionRecordConflict,
8532 "provider session is already active through another managed record",
8533 ));
8534 }
8535 }
8536 let runtime_requirement = if initial_prompt.is_some() {
8537 ProviderRuntimeRequirement::ResumeWithPrompt
8538 } else {
8539 ProviderRuntimeRequirement::Resume
8540 };
8541 let runtime_policy = self.admit_provider_runtime(
8542 &record.provider,
8543 runtime_requirement,
8544 )
8545 .await?;
8546 let request = ResumeLaunchRequest {
8547 working_directory,
8548 terminal_size,
8549 initial_prompt,
8550 };
8551 request
8552 .validate()
8553 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8554 self.ensure_binding_capacity()?;
8555 let resolved_materialization = match self.resolve_record_materialization(
8556 record_id,
8557 &record.provider,
8558 record.mode,
8559 record.environment_profile.as_ref(),
8560 record.bundle.as_ref(),
8561 record.context.as_ref(),
8562 ) {
8563 Ok(materialization) => materialization,
8564 Err(error) => {
8565 let label = match error.code {
8566 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8567 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8568 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8569 _ => "environment-profile-unavailable",
8570 };
8571 self.mark_record_error(record_id, label)?;
8572 return Err(error);
8573 }
8574 };
8575 let (materialization_id, environment_overlay) = match resolved_materialization {
8576 Some((id, overlay)) => (Some(id), overlay),
8577 None => (None, None),
8578 };
8579 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8580 let mut environment_selection = match SpawnPreparer::select_environment(self,
8581 instance_id,
8582 &record.provider,
8583 record.mode,
8584 record.environment_profile.as_ref(),
8585 ) {
8586 Ok(selection) => selection,
8587 Err(error) => {
8588 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8589 return Err(error);
8590 }
8591 };
8592 let mut instance_overlay = environment_overlay
8593 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8594 .transpose()?
8595 .flatten();
8596 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8597 self.remove_session(&stale_address).await?;
8598 }
8599 let address = SessionAddress {
8600 workspace_id: record.workspace_id.clone(),
8601 session: SessionKey {
8602 instance_id,
8603 generation: SessionGeneration(1),
8604 },
8605 };
8606 let transaction = self
8607 .state_transaction
8608 .lock()
8609 .unwrap_or_else(|poisoned| poisoned.into_inner());
8610 let current = self.record(record_id)?;
8611 if current.active_session.is_some()
8612 || matches!(
8613 current.state,
8614 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8615 )
8616 {
8617 return Err(failure(
8618 NodeFailureCode::SessionRecordBusy,
8619 "managed session became active while resume was being prepared",
8620 ));
8621 }
8622 self.bind_managed_session_with_materialization(
8623 &address,
8624 record_id.clone(),
8625 runtime_policy,
8626 record.environment_profile.clone(),
8627 record.bundle.clone(),
8628 record.context.clone(),
8629 materialization_id,
8630 );
8631 if let Some(selection) = environment_selection.take() {
8632 selection.retain();
8633 }
8634 if let Some(overlay) = instance_overlay.take() {
8635 overlay.retain();
8636 }
8637 drop(transaction);
8638
8639 let transport = match record.mode {
8640 SessionMode::Pty => TransportKind::Pty,
8641 SessionMode::Inline => TransportKind::Pipe,
8642 SessionMode::Acp => TransportKind::Acp,
8643 };
8644 let agent_id = record.provider.clone();
8645 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8646 if let Err(error) = self
8647 .dispatch_bounded(
8648 ControlCommand::Register {
8649 instance_id,
8650 agent_id,
8651 transport,
8652 },
8653 dispatch_timeout,
8654 )
8655 .await
8656 {
8657 if let Some(binding) = self.remove_binding(&address) {
8658 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8659 }
8660 return Err(error);
8661 }
8662 let command = self.prepare_command(ControlCommand::Resume {
8663 instance_id,
8664 target: ResumeTarget::ProviderSession { identity },
8665 runtime_policy,
8666 request,
8667 });
8668 let started_after = self.current_sequence();
8669 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8670 self.rollback_spawn(&address, dispatch_timeout).await?;
8671 return Err(error);
8672 }
8673 if let Err(error) = self.dispatch_envelope(command) {
8674 self.clear_armed_resume(&address);
8675 self.rollback_spawn(&address, dispatch_timeout).await?;
8676 return Err(error);
8677 }
8678 match self
8679 .wait_for_record_resume(&address, started_after)
8680 .await
8681 {
8682 Ok(settled_address) => {
8683 let managed_lease_id = {
8684 self.managed_worktrees.lock()
8685 .unwrap_or_else(|poisoned| poisoned.into_inner())
8686 .lease_for_workspace(&record.workspace_id)
8687 };
8688 if let Some(lease_id) = managed_lease_id {
8689 {
8690 let mut bindings = self.session_bindings.lock()
8691 .unwrap_or_else(|poisoned| poisoned.into_inner());
8692 let binding = bindings.get_mut(&settled_address.session.instance_id)
8693 .ok_or_else(|| failure(
8694 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8695 "resumed managed session lost its exact binding",
8696 ))?;
8697 if binding.generation != settled_address.session.generation {
8698 return Err(failure(
8699 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8700 "resumed managed session generation diverged",
8701 ));
8702 }
8703 binding.managed_worktree_lease_id = Some(lease_id.clone());
8704 }
8705 let snapshot = self.managed_worktrees.lock()
8706 .unwrap_or_else(|poisoned| poisoned.into_inner())
8707 .bind_session(
8708 &lease_id,
8709 ManagedWorktreeSessionHolder {
8710 incarnation_id: self.incarnation_id,
8711 instance_id: settled_address.session.instance_id,
8712 generation: settled_address.session.generation,
8713 },
8714 Some(record_id.clone()),
8715 unix_time_ms(),
8716 ).map_err(|message| failure(
8717 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8718 &message,
8719 ))?;
8720 if let Err(error) = self.persist_state() {
8721 let _ = self.remove_session(&settled_address).await;
8722 return Err(persistence_failure(error));
8723 }
8724 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8725 }
8726 Ok((self.record(record_id)?, settled_address))
8727 }
8728 Err(error) => {
8729 if matches!(
8730 error.code,
8731 NodeFailureCode::SessionRecordNotResumable
8732 | NodeFailureCode::BackendOperationFailed
8733 ) {
8734 if let Some(bound) = self.bound_address_for_record(record_id) {
8735 let _ = self.remove_session(&bound).await;
8736 }
8737 }
8738 Err(error)
8739 }
8740 }
8741 }
8742
8743 async fn wait_for_record_resume(
8744 &self,
8745 initial_address: &SessionAddress,
8746 after_sequence: u64,
8747 ) -> Result<SessionAddress, NodeFailure> {
8748 let deadline = Instant::now()
8749 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8750 let mut scan_after = after_sequence;
8751 loop {
8752 let events = self
8753 .history
8754 .lock()
8755 .unwrap_or_else(|poisoned| poisoned.into_inner())
8756 .events
8757 .iter()
8758 .filter(|event| event.sequence > scan_after)
8759 .cloned()
8760 .collect::<Vec<_>>();
8761 for envelope in events {
8762 scan_after = scan_after.max(envelope.sequence);
8763 let NodeEvent::Control { address, event } = envelope.event else {
8764 continue;
8765 };
8766 if address.session.instance_id != initial_address.session.instance_id {
8767 continue;
8768 }
8769 match event.event {
8770 ControlEventKind::Resumed { .. } => return Ok(address),
8771 ControlEventKind::ResumeDenied { reason } => {
8772 return Err(failure(
8773 NodeFailureCode::SessionRecordNotResumable,
8774 &reason,
8775 ));
8776 }
8777 ControlEventKind::ResumeFailed { message }
8778 | ControlEventKind::CommandRejected { message } => {
8779 return Err(failure(
8780 NodeFailureCode::BackendOperationFailed,
8781 &message,
8782 ));
8783 }
8784 _ => {}
8785 }
8786 }
8787 if Instant::now() >= deadline {
8788 return Err(failure(
8789 NodeFailureCode::BackendBusy,
8790 "managed resume did not settle before the bounded deadline",
8791 ));
8792 }
8793 sleep(Duration::from_millis(5)).await;
8794 }
8795 }
8796
8797 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8798 self.workspaces
8799 .read()
8800 .unwrap_or_else(|poisoned| poisoned.into_inner())
8801 .get(workspace_id)
8802 .cloned()
8803 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8804 }
8805
8806 fn workspace_roots(&self) -> Vec<PathBuf> {
8807 self.workspaces
8808 .read()
8809 .unwrap_or_else(|poisoned| poisoned.into_inner())
8810 .values()
8811 .map(PathBuf::from)
8812 .collect()
8813 }
8814
8815 async fn inspect_workspace(
8816 &self,
8817 workspace_id: WorkspaceId,
8818 ) -> Result<WorkspaceInspection, NodeFailure> {
8819 let _permit = self
8820 .inspection_slots
8821 .clone()
8822 .try_acquire_owned()
8823 .map_err(|_| {
8824 tracing::warn!(
8825 workspace_id = %workspace_id,
8826 "workspace inspection rejected: inspection capacity is busy",
8827 );
8828 failure(
8829 NodeFailureCode::BackendBusy,
8830 "workspace inspection capacity is busy",
8831 )
8832 })?;
8833 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8834 tracing::warn!(
8835 workspace_id = %workspace_id,
8836 code = ?error.code,
8837 "workspace inspection rejected: workspace is not registered",
8838 );
8839 error
8840 })?;
8841 let tree_root = canonical_root.clone();
8842 let started_at = Instant::now();
8843 let time_budget_ms = workspace_inspection_time_budget_ms();
8844 let entry_cap = workspace_inspection_entry_cap();
8845 let deadline = started_at + Duration::from_millis(time_budget_ms);
8846 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8847 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8848 })
8849 .await
8850 .map_err(|error| {
8851 tracing::warn!(
8852 workspace_id = %workspace_id,
8853 error = %error,
8854 "workspace inspection rejected: walk task failed",
8855 );
8856 failure(
8857 NodeFailureCode::BackendOperationFailed,
8858 &format!("workspace inspection task failed: {error}"),
8859 )
8860 })?;
8861 let (mut git, git_time_budget_exceeded) =
8862 inspect_git_workspace(&canonical_root, deadline).await;
8863 if !git.worktrees.is_empty() {
8864 let registered = self
8865 .workspaces
8866 .read()
8867 .unwrap_or_else(|poisoned| poisoned.into_inner())
8868 .iter()
8869 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8870 .collect::<Vec<_>>();
8871 for worktree in &mut git.worktrees {
8872 worktree.workspace_id = registered
8873 .iter()
8874 .find(|(_, root)| {
8875 worktree_paths_equal(root, windows_path_text(&worktree.path))
8876 })
8877 .map(|(workspace_id, _)| workspace_id.clone());
8878 }
8879 }
8880 if git.is_repository {
8881 git.managed_worktree = self
8882 .managed_worktrees
8883 .lock()
8884 .unwrap_or_else(|poisoned| poisoned.into_inner())
8885 .git_scope(&workspace_id, git.branch.as_deref());
8886 }
8887 let truncation = workspace_inspection_truncation(
8888 &walk_budget,
8889 git_time_budget_exceeded,
8890 started_at.elapsed(),
8891 );
8892 if let Some(truncation) = &truncation {
8893 tracing::info!(
8894 workspace_id = %workspace_id,
8895 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8896 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8897 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8898 entries_visited = truncation.entries_visited,
8899 elapsed_ms = truncation.elapsed_ms,
8900 "workspace inspection truncated by its inner budget",
8901 );
8902 }
8903 Ok(WorkspaceInspection {
8904 workspace_id,
8905 entries,
8906 tree_truncated,
8907 git,
8908 truncation,
8909 })
8910 }
8911
8912 async fn browse_host_directories(
8913 &self,
8914 directory: Option<OpaqueHostPath>,
8915 after: Option<OpaqueHostPath>,
8916 ) -> Result<HostDirectoryListing, NodeFailure> {
8917 let permit = self
8918 .inspection_slots
8919 .clone()
8920 .try_acquire_owned()
8921 .map_err(|_| {
8922 failure(
8923 NodeFailureCode::BackendBusy,
8924 "host directory browse capacity is busy",
8925 )
8926 })?;
8927 let task = tokio::task::spawn_blocking(move || {
8928 let _permit = permit;
8929 browse_host_directories(directory, after)
8930 });
8931 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8932 .await
8933 .map_err(|_| {
8934 failure(
8935 NodeFailureCode::HostDirectoryReadTimedOut,
8936 "host directory browse exceeded its bounded deadline",
8937 )
8938 })?
8939 .map_err(|_| {
8940 failure(
8941 NodeFailureCode::HostDirectoryReadFailed,
8942 "host directory browse task failed",
8943 )
8944 })?
8945 .map_err(host_directory_failure)
8946 }
8947
8948 async fn read_workspace_file(
8949 &self,
8950 workspace_id: WorkspaceId,
8951 path: RepositoryPath,
8952 ) -> Result<WorkspaceFileRead, NodeFailure> {
8953 let canonical_root = self.workspace_root(&workspace_id)?;
8954 let permit = self
8955 .inspection_slots
8956 .clone()
8957 .try_acquire_owned()
8958 .map_err(|_| {
8959 failure(
8960 NodeFailureCode::BackendBusy,
8961 "workspace file read capacity is busy",
8962 )
8963 })?;
8964 let path_to_read = path.clone();
8965 let task = tokio::task::spawn_blocking(move || {
8966 let _permit = permit;
8967 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8968 });
8969 let content = timeout(
8970 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8971 task,
8972 )
8973 .await
8974 .map_err(|_| {
8975 failure(
8976 NodeFailureCode::RepositoryFileReadTimedOut,
8977 "repository file read exceeded its bounded deadline",
8978 )
8979 })?
8980 .map_err(|_| {
8981 failure(
8982 NodeFailureCode::RepositoryFileReadFailed,
8983 "repository file read task failed",
8984 )
8985 })?
8986 .map_err(workspace_file_failure)?;
8987 let content = match content {
8988 WorkspaceFileBytes::Utf8(text) => {
8989 let revision = Some(workspace_file_revision(text.as_bytes()));
8990 return Ok(WorkspaceFileRead {
8991 workspace_id,
8992 path,
8993 content: WorkspaceFileContent::Utf8 {
8994 byte_len: u32::try_from(text.len())
8995 .expect("bounded workspace text length must fit u32"),
8996 text,
8997 },
8998 revision,
8999 });
9000 }
9001 WorkspaceFileBytes::NonUtf8 { byte_length } => {
9002 WorkspaceFileContent::NonUtf8 {
9003 byte_len: u32::try_from(byte_length)
9004 .expect("bounded workspace file length must fit u32"),
9005 }
9006 }
9007 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9008 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9009 },
9010 };
9011 Ok(WorkspaceFileRead {
9012 workspace_id,
9013 path,
9014 content,
9015 revision: None,
9016 })
9017 }
9018
9019 async fn write_workspace_file(
9020 &self,
9021 workspace_id: WorkspaceId,
9022 path: RepositoryPath,
9023 expected_revision: WorkspaceFileRevision,
9024 text: String,
9025 ) -> Result<WorkspaceFileRead, NodeFailure> {
9026 let canonical_root = self.workspace_root(&workspace_id)?;
9027 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9028 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9029 })?;
9030 let path_to_write = path.clone();
9031 let expected = expected_revision.as_str().to_owned();
9032 let text_to_write = text.clone();
9033 let task = tokio::task::spawn_blocking(move || {
9034 let _permit = permit;
9035 write_workspace_file_to_disk(
9036 Path::new(&canonical_root),
9037 &path_to_write,
9038 &expected,
9039 &text_to_write,
9040 )
9041 });
9042 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9043 .await
9044 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9045 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9046 .map_err(workspace_file_write_failure)?;
9047 Ok(WorkspaceFileRead {
9048 workspace_id,
9049 path,
9050 content: WorkspaceFileContent::Utf8 {
9051 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9052 text,
9053 },
9054 revision: Some(WorkspaceFileRevision::new(revision)
9055 .expect("workspace writer returns a valid SHA-256 revision")),
9056 })
9057 }
9058
9059 async fn create_workspace_file(
9060 &self,
9061 workspace_id: WorkspaceId,
9062 path: RepositoryPath,
9063 ) -> Result<WorkspaceFileRead, NodeFailure> {
9064 let canonical_root = self.workspace_root(&workspace_id)?;
9065 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9066 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9067 })?;
9068 let path_to_create = path.clone();
9069 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9070 let task_commit_state = Arc::clone(&commit_state);
9071 let task = tokio::task::spawn_blocking(move || {
9072 let _permit = permit;
9073 create_workspace_file_on_disk(
9074 Path::new(&canonical_root),
9075 &path_to_create,
9076 &task_commit_state,
9077 )
9078 });
9079 let revision = settle_workspace_entry_create(
9080 task,
9081 commit_state,
9082 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9083 "repository file create exceeded its bounded deadline",
9084 "repository file create task failed",
9085 )
9086 .await?;
9087 Ok(WorkspaceFileRead {
9088 workspace_id,
9089 path,
9090 content: WorkspaceFileContent::Utf8 {
9091 text: String::new(),
9092 byte_len: 0,
9093 },
9094 revision: Some(WorkspaceFileRevision::new(revision)
9095 .expect("workspace creator returns a valid SHA-256 revision")),
9096 })
9097 }
9098
9099 async fn create_workspace_directory(
9100 &self,
9101 workspace_id: WorkspaceId,
9102 path: RepositoryPath,
9103 ) -> Result<WorkspaceEntry, NodeFailure> {
9104 let canonical_root = self.workspace_root(&workspace_id)?;
9105 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9106 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9107 })?;
9108 let path_to_create = path.clone();
9109 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9110 let task_commit_state = Arc::clone(&commit_state);
9111 let task = tokio::task::spawn_blocking(move || {
9112 let _permit = permit;
9113 create_workspace_directory_on_disk(
9114 Path::new(&canonical_root),
9115 &path_to_create,
9116 &task_commit_state,
9117 )
9118 });
9119 settle_workspace_entry_create(
9120 task,
9121 commit_state,
9122 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9123 "repository directory create exceeded its bounded deadline",
9124 "repository directory create task failed",
9125 )
9126 .await?;
9127 Ok(WorkspaceEntry {
9128 relative_path: path,
9129 kind: WorkspaceEntryKind::Directory,
9130 })
9131 }
9132
9133 async fn read_git_history(
9134 &self,
9135 workspace_id: WorkspaceId,
9136 path: Option<RepositoryPath>,
9137 before: Option<GitObjectId>,
9138 limit: u16,
9139 ) -> Result<GitHistoryPage, NodeFailure> {
9140 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9141 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9142 })?;
9143 let root = self.workspace_root(&workspace_id)?;
9144 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9145 }
9146
9147 async fn read_git_diff(
9148 &self,
9149 workspace_id: WorkspaceId,
9150 request: GitDiffRequest,
9151 ) -> Result<GitDiff, NodeFailure> {
9152 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9153 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9154 })?;
9155 let root = self.workspace_root(&workspace_id)?;
9156 read_git_diff_bounded(&root, request).await
9157 }
9158
9159 fn native_session_route_root(
9160 &self,
9161 route: &NativeSessionCatalogRoute,
9162 ) -> Result<Option<String>, NodeFailure> {
9163 route
9164 .validate()
9165 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9166 match route.scope {
9167 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9168 .workspace_id
9169 .as_ref()
9170 .map(|workspace_id| self.workspace_root(workspace_id))
9171 .transpose(),
9172 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9173 }
9174 }
9175
9176 fn project_native_session_entries(
9177 &self,
9178 route: &NativeSessionCatalogRoute,
9179 entries: Vec<ScopedNativeSessionCatalogEntry>,
9180 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9181 let current_root = route
9182 .workspace_id
9183 .as_ref()
9184 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9185 let records = self
9186 .session_records
9187 .lock()
9188 .unwrap_or_else(|poisoned| poisoned.into_inner());
9189 let mut seen_records = std::collections::HashSet::new();
9190 entries
9191 .into_iter()
9192 .map(|entry| {
9193 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9194 let matches = records
9195 .records
9196 .values()
9197 .filter(|record| {
9198 record.provider == route.provider
9199 && record.workspace_id == *workspace_id
9200 && current_root.as_deref().is_some_and(|root| {
9201 platform::roots_equal(
9202 root,
9203 windows_path_text(&record.canonical_root),
9204 )
9205 })
9206 && record.provider_session.as_ref().is_some_and(|identity| {
9207 session_registry::same_provider_session(
9208 &record.provider,
9209 identity,
9210 &route.provider,
9211 &entry.provider_session,
9212 )
9213 })
9214 })
9215 .map(|record| record.record_id.clone())
9216 .collect::<Vec<_>>();
9217 (matches.len() == 1
9218 && seen_records.insert(matches[0].clone()))
9219 .then(|| matches[0].clone())
9220 });
9221 let metadata = entry.metadata;
9222 crate::protocol::NativeSessionCatalogEntry {
9223 selection_id: metadata.selection_id,
9224 title: metadata.title,
9225 modified_at_unix_ms: metadata.modified_at_unix_ms,
9226 model: metadata.model,
9227 message_count: metadata.message_count,
9228 completed_turn_count: metadata.completed_turn_count,
9229 external_group: entry.external_group,
9230 record_id,
9231 }
9232 })
9233 .collect()
9234 }
9235
9236 async fn catalog_native_sessions(
9237 &self,
9238 route: NativeSessionCatalogRoute,
9239 limit: u16,
9240 ) -> Result<(
9241 Vec<crate::protocol::NativeSessionCatalogEntry>,
9242 crate::protocol::NativeSessionCatalogSummary,
9243 ), NodeFailure> {
9244 let canonical_root = self.native_session_route_root(&route)?;
9245 let registered_workspace_roots = self.workspace_roots();
9246 let provider = route.provider.clone();
9247 let scope = route.scope;
9248 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9249 failure(
9250 NodeFailureCode::InvalidRequest,
9251 "native session catalog is not configured",
9252 )
9253 })?;
9254 let _permit = Arc::clone(&self.inspection_slots)
9255 .try_acquire_owned()
9256 .map_err(|_| {
9257 failure(
9258 NodeFailureCode::BackendBusy,
9259 "native session catalog capacity is busy",
9260 )
9261 })?;
9262 let page = run_native_session_catalog_operation(
9263 catalog,
9264 "native session catalog",
9265 move |catalog| {
9266 catalog.catalog_initial_for_scope(
9267 &provider,
9268 scope,
9269 canonical_root.as_deref().map(Path::new),
9270 ®istered_workspace_roots,
9271 limit,
9272 )
9273 },
9274 )
9275 .await?;
9276 let summary = crate::protocol::NativeSessionCatalogSummary {
9277 catalog_revision: page.revision,
9278 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9279 recent_total_count: page.recent_total_count,
9280 older_total_count: page.older_total_count,
9281 recent_next_after_selection_id: page.next_after_selection_id,
9282 recent_has_more: page.remaining_count > 0,
9283 };
9284 Ok((self.project_native_session_entries(&route, page.entries), summary))
9285 }
9286
9287 async fn page_native_sessions(
9288 &self,
9289 route: NativeSessionCatalogRoute,
9290 window: crate::protocol::NativeSessionCatalogWindow,
9291 catalog_revision: u64,
9292 recent_cutoff_unix_ms: u64,
9293 after_selection_id: Option<String>,
9294 limit: u16,
9295 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9296 let canonical_root = self.native_session_route_root(&route)?;
9297 let registered_workspace_roots = self.workspace_roots();
9298 let provider = route.provider.clone();
9299 let scope = route.scope;
9300 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9301 failure(
9302 NodeFailureCode::InvalidRequest,
9303 "native session catalog is not configured",
9304 )
9305 })?;
9306 let _permit = Arc::clone(&self.inspection_slots)
9307 .try_acquire_owned()
9308 .map_err(|_| {
9309 failure(
9310 NodeFailureCode::BackendBusy,
9311 "native session catalog capacity is busy",
9312 )
9313 })?;
9314 let page = run_native_session_catalog_operation(
9315 catalog,
9316 "native session catalog page",
9317 move |catalog| {
9318 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9319 &provider,
9320 scope,
9321 canonical_root.as_deref().map(Path::new),
9322 ®istered_workspace_roots,
9323 window,
9324 catalog_revision,
9325 recent_cutoff_unix_ms,
9326 after_selection_id.as_deref(),
9327 limit,
9328 ))
9329 },
9330 )
9331 .await?;
9332 let page = page.map_err(|error| match error {
9333 NativeSessionCatalogError::StaleCatalog => failure(
9334 NodeFailureCode::StaleNativeSessionCatalog,
9335 "native session catalog revision or cursor is stale",
9336 ),
9337 _ => failure(
9338 NodeFailureCode::BackendOperationFailed,
9339 "native session catalog page failed",
9340 ),
9341 })?;
9342 let entries = self.project_native_session_entries(&route, page.entries);
9343 Ok(crate::protocol::NativeSessionCatalogPage {
9344 window: page.window,
9345 revision: page.revision,
9346 entries,
9347 next_after_selection_id: page.next_after_selection_id,
9348 remaining_count: page.remaining_count,
9349 has_more: page.remaining_count > 0,
9350 })
9351 }
9352
9353 async fn preview_native_session(
9354 &self,
9355 selection: NativeSessionSelection,
9356 message_limit: u16,
9357 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9358 selection
9359 .validate()
9360 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9361 let canonical_root = self.native_session_route_root(&selection.route)?;
9362 let registered_workspace_roots = self.workspace_roots();
9363 let provider = selection.route.provider.clone();
9364 let scope = selection.route.scope;
9365 let catalog_revision = selection.catalog_revision;
9366 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9367 let selection_id = selection.selection_id.clone();
9368 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9369 failure(
9370 NodeFailureCode::InvalidRequest,
9371 "native session preview is not configured",
9372 )
9373 })?;
9374 let _permit = Arc::clone(&self.inspection_slots)
9375 .try_acquire_owned()
9376 .map_err(|_| {
9377 failure(
9378 NodeFailureCode::BackendBusy,
9379 "native session preview capacity is busy",
9380 )
9381 })?;
9382 let preview = run_native_session_catalog_operation(
9383 catalog,
9384 "native session preview",
9385 move |catalog| {
9386 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9387 &provider,
9388 scope,
9389 canonical_root.as_deref().map(Path::new),
9390 ®istered_workspace_roots,
9391 catalog_revision,
9392 recent_cutoff_unix_ms,
9393 &selection_id,
9394 message_limit,
9395 ))
9396 },
9397 )
9398 .await?;
9399 preview
9400 .map(Into::into)
9401 .map_err(native_session_preview_failure)
9402 }
9403
9404 async fn index_native_session(
9405 &self,
9406 selection: NativeSessionSelection,
9407 display_name: String,
9408 ) -> Result<ManagedSessionRecord, NodeFailure> {
9409 selection
9410 .validate()
9411 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9412 if selection.route.scope
9413 != gate4agent_types::NativeSessionCatalogScope::Workspace
9414 {
9415 return Err(failure(
9416 NodeFailureCode::WorkspaceRegistrationRequired,
9417 "register the external project as a workspace before indexing or resuming it",
9418 ));
9419 }
9420 let workspace_id = selection
9421 .route
9422 .workspace_id
9423 .clone()
9424 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9425 let canonical_root = self
9426 .native_session_route_root(&selection.route)?
9427 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9428 let registered_workspace_roots = self.workspace_roots();
9429 let provider = selection.route.provider.clone();
9430 let catalog_revision = selection.catalog_revision;
9431 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9432 let selection_id = selection.selection_id.clone();
9433 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9434 failure(
9435 NodeFailureCode::InvalidRequest,
9436 "native session index is not configured",
9437 )
9438 })?;
9439 let _permit = Arc::clone(&self.inspection_slots)
9440 .try_acquire_owned()
9441 .map_err(|_| {
9442 failure(
9443 NodeFailureCode::BackendBusy,
9444 "native session index capacity is busy",
9445 )
9446 })?;
9447 let resolved = run_native_session_catalog_operation(
9448 catalog,
9449 "native session index resolution",
9450 move |catalog| {
9451 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9452 &provider,
9453 gate4agent_types::NativeSessionCatalogScope::Workspace,
9454 Some(Path::new(&canonical_root)),
9455 ®istered_workspace_roots,
9456 catalog_revision,
9457 recent_cutoff_unix_ms,
9458 &selection_id,
9459 ))
9460 },
9461 )
9462 .await?
9463 .map_err(native_session_preview_failure)?;
9464 self.index_provider_session_with_policy(
9465 workspace_id,
9466 selection.route.provider,
9467 resolved.identity,
9468 display_name,
9469 true,
9470 )
9471 }
9472
9473 async fn preview_native_session_by_session_id(
9474 &self,
9475 workspace_id: WorkspaceId,
9476 provider: AgentId,
9477 session_id: String,
9478 message_limit: u16,
9479 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9480 let canonical_root = self.workspace_root(&workspace_id)?;
9481 let registered_workspace_roots = self.workspace_roots();
9482 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9483 failure(
9484 NodeFailureCode::InvalidRequest,
9485 "native session preview is not configured",
9486 )
9487 })?;
9488 let _permit = Arc::clone(&self.inspection_slots)
9489 .try_acquire_owned()
9490 .map_err(|_| {
9491 failure(
9492 NodeFailureCode::BackendBusy,
9493 "native session preview capacity is busy",
9494 )
9495 })?;
9496 run_native_session_catalog_operation(
9497 catalog,
9498 "native session preview",
9499 move |catalog| {
9500 catalog.preview_session_id_for_workspace(
9501 &provider,
9502 Path::new(&canonical_root),
9503 ®istered_workspace_roots,
9504 &session_id,
9505 message_limit,
9506 )
9507 },
9508 )
9509 .await
9510 }
9511
9512 async fn register_workspace(
9513 &self,
9514 workspace_id: WorkspaceId,
9515 root: String,
9516 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9517 validate_workspace_request_root(&root)?;
9518 let canonicalize_workspace_id = workspace_id.clone();
9519 let workspace = tokio::task::spawn_blocking(move || {
9520 WorkspaceConfig::new(canonicalize_workspace_id, root)
9521 })
9522 .await
9523 .map_err(|error| {
9524 failure(
9525 NodeFailureCode::BackendOperationFailed,
9526 &format!("workspace canonicalization task failed: {error}"),
9527 )
9528 })?
9529 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9530 let transaction = self
9531 .state_transaction
9532 .lock()
9533 .unwrap_or_else(|poisoned| poisoned.into_inner());
9534 let mut workspaces = self
9535 .workspaces
9536 .write()
9537 .unwrap_or_else(|poisoned| poisoned.into_inner());
9538 if workspaces.contains_key(&workspace_id) {
9539 return Err(failure(
9540 NodeFailureCode::DuplicateWorkspaceId,
9541 "workspace ID is already registered",
9542 ));
9543 }
9544 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9545 platform::roots_equal(existing_root, workspace.canonical_root())
9546 }) {
9547 return Err(failure(
9548 NodeFailureCode::DuplicateWorkspaceRoot,
9549 &format!("workspace root is already registered as '{existing_id}'"),
9550 ));
9551 }
9552 {
9553 let records = self
9554 .session_records
9555 .lock()
9556 .unwrap_or_else(|poisoned| poisoned.into_inner());
9557 if let Some(record) = records.records.values().find(|record| {
9558 record.workspace_id == workspace_id
9559 && !record
9560 .canonical_root
9561 .as_utf8()
9562 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9563 }) {
9564 return Err(failure(
9565 NodeFailureCode::SessionRecordConflict,
9566 &format!(
9567 "workspace ID is retained by session record '{}' at another root",
9568 record.record_id,
9569 ),
9570 ));
9571 }
9572 }
9573 let snapshot = WorkspaceSnapshot {
9574 workspace_id: workspace_id.clone(),
9575 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9576 sessions: Vec::new(),
9577 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9578 managed_worktree_profiles: Some(WorktreeProfileInventory {
9579 profiles: Vec::new(),
9580 }),
9581 };
9582 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9583 drop(workspaces);
9584 let previous_records = {
9585 let mut records = self
9586 .session_records
9587 .lock()
9588 .unwrap_or_else(|poisoned| poisoned.into_inner());
9589 let previous = records.records.clone();
9590 for record in records.records.values_mut().filter(|record| {
9591 record.workspace_id == workspace_id
9592 && record
9593 .canonical_root
9594 .as_utf8()
9595 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9596 }) {
9597 if record.active_session.is_none() {
9598 record.state = detached_record_state(record);
9599 }
9600 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9601 record.last_error = None;
9602 }
9603 record.updated_at_unix_ms = unix_time_ms();
9604 }
9605 previous
9606 };
9607 if let Err(error) = self.persist_state_locked() {
9608 self.workspaces
9609 .write()
9610 .unwrap_or_else(|poisoned| poisoned.into_inner())
9611 .remove(&workspace_id);
9612 self.session_records
9613 .lock()
9614 .unwrap_or_else(|poisoned| poisoned.into_inner())
9615 .records = previous_records;
9616 return Err(persistence_failure(error));
9617 }
9618 let restored = self
9619 .session_records
9620 .lock()
9621 .unwrap_or_else(|poisoned| poisoned.into_inner())
9622 .records
9623 .values()
9624 .filter(|record| record.workspace_id == workspace_id)
9625 .cloned()
9626 .collect::<Vec<_>>();
9627 drop(transaction);
9628 self.publish(NodeEvent::WorkspaceAdded {
9629 workspace: snapshot.clone(),
9630 });
9631 for record in restored {
9632 self.publish_record(record);
9633 }
9634 Ok(snapshot)
9635 }
9636
9637 async fn create_standalone_workspace(
9638 &self,
9639 workspace_id: WorkspaceId,
9640 root: String,
9641 initial_branch: Option<String>,
9642 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9643 validate_workspace_request_root(&root)?;
9644 validate_git_revision("initial branch", initial_branch.as_deref())?;
9645 let preflight_workspace_id = workspace_id.clone();
9646 let preflight_root = root.clone();
9647 let canonical_candidate = tokio::task::spawn_blocking(move || {
9648 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9649 })
9650 .await
9651 .map_err(|_| failure(
9652 NodeFailureCode::BackendOperationFailed,
9653 "standalone workspace root preflight task failed",
9654 ))?
9655 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9656 {
9657 let workspaces = self
9658 .workspaces
9659 .read()
9660 .unwrap_or_else(|poisoned| poisoned.into_inner());
9661 if workspaces.contains_key(&workspace_id) {
9662 return Err(failure(
9663 NodeFailureCode::DuplicateWorkspaceId,
9664 "workspace ID is already registered",
9665 ));
9666 }
9667 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9668 platform::roots_equal(existing_root, &canonical_candidate)
9669 }) {
9670 return Err(failure(
9671 NodeFailureCode::DuplicateWorkspaceRoot,
9672 &format!("workspace root is already registered as '{existing_id}'"),
9673 ));
9674 }
9675 }
9676 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9677 .await
9678 .map_err(standalone_workspace_failure)?;
9679 prepared
9680 .verify_for_registration()
9681 .await
9682 .map_err(standalone_workspace_failure)?;
9683 match self
9684 .register_workspace(workspace_id, prepared.root().to_owned())
9685 .await
9686 {
9687 Ok(workspace) => Ok(workspace),
9688 Err(registration_error) => {
9689 if prepared.compensate_registration_failure().await {
9690 Err(registration_error)
9691 } else {
9692 Err(failure(
9693 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9694 "standalone workspace initialization succeeded but registration failed; recovery is required",
9695 ))
9696 }
9697 }
9698 }
9699 }
9700
9701 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9702 let bindings = self
9703 .session_bindings
9704 .lock()
9705 .unwrap_or_else(|poisoned| poisoned.into_inner());
9706 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9707 return Err(failure(
9708 NodeFailureCode::WorkspaceBusy,
9709 "workspace retains a bound session; remove every session before unregistering it",
9710 ));
9711 }
9712 drop(bindings);
9713 let transaction = self
9714 .state_transaction
9715 .lock()
9716 .unwrap_or_else(|poisoned| poisoned.into_inner());
9717 let mut workspaces = self
9718 .workspaces
9719 .write()
9720 .unwrap_or_else(|poisoned| poisoned.into_inner());
9721 if !workspaces.contains_key(workspace_id) {
9722 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9723 }
9724 if workspaces.len() == 1 {
9725 return Err(failure(
9726 NodeFailureCode::LastWorkspace,
9727 "node must retain at least one workspace",
9728 ));
9729 }
9730 let removed_root = workspaces
9731 .remove(workspace_id)
9732 .expect("workspace presence was validated");
9733 drop(workspaces);
9734 let previous_records = {
9735 let mut records = self
9736 .session_records
9737 .lock()
9738 .unwrap_or_else(|poisoned| poisoned.into_inner());
9739 let previous = records.records.clone();
9740 for record in records
9741 .records
9742 .values_mut()
9743 .filter(|record| &record.workspace_id == workspace_id)
9744 {
9745 record.active_session = None;
9746 record.state = ManagedSessionState::Unavailable;
9747 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9748 record.updated_at_unix_ms = unix_time_ms();
9749 }
9750 previous
9751 };
9752 if let Err(error) = self.persist_state_locked() {
9753 self.workspaces
9754 .write()
9755 .unwrap_or_else(|poisoned| poisoned.into_inner())
9756 .insert(workspace_id.clone(), removed_root);
9757 self.session_records
9758 .lock()
9759 .unwrap_or_else(|poisoned| poisoned.into_inner())
9760 .records = previous_records;
9761 return Err(persistence_failure(error));
9762 }
9763 let unavailable = self
9764 .session_records
9765 .lock()
9766 .unwrap_or_else(|poisoned| poisoned.into_inner())
9767 .records
9768 .values()
9769 .filter(|record| &record.workspace_id == workspace_id)
9770 .cloned()
9771 .collect::<Vec<_>>();
9772 drop(transaction);
9773 self.publish(NodeEvent::WorkspaceRemoved {
9774 workspace_id: workspace_id.clone(),
9775 });
9776 for record in unavailable {
9777 self.publish_record(record);
9778 }
9779 Ok(())
9780 }
9781
9782 async fn create_worktree(
9783 &self,
9784 source_workspace_id: WorkspaceId,
9785 workspace_id: WorkspaceId,
9786 target_root: String,
9787 branch: String,
9788 base: Option<String>,
9789 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9790 self.require_worktree_service(&source_workspace_id)?;
9791 validate_workspace_request_root(&target_root)?;
9792 validate_git_revision("worktree base", base.as_deref())?;
9793 let source_root = self.workspace_root(&source_workspace_id)?;
9794 {
9795 let workspaces = self
9796 .workspaces
9797 .read()
9798 .unwrap_or_else(|poisoned| poisoned.into_inner());
9799 if workspaces.contains_key(&workspace_id) {
9800 return Err(failure(
9801 NodeFailureCode::DuplicateWorkspaceId,
9802 "workspace ID is already registered",
9803 ));
9804 }
9805 if let Some((existing_id, _)) = workspaces
9806 .iter()
9807 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9808 {
9809 return Err(failure(
9810 NodeFailureCode::DuplicateWorkspaceRoot,
9811 &format!("workspace root is already registered as '{existing_id}'"),
9812 ));
9813 }
9814 }
9815 let mut worktree = create_git_worktree(
9816 &source_root,
9817 &target_root,
9818 &branch,
9819 base.as_deref(),
9820 )
9821 .await
9822 .map_err(git_worktree_failure)?;
9823 let workspace = match self
9824 .register_workspace(workspace_id, worktree.path.clone())
9825 .await
9826 {
9827 Ok(workspace) => workspace,
9828 Err(registration_error) => {
9829 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9830 return Err(failure(
9831 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9832 "worktree registration failed and compensation requires recovery",
9833 ));
9834 }
9835 return Err(failure(
9836 registration_error.code,
9837 ®istration_error.message,
9838 ));
9839 }
9840 };
9841 worktree.workspace_id = Some(workspace.workspace_id.clone());
9842 Ok((protocol_worktree(worktree), workspace))
9843 }
9844
9845 async fn remove_worktree(
9846 &self,
9847 source_workspace_id: WorkspaceId,
9848 target_root: String,
9849 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9850 self.require_worktree_service(&source_workspace_id)?;
9851 validate_workspace_request_root(&target_root)?;
9852 let source_root = self.workspace_root(&source_workspace_id)?;
9853 let target_root = normalize_worktree_removal_target(&target_root)
9854 .map_err(git_worktree_failure)?;
9855 let target = list_git_worktrees(&source_root)
9856 .await
9857 .map_err(git_worktree_failure)?
9858 .into_iter()
9859 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9860 .ok_or_else(|| {
9861 failure(
9862 NodeFailureCode::WorktreeProtected,
9863 "refusing to remove a path that is not in Git's worktree listing",
9864 )
9865 })?;
9866 let registered_workspace_id = {
9867 let workspaces = self
9868 .workspaces
9869 .read()
9870 .unwrap_or_else(|poisoned| poisoned.into_inner());
9871 let matched = workspaces
9872 .iter()
9873 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9874 .map(|(workspace_id, _)| workspace_id.clone());
9875 if matched.is_some() && workspaces.len() == 1 {
9876 return Err(failure(
9877 NodeFailureCode::LastWorkspace,
9878 "node must retain at least one workspace",
9879 ));
9880 }
9881 matched
9882 };
9883 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9884 let bindings = self
9885 .session_bindings
9886 .lock()
9887 .unwrap_or_else(|poisoned| poisoned.into_inner());
9888 if bindings
9889 .values()
9890 .any(|binding| &binding.workspace_id == workspace_id)
9891 {
9892 return Err(failure(
9893 NodeFailureCode::WorkspaceBusy,
9894 "worktree workspace retains a bound session; remove every session first",
9895 ));
9896 }
9897 }
9898 remove_git_worktree(&source_root, &target.path)
9899 .await
9900 .map_err(git_worktree_failure)?;
9901 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9902 self.unregister_workspace(workspace_id)
9903 .map_err(|error| failure(error.code, &error.message))?;
9904 }
9905 Ok(registered_workspace_id)
9906 }
9907
9908 async fn cleanup_managed_worktree(
9909 &self,
9910 lease_id: &ManagedWorktreeLeaseId,
9911 explicit: bool,
9912 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9913 let lease = self.managed_worktrees.lock()
9914 .unwrap_or_else(|poisoned| poisoned.into_inner())
9915 .get(lease_id)
9916 .cloned()
9917 .ok_or_else(|| failure(
9918 NodeFailureCode::UnknownManagedWorktreeLease,
9919 "managed worktree lease does not exist",
9920 ))?;
9921 if lease.state == ManagedWorktreeLeaseState::Removed {
9922 return Ok(lease.snapshot());
9923 }
9924 if lease.has_holders() {
9925 return Err(failure(
9926 NodeFailureCode::ManagedWorktreeBusy,
9927 "managed worktree lease still has a session or durable-record holder",
9928 ));
9929 }
9930 if self
9931 .materializations
9932 .lock()
9933 .unwrap_or_else(|poisoned| poisoned.into_inner())
9934 .values()
9935 .any(|record| record.managed_lease_id() == Some(lease_id))
9936 {
9937 return Err(failure(
9938 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9939 "managed worktree cleanup is blocked by session environment ownership",
9940 ));
9941 }
9942 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9943 let snapshot = {
9944 let mut registry = self.managed_worktrees.lock()
9945 .unwrap_or_else(|poisoned| poisoned.into_inner());
9946 let current = registry.get_mut(lease_id)
9947 .expect("managed worktree lease remains present");
9948 current.state = ManagedWorktreeLeaseState::Retained;
9949 current.cleanup_failure = None;
9950 current.updated_at_unix_ms = unix_time_ms();
9951 current.snapshot()
9952 };
9953 if self.persist_state().is_err() {
9954 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9955 }
9956 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9957 return Ok(snapshot);
9958 }
9959 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9960 if profile.revision() != &lease.profile_revision {
9961 self.mark_managed_recovery_required(
9962 lease_id,
9963 ManagedWorktreeCleanupFailure::OwnershipConflict,
9964 );
9965 return Err(failure(
9966 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9967 "managed worktree profile revision changed",
9968 ));
9969 }
9970 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9971 self.mark_managed_recovery_required(
9972 lease_id,
9973 ManagedWorktreeCleanupFailure::OwnershipConflict,
9974 );
9975 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9976 })?;
9977 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9978 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9979 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9980 managed_git_worktree_failure(error)
9981 })?;
9982 let target = listed.iter()
9983 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9984 if let Some(target) = target {
9985 if target.is_main || target.is_bare || !exact_owned_worktree(
9986 &lease,
9987 &target.path,
9988 target.branch.as_deref(),
9989 &target.head,
9990 ) {
9991 self.mark_managed_recovery_required(
9992 lease_id,
9993 ManagedWorktreeCleanupFailure::OwnershipConflict,
9994 );
9995 return Err(failure(
9996 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9997 "Git worktree identity no longer matches the managed lease",
9998 ));
9999 }
10000 if target.prunable {
10001 self.mark_managed_cleanup_blocked(
10002 lease_id,
10003 ManagedWorktreeCleanupFailure::Prunable,
10004 );
10005 return Err(failure(
10006 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10007 "managed worktree is prunable and requires recovery",
10008 ));
10009 }
10010 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10011 let failure_kind = match error.kind {
10012 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10013 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10014 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10015 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10016 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10017 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10018 };
10019 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10020 self.mark_managed_recovery_required(lease_id, failure_kind);
10021 } else {
10022 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10023 }
10024 return Err(managed_git_worktree_failure(error));
10025 }
10026 } else {
10027 match std::fs::symlink_metadata(&lease.target_root) {
10028 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10029 Ok(_) => {
10030 self.mark_managed_recovery_required(
10031 lease_id,
10032 ManagedWorktreeCleanupFailure::OwnershipConflict,
10033 );
10034 return Err(failure(
10035 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10036 "managed target exists but is not owned by Git",
10037 ));
10038 }
10039 Err(_) => {
10040 self.mark_managed_cleanup_blocked(
10041 lease_id,
10042 ManagedWorktreeCleanupFailure::Backend,
10043 );
10044 return Err(failure(
10045 NodeFailureCode::BackendOperationFailed,
10046 "managed target identity could not be inspected",
10047 ));
10048 }
10049 }
10050 }
10051 let registered = self.workspaces.read()
10052 .unwrap_or_else(|poisoned| poisoned.into_inner())
10053 .contains_key(&lease.workspace_id);
10054 if registered {
10055 self.unregister_workspace(&lease.workspace_id)?;
10056 }
10057 let removed = {
10058 self.managed_worktrees.lock()
10059 .unwrap_or_else(|poisoned| poisoned.into_inner())
10060 .tombstone(lease_id, unix_time_ms())
10061 .expect("managed worktree lease remains present during cleanup")
10062 };
10063 self.persist_state().map_err(persistence_failure)?;
10064 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10065 Ok(removed.snapshot())
10066 }
10067
10068 fn mark_managed_cleanup_blocked(
10069 &self,
10070 lease_id: &ManagedWorktreeLeaseId,
10071 failure_kind: ManagedWorktreeCleanupFailure,
10072 ) {
10073 let snapshot = {
10074 let mut registry = self.managed_worktrees.lock()
10075 .unwrap_or_else(|poisoned| poisoned.into_inner());
10076 let Some(lease) = registry.get_mut(lease_id) else { return };
10077 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10078 lease.cleanup_failure = Some(failure_kind);
10079 lease.updated_at_unix_ms = unix_time_ms();
10080 lease.snapshot()
10081 };
10082 let _ = self.persist_state();
10083 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10084 }
10085
10086 fn require_worktree_service(
10087 &self,
10088 source_workspace_id: &WorkspaceId,
10089 ) -> Result<(), NodeFailure> {
10090 if self.managed_worktrees.lock()
10091 .unwrap_or_else(|poisoned| poisoned.into_inner())
10092 .lease_for_workspace(source_workspace_id)
10093 .is_some()
10094 {
10095 return Err(failure(
10096 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10097 "managed worktree targets are internal-only workspaces",
10098 ));
10099 }
10100 match self
10101 .worktree_service_modes
10102 .get(source_workspace_id)
10103 .copied()
10104 .unwrap_or(WorktreeServiceMode::Manual)
10105 {
10106 WorktreeServiceMode::Manual => Ok(()),
10107 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10108 NodeFailureCode::UnsupportedSpawnCapability,
10109 "Git worktree service mode is unavailable in this node build",
10110 )),
10111 }
10112 }
10113
10114 fn managed_reservation_conflict(
10115 &self,
10116 workspace_id: Option<&WorkspaceId>,
10117 root: Option<&str>,
10118 ) -> bool {
10119 self.managed_worktrees.lock()
10120 .unwrap_or_else(|poisoned| poisoned.into_inner())
10121 .active_records()
10122 .any(|lease| {
10123 workspace_id.is_some_and(|workspace_id| {
10124 workspace_id == &lease.source_workspace_id
10125 || workspace_id == &lease.workspace_id
10126 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10127 })
10128 }
10129
10130 fn reject_managed_reservation(
10131 &self,
10132 workspace_id: Option<&WorkspaceId>,
10133 root: Option<&str>,
10134 ) -> Result<(), NodeFailure> {
10135 if self.managed_reservation_conflict(workspace_id, root) {
10136 Err(failure(
10137 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10138 "managed worktree ownership may only be mutated through its lease cleanup",
10139 ))
10140 } else {
10141 Ok(())
10142 }
10143 }
10144
10145 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10146 self.validate_address(address)?;
10147 self.workspace_root(&address.workspace_id)
10148 }
10149
10150 fn require_session_runtime_policy(
10184 &self,
10185 address: &SessionAddress,
10186 requirement: ProviderRuntimeRequirement,
10187 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10188 let runtime_policy = {
10189 let bindings = self
10190 .session_bindings
10191 .lock()
10192 .unwrap_or_else(|poisoned| poisoned.into_inner());
10193 let binding = bindings
10194 .get(&address.session.instance_id)
10195 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10196 if binding.workspace_id != address.workspace_id {
10197 return Err(failure(
10198 NodeFailureCode::SessionWorkspaceMismatch,
10199 "session belongs to another workspace",
10200 ));
10201 }
10202 if binding.generation != address.session.generation {
10203 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10204 }
10205 binding.runtime_policy
10206 };
10207 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10208 let snapshot = self.internal_session_snapshot(address)?;
10209 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10210 && snapshot.provider.lead_activity != ProviderActivity::Idle
10211 {
10212 return Err(failure(
10213 NodeFailureCode::TurnInFlight,
10214 "session already has a turn in flight; wait for it to complete before sending another prompt",
10215 ));
10216 }
10217 }
10218 require_policy(runtime_policy, false, false, requirement)
10219 .map(|()| runtime_policy)
10220 .map_err(|_| failure(
10221 NodeFailureCode::UnsupportedCapability,
10222 "session runtime policy does not admit this semantic operation",
10223 ))
10224 }
10225
10226 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10227 let mut bindings = self
10228 .session_bindings
10229 .lock()
10230 .unwrap_or_else(|poisoned| poisoned.into_inner());
10231 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10232 binding.workspace_id == address.workspace_id
10233 && binding.generation == address.session.generation
10234 }) {
10235 let removed = bindings.remove(&address.session.instance_id);
10236 self.clear_terminal_frame_watermark(address);
10237 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10238 return removed;
10239 }
10240 None
10241 }
10242
10243 fn cleanup_session_owned_materialization(
10244 &self,
10245 address: &SessionAddress,
10246 binding: &SessionBinding,
10247 ) -> Result<(), NodeFailure> {
10248 let Some(id) = binding.materialization_id.as_ref() else {
10249 return Ok(());
10250 };
10251 let owner = self
10252 .materializations
10253 .lock()
10254 .unwrap_or_else(|poisoned| poisoned.into_inner())
10255 .get(id)
10256 .map(|record| record.owner().clone());
10257 match owner {
10258 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10259 Some(MaterializationOwner::Session {
10260 incarnation_id,
10261 instance_id,
10262 generation,
10263 }) if incarnation_id == self.incarnation_id
10264 && instance_id == address.session.instance_id
10265 && generation == address.session.generation =>
10266 {
10267 self.cleanup_materialization(id)
10268 }
10269 Some(MaterializationOwner::Session { .. }) => Err(failure(
10270 NodeFailureCode::BackendOperationFailed,
10271 "session environment ownership requires recovery",
10272 )),
10273 }
10274 }
10275
10276 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10277 let mut watermarks = self
10278 .terminal_frame_watermarks
10279 .lock()
10280 .unwrap_or_else(|poisoned| poisoned.into_inner());
10281 if watermarks
10282 .get(&address.session.instance_id)
10283 .is_some_and(|(current, _)| current == address)
10284 {
10285 watermarks.remove(&address.session.instance_id);
10286 }
10287 }
10288
10289 #[cfg(test)]
10290 fn address_for(
10291 &self,
10292 instance_id: AgentInstanceId,
10293 generation: SessionGeneration,
10294 ) -> Option<SessionAddress> {
10295 let binding = self.session_bindings
10296 .lock()
10297 .unwrap_or_else(|poisoned| poisoned.into_inner())
10298 .get(&instance_id)
10299 .cloned()?;
10300 (binding.generation == generation).then_some(SessionAddress {
10301 workspace_id: binding.workspace_id,
10302 session: SessionKey { instance_id, generation },
10303 })
10304 }
10305
10306 fn arm_resume(
10307 &self,
10308 address: &SessionAddress,
10309 command_id: CommandId,
10310 runtime_policy: ProviderRuntimePolicy,
10311 ) -> Result<(), NodeFailure> {
10312 let mut bindings = self
10313 .session_bindings
10314 .lock()
10315 .unwrap_or_else(|poisoned| poisoned.into_inner());
10316 let binding = bindings
10317 .get_mut(&address.session.instance_id)
10318 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10319 if binding.workspace_id != address.workspace_id {
10320 return Err(failure(
10321 NodeFailureCode::SessionWorkspaceMismatch,
10322 "session belongs to another workspace",
10323 ));
10324 }
10325 if binding.generation != address.session.generation {
10326 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10327 }
10328 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10329 Ok(())
10330 }
10331
10332 fn clear_armed_resume(&self, address: &SessionAddress) {
10333 let mut bindings = self
10334 .session_bindings
10335 .lock()
10336 .unwrap_or_else(|poisoned| poisoned.into_inner());
10337 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10338 if binding.workspace_id == address.workspace_id
10339 && binding.generation == address.session.generation
10340 {
10341 binding.pending_resume = None;
10342 }
10343 }
10344 }
10345
10346 fn publish_control(&self, event: ControlEvent) {
10347 let revokes_harness_mcp = matches!(
10348 &event.event,
10349 ControlEventKind::Exited { .. }
10350 | ControlEventKind::Failed { .. }
10351 | ControlEventKind::Removed
10352 );
10353 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10354 let mut bindings = self
10355 .session_bindings
10356 .lock()
10357 .unwrap_or_else(|poisoned| poisoned.into_inner());
10358 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10359 return;
10360 };
10361 let previous_address = SessionAddress {
10362 workspace_id: binding.workspace_id.clone(),
10363 session: SessionKey {
10364 instance_id: event.instance_id,
10365 generation: binding.generation,
10366 },
10367 };
10368 let mut resume_command_rejected = false;
10369 if binding.generation == event.generation {
10370 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10371 let rejected_resume = matches!(
10372 &event.event,
10373 ControlEventKind::CommandRejected { .. }
10374 ) && event.command_id == Some(pending_command_id);
10375 let resume_failed = matches!(
10376 &event.event,
10377 ControlEventKind::ResumeDenied { .. }
10378 | ControlEventKind::ResumeFailed { .. }
10379 );
10380 if pending_generation == binding.generation
10381 && (rejected_resume || resume_failed)
10382 {
10383 resume_command_rejected = rejected_resume;
10384 binding.pending_resume = None;
10385 }
10386 }
10387 } else {
10388 let expected = binding.generation.0.checked_add(1);
10389 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10390 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10391 generation == binding.generation
10392 })
10393 || expected != Some(event.generation.0)
10394 || !authorized
10395 {
10396 return;
10397 }
10398 let (_, _, runtime_policy) = binding
10399 .pending_resume
10400 .expect("authorized resume retains its pending runtime policy");
10401 binding.generation = event.generation;
10402 binding.runtime_policy = runtime_policy;
10403 binding.pending_resume = None;
10404 }
10405 (
10406 SessionAddress {
10407 workspace_id: binding.workspace_id.clone(),
10408 session: SessionKey {
10409 instance_id: event.instance_id,
10410 generation: event.generation,
10411 },
10412 },
10413 (previous_address.session.generation != event.generation)
10414 .then_some(previous_address),
10415 binding.record_id.clone(),
10416 binding.runtime_policy.provider_session_identity,
10417 resume_command_rejected,
10418 )
10419 };
10420 if let Some(previous_address) = previous_address.as_ref() {
10421 self.clear_terminal_frame_watermark(previous_address);
10422 }
10423 if let Some(record_id) = record_id {
10424 self.reconcile_managed_record(
10425 &record_id,
10426 &address,
10427 &event,
10428 identity_admitted,
10429 resume_command_rejected,
10430 );
10431 }
10432 match &event.event {
10440 ControlEventKind::Running { process_id } => {
10441 #[cfg(unix)]
10442 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10443 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10444 }
10445 tracing::info!(
10446 node_id = %self.node_id,
10447 workspace_id = %address.workspace_id,
10448 session = ?address.session,
10449 pid = ?process_id,
10450 "session process is running",
10451 );
10452 }
10453 ControlEventKind::Exited { exit_code, forced } => {
10454 #[cfg(unix)]
10455 if let Some(proxy) = self.signer_proxy.get().cloned() {
10456 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10457 if proxy.has_session(&key) {
10458 std::thread::spawn(move || proxy.release(&key));
10460 }
10461 }
10462 tracing::info!(
10463 node_id = %self.node_id,
10464 workspace_id = %address.workspace_id,
10465 session = ?address.session,
10466 exit_code = ?exit_code,
10467 forced = forced,
10468 "session process exited",
10469 );
10470 }
10471 ControlEventKind::Failed { message } => {
10472 tracing::warn!(
10473 node_id = %self.node_id,
10474 workspace_id = %address.workspace_id,
10475 session = ?address.session,
10476 cause = %message,
10477 "session failed",
10478 );
10479 }
10480 ControlEventKind::CommandRejected { message } => {
10481 tracing::warn!(
10482 node_id = %self.node_id,
10483 workspace_id = %address.workspace_id,
10484 session = ?address.session,
10485 cause = %message,
10486 "session command rejected",
10487 );
10488 }
10489 ControlEventKind::Removed => {
10490 tracing::info!(
10491 node_id = %self.node_id,
10492 workspace_id = %address.workspace_id,
10493 session = ?address.session,
10494 "session removed",
10495 );
10496 }
10497 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10498 tracing::info!(
10499 node_id = %self.node_id,
10500 workspace_id = %address.workspace_id,
10501 session = ?address.session,
10502 provider_session = ?provider_session,
10503 "session resume authorized",
10504 );
10505 }
10506 ControlEventKind::Resumed { session: provider_session, process_id } => {
10507 tracing::info!(
10508 node_id = %self.node_id,
10509 workspace_id = %address.workspace_id,
10510 session = ?address.session,
10511 provider_session = ?provider_session,
10512 pid = ?process_id,
10513 "session resumed",
10514 );
10515 }
10516 ControlEventKind::ResumeDenied { reason } => {
10517 tracing::warn!(
10518 node_id = %self.node_id,
10519 workspace_id = %address.workspace_id,
10520 session = ?address.session,
10521 cause = %reason,
10522 "session resume denied",
10523 );
10524 }
10525 ControlEventKind::ResumeFailed { message } => {
10526 tracing::warn!(
10527 node_id = %self.node_id,
10528 workspace_id = %address.workspace_id,
10529 session = ?address.session,
10530 cause = %message,
10531 "session resume failed",
10532 );
10533 }
10534 _ => {}
10535 }
10536 self.apply_pending_acp_options(&address, &event);
10537 let stream_chunk = agent_stream_chunk(&event);
10538 self.publish(NodeEvent::Control {
10539 address: address.clone(),
10540 event,
10541 });
10542 if let Some(chunk) = stream_chunk {
10543 self.publish_agent_stream_chunk(address.clone(), chunk);
10544 }
10545 if revokes_harness_mcp {
10546 SessionLifecycleHook::on_session_ended(self, &address);
10547 }
10548 }
10549
10550 fn reconcile_managed_record(
10551 &self,
10552 record_id: &SessionRecordId,
10553 address: &SessionAddress,
10554 event: &ControlEvent,
10555 identity_admitted: bool,
10556 resume_command_rejected: bool,
10557 ) {
10558 let _transaction = self
10559 .state_transaction
10560 .lock()
10561 .unwrap_or_else(|poisoned| poisoned.into_inner());
10562 let observed_identity = match &event.event {
10563 ControlEventKind::ProviderEvent {
10564 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10565 ..
10566 } if identity_admitted => Some(identity.clone()),
10567 _ => None,
10568 };
10569 let identity_was_observed = observed_identity.is_some();
10570 let replacement_id = observed_identity
10571 .as_ref()
10572 .and_then(|_| self.allocate_record_id().ok());
10573 let mut upserts = Vec::new();
10574 let mut removals = Vec::new();
10575 let mut rebind = None;
10576 let original_records;
10577 let original_materializations = self
10578 .materializations
10579 .lock()
10580 .unwrap_or_else(|poisoned| poisoned.into_inner())
10581 .clone();
10582 {
10583 let mut records = self
10584 .session_records
10585 .lock()
10586 .unwrap_or_else(|poisoned| poisoned.into_inner());
10587 original_records = records.records.clone();
10588 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10589 return;
10590 };
10591 if let Some(identity) = observed_identity {
10592 let existing_id = records
10593 .records
10594 .values()
10595 .find(|record| {
10596 record.record_id != *record_id
10597 && record.provider_session.as_ref().is_some_and(|record_identity| {
10598 session_registry::same_provider_session(
10599 &record.provider,
10600 record_identity,
10601 ¤t_snapshot.provider,
10602 &identity,
10603 )
10604 })
10605 })
10606 .map(|record| record.record_id.clone());
10607 if let Some(existing_id) = existing_id {
10608 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10609 record.mode == current_snapshot.mode
10610 && record.workspace_id == current_snapshot.workspace_id
10611 && record.environment_profile == current_snapshot.environment_profile
10612 && record.bundle == current_snapshot.bundle
10613 && record.context_id == current_snapshot.context_id
10614 && record.context == current_snapshot.context
10615 && record
10616 .canonical_root
10617 .as_utf8()
10618 .zip(current_snapshot.canonical_root.as_utf8())
10619 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10620 });
10621 if !scope_matches {
10622 if let Some(current) = records.records.get_mut(record_id) {
10623 current.state = ManagedSessionState::Unavailable;
10624 current.last_error =
10625 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10626 current.updated_at_unix_ms = unix_time_ms();
10627 upserts.push(current.clone());
10628 }
10629 } else {
10630 let existing_is_active = records
10631 .records
10632 .get(&existing_id)
10633 .is_some_and(|record| record.active_session.is_some());
10634 if existing_is_active {
10635 if let Some(current) = records.records.get_mut(record_id) {
10636 current.state = ManagedSessionState::Unavailable;
10637 current.last_error =
10638 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10639 current.updated_at_unix_ms = unix_time_ms();
10640 upserts.push(current.clone());
10641 }
10642 } else {
10643 if current_snapshot.provider_session.is_some() {
10644 if let Some(current) = records.records.get_mut(record_id) {
10645 current.active_session = None;
10646 current.state = ManagedSessionState::Dormant;
10647 current.updated_at_unix_ms = unix_time_ms();
10648 upserts.push(current.clone());
10649 }
10650 } else {
10651 records.records.remove(record_id);
10652 removals.push(record_id.clone());
10653 }
10654 if let Some(existing) = records.records.get_mut(&existing_id) {
10655 existing.provider_session = Some(identity.clone());
10656 existing.active_session = Some(address.clone());
10657 existing.state = ManagedSessionState::Live;
10658 existing.updated_at_unix_ms = unix_time_ms();
10659 existing.last_error = None;
10660 upserts.push(existing.clone());
10661 }
10662 rebind = Some(existing_id);
10663 }
10664 }
10665 } else if current_snapshot
10666 .provider_session
10667 .as_ref()
10668 .is_some_and(|current| {
10669 !session_registry::same_provider_session(
10670 ¤t_snapshot.provider,
10671 current,
10672 ¤t_snapshot.provider,
10673 &identity,
10674 )
10675 })
10676 {
10677 if let Some(current) = records.records.get_mut(record_id) {
10678 current.active_session = None;
10679 if current.bundle.is_some() || current.context.is_some() {
10680 let had_bundle = current.bundle.is_some();
10681 current.provider_session = None;
10682 current.bundle = None;
10683 current.context_id = None;
10684 current.context = None;
10685 current.state = ManagedSessionState::Unavailable;
10686 current.last_error = Some(if had_bundle {
10687 "bundle-unavailable".to_owned()
10688 } else {
10689 "context-unavailable".to_owned()
10690 });
10691 } else {
10692 current.state = ManagedSessionState::Dormant;
10693 }
10694 current.updated_at_unix_ms = unix_time_ms();
10695 upserts.push(current.clone());
10696 }
10697 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10698 if let Some(current) = records.records.get_mut(record_id) {
10699 current.state = ManagedSessionState::Unavailable;
10700 current.last_error =
10701 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10702 current.updated_at_unix_ms = unix_time_ms();
10703 upserts.push(current.clone());
10704 }
10705 } else if let Some(new_record_id) = replacement_id {
10706 let now = unix_time_ms();
10707 let replacement = ManagedSessionRecord {
10708 record_id: new_record_id.clone(),
10709 display_name: format!(
10710 "{} #{}",
10711 current_snapshot.provider.as_str(),
10712 records
10713 .records
10714 .values()
10715 .filter(|record| {
10716 record.provider == current_snapshot.provider
10717 })
10718 .count()
10719 .saturating_add(1),
10720 ),
10721 provider: current_snapshot.provider.clone(),
10722 mode: current_snapshot.mode,
10723 state: ManagedSessionState::Live,
10724 workspace_id: current_snapshot.workspace_id,
10725 canonical_root: current_snapshot.canonical_root,
10726 provider_session: Some(identity),
10727 active_session: Some(address.clone()),
10728 environment_profile: current_snapshot.environment_profile,
10729 bundle: current_snapshot.bundle,
10730 context_id: current_snapshot.context_id,
10731 context: current_snapshot.context,
10732 exported_context: None,
10738 task_binding: current_snapshot.task_binding,
10739 created_at_unix_ms: now,
10740 updated_at_unix_ms: now,
10741 last_error: None,
10742 };
10743 records
10744 .records
10745 .insert(new_record_id.clone(), replacement.clone());
10746 upserts.push(replacement);
10747 rebind = Some(new_record_id);
10748 } else if let Some(current) = records.records.get_mut(record_id) {
10749 current.state = ManagedSessionState::Unavailable;
10750 current.last_error =
10751 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10752 upserts.push(current.clone());
10753 }
10754 } else if let Some(current) = records.records.get_mut(record_id) {
10755 current.provider_session = Some(identity);
10756 current.active_session = Some(address.clone());
10757 current.state = ManagedSessionState::Live;
10758 current.updated_at_unix_ms = unix_time_ms();
10759 current.last_error = None;
10760 upserts.push(current.clone());
10761 }
10762 } else if let Some(current) = records.records.get_mut(record_id) {
10763 if resume_command_rejected {
10764 current.active_session = None;
10765 current.state = detached_record_state(current);
10766 current.updated_at_unix_ms = unix_time_ms();
10767 current.last_error =
10768 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10769 upserts.push(current.clone());
10770 } else {
10771 match &event.event {
10772 ControlEventKind::Running { .. }
10773 | ControlEventKind::ResumeAuthorized { .. }
10774 | ControlEventKind::Resumed { .. } => {
10775 current.active_session = Some(address.clone());
10776 current.state = active_record_state(
10777 identity_admitted,
10778 current.provider_session.is_some(),
10779 );
10780 current.updated_at_unix_ms = unix_time_ms();
10781 current.last_error = None;
10782 upserts.push(current.clone());
10783 }
10784 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10785 current.active_session = None;
10786 current.state = detached_record_state(current);
10787 current.updated_at_unix_ms = unix_time_ms();
10788 upserts.push(current.clone());
10789 }
10790 ControlEventKind::Failed { message }
10791 | ControlEventKind::ResumeFailed { message } => {
10792 current.active_session = None;
10793 current.state = detached_record_state(current);
10794 current.updated_at_unix_ms = unix_time_ms();
10795 current.last_error = Some(
10796 session_registry::sanitized_record_error_summary(message),
10797 );
10798 upserts.push(current.clone());
10799 }
10800 ControlEventKind::ResumeDenied { reason } => {
10801 current.active_session = None;
10802 current.state = detached_record_state(current);
10803 current.updated_at_unix_ms = unix_time_ms();
10804 current.last_error = Some(
10805 session_registry::sanitized_record_error_summary(reason),
10806 );
10807 upserts.push(current.clone());
10808 }
10809 _ => {}
10810 }
10811 }
10812 }
10813 }
10814 let materialization_owner_record = identity_was_observed.then(|| {
10815 rebind.clone().unwrap_or_else(|| record_id.clone())
10816 }).and_then(|candidate| {
10817 self.session_records
10818 .lock()
10819 .unwrap_or_else(|poisoned| poisoned.into_inner())
10820 .records
10821 .get(&candidate)
10822 .filter(|record| {
10823 record.provider_session.is_some()
10824 && record.active_session.as_ref() == Some(address)
10825 })
10826 .map(|_| candidate)
10827 });
10828 if let Some(owner_record_id) = materialization_owner_record {
10829 let materialization_id = self
10830 .session_bindings
10831 .lock()
10832 .unwrap_or_else(|poisoned| poisoned.into_inner())
10833 .get(&address.session.instance_id)
10834 .and_then(|binding| binding.materialization_id.clone());
10835 if let Some(materialization_id) = materialization_id {
10836 let mut transfer_failed = false;
10837 {
10838 let mut materializations = self
10839 .materializations
10840 .lock()
10841 .unwrap_or_else(|poisoned| poisoned.into_inner());
10842 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10843 match ownership.owner() {
10844 MaterializationOwner::Session {
10845 incarnation_id,
10846 instance_id,
10847 generation,
10848 } if *incarnation_id == self.incarnation_id
10849 && *instance_id == address.session.instance_id
10850 && *generation == address.session.generation =>
10851 {
10852 transfer_failed = ownership
10853 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10854 .is_err();
10855 }
10856 MaterializationOwner::Record { record_id }
10857 if record_id == &owner_record_id => {}
10858 MaterializationOwner::Record { record_id } => {
10859 let expected = record_id.clone();
10860 transfer_failed = ownership
10861 .transfer_record_owner(
10862 &expected,
10863 owner_record_id.clone(),
10864 unix_time_ms(),
10865 )
10866 .is_err();
10867 }
10868 _ => transfer_failed = true,
10869 }
10870 if transfer_failed {
10871 let _ = ownership.mark_recovery_required(unix_time_ms());
10872 }
10873 } else {
10874 transfer_failed = true;
10875 }
10876 }
10877 if transfer_failed {
10878 if let Some(record) = self
10879 .session_records
10880 .lock()
10881 .unwrap_or_else(|poisoned| poisoned.into_inner())
10882 .records
10883 .get_mut(&owner_record_id)
10884 {
10885 record.state = ManagedSessionState::Unavailable;
10886 record.last_error = Some("environment-profile-unavailable".to_owned());
10887 record.updated_at_unix_ms = unix_time_ms();
10888 upserts.push(record.clone());
10889 }
10890 }
10891 }
10892 }
10893 if let Some(new_record_id) = rebind.clone() {
10894 if let Some(binding) = self
10895 .session_bindings
10896 .lock()
10897 .unwrap_or_else(|poisoned| poisoned.into_inner())
10898 .get_mut(&address.session.instance_id)
10899 {
10900 binding.record_id = Some(new_record_id);
10901 }
10902 }
10903 if upserts.is_empty() && removals.is_empty() {
10904 return;
10905 }
10906 if self.persist_state_locked().is_err() {
10907 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10908 let mut records = self
10909 .session_records
10910 .lock()
10911 .unwrap_or_else(|poisoned| poisoned.into_inner());
10912 records.records = original_records;
10913 *self
10914 .materializations
10915 .lock()
10916 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10917 if let Some(current) = records.records.get_mut(record_id) {
10918 current.state = ManagedSessionState::Unavailable;
10919 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10920 current.updated_at_unix_ms = unix_time_ms();
10921 upserts = vec![current.clone()];
10922 } else {
10923 upserts.clear();
10924 }
10925 removals.clear();
10926 drop(records);
10927 if let Some(binding) = self
10928 .session_bindings
10929 .lock()
10930 .unwrap_or_else(|poisoned| poisoned.into_inner())
10931 .get_mut(&address.session.instance_id)
10932 {
10933 binding.record_id = Some(record_id.clone());
10934 }
10935 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10936 }
10937 for record in upserts {
10938 self.publish_record(record);
10939 }
10940 for record_id in removals {
10941 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10942 }
10943 }
10944
10945 fn snapshot(&self) -> NodeSnapshot {
10946 let control = self.handle.snapshot();
10947 let bindings = self
10948 .session_bindings
10949 .lock()
10950 .unwrap_or_else(|poisoned| poisoned.into_inner())
10951 .clone();
10952 let workspace_roots = self
10953 .workspaces
10954 .read()
10955 .unwrap_or_else(|poisoned| poisoned.into_inner())
10956 .clone();
10957 let environment_profiles = self
10958 .environment_profiles
10959 .read()
10960 .unwrap_or_else(|poisoned| poisoned.into_inner());
10961 let mut workspaces = workspace_roots
10962 .iter()
10963 .map(|(workspace_id, canonical_root)| {
10964 (
10965 workspace_id.clone(),
10966 WorkspaceSnapshot {
10967 workspace_id: workspace_id.clone(),
10968 canonical_root: opaque_windows_path(canonical_root.clone()),
10969 sessions: Vec::new(),
10970 worktree_service_mode: Some(
10971 self.worktree_service_modes
10972 .get(workspace_id)
10973 .copied()
10974 .unwrap_or(WorktreeServiceMode::Manual),
10975 ),
10976 managed_worktree_profiles: Some(WorktreeProfileInventory {
10977 profiles: self
10978 .managed_worktree_profiles
10979 .get(workspace_id)
10980 .into_iter()
10981 .flat_map(BTreeMap::values)
10982 .map(|profile| ManagedWorktreeProfileSummary {
10983 id: profile.profile_id().clone(),
10984 revision: profile.revision().clone(),
10985 retention: profile.retention(),
10986 })
10987 .collect(),
10988 }),
10989 },
10990 )
10991 })
10992 .collect::<BTreeMap<_, _>>();
10993 let mut agent_progress = Vec::new();
10994 for session in &control.sessions {
10995 let Some(binding) = bindings.get(&session.instance_id) else {
10996 continue;
10997 };
10998 if binding.generation != session.generation {
10999 continue;
11000 }
11001 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11002 let address = SessionAddress {
11003 workspace_id: binding.workspace_id.clone(),
11004 session: SessionKey {
11005 instance_id: session.instance_id,
11006 generation: session.generation,
11007 },
11008 };
11009 if let Some(progress) =
11010 agent_progress_from_provider_snapshot(address, &session.provider)
11011 {
11012 agent_progress.push(progress);
11013 }
11014 workspace.sessions.push(session.clone());
11015 }
11016 }
11017 NodeSnapshot {
11018 node_id: self.node_id.clone(),
11019 enabled_providers: self.enabled_providers.clone(),
11020 provider_runtime_statuses: {
11021 let updates = self
11022 .provider_runtime_status_updates
11023 .read()
11024 .unwrap_or_else(|poisoned| poisoned.into_inner());
11025 ProviderRuntimeStatuses::new(
11026 self.provider_runtime_statuses
11027 .iter()
11028 .filter(|status| !updates.contains_key(status.provider()))
11029 .cloned()
11030 .chain(updates.values().cloned()),
11031 )
11032 .expect("refreshed provider runtime inventory remains bounded and unique")
11033 },
11034 workspaces: workspaces.into_values().collect(),
11035 session_records: self
11036 .session_records
11037 .lock()
11038 .unwrap_or_else(|poisoned| poisoned.into_inner())
11039 .records
11040 .values()
11041 .cloned()
11042 .collect(),
11043 managed_worktrees: self.managed_worktrees
11044 .lock()
11045 .unwrap_or_else(|poisoned| poisoned.into_inner())
11046 .snapshots(),
11047 launch_inventory: Some(LaunchInventory {
11048 spawn_profiles: Some(
11049 self.spawn_profiles
11050 .iter()
11051 .filter_map(|profile| {
11052 resolved_spawn_profile_summary(profile, &environment_profiles)
11053 })
11054 .collect(),
11055 ),
11056 bundles: Some(
11057 self.bundle_catalog
11058 .read()
11059 .unwrap_or_else(|poisoned| poisoned.into_inner())
11060 .iter()
11061 .map(NodeBundle::receipt)
11062 .collect(),
11063 ),
11064 network_allowlists: {
11067 let catalog = self
11068 .network_allowlist_catalog
11069 .read()
11070 .unwrap_or_else(|poisoned| poisoned.into_inner());
11071 if catalog.is_empty() {
11072 None
11073 } else {
11074 Some(catalog.ids().cloned().collect())
11075 }
11076 },
11077 }),
11078 agent_progress,
11079 }
11080 }
11081
11082 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11083 assert!(
11084 !matches!(
11085 &event,
11086 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11087 ),
11088 "terminal frame and agent stream events must use their own dedicated live channel",
11089 );
11090 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11091 let sequence = history
11092 .last_sequence
11093 .checked_add(1)
11094 .expect("node event sequence exhausted");
11095 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11096 if let Some(evicted) = history.events.pop_front() {
11097 history.replay_floor_sequence = evicted
11098 .sequence
11099 .checked_add(1)
11100 .expect("node replay floor sequence exhausted");
11101 history.removed_record_providers.remove(&evicted.sequence);
11102 }
11103 }
11104 match &event {
11105 NodeEvent::HarnessMcpReadCall { .. } => {
11106 panic!("transient harness MCP calls must not enter durable event history")
11107 }
11108 NodeEvent::SessionRecordUpserted { record } => {
11109 history
11110 .record_providers
11111 .insert(record.record_id.clone(), record.provider.clone());
11112 }
11113 NodeEvent::SessionRecordRemoved { record_id } => {
11114 if let Some(provider) = history.record_providers.remove(record_id) {
11115 history.removed_record_providers.insert(sequence, provider);
11116 }
11117 }
11118 NodeEvent::ManagedWorktreeUpserted { .. }
11119 | NodeEvent::ManagedWorktreeRemoved { .. }
11120 | NodeEvent::Control { .. }
11121 | NodeEvent::ControllerChanged { .. }
11122 | NodeEvent::SessionRecordHistorySummarized { .. }
11123 | NodeEvent::WorkspaceAdded { .. }
11124 | NodeEvent::WorkspaceRemoved { .. }
11125 | NodeEvent::ResyncRequired { .. }
11126 | NodeEvent::AgentStream { .. }
11127 | NodeEvent::TerminalFrame { .. } => {}
11128 }
11129 let envelope = NodeEventEnvelope { sequence, event };
11130 history.events.push_back(envelope.clone());
11131 history.last_sequence = sequence;
11132 let _ = self.event_tx.send(envelope.clone());
11133 drop(history);
11134 envelope
11135 }
11136
11137 fn publish_transient(&self, event: NodeEvent) -> bool {
11148 assert!(event.requires_harness_mcp_proxy_capability());
11149 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11150 let envelope = NodeEventEnvelope { sequence: 0, event };
11151 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11152 Ok(_) => true,
11153 Err(broadcast::error::SendError(undelivered)) => {
11154 if let Some(NodeEventEnvelope {
11155 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11156 ..
11157 }) = undelivered.first() {
11158 tracing::warn!(
11164 call_id = call_id.as_str(),
11165 content_type = ?request.content_type,
11166 cause = "no-subscribers",
11167 "harness MCP proxy event could not be published",
11168 );
11169 }
11170 false
11171 }
11172 }
11173 }
11174
11175 fn publish_terminal_frames(&self) {
11176 let snapshot = self.handle.snapshot();
11177 let bindings = self
11178 .session_bindings
11179 .lock()
11180 .unwrap_or_else(|poisoned| poisoned.into_inner());
11181 let candidates = snapshot
11182 .sessions
11183 .iter()
11184 .filter_map(|session| {
11185 let binding = bindings.get(&session.instance_id)?;
11186 if binding.generation != session.generation {
11187 return None;
11188 }
11189 let frame = session.terminal_frame.clone()?;
11190 crate::session_signals::feed(
11191 binding.workspace_id.as_str(),
11192 session.instance_id.0,
11193 session.agent_id.as_str(),
11194 &frame.contents,
11195 );
11196 Some((
11197 SessionAddress {
11198 workspace_id: binding.workspace_id.clone(),
11199 session: SessionKey {
11200 instance_id: session.instance_id,
11201 generation: session.generation,
11202 },
11203 },
11204 frame,
11205 ))
11206 })
11207 .collect();
11208 drop(bindings);
11209 self.publish_terminal_frame_candidates(candidates);
11210 }
11211
11212 fn publish_terminal_frame_candidates(
11213 &self,
11214 candidates: Vec<(SessionAddress, TerminalFrame)>,
11215 ) {
11216 let mut watermarks = self
11217 .terminal_frame_watermarks
11218 .lock()
11219 .unwrap_or_else(|poisoned| poisoned.into_inner());
11220 let mut events = Vec::with_capacity(candidates.len());
11221 for (address, frame) in candidates {
11222 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11223 let advanced = watermarks
11224 .get(&address.session.instance_id)
11225 .map_or(true, |(current_address, sequence)| {
11226 current_address != &address || frame.sequence > *sequence
11227 });
11228 if !advanced {
11229 continue;
11230 }
11231 if is_first_frame {
11232 if self.controller_state().is_none() {
11246 tracing::warn!(
11247 node_id = %self.node_id,
11248 workspace_id = %address.workspace_id,
11249 session = ?address.session,
11250 terminal_size = ?frame.size,
11251 "session producing output with no controller attached",
11252 );
11253 } else {
11254 tracing::info!(
11255 node_id = %self.node_id,
11256 workspace_id = %address.workspace_id,
11257 session = ?address.session,
11258 terminal_size = ?frame.size,
11259 "session produced its first output",
11260 );
11261 }
11262 }
11263 watermarks.insert(
11264 address.session.instance_id,
11265 (address.clone(), frame.sequence),
11266 );
11267 if let Some((at, reported)) = self
11268 .pty_input_marks
11269 .lock()
11270 .unwrap_or_else(|poisoned| poisoned.into_inner())
11271 .get_mut(&address.session.instance_id)
11272 {
11273 if !*reported && at.elapsed() < Duration::from_secs(5) {
11275 *reported = true;
11276 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");
11277 }
11278 }
11279 events.push(NodeEvent::TerminalFrame { address, frame });
11280 }
11281 drop(watermarks);
11282 if events.is_empty() {
11283 return;
11284 }
11285 let mut history = self
11286 .history
11287 .lock()
11288 .unwrap_or_else(|poisoned| poisoned.into_inner());
11289 let mut envelopes = Vec::with_capacity(events.len());
11290 for event in events {
11291 let sequence = history
11292 .last_sequence
11293 .checked_add(1)
11294 .expect("node event sequence exhausted");
11295 history.last_sequence = sequence;
11296 envelopes.push(NodeEventEnvelope { sequence, event });
11297 }
11298 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11299 drop(history);
11300 }
11301
11302 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11311 let mut history = self
11312 .history
11313 .lock()
11314 .unwrap_or_else(|poisoned| poisoned.into_inner());
11315 let sequence = history
11316 .last_sequence
11317 .checked_add(1)
11318 .expect("node event sequence exhausted");
11319 history.last_sequence = sequence;
11320 drop(history);
11321 tracing::debug!(
11329 source_sequence = chunk.source_sequence,
11330 kind = agent_stream_chunk_kind_label(&chunk.kind),
11331 envelope_sequence = sequence,
11332 "publishing agent stream chunk",
11333 );
11334 let envelope = NodeEventEnvelope {
11335 sequence,
11336 event: NodeEvent::AgentStream { address, chunk },
11337 };
11338 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11339 }
11340
11341 fn current_sequence(&self) -> u64 {
11342 self.history
11343 .lock()
11344 .unwrap_or_else(|poisoned| poisoned.into_inner())
11345 .last_sequence
11346 }
11347
11348 fn controller_state(&self) -> Option<ControllerState> {
11349 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11350 let lease = controller.as_ref().copied()?;
11351 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11352 if remaining.is_zero() {
11353 *controller = None;
11354 return None;
11355 }
11356 Some(ControllerState {
11357 connection_id: lease.connection_id,
11358 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11359 })
11360 }
11361
11362 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11363 if role != ClientRole::Operator {
11364 tracing::warn!(
11365 node_id = %self.node_id,
11366 connection_id,
11367 "controller lease rejected: observer connections cannot acquire control",
11368 );
11369 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11370 }
11371 let lease_ms = if lease_ms == 0 {
11372 DEFAULT_CONTROLLER_LEASE_MS
11373 } else {
11374 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11375 };
11376 let now = Instant::now();
11377 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11378 if let Some(active) = controller.as_ref() {
11379 if active.connection_id != connection_id && active.expires_at > now {
11380 tracing::warn!(
11381 node_id = %self.node_id,
11382 connection_id,
11383 holder_connection_id = active.connection_id,
11384 "controller lease rejected: another operator holds it",
11385 );
11386 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11387 }
11388 }
11389 let is_new_attach = match controller.as_ref() {
11396 Some(active) => active.connection_id != connection_id,
11397 None => true,
11398 };
11399 *controller = Some(ControllerLease {
11400 connection_id,
11401 expires_at: now + Duration::from_millis(lease_ms),
11402 });
11403 drop(controller);
11404 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11405 if is_new_attach {
11406 tracing::info!(
11407 node_id = %self.node_id,
11408 connection_id,
11409 lease_ms,
11410 "controller lease acquired",
11411 );
11412 } else {
11413 tracing::debug!(
11414 node_id = %self.node_id,
11415 connection_id,
11416 lease_ms,
11417 "controller lease renewed",
11418 );
11419 }
11420 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11421 Ok(state)
11422 }
11423
11424 fn release_controller(&self, connection_id: u64) -> bool {
11425 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11426 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11427 if released {
11428 *controller = None;
11429 }
11430 drop(controller);
11431 if released {
11432 tracing::info!(
11433 node_id = %self.node_id,
11434 connection_id,
11435 "controller lease released",
11436 );
11437 self.publish(NodeEvent::ControllerChanged { controller: None });
11438 }
11439 released
11440 }
11441
11442 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11451 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11452 return;
11453 };
11454 let id = address.session.instance_id;
11455 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11456 return;
11457 };
11458 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11459 .iter()
11460 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11461 .collect();
11462 let (writes, set_model, effort_gap) =
11463 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11464 if set_model {
11465 if let Some(model_id) = model.clone() {
11466 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11467 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11468 }
11469 }
11470 }
11471 for (option_id, value_json) in writes {
11472 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11473 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11474 }
11475 }
11476 if effort_gap {
11477 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11478 }
11479 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11480 }
11481
11482 fn mark_spawn_provisional(&self, address: SessionAddress) {
11483 let id = address.session.instance_id;
11484 self.provisional_spawns
11485 .lock()
11486 .unwrap_or_else(|poisoned| poisoned.into_inner())
11487 .insert(id, (address, Instant::now()));
11488 tracing::info!(
11489 node_id = %self.node_id,
11490 instance_id = id.0,
11491 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11492 "draft spawn; waiting for HQ view",
11493 );
11494 }
11495
11496 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11497 let removed = self
11498 .provisional_spawns
11499 .lock()
11500 .unwrap_or_else(|poisoned| poisoned.into_inner())
11501 .remove(&instance_id);
11502 if removed.is_some() {
11503 tracing::info!(
11504 node_id = %self.node_id,
11505 instance_id = instance_id.0,
11506 "draft spawn claimed by HQ view",
11507 );
11508 }
11509 }
11510
11511 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11512 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11513 let mut map = self
11514 .provisional_spawns
11515 .lock()
11516 .unwrap_or_else(|poisoned| poisoned.into_inner());
11517 let due: Vec<AgentInstanceId> = map
11518 .iter()
11519 .filter(|(_, (_, at))| at.elapsed() >= grace)
11520 .map(|(id, _)| *id)
11521 .collect();
11522 due.into_iter()
11523 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11524 .collect()
11525 }
11526
11527 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11528 let instance_id = address.session.instance_id;
11529 tracing::warn!(
11530 node_id = %self.node_id,
11531 instance_id = instance_id.0,
11532 workspace = %address.workspace_id,
11533 reason = "attach_never_arrived",
11534 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11535 "draft spawn never viewed; stopping PTY",
11536 );
11537 let _ = self.dispatch(ControlCommand::Stop {
11538 instance_id,
11539 force: true,
11540 });
11541 let _ = self.remove_session(&address).await;
11542 }
11543
11544 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11545 if role != ClientRole::Operator {
11546 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11547 }
11548 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11549 let now = Instant::now();
11550 match controller.as_ref().copied() {
11551 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11552 Some(active) if active.expires_at <= now => {
11553 *controller = None;
11554 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11555 }
11556 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11557 }
11558 }
11559
11560 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11561 if !self
11562 .workspaces
11563 .read()
11564 .unwrap_or_else(|poisoned| poisoned.into_inner())
11565 .contains_key(&address.workspace_id)
11566 {
11567 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11568 }
11569 let binding = self
11570 .session_bindings
11571 .lock()
11572 .unwrap_or_else(|poisoned| poisoned.into_inner())
11573 .get(&address.session.instance_id)
11574 .cloned()
11575 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11576 if binding.workspace_id != address.workspace_id {
11577 return Err(failure(
11578 NodeFailureCode::SessionWorkspaceMismatch,
11579 "session belongs to another workspace",
11580 ));
11581 }
11582 if binding.generation != address.session.generation {
11583 return Err(failure(
11584 NodeFailureCode::StaleGeneration,
11585 &format!(
11586 "stale session generation: expected {}, received {}",
11587 binding.generation.0,
11588 address.session.generation.0,
11589 ),
11590 ));
11591 }
11592 let snapshot = self.handle.snapshot();
11593 let Some(current) = snapshot
11594 .sessions
11595 .iter()
11596 .find(|item| item.instance_id == address.session.instance_id)
11597 else {
11598 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11599 };
11600 if current.generation != address.session.generation {
11601 return Err(failure(
11602 NodeFailureCode::StaleGeneration,
11603 &format!(
11604 "stale session generation: expected {}, received {}",
11605 current.generation.0,
11606 address.session.generation.0,
11607 ),
11608 ));
11609 }
11610 Ok(current.agent_id.clone())
11611 }
11612
11613 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11614 self.dispatch_envelope(self.prepare_command(command))
11615 }
11616
11617 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11618 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11619 CommandEnvelope {
11620 id: CommandId(id),
11621 command,
11622 }
11623 }
11624
11625 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11626 let command_id = envelope.id;
11627 self.handle
11628 .dispatch(envelope)
11629 .map_err(|error| match error {
11630 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11631 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11632 })?;
11633 Ok(command_id)
11634 }
11635
11636 fn record_rejected_command(
11645 &self,
11646 command_id: CommandId,
11647 message: String,
11648 unsupported_transport: bool,
11649 ) {
11650 let now = Instant::now();
11651 let mut reasons = self.rejected_command_reasons.lock()
11652 .unwrap_or_else(|poisoned| poisoned.into_inner());
11653 reasons.retain(|_, reason| reason.expires_at > now);
11654 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11655 if let Some(&oldest) = reasons.keys().next() {
11656 reasons.remove(&oldest);
11657 }
11658 }
11659 reasons.insert(
11660 command_id,
11661 RejectedCommandReason {
11662 message,
11663 unsupported_transport,
11664 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11665 },
11666 );
11667 }
11668
11669 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11676 let now = Instant::now();
11677 let mut reasons = self.rejected_command_reasons.lock()
11678 .unwrap_or_else(|poisoned| poisoned.into_inner());
11679 match reasons.remove(&command_id) {
11680 Some(reason) if reason.expires_at > now => Some(reason),
11681 _ => None,
11682 }
11683 }
11684
11685 async fn dispatch_bounded(
11686 &self,
11687 command: ControlCommand,
11688 timeout_duration: Duration,
11689 ) -> Result<CommandId, NodeFailure> {
11690 let deadline = Instant::now() + timeout_duration;
11691 loop {
11692 match self.dispatch(command.clone()) {
11693 Ok(command_id) => return Ok(command_id),
11694 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11695 sleep(Duration::from_millis(2)).await;
11696 }
11697 Err(error) => return Err(error),
11698 }
11699 }
11700 }
11701
11702 fn internal_session_snapshot(
11703 &self,
11704 address: &SessionAddress,
11705 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11706 self.validate_address(address)?;
11707 self.handle
11708 .snapshot()
11709 .sessions
11710 .clone()
11711 .into_iter()
11712 .find(|session| {
11713 session.instance_id == address.session.instance_id
11714 && session.generation == address.session.generation
11715 })
11716 .ok_or_else(|| {
11717 failure(
11718 NodeFailureCode::UnknownSession,
11719 "session snapshot is unavailable",
11720 )
11721 })
11722 }
11723
11724 fn require_acp_session(
11732 &self,
11733 address: &SessionAddress,
11734 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11735 let snapshot = self.internal_session_snapshot(address)?;
11736 if snapshot.transport != TransportKind::Acp {
11737 return Err(failure(
11738 NodeFailureCode::UnsupportedTransport,
11739 "this verb requires a session spawned over the ACP transport",
11740 ));
11741 }
11742 Ok(snapshot)
11743 }
11744
11745 async fn dispatch_history_bounded(
11746 &self,
11747 address: &SessionAddress,
11748 command: ControlCommand,
11749 operation: HistoryOperation,
11750 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11751 self.validate_address(address)?;
11752 let started_after = self.current_sequence();
11753 let command_id = self
11754 .dispatch_bounded(
11755 command,
11756 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11757 )
11758 .await?;
11759 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11760 let mut scan_after = started_after;
11761 let mut accepted_after = None;
11762 loop {
11763 let events = self
11764 .history
11765 .lock()
11766 .unwrap_or_else(|poisoned| poisoned.into_inner())
11767 .events
11768 .iter()
11769 .filter(|envelope| envelope.sequence > scan_after)
11770 .cloned()
11771 .collect::<Vec<_>>();
11772 for envelope in events {
11773 scan_after = scan_after.max(envelope.sequence);
11774 let NodeEvent::Control {
11775 address: event_address,
11776 event,
11777 } = envelope.event
11778 else {
11779 continue;
11780 };
11781 if event_address != *address {
11782 continue;
11783 }
11784 if event.command_id == Some(command_id) {
11785 match &event.event {
11786 ControlEventKind::CommandRejected { .. } => {
11787 return Err(failure(
11788 NodeFailureCode::InvalidRequest,
11789 "history request was rejected",
11790 ));
11791 }
11792 ControlEventKind::HistoryRequested {
11793 operation: requested,
11794 ..
11795 } if requested == &operation => {
11796 accepted_after = Some(envelope.sequence);
11797 continue;
11798 }
11799 _ => {}
11800 }
11801 }
11802 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11803 match (&operation, &event.event) {
11804 (
11805 HistoryOperation::Discover { .. },
11806 ControlEventKind::HistoryDiscovered { .. },
11807 )
11808 | (
11809 HistoryOperation::Load { .. },
11810 ControlEventKind::HistoryLoaded { .. },
11811 ) => return self.internal_session_snapshot(address),
11812 (_, ControlEventKind::HistoryFailed { .. }) => {
11813 return Err(failure(
11814 NodeFailureCode::BackendOperationFailed,
11815 "history operation failed",
11816 ));
11817 }
11818 _ => {}
11819 }
11820 }
11821 }
11822 if Instant::now() >= deadline {
11823 return Err(failure(
11824 NodeFailureCode::BackendBusy,
11825 if accepted_after.is_some() {
11826 "history operation did not settle before the bounded deadline"
11827 } else {
11828 "history operation was not accepted before the bounded deadline"
11829 },
11830 ));
11831 }
11832 sleep(Duration::from_millis(2)).await;
11833 }
11834 }
11835
11836 async fn discover_history(
11837 &self,
11838 address: &SessionAddress,
11839 limit: u16,
11840 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11841 let query = HistoryQuery {
11842 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11843 limit,
11844 };
11845 query
11846 .validate()
11847 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11848 let snapshot = self
11849 .dispatch_history_bounded(
11850 address,
11851 ControlCommand::DiscoverHistory {
11852 instance_id: address.session.instance_id,
11853 query: query.clone(),
11854 },
11855 HistoryOperation::Discover { query },
11856 )
11857 .await?;
11858 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11859 return Err(failure(
11860 NodeFailureCode::BackendOperationFailed,
11861 "history discovery settled into an invalid state",
11862 ));
11863 }
11864 Ok(snapshot.history.candidates)
11865 }
11866
11867 async fn load_history(
11868 &self,
11869 address: &SessionAddress,
11870 candidate_id: String,
11871 ) -> Result<HistorySessionRecord, NodeFailure> {
11872 validate_candidate_id(&candidate_id)
11873 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11874 let snapshot = self
11875 .dispatch_history_bounded(
11876 address,
11877 ControlCommand::LoadHistory {
11878 instance_id: address.session.instance_id,
11879 candidate_id: candidate_id.clone(),
11880 },
11881 HistoryOperation::Load {
11882 candidate_id: candidate_id.clone(),
11883 },
11884 )
11885 .await?;
11886 if snapshot.history.pending.is_some()
11887 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11888 {
11889 return Err(failure(
11890 NodeFailureCode::BackendOperationFailed,
11891 "history load settled into an invalid state",
11892 ));
11893 }
11894 snapshot.history.loaded.ok_or_else(|| {
11895 failure(
11896 NodeFailureCode::BackendOperationFailed,
11897 "history load produced no bounded session",
11898 )
11899 })
11900 }
11901
11902 async fn export_context_pack_for_session_record(
11909 &self,
11910 record_id: &SessionRecordId,
11911 session: &SessionAddress,
11912 allow_clean_detachment: bool,
11913 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11914 let outcome = self
11920 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11921 .await;
11922 if let Err(failure) = &outcome {
11923 tracing::warn!(
11924 record = %record_id,
11925 code = ?failure.code,
11926 reason = %failure.message,
11927 "session-record context-pack export refused",
11928 );
11929 }
11930 outcome
11931 }
11932
11933 async fn export_context_pack_for_session_record_inner(
11934 &self,
11935 record_id: &SessionRecordId,
11936 session: &SessionAddress,
11937 allow_clean_detachment: bool,
11938 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11939 let step = |name: &'static str| move |failure: NodeFailure| {
11944 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11945 failure
11946 };
11947 let record = self.record(record_id).map_err(step("record-lookup"))?;
11948 let identity = match record.provider_session.clone() {
11964 Some(identity) => {
11965 if identity.key != ProviderSessionKey::SessionId {
11966 return Err(step("provider-identity-key")(failure(
11967 NodeFailureCode::BackendOperationFailed,
11968 "managed session provider identity cannot bind history exactly",
11969 )));
11970 }
11971 Some(identity)
11972 }
11973 None if record.state == ManagedSessionState::IdentityPending => {
11974 return Err(step("provider-session-identity")(failure(
11975 NodeFailureCode::BackendOperationFailed,
11976 "managed session has not yet observed its provider session identity",
11977 )));
11978 }
11979 None => None,
11980 };
11981 self.revalidate_session_record_context_export(
11982 &record,
11983 identity.as_ref(),
11984 session,
11985 allow_clean_detachment,
11986 ).map_err(step("revalidate-before-discovery"))?;
11987 let candidates = self
11988 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11989 .await
11990 .map_err(step("discover-history"))?;
11991 let candidate_id = select_history_candidate(
11996 &candidates,
11997 identity.as_ref().map(|identity| identity.id.as_str()),
11998 ).map_err(step("unique-candidate"))?;
11999 let loaded = self.load_history(session, candidate_id.clone()).await
12000 .map_err(step("load-history"))?;
12001 if let Some(identity) = identity.as_ref() {
12002 if loaded.session_id != identity.id {
12003 return Err(failure(
12004 NodeFailureCode::SessionRecordConflict,
12005 "loaded history identity does not match the managed session record",
12006 ));
12007 }
12008 }
12009 self.revalidate_session_record_context_export(
12010 &record,
12011 identity.as_ref(),
12012 session,
12013 allow_clean_detachment,
12014 )?;
12015 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12016 .map_err(step("revalidate-loaded-history"))?;
12017 let pack = self.materialize_context_pack(session).await
12018 .map_err(step("materialize-context-pack"))?;
12019 self.commit_context_pack_for_session_record(
12020 &record,
12021 identity.as_ref(),
12022 session,
12023 &candidate_id,
12024 &loaded,
12025 pack,
12026 allow_clean_detachment,
12027 )
12028 }
12029
12030 async fn export_context_pack(
12031 &self,
12032 address: &SessionAddress,
12033 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12034 let pack = self.materialize_context_pack(address).await?;
12035 self.commit_context_pack(pack)
12036 }
12037
12038 async fn materialize_context_pack(
12039 &self,
12040 address: &SessionAddress,
12041 ) -> Result<NodeContextPack, NodeFailure> {
12042 let snapshot = self.internal_session_snapshot(address)?;
12043 if !context_pack_source_status_is_usable(&snapshot.status) {
12044 return Err(failure(
12045 NodeFailureCode::InvalidRequest,
12046 "context export requires a running or successfully exited source session",
12047 ));
12048 }
12049 if snapshot.history.pending.is_some()
12050 || snapshot.history.loaded_candidate_id.is_none()
12051 {
12052 return Err(failure(
12053 NodeFailureCode::InvalidRequest,
12054 "context export requires settled loaded history",
12055 ));
12056 }
12057 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12058 failure(
12059 NodeFailureCode::InvalidRequest,
12060 "context export requires settled loaded history",
12061 )
12062 })?;
12063 let source_provider = snapshot.agent_id.clone();
12064 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12065 let repository = self
12066 .context_pack_repository(&address.workspace_id)
12067 .await?;
12068 let current = self.internal_session_snapshot(address)?;
12069 if !context_pack_source_status_is_usable(¤t.status)
12070 || current.agent_id != source_provider
12071 || current.history.pending.is_some()
12072 || current.history.loaded_candidate_id != loaded_candidate_id
12073 || current.history.loaded.as_ref() != Some(&loaded)
12074 {
12075 return Err(failure(
12076 NodeFailureCode::ContextPackMaterializationFailed,
12077 "context source changed during bounded repository capture",
12078 ));
12079 }
12080 NodeContextPack::export_with_repository(
12081 ContextPackLineageReceipt {
12082 source_node_id: self.node_id.clone(),
12083 source_session: address.clone(),
12084 source_provider,
12085 },
12086 &loaded,
12087 Some(repository),
12088 )
12089 .map_err(|_| {
12090 failure(
12091 NodeFailureCode::ContextPackMaterializationFailed,
12092 "bounded context pack export failed",
12093 )
12094 })
12095 }
12096
12097 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12103 let mut store = self
12104 .context_pack_store
12105 .lock()
12106 .unwrap_or_else(|poisoned| poisoned.into_inner());
12107 let Some(store) = store.as_mut() else {
12108 return Ok(());
12109 };
12110 store.commit(pack).map_err(|_| {
12111 failure(
12112 NodeFailureCode::ContextPackMaterializationFailed,
12113 "durable context pack commit failed",
12114 )
12115 })
12116 }
12117
12118 fn commit_context_pack(
12119 &self,
12120 pack: NodeContextPack,
12121 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12122 self.commit_context_pack_durably(&pack)?;
12123 self.context_catalog
12124 .write()
12125 .unwrap_or_else(|poisoned| poisoned.into_inner())
12126 .insert(pack)
12127 .map_err(|_| {
12128 failure(
12129 NodeFailureCode::ContextPackMaterializationFailed,
12130 "context pack catalog is full",
12131 )
12132 })
12133 }
12134
12135 fn commit_context_pack_for_session_record(
12136 &self,
12137 expected: &ManagedSessionRecord,
12138 identity: Option<&ProviderSessionIdentity>,
12139 session: &SessionAddress,
12140 candidate_id: &str,
12141 loaded: &HistorySessionRecord,
12142 pack: NodeContextPack,
12143 allow_clean_detachment: bool,
12144 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12145 let current_root = self.workspace_root(&expected.workspace_id)?;
12146 let _transaction = self
12147 .state_transaction
12148 .lock()
12149 .unwrap_or_else(|poisoned| poisoned.into_inner());
12150 let records = self
12151 .session_records
12152 .lock()
12153 .unwrap_or_else(|poisoned| poisoned.into_inner());
12154 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12155 failure(
12156 NodeFailureCode::SessionRecordConflict,
12157 "managed session record disappeared during context export",
12158 )
12159 })?;
12160 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12161 return Err(failure(
12162 NodeFailureCode::SessionRecordConflict,
12163 "managed session record changed during context export",
12164 ));
12165 }
12166 let bindings = self
12167 .session_bindings
12168 .lock()
12169 .unwrap_or_else(|poisoned| poisoned.into_inner());
12170 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12171 failure(
12172 NodeFailureCode::SessionRecordConflict,
12173 "managed session runtime binding disappeared during context export",
12174 )
12175 })?;
12176 let runtime = self
12177 .handle
12178 .snapshot()
12179 .sessions
12180 .iter()
12181 .find(|snapshot| {
12182 snapshot.instance_id == session.session.instance_id
12183 && snapshot.generation == session.session.generation
12184 })
12185 .cloned()
12186 .ok_or_else(|| {
12187 failure(
12188 NodeFailureCode::SessionRecordConflict,
12189 "managed session runtime disappeared during context export",
12190 )
12191 })?;
12192 let receipt = pack.receipt();
12193 if !session_record_context_export_binding_is_exact(
12194 expected,
12195 current,
12196 identity,
12197 session,
12198 binding,
12199 &runtime.agent_id,
12200 ¤t_root,
12201 allow_clean_detachment,
12202 ) || !session_record_context_export_source_is_usable(
12203 current,
12204 &runtime.status,
12205 allow_clean_detachment,
12206 ) || runtime.history.pending.is_some()
12207 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12208 || runtime.history.loaded.as_ref() != Some(loaded)
12209 || receipt.lineage.source_node_id != self.node_id
12210 || receipt.lineage.source_session != *session
12211 || receipt.lineage.source_provider != current.provider
12212 || receipt.source_message_count != loaded.message_count
12213 {
12214 return Err(failure(
12215 NodeFailureCode::SessionRecordConflict,
12216 "managed session source changed before context catalog commit",
12217 ));
12218 }
12219 self.commit_context_pack_durably(&pack)?;
12220 self.context_catalog
12221 .write()
12222 .unwrap_or_else(|poisoned| poisoned.into_inner())
12223 .insert(pack)
12224 .map_err(|_| {
12225 failure(
12226 NodeFailureCode::ContextPackMaterializationFailed,
12227 "context pack catalog is full",
12228 )
12229 })
12230 }
12231
12232 async fn context_pack_repository(
12233 &self,
12234 workspace_id: &WorkspaceId,
12235 ) -> Result<ContextPackRepository, NodeFailure> {
12236 let first = self
12237 .context_pack_repository_observation(workspace_id)
12238 .await?;
12239 let second = self
12240 .context_pack_repository_observation(workspace_id)
12241 .await?;
12242 stable_context_pack_repository(first, second)
12243 }
12244
12245 async fn context_pack_repository_observation(
12246 &self,
12247 workspace_id: &WorkspaceId,
12248 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12249 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12250 let head = if git.is_repository {
12251 let root = self.workspace_root(workspace_id)?;
12252 match resolve_base_commit_with_timeout(
12253 &root,
12254 "HEAD",
12255 GIT_COMMAND_TIMEOUT_MS,
12256 )
12257 .await
12258 {
12259 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12260 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12261 ContextPackRepositoryHead::Unborn
12262 }
12263 Err(_) => {
12264 return Err(failure(
12265 NodeFailureCode::ContextPackMaterializationFailed,
12266 "context pack HEAD observation failed",
12267 ));
12268 }
12269 }
12270 } else {
12271 ContextPackRepositoryHead::NotRepository
12272 };
12273 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12274 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12275 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12276 failure(
12277 NodeFailureCode::InvalidRepositoryPath,
12278 "context pack selected file path is invalid",
12279 )
12280 })?;
12281 match self.read_workspace_file(workspace_id.clone(), path).await {
12282 Ok(WorkspaceFileRead {
12283 content: WorkspaceFileContent::Utf8 { text, byte_len },
12284 ..
12285 }) => files.push(ContextPackRepositoryFileSource::utf8(
12286 selected_path,
12287 text,
12288 byte_len,
12289 )),
12290 Ok(WorkspaceFileRead {
12291 content: WorkspaceFileContent::NonUtf8 { byte_len },
12292 ..
12293 }) => files.push(ContextPackRepositoryFileSource::skipped(
12294 selected_path,
12295 ContextPackSelectedFileSkipReason::NonUtf8,
12296 Some(byte_len),
12297 )),
12298 Ok(WorkspaceFileRead {
12299 content: WorkspaceFileContent::TooLarge { .. },
12300 ..
12301 }) => files.push(ContextPackRepositoryFileSource::skipped(
12302 selected_path,
12303 ContextPackSelectedFileSkipReason::TooLarge,
12304 None,
12305 )),
12306 Err(error) => {
12307 let reason = if matches!(
12308 error.code,
12309 NodeFailureCode::InvalidRepositoryPath
12310 | NodeFailureCode::RepositoryFileNotRegular
12311 | NodeFailureCode::RepositoryPathUnsafe
12312 ) {
12313 ContextPackSelectedFileSkipReason::Unsafe
12314 } else {
12315 ContextPackSelectedFileSkipReason::Unavailable
12316 };
12317 files.push(ContextPackRepositoryFileSource::skipped(
12318 selected_path,
12319 reason,
12320 None,
12321 ));
12322 }
12323 }
12324 }
12325 Ok(ContextPackRepositoryObservation { head, git, files })
12326 }
12327
12328 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12329 let referenced_by_binding = self
12330 .session_bindings
12331 .lock()
12332 .unwrap_or_else(|poisoned| poisoned.into_inner())
12333 .values()
12334 .any(|binding| {
12335 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12336 });
12337 let referenced_by_record = self
12338 .session_records
12339 .lock()
12340 .unwrap_or_else(|poisoned| poisoned.into_inner())
12341 .records
12342 .values()
12343 .any(|record| record.context_id.as_ref() == Some(context_id));
12344 let referenced_by_materialization = self
12345 .materializations
12346 .lock()
12347 .unwrap_or_else(|poisoned| poisoned.into_inner())
12348 .values()
12349 .any(|ownership| {
12350 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12351 });
12352 if referenced_by_binding
12353 || referenced_by_record
12354 || referenced_by_materialization
12355 {
12356 return Err(failure(
12357 NodeFailureCode::ContextPackBusy,
12358 "context pack is still referenced",
12359 ));
12360 }
12361 let mut catalog = self.context_catalog
12362 .write()
12363 .unwrap_or_else(|poisoned| poisoned.into_inner());
12364 if catalog.get(context_id).is_none() {
12365 return Err(failure(
12366 NodeFailureCode::UnknownContextPack,
12367 "context pack is unavailable",
12368 ));
12369 }
12370 self.spawn_idempotency
12371 .lock()
12372 .unwrap_or_else(|poisoned| poisoned.into_inner())
12373 .entries
12374 .retain(|_, entry| !entry.value.references_context(context_id));
12375 let removed = catalog.remove(context_id);
12376 debug_assert!(removed.is_some(), "validated context pack remains present");
12377 Ok(())
12378 }
12379
12380 async fn dispatch_input_bounded(
12381 &self,
12382 address: &SessionAddress,
12383 action: InputAction,
12384 ) -> Result<(), NodeFailure> {
12385 let started_after = self.current_sequence();
12386 self.pty_input_marks
12387 .lock()
12388 .unwrap_or_else(|poisoned| poisoned.into_inner())
12389 .insert(address.session.instance_id, (Instant::now(), false));
12390 self.pty_input_wake.notify_one();
12391 let command_id = self
12392 .dispatch_bounded(
12393 ControlCommand::SendInput {
12394 instance_id: address.session.instance_id,
12395 action,
12396 },
12397 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12398 )
12399 .await?;
12400 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12404 let mut scan_after = started_after;
12405 let mut accepted_after = None;
12406 loop {
12407 let events = self
12408 .history
12409 .lock()
12410 .unwrap_or_else(|poisoned| poisoned.into_inner())
12411 .events
12412 .iter()
12413 .filter(|envelope| envelope.sequence > scan_after)
12414 .cloned()
12415 .collect::<Vec<_>>();
12416 for envelope in events {
12417 scan_after = scan_after.max(envelope.sequence);
12418 let NodeEvent::Control {
12419 address: event_address,
12420 event,
12421 } = envelope.event
12422 else {
12423 continue;
12424 };
12425 if event_address != *address {
12426 continue;
12427 }
12428 if event.command_id == Some(command_id) {
12429 match &event.event {
12430 ControlEventKind::CommandRejected { message } => {
12431 return Err(failure(NodeFailureCode::InvalidRequest, message));
12432 }
12433 ControlEventKind::InputRequested { .. } => {
12434 accepted_after = Some(envelope.sequence);
12435 continue;
12436 }
12437 _ => {}
12438 }
12439 }
12440 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12441 match &event.event {
12442 ControlEventKind::InputCompleted { .. } => return Ok(()),
12443 ControlEventKind::InputFailed { message, .. } => {
12444 return Err(failure(
12445 NodeFailureCode::BackendOperationFailed,
12446 message,
12447 ));
12448 }
12449 _ => {}
12450 }
12451 }
12452 }
12453 if Instant::now() >= deadline {
12454 return Err(failure(
12455 NodeFailureCode::BackendBusy,
12456 if accepted_after.is_some() {
12457 "input was accepted but did not settle before the bounded deadline"
12458 } else {
12459 "input was not accepted before the bounded deadline"
12460 },
12461 ));
12462 }
12463 sleep(Duration::from_millis(2)).await;
12464 }
12465 }
12466
12467 async fn dispatch_resize_bounded(
12468 &self,
12469 address: &SessionAddress,
12470 size: gate4agent_types::TerminalSize,
12471 ) -> Result<(), NodeFailure> {
12472 let started_after = self.current_sequence();
12473 let command_id = self
12474 .dispatch_bounded(
12475 ControlCommand::Resize {
12476 instance_id: address.session.instance_id,
12477 size,
12478 },
12479 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12480 )
12481 .await?;
12482 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12483 let mut scan_after = started_after;
12484 let mut accepted_after = None;
12485 loop {
12486 let events = self
12487 .history
12488 .lock()
12489 .unwrap_or_else(|poisoned| poisoned.into_inner())
12490 .events
12491 .iter()
12492 .filter(|envelope| envelope.sequence > scan_after)
12493 .cloned()
12494 .collect::<Vec<_>>();
12495 for envelope in events {
12496 scan_after = scan_after.max(envelope.sequence);
12497 let NodeEvent::Control {
12498 address: event_address,
12499 event,
12500 } = envelope.event
12501 else {
12502 continue;
12503 };
12504 if event_address != *address {
12505 continue;
12506 }
12507 if event.command_id == Some(command_id) {
12508 match &event.event {
12509 ControlEventKind::CommandRejected { message } => {
12510 return Err(failure(NodeFailureCode::InvalidRequest, message));
12511 }
12512 ControlEventKind::ResizeRequested {
12513 size: requested_size,
12514 ..
12515 } if *requested_size == size => {
12516 accepted_after = Some(envelope.sequence);
12517 continue;
12518 }
12519 _ => {}
12520 }
12521 }
12522 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12523 match &event.event {
12524 ControlEventKind::Resized { size: settled_size }
12525 if *settled_size == size =>
12526 {
12527 return Ok(());
12528 }
12529 ControlEventKind::ResizeFailed { message } => {
12530 return Err(failure(
12531 NodeFailureCode::BackendOperationFailed,
12532 message,
12533 ));
12534 }
12535 _ => {}
12536 }
12537 }
12538 }
12539 if Instant::now() >= deadline {
12540 return Err(failure(
12541 NodeFailureCode::BackendBusy,
12542 if accepted_after.is_some() {
12543 "resize was accepted but did not settle before the bounded deadline"
12544 } else {
12545 "resize was not accepted before the bounded deadline"
12546 },
12547 ));
12548 }
12549 sleep(Duration::from_millis(2)).await;
12550 }
12551 }
12552
12553 async fn spawn_session(
12554 &self,
12555 workspace_id: WorkspaceId,
12556 provider: AgentId,
12557 mode: SessionMode,
12558 terminal_size: gate4agent_types::TerminalSize,
12559 initial_prompt: Option<String>,
12560 ) -> Result<SessionAddress, NodeFailure> {
12561 if self.managed_worktrees.lock()
12562 .unwrap_or_else(|poisoned| poisoned.into_inner())
12563 .lease_for_workspace(&workspace_id)
12564 .is_some()
12565 {
12566 return Err(failure(
12567 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12568 "managed worktree targets may only be spawned through their lease request",
12569 ));
12570 }
12571 self
12572 .spawn_session_with_deadline(
12573 workspace_id,
12574 provider,
12575 mode,
12576 terminal_size,
12577 initial_prompt,
12578 None,
12579 None,
12580 None,
12581 None,
12582 None,
12583 SpawnRecordPolicy::ProviderIdentityOnly,
12584 None,
12585 &[],
12586 None,
12587 ApprovalLevel::default(),
12593 None,
12594 None,
12595 )
12596 .await
12597 .map(|(session, _runtime_policy)| session)
12598 }
12599
12600 async fn spawn_session_with_deadline(
12601 &self,
12602 workspace_id: WorkspaceId,
12603 provider: AgentId,
12604 mode: SessionMode,
12605 terminal_size: gate4agent_types::TerminalSize,
12606 initial_prompt: Option<String>,
12607 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12608 bundle: Option<ResolvedBundleReceipt>,
12609 context: Option<ResolvedContextPackReceipt>,
12610 managed_authority: Option<ManagedWorktreeLeaseId>,
12611 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12612 record_policy: SpawnRecordPolicy,
12613 deadline: Option<Instant>,
12614 required_capabilities: &[ProviderRuntimeCapability],
12615 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12616 approval_level: ApprovalLevel,
12617 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12618 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12624 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12625 let reserved_lease = self.managed_worktrees.lock()
12626 .unwrap_or_else(|poisoned| poisoned.into_inner())
12627 .lease_for_workspace(&workspace_id);
12628 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12629 (Some(expected), Some(provided)) if expected == provided => {}
12630 (None, None) => {}
12631 _ => {
12632 return Err(failure(
12633 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12634 "spawn lacks exact managed worktree lease authority",
12635 ));
12636 }
12637 }
12638 self.ensure_workspace_environment_profile_compatible(
12642 &workspace_id,
12643 environment_profile.as_ref(),
12644 )?;
12645 let runtime_requirement = match (mode, initial_prompt.is_some()) {
12646 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12647 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12648 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12649 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12650 };
12651 let transport = match mode {
12657 SessionMode::Pty => TransportKind::Pty,
12658 SessionMode::Inline => TransportKind::Pipe,
12659 SessionMode::Acp => TransportKind::Acp,
12660 };
12661 let pipe_transport = self.provider_supports_pipe_transport(&provider);
12662 let acp_transport = self.provider_supports_acp_transport(&provider);
12663 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12664 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12665 NodeFailureCode::UnsupportedSpawnCapability,
12666 "pre-admitted provider runtime no longer satisfies spawn requirements",
12667 ))?;
12668 runtime_policy
12669 } else if let Some(deadline) = deadline {
12670 let remaining = spawn_deadline_remaining(deadline)?;
12671 timeout(
12672 remaining,
12673 self.admit_provider_runtime(&provider, runtime_requirement),
12674 )
12675 .await
12676 .map_err(|_| {
12677 failure(
12678 NodeFailureCode::SpawnDeadlineExceeded,
12679 "provider admission exceeded the spawn deadline",
12680 )
12681 })??
12682 } else {
12683 self.admit_provider_runtime(&provider, runtime_requirement).await?
12684 };
12685 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12686 let runtime_policy =
12698 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12699 if required_capabilities
12700 .iter()
12701 .any(|capability| !runtime_policy.admits(*capability))
12702 {
12703 return Err(failure(
12704 NodeFailureCode::UnsupportedSpawnCapability,
12705 "provider runtime does not admit a required spawn capability",
12706 ));
12707 }
12708 if let Some(deadline) = deadline {
12709 spawn_deadline_remaining(deadline)?;
12710 }
12711 self.ensure_binding_capacity()?;
12712 let working_directory = self.workspace_root(&workspace_id)?;
12713 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12714 let session = SessionKey {
12715 instance_id,
12716 generation: SessionGeneration(1),
12717 };
12718 let address = SessionAddress {
12719 workspace_id: workspace_id.clone(),
12720 session,
12721 };
12722 tracing::info!(
12730 node_id = %self.node_id,
12731 workspace_id = %workspace_id,
12732 provider = %provider,
12733 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12734 session = ?address.session,
12735 "session spawn request accepted",
12736 );
12737 let mut prepared_spawn = SpawnPreparer::prepare(
12738 self,
12739 SpawnPrepareRequest {
12740 instance_id,
12741 address: &address,
12742 provider: &provider,
12743 mode,
12744 environment_profile: environment_profile.as_ref(),
12745 bundle: bundle.as_ref(),
12746 context: context.as_ref(),
12747 managed_authority: managed_authority.as_ref(),
12748 harness_mcp,
12749 approval_level,
12750 network_allowlist: network_allowlist.as_ref(),
12751 browser_profile_id,
12752 },
12753 )?;
12754 let materialization_id = prepared_spawn.materialization_id.take();
12755 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12756 let record_id = self.bind_spawn_session_with_materialization(
12757 &address,
12758 provider.clone(),
12759 mode,
12760 runtime_policy,
12761 record_policy,
12762 environment_profile,
12763 bundle,
12764 context,
12765 materialization_id,
12766 )?;
12767 prepared_spawn.retain_session_guards();
12768 let agent_id = provider;
12772 let register_command_id = match self.dispatch_bounded(
12773 ControlCommand::Register {
12774 instance_id,
12775 agent_id,
12776 transport,
12777 },
12778 dispatch_timeout,
12779 )
12780 .await {
12781 Ok(command_id) => command_id,
12782 Err(error) => {
12783 if let Some(binding) = self.remove_binding(&address) {
12784 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12785 }
12786 if let Some(record_id) = record_id.as_ref() {
12787 self.discard_record(record_id)?;
12788 }
12789 return Err(spawn_dispatch_error(error, deadline));
12790 }
12791 };
12792 let start_timeout = match spawn_dispatch_timeout(deadline) {
12793 Ok(timeout) => timeout,
12794 Err(error) => {
12795 let recovery = self
12796 .rollback_spawn(
12797 &address,
12798 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12799 )
12800 .await;
12801 return match recovery {
12802 Ok(()) => {
12803 if let Some(record_id) = record_id.as_ref() {
12804 self.discard_record(record_id)?;
12805 }
12806 Err(error)
12807 }
12808 Err(recovery_error) => {
12809 if let Some(record_id) = record_id.as_ref() {
12810 self.mark_record_error(
12811 record_id,
12812 "spawn deadline elapsed and registration recovery failed",
12813 )?;
12814 }
12815 Err(recovery_error)
12816 }
12817 };
12818 }
12819 };
12820 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12821 if let Err(recovery_error) = self
12822 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12823 .await
12824 {
12825 return Err(recovery_error);
12826 }
12827 if let Some(record_id) = record_id.as_ref() {
12828 self.discard_record(record_id)?;
12829 }
12830 return Err(start_error);
12831 }
12832 let start_result = self
12833 .dispatch_bounded(
12834 ControlCommand::Start {
12835 instance_id,
12836 runtime_policy,
12837 request: StartRequest {
12838 working_directory,
12839 terminal_size,
12840 initial_prompt,
12841 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
12842 approval_level,
12843 },
12844 },
12845 start_timeout,
12846 )
12847 .await;
12848 let start_command_id = match start_result {
12849 Ok(command_id) => command_id,
12850 Err(start_error) => {
12851 let start_error = spawn_dispatch_error(start_error, deadline);
12852 let recovery = self
12853 .rollback_spawn(
12854 &address,
12855 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12856 )
12857 .await;
12858 return match recovery {
12859 Ok(()) => {
12860 if let Some(record_id) = record_id.as_ref() {
12861 self.discard_record(record_id)?;
12862 }
12863 Err(start_error)
12864 }
12865 Err(recovery_error) => {
12866 let diagnostic = format!(
12867 "start failed and registration recovery failed: {}",
12868 recovery_error.message,
12869 );
12870 if let Some(record_id) = record_id.as_ref() {
12871 self.mark_record_error(record_id, &diagnostic)?;
12872 }
12873 Err(failure(recovery_error.code, &diagnostic))
12874 }
12875 };
12876 }
12877 };
12878
12879 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12880 loop {
12881 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12890 .or_else(|| self.take_rejected_command_reason(start_command_id))
12891 {
12892 self.rollback_spawn(
12893 &address,
12894 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12895 ).await.map_err(|error| {
12896 failure(
12897 error.code,
12898 &format!(
12899 "spawn was rejected ({}) and registration recovery failed: {}",
12900 reason.message, error.message
12901 ),
12902 )
12903 })?;
12904 if let Some(record_id) = record_id.as_ref() {
12905 self.discard_record(record_id)?;
12906 }
12907 let code = if reason.unsupported_transport {
12908 NodeFailureCode::UnsupportedTransport
12909 } else {
12910 NodeFailureCode::BackendOperationFailed
12911 };
12912 return Err(failure(code, &reason.message));
12913 }
12914 if let Some(current) = self
12915 .handle
12916 .snapshot()
12917 .sessions
12918 .iter()
12919 .find(|current| current.instance_id == instance_id)
12920 {
12921 if current.generation != session.generation {
12922 let divergence = failure(
12923 NodeFailureCode::StaleGeneration,
12924 "spawn generation diverged",
12925 );
12926 return match self
12927 .rollback_spawn(
12928 &address,
12929 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12930 )
12931 .await
12932 {
12933 Ok(()) => {
12934 if let Some(record_id) = record_id.as_ref() {
12935 self.discard_record(record_id)?;
12936 }
12937 Err(divergence)
12938 }
12939 Err(recovery_error) => {
12940 if let Some(record_id) = record_id.as_ref() {
12941 self.mark_record_error(
12942 record_id,
12943 "spawn generation diverged and recovery failed",
12944 )?;
12945 }
12946 Err(recovery_error)
12947 }
12948 };
12949 }
12950 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12951 prepared_spawn.retain_launch_guards();
12952 return Ok((address, runtime_policy));
12953 }
12954 }
12955 if Instant::now() >= commit_deadline {
12956 self.rollback_spawn(
12957 &address,
12958 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12959 ).await.map_err(|error| {
12960 failure(
12961 error.code,
12962 &format!(
12963 "start did not commit and registration recovery failed: {}",
12964 error.message
12965 ),
12966 )
12967 })?;
12968 if let Some(record_id) = record_id.as_ref() {
12969 self.discard_record(record_id)?;
12970 }
12971 return Err(failure(
12972 if deadline.is_some() {
12973 NodeFailureCode::SpawnDeadlineExceeded
12974 } else {
12975 NodeFailureCode::BackendBusy
12976 },
12977 "start did not commit before the bounded deadline; registration was removed",
12978 ));
12979 }
12980 sleep(Duration::from_millis(2)).await;
12981 }
12982 }
12983
12984 fn effective_spawn_runtime_policy(
12985 &self,
12986 runtime_policy: ProviderRuntimePolicy,
12987 ) -> ProviderRuntimePolicy {
12988 #[cfg(feature = "fixture")]
12989 let runtime_policy = if self.fixture_semantic_hook_policy {
12990 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12991 .expect("monitoring Hook fixture policy is internally valid")
12992 } else {
12993 runtime_policy
12994 };
12995 runtime_policy
12996 }
12997
12998 fn verify_harness_mcp_before_start(
12999 &self,
13000 prepared: Option<&PreparedHarnessMcpSpawn>,
13001 ) -> Result<(), NodeFailure> {
13002 prepared
13003 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13004 .transpose()
13005 .map(|_| ())
13006 .map_err(harness_mcp_failure)
13007 }
13008
13009 async fn rollback_spawn(
13010 &self,
13011 address: &SessionAddress,
13012 commit_timeout: Duration,
13013 ) -> Result<(), NodeFailure> {
13014 self.dispatch_bounded(
13015 ControlCommand::Remove {
13016 instance_id: address.session.instance_id,
13017 },
13018 commit_timeout,
13019 )
13020 .await?;
13021 self.wait_until_removed(address, commit_timeout).await?;
13022 if let Some(binding) = self.remove_binding(address) {
13023 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13024 }
13025 Ok(())
13026 }
13027
13028 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13029 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13030 self.dispatch_bounded(
13031 ControlCommand::Remove {
13032 instance_id: address.session.instance_id,
13033 },
13034 commit_timeout,
13035 )
13036 .await?;
13037 self.wait_until_removed(address, commit_timeout).await?;
13038 let removed = self.remove_binding(address);
13039 if let Some(binding) = removed.as_ref() {
13040 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13041 }
13042 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13043 let snapshot = self.managed_worktrees.lock()
13044 .unwrap_or_else(|poisoned| poisoned.into_inner())
13045 .release_session(
13046 &lease_id,
13047 self.incarnation_id,
13048 address.session.instance_id,
13049 address.session.generation,
13050 unix_time_ms(),
13051 );
13052 if self.persist_state().is_err() {
13053 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13054 }
13055 if let Some(snapshot) = snapshot {
13056 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13057 }
13058 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13059 }
13060 Ok(())
13061 }
13062
13063 async fn wait_until_removed(
13109 &self,
13110 address: &SessionAddress,
13111 commit_timeout: Duration,
13112 ) -> Result<(), NodeFailure> {
13113 let deadline = Instant::now() + commit_timeout;
13114 let mut next_dispatch = Instant::now();
13115 loop {
13116 let snapshot = self.handle.snapshot();
13117 let status = snapshot
13118 .sessions
13119 .iter()
13120 .find(|session| session.instance_id == address.session.instance_id)
13121 .map(|session| session.status.clone());
13122 let Some(status) = status else {
13123 return Ok(());
13124 };
13125 let now = Instant::now();
13126 if now >= deadline {
13127 return Err(failure(
13128 NodeFailureCode::BackendBusy,
13129 "remove did not commit before the bounded deadline; session binding was retained",
13130 ));
13131 }
13132 if status.allows_remove() && now >= next_dispatch {
13133 let _ = self.dispatch(ControlCommand::Remove {
13134 instance_id: address.session.instance_id,
13135 });
13136 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13137 }
13138 sleep(Duration::from_millis(2)).await;
13139 }
13140 }
13141
13142 fn resync(&self, after_sequence: u64) -> NodeResponse {
13143 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13144 let event_sequence = history.last_sequence;
13145 let oldest_available_sequence = history.replay_floor_sequence;
13146 let events = history
13147 .events
13148 .iter()
13149 .filter(|event| event.sequence > after_sequence)
13150 .cloned()
13151 .collect();
13152 drop(history);
13153 NodeResponse::Resync {
13154 event_sequence,
13155 oldest_available_sequence,
13156 snapshot: self.snapshot(),
13157 events,
13158 }
13159 }
13160}
13161
13162fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13167 match kind {
13168 AgentStreamChunkKindV1::Text { .. } => "text",
13169 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13170 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13171 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13172 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13173 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13174 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13175 }
13176}
13177
13178async fn accept_connections(
13192 endpoint: &str,
13193 call_home: Option<std::net::SocketAddr>,
13194 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13195 shared: Arc<NodeShared>,
13196) -> Result<(), NodeServerError> {
13197 let mut connections = JoinSet::new();
13198 let result = match call_home {
13199 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13200 Some(relay) => {
13201 let listener =
13202 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13203 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13204 tokio::pin!(listener);
13205 tokio::pin!(dialler);
13206 tokio::select! {
13207 result = &mut listener => result,
13208 result = &mut dialler => result,
13209 }
13210 }
13211 };
13212 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13213 while !connections.is_empty() {
13214 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13215 if remaining.is_zero()
13216 || timeout(remaining, connections.join_next()).await.is_err()
13217 {
13218 break;
13219 }
13220 }
13221 connections.abort_all();
13222 while connections.join_next().await.is_some() {}
13223 result
13224}
13225
13226const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13230
13231async fn call_home_loop(
13245 relay: std::net::SocketAddr,
13246 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13247 shared: Arc<NodeShared>,
13248) -> Result<(), NodeServerError> {
13249 let mut failures = 0_usize;
13250 loop {
13251 if shared.shutdown.load(Ordering::Acquire) {
13252 return Ok(());
13253 }
13254 let permit = tokio::select! {
13255 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13256 permit.map_err(|_| NodeServerError::PreauthClosed)?
13257 }
13258 _ = shared.shutdown_notify.notified() => return Ok(()),
13259 };
13260 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13261 Ok(()) => {
13262 failures = 0;
13266 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13267 }
13268 Err(error) => {
13269 failures = failures.saturating_add(1);
13270 tracing::warn!(
13271 node_id = %shared.node_id,
13272 %relay,
13273 cause = %error,
13274 attempt = failures,
13275 "call-home connection failed",
13276 );
13277 let backoff = call_home_backoff(failures);
13278 tokio::select! {
13279 _ = sleep(backoff) => {}
13280 _ = shared.shutdown_notify.notified() => return Ok(()),
13281 }
13282 }
13283 }
13284 }
13285}
13286
13287const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13289const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13291
13292fn call_home_keepalive() -> socket2::TcpKeepalive {
13293 let keepalive = socket2::TcpKeepalive::new()
13294 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13295 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13296 #[cfg(not(windows))]
13297 let keepalive = keepalive.with_retries(3);
13298 keepalive
13299}
13300
13301fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13302 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13303}
13304
13305pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13307 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13308}
13309
13310fn call_home_backoff(attempt: usize) -> Duration {
13312 Duration::from_millis(
13313 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13314 )
13315}
13316
13317
13318async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13321 #[cfg(feature = "wireguard")]
13322 {
13323 let std_stream = tokio::task::spawn_blocking(move || {
13324 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13325 })
13326 .await
13327 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13328 .map_err(NodeServerError::CallHomeTransport)?;
13329 std_stream
13330 .set_nonblocking(true)
13331 .map_err(NodeServerError::CallHomeTransport)?;
13332 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13333 }
13334 #[cfg(not(feature = "wireguard"))]
13335 {
13336 tokio::net::TcpStream::connect(relay)
13337 .await
13338 .map_err(NodeServerError::CallHomeTransport)
13339 }
13340}
13341
13342async fn dial_relay_and_serve(
13344 relay: std::net::SocketAddr,
13345 identity: Option<&hatchery_enroll::NodeIdentity>,
13346 shared: Arc<NodeShared>,
13347 preauth_permit: OwnedSemaphorePermit,
13348) -> Result<(), NodeServerError> {
13349 let mut stream = dial_call_home(relay).await?;
13350 let _ = stream.set_nodelay(true);
13354 if let Err(error) = enable_call_home_keepalive(&stream) {
13360 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13361 }
13362 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13363 .await
13364 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13365 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13366 let mut session_token = None;
13367 if let Some(identity) = identity {
13368 let mut magic = [0_u8; 4];
13372 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13373 .await
13374 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13375 .map_err(NodeServerError::CallHomeTransport)?;
13376 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13377 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13378 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13379 .await
13380 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13381 .map_err(NodeServerError::CallHomeTransport)?;
13382 let (answer, token) = identity
13383 .answer_challenge(shared.node_id.as_str(), &frame)
13384 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13385 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13386 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13387 session_token = Some(token);
13388 }
13389 }
13390 serve_connection(stream, shared, session_token, preauth_permit).await
13391}
13392
13393async fn accept_connections_inner(
13394 endpoint: &str,
13395 shared: Arc<NodeShared>,
13396 connections: &mut JoinSet<Result<(), NodeServerError>>,
13397) -> Result<(), NodeServerError> {
13398 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13399 loop {
13400 while connections.try_join_next().is_some() {}
13401 if shared.shutdown.load(Ordering::Acquire) {
13402 return Ok(());
13403 }
13404 let preauth_permit = tokio::select! {
13405 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13406 permit.map_err(|_| NodeServerError::PreauthClosed)?
13407 }
13408 _ = shared.shutdown_notify.notified() => return Ok(()),
13409 };
13410 let server = tokio::select! {
13411 result = listener.accept() => result?,
13412 _ = shared.shutdown_notify.notified() => return Ok(()),
13413 };
13414 let connection_shared = Arc::clone(&shared);
13415 connections.spawn(async move {
13416 serve_connection(server, connection_shared, None, preauth_permit).await
13417 });
13418 if shared.shutdown.load(Ordering::Acquire) {
13419 return Ok(());
13420 }
13421 }
13422}
13423
13424async fn serve_connection<S>(
13432 mut pipe: S,
13433 shared: Arc<NodeShared>,
13434 session_token: Option<String>,
13435 preauth_permit: OwnedSemaphorePermit,
13436) -> Result<(), NodeServerError>
13437where
13438 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13439{
13440 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13442 let hello_frame = timeout(
13443 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13444 read_json_frame_limited_body_timeout(
13445 &mut pipe,
13446 MAX_NODE_HELLO_FRAME_BYTES,
13447 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13448 ),
13449 )
13450 .await
13451 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13452 let ClientFrame::Hello(hello) = hello_frame else {
13453 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13454 };
13455 if hello.build_stamp != BUILD_STAMP {
13456 return Err(NodeServerError::Handshake(format!(
13457 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13458 hello.build_stamp,
13459 )));
13460 }
13461 let compatibility = match hello.compatibility.as_ref() {
13462 Some(offer) => {
13463 let mut selected = node_compatibility_support(&shared)?
13464 .negotiate(offer)
13465 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13466 project_negotiated_provider_ids(&mut selected);
13467 Some(selected)
13468 }
13469 None => None,
13470 };
13471 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13472 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13473 (Some(offer), Some(selected)) => negotiated_auth_proof(
13474 auth_token,
13475 AuthDirection::Server,
13476 hello.role,
13477 &hello.client_nonce,
13478 &server_nonce,
13479 offer,
13480 selected,
13481 ),
13482 (None, None) => auth_proof(
13483 auth_token,
13484 AuthDirection::Server,
13485 hello.role,
13486 &hello.client_nonce,
13487 &server_nonce,
13488 ),
13489 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13490 }
13491 .map_err(NodeServerError::Authentication)?;
13492 write_json_frame_limited(
13493 &mut pipe,
13494 &ServerFrame::Challenge(ServerChallenge {
13495 build_stamp: BUILD_STAMP.to_owned(),
13496 server_nonce,
13497 server_proof,
13498 compatibility: compatibility.clone(),
13499 }),
13500 MAX_NODE_HELLO_FRAME_BYTES,
13501 )
13502 .await?;
13503 let authentication = timeout(
13504 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13505 read_json_frame_limited_body_timeout(
13506 &mut pipe,
13507 MAX_NODE_HELLO_FRAME_BYTES,
13508 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13509 ),
13510 )
13511 .await
13512 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13513 let ClientFrame::Authenticate(authentication) = authentication else {
13514 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13515 };
13516 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13517 (Some(offer), Some(selected)) => negotiated_auth_proof(
13518 auth_token,
13519 AuthDirection::Client,
13520 hello.role,
13521 &hello.client_nonce,
13522 &server_nonce,
13523 offer,
13524 selected,
13525 ),
13526 (None, None) => auth_proof(
13527 auth_token,
13528 AuthDirection::Client,
13529 hello.role,
13530 &hello.client_nonce,
13531 &server_nonce,
13532 ),
13533 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13534 }
13535 .map_err(NodeServerError::Authentication)?;
13536 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13537 return Err(NodeServerError::Handshake("access denied".to_owned()));
13538 }
13539 let selected_capabilities = compatibility
13540 .as_ref()
13541 .map(|selected| selected.capabilities.clone())
13542 .unwrap_or_default();
13543 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13544 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13545 });
13546 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13547 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13548 });
13549 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13550 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13551 });
13552 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13553 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13554 });
13555 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13556 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13557 });
13558 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13559 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13560 }) && selected_capabilities.iter().any(|capability| {
13561 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13562 });
13563 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13564 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13565 });
13566 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13567 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13568 });
13569 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13570 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13571 });
13572 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13573 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13574 });
13575 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13576 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13577 });
13578 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13579 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13580 });
13581 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13582 .try_acquire_owned()
13583 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13584 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13585 let _release_guard = ControllerReleaseGuard {
13586 shared: Arc::clone(&shared),
13587 connection_id,
13588 };
13589 drop(preauth_permit);
13590 let _authenticated_permit = authenticated_permit;
13591 let mut event_rx = shared.event_tx.subscribe();
13592 let mut terminal_event_rx = terminal_event_subscription(
13593 &shared,
13594 include_terminal_frame_events,
13595 );
13596 let mut agent_stream_event_rx = agent_stream_event_subscription(
13597 &shared,
13598 include_agent_stream_events,
13599 );
13600 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13601 &shared,
13602 include_harness_mcp_proxy,
13603 );
13604 write_json_frame(
13605 &mut pipe,
13606 &ServerFrame::Hello(NodeHello {
13607 build_stamp: BUILD_STAMP.to_owned(),
13608 incarnation_id: shared.incarnation_id,
13609 connection_id,
13610 role: hello.role,
13611 event_sequence: shared.current_sequence(),
13612 controller: shared.controller_state(),
13613 snapshot: snapshot_for_wire(
13614 &shared,
13615 include_provider_runtime_status,
13616 include_open_provider_ids,
13617 include_spawn_profiles,
13618 include_managed_worktrees,
13619 include_child_environment_profiles,
13620 include_session_bundles,
13621 include_history_context_packs,
13622 include_agent_progress,
13623 include_session_task_correlation,
13624 ),
13625 compatibility,
13626 }),
13627 )
13628 .await?;
13629 let (mut reader, mut writer) = tokio::io::split(pipe);
13630 let (frame_tx, mut frame_rx) = mpsc::channel(16);
13631 let reader_task = tokio::spawn(async move {
13632 loop {
13633 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13634 &mut reader,
13635 MAX_NODE_CLIENT_FRAME_BYTES,
13636 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13637 )
13638 .await;
13639 let terminal = frame.is_err();
13640 if frame_tx.send(frame).await.is_err() || terminal {
13641 break;
13642 }
13643 }
13644 });
13645 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13646 let mut pending_events = Vec::new();
13647 let mut resync_required = false;
13648 let mut discard_events_through = 0_u64;
13649 loop {
13650 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13655 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13656 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13657 for _ in 0..durable_drain_budget {
13658 match event_rx.try_recv() {
13659 Ok(event) => queue_connection_event(
13660 &mut pending_events,
13661 event,
13662 discard_events_through,
13663 ),
13664 Err(broadcast::error::TryRecvError::Empty) => break,
13665 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13666 resync_required = true;
13667 break;
13668 }
13669 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13670 }
13671 }
13672 if let Some(receiver) = terminal_event_rx.as_mut() {
13673 match receiver.try_recv() {
13674 Ok(events) => queue_connection_event_batch(
13675 &mut pending_events,
13676 &events,
13677 discard_events_through,
13678 ),
13679 Err(broadcast::error::TryRecvError::Empty) => {}
13680 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13681 resync_required = true;
13682 }
13683 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13684 }
13685 }
13686 if let Some(receiver) = agent_stream_event_rx.as_mut() {
13687 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13697 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13698 for _ in 0..agent_stream_drain_budget {
13699 match receiver.try_recv() {
13700 Ok(events) => queue_connection_event_batch(
13701 &mut pending_events,
13702 &events,
13703 discard_events_through,
13704 ),
13705 Err(broadcast::error::TryRecvError::Empty) => break,
13706 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13707 tracing::warn!(
13708 connection_id,
13709 dropped,
13710 "agent stream event subscription lagged; forcing a resync",
13711 );
13712 resync_required = true;
13713 break;
13714 }
13715 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13716 }
13717 }
13718 }
13719 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13720 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13726 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13727 for _ in 0..harness_mcp_proxy_drain_budget {
13728 match receiver.try_recv() {
13729 Ok(events) => queue_connection_event_batch(
13730 &mut pending_events,
13731 &events,
13732 discard_events_through,
13733 ),
13734 Err(broadcast::error::TryRecvError::Empty) => break,
13735 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13736 tracing::warn!(
13737 connection_id,
13738 dropped,
13739 "harness MCP proxy event subscription lagged; forcing a resync",
13740 );
13741 resync_required = true;
13742 break;
13743 }
13744 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13745 }
13746 }
13747 }
13748 if resync_required {
13749 let event = resync_required_event(&shared);
13750 apply_connection_resync_watermark(
13751 &mut pending_events,
13752 &mut discard_events_through,
13753 event.sequence,
13754 );
13755 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13756 resync_required = false;
13757 continue;
13758 }
13759 if !pending_events.is_empty() {
13760 pending_events.sort_unstable_by_key(|event| event.sequence);
13761 let burst_len = connection_event_burst_len(pending_events.len());
13762 shared
13763 .connection_events_sent
13764 .fetch_add(burst_len as u64, Ordering::Relaxed);
13765 for event in pending_events.drain(..burst_len) {
13766 let event = if include_managed_worktrees {
13767 Some(event)
13768 } else {
13769 project_event_without_managed_worktrees(event)
13770 };
13771 let event = event.and_then(|event| if include_open_provider_ids {
13772 Some(event)
13773 } else {
13774 project_event_legacy_provider_ids(&shared, event)
13775 });
13776 let event = event.map(|event| if include_child_environment_profiles {
13777 event
13778 } else {
13779 project_event_without_child_environment_profile(event)
13780 });
13781 let event = event.map(|event| if include_session_bundles {
13782 event
13783 } else {
13784 project_event_without_session_bundle(event)
13785 });
13786 let event = event.map(|event| {
13787 project_event_history_for_wire(event, include_history_context_packs)
13788 });
13789 let event = event.map(|event| if include_history_context_packs {
13790 event
13791 } else {
13792 project_event_without_context_pack(event)
13793 });
13794 let event = event.map(|event| if include_session_task_correlation {
13795 event
13796 } else {
13797 project_event_without_session_task_binding(event)
13798 });
13799 let exact_harness_controller = hello.role == ClientRole::Operator
13800 && shared.controller_state().is_some_and(|controller| {
13801 controller.connection_id == connection_id
13802 });
13803 let event = event.and_then(|event| {
13804 if include_harness_mcp_proxy && exact_harness_controller {
13805 Some(event)
13806 } else {
13807 project_event_without_harness_mcp_proxy(event)
13808 }
13809 });
13810 if let Some(event) = event {
13811 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13812 }
13813 }
13814 }
13815 tokio::select! {
13816 biased;
13817 frame = frame_rx.recv() => {
13818 let frame = match frame {
13819 Some(frame) => frame,
13820 None => break,
13821 };
13822 let frame = match frame {
13823 Ok(frame) => frame,
13824 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13825 Err(error) => return Err(error.into()),
13826 };
13827 let ClientFrame::Request(request) = frame else {
13828 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13829 };
13830 #[cfg(feature = "fixture")]
13831 let probe_spawn_managed_worktree_v2 = matches!(
13832 &request.request,
13833 NodeRequest::SpawnManagedWorktreeV2 { .. }
13834 );
13835 let requires_open_provider_ids =
13836 request_requires_open_provider_ids(&shared, &request.request);
13837 let requires_child_environment_profile =
13838 request_requires_child_environment_profile(&shared, &request.request);
13839 let requires_session_bundle =
13840 request_requires_session_bundle(&shared, &request.request);
13841 let requires_history_context_pack =
13842 request_requires_history_context_pack(&shared, &request.request);
13843 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13844 ResponseEnvelope {
13845 request_id: request.request_id,
13846 result: Err(failure(
13847 NodeFailureCode::UnsupportedCapability,
13848 "open provider IDs were not negotiated",
13849 )),
13850 }
13851 } else if requires_child_environment_profile
13852 && !include_child_environment_profiles
13853 {
13854 ResponseEnvelope {
13855 request_id: request.request_id,
13856 result: Err(failure(
13857 NodeFailureCode::UnsupportedCapability,
13858 "child environment profiles were not negotiated",
13859 )),
13860 }
13861 } else if requires_session_bundle && !include_session_bundles {
13862 ResponseEnvelope {
13863 request_id: request.request_id,
13864 result: Err(failure(
13865 NodeFailureCode::UnsupportedCapability,
13866 "session bundles were not negotiated",
13867 )),
13868 }
13869 } else if requires_history_context_pack && !include_history_context_packs {
13870 ResponseEnvelope {
13871 request_id: request.request_id,
13872 result: Err(failure(
13873 NodeFailureCode::UnsupportedCapability,
13874 "history context packs were not negotiated",
13875 )),
13876 }
13877 } else if request_uses_unnegotiated_capability(
13878 &request.request,
13879 &selected_capabilities,
13880 )
13881 {
13882 ResponseEnvelope {
13883 request_id: request.request_id,
13884 result: Err(failure(
13885 NodeFailureCode::UnsupportedCapability,
13886 "request capability was not negotiated",
13887 )),
13888 }
13889 } else {
13890 process_request(&shared, connection_id, hello.role, request).await
13891 };
13892 #[cfg(feature = "fixture")]
13893 if probe_spawn_managed_worktree_v2 {
13894 shared
13895 .fixture_spawn_managed_worktree_v2_failure_probe
13896 .record(reply.result.as_ref().err().map(|failure| failure.code));
13897 }
13898 if !include_provider_runtime_status {
13899 clear_response_provider_runtime_status(&mut reply);
13900 }
13901 if !include_managed_worktrees {
13902 project_response_without_managed_worktrees(&mut reply);
13903 }
13904 if !include_open_provider_ids {
13905 project_response_legacy_provider_ids(&shared, &mut reply);
13906 }
13907 if !include_child_environment_profiles {
13908 project_response_without_child_environment_profile(&mut reply);
13909 }
13910 if !include_session_bundles {
13911 project_response_without_session_bundle(&mut reply);
13912 }
13913 project_response_history_for_wire(
13914 &mut reply,
13915 include_history_context_packs,
13916 );
13917 if !include_history_context_packs {
13918 project_response_without_context_pack(&mut reply);
13919 }
13920 if !include_agent_progress {
13921 project_response_without_agent_progress(&mut reply);
13922 }
13923 if !include_session_task_correlation {
13924 project_response_without_session_task_binding(&mut reply);
13925 }
13926 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13927 }
13928 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13929 event = event_rx.recv() => {
13930 match event {
13931 Ok(event) => queue_connection_event(
13932 &mut pending_events,
13933 event,
13934 discard_events_through,
13935 ),
13936 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13937 Err(broadcast::error::RecvError::Closed) => break,
13938 }
13939 }
13940 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13941 match events {
13942 Ok(events) => queue_connection_event_batch(
13943 &mut pending_events,
13944 &events,
13945 discard_events_through,
13946 ),
13947 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13948 Err(broadcast::error::RecvError::Closed) => break,
13949 }
13950 }
13951 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13952 match events {
13953 Ok(events) => queue_connection_event_batch(
13954 &mut pending_events,
13955 &events,
13956 discard_events_through,
13957 ),
13958 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13959 Err(broadcast::error::RecvError::Closed) => break,
13960 }
13961 }
13962 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13963 match events {
13964 Ok(events) => queue_connection_event_batch(
13965 &mut pending_events,
13966 &events,
13967 discard_events_through,
13968 ),
13969 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13970 Err(broadcast::error::RecvError::Closed) => break,
13971 }
13972 }
13973 _ = shared.shutdown_notify.notified() => break,
13974 }
13975 }
13976 Ok(())
13977}
13978
13979async fn receive_terminal_event_batch(
13980 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13981) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13982 match receiver {
13983 Some(receiver) => receiver.recv().await,
13984 None => std::future::pending().await,
13985 }
13986}
13987
13988fn terminal_event_subscription(
13989 shared: &NodeShared,
13990 enabled: bool,
13991) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13992 enabled.then(|| shared.terminal_event_tx.subscribe())
13993}
13994
13995fn agent_stream_event_subscription(
13999 shared: &NodeShared,
14000 enabled: bool,
14001) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14002 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14003}
14004
14005fn harness_mcp_proxy_event_subscription(
14010 shared: &NodeShared,
14011 enabled: bool,
14012) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14013 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14014}
14015
14016fn queue_connection_event(
14017 pending: &mut Vec<NodeEventEnvelope>,
14018 event: NodeEventEnvelope,
14019 discard_through: u64,
14020) {
14021 if event.sequence <= discard_through
14022 && !event.event.requires_harness_mcp_proxy_capability()
14023 {
14024 return;
14025 }
14026 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14027 if let Some(index) = pending.iter().position(|current| {
14028 matches!(
14029 ¤t.event,
14030 NodeEvent::TerminalFrame {
14031 address: current_address,
14032 ..
14033 } if current_address == address
14034 )
14035 }) {
14036 pending.remove(index);
14037 }
14038 }
14039 pending.push(event);
14040}
14041
14042fn project_event_without_harness_mcp_proxy(
14043 envelope: NodeEventEnvelope,
14044) -> Option<NodeEventEnvelope> {
14045 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14046}
14047
14048fn queue_connection_event_batch(
14049 pending: &mut Vec<NodeEventEnvelope>,
14050 events: &[NodeEventEnvelope],
14051 discard_through: u64,
14052) {
14053 for event in events {
14054 queue_connection_event(pending, event.clone(), discard_through);
14055 }
14056}
14057
14058fn apply_connection_resync_watermark(
14059 pending: &mut Vec<NodeEventEnvelope>,
14060 discard_through: &mut u64,
14061 marker_sequence: u64,
14062) {
14063 *discard_through = (*discard_through).max(marker_sequence);
14064 pending.retain(|event| event.sequence > *discard_through);
14065}
14066
14067fn connection_event_burst_len(pending_len: usize) -> usize {
14068 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14069}
14070
14071fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14072 let history = shared
14073 .history
14074 .lock()
14075 .unwrap_or_else(|poisoned| poisoned.into_inner());
14076 let sequence = history.last_sequence;
14077 let oldest_available_sequence = history.replay_floor_sequence;
14078 NodeEventEnvelope {
14079 sequence,
14080 event: NodeEvent::ResyncRequired {
14081 oldest_available_sequence,
14082 },
14083 }
14084}
14085
14086fn node_compatibility_support(
14087 shared: &NodeShared,
14088) -> Result<NodeCompatibilitySupport, NodeServerError> {
14089 let mut support = node_compatibility_support_for_manifest(
14090 &shared.provider_contracts,
14091 &shared.provider_adapter_contracts,
14092 )?;
14093 if shared
14094 .delivery_store
14095 .lock()
14096 .unwrap_or_else(|poisoned| poisoned.into_inner())
14097 .is_some()
14098 {
14099 support.capabilities.push(
14100 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14101 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14102 );
14103 }
14104 if shared.harness_mcp_registry.is_some() {
14105 support.capabilities.push(
14106 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14107 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14108 );
14109 }
14110 Ok(support)
14111}
14112
14113fn node_compatibility_support_for_manifest(
14114 provider_contracts: &[ProviderContractSupport],
14115 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14116) -> Result<NodeCompatibilitySupport, NodeServerError> {
14117 Ok(NodeCompatibilitySupport {
14118 build_stamp: BUILD_STAMP.to_owned(),
14119 capabilities: baseline_capabilities()?,
14120 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14121 path_semantics: platform::path_semantics(),
14122 local_transport: platform::local_transport(),
14123 state_schema: StateSchemaSupport {
14124 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14125 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14126 },
14127 provider_contracts: provider_contracts.to_vec(),
14128 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14129 })
14130}
14131
14132fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14133 let capabilities = [
14134 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14135 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14136 NODE_REPOSITORY_PATH_CAPABILITY,
14137 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14138 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14139 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14140 NODE_GIT_READ_CAPABILITY,
14141 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14142 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14143 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14144 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14145 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14146 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14147 NODE_ACP_CONTROL_CAPABILITY,
14148 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14149 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14150 NODE_WORKTREE_SELECTION_CAPABILITY,
14151 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14152 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14153 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14154 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14155 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14156 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14157 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14158 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14159 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14160 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14161 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14162 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14163 ]
14164 .into_iter()
14165 .map(|capability| {
14166 CapabilityId::new(capability)
14167 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14168 })
14169 .collect::<Result<Vec<_>, _>>()?;
14170 #[cfg(windows)]
14171 let capabilities = {
14172 let mut capabilities = capabilities;
14173 capabilities.push(
14174 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14175 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14176 );
14177 capabilities
14178 };
14179 Ok(capabilities)
14180}
14181
14182fn request_uses_unnegotiated_capability(
14183 request: &NodeRequest,
14184 selected_capabilities: &[CapabilityId],
14185) -> bool {
14186 let selected = |required: &str| {
14187 selected_capabilities
14188 .iter()
14189 .any(|capability| capability.as_str() == required)
14190 };
14191 request
14192 .required_capability()
14193 .is_some_and(|required| !selected(required))
14194 || (request.requires_worktree_selection_capability()
14195 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14196 || (request.requires_spawn_spec_defaults_overrides_capability()
14197 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14198 || (request.requires_spawn_profile_revision_capability()
14199 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14200 || (request.requires_child_environment_profile_capability()
14201 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14202 || (request.requires_session_bundle_materialization_capability()
14203 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14204 || (request.requires_history_context_pack_capability()
14205 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14206}
14207
14208fn snapshot_for_wire(
14209 shared: &NodeShared,
14210 include_provider_runtime_status: bool,
14211 include_open_provider_ids: bool,
14212 include_spawn_profiles: bool,
14213 include_managed_worktrees: bool,
14214 include_child_environment_profiles: bool,
14215 include_session_bundles: bool,
14216 include_history_context_packs: bool,
14217 include_agent_progress: bool,
14218 include_session_task_correlation: bool,
14219) -> NodeSnapshot {
14220 let mut snapshot = shared.snapshot();
14221 if !include_provider_runtime_status {
14222 snapshot.provider_runtime_statuses.clear();
14223 }
14224 if !include_open_provider_ids {
14225 project_snapshot_legacy_provider_ids(&mut snapshot);
14226 }
14227 if !include_managed_worktrees {
14228 snapshot.managed_worktrees.clear();
14229 for workspace in &mut snapshot.workspaces {
14230 workspace.managed_worktree_profiles = None;
14231 }
14232 }
14233 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14234 if !include_spawn_profiles {
14235 inventory.spawn_profiles = None;
14236 }
14237 if !include_session_bundles {
14238 inventory.bundles = None;
14239 }
14240 if inventory.spawn_profiles.is_none()
14241 && inventory.bundles.is_none()
14242 && inventory.network_allowlists.is_none()
14243 {
14244 snapshot.launch_inventory = None;
14245 }
14246 }
14247 if !include_child_environment_profiles {
14248 clear_snapshot_child_environment_profiles(&mut snapshot);
14249 }
14250 if !include_session_bundles {
14251 clear_snapshot_session_bundles(&mut snapshot);
14252 }
14253 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14254 if !include_history_context_packs {
14255 clear_snapshot_context_packs(&mut snapshot);
14256 }
14257 if !include_agent_progress {
14258 snapshot.agent_progress.clear();
14259 }
14260 if !include_session_task_correlation {
14261 clear_snapshot_session_task_bindings(&mut snapshot);
14262 }
14263 snapshot
14264}
14265
14266fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14267 for record in &mut snapshot.session_records {
14268 record.task_binding = None;
14269 }
14270}
14271
14272fn project_event_without_session_task_binding(
14273 mut envelope: NodeEventEnvelope,
14274) -> NodeEventEnvelope {
14275 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14276 record.task_binding = None;
14277 }
14278 envelope
14279}
14280
14281fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14282 let Ok(response) = reply.result.as_mut() else { return };
14283 match response {
14284 NodeResponse::Snapshot { snapshot, .. } => {
14285 clear_snapshot_session_task_bindings(snapshot);
14286 }
14287 NodeResponse::Resync { snapshot, events, .. } => {
14288 clear_snapshot_session_task_bindings(snapshot);
14289 for event in events {
14290 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14291 record.task_binding = None;
14292 }
14293 }
14294 }
14295 NodeResponse::SessionRecordUpdated { record }
14296 | NodeResponse::ProviderSessionIndexed { record }
14297 | NodeResponse::NativeSessionIndexed { record, .. }
14298 | NodeResponse::SessionRecordResumed { record, .. } => {
14299 record.task_binding = None;
14300 }
14301 _ => {}
14302 }
14303}
14304
14305fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14306 let Ok(response) = reply.result.as_mut() else { return };
14307 match response {
14308 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14309 snapshot.agent_progress.clear();
14310 }
14311 _ => {}
14312 }
14313}
14314
14315fn project_event_without_managed_worktrees(
14316 mut envelope: NodeEventEnvelope,
14317) -> Option<NodeEventEnvelope> {
14318 if matches!(
14319 envelope.event,
14320 NodeEvent::ManagedWorktreeUpserted { .. }
14321 | NodeEvent::ManagedWorktreeRemoved { .. }
14322 ) {
14323 return None;
14324 }
14325 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14326 workspace.managed_worktree_profiles = None;
14327 }
14328 Some(envelope)
14329}
14330
14331fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14332 if matches!(
14333 reply.result,
14334 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14335 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14336 ) {
14337 reply.result = Err(failure(
14338 NodeFailureCode::UnsupportedCapability,
14339 "managed worktree capability was not negotiated",
14340 ));
14341 return;
14342 }
14343 let Ok(response) = reply.result.as_mut() else { return };
14344 match response {
14345 NodeResponse::Snapshot { snapshot, .. } => {
14346 snapshot.managed_worktrees.clear();
14347 for workspace in &mut snapshot.workspaces {
14348 workspace.managed_worktree_profiles = None;
14349 }
14350 }
14351 NodeResponse::Resync { snapshot, events, .. } => {
14352 snapshot.managed_worktrees.clear();
14353 for workspace in &mut snapshot.workspaces {
14354 workspace.managed_worktree_profiles = None;
14355 }
14356 *events = std::mem::take(events)
14357 .into_iter()
14358 .filter_map(project_event_without_managed_worktrees)
14359 .collect();
14360 }
14361 NodeResponse::WorkspaceRegistered { workspace }
14362 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14363 | NodeResponse::WorktreeCreated { workspace, .. } => {
14364 workspace.managed_worktree_profiles = None;
14365 }
14366 NodeResponse::WorkspaceInspected { inspection } => {
14367 inspection.git.managed_worktree = None;
14368 }
14369 _ => {}
14370 }
14371}
14372
14373fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14374 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14375 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14376 });
14377 if includes_open_provider_ids {
14378 return;
14379 }
14380 compatibility
14381 .provider_contracts
14382 .retain(|contract| provider_id_is_legacy(&contract.provider));
14383 compatibility
14384 .provider_adapter_contracts
14385 .retain(|contract| provider_id_is_legacy(&contract.provider));
14386}
14387
14388fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14389 if let Some(spec) = match request {
14390 NodeRequest::SpawnSpec { spec }
14391 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14392 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14393 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14394 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14395 _ => None,
14396 } {
14397 return shared
14398 .resolve_spawn_spec(spec)
14399 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14400 }
14401 request_requires_open_provider_ids_with(
14402 request,
14403 |session| shared.validate_address(session).ok(),
14404 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14405 )
14406}
14407
14408fn request_requires_child_environment_profile(
14409 shared: &NodeShared,
14410 request: &NodeRequest,
14411) -> bool {
14412 if let Some(spec) = match request {
14413 NodeRequest::SpawnSpec { spec }
14414 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14415 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14416 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14417 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14418 _ => None,
14419 } {
14420 return shared
14421 .resolve_spawn_spec(spec)
14422 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14423 }
14424 match request {
14425 NodeRequest::Resume { session, .. }
14426 | NodeRequest::Prompt { session, .. }
14427 | NodeRequest::Paste { session, .. }
14428 | NodeRequest::Input { session, .. }
14429 | NodeRequest::TerminalBytes { session, .. }
14430 | NodeRequest::TerminalControl { session, .. }
14431 | NodeRequest::Resize { session, .. }
14432 | NodeRequest::Interrupt { session }
14433 | NodeRequest::Stop { session, .. }
14434 | NodeRequest::Remove { session }
14435 | NodeRequest::ResolveInteraction { session, .. }
14436 | NodeRequest::SetSessionMode { session, .. }
14437 | NodeRequest::SetSessionConfigOption { session, .. }
14438 | NodeRequest::SetSessionModel { session, .. }
14439 | NodeRequest::DiscoverHistory { session, .. }
14440 | NodeRequest::LoadHistory { session, .. }
14441 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14442 | NodeRequest::ExportContextPack { session } => shared
14443 .session_bindings
14444 .lock()
14445 .unwrap_or_else(|poisoned| poisoned.into_inner())
14446 .get(&session.session.instance_id)
14447 .filter(|binding| {
14448 binding.workspace_id == session.workspace_id
14449 && binding.generation == session.session.generation
14450 })
14451 .map_or(true, |binding| binding.environment_profile.is_some()),
14452 NodeRequest::RenameSessionRecord { record_id, .. }
14453 | NodeRequest::SetSessionTask { record_id, .. }
14454 | NodeRequest::ResumeSessionRecord { record_id, .. }
14455 | NodeRequest::ForgetSessionRecord { record_id } => shared
14456 .record(record_id)
14457 .map_or(true, |record| record.environment_profile.is_some()),
14458 NodeRequest::ArmHarnessMcpReservation { .. }
14459 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14460 | NodeRequest::ActivateHarnessMcpReservation { .. }
14461 | NodeRequest::AbortHarnessMcpReservation { .. }
14462 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14463 | NodeRequest::RejectHarnessMcpCall { .. }
14464 | NodeRequest::Snapshot
14465 | NodeRequest::Resync { .. }
14466 | NodeRequest::BeginDeliveryStage { .. }
14467 | NodeRequest::PutDeliveryBlobChunk { .. }
14468 | NodeRequest::CommitDeliveryStage { .. }
14469 | NodeRequest::AbortDeliveryStage { .. }
14470 | NodeRequest::BrowseHostDirectories { .. }
14471 | NodeRequest::InspectWorkspace { .. }
14472 | NodeRequest::ReadWorkspaceFile { .. }
14473 | NodeRequest::WriteWorkspaceFile { .. }
14474 | NodeRequest::CreateWorkspaceFile { .. }
14475 | NodeRequest::CreateWorkspaceDirectory { .. }
14476 | NodeRequest::ReadGitHistory { .. }
14477 | NodeRequest::ReadGitDiff { .. }
14478 | NodeRequest::AcquireController { .. }
14479 | NodeRequest::ReleaseController
14480 | NodeRequest::Spawn { .. }
14481 | NodeRequest::SpawnWithOptions { .. }
14482 | NodeRequest::SpawnSpec { .. }
14483 | NodeRequest::RegisterWorkspace { .. }
14484 | NodeRequest::CreateStandaloneWorkspace { .. }
14485 | NodeRequest::UnregisterWorkspace { .. }
14486 | NodeRequest::CreateWorktree { .. }
14487 | NodeRequest::RemoveWorktree { .. }
14488 | NodeRequest::SpawnManagedWorktree { .. }
14489 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14490 | NodeRequest::CleanupManagedWorktree { .. }
14491 | NodeRequest::IndexProviderSession { .. }
14492 | NodeRequest::IndexNativeSession { .. }
14493 | NodeRequest::CatalogNativeSessions { .. }
14494 | NodeRequest::PageNativeSessions { .. }
14495 | NodeRequest::PreviewNativeSession { .. }
14496 | NodeRequest::PreviewSessionRecord { .. }
14497 | NodeRequest::ForgetContextPack { .. }
14498 | NodeRequest::ResolveDurableContextPack { .. }
14499 | NodeRequest::ReadContextPack { .. }
14500 | NodeRequest::Shutdown => false,
14501 }
14502}
14503
14504fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14505 if let Some(spec) = match request {
14506 NodeRequest::SpawnSpec { spec }
14507 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14508 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14509 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14510 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14511 _ => None,
14512 } {
14513 return shared
14514 .resolve_spawn_spec(spec)
14515 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14516 }
14517 match request {
14518 NodeRequest::Resume { session, .. }
14519 | NodeRequest::Prompt { session, .. }
14520 | NodeRequest::Paste { session, .. }
14521 | NodeRequest::Input { session, .. }
14522 | NodeRequest::TerminalBytes { session, .. }
14523 | NodeRequest::TerminalControl { session, .. }
14524 | NodeRequest::Resize { session, .. }
14525 | NodeRequest::Interrupt { session }
14526 | NodeRequest::Stop { session, .. }
14527 | NodeRequest::Remove { session }
14528 | NodeRequest::ResolveInteraction { session, .. }
14529 | NodeRequest::SetSessionMode { session, .. }
14530 | NodeRequest::SetSessionConfigOption { session, .. }
14531 | NodeRequest::SetSessionModel { session, .. }
14532 | NodeRequest::DiscoverHistory { session, .. }
14533 | NodeRequest::LoadHistory { session, .. }
14534 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14535 | NodeRequest::ExportContextPack { session } => shared
14536 .session_bindings
14537 .lock()
14538 .unwrap_or_else(|poisoned| poisoned.into_inner())
14539 .get(&session.session.instance_id)
14540 .filter(|binding| {
14541 binding.workspace_id == session.workspace_id
14542 && binding.generation == session.session.generation
14543 })
14544 .map_or(true, |binding| binding.bundle.is_some()),
14545 NodeRequest::RenameSessionRecord { record_id, .. }
14546 | NodeRequest::SetSessionTask { record_id, .. }
14547 | NodeRequest::ResumeSessionRecord { record_id, .. }
14548 | NodeRequest::ForgetSessionRecord { record_id } => shared
14549 .record(record_id)
14550 .map_or(true, |record| record.bundle.is_some()),
14551 NodeRequest::ArmHarnessMcpReservation { .. }
14552 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14553 | NodeRequest::ActivateHarnessMcpReservation { .. }
14554 | NodeRequest::AbortHarnessMcpReservation { .. }
14555 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14556 | NodeRequest::RejectHarnessMcpCall { .. }
14557 | NodeRequest::Snapshot
14558 | NodeRequest::Resync { .. }
14559 | NodeRequest::BeginDeliveryStage { .. }
14560 | NodeRequest::PutDeliveryBlobChunk { .. }
14561 | NodeRequest::CommitDeliveryStage { .. }
14562 | NodeRequest::AbortDeliveryStage { .. }
14563 | NodeRequest::BrowseHostDirectories { .. }
14564 | NodeRequest::InspectWorkspace { .. }
14565 | NodeRequest::ReadWorkspaceFile { .. }
14566 | NodeRequest::WriteWorkspaceFile { .. }
14567 | NodeRequest::CreateWorkspaceFile { .. }
14568 | NodeRequest::CreateWorkspaceDirectory { .. }
14569 | NodeRequest::ReadGitHistory { .. }
14570 | NodeRequest::ReadGitDiff { .. }
14571 | NodeRequest::AcquireController { .. }
14572 | NodeRequest::ReleaseController
14573 | NodeRequest::Spawn { .. }
14574 | NodeRequest::SpawnWithOptions { .. }
14575 | NodeRequest::SpawnSpec { .. }
14576 | NodeRequest::RegisterWorkspace { .. }
14577 | NodeRequest::CreateStandaloneWorkspace { .. }
14578 | NodeRequest::UnregisterWorkspace { .. }
14579 | NodeRequest::CreateWorktree { .. }
14580 | NodeRequest::RemoveWorktree { .. }
14581 | NodeRequest::SpawnManagedWorktree { .. }
14582 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14583 | NodeRequest::CleanupManagedWorktree { .. }
14584 | NodeRequest::IndexProviderSession { .. }
14585 | NodeRequest::IndexNativeSession { .. }
14586 | NodeRequest::CatalogNativeSessions { .. }
14587 | NodeRequest::PageNativeSessions { .. }
14588 | NodeRequest::PreviewNativeSession { .. }
14589 | NodeRequest::PreviewSessionRecord { .. }
14590 | NodeRequest::ForgetContextPack { .. }
14591 | NodeRequest::ResolveDurableContextPack { .. }
14592 | NodeRequest::ReadContextPack { .. }
14593 | NodeRequest::Shutdown => false,
14594 }
14595}
14596
14597fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14598 if request.requires_history_context_pack_capability() {
14599 return true;
14600 }
14601 if let Some(spec) = match request {
14602 NodeRequest::SpawnSpec { spec }
14603 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14604 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14605 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14606 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14607 _ => None,
14608 } {
14609 return shared
14610 .resolve_spawn_spec(spec)
14611 .map_or(true, |resolved| resolved.context_id.is_some());
14612 }
14613 match request {
14614 NodeRequest::Resume { session, .. }
14615 | NodeRequest::Prompt { session, .. }
14616 | NodeRequest::Paste { session, .. }
14617 | NodeRequest::Input { session, .. }
14618 | NodeRequest::TerminalBytes { session, .. }
14619 | NodeRequest::TerminalControl { session, .. }
14620 | NodeRequest::Resize { session, .. }
14621 | NodeRequest::Interrupt { session }
14622 | NodeRequest::Stop { session, .. }
14623 | NodeRequest::Remove { session }
14624 | NodeRequest::ResolveInteraction { session, .. }
14625 | NodeRequest::SetSessionMode { session, .. }
14626 | NodeRequest::SetSessionConfigOption { session, .. }
14627 | NodeRequest::SetSessionModel { session, .. } => shared
14628 .session_bindings
14629 .lock()
14630 .unwrap_or_else(|poisoned| poisoned.into_inner())
14631 .get(&session.session.instance_id)
14632 .filter(|binding| {
14633 binding.workspace_id == session.workspace_id
14634 && binding.generation == session.session.generation
14635 })
14636 .map_or(true, |binding| binding.context.is_some()),
14637 NodeRequest::RenameSessionRecord { record_id, .. }
14638 | NodeRequest::SetSessionTask { record_id, .. }
14639 | NodeRequest::ResumeSessionRecord { record_id, .. }
14640 | NodeRequest::ForgetSessionRecord { record_id } => shared
14641 .record(record_id)
14642 .map_or(true, |record| record.context.is_some()),
14643 NodeRequest::ArmHarnessMcpReservation { .. }
14644 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14645 | NodeRequest::ActivateHarnessMcpReservation { .. }
14646 | NodeRequest::AbortHarnessMcpReservation { .. }
14647 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14648 | NodeRequest::RejectHarnessMcpCall { .. }
14649 | NodeRequest::Snapshot
14650 | NodeRequest::Resync { .. }
14651 | NodeRequest::BeginDeliveryStage { .. }
14652 | NodeRequest::PutDeliveryBlobChunk { .. }
14653 | NodeRequest::CommitDeliveryStage { .. }
14654 | NodeRequest::AbortDeliveryStage { .. }
14655 | NodeRequest::BrowseHostDirectories { .. }
14656 | NodeRequest::InspectWorkspace { .. }
14657 | NodeRequest::ReadWorkspaceFile { .. }
14658 | NodeRequest::WriteWorkspaceFile { .. }
14659 | NodeRequest::CreateWorkspaceFile { .. }
14660 | NodeRequest::CreateWorkspaceDirectory { .. }
14661 | NodeRequest::ReadGitHistory { .. }
14662 | NodeRequest::ReadGitDiff { .. }
14663 | NodeRequest::AcquireController { .. }
14664 | NodeRequest::ReleaseController
14665 | NodeRequest::Spawn { .. }
14666 | NodeRequest::SpawnWithOptions { .. }
14667 | NodeRequest::SpawnSpec { .. }
14668 | NodeRequest::RegisterWorkspace { .. }
14669 | NodeRequest::CreateStandaloneWorkspace { .. }
14670 | NodeRequest::UnregisterWorkspace { .. }
14671 | NodeRequest::CreateWorktree { .. }
14672 | NodeRequest::RemoveWorktree { .. }
14673 | NodeRequest::SpawnManagedWorktree { .. }
14674 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14675 | NodeRequest::CleanupManagedWorktree { .. }
14676 | NodeRequest::IndexProviderSession { .. }
14677 | NodeRequest::IndexNativeSession { .. }
14678 | NodeRequest::CatalogNativeSessions { .. }
14679 | NodeRequest::PageNativeSessions { .. }
14680 | NodeRequest::PreviewNativeSession { .. }
14681 | NodeRequest::PreviewSessionRecord { .. }
14682 | NodeRequest::DiscoverHistory { .. }
14683 | NodeRequest::LoadHistory { .. }
14684 | NodeRequest::ExportContextPackForSessionRecord { .. }
14685 | NodeRequest::ExportContextPack { .. }
14686 | NodeRequest::ForgetContextPack { .. }
14687 | NodeRequest::ResolveDurableContextPack { .. }
14688 | NodeRequest::ReadContextPack { .. }
14689 | NodeRequest::Shutdown => false,
14690 }
14691}
14692
14693fn request_requires_open_provider_ids_with(
14694 request: &NodeRequest,
14695 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14696 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14697) -> bool {
14698 match request {
14699 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
14700 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14701 NodeRequest::CatalogNativeSessions { route, .. }
14702 | NodeRequest::PageNativeSessions { route, .. } => {
14703 !provider_id_is_legacy(&route.provider)
14704 }
14705 NodeRequest::PreviewNativeSession { selection, .. }
14706 | NodeRequest::IndexNativeSession { selection, .. } => {
14707 !provider_id_is_legacy(&selection.route.provider)
14708 }
14709 NodeRequest::SpawnSpec { .. }
14710 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14711 NodeRequest::SpawnManagedWorktree { .. }
14712 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14713 NodeRequest::Resume { session, .. }
14714 | NodeRequest::Prompt { session, .. }
14715 | NodeRequest::Paste { session, .. }
14716 | NodeRequest::Input { session, .. }
14717 | NodeRequest::TerminalBytes { session, .. }
14718 | NodeRequest::TerminalControl { session, .. }
14719 | NodeRequest::Resize { session, .. }
14720 | NodeRequest::Interrupt { session }
14721 | NodeRequest::Stop { session, .. }
14722 | NodeRequest::Remove { session }
14723 | NodeRequest::ResolveInteraction { session, .. }
14724 | NodeRequest::SetSessionMode { session, .. }
14725 | NodeRequest::SetSessionConfigOption { session, .. }
14726 | NodeRequest::SetSessionModel { session, .. }
14727 | NodeRequest::DiscoverHistory { session, .. }
14728 | NodeRequest::LoadHistory { session, .. }
14729 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14730 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14731 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14732 NodeRequest::RenameSessionRecord { record_id, .. }
14733 | NodeRequest::SetSessionTask { record_id, .. }
14734 | NodeRequest::ResumeSessionRecord { record_id, .. }
14735 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14736 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14737 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14738 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14739 NodeRequest::ArmHarnessMcpReservation { .. }
14740 | NodeRequest::ActivateHarnessMcpReservation { .. }
14741 | NodeRequest::AbortHarnessMcpReservation { .. }
14742 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14743 | NodeRequest::RejectHarnessMcpCall { .. }
14744 | NodeRequest::Snapshot
14745 | NodeRequest::Resync { .. }
14746 | NodeRequest::BeginDeliveryStage { .. }
14747 | NodeRequest::PutDeliveryBlobChunk { .. }
14748 | NodeRequest::CommitDeliveryStage { .. }
14749 | NodeRequest::AbortDeliveryStage { .. }
14750 | NodeRequest::BrowseHostDirectories { .. }
14751 | NodeRequest::InspectWorkspace { .. }
14752 | NodeRequest::ReadWorkspaceFile { .. }
14753 | NodeRequest::WriteWorkspaceFile { .. }
14754 | NodeRequest::CreateWorkspaceFile { .. }
14755 | NodeRequest::CreateWorkspaceDirectory { .. }
14756 | NodeRequest::ReadGitHistory { .. }
14757 | NodeRequest::ReadGitDiff { .. }
14758 | NodeRequest::AcquireController { .. }
14759 | NodeRequest::ReleaseController
14760 | NodeRequest::RegisterWorkspace { .. }
14761 | NodeRequest::CreateStandaloneWorkspace { .. }
14762 | NodeRequest::UnregisterWorkspace { .. }
14763 | NodeRequest::CreateWorktree { .. }
14764 | NodeRequest::RemoveWorktree { .. }
14765 | NodeRequest::CleanupManagedWorktree { .. }
14766 | NodeRequest::Shutdown => false,
14767 NodeRequest::ForgetContextPack { .. } => true,
14768 NodeRequest::ResolveDurableContextPack { .. }
14774 | NodeRequest::ReadContextPack { .. } => true,
14775 }
14776}
14777
14778fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14779 snapshot.enabled_providers.retain(provider_id_is_legacy);
14780 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14781 snapshot
14782 .provider_runtime_statuses
14783 .as_slice()
14784 .iter()
14785 .filter(|status| provider_id_is_legacy(status.provider()))
14786 .cloned(),
14787 )
14788 .expect("a filtered provider runtime projection remains valid");
14789 snapshot
14790 .session_records
14791 .retain(|record| provider_id_is_legacy(&record.provider));
14792 for workspace in &mut snapshot.workspaces {
14793 workspace
14794 .sessions
14795 .retain(|session| provider_id_is_legacy(&session.agent_id));
14796 }
14797}
14798
14799fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14800 for record in &mut snapshot.session_records {
14801 record.environment_profile = None;
14802 }
14803 if let Some(profiles) = snapshot
14804 .launch_inventory
14805 .as_mut()
14806 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14807 {
14808 for profile in profiles {
14809 profile.environment_profile = None;
14810 }
14811 }
14812}
14813
14814fn project_event_without_child_environment_profile(
14815 mut envelope: NodeEventEnvelope,
14816) -> NodeEventEnvelope {
14817 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14818 record.environment_profile = None;
14819 }
14820 envelope
14821}
14822
14823fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14824 let contains_environment_profile = match reply.result.as_ref() {
14825 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14826 receipt.environment_profile.is_some()
14827 }
14828 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14829 receipt.spawn.environment_profile.is_some()
14830 }
14831 _ => false,
14832 };
14833 if contains_environment_profile {
14834 reply.result = Err(failure(
14835 NodeFailureCode::UnsupportedCapability,
14836 "child environment profiles were not negotiated",
14837 ));
14838 return;
14839 }
14840 let Ok(response) = reply.result.as_mut() else {
14841 return;
14842 };
14843 match response {
14844 NodeResponse::Snapshot { snapshot, .. } => {
14845 clear_snapshot_child_environment_profiles(snapshot);
14846 }
14847 NodeResponse::Resync { snapshot, events, .. } => {
14848 clear_snapshot_child_environment_profiles(snapshot);
14849 for event in events {
14850 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14851 record.environment_profile = None;
14852 }
14853 }
14854 }
14855 NodeResponse::SessionRecordUpdated { record }
14856 | NodeResponse::ProviderSessionIndexed { record }
14857 | NodeResponse::NativeSessionIndexed { record, .. }
14858 | NodeResponse::SessionRecordResumed { record, .. } => {
14859 record.environment_profile = None;
14860 }
14861 NodeResponse::Armed { .. }
14862 | NodeResponse::Spawned { .. }
14863 | NodeResponse::Activated { .. }
14864 | NodeResponse::Aborted { .. }
14865 | NodeResponse::ReplyChunkAccepted { .. }
14866 | NodeResponse::CallRejected { .. }
14867 | NodeResponse::DeliveryStageBegun { .. }
14868 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14869 | NodeResponse::DeliveryCommitted { .. }
14870 | NodeResponse::DeliveryStageAborted { .. }
14871 | NodeResponse::WorkspaceInspected { .. }
14872 | NodeResponse::HostDirectoriesBrowsed { .. }
14873 | NodeResponse::WorkspaceFileRead { .. }
14874 | NodeResponse::WorkspaceFileWritten { .. }
14875 | NodeResponse::WorkspaceFileCreated { .. }
14876 | NodeResponse::WorkspaceDirectoryCreated { .. }
14877 | NodeResponse::GitHistoryRead { .. }
14878 | NodeResponse::GitDiffRead { .. }
14879 | NodeResponse::Controller { .. }
14880 | NodeResponse::SpawnAccepted { .. }
14881 | NodeResponse::SpawnSpecAccepted { .. }
14882 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14883 | NodeResponse::ManagedWorktreeCleanup { .. }
14884 | NodeResponse::NativeSessionsCataloged { .. }
14885 | NodeResponse::NativeSessionsPaged { .. }
14886 | NodeResponse::NativeSessionPreviewed { .. }
14887 | NodeResponse::SessionRecordPreviewed { .. }
14888 | NodeResponse::HistoryDiscovered { .. }
14889 | NodeResponse::HistoryLoaded { .. }
14890 | NodeResponse::ContextPackForSessionRecordExported { .. }
14891 | NodeResponse::ContextPackExported { .. }
14892 | NodeResponse::ContextPackForgotten { .. }
14893 | NodeResponse::DurableContextPackResolved { .. }
14894 | NodeResponse::ContextPackBytesRead { .. }
14895 | NodeResponse::SessionRecordForgotten { .. }
14896 | NodeResponse::WorkspaceRegistered { .. }
14897 | NodeResponse::StandaloneWorkspaceCreated { .. }
14898 | NodeResponse::WorkspaceUnregistered { .. }
14899 | NodeResponse::WorktreeCreated { .. }
14900 | NodeResponse::WorktreeRemoved { .. }
14901 | NodeResponse::Accepted
14902 | NodeResponse::ShuttingDown => {}
14903 }
14904}
14905
14906fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14907 for record in &mut snapshot.session_records {
14908 record.bundle = None;
14909 }
14910}
14911
14912fn project_event_without_session_bundle(
14913 mut envelope: NodeEventEnvelope,
14914) -> NodeEventEnvelope {
14915 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14916 record.bundle = None;
14917 }
14918 envelope
14919}
14920
14921fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14922 let contains_bundle = match reply.result.as_ref() {
14923 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14924 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14925 receipt.spawn.bundle.is_some()
14926 }
14927 _ => false,
14928 };
14929 if contains_bundle {
14930 reply.result = Err(failure(
14931 NodeFailureCode::UnsupportedCapability,
14932 "session bundles were not negotiated",
14933 ));
14934 return;
14935 }
14936 let Ok(response) = reply.result.as_mut() else {
14937 return;
14938 };
14939 match response {
14940 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14941 NodeResponse::Resync { snapshot, events, .. } => {
14942 clear_snapshot_session_bundles(snapshot);
14943 for event in events {
14944 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14945 record.bundle = None;
14946 }
14947 }
14948 }
14949 NodeResponse::SessionRecordUpdated { record }
14950 | NodeResponse::ProviderSessionIndexed { record }
14951 | NodeResponse::NativeSessionIndexed { record, .. }
14952 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14953 NodeResponse::Armed { .. }
14954 | NodeResponse::Spawned { .. }
14955 | NodeResponse::Activated { .. }
14956 | NodeResponse::Aborted { .. }
14957 | NodeResponse::ReplyChunkAccepted { .. }
14958 | NodeResponse::CallRejected { .. }
14959 | NodeResponse::DeliveryStageBegun { .. }
14960 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14961 | NodeResponse::DeliveryCommitted { .. }
14962 | NodeResponse::DeliveryStageAborted { .. }
14963 | NodeResponse::WorkspaceInspected { .. }
14964 | NodeResponse::HostDirectoriesBrowsed { .. }
14965 | NodeResponse::WorkspaceFileRead { .. }
14966 | NodeResponse::WorkspaceFileWritten { .. }
14967 | NodeResponse::WorkspaceFileCreated { .. }
14968 | NodeResponse::WorkspaceDirectoryCreated { .. }
14969 | NodeResponse::GitHistoryRead { .. }
14970 | NodeResponse::GitDiffRead { .. }
14971 | NodeResponse::Controller { .. }
14972 | NodeResponse::SpawnAccepted { .. }
14973 | NodeResponse::SpawnSpecAccepted { .. }
14974 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14975 | NodeResponse::ManagedWorktreeCleanup { .. }
14976 | NodeResponse::NativeSessionsCataloged { .. }
14977 | NodeResponse::NativeSessionsPaged { .. }
14978 | NodeResponse::NativeSessionPreviewed { .. }
14979 | NodeResponse::SessionRecordPreviewed { .. }
14980 | NodeResponse::HistoryDiscovered { .. }
14981 | NodeResponse::HistoryLoaded { .. }
14982 | NodeResponse::ContextPackForSessionRecordExported { .. }
14983 | NodeResponse::ContextPackExported { .. }
14984 | NodeResponse::ContextPackForgotten { .. }
14985 | NodeResponse::DurableContextPackResolved { .. }
14986 | NodeResponse::ContextPackBytesRead { .. }
14987 | NodeResponse::SessionRecordForgotten { .. }
14988 | NodeResponse::WorkspaceRegistered { .. }
14989 | NodeResponse::StandaloneWorkspaceCreated { .. }
14990 | NodeResponse::WorkspaceUnregistered { .. }
14991 | NodeResponse::WorktreeCreated { .. }
14992 | NodeResponse::WorktreeRemoved { .. }
14993 | NodeResponse::Accepted
14994 | NodeResponse::ShuttingDown => {}
14995 }
14996}
14997
14998fn project_snapshot_history_for_wire(
14999 snapshot: &mut NodeSnapshot,
15000 include_history_metadata: bool,
15001) {
15002 for workspace in &mut snapshot.workspaces {
15003 for session in &mut workspace.sessions {
15004 if include_history_metadata {
15005 session.history.loaded = None;
15006 } else {
15007 session.history = Default::default();
15008 }
15009 }
15010 }
15011}
15012
15013fn project_event_history_for_wire(
15014 mut envelope: NodeEventEnvelope,
15015 include_history_metadata: bool,
15016) -> NodeEventEnvelope {
15017 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15018 for session in &mut workspace.sessions {
15019 if include_history_metadata {
15020 session.history.loaded = None;
15021 } else {
15022 session.history = Default::default();
15023 }
15024 }
15025 }
15026 envelope
15027}
15028
15029fn project_response_history_for_wire(
15030 reply: &mut ResponseEnvelope,
15031 include_history_metadata: bool,
15032) {
15033 let Ok(response) = reply.result.as_mut() else {
15034 return;
15035 };
15036 match response {
15037 NodeResponse::Snapshot { snapshot, .. } => {
15038 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15039 }
15040 NodeResponse::Resync {
15041 snapshot, events, ..
15042 } => {
15043 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15044 for event in events {
15045 *event = project_event_history_for_wire(
15046 event.clone(),
15047 include_history_metadata,
15048 );
15049 }
15050 }
15051 NodeResponse::WorkspaceRegistered { workspace }
15052 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15053 | NodeResponse::WorktreeCreated { workspace, .. } => {
15054 for session in &mut workspace.sessions {
15055 if include_history_metadata {
15056 session.history.loaded = None;
15057 } else {
15058 session.history = Default::default();
15059 }
15060 }
15061 }
15062 _ => {}
15063 }
15064}
15065
15066fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15067 for record in &mut snapshot.session_records {
15068 record.context_id = None;
15069 record.context = None;
15070 record.exported_context = None;
15071 }
15072}
15073
15074fn project_event_without_context_pack(
15075 mut envelope: NodeEventEnvelope,
15076) -> NodeEventEnvelope {
15077 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15078 record.context_id = None;
15079 record.context = None;
15080 record.exported_context = None;
15081 }
15082 envelope
15083}
15084
15085fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15086 let contains_context = match reply.result.as_ref() {
15087 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15088 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15089 receipt.spawn.context.is_some()
15090 }
15091 Ok(NodeResponse::HistoryDiscovered { .. })
15092 | Ok(NodeResponse::HistoryLoaded { .. })
15093 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15094 | Ok(NodeResponse::ContextPackExported { .. })
15095 | Ok(NodeResponse::ContextPackForgotten { .. })
15096 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15097 _ => false,
15098 };
15099 if contains_context {
15100 reply.result = Err(failure(
15101 NodeFailureCode::UnsupportedCapability,
15102 "history context packs were not negotiated",
15103 ));
15104 return;
15105 }
15106 let Ok(response) = reply.result.as_mut() else {
15107 return;
15108 };
15109 match response {
15110 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15111 NodeResponse::Resync {
15112 snapshot, events, ..
15113 } => {
15114 clear_snapshot_context_packs(snapshot);
15115 for event in events {
15116 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15117 record.context_id = None;
15118 record.context = None;
15119 record.exported_context = None;
15120 }
15121 }
15122 }
15123 NodeResponse::SessionRecordUpdated { record }
15124 | NodeResponse::ProviderSessionIndexed { record }
15125 | NodeResponse::NativeSessionIndexed { record, .. }
15126 | NodeResponse::SessionRecordResumed { record, .. } => {
15127 record.context_id = None;
15128 record.context = None;
15129 record.exported_context = None;
15130 }
15131 _ => {}
15132 }
15133}
15134
15135fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15136 let contains_open_record = match reply.result.as_ref() {
15137 Ok(NodeResponse::SessionRecordUpdated { record })
15138 | Ok(NodeResponse::ProviderSessionIndexed { record })
15139 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15140 !provider_id_is_legacy(&record.provider)
15141 }
15142 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15143 !provider_id_is_legacy(&selection.route.provider)
15144 || !provider_id_is_legacy(&record.provider)
15145 }
15146 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15147 !provider_id_is_legacy(&receipt.provider)
15148 }
15149 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15150 !provider_id_is_legacy(&receipt.spawn.provider)
15151 }
15152 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15153 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15154 !provider_id_is_legacy(&route.provider)
15155 }
15156 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15157 !provider_id_is_legacy(&selection.route.provider)
15158 }
15159 _ => false,
15160 };
15161 if contains_open_record {
15162 reply.result = Err(failure(
15163 NodeFailureCode::UnsupportedCapability,
15164 "open provider IDs were not negotiated",
15165 ));
15166 return;
15167 }
15168 let Ok(response) = reply.result.as_mut() else {
15169 return;
15170 };
15171 match response {
15172 NodeResponse::Snapshot { snapshot, .. } => {
15173 project_snapshot_legacy_provider_ids(snapshot);
15174 }
15175 NodeResponse::Resync { snapshot, events, .. } => {
15176 project_snapshot_legacy_provider_ids(snapshot);
15177 *events = std::mem::take(events)
15178 .into_iter()
15179 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15180 .collect();
15181 }
15182 NodeResponse::WorkspaceRegistered { workspace }
15183 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15184 | NodeResponse::WorktreeCreated { workspace, .. } => {
15185 workspace
15186 .sessions
15187 .retain(|session| provider_id_is_legacy(&session.agent_id));
15188 }
15189 NodeResponse::Armed { .. }
15190 | NodeResponse::Spawned { .. }
15191 | NodeResponse::Activated { .. }
15192 | NodeResponse::Aborted { .. }
15193 | NodeResponse::ReplyChunkAccepted { .. }
15194 | NodeResponse::CallRejected { .. }
15195 | NodeResponse::DeliveryStageBegun { .. }
15196 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15197 | NodeResponse::DeliveryCommitted { .. }
15198 | NodeResponse::DeliveryStageAborted { .. }
15199 | NodeResponse::WorkspaceInspected { .. }
15200 | NodeResponse::HostDirectoriesBrowsed { .. }
15201 | NodeResponse::WorkspaceFileRead { .. }
15202 | NodeResponse::WorkspaceFileWritten { .. }
15203 | NodeResponse::WorkspaceFileCreated { .. }
15204 | NodeResponse::WorkspaceDirectoryCreated { .. }
15205 | NodeResponse::GitHistoryRead { .. }
15206 | NodeResponse::GitDiffRead { .. }
15207 | NodeResponse::Controller { .. }
15208 | NodeResponse::SpawnAccepted { .. }
15209 | NodeResponse::SpawnSpecAccepted { .. }
15210 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15211 | NodeResponse::ManagedWorktreeCleanup { .. }
15212 | NodeResponse::NativeSessionsCataloged { .. }
15213 | NodeResponse::NativeSessionsPaged { .. }
15214 | NodeResponse::NativeSessionPreviewed { .. }
15215 | NodeResponse::SessionRecordPreviewed { .. }
15216 | NodeResponse::HistoryDiscovered { .. }
15217 | NodeResponse::HistoryLoaded { .. }
15218 | NodeResponse::ContextPackForSessionRecordExported { .. }
15219 | NodeResponse::ContextPackExported { .. }
15220 | NodeResponse::ContextPackForgotten { .. }
15221 | NodeResponse::DurableContextPackResolved { .. }
15222 | NodeResponse::ContextPackBytesRead { .. }
15223 | NodeResponse::SessionRecordUpdated { .. }
15224 | NodeResponse::ProviderSessionIndexed { .. }
15225 | NodeResponse::NativeSessionIndexed { .. }
15226 | NodeResponse::SessionRecordResumed { .. }
15227 | NodeResponse::SessionRecordForgotten { .. }
15228 | NodeResponse::WorkspaceUnregistered { .. }
15229 | NodeResponse::WorktreeRemoved { .. }
15230 | NodeResponse::Accepted
15231 | NodeResponse::ShuttingDown => {}
15232 }
15233}
15234
15235fn project_event_legacy_provider_ids(
15236 shared: &NodeShared,
15237 mut envelope: NodeEventEnvelope,
15238) -> Option<NodeEventEnvelope> {
15239 let include = match &mut envelope.event {
15240 NodeEvent::HarnessMcpReadCall { .. } => true,
15241 NodeEvent::Control { address, .. }
15242 | NodeEvent::AgentStream { address, .. }
15243 | NodeEvent::TerminalFrame { address, .. } => shared
15244 .validate_address(address)
15245 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15246 NodeEvent::WorkspaceAdded { workspace } => {
15247 workspace
15248 .sessions
15249 .retain(|session| provider_id_is_legacy(&session.agent_id));
15250 true
15251 }
15252 NodeEvent::SessionRecordUpserted { record } => {
15253 provider_id_is_legacy(&record.provider)
15254 }
15255 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15256 NodeEvent::SessionRecordRemoved { .. } => shared
15257 .history
15258 .lock()
15259 .unwrap_or_else(|poisoned| poisoned.into_inner())
15260 .removed_record_providers
15261 .get(&envelope.sequence)
15262 .is_some_and(provider_id_is_legacy),
15263 NodeEvent::ControllerChanged { .. }
15264 | NodeEvent::ManagedWorktreeUpserted { .. }
15265 | NodeEvent::ManagedWorktreeRemoved { .. }
15266 | NodeEvent::WorkspaceRemoved { .. }
15267 | NodeEvent::ResyncRequired { .. } => true,
15268 };
15269 include.then_some(envelope)
15270}
15271
15272fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15273 let Ok(response) = reply.result.as_mut() else {
15274 return;
15275 };
15276 match response {
15277 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15278 snapshot.provider_runtime_statuses.clear();
15279 }
15280 NodeResponse::Armed { .. }
15281 | NodeResponse::Spawned { .. }
15282 | NodeResponse::Activated { .. }
15283 | NodeResponse::Aborted { .. }
15284 | NodeResponse::ReplyChunkAccepted { .. }
15285 | NodeResponse::CallRejected { .. }
15286 | NodeResponse::DeliveryStageBegun { .. }
15287 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15288 | NodeResponse::DeliveryCommitted { .. }
15289 | NodeResponse::DeliveryStageAborted { .. }
15290 | NodeResponse::WorkspaceInspected { .. }
15291 | NodeResponse::HostDirectoriesBrowsed { .. }
15292 | NodeResponse::WorkspaceFileRead { .. }
15293 | NodeResponse::WorkspaceFileWritten { .. }
15294 | NodeResponse::WorkspaceFileCreated { .. }
15295 | NodeResponse::WorkspaceDirectoryCreated { .. }
15296 | NodeResponse::GitHistoryRead { .. }
15297 | NodeResponse::GitDiffRead { .. }
15298 | NodeResponse::Controller { .. }
15299 | NodeResponse::SpawnAccepted { .. }
15300 | NodeResponse::SpawnSpecAccepted { .. }
15301 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15302 | NodeResponse::ManagedWorktreeCleanup { .. }
15303 | NodeResponse::NativeSessionsCataloged { .. }
15304 | NodeResponse::NativeSessionsPaged { .. }
15305 | NodeResponse::NativeSessionPreviewed { .. }
15306 | NodeResponse::SessionRecordPreviewed { .. }
15307 | NodeResponse::HistoryDiscovered { .. }
15308 | NodeResponse::HistoryLoaded { .. }
15309 | NodeResponse::ContextPackForSessionRecordExported { .. }
15310 | NodeResponse::ContextPackExported { .. }
15311 | NodeResponse::ContextPackForgotten { .. }
15312 | NodeResponse::DurableContextPackResolved { .. }
15313 | NodeResponse::ContextPackBytesRead { .. }
15314 | NodeResponse::SessionRecordUpdated { .. }
15315 | NodeResponse::ProviderSessionIndexed { .. }
15316 | NodeResponse::NativeSessionIndexed { .. }
15317 | NodeResponse::SessionRecordResumed { .. }
15318 | NodeResponse::SessionRecordForgotten { .. }
15319 | NodeResponse::WorkspaceRegistered { .. }
15320 | NodeResponse::StandaloneWorkspaceCreated { .. }
15321 | NodeResponse::WorkspaceUnregistered { .. }
15322 | NodeResponse::WorktreeCreated { .. }
15323 | NodeResponse::WorktreeRemoved { .. }
15324 | NodeResponse::Accepted
15325 | NodeResponse::ShuttingDown => {}
15326 }
15327}
15328
15329struct ControllerReleaseGuard {
15330 shared: Arc<NodeShared>,
15331 connection_id: u64,
15332}
15333
15334impl Drop for ControllerReleaseGuard {
15335 fn drop(&mut self) {
15336 self.shared.release_controller(self.connection_id);
15337 }
15338}
15339
15340struct AbortTaskOnDrop(AbortHandle);
15341
15342impl Drop for AbortTaskOnDrop {
15343 fn drop(&mut self) {
15344 self.0.abort();
15345 }
15346}
15347
15348fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15353 std::env::var(name)
15354 .ok()
15355 .and_then(|value| value.parse::<u64>().ok())
15356 .filter(|value| *value > 0)
15357 .unwrap_or(default)
15358 .min(max)
15359}
15360
15361fn workspace_inspection_time_budget_ms() -> u64 {
15362 env_config_bounded(
15363 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15364 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15365 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15366 )
15367}
15368
15369fn workspace_inspection_entry_cap() -> usize {
15370 env_config_bounded(
15371 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15372 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15373 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15374 ) as usize
15375}
15376
15377struct WorkspaceWalkBudget {
15383 deadline: Instant,
15384 entry_cap: usize,
15385 entries_visited: u64,
15386 time_budget_exceeded: bool,
15387 entry_cap_exceeded: bool,
15388}
15389
15390impl WorkspaceWalkBudget {
15391 fn new(deadline: Instant, entry_cap: usize) -> Self {
15392 Self {
15393 deadline,
15394 entry_cap,
15395 entries_visited: 0,
15396 time_budget_exceeded: false,
15397 entry_cap_exceeded: false,
15398 }
15399 }
15400
15401 fn is_exhausted(&self) -> bool {
15402 self.time_budget_exceeded || self.entry_cap_exceeded
15403 }
15404
15405 fn record_visit(&mut self) -> bool {
15408 self.entries_visited += 1;
15409 if self.entries_visited as usize >= self.entry_cap {
15410 self.entry_cap_exceeded = true;
15411 }
15412 if Instant::now() >= self.deadline {
15413 self.time_budget_exceeded = true;
15414 }
15415 self.is_exhausted()
15416 }
15417}
15418
15419fn collect_workspace_entries(
15420 root: &Path,
15421 deadline: Instant,
15422 entry_cap: usize,
15423) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15424 let mut entries = Vec::new();
15425 let mut truncated = false;
15426 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15427 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15428 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15432 (entries, truncated, budget)
15433}
15434
15435fn workspace_inspection_truncation(
15439 walk_budget: &WorkspaceWalkBudget,
15440 git_time_budget_exceeded: bool,
15441 elapsed: Duration,
15442) -> Option<WorkspaceInspectionTruncationV1> {
15443 if !walk_budget.time_budget_exceeded
15444 && !walk_budget.entry_cap_exceeded
15445 && !git_time_budget_exceeded
15446 {
15447 return None;
15448 }
15449 Some(WorkspaceInspectionTruncationV1 {
15450 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15451 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15452 git_time_budget_exceeded,
15453 entries_visited: walk_budget.entries_visited,
15454 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15455 })
15456}
15457
15458fn walk_workspace_directory(
15459 directory: &Path,
15460 relative_directory: Option<&RepositoryPath>,
15461 depth: usize,
15462 entries: &mut Vec<WorkspaceEntry>,
15463 truncated: &mut bool,
15464 budget: &mut WorkspaceWalkBudget,
15465) {
15466 if budget.is_exhausted() {
15467 *truncated = true;
15468 return;
15469 }
15470 let read_dir = match std::fs::read_dir(directory) {
15471 Ok(read_dir) => read_dir,
15472 Err(_) => {
15473 *truncated = true;
15474 return;
15475 }
15476 };
15477 let mut children = Vec::new();
15478 for child in read_dir {
15479 match child {
15480 Ok(child) => children.push(child),
15481 Err(_) => *truncated = true,
15482 }
15483 }
15484 children.sort_by_key(|child| child.file_name());
15485 for child in children {
15486 if budget.record_visit() {
15487 *truncated = true;
15488 return;
15489 }
15490 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15491 *truncated = true;
15492 return;
15493 }
15494 let file_type = match child.file_type() {
15495 Ok(file_type) => file_type,
15496 Err(_) => {
15497 *truncated = true;
15498 continue;
15499 }
15500 };
15501 if file_type.is_symlink() {
15502 continue;
15503 }
15504 let name = match child.file_name().into_string() {
15505 Ok(name) => name,
15506 Err(_) => {
15507 *truncated = true;
15508 continue;
15509 }
15510 };
15511 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15512 continue;
15513 }
15514 let relative_path = match relative_directory {
15515 None => windows_repository_path(name),
15516 Some(parent) => parent
15517 .as_utf8()
15518 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15519 };
15520 let Some(relative_path) = relative_path else {
15521 *truncated = true;
15522 continue;
15523 };
15524 let kind = if file_type.is_dir() {
15525 WorkspaceEntryKind::Directory
15526 } else if file_type.is_file() {
15527 WorkspaceEntryKind::File
15528 } else {
15529 continue;
15530 };
15531 entries.push(WorkspaceEntry {
15532 relative_path: relative_path.clone(),
15533 kind,
15534 });
15535 if file_type.is_dir() {
15536 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15537 walk_workspace_directory(
15538 &child.path(),
15539 Some(&relative_path),
15540 depth + 1,
15541 entries,
15542 truncated,
15543 budget,
15544 );
15545 } else {
15546 *truncated = true;
15547 }
15548 }
15549 }
15550}
15551
15552fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15553 if value.contains('\\') {
15554 return None;
15555 }
15556 RepositoryPath::utf8(value).ok()
15557}
15558
15559fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15560 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15561}
15562
15563fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15572 if name.eq_ignore_ascii_case(".git")
15573 || name.eq_ignore_ascii_case("target")
15574 || name.eq_ignore_ascii_case("node_modules")
15575 {
15576 return true;
15577 }
15578 path.join("CACHEDIR.TAG").is_file()
15579}
15580
15581fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15582 matches!(
15583 status,
15584 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15585 )
15586}
15587
15588#[derive(Clone, Debug, Eq, PartialEq)]
15589enum ContextPackRepositoryHead {
15590 NotRepository,
15591 Unborn,
15592 Commit(String),
15593}
15594
15595#[derive(Clone, Debug, Eq, PartialEq)]
15596struct ContextPackRepositoryObservation {
15597 head: ContextPackRepositoryHead,
15598 git: GitSnapshot,
15599 files: Vec<ContextPackRepositoryFileSource>,
15600}
15601
15602fn stable_context_pack_repository(
15603 first: ContextPackRepositoryObservation,
15604 second: ContextPackRepositoryObservation,
15605) -> Result<ContextPackRepository, NodeFailure> {
15606 if first != second {
15607 return Err(failure(
15608 NodeFailureCode::ContextPackMaterializationFailed,
15609 "context repository changed during bounded capture",
15610 ));
15611 }
15612 Ok(ContextPackRepository::from_sources(
15613 &second.git,
15614 second.files,
15615 ))
15616}
15617
15618fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15625 if Instant::now() < deadline {
15626 return false;
15627 }
15628 append_git_diagnostic(
15629 snapshot,
15630 &format!("{stage} skipped: workspace inspection time budget exhausted"),
15631 );
15632 true
15633}
15634
15635async fn run_git_bounded_with_deadline(
15644 root: &str,
15645 arguments: &[&str],
15646 output_limit: usize,
15647 deadline: Instant,
15648) -> io::Result<GitCommandOutput> {
15649 let remaining = deadline.checked_duration_since(Instant::now());
15650 let timeout_ms = remaining
15651 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15652 .unwrap_or(0)
15653 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15654 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15655}
15656
15657async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15664 let mut snapshot = GitSnapshot {
15665 is_repository: false,
15666 branch: None,
15667 status: Vec::new(),
15668 recent_commits: Vec::new(),
15669 worktrees: Vec::new(),
15670 managed_worktree: None,
15671 truncated: false,
15672 diagnostic: None,
15673 };
15674 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15675 return (snapshot, true);
15676 }
15677 let repository = match run_git_bounded_with_deadline(
15678 root,
15679 &["rev-parse", "--is-inside-work-tree"],
15680 4 * 1_024,
15681 deadline,
15682 )
15683 .await
15684 {
15685 Ok(output) => output,
15686 Err(error) => {
15687 append_git_diagnostic(
15688 &mut snapshot,
15689 &format!("git inspection unavailable: {error}"),
15690 );
15691 return (snapshot, false);
15692 }
15693 };
15694 snapshot.truncated |= repository.truncated;
15695 if repository.timed_out {
15696 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15697 return (snapshot, false);
15698 }
15699 if !repository.success
15700 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15701 {
15702 let stderr = String::from_utf8_lossy(&repository.stderr);
15703 if !stderr.to_ascii_lowercase().contains("not a git repository") {
15704 append_git_diagnostic(&mut snapshot, stderr.trim());
15705 }
15706 return (snapshot, false);
15707 }
15708 snapshot.is_repository = true;
15709
15710 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15711 return (snapshot, true);
15712 }
15713 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15714 Ok(output) => {
15715 snapshot.truncated |= output.truncated;
15716 if output.timed_out {
15717 append_git_diagnostic(&mut snapshot, "git branch query timed out");
15718 } else if output.success {
15719 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15720 if !branch.is_empty() {
15721 snapshot.branch = Some(truncate_git_field(
15722 &branch,
15723 MAX_REPOSITORY_PATH_BYTES,
15724 &mut snapshot.truncated,
15725 ));
15726 }
15727 }
15728 }
15729 Err(error) => append_git_diagnostic(
15730 &mut snapshot,
15731 &format!("git branch query failed: {error}"),
15732 ),
15733 }
15734 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15735 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15736 snapshot.truncated |= output.truncated;
15737 if output.success && !output.timed_out {
15738 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15739 if !head.is_empty() {
15740 snapshot.branch = Some(format!("detached:{head}"));
15741 }
15742 }
15743 }
15744 }
15745
15746 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15747 return (snapshot, true);
15748 }
15749 match run_git_bounded_with_deadline(
15750 root,
15751 &[
15752 "status",
15753 "--porcelain=v1",
15754 "-z",
15755 "--renames",
15756 "--untracked-files=normal",
15757 "--",
15758 ".",
15759 ],
15760 GIT_OUTPUT_MAX_BYTES,
15761 deadline,
15762 )
15763 .await
15764 {
15765 Ok(output) => {
15766 snapshot.truncated |= output.truncated;
15767 if output.timed_out {
15768 append_git_diagnostic(&mut snapshot, "git status timed out");
15769 } else if output.success {
15770 parse_git_status(&output.stdout, &mut snapshot);
15771 } else {
15772 append_git_diagnostic(
15773 &mut snapshot,
15774 String::from_utf8_lossy(&output.stderr).trim(),
15775 );
15776 }
15777 }
15778 Err(error) => append_git_diagnostic(
15779 &mut snapshot,
15780 &format!("git status failed: {error}"),
15781 ),
15782 }
15783
15784 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15785 return (snapshot, true);
15786 }
15787 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15788 match run_git_bounded_with_deadline(
15789 root,
15790 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15791 16 * 1_024,
15792 deadline,
15793 )
15794 .await
15795 {
15796 Ok(output) => {
15797 snapshot.truncated |= output.truncated;
15798 if output.timed_out {
15799 append_git_diagnostic(&mut snapshot, "git log timed out");
15800 } else if output.success {
15801 parse_git_commits(&output.stdout, &mut snapshot);
15802 } else {
15803 let stderr = String::from_utf8_lossy(&output.stderr);
15804 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15805 append_git_diagnostic(&mut snapshot, stderr.trim());
15806 }
15807 }
15808 }
15809 Err(error) => append_git_diagnostic(
15810 &mut snapshot,
15811 &format!("git log failed: {error}"),
15812 ),
15813 }
15814
15815 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15816 return (snapshot, true);
15817 }
15818 match list_git_worktrees_with_deadline(root, deadline).await {
15819 Ok(worktrees) => {
15820 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15821 }
15822 Err(error) => append_git_diagnostic(
15823 &mut snapshot,
15824 &format!("git worktree list failed: {}", error.message),
15825 ),
15826 }
15827 (snapshot, false)
15828}
15829
15830fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15831 let mut remaining = output;
15832 while !remaining.is_empty() {
15833 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15834 snapshot.truncated = true;
15835 break;
15836 }
15837 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15838 snapshot.truncated = true;
15839 break;
15840 };
15841 let record = &remaining[..record_end];
15842 remaining = &remaining[record_end + 1..];
15843 let has_previous_path = record
15844 .get(..2)
15845 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15846 let previous_path_bytes = if has_previous_path {
15847 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15848 snapshot.truncated = true;
15849 break;
15850 };
15851 let previous = &remaining[..previous_end];
15852 remaining = &remaining[previous_end + 1..];
15853 Some(previous)
15854 } else {
15855 None
15856 };
15857 if record.len() < 4
15858 || record[2] != b' '
15859 || !is_git_status_code(record[0])
15860 || !is_git_status_code(record[1])
15861 {
15862 snapshot.truncated = true;
15863 continue;
15864 }
15865 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15866 snapshot.truncated = true;
15867 continue;
15868 };
15869 let previous_path = match previous_path_bytes {
15870 Some(previous) => {
15871 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15872 snapshot.truncated = true;
15873 continue;
15874 };
15875 Some(previous)
15876 }
15877 None => None,
15878 };
15879 snapshot.status.push(GitStatusEntry {
15880 index_status: (record[0] as char).to_string(),
15881 worktree_status: (record[1] as char).to_string(),
15882 path,
15883 previous_path,
15884 });
15885 }
15886}
15887
15888fn is_git_status_code(byte: u8) -> bool {
15889 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15890}
15891
15892fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15893 for line in String::from_utf8_lossy(output).lines() {
15894 let Some((id, summary)) = line.split_once('\u{1f}') else {
15895 continue;
15896 };
15897 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15898 snapshot.truncated = true;
15899 break;
15900 }
15901 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15902 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15903 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15904 }
15905}
15906
15907fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15908 if value.len() <= max_bytes {
15909 return value.to_owned();
15910 }
15911 *truncated = true;
15912 let mut boundary = max_bytes;
15913 while boundary > 0 && !value.is_char_boundary(boundary) {
15914 boundary -= 1;
15915 }
15916 value[..boundary].to_owned()
15917}
15918
15919fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15920 if message.is_empty() {
15921 return;
15922 }
15923 let normalized = message
15924 .chars()
15925 .map(|character| if character.is_control() { ' ' } else { character })
15926 .collect::<String>();
15927 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15928 if !diagnostic.is_empty() {
15929 diagnostic.push_str("; ");
15930 }
15931 diagnostic.push_str(normalized.trim());
15932 snapshot.diagnostic = Some(truncate_git_field(
15933 &diagnostic,
15934 GIT_DIAGNOSTIC_MAX_BYTES,
15935 &mut snapshot.truncated,
15936 ));
15937}
15938
15939async fn run_git_bounded(
15940 root: &str,
15941 arguments: &[&str],
15942 output_limit: usize,
15943) -> io::Result<GitCommandOutput> {
15944 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15945}
15946
15947async fn read_git_history_bounded(
15948 root: &str,
15949 path: Option<&RepositoryPath>,
15950 before: Option<&GitObjectId>,
15951 limit: u16,
15952) -> Result<GitHistoryPage, NodeFailure> {
15953 let path = path
15954 .map(|path| {
15955 path.as_utf8().ok_or_else(|| {
15956 failure(
15957 NodeFailureCode::InvalidRepositoryPath,
15958 "git history path must use UTF-8",
15959 )
15960 })
15961 })
15962 .transpose()?
15963 .unwrap_or(".");
15964 let count = (usize::from(limit) + 1).to_string();
15965 let start = before
15966 .map(|revision| format!("{}^", revision.as_str()))
15967 .unwrap_or_else(|| "HEAD".to_owned());
15968 let arguments = [
15969 "--no-pager",
15970 "log",
15971 "--no-decorate",
15972 "--date=iso-strict",
15973 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15974 "-n",
15975 count.as_str(),
15976 start.as_str(),
15977 "--",
15978 path,
15979 ];
15980 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15981 .await
15982 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15983 if output.timed_out {
15984 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15985 }
15986 if !output.success {
15987 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15988 }
15989 let mut commits = parse_git_history(&output.stdout);
15990 let has_more = commits.len() > usize::from(limit) || output.truncated;
15991 commits.truncate(usize::from(limit));
15992 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15993 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15994}
15995
15996fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15997 String::from_utf8_lossy(output)
15998 .split('\u{1e}')
15999 .filter_map(|record| {
16000 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16001 if fields.len() != 9 {
16002 return None;
16003 }
16004 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16005 let parents = if fields[1].is_empty() {
16006 Vec::new()
16007 } else {
16008 fields[1]
16009 .split(' ')
16010 .map(|value| GitObjectId::new(value.to_owned()))
16011 .collect::<Result<Vec<_>, _>>()
16012 .ok()?
16013 };
16014 Some(GitCommitDetails {
16015 id,
16016 parents,
16017 subject: bounded_git_text(fields[2], 512),
16018 author_name: bounded_git_text(fields[3], 256),
16019 author_email: bounded_git_text(fields[4], 320),
16020 authored_at: bounded_git_text(fields[5], 64),
16021 committer_name: bounded_git_text(fields[6], 256),
16022 committer_email: bounded_git_text(fields[7], 320),
16023 committed_at: bounded_git_text(fields[8], 64),
16024 signature_status: GitSignatureStatus::NoSignature,
16025 signer: None,
16026 })
16027 })
16028 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16029 .collect()
16030}
16031
16032fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16033 let normalized = value
16034 .chars()
16035 .map(|character| if character.is_control() { ' ' } else { character })
16036 .collect::<String>();
16037 if normalized.len() <= max_bytes {
16038 return normalized;
16039 }
16040 let mut boundary = max_bytes;
16041 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16042 boundary -= 1;
16043 }
16044 normalized[..boundary].to_owned()
16045}
16046
16047async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16048 let path = request.path.as_ref().map(|path| {
16049 path.as_utf8()
16050 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16051 }).transpose()?;
16052 let mut arguments = match &request.mode {
16053 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16054 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16055 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16056 };
16057 if let GitDiffMode::Commit { revision } = &request.mode {
16058 arguments.push(revision.as_str());
16059 }
16060 arguments.push("--");
16061 if let Some(path) = path {
16062 arguments.push(path);
16063 }
16064 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16065 .await
16066 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16067 if output.timed_out {
16068 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16069 }
16070 if !output.success {
16071 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16072 }
16073 Ok(GitDiff {
16074 mode: request.mode,
16075 path: request.path,
16076 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16077 truncated: output.truncated,
16078 })
16079}
16080
16081async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16082 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16083 ResponseEnvelope { request_id: envelope.request_id, result }
16084}
16085
16086async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16087 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16088 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16089 }
16090 let read_only = matches!(
16091 &request,
16092 NodeRequest::Snapshot
16093 | NodeRequest::Resync { .. }
16094 | NodeRequest::BrowseHostDirectories { .. }
16095 | NodeRequest::InspectWorkspace { .. }
16096 | NodeRequest::ReadWorkspaceFile { .. }
16097 | NodeRequest::ReadGitHistory { .. }
16098 | NodeRequest::ReadGitDiff { .. }
16099 | NodeRequest::CatalogNativeSessions { .. }
16100 | NodeRequest::PageNativeSessions { .. }
16101 | NodeRequest::PreviewNativeSession { .. }
16102 | NodeRequest::PreviewSessionRecord { .. }
16103 );
16104 let _mutation_guard = if read_only {
16105 None
16106 } else {
16107 let guard = shared.mutation_gate.lock().await;
16108 if shared.shutdown.load(Ordering::Acquire) {
16109 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16110 }
16111 Some(guard)
16112 };
16113 match request {
16114 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16115 event_sequence: shared.current_sequence(),
16116 controller: shared.controller_state(),
16117 snapshot: shared.snapshot(),
16118 }),
16119 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16120 NodeRequest::ArmHarnessMcpReservation {
16121 reservation_id,
16122 activation_digest,
16123 spawn_spec,
16124 launch,
16125 expires_at_unix_ms,
16126 } => {
16127 shared.require_controller(connection_id, role)?;
16128 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16129 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16130 })?;
16131 let expires_at_unix_ms = registry
16132 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16133 .await
16134 .map_err(harness_mcp_failure)?;
16135 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16136 }
16137 NodeRequest::SpawnSpecWithHarnessMcp {
16138 reservation_id,
16139 activation_digest,
16140 spec,
16141 deadline_unix_ms,
16142 } => {
16143 shared.require_controller(connection_id, role)?;
16144 let receipt = shared
16145 .spawn_from_spec_with_harness_mcp(
16146 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16147 )
16148 .await?;
16149 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16150 }
16151 NodeRequest::ActivateHarnessMcpReservation {
16152 reservation_id,
16153 activation_digest,
16154 record_id,
16155 session,
16156 } => {
16157 shared.require_controller(connection_id, role)?;
16158 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16159 current.instance_id == session.session.instance_id
16160 && current.generation == session.session.generation
16161 }).and_then(|current| current.process_id).ok_or_else(|| {
16162 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16163 })?;
16164 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16165 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16166 })?.activate(
16167 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16168 ).map_err(harness_mcp_failure)?;
16169 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16170 }
16171 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16172 shared.require_controller(connection_id, role)?;
16173 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16174 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16175 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16176 if let Some(instance_id) = instance_id {
16177 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16178 }
16179 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16180 }
16181 NodeRequest::PutHarnessMcpReplyChunk {
16182 reservation_id, activation_digest, record_id, session, call_id,
16183 offset, final_chunk, chunk_hex,
16184 } => {
16185 shared.require_controller(connection_id, role)?;
16186 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16187 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16188 })?.put_reply_chunk(
16189 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16190 offset, final_chunk, &chunk_hex,
16191 ).map_err(harness_mcp_failure)?;
16192 Ok(NodeResponse::ReplyChunkAccepted {
16193 reservation_id, activation_digest, record_id, session, call_id,
16194 next_offset, completed,
16195 })
16196 }
16197 NodeRequest::RejectHarnessMcpCall {
16198 reservation_id, activation_digest, record_id, session, call_id, reason,
16199 } => {
16200 shared.require_controller(connection_id, role)?;
16201 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16202 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16203 })?.reject_call(
16204 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16205 ).map_err(harness_mcp_failure)?;
16206 Ok(NodeResponse::CallRejected {
16207 reservation_id, activation_digest, record_id, session, call_id,
16208 })
16209 }
16210 NodeRequest::BeginDeliveryStage { manifest } => {
16211 shared.require_controller(connection_id, role)?;
16212 shared.begin_delivery_stage(manifest)
16213 }
16214 NodeRequest::PutDeliveryBlobChunk {
16215 stage_id,
16216 blob_digest,
16217 offset,
16218 chunk_hex,
16219 } => {
16220 shared.require_controller(connection_id, role)?;
16221 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16222 }
16223 NodeRequest::CommitDeliveryStage { stage_id } => {
16224 shared.require_controller(connection_id, role)?;
16225 let receipt = shared.commit_delivery_stage(stage_id)?;
16226 Ok(NodeResponse::DeliveryCommitted { receipt })
16227 }
16228 NodeRequest::AbortDeliveryStage { stage_id } => {
16229 shared.require_controller(connection_id, role)?;
16230 shared.abort_delivery_stage(&stage_id)?;
16231 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16232 }
16233 NodeRequest::BrowseHostDirectories { directory, after } => {
16234 let listing = shared.browse_host_directories(directory, after).await?;
16235 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16236 }
16237 NodeRequest::InspectWorkspace { workspace_id } => {
16238 let inspection = shared.inspect_workspace(workspace_id).await?;
16239 Ok(NodeResponse::WorkspaceInspected { inspection })
16240 }
16241 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16242 let file = shared.read_workspace_file(workspace_id, path).await?;
16243 Ok(NodeResponse::WorkspaceFileRead { file })
16244 }
16245 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16246 shared.require_controller(connection_id, role)?;
16247 let file = shared
16248 .write_workspace_file(workspace_id, path, expected_revision, text)
16249 .await?;
16250 Ok(NodeResponse::WorkspaceFileWritten { file })
16251 }
16252 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16253 shared.require_controller(connection_id, role)?;
16254 let file = shared.create_workspace_file(workspace_id, path).await?;
16255 Ok(NodeResponse::WorkspaceFileCreated { file })
16256 }
16257 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16258 shared.require_controller(connection_id, role)?;
16259 let entry = shared
16260 .create_workspace_directory(workspace_id.clone(), path)
16261 .await?;
16262 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16263 }
16264 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16265 if role != ClientRole::Operator {
16266 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16267 }
16268 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16269 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16270 }
16271 let page = shared
16272 .read_git_history(workspace_id.clone(), path, before, limit)
16273 .await?;
16274 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16275 }
16276 NodeRequest::ReadGitDiff { workspace_id, request } => {
16277 if role != ClientRole::Operator {
16278 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16279 }
16280 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16281 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16282 }
16283 NodeRequest::CatalogNativeSessions { route, limit } => {
16284 if role != ClientRole::Operator {
16285 return Err(failure(
16286 NodeFailureCode::ObserverReadOnly,
16287 "native session catalog requires an operator connection",
16288 ));
16289 }
16290 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16291 return Err(failure(
16292 NodeFailureCode::InvalidRequest,
16293 "native session catalog limit is invalid",
16294 ));
16295 }
16296 let (entries, summary) = shared
16297 .catalog_native_sessions(route.clone(), limit)
16298 .await?;
16299 Ok(NodeResponse::NativeSessionsCataloged {
16300 route,
16301 entries,
16302 summary: Some(summary),
16303 })
16304 }
16305 NodeRequest::PageNativeSessions {
16306 route,
16307 window,
16308 catalog_revision,
16309 recent_cutoff_unix_ms,
16310 after_selection_id,
16311 limit,
16312 } => {
16313 if role != ClientRole::Operator {
16314 return Err(failure(
16315 NodeFailureCode::ObserverReadOnly,
16316 "native session catalog paging requires an operator connection",
16317 ));
16318 }
16319 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16320 || after_selection_id
16321 .as_deref()
16322 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16323 {
16324 return Err(failure(
16325 NodeFailureCode::InvalidRequest,
16326 "native session catalog page request is invalid",
16327 ));
16328 }
16329 let page = shared
16330 .page_native_sessions(
16331 route.clone(),
16332 window,
16333 catalog_revision,
16334 recent_cutoff_unix_ms,
16335 after_selection_id,
16336 limit,
16337 )
16338 .await?;
16339 Ok(NodeResponse::NativeSessionsPaged {
16340 route,
16341 page,
16342 })
16343 }
16344 NodeRequest::PreviewNativeSession {
16345 selection,
16346 message_limit,
16347 } => {
16348 if role != ClientRole::Operator {
16349 return Err(failure(
16350 NodeFailureCode::ObserverReadOnly,
16351 "native session preview requires an operator connection",
16352 ));
16353 }
16354 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16355 .contains(&message_limit)
16356 || selection.validate().is_err()
16357 {
16358 return Err(failure(
16359 NodeFailureCode::InvalidRequest,
16360 "native session preview request is invalid",
16361 ));
16362 }
16363 let preview = shared
16364 .preview_native_session(selection.clone(), message_limit)
16365 .await?;
16366 Ok(NodeResponse::NativeSessionPreviewed {
16367 selection,
16368 preview,
16369 })
16370 }
16371 NodeRequest::IndexNativeSession {
16372 selection,
16373 display_name,
16374 } => {
16375 shared.require_controller(connection_id, role)?;
16376 let record = shared
16377 .index_native_session(selection.clone(), display_name)
16378 .await?;
16379 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16380 }
16381 NodeRequest::PreviewSessionRecord {
16382 record_id,
16383 message_limit,
16384 } => {
16385 if role != ClientRole::Operator {
16386 return Err(failure(
16387 NodeFailureCode::ObserverReadOnly,
16388 "native session preview requires an operator connection",
16389 ));
16390 }
16391 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16392 .contains(&message_limit)
16393 {
16394 return Err(failure(
16395 NodeFailureCode::InvalidRequest,
16396 "native session preview request is invalid",
16397 ));
16398 }
16399 let record = shared.record(&record_id)?;
16400 let working_directory = shared.workspace_root(&record.workspace_id)?;
16401 if !platform::roots_equal(
16402 &working_directory,
16403 windows_path_text(&record.canonical_root),
16404 ) {
16405 return Err(failure(
16406 NodeFailureCode::SessionRecordConflict,
16407 "managed session workspace root changed; refusing to preview another directory",
16408 ));
16409 }
16410 let identity = record.provider_session.clone().ok_or_else(|| {
16411 failure(
16412 NodeFailureCode::BackendOperationFailed,
16413 "native session preview is unavailable for this session record",
16414 )
16415 })?;
16416 if identity.key != ProviderSessionKey::SessionId {
16417 return Err(failure(
16418 NodeFailureCode::BackendOperationFailed,
16419 "native session preview is unavailable for this session identity",
16420 ));
16421 }
16422 let preview = shared
16423 .preview_native_session_by_session_id(
16424 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16425 )
16426 .await?;
16427 if preview.session_id != identity.id {
16428 return Err(failure(
16429 NodeFailureCode::SessionRecordConflict,
16430 "native session preview identity changed while loading",
16431 ));
16432 }
16433 shared.revalidate_session_record_preview(&record, &identity)?;
16434 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16435 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16436 record_id: record_id.clone(),
16437 summary: SessionHistorySummaryV1::from(&preview),
16438 });
16439 Ok(NodeResponse::SessionRecordPreviewed {
16440 record_id,
16441 preview,
16442 })
16443 }
16444 NodeRequest::AcquireController { lease_ms } => {
16445 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16446 Ok(NodeResponse::Controller { controller: Some(controller) })
16447 }
16448 NodeRequest::ReleaseController => {
16449 shared.release_controller(connection_id);
16450 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16451 }
16452 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16453 shared.require_controller(connection_id, role)?;
16454 let root = require_windows_path(root)?;
16455 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16456 let workspace = shared.register_workspace(workspace_id, root).await?;
16457 Ok(NodeResponse::WorkspaceRegistered { workspace })
16458 }
16459 NodeRequest::CreateStandaloneWorkspace {
16460 workspace_id,
16461 root,
16462 initial_branch,
16463 } => {
16464 shared.require_controller(connection_id, role)?;
16465 let root = require_windows_path(root)?;
16466 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16467 let workspace = shared
16468 .create_standalone_workspace(workspace_id, root, initial_branch)
16469 .await?;
16470 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16471 }
16472 NodeRequest::UnregisterWorkspace { workspace_id } => {
16473 shared.require_controller(connection_id, role)?;
16474 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16475 shared.unregister_workspace(&workspace_id)?;
16476 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16477 }
16478 NodeRequest::CreateWorktree {
16479 source_workspace_id,
16480 workspace_id,
16481 target_root,
16482 branch,
16483 base,
16484 } => {
16485 shared.require_controller(connection_id, role)?;
16486 let target_root = require_windows_path(target_root)?;
16487 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16488 let (worktree, workspace) = shared
16489 .create_worktree(
16490 source_workspace_id,
16491 workspace_id,
16492 target_root,
16493 branch,
16494 base,
16495 )
16496 .await?;
16497 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16498 }
16499 NodeRequest::RemoveWorktree {
16500 source_workspace_id,
16501 target_root,
16502 } => {
16503 shared.require_controller(connection_id, role)?;
16504 let native_target_root = require_windows_path(target_root.clone())?;
16505 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16506 let workspace_id = shared
16507 .remove_worktree(source_workspace_id, native_target_root)
16508 .await?;
16509 Ok(NodeResponse::WorktreeRemoved {
16510 target_root,
16511 workspace_id,
16512 })
16513 }
16514 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16515 shared.require_controller(connection_id, role)?;
16516 if !terminal_size.is_valid() {
16517 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16518 }
16519 if let Some(prompt) = initial_prompt.as_deref() {
16520 validate_node_text("spawn initial prompt", prompt)?;
16521 }
16522 match shared
16523 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16524 .await
16525 {
16526 Ok(session) => {
16527 shared.mark_spawn_provisional(session.clone());
16528 Ok(NodeResponse::SpawnAccepted { session })
16529 }
16530 Err(err) => {
16531 tracing::warn!(
16532 %provider,
16533 %workspace_id,
16534 ?mode,
16535 code = ?err.code,
16536 message = %err.message,
16537 "session spawn refused"
16538 );
16539 Err(err)
16540 }
16541 }
16542 }
16543 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16544 shared.require_controller(connection_id, role)?;
16545 if !terminal_size.is_valid() {
16546 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16547 }
16548 if let Some(prompt) = initial_prompt.as_deref() {
16549 validate_node_text("spawn initial prompt", prompt)?;
16550 }
16551 let plan = backend_paths::plan_launch_options(
16552 provider.as_str(),
16553 mode,
16554 options.model.is_some(),
16555 options.effort.is_some(),
16556 )
16557 .map_err(|r| {
16558 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16559 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16560 })?;
16561 let selection = spawn_selection(&options)?;
16562 let pty_selection = match plan {
16563 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16564 _ => None,
16565 };
16566 let spawned = SPAWN_SESSION_OPTIONS
16567 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16568 .await;
16569 match spawned {
16570 Ok(session) => {
16571 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16572 shared
16573 .pending_acp_options
16574 .lock()
16575 .unwrap_or_else(|p| p.into_inner())
16576 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16577 }
16578 shared.mark_spawn_provisional(session.clone());
16579 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16580 Ok(NodeResponse::SpawnAccepted { session })
16581 }
16582 Err(err) => {
16583 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16584 Err(err)
16585 }
16586 }
16587 }
16588 NodeRequest::SpawnSpec { spec } => {
16589 shared.require_controller(connection_id, role)?;
16590 let receipt = shared.spawn_from_spec(spec).await?;
16591 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16592 }
16593 NodeRequest::SpawnManagedWorktree { request } => {
16594 shared.require_controller(connection_id, role)?;
16595 let receipt = shared.spawn_managed_worktree(request).await?;
16596 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16597 }
16598 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16599 shared.require_controller(connection_id, role)?;
16600 let receipt = shared.spawn_managed_worktree_v2(request).await?;
16601 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16602 }
16603 NodeRequest::CleanupManagedWorktree { lease_id } => {
16604 shared.require_controller(connection_id, role)?;
16605 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16606 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16607 }
16608 NodeRequest::DiscoverHistory { session, limit } => {
16609 controlled_session(shared, connection_id, role, &session)?;
16610 let candidates = shared.discover_history(&session, limit).await?;
16611 Ok(NodeResponse::HistoryDiscovered {
16612 session,
16613 candidates,
16614 })
16615 }
16616 NodeRequest::LoadHistory {
16617 session,
16618 candidate_id,
16619 } => {
16620 controlled_session(shared, connection_id, role, &session)?;
16621 let loaded = shared
16622 .load_history(&session, candidate_id)
16623 .await?;
16624 Ok(NodeResponse::HistoryLoaded {
16625 session,
16626 session_id: loaded.session_id,
16627 message_count: loaded.message_count,
16628 completed_turn_count: loaded.completed_turn_count,
16629 })
16630 }
16631 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16632 shared.require_controller(connection_id, role)?;
16633 let context = shared
16634 .export_context_pack_for_session_record(&record_id, &session, false)
16635 .await?;
16636 Ok(NodeResponse::ContextPackForSessionRecordExported {
16637 record_id,
16638 session,
16639 context,
16640 })
16641 }
16642 NodeRequest::ExportContextPack { session } => {
16643 controlled_session(shared, connection_id, role, &session)?;
16644 let context = shared.export_context_pack(&session).await?;
16645 Ok(NodeResponse::ContextPackExported { context })
16646 }
16647 NodeRequest::ForgetContextPack { context_id } => {
16648 shared.require_controller(connection_id, role)?;
16649 shared.forget_context_pack(&context_id)?;
16650 Ok(NodeResponse::ContextPackForgotten { context_id })
16651 }
16652 NodeRequest::ResolveDurableContextPack { context_id } => {
16653 let context = shared
16657 .context_catalog
16658 .read()
16659 .unwrap_or_else(|poisoned| poisoned.into_inner())
16660 .get(&context_id)
16661 .map(|pack| pack.receipt().clone())
16662 .ok_or_else(|| {
16663 failure(
16664 NodeFailureCode::UnknownContextPack,
16665 "context pack is not present",
16666 )
16667 })?;
16668 Ok(NodeResponse::DurableContextPackResolved { context })
16669 }
16670 NodeRequest::ReadContextPack { digest } => {
16671 let (id, byte_len, bytes) = shared
16676 .context_catalog
16677 .read()
16678 .unwrap_or_else(|poisoned| poisoned.into_inner())
16679 .get_by_digest(&digest)
16680 .map(|pack| {
16681 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16682 })
16683 .ok_or_else(|| {
16684 failure(
16685 NodeFailureCode::UnknownContextPack,
16686 &format!("context pack {digest} not on this node"),
16687 )
16688 })?;
16689 tracing::debug!(
16690 digest = digest.as_str(),
16691 byte_len,
16692 "read context pack bytes",
16693 );
16694 Ok(NodeResponse::ContextPackBytesRead {
16695 pack: ContextPackBytesRead {
16696 digest,
16697 id,
16698 byte_len,
16699 bytes,
16700 },
16701 })
16702 }
16703 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16704 let provider = controlled_session(shared, connection_id, role, &session)?;
16705 if let Some(prompt) = initial_prompt.as_deref() {
16706 validate_node_text("resume initial prompt", prompt)?;
16707 }
16708 let runtime_requirement = if initial_prompt.is_some() {
16709 ProviderRuntimeRequirement::ResumeWithPrompt
16710 } else {
16711 ProviderRuntimeRequirement::Resume
16712 };
16713 shared.require_session_runtime_policy(&session, runtime_requirement)?;
16714 let runtime_policy = shared
16715 .admit_provider_runtime(&provider, runtime_requirement)
16716 .await?;
16717 let working_directory = shared.bound_workspace_root(&session)?;
16718 let request = ResumeLaunchRequest {
16719 working_directory,
16720 terminal_size,
16721 initial_prompt,
16722 };
16723 request
16724 .validate()
16725 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16726 let command = shared.prepare_command(ControlCommand::Resume {
16727 instance_id: session.session.instance_id,
16728 target: ResumeTarget::CurrentProvider,
16729 runtime_policy,
16730 request,
16731 });
16732 shared.arm_resume(&session, command.id, runtime_policy)?;
16733 let dispatch = shared.dispatch_envelope(command);
16734 if let Err(error) = dispatch {
16735 shared.clear_armed_resume(&session);
16736 return Err(error);
16737 }
16738 Ok(NodeResponse::Accepted)
16739 }
16740 NodeRequest::RenameSessionRecord {
16741 record_id,
16742 display_name,
16743 } => {
16744 shared.require_controller(connection_id, role)?;
16745 let record = shared.rename_session_record(&record_id, display_name)?;
16746 Ok(NodeResponse::SessionRecordUpdated { record })
16747 }
16748 NodeRequest::SetSessionTask {
16749 record_id,
16750 expected_revision,
16751 target,
16752 } => {
16753 shared.require_controller(connection_id, role)?;
16754 let record = shared.set_session_task(&record_id, expected_revision, target)?;
16755 Ok(NodeResponse::SessionRecordUpdated { record })
16756 }
16757 NodeRequest::IndexProviderSession {
16758 workspace_id,
16759 provider,
16760 identity,
16761 display_name,
16762 } => {
16763 shared.require_controller(connection_id, role)?;
16764 let record = shared.index_provider_session(
16765 workspace_id,
16766 provider,
16767 identity,
16768 display_name,
16769 )?;
16770 Ok(NodeResponse::ProviderSessionIndexed { record })
16771 }
16772 NodeRequest::ResumeSessionRecord {
16773 record_id,
16774 terminal_size,
16775 initial_prompt,
16776 } => {
16777 shared.require_controller(connection_id, role)?;
16778 if let Some(prompt) = initial_prompt.as_deref() {
16779 validate_node_text("managed resume initial prompt", prompt)?;
16780 }
16781 let (record, session) = shared
16782 .resume_session_record(
16783 &record_id,
16784 terminal_size,
16785 initial_prompt,
16786 )
16787 .await?;
16788 Ok(NodeResponse::SessionRecordResumed { record, session })
16789 }
16790 NodeRequest::ForgetSessionRecord { record_id } => {
16791 shared.require_controller(connection_id, role)?;
16792 shared.forget_session_record(&record_id).await?;
16793 Ok(NodeResponse::SessionRecordForgotten { record_id })
16794 }
16795 NodeRequest::Prompt { session, text } => {
16796 let agent_id = controlled_session(shared, connection_id, role, &session)?;
16797 validate_node_text("prompt", &text)?;
16798 shared.require_session_runtime_policy(
16799 &session,
16800 ProviderRuntimeRequirement::SemanticPrompt,
16801 )?;
16802 let framing = prompt_framing(&agent_id);
16803 shared
16804 .dispatch_input_bounded(
16805 &session,
16806 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16807 )
16808 .await?;
16809 Ok(NodeResponse::Accepted)
16810 }
16811 NodeRequest::Paste { session, text } => {
16812 controlled_session(shared, connection_id, role, &session)?;
16813 validate_node_text("paste", &text)?;
16814 shared.require_session_runtime_policy(
16815 &session,
16816 ProviderRuntimeRequirement::SemanticPrompt,
16817 )?;
16818 shared
16819 .dispatch_input_bounded(
16820 &session,
16821 InputAction::InsertDraft(PromptPayload {
16822 text,
16823 framing: PromptFraming::BracketedPaste,
16824 }),
16825 )
16826 .await?;
16827 Ok(NodeResponse::Accepted)
16828 }
16829 NodeRequest::Input { session, text } => {
16830 controlled_session(shared, connection_id, role, &session)?;
16831 validate_node_text("terminal input", &text)?;
16832 shared
16833 .dispatch_input_bounded(
16834 &session,
16835 InputAction::TerminalText(TerminalText { text }),
16836 )
16837 .await?;
16838 Ok(NodeResponse::Accepted)
16839 }
16840 NodeRequest::TerminalBytes { session, bytes } => {
16841 controlled_session(shared, connection_id, role, &session)?;
16842 shared.sessionize_spawn(session.session.instance_id);
16843 let action = terminal_bytes_action(bytes)?;
16844 shared
16845 .dispatch_input_bounded(&session, action)
16846 .await?;
16847 Ok(NodeResponse::Accepted)
16848 }
16849 NodeRequest::TerminalControl { session, control } => {
16850 controlled_session(shared, connection_id, role, &session)?;
16851 shared
16852 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16853 .await?;
16854 Ok(NodeResponse::Accepted)
16855 }
16856 NodeRequest::Resize { session, size } => {
16857 controlled_session(shared, connection_id, role, &session)?;
16858 shared.sessionize_spawn(session.session.instance_id);
16859 if !size.is_valid() {
16860 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16861 }
16862 shared.dispatch_resize_bounded(&session, size).await?;
16863 Ok(NodeResponse::Accepted)
16864 }
16865 NodeRequest::Interrupt { session } => {
16866 controlled_session(shared, connection_id, role, &session)?;
16867 shared
16868 .dispatch_input_bounded(
16869 &session,
16870 InputAction::TerminalControl(TerminalControl::Interrupt),
16871 )
16872 .await?;
16873 Ok(NodeResponse::Accepted)
16874 }
16875 NodeRequest::Stop { session, force } => {
16876 controlled_session(shared, connection_id, role, &session)?;
16877 shared.sessionize_spawn(session.session.instance_id);
16878 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16879 shared.retire_session_grok_leader(session.session.instance_id);
16880 Ok(NodeResponse::Accepted)
16881 }
16882 NodeRequest::Remove { session } => {
16883 controlled_session(shared, connection_id, role, &session)?;
16884 shared.remove_session(&session).await?;
16885 Ok(NodeResponse::Accepted)
16886 }
16887 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16888 shared.require_controller(connection_id, role)?;
16889 let snapshot = shared.require_acp_session(&session)?;
16890 let interaction = snapshot
16891 .provider
16892 .interactions
16893 .iter()
16894 .find(|interaction| {
16895 interaction_correlation(
16896 session.session.instance_id,
16897 session.session.generation,
16898 interaction.id.0,
16899 ) == correlation_id
16900 })
16901 .ok_or_else(|| failure(
16902 NodeFailureCode::InvalidRequest,
16903 "unknown interaction correlation",
16904 ))?;
16905 if interaction.status != ProviderInteractionStatus::Pending {
16906 return Err(failure(
16907 NodeFailureCode::BackendOperationFailed,
16908 "interaction has already been resolved",
16909 ));
16910 }
16911 response
16912 .validate_for(interaction.interaction_kind)
16913 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16914 let interaction_id = interaction.id;
16915 let response_kind = response.kind();
16916 let response_outcome = response.outcome();
16917 shared.dispatch(ControlCommand::ResolveInteraction {
16918 instance_id: session.session.instance_id,
16919 generation: session.session.generation,
16920 interaction_id,
16921 response,
16922 })?;
16923 tracing::info!(
16929 node_id = %shared.node_id,
16930 session = ?session.session,
16931 correlation_id = %correlation_id,
16932 interaction_id = interaction_id.0,
16933 response_kind = ?response_kind,
16934 outcome = ?response_outcome,
16935 "interaction resolved",
16936 );
16937 Ok(NodeResponse::Accepted)
16938 }
16939 NodeRequest::SetSessionMode { session, mode_id } => {
16940 shared.require_controller(connection_id, role)?;
16941 shared.require_acp_session(&session)?;
16942 shared.dispatch(ControlCommand::SetSessionMode {
16943 instance_id: session.session.instance_id,
16944 mode_id,
16945 })?;
16946 Ok(NodeResponse::Accepted)
16947 }
16948 NodeRequest::SetSessionConfigOption {
16949 session,
16950 option_id,
16951 value_json,
16952 } => {
16953 shared.require_controller(connection_id, role)?;
16954 shared.require_acp_session(&session)?;
16955 shared.dispatch(ControlCommand::SetSessionConfigOption {
16956 instance_id: session.session.instance_id,
16957 option_id,
16958 value_json,
16959 })?;
16960 Ok(NodeResponse::Accepted)
16961 }
16962 NodeRequest::SetSessionModel { session, model_id } => {
16963 shared.require_controller(connection_id, role)?;
16964 shared.require_acp_session(&session)?;
16965 shared.dispatch(ControlCommand::SetSessionModel {
16966 instance_id: session.session.instance_id,
16967 model_id,
16968 })?;
16969 Ok(NodeResponse::Accepted)
16970 }
16971 NodeRequest::Shutdown => {
16972 shared.require_controller(connection_id, role)?;
16973 shared.begin_shutdown_locked().await?;
16974 Ok(NodeResponse::ShuttingDown)
16975 }
16976 }
16977}
16978
16979fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16980 shared.require_controller(connection_id, role)?;
16981 shared.validate_address(session)
16982}
16983
16984fn select_history_candidate(
16990 candidates: &[HistoryCandidateSummary],
16991 provider_session_id: Option<&str>,
16992) -> Result<String, NodeFailure> {
16993 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16994 Some(id) => candidate.session_id_hint == id,
16995 None => true,
16996 });
16997 let candidate = matches.next().ok_or_else(|| {
16998 failure(
16999 NodeFailureCode::SessionRecordConflict,
17000 if provider_session_id.is_some() {
17001 "no history candidate matches the managed provider session"
17002 } else {
17003 "no history candidate exists for the managed session"
17004 },
17005 )
17006 })?;
17007 if matches.next().is_some() {
17008 return Err(failure(
17009 NodeFailureCode::SessionRecordConflict,
17010 if provider_session_id.is_some() {
17011 "history identity is ambiguous for the managed provider session"
17012 } else {
17013 "history is ambiguous without a verified provider session identity"
17014 },
17015 ));
17016 }
17017 Ok(candidate.id.clone())
17018}
17019
17020fn resolved_spawn_profile_summary(
17021 profile: &SpawnProfileDefaults,
17022 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17023) -> Option<SpawnProfileSummary> {
17024 let environment_profile = match profile.environment_profile_id.as_ref() {
17025 None => None,
17026 Some(profile_id) => {
17027 let binding = environment_profiles.get(profile_id)?;
17028 if binding.provider != profile.provider
17029 || binding.native_profile_id(profile.mode).is_none()
17030 {
17031 return None;
17032 }
17033 Some(ResolvedEnvironmentProfileReceipt {
17034 profile_id: binding.id.clone(),
17035 profile_revision: binding.revision.clone(),
17036 network_allowlist: None,
17037 browser_profile_id: None,
17038 })
17039 }
17040 };
17041 Some(SpawnProfileSummary {
17042 id: profile.profile_id.clone(),
17043 revision: profile.revision.clone(),
17044 environment_profile,
17045 })
17046}
17047
17048fn session_record_export_target_matches(
17067 expected: &ManagedSessionRecord,
17068 current: &ManagedSessionRecord,
17069 session: &SessionAddress,
17070 allow_clean_detachment: bool,
17071) -> bool {
17072 if current == expected && current.active_session.as_ref() == Some(session) {
17073 return current.state == ManagedSessionState::Live;
17074 }
17075 allow_clean_detachment
17076 && current.state == ManagedSessionState::Dormant
17077 && current.active_session.is_none()
17078 && current.record_id == expected.record_id
17079 && current.display_name == expected.display_name
17080 && current.provider == expected.provider
17081 && current.mode == expected.mode
17082 && current.workspace_id == expected.workspace_id
17083 && current.canonical_root == expected.canonical_root
17084 && current.provider_session == expected.provider_session
17085 && current.environment_profile == expected.environment_profile
17086 && current.bundle == expected.bundle
17087 && current.context_id == expected.context_id
17088 && current.context == expected.context
17089 && current.exported_context == expected.exported_context
17090 && current.task_binding == expected.task_binding
17091 && current.created_at_unix_ms == expected.created_at_unix_ms
17092 && current.last_error == expected.last_error
17093}
17094
17095fn session_record_context_export_binding_is_exact(
17096 expected: &ManagedSessionRecord,
17097 current: &ManagedSessionRecord,
17098 identity: Option<&ProviderSessionIdentity>,
17099 session: &SessionAddress,
17100 binding: &SessionBinding,
17101 provider: &AgentId,
17102 current_root: &str,
17103 allow_clean_detachment: bool,
17104) -> bool {
17105 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17106 && current.provider_session.as_ref() == identity
17107 && current.workspace_id == session.workspace_id
17108 && provider == ¤t.provider
17109 && binding.workspace_id == session.workspace_id
17110 && binding.generation == session.session.generation
17111 && binding.record_id.as_ref() == Some(¤t.record_id)
17112 && platform::roots_equal(
17113 current_root,
17114 windows_path_text(¤t.canonical_root),
17115 )
17116}
17117
17118fn session_record_context_export_source_is_usable(
17119 record: &ManagedSessionRecord,
17120 status: &SessionStatus,
17121 allow_clean_detachment: bool,
17122) -> bool {
17123 (record.state == ManagedSessionState::Live
17124 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17125 && context_pack_source_status_is_usable(status)
17126}
17127
17128fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17129 PromptFraming::BracketedPaste
17130}
17131
17132fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17133 if text.len() > MAX_NODE_TEXT_BYTES {
17134 return Err(failure(
17135 NodeFailureCode::InvalidRequest,
17136 &format!(
17137 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17138 text.len(),
17139 ),
17140 ));
17141 }
17142 Ok(())
17143}
17144
17145fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17146 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17147 return Err(failure(
17148 NodeFailureCode::InvalidRequest,
17149 &format!(
17150 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17151 bytes.len(),
17152 ),
17153 ));
17154 }
17155 Ok(())
17156}
17157
17158fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17159 validate_terminal_bytes(&bytes)?;
17160 Ok(InputAction::TerminalBytes(bytes))
17161}
17162
17163fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17164 if root.is_empty()
17165 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17166 || root.chars().any(char::is_control)
17167 {
17168 return Err(failure(
17169 NodeFailureCode::InvalidWorkspaceRoot,
17170 &format!(
17171 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17172 ),
17173 ));
17174 }
17175 Ok(())
17176}
17177
17178fn canonical_standalone_workspace_candidate(
17179 workspace_id: &WorkspaceId,
17180 root: &str,
17181) -> Result<String, NodeServerError> {
17182 let path = Path::new(root);
17183 match std::fs::symlink_metadata(path) {
17184 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17185 .map(|workspace| workspace.canonical_root),
17186 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17187 if !path.is_absolute() {
17188 return Err(NodeServerError::InvalidWorkspaceRoot {
17189 workspace_id: workspace_id.clone(),
17190 path: root.to_owned(),
17191 message: "path is not absolute".to_owned(),
17192 });
17193 }
17194 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17195 workspace_id: workspace_id.clone(),
17196 path: root.to_owned(),
17197 message: "path has no standalone directory name".to_owned(),
17198 })?;
17199 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17200 workspace_id: workspace_id.clone(),
17201 path: root.to_owned(),
17202 message: "path has no parent directory".to_owned(),
17203 })?;
17204 let canonical = std::fs::canonicalize(parent)
17205 .map(|parent| parent.join(name))
17206 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17207 workspace_id: workspace_id.clone(),
17208 path: root.to_owned(),
17209 message: error.to_string(),
17210 })?;
17211 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17212 NodeServerError::InvalidWorkspaceRoot {
17213 workspace_id: workspace_id.clone(),
17214 path: path.to_string_lossy().into_owned(),
17215 message: "canonical path is not valid Unicode".to_owned(),
17216 }
17217 })?;
17218 let canonical_root = platform::normalize_canonical_root(canonical_root);
17219 validate_workspace_root(workspace_id, &canonical_root)?;
17220 Ok(canonical_root)
17221 }
17222 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17223 workspace_id: workspace_id.clone(),
17224 path: root.to_owned(),
17225 message: error.to_string(),
17226 }),
17227 }
17228}
17229
17230fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17231 let Some(revision) = revision else {
17232 return Ok(());
17233 };
17234 if revision.is_empty()
17235 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17236 || revision.chars().any(char::is_control)
17237 {
17238 return Err(failure(
17239 NodeFailureCode::InvalidRequest,
17240 &format!(
17241 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17242 ),
17243 ));
17244 }
17245 Ok(())
17246}
17247
17248fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17249 let code = match error.kind {
17250 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17251 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17252 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17253 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17254 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17255 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17256 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17257 };
17258 failure(code, &error.message)
17259}
17260
17261fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17262 let code = match error.kind {
17263 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17264 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17265 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17266 NodeFailureCode::BackendOperationFailed
17267 }
17268 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17269 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17270 }
17271 };
17272 failure(code, error.message)
17273}
17274
17275fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17276 let (code, message) = match error.kind {
17277 GitWorktreeErrorKind::Invalid => (
17278 NodeFailureCode::InvalidRequest,
17279 "managed Git input is invalid",
17280 ),
17281 GitWorktreeErrorKind::NotRepository => (
17282 NodeFailureCode::NotGitRepository,
17283 "managed source is not a Git repository",
17284 ),
17285 GitWorktreeErrorKind::Conflict => (
17286 NodeFailureCode::WorktreeConflict,
17287 "managed Git identity conflicts with existing repository state",
17288 ),
17289 GitWorktreeErrorKind::Protected => (
17290 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17291 "managed worktree ownership validation failed",
17292 ),
17293 GitWorktreeErrorKind::Dirty => (
17294 NodeFailureCode::WorktreeDirty,
17295 "managed worktree contains uncommitted changes",
17296 ),
17297 GitWorktreeErrorKind::Locked => (
17298 NodeFailureCode::WorktreeLocked,
17299 "managed worktree is locked",
17300 ),
17301 GitWorktreeErrorKind::Failed => (
17302 NodeFailureCode::BackendOperationFailed,
17303 "managed Git operation failed",
17304 ),
17305 };
17306 NodeFailure { code, message: message.to_owned() }
17307}
17308
17309fn managed_spawn_receipt(
17310 resolved: &ResolvedSpawnSpec,
17311 incarnation_id: NodeIncarnationId,
17312 session: SessionAddress,
17313 lease: ManagedWorktreeLeaseSnapshot,
17314 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17315 bundle: Option<ResolvedBundleReceipt>,
17316 context: Option<ResolvedContextPackReceipt>,
17317) -> ManagedWorktreeSpawnReceipt {
17318 let mut managed = resolved.clone();
17319 managed.target.worktree_id = Some(lease.workspace_id.clone());
17320 ManagedWorktreeSpawnReceipt {
17321 spawn: managed.receipt_with_materialization(
17322 incarnation_id,
17323 session,
17324 environment_profile,
17325 bundle,
17326 context,
17327 ),
17328 lease,
17329 }
17330}
17331
17332fn validate_selected_worktree(
17333 source_root: &str,
17334 selected_root: &str,
17335 worktrees: &[NativeGitWorktreeSnapshot],
17336) -> Result<(), NodeFailure> {
17337 if !worktrees
17338 .iter()
17339 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17340 {
17341 return Err(failure(
17342 NodeFailureCode::WorktreeProtected,
17343 "source workspace is not an authoritative non-bare Git worktree root",
17344 ));
17345 }
17346 if !worktrees.iter().any(|worktree| {
17347 worktree_paths_equal(&worktree.path, selected_root)
17348 && !worktree.is_main
17349 && !worktree.is_bare
17350 }) {
17351 return Err(failure(
17352 NodeFailureCode::WorktreeProtected,
17353 "selected workspace is not an eligible Git worktree in the source repository",
17354 ));
17355 }
17356 Ok(())
17357}
17358
17359fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17360 let code = match error.kind() {
17361 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17362 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17363 NodeFailureCode::RepositoryPathUnsafe
17364 }
17365 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17366 WorkspaceFileReadErrorKind::NotRegularFile => {
17367 NodeFailureCode::RepositoryFileNotRegular
17368 }
17369 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17370 NodeFailureCode::RepositoryFileReadFailed
17371 }
17372 WorkspaceFileReadErrorKind::RevisionConflict => {
17373 NodeFailureCode::RepositoryFileRevisionConflict
17374 }
17375 WorkspaceFileReadErrorKind::AlreadyExists
17376 | WorkspaceFileReadErrorKind::ParentNotFound
17377 | WorkspaceFileReadErrorKind::ParentNotDirectory
17378 | WorkspaceFileReadErrorKind::Canceled => {
17379 NodeFailureCode::RepositoryFileReadFailed
17380 }
17381 };
17382 failure(code, "repository file read failed")
17383}
17384
17385fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17386 let code = match error.kind() {
17387 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17388 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17389 NodeFailureCode::RepositoryPathUnsafe
17390 }
17391 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17392 WorkspaceFileReadErrorKind::NotRegularFile => {
17393 NodeFailureCode::RepositoryFileNotRegular
17394 }
17395 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17396 NodeFailureCode::RepositoryFileWriteFailed
17397 }
17398 WorkspaceFileReadErrorKind::RevisionConflict => {
17399 NodeFailureCode::RepositoryFileRevisionConflict
17400 }
17401 WorkspaceFileReadErrorKind::AlreadyExists
17402 | WorkspaceFileReadErrorKind::ParentNotFound
17403 | WorkspaceFileReadErrorKind::ParentNotDirectory
17404 | WorkspaceFileReadErrorKind::Canceled => {
17405 NodeFailureCode::RepositoryFileWriteFailed
17406 }
17407 };
17408 failure(code, "repository file write failed")
17409}
17410
17411async fn settle_workspace_entry_create<T>(
17412 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17413 commit_state: Arc<AtomicU8>,
17414 deadline: Duration,
17415 timeout_message: &'static str,
17416 task_failure_message: &'static str,
17417) -> Result<T, NodeFailure> {
17418 let joined = match timeout(deadline, &mut task).await {
17419 Ok(joined) => joined,
17420 Err(_) => {
17421 if commit_state
17422 .compare_exchange(
17423 WORKSPACE_ENTRY_CREATE_PENDING,
17424 WORKSPACE_ENTRY_CREATE_CANCELED,
17425 Ordering::AcqRel,
17426 Ordering::Acquire,
17427 )
17428 .is_ok()
17429 {
17430 return Err(failure(
17431 NodeFailureCode::RepositoryEntryCreateTimedOut,
17432 timeout_message,
17433 ));
17434 }
17435 debug_assert_eq!(
17436 commit_state.load(Ordering::Acquire),
17437 WORKSPACE_ENTRY_CREATE_COMMITTING,
17438 );
17439 task.await
17440 }
17441 };
17442 joined
17443 .map_err(|_| {
17444 failure(
17445 NodeFailureCode::RepositoryEntryCreateFailed,
17446 task_failure_message,
17447 )
17448 })?
17449 .map_err(workspace_entry_create_failure)
17450}
17451
17452fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17453 let code = match error.kind() {
17454 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17455 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17456 NodeFailureCode::RepositoryPathUnsafe
17457 }
17458 WorkspaceFileReadErrorKind::AlreadyExists => {
17459 NodeFailureCode::RepositoryEntryAlreadyExists
17460 }
17461 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17462 NodeFailureCode::RepositoryParentNotFound
17463 }
17464 WorkspaceFileReadErrorKind::ParentNotDirectory
17465 | WorkspaceFileReadErrorKind::NotRegularFile => {
17466 NodeFailureCode::RepositoryParentNotDirectory
17467 }
17468 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17469 NodeFailureCode::RepositoryEntryCreateFailed
17470 }
17471 WorkspaceFileReadErrorKind::RevisionConflict => {
17472 NodeFailureCode::RepositoryEntryCreateFailed
17473 }
17474 WorkspaceFileReadErrorKind::Canceled => {
17475 NodeFailureCode::RepositoryEntryCreateTimedOut
17476 }
17477 };
17478 failure(code, "repository entry create failed")
17479}
17480
17481fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17482 let value = digest(&SHA256, bytes)
17483 .as_ref()
17484 .iter()
17485 .map(|byte| format!("{byte:02x}"))
17486 .collect();
17487 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17488}
17489
17490fn managed_spawn_request_digest_v2(
17491 request: &ManagedWorktreeSpawnRequestV2,
17492) -> Result<String, NodeFailure> {
17493 let encoded = serde_json::to_vec(request).map_err(|_| {
17494 failure(
17495 NodeFailureCode::InvalidRequest,
17496 "managed worktree V2 request could not be canonicalized",
17497 )
17498 })?;
17499 let mut value = String::with_capacity(71);
17500 value.push_str("sha256:");
17501 for byte in digest(&SHA256, &encoded).as_ref() {
17502 use std::fmt::Write as _;
17503 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17504 }
17505 Ok(value)
17506}
17507
17508fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17509 let code = match error {
17510 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17511 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17512 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17513 NativeSessionPreviewError::UnsupportedProvider
17514 | NativeSessionPreviewError::PreviewUnavailable
17515 | NativeSessionPreviewError::SessionNotFound
17516 | NativeSessionPreviewError::AmbiguousSession => {
17517 NodeFailureCode::BackendOperationFailed
17518 }
17519 };
17520 failure(code, "native session preview failed")
17521}
17522
17523fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17524 let code = match error.kind() {
17525 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17526 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17527 };
17528 failure(code, "host directory browse failed")
17529}
17530
17531fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17532 let code = match error {
17533 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17534 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17535 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17536 NodeFailureCode::DeliveryStageConflict
17537 }
17538 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17539 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17540 NodeFailureCode::DeliveryChunkOutOfOrder
17541 }
17542 DeliveryStoreError::BlobDigestMismatch => {
17543 NodeFailureCode::DeliveryBlobDigestMismatch
17544 }
17545 DeliveryStoreError::BundleDigestMismatch => {
17546 NodeFailureCode::DeliveryBundleDigestMismatch
17547 }
17548 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17549 DeliveryStoreError::Unavailable
17550 | DeliveryStoreError::Corrupt
17551 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17552 };
17553 failure(code, "delivery operation failed")
17554}
17555
17556fn validate_workspace_root(
17557 workspace_id: &WorkspaceId,
17558 canonical_root: &str,
17559) -> Result<(), NodeServerError> {
17560 #[cfg(windows)]
17561 if canonical_root.starts_with(r"\\") {
17562 return Err(NodeServerError::InvalidWorkspaceRoot {
17563 workspace_id: workspace_id.clone(),
17564 path: canonical_root.to_owned(),
17565 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17566 });
17567 }
17568 if !platform::workspace_root_supported(canonical_root)
17569 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17570 {
17571 return Err(NodeServerError::InvalidWorkspaceRoot {
17572 workspace_id: workspace_id.clone(),
17573 path: canonical_root.to_owned(),
17574 message: "canonical path must be absolute, bounded, and free of control characters"
17575 .to_owned(),
17576 });
17577 }
17578 Ok(())
17579}
17580
17581fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17582 NodeFailure {
17583 code,
17584 message: node_failure_category(code).to_owned(),
17585 }
17586}
17587
17588fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17589 let code = match error {
17590 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17591 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17592 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17593 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17594 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17595 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17596 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17597 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17598 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17599 };
17600 failure(code, "harness MCP proxy operation failed")
17601}
17602
17603static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17607
17608fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17615 let trace = launch.trace.as_ref()?;
17616 let trace_dir = std::env::var(&trace.dir_env).ok()?;
17617 if trace_dir.is_empty() {
17618 return None;
17619 }
17620 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17621 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17622 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17623}
17624
17625fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17626 let remaining = deadline.saturating_duration_since(Instant::now());
17627 if remaining.is_zero() {
17628 Err(failure(
17629 NodeFailureCode::SpawnDeadlineExceeded,
17630 "spawn deadline elapsed",
17631 ))
17632 } else {
17633 Ok(remaining)
17634 }
17635}
17636
17637fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17638 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17639 match deadline {
17640 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17641 None => Ok(bounded),
17642 }
17643}
17644
17645fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17646 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17647 failure(
17648 NodeFailureCode::SpawnDeadlineExceeded,
17649 "spawn dispatch exceeded the spawn deadline",
17650 )
17651 } else {
17652 error
17653 }
17654}
17655
17656fn spawn_runtime_capabilities(
17657 capabilities: &SpawnRequiredCapabilities,
17658) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17659 capabilities
17660 .iter()
17661 .map(|capability| match capability.as_str() {
17662 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17663 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17664 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17665 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17666 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17667 }
17668 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17669 _ => Err(failure(
17670 NodeFailureCode::UnsupportedSpawnCapability,
17671 "spawn required capability is unknown",
17672 )),
17673 })
17674 .collect()
17675}
17676
17677fn persistence_failure(_error: io::Error) -> NodeFailure {
17678 NodeFailure {
17679 code: NodeFailureCode::BackendOperationFailed,
17680 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17681 }
17682}
17683
17684fn node_failure_category(code: NodeFailureCode) -> &'static str {
17685 match code {
17686 NodeFailureCode::InvalidRequest => "invalid-request",
17687 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17688 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17689 NodeFailureCode::ReservationNotFound => "reservation-not-found",
17690 NodeFailureCode::ReservationConflict => "reservation-conflict",
17691 NodeFailureCode::ReservationExpired => "reservation-expired",
17692 NodeFailureCode::BindingMismatch => "binding-mismatch",
17693 NodeFailureCode::NotActivated => "not-activated",
17694 NodeFailureCode::CallNotFound => "call-not-found",
17695 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17696 NodeFailureCode::ResponseTooLarge => "response-too-large",
17697 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17698 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17699 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17700 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17701 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17702 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17703 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17704 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17705 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17706 NodeFailureCode::Unauthorized => "unauthorized",
17707 NodeFailureCode::ObserverReadOnly => "observer-read-only",
17708 NodeFailureCode::ControllerBusy => "controller-busy",
17709 NodeFailureCode::ControllerRequired => "controller-required",
17710 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17711 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17712 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17713 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17714 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17715 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17716 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17717 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17718 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17719 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17720 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17721 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17722 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17723 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17724 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17725 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17726 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17727 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17728 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17729 NodeFailureCode::GitReadFailed => "git-read-failed",
17730 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17731 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17732 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17733 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17734 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17735 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17736 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17737 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17738 NodeFailureCode::TurnInFlight => "turn-in-flight",
17739 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17740 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17741 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17742 "unsupported-network-allowlist-mapping"
17743 }
17744 NodeFailureCode::BrowserStationProbeUnavailable => {
17745 "browser-station-probe-unavailable"
17746 }
17747 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17748 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17749 NodeFailureCode::EnvironmentProfileBindingMismatch => {
17750 "environment-profile-binding-mismatch"
17751 }
17752 NodeFailureCode::UnknownBundle => "unknown-bundle",
17753 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17754 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17755 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17756 NodeFailureCode::ContextPackBusy => "context-pack-busy",
17757 NodeFailureCode::ContextPackMaterializationFailed => {
17758 "context-pack-materialization-failed"
17759 }
17760 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17761 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17762 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17763 NodeFailureCode::WorkspaceBusy => "workspace-busy",
17764 NodeFailureCode::LastWorkspace => "last-workspace",
17765 NodeFailureCode::NotGitRepository => "not-git-repository",
17766 NodeFailureCode::WorktreeConflict => "worktree-conflict",
17767 NodeFailureCode::WorktreeProtected => "worktree-protected",
17768 NodeFailureCode::WorktreeDirty => "worktree-dirty",
17769 NodeFailureCode::WorktreeLocked => "worktree-locked",
17770 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17771 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17772 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17773 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17774 "managed-worktree-profile-revision-mismatch"
17775 }
17776 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17777 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17778 "standalone-workspace-recovery-required"
17779 }
17780 NodeFailureCode::UnknownSession => "unknown-session",
17781 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17782 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17783 NodeFailureCode::SessionRecordBusy => "session-record-busy",
17784 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17785 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17786 NodeFailureCode::WorkspaceRegistrationRequired => {
17787 "workspace-registration-required"
17788 }
17789 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17790 NodeFailureCode::StaleGeneration => "stale-generation",
17791 NodeFailureCode::BackendBusy => "backend-busy",
17792 NodeFailureCode::BackendDisconnected => "backend-disconnected",
17793 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17794 NodeFailureCode::ShuttingDown => "shutting-down",
17795 }
17796}
17797
17798fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17799 NodeServerError::DurableState(io::Error::new(error.kind(), category))
17800}
17801
17802fn durable_state_load_error(error: io::Error) -> NodeServerError {
17803 let category = match session_registry::state_load_refusal(&error) {
17804 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17805 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17806 }
17807 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17808 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17809 }
17810 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17811 DURABLE_STATE_CONFLICT_ERROR
17812 }
17813 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17814 };
17815 durable_state_server_error(error, category)
17816}
17817
17818fn unix_time_ms() -> u64 {
17819 std::time::SystemTime::now()
17820 .duration_since(std::time::UNIX_EPOCH)
17821 .unwrap_or_default()
17822 .as_millis()
17823 .min(u64::MAX as u128) as u64
17824}
17825
17826fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17827 if record.provider_session.is_some() {
17828 ManagedSessionState::Dormant
17829 } else {
17830 ManagedSessionState::Unavailable
17831 }
17832}
17833
17834fn active_record_state(
17835 identity_admitted: bool,
17836 provider_session_is_known: bool,
17837) -> ManagedSessionState {
17838 if identity_admitted && !provider_session_is_known {
17839 ManagedSessionState::IdentityPending
17840 } else {
17841 ManagedSessionState::Live
17842 }
17843}
17844
17845fn managed_worktree_record_holders(
17846 records: &[ManagedSessionRecord],
17847) -> Vec<(SessionRecordId, WorkspaceId)> {
17848 records
17849 .iter()
17850 .filter(|record| record.provider_session.is_some())
17851 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17852 .collect()
17853}
17854
17855fn managed_spawn_record_ownership(
17856 binding: &SessionBinding,
17857 effective_runtime_policy: ProviderRuntimePolicy,
17858) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17859 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17860 if effective_runtime_policy.provider_session_identity {
17861 (binding.record_id.clone(), None)
17862 } else {
17863 (None, binding.record_id.clone())
17864 }
17865}
17866
17867#[derive(Debug, Error)]
17868pub enum NodeServerError {
17869 #[error("node endpoint must be a bounded absolute local transport path")]
17870 InvalidEndpoint,
17871 #[error("node access token must contain 1..=4096 bytes")]
17872 InvalidAccessToken,
17873 #[error("node HTTP observer must listen on a loopback address: {0}")]
17874 InvalidApiListen(std::net::SocketAddr),
17875 #[error("node browser bridge must listen on a loopback address: {0}")]
17876 InvalidBridgeListen(std::net::SocketAddr),
17877 #[error("node browser bridge token must contain 1..=4096 bytes")]
17878 InvalidBridgeToken,
17879 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17880 BridgeUnderlayRequiresBridgeListen,
17881 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17882 BridgeUnderlayRequiresBridgeToken,
17883 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17884 InvalidBridgeUnderlayToken,
17885 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17886 InvalidBridgeUnderlayKey,
17887 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17888 InvalidCallHome(std::net::SocketAddr),
17889 #[error("node could not reach its call-home relay: {0}")]
17890 CallHomeTransport(#[source] std::io::Error),
17891 #[error("node durable state path must be an absolute file path: {0}")]
17892 InvalidStatePath(String),
17893 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17894 InvalidHarnessMcpHelper,
17895 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17896 InvalidNetworkAllowlistCatalogPath(String),
17897 #[error("network allowlist catalog could not be read: {0}")]
17898 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17899 #[error("network allowlist catalog line {line}: {message}")]
17900 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17901 #[error("network allowlist catalog: {0}")]
17902 InvalidNetworkAllowlistCatalog(String),
17903 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17904 NetworkAllowlistCatalogCapacity { max: usize },
17905 #[error("the local state directory is unavailable; supply an explicit state path")]
17906 LocalStateDirectoryUnavailable,
17907 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17908 LocalRuntimeDirectoryUnavailable,
17909 #[error("node durable state failed: {0}")]
17910 DurableState(io::Error),
17911 #[error("node requires at least one configured workspace")]
17912 NoWorkspaces,
17913 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17914 InvalidWorkspaceRoot {
17915 workspace_id: WorkspaceId,
17916 path: String,
17917 message: String,
17918 },
17919 #[error("duplicate workspace ID: {0}")]
17920 DuplicateWorkspaceId(WorkspaceId),
17921 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17922 DuplicateWorkspaceRoot {
17923 first: WorkspaceId,
17924 second: WorkspaceId,
17925 root: String,
17926 },
17927 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17928 ManagedWorktreeProfileRequired(WorkspaceId),
17929 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17930 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17931 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17932 DuplicateManagedWorktreeProfile {
17933 workspace_id: WorkspaceId,
17934 profile_id: WorktreeProfileId,
17935 },
17936 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17937 ManagedWorktreeProfileCapacity {
17938 workspace_id: WorkspaceId,
17939 max: usize,
17940 },
17941 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17942 InvalidManagedWorktreeProfile {
17943 workspace_id: WorkspaceId,
17944 profile_id: WorktreeProfileId,
17945 message: String,
17946 },
17947 #[error("duplicate node environment profile: {0}")]
17948 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17949 #[error("node environment profile provider is unavailable: {0}")]
17950 EnvironmentProfileProviderUnavailable(AgentId),
17951 #[error("node environment profile capacity is {max}")]
17952 EnvironmentProfileCapacity { max: usize },
17953 #[error("node environment profiles reuse native profile ID '{0}'")]
17954 DuplicateNativeEnvironmentProfile(String),
17955 #[error("node native environment profile is invalid: {0}")]
17956 NativeEnvironmentProfile(String),
17957 #[error("node session-environment materialization root must be absolute")]
17958 InvalidSessionEnvironmentRoot,
17959 #[error("node session-environment materializer is unavailable")]
17960 SessionEnvironmentMaterializerRequired,
17961 #[error("node session-environment materializer failed to initialize")]
17962 SessionEnvironmentMaterializer,
17963 #[error("node bundle catalog is invalid: {0}")]
17964 BundleCatalog(String),
17965 #[error("node delivery store failed to initialize: {0}")]
17966 DeliveryStore(String),
17967 #[error("node context pack store failed to initialize")]
17968 ContextPackStore,
17969 #[error("active agent registry failed: {0}")]
17970 Registry(String),
17971 #[error("node provider contract manifest is invalid: {0}")]
17972 ProviderContractManifest(String),
17973 #[error("named pipe I/O failed: {0}")]
17974 Io(#[from] io::Error),
17975 #[error("node HTTP observer failed: {0}")]
17976 HttpApi(io::Error),
17977 #[error("node browser bridge failed: {0}")]
17978 BridgeApi(io::Error),
17979 #[error(transparent)]
17980 Frame(#[from] FrameError),
17981 #[error("node handshake failed: {0}")]
17982 Handshake(String),
17983 #[error("node authentication frame was not received before the bounded deadline")]
17984 AuthenticationTimedOut,
17985 #[error("node authentication primitive failed: {0}")]
17986 Authentication(String),
17987 #[error("node incarnation identity generation failed: {0}")]
17988 IncarnationIdentity(String),
17989 #[error("node pre-authentication limiter is closed")]
17990 PreauthClosed,
17991 #[error("node authenticated connection limit was reached")]
17992 AuthenticatedConnectionLimit,
17993 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17994 SignalStreamClosed,
17995 #[error("node stop-all dispatch failed: {0:?}")]
17996 ShutdownDispatch(NodeFailure),
17997 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17998 ShutdownTimedOut { active_native_sessions: usize },
17999}
18000
18001impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18002 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18003 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18004 match error {
18005 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18006 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18007 E::InvalidEntry { line, message } => {
18008 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18009 }
18010 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18011 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18012 }
18013 }
18014}
18015
18016#[cfg(test)]
18017mod agent_stream_blocked_tests {
18018 use super::*;
18019 use gate4agent_types::{
18020 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18021 ProviderSource,
18022 };
18023
18024 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18025 ProviderSource {
18026 family,
18027 binding: AdapterBinding::new(
18028 AdapterId::new(adapter).unwrap(),
18029 "fixture/v1",
18030 AdapterVerification::SyntheticFixture,
18031 )
18032 .unwrap(),
18033 }
18034 }
18035
18036 fn provider_control_event_at(
18037 family: AdapterFamily,
18038 adapter: &str,
18039 source_sequence: u64,
18040 provider_event: ProviderEvent,
18041 ) -> ControlEvent {
18042 ControlEvent {
18043 sequence: 41,
18044 command_id: None,
18045 instance_id: AgentInstanceId(7),
18046 generation: SessionGeneration(3),
18047 event: ControlEventKind::ProviderEvent {
18048 sequence: 8,
18049 source: provider_source(family, adapter),
18050 source_sequence,
18051 event: provider_event,
18052 },
18053 }
18054 }
18055
18056 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18057 provider_control_event_at(family, "fixture", 9, provider_event)
18058 }
18059
18060 fn denied_host_request(
18061 by: ProviderHostDecisionAuthority,
18062 reason: Option<String>,
18063 ) -> ControlEvent {
18064 provider_control_event_at(
18065 AdapterFamily::Acp,
18066 "claude-code",
18067 2,
18068 ProviderEvent::HostRequestObserved {
18069 method: "terminal/create".to_owned(),
18070 params_json: String::new(),
18071 decision: ProviderHostRequestDecision::Denied { by },
18072 outcome: ProviderHostRequestOutcome::Executed,
18073 reason,
18074 },
18075 )
18076 }
18077
18078 fn blocked(
18079 event: &ControlEvent,
18080 ) -> (
18081 Option<String>,
18082 String,
18083 BlockAuthorityV1,
18084 Option<String>,
18085 String,
18086 Option<String>,
18087 ) {
18088 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18089 chunk.validate().expect("minted chunk must validate");
18090 let AgentStreamChunkKindV1::Blocked {
18091 correlation_id,
18092 tool_class,
18093 authority,
18094 reason_kind,
18095 reason,
18096 help,
18097 } = chunk.kind
18098 else {
18099 panic!("expected AgentStreamChunkKindV1::Blocked");
18100 };
18101 (correlation_id, tool_class, authority, reason_kind, reason, help)
18102 }
18103
18104 #[test]
18108 fn the_enabled_provider_matrix_is_exact() {
18109 let registry = active_registry().unwrap();
18110 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18111 assert_eq!(
18112 providers
18113 .iter()
18114 .map(|contract| contract.provider.as_str())
18115 .collect::<Vec<_>>(),
18116 vec!["shell", "claude", "codex", "grok", "kimi"],
18118 );
18119 assert!(adapters.iter().all(|contract| {
18120 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18121 }));
18122 }
18123
18124 #[test]
18127 fn gate_denied_host_request_mints_a_blocked_chunk() {
18128 let gate_text =
18129 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18130 let event = denied_host_request(
18131 ProviderHostDecisionAuthority::Gate,
18132 Some(gate_text.to_owned()),
18133 );
18134 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18135 assert_eq!(chunk.source_sequence, 2);
18136 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18137 assert_eq!(authority, BlockAuthorityV1::HostGate);
18138 assert_eq!(reason, gate_text);
18139 assert_eq!(correlation_id, None);
18140 assert_eq!(reason_kind, None);
18141 assert_eq!(help, None);
18142 assert_eq!(tool_class, "Write");
18143 }
18144
18145 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.";
18146
18147 #[test]
18153 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18154 let event = provider_control_event(
18155 AdapterFamily::Acp,
18156 ProviderEvent::ToolCompleted {
18157 id: "toolu_9".to_owned(),
18158 output: CLASSIFIER_SAMPLE.to_owned(),
18159 is_error: true,
18160 duration_ms: None,
18161 agent_id: None,
18162 non_execution_kind: Some("permission-rule".to_owned()),
18163 },
18164 );
18165 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18166 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18167 assert_eq!(reason, "Blocked by classifier");
18168 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18169 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18170 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18171 }
18172
18173 #[test]
18178 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18179 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.";
18180 let event = provider_control_event(
18181 AdapterFamily::Acp,
18182 ProviderEvent::SessionEnded {
18183 result: "JSON-RPC error -32603: Internal error".to_owned(),
18184 cost_usd: None,
18185 is_error: true,
18186 stop_reason: Some(ProviderStopReason::ProviderError {
18187 code: -32603,
18188 message: message.to_owned(),
18189 vendor_code: Some("usageLimitExceeded".to_owned()),
18190 }),
18191 },
18192 );
18193 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18194 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18195 assert_eq!(reason, message);
18196 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18197 assert_eq!(tool_class, "Session");
18198 }
18199
18200 #[test]
18203 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18204 let event = provider_control_event(
18205 AdapterFamily::Acp,
18206 ProviderEvent::SessionEnded {
18207 result: "refusal".to_owned(),
18208 cost_usd: None,
18209 is_error: true,
18210 stop_reason: Some(ProviderStopReason::Refusal),
18211 },
18212 );
18213 let (_, _, authority, _, _, _) = blocked(&event);
18214 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18215 }
18216
18217 #[test]
18221 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18222 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18223 let event = provider_control_event(
18224 AdapterFamily::Acp,
18225 ProviderEvent::ToolCompleted {
18226 id: "toolu_1".to_owned(),
18227 output: "command exited with status 1".to_owned(),
18228 is_error: true,
18229 duration_ms: Some(12),
18230 agent_id: None,
18231 non_execution_kind,
18232 },
18233 );
18234 assert!(agent_stream_chunk(&event).is_none());
18235 }
18236 let narrative = provider_control_event(
18237 AdapterFamily::Acp,
18238 ProviderEvent::ToolCompleted {
18239 id: "toolu_2".to_owned(),
18240 output: "the harness blocked the write, approval needed".to_owned(),
18241 is_error: false,
18242 duration_ms: Some(3),
18243 agent_id: None,
18244 non_execution_kind: None,
18245 },
18246 );
18247 assert!(agent_stream_chunk(&narrative).is_none());
18248 }
18249
18250 #[test]
18254 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18255 for (decision, outcome) in [
18256 (
18257 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18258 ProviderHostRequestOutcome::Executed,
18259 ),
18260 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18261 (
18262 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18263 ProviderHostRequestOutcome::Failed {
18264 error: "terminal/create spawn failed: os error 3".to_owned(),
18265 },
18266 ),
18267 ] {
18268 let event = provider_control_event_at(
18269 AdapterFamily::Acp,
18270 "claude-code",
18271 3,
18272 ProviderEvent::HostRequestObserved {
18273 method: "fs/read_text_file".to_owned(),
18274 params_json: String::new(),
18275 decision: decision.clone(),
18276 outcome,
18277 reason: None,
18278 },
18279 );
18280 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18281 }
18282 }
18283
18284 #[test]
18288 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18289 for (by, expected_authority, expected_reason) in [
18290 (
18291 ProviderHostDecisionAuthority::Operator,
18292 BlockAuthorityV1::Operator,
18293 "declined by the operator",
18294 ),
18295 (
18296 ProviderHostDecisionAuthority::DeadlinePolicy,
18297 BlockAuthorityV1::HostDeadline,
18298 "denied by host policy after the operator deadline expired",
18299 ),
18300 ] {
18301 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18302 assert_eq!(authority, expected_authority);
18303 assert_eq!(reason, expected_reason);
18304 }
18305 }
18306
18307 #[test]
18311 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18312 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18313 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18314 ProviderHostDecisionAuthority::Policy,
18315 Some(oversized),
18316 ));
18317 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18318 }
18319}
18320
18321#[cfg(test)]
18322mod standalone_capability_platform_tests {
18323 use super::*;
18324
18325 #[test]
18326 fn standalone_workspace_capability_requires_windows_identity_guards() {
18327 let capabilities = baseline_capabilities().unwrap();
18328 let advertised = capabilities.iter().any(|capability| {
18329 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18330 });
18331 assert_eq!(advertised, cfg!(windows));
18332
18333 let request = NodeRequest::CreateStandaloneWorkspace {
18334 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18335 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18336 initial_branch: None,
18337 };
18338 assert_eq!(
18339 request_uses_unnegotiated_capability(&request, &capabilities),
18340 !cfg!(windows),
18341 );
18342 }
18343}
18344
18345
18346#[cfg(test)]
18347mod station_knobs_resolve_tests {
18348 use super::*;
18349 use gate4agent_catalog::EnvMutation;
18350 use gate4agent_runtime_native::{
18351 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18352 NativeLaunchProfileId,
18353 };
18354 use std::ffi::OsString;
18355 use std::sync::Arc;
18356
18357 struct EmptyEnvironmentResolver;
18358
18359 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18360 fn resolve_child_environment(
18361 &self,
18362 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18363 Ok(vec![EnvMutation {
18364 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18365 value: None,
18366 }])
18367 }
18368 }
18369
18370 fn agent(value: &str) -> AgentId {
18371 AgentId::new(value).unwrap()
18372 }
18373
18374 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18375 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18376 let profile_revision =
18377 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18378 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18379 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18380 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18381 provider: agent("claude"),
18382 mode: SessionMode::Pty,
18383 terminal_size: gate4agent_types::TerminalSize {
18384 rows: 24,
18385 columns: 80,
18386 },
18387 prompt: None,
18388 bundle_id: None,
18389 context_id: None,
18390 environment_profile_id: Some(profile_id.clone()),
18391 };
18392 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18393 let catalog = active_registry().unwrap();
18394 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18395 let control = runtime.native_launch_profile_control();
18396 let node_profile = NodeEnvironmentProfile::new(
18397 profile_id.clone(),
18398 profile_revision,
18399 agent("claude"),
18400 [NativeLaunchProfile::new(
18401 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18402 agent("claude"),
18403 TransportKind::Pty,
18404 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18405 Arc::new(EmptyEnvironmentResolver),
18406 )
18407 .unwrap()],
18408 )
18409 .unwrap();
18410 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18411 for native_profile in native_profiles {
18412 runtime.upsert_native_launch_profile(native_profile).unwrap();
18413 }
18414 let workspace = WorkspaceConfig::new(
18415 WorkspaceId::new("primary").unwrap(),
18416 std::env::current_dir().unwrap(),
18417 )
18418 .unwrap();
18419 let shared = NodeShared::new_with_incarnation(
18420 handle,
18421 "fixture-token".to_owned(),
18422 NodeId::new("node-station-knobs-test").unwrap(),
18423 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18424 vec![workspace],
18425 vec![agent("claude")],
18426 ProviderRuntimeStatuses::default(),
18427 None,
18428 Vec::new(),
18429 Vec::new(),
18430 spawn_profiles,
18431 Some(control),
18432 None,
18433 None,
18434 None,
18435 Vec::new(),
18436 Vec::new(),
18437 Vec::new(),
18438 Vec::new(),
18439 None,
18440 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18441 );
18442 shared
18443 .environment_profiles
18444 .write()
18445 .unwrap_or_else(|poisoned| poisoned.into_inner())
18446 .insert(binding.id.clone(), binding);
18447 (shared, spawn_defaults)
18448 }
18449
18450 #[test]
18453 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18454 let (shared, defaults) = station_knobs_fixture();
18455 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18456 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18457 let spec = crate::protocol::SpawnSpec {
18458 target: crate::protocol::SpawnTarget {
18459 node_id: shared.node_id.clone(),
18460 workspace_id: WorkspaceId::new("primary").unwrap(),
18461 worktree_id: None,
18462 },
18463 profile_id: defaults.profile_id.clone(),
18464 expected_profile_revision: defaults.revision.clone(),
18465 overrides: crate::protocol::SpawnOverrides {
18466 network_allowlist: Some(unknown),
18467 browser_profile_id: Some(browser),
18468 ..crate::protocol::SpawnOverrides::default()
18469 },
18470 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18471 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18472 "station-knobs-unknown-allowlist",
18473 )
18474 .unwrap(),
18475 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18476 };
18477 let resolved = spec.resolve(&defaults).unwrap();
18478 let failure = shared
18479 .resolve_environment_profile(&resolved)
18480 .expect_err("unknown allowlist must refuse");
18481 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18482 }
18483
18484 #[test]
18490 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18491 let (shared, defaults) = station_knobs_fixture();
18492 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18493 #[cfg(not(feature = "dig2-station-probe"))]
18494 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18495 shared
18496 .network_allowlist_catalog
18497 .write()
18498 .unwrap_or_else(|poisoned| poisoned.into_inner())
18499 .insert_id(allowlist.clone())
18500 .unwrap();
18501 let spec = crate::protocol::SpawnSpec {
18502 target: crate::protocol::SpawnTarget {
18503 node_id: shared.node_id.clone(),
18504 workspace_id: WorkspaceId::new("primary").unwrap(),
18505 worktree_id: None,
18506 },
18507 profile_id: defaults.profile_id.clone(),
18508 expected_profile_revision: defaults.revision.clone(),
18509 overrides: crate::protocol::SpawnOverrides {
18510 network_allowlist: Some(allowlist.clone()),
18511 #[cfg(not(feature = "dig2-station-probe"))]
18512 browser_profile_id: Some(browser.clone()),
18513 ..crate::protocol::SpawnOverrides::default()
18514 },
18515 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18516 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18517 .unwrap(),
18518 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18519 };
18520 let resolved = spec.resolve(&defaults).unwrap();
18521 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18522 #[cfg(not(feature = "dig2-station-probe"))]
18523 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18524 let receipt = shared
18525 .resolve_environment_profile(&resolved)
18526 .expect("resolve ok")
18527 .expect("env profile present");
18528 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18529 #[cfg(not(feature = "dig2-station-probe"))]
18530 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18531 #[cfg(feature = "dig2-station-probe")]
18532 assert_eq!(receipt.browser_profile_id, None);
18533 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18534
18535 let omitted = crate::protocol::SpawnSpec {
18536 overrides: crate::protocol::SpawnOverrides::default(),
18537 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18538 .unwrap(),
18539 ..spec
18540 };
18541 let resolved_omit = omitted.resolve(&defaults).unwrap();
18542 let bare = shared
18543 .resolve_environment_profile(&resolved_omit)
18544 .expect("resolve ok")
18545 .expect("env profile present");
18546 assert_eq!(bare.network_allowlist, None);
18547 assert_eq!(bare.browser_profile_id, None);
18548 }
18549
18550 #[cfg(feature = "dig2-station-probe")]
18555 #[test]
18556 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18557 let (shared, defaults) = station_knobs_fixture();
18558 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18559 let spec = crate::protocol::SpawnSpec {
18560 target: crate::protocol::SpawnTarget {
18561 node_id: shared.node_id.clone(),
18562 workspace_id: WorkspaceId::new("primary").unwrap(),
18563 worktree_id: None,
18564 },
18565 profile_id: defaults.profile_id.clone(),
18566 expected_profile_revision: defaults.revision.clone(),
18567 overrides: crate::protocol::SpawnOverrides {
18568 browser_profile_id: Some(browser),
18569 ..crate::protocol::SpawnOverrides::default()
18570 },
18571 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18572 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18573 "station-knobs-probe-refuse",
18574 )
18575 .unwrap(),
18576 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18577 };
18578 let resolved = spec.resolve(&defaults).unwrap();
18579 let failure = shared
18580 .resolve_environment_profile(&resolved)
18581 .expect_err("browser_profile with failing probe must refuse");
18582 #[cfg(windows)]
18583 {
18584 assert!(
18585 matches!(
18586 failure.code,
18587 NodeFailureCode::BrowserStationUnreachable
18588 | NodeFailureCode::BrowserStationProbeUnavailable
18589 ),
18590 "unexpected code {:?}",
18591 failure.code
18592 );
18593 }
18594 #[cfg(not(windows))]
18595 {
18596 assert_eq!(
18597 failure.code,
18598 NodeFailureCode::BrowserStationProbeUnavailable
18599 );
18600 }
18601 }
18602
18603 #[cfg(feature = "dig2-station-probe")]
18605 #[test]
18606 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18607 let (shared, defaults) = station_knobs_fixture();
18608 let spec = crate::protocol::SpawnSpec {
18609 target: crate::protocol::SpawnTarget {
18610 node_id: shared.node_id.clone(),
18611 workspace_id: WorkspaceId::new("primary").unwrap(),
18612 worktree_id: None,
18613 },
18614 profile_id: defaults.profile_id.clone(),
18615 expected_profile_revision: defaults.revision.clone(),
18616 overrides: crate::protocol::SpawnOverrides::default(),
18617 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18618 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18619 "station-knobs-probe-omit",
18620 )
18621 .unwrap(),
18622 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18623 };
18624 let resolved = spec.resolve(&defaults).unwrap();
18625 let receipt = shared
18626 .resolve_environment_profile(&resolved)
18627 .expect("omit browser_profile must not probe")
18628 .expect("env profile present");
18629 assert_eq!(receipt.browser_profile_id, None);
18630 }
18631
18632 #[cfg(feature = "dig2-station-probe")]
18636 #[test]
18637 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18638 let (shared, _defaults) = station_knobs_fixture();
18639 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18640 let workspace_id = WorkspaceId::new("primary").unwrap();
18641 let address_a = SessionAddress {
18642 workspace_id: workspace_id.clone(),
18643 session: SessionKey {
18644 instance_id: AgentInstanceId(101),
18645 generation: SessionGeneration(1),
18646 },
18647 };
18648 let address_b = SessionAddress {
18649 workspace_id: workspace_id.clone(),
18650 session: SessionKey {
18651 instance_id: AgentInstanceId(102),
18652 generation: SessionGeneration(1),
18653 },
18654 };
18655 let guard_a = crate::dig2_station_lease::acquire_lease(
18656 &shared.browser_station_leases,
18657 profile.clone(),
18658 address_a.session.instance_id,
18659 1,
18660 )
18661 .expect("first lease");
18662 let busy = crate::dig2_station_lease::acquire_lease(
18663 &shared.browser_station_leases,
18664 profile.clone(),
18665 address_b.session.instance_id,
18666 2,
18667 )
18668 .expect_err("second holder must refuse");
18669 assert!(matches!(
18670 busy,
18671 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18672 ));
18673 shared.bind_session_with_policy(
18675 &address_a,
18676 ProviderRuntimePolicy::raw_pty(),
18677 None,
18678 );
18679 guard_a.retain();
18680 assert!(shared.remove_binding(&address_a).is_some());
18681 crate::dig2_station_lease::acquire_lease(
18682 &shared.browser_station_leases,
18683 profile,
18684 address_b.session.instance_id,
18685 3,
18686 )
18687 .expect("reacquire after clear")
18688 .retain();
18689 assert!(
18690 crate::dig2_station_lease::release_lease_for_holder(
18691 &shared.browser_station_leases,
18692 address_b.session.instance_id,
18693 )
18694 );
18695 }
18696
18697 #[cfg(not(feature = "dig2-station-probe"))]
18700 #[test]
18701 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18702 let (shared, defaults) = station_knobs_fixture();
18703 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18704 let spec = crate::protocol::SpawnSpec {
18705 target: crate::protocol::SpawnTarget {
18706 node_id: shared.node_id.clone(),
18707 workspace_id: WorkspaceId::new("primary").unwrap(),
18708 worktree_id: None,
18709 },
18710 profile_id: defaults.profile_id.clone(),
18711 expected_profile_revision: defaults.revision.clone(),
18712 overrides: crate::protocol::SpawnOverrides {
18713 browser_profile_id: Some(browser.clone()),
18714 ..crate::protocol::SpawnOverrides::default()
18715 },
18716 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18717 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18718 .unwrap(),
18719 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18720 };
18721 let resolved = spec.resolve(&defaults).unwrap();
18722 let receipt = shared
18723 .resolve_environment_profile(&resolved)
18724 .expect("stub resolve")
18725 .expect("env profile");
18726 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18727 }
18728
18729
18730 #[test]
18733 fn network_allowlist_catalog_loads_from_temp_file() {
18734 let unique = std::time::SystemTime::now()
18735 .duration_since(std::time::UNIX_EPOCH)
18736 .unwrap()
18737 .as_nanos();
18738 let path = std::env::temp_dir().join(format!(
18739 "gate4agent-node-allowlist-config-{}-{unique}.txt",
18740 std::process::id(),
18741 ));
18742 std::fs::write(
18743 &path,
18744 "# opaque station allowlist ids\negress-default\nlab-egress\n",
18745 )
18746 .unwrap();
18747 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18748 .expect("temp catalog file must load");
18749 let _ = std::fs::remove_file(&path);
18750 assert_eq!(loaded.len(), 2);
18751 let (shared, defaults) = station_knobs_fixture();
18752 {
18753 let mut catalog = shared
18754 .network_allowlist_catalog
18755 .write()
18756 .unwrap_or_else(|poisoned| poisoned.into_inner());
18757 *catalog = loaded;
18758 }
18759 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18760 let spec = crate::protocol::SpawnSpec {
18761 target: crate::protocol::SpawnTarget {
18762 node_id: shared.node_id.clone(),
18763 workspace_id: WorkspaceId::new("primary").unwrap(),
18764 worktree_id: None,
18765 },
18766 profile_id: defaults.profile_id.clone(),
18767 expected_profile_revision: defaults.revision.clone(),
18768 overrides: crate::protocol::SpawnOverrides {
18769 network_allowlist: Some(allowlist.clone()),
18770 ..crate::protocol::SpawnOverrides::default()
18771 },
18772 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18773 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18774 "station-knobs-catalog-file",
18775 )
18776 .unwrap(),
18777 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18778 };
18779 let resolved = spec.resolve(&defaults).unwrap();
18780 let receipt = shared
18781 .resolve_environment_profile(&resolved)
18782 .expect("registered via file load")
18783 .expect("env profile present");
18784 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18785 }
18786
18787 #[test]
18790 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18791 let (shared, _defaults) = station_knobs_fixture();
18792 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18793 shared
18794 .network_allowlist_catalog
18795 .write()
18796 .unwrap_or_else(|poisoned| poisoned.into_inner())
18797 .insert_id(allowlist.clone())
18798 .unwrap();
18799 let snapshot = shared.snapshot();
18800 let inventory = snapshot
18801 .launch_inventory
18802 .expect("launch inventory present");
18803 let ids = inventory
18804 .network_allowlists
18805 .expect("non-empty catalog surfaces ids");
18806 assert_eq!(ids, vec![allowlist]);
18807 }
18808
18809 #[test]
18813 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18814 let (shared, defaults) = station_knobs_fixture();
18815 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18816 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18817 id: allowlist.clone(),
18818 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18819 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18820 peer: "127.0.0.1:443".to_owned(),
18821 }],
18822 provider_native: Some(
18823 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18824 codex_network_access: Some(true),
18825 },
18826 ),
18827 };
18828 shared
18829 .network_allowlist_catalog
18830 .write()
18831 .unwrap_or_else(|poisoned| poisoned.into_inner())
18832 .insert(entry)
18833 .unwrap();
18834 let spec = crate::protocol::SpawnSpec {
18835 target: crate::protocol::SpawnTarget {
18836 node_id: shared.node_id.clone(),
18837 workspace_id: WorkspaceId::new("primary").unwrap(),
18838 worktree_id: None,
18839 },
18840 profile_id: defaults.profile_id.clone(),
18841 expected_profile_revision: defaults.revision.clone(),
18842 overrides: crate::protocol::SpawnOverrides {
18843 network_allowlist: Some(allowlist),
18844 ..crate::protocol::SpawnOverrides::default()
18845 },
18846 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18847 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18848 "station-knobs-unsupported-mapping",
18849 )
18850 .unwrap(),
18851 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18852 };
18853 let resolved = spec.resolve(&defaults).unwrap();
18854 assert_eq!(resolved.provider.as_str(), "claude");
18855 let failure = shared
18856 .resolve_environment_profile(&resolved)
18857 .expect_err("claude + codex_network_access must refuse");
18858 assert_eq!(
18859 failure.code,
18860 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18861 );
18862 }
18863
18864 #[test]
18868 fn resolve_codex_network_access_overlay_moderate_only() {
18869 let (shared, defaults) = station_knobs_fixture();
18870 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18871 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18872 id: allowlist.clone(),
18873 permits: Vec::new(),
18874 provider_native: Some(
18875 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18876 codex_network_access: Some(true),
18877 },
18878 ),
18879 };
18880 shared
18881 .network_allowlist_catalog
18882 .write()
18883 .unwrap_or_else(|poisoned| poisoned.into_inner())
18884 .insert(entry)
18885 .unwrap();
18886
18887 let overlay = shared
18888 .resolve_codex_network_access_overlay_args(
18889 &agent("codex"),
18890 SessionMode::Pty,
18891 ApprovalLevel::Moderate,
18892 Some(&allowlist),
18893 )
18894 .expect("Moderate Codex maps network_access");
18895 assert_eq!(
18896 overlay,
18897 vec![
18898 std::ffi::OsString::from("-c"),
18899 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18900 ]
18901 );
18902
18903 let acp = shared
18904 .resolve_codex_network_access_overlay_args(
18905 &agent("codex"),
18906 SessionMode::Acp,
18907 ApprovalLevel::Moderate,
18908 Some(&allowlist),
18909 )
18910 .unwrap();
18911 assert_eq!(acp, overlay);
18912
18913 let inline = shared
18914 .resolve_codex_network_access_overlay_args(
18915 &agent("codex"),
18916 SessionMode::Inline,
18917 ApprovalLevel::Moderate,
18918 Some(&allowlist),
18919 )
18920 .unwrap_err();
18921 assert_eq!(
18922 inline.code,
18923 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18924 );
18925
18926 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18927 let spec = crate::protocol::SpawnSpec {
18928 target: crate::protocol::SpawnTarget {
18929 node_id: shared.node_id.clone(),
18930 workspace_id: WorkspaceId::new("primary").unwrap(),
18931 worktree_id: None,
18932 },
18933 profile_id: defaults.profile_id.clone(),
18934 expected_profile_revision: defaults.revision.clone(),
18935 overrides: crate::protocol::SpawnOverrides {
18936 provider: crate::protocol::SpawnOverride::Set {
18937 value: agent("codex"),
18938 },
18939 approval_level: Some(level),
18940 network_allowlist: Some(allowlist.clone()),
18941 ..crate::protocol::SpawnOverrides::default()
18942 },
18943 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18944 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18945 "codex-net-refuse-{}",
18946 match level {
18947 ApprovalLevel::ReadOnly => "ro",
18948 ApprovalLevel::FullAuto => "fa",
18949 _ => "other",
18950 }
18951 ))
18952 .unwrap(),
18953 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18954 };
18955 let resolved = spec.resolve(&defaults).unwrap();
18956 let failure = shared
18957 .resolve_environment_profile(&resolved)
18958 .expect_err("RO/FullAuto must refuse");
18959 assert_eq!(
18960 failure.code,
18961 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18962 );
18963 }
18964 }
18965
18966 #[test]
18970 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18971 let (shared, _defaults) = station_knobs_fixture();
18972 let allowlist =
18973 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18974 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18975 id: allowlist.clone(),
18976 permits: vec![
18977 crate::network_allowlist_catalog::NetworkPermitSketch {
18978 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18979 peer: "github.com:443".to_owned(),
18980 },
18981 crate::network_allowlist_catalog::NetworkPermitSketch {
18982 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18983 peer: "*.npmjs.org".to_owned(),
18984 },
18985 ],
18986 provider_native: None,
18987 };
18988 shared
18989 .network_allowlist_catalog
18990 .write()
18991 .unwrap_or_else(|poisoned| poisoned.into_inner())
18992 .insert(entry)
18993 .unwrap();
18994
18995 let unique = std::time::SystemTime::now()
18996 .duration_since(std::time::UNIX_EPOCH)
18997 .unwrap()
18998 .as_nanos();
18999 let provider_home = std::env::temp_dir().join(format!(
19000 "gate4agent-claude-provider-home-{}-{unique}",
19001 std::process::id(),
19002 ));
19003 std::fs::create_dir_all(&provider_home).unwrap();
19004
19005 let overlay = shared
19006 .resolve_network_allowlist_overlay_args(
19007 &agent("claude"),
19008 SessionMode::Pty,
19009 ApprovalLevel::Moderate,
19010 Some(&allowlist),
19011 Some(provider_home.as_path()),
19012 )
19013 .expect("Claude PTY settings overlay on unix");
19014 let settings_path = provider_home.join(
19015 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19016 );
19017 assert_eq!(
19018 overlay,
19019 vec![
19020 std::ffi::OsString::from("--settings"),
19021 std::ffi::OsString::from(settings_path.as_os_str()),
19022 ]
19023 );
19024 let body = std::fs::read_to_string(&settings_path).unwrap();
19025 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19026 assert_eq!(value["sandbox"]["enabled"], true);
19027 assert_eq!(
19028 value["sandbox"]["network"]["allowedDomains"],
19029 serde_json::json!(["github.com", "*.npmjs.org"])
19030 );
19031 assert!(settings_path.starts_with(&provider_home));
19032
19033 let acp = shared
19034 .resolve_network_allowlist_overlay_args(
19035 &agent("claude"),
19036 SessionMode::Acp,
19037 ApprovalLevel::Moderate,
19038 Some(&allowlist),
19039 Some(provider_home.as_path()),
19040 )
19041 .expect_err("Claude ACP must refuse settings overlay");
19042 assert_eq!(
19043 acp.code,
19044 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19045 );
19046
19047 let no_home = shared
19048 .resolve_network_allowlist_overlay_args(
19049 &agent("claude"),
19050 SessionMode::Pty,
19051 ApprovalLevel::Moderate,
19052 Some(&allowlist),
19053 None,
19054 )
19055 .expect_err("Claude overlay requires ProviderHome");
19056 assert_eq!(
19057 no_home.code,
19058 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19059 );
19060
19061 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19062 shared
19063 .network_allowlist_catalog
19064 .write()
19065 .unwrap_or_else(|poisoned| poisoned.into_inner())
19066 .insert_id(bare.clone())
19067 .unwrap();
19068 let empty = shared
19069 .resolve_network_allowlist_overlay_args(
19070 &agent("claude"),
19071 SessionMode::Pty,
19072 ApprovalLevel::Moderate,
19073 Some(&bare),
19074 Some(provider_home.as_path()),
19075 )
19076 .unwrap();
19077 assert!(empty.is_empty());
19078
19079 let _ = std::fs::remove_dir_all(&provider_home);
19080 }
19081
19082 #[test]
19084 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19085 let (shared, _defaults) = station_knobs_fixture();
19086 let allowlist =
19087 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19088 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19089 id: allowlist.clone(),
19090 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19091 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19092 peer: "example.com:443".to_owned(),
19093 }],
19094 provider_native: None,
19095 };
19096 shared
19097 .network_allowlist_catalog
19098 .write()
19099 .unwrap_or_else(|poisoned| poisoned.into_inner())
19100 .insert(entry)
19101 .unwrap();
19102 for provider in ["kimi", "grok"] {
19103 let overlay = shared
19104 .resolve_network_allowlist_overlay_args(
19105 &agent(provider),
19106 SessionMode::Pty,
19107 ApprovalLevel::Moderate,
19108 Some(&allowlist),
19109 None,
19110 )
19111 .unwrap();
19112 assert!(
19113 overlay.is_empty(),
19114 "{provider} must not invent network argv from permits"
19115 );
19116 }
19117 }
19118
19119 #[test]
19121 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19122 let unique = std::time::SystemTime::now()
19123 .duration_since(std::time::UNIX_EPOCH)
19124 .unwrap()
19125 .as_nanos();
19126 let path = std::env::temp_dir().join(format!(
19127 "gate4agent-node-allowlist-json-{}-{unique}.json",
19128 std::process::id(),
19129 ));
19130 std::fs::write(
19131 &path,
19132 r#"{
19133 "schema_version": 2,
19134 "entries": [
19135 {
19136 "id": "egress-default",
19137 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19138 },
19139 { "id": "lab-egress" }
19140 ]
19141 }"#,
19142 )
19143 .unwrap();
19144 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19145 .expect("json v2 catalog must load");
19146 let _ = std::fs::remove_file(&path);
19147 assert_eq!(loaded.len(), 2);
19148 let entry = loaded
19149 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19150 .unwrap();
19151 assert_eq!(entry.permits.len(), 1);
19152 let (shared, defaults) = station_knobs_fixture();
19153 {
19154 let mut catalog = shared
19155 .network_allowlist_catalog
19156 .write()
19157 .unwrap_or_else(|poisoned| poisoned.into_inner());
19158 *catalog = loaded;
19159 }
19160 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19161 let spec = crate::protocol::SpawnSpec {
19162 target: crate::protocol::SpawnTarget {
19163 node_id: shared.node_id.clone(),
19164 workspace_id: WorkspaceId::new("primary").unwrap(),
19165 worktree_id: None,
19166 },
19167 profile_id: defaults.profile_id.clone(),
19168 expected_profile_revision: defaults.revision.clone(),
19169 overrides: crate::protocol::SpawnOverrides {
19170 network_allowlist: Some(allowlist.clone()),
19171 ..crate::protocol::SpawnOverrides::default()
19172 },
19173 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19174 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19175 "station-knobs-catalog-json",
19176 )
19177 .unwrap(),
19178 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19179 };
19180 let resolved = spec.resolve(&defaults).unwrap();
19181 let receipt = shared
19182 .resolve_environment_profile(&resolved)
19183 .expect("permits-only entry ok for claude")
19184 .expect("env profile present");
19185 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19186 let inventory = shared
19187 .snapshot()
19188 .launch_inventory
19189 .expect("launch inventory present");
19190 let ids = inventory.network_allowlists.expect("ids surface");
19191 assert_eq!(
19192 ids,
19193 vec![
19194 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19195 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19196 ]
19197 );
19198 }
19199
19200
19201}
19202
19203
19204#[cfg(test)]
19205mod workspace_environment_collision_tests {
19206 use super::*;
19207 use crate::session_environment::{
19208 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19209 };
19210 use gate4agent_catalog::EnvMutation;
19211 use gate4agent_runtime_native::{
19212 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19213 NativeLaunchProfileId,
19214 };
19215 use std::ffi::OsString;
19216 use std::sync::Arc;
19217
19218 struct EmptyEnvironmentResolver;
19219
19220 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19221 fn resolve_child_environment(
19222 &self,
19223 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19224 Ok(vec![EnvMutation {
19225 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19226 value: None,
19227 }])
19228 }
19229 }
19230
19231 fn agent(value: &str) -> AgentId {
19232 AgentId::new(value).unwrap()
19233 }
19234
19235 fn claude_config_dir_collision_fixture() -> (
19237 NodeShared,
19238 ResolvedEnvironmentProfileReceipt,
19239 ResolvedEnvironmentProfileReceipt,
19240 ) {
19241 let catalog = active_registry().unwrap();
19242 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19243 let control = runtime.native_launch_profile_control();
19244 let workspace = WorkspaceConfig::new(
19245 WorkspaceId::new("primary").unwrap(),
19246 std::env::current_dir().unwrap(),
19247 )
19248 .unwrap();
19249 let shared = NodeShared::new_with_incarnation(
19250 handle,
19251 "fixture-token".to_owned(),
19252 NodeId::new("node-env-collision-test").unwrap(),
19253 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19254 vec![workspace],
19255 vec![agent("claude")],
19256 ProviderRuntimeStatuses::default(),
19257 None,
19258 Vec::new(),
19259 Vec::new(),
19260 SpawnProfileRegistry::default(),
19261 Some(control),
19262 None,
19263 None,
19264 None,
19265 Vec::new(),
19266 Vec::new(),
19267 Vec::new(),
19268 Vec::new(),
19269 None,
19270 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19271 );
19272
19273 let mut receipts = Vec::new();
19274 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19275 let profile_id =
19276 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19277 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19278 "claude-config-dir-{suffix}-r1"
19279 ))
19280 .unwrap();
19281 let materialization = NodeSessionMaterializationProfile::new(
19282 Vec::new(),
19283 vec![NodeSessionPathBinding::new(
19284 "CLAUDE_CONFIG_DIR",
19285 NodeSessionPathClass::ProviderHome,
19286 )
19287 .unwrap()],
19288 Vec::new(),
19289 )
19290 .unwrap();
19291 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19292 profile_id.clone(),
19293 profile_revision.clone(),
19294 agent("claude"),
19295 [NativeLaunchProfile::new(
19296 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19297 .unwrap(),
19298 agent("claude"),
19299 TransportKind::Pty,
19300 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19301 Arc::new(EmptyEnvironmentResolver),
19302 )
19303 .unwrap()],
19304 Some(materialization),
19305 )
19306 .unwrap();
19307 let (binding, native_profiles, materialization) = node_profile.into_parts();
19308 for native_profile in native_profiles {
19309 runtime.upsert_native_launch_profile(native_profile).unwrap();
19310 }
19311 shared
19312 .environment_profiles
19313 .write()
19314 .unwrap_or_else(|poisoned| poisoned.into_inner())
19315 .insert(binding.id.clone(), binding);
19316 shared
19317 .environment_materialization_profiles
19318 .write()
19319 .unwrap_or_else(|poisoned| poisoned.into_inner())
19320 .insert(profile_id.clone(), materialization.unwrap());
19321 receipts.push(ResolvedEnvironmentProfileReceipt {
19322 profile_id,
19323 profile_revision,
19324 network_allowlist: None,
19325 browser_profile_id: None,
19326 });
19327 }
19328 let profile_b = receipts.pop().unwrap();
19329 let profile_a = receipts.pop().unwrap();
19330 (shared, profile_a, profile_b)
19331 }
19332
19333 #[test]
19334 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19335 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19336 let primary = WorkspaceId::new("primary").unwrap();
19337 let worktree = WorkspaceId::new("worktree-b").unwrap();
19338 let address = SessionAddress {
19339 workspace_id: primary.clone(),
19340 session: SessionKey {
19341 instance_id: AgentInstanceId(41),
19342 generation: SessionGeneration(1),
19343 },
19344 };
19345 shared.bind_session_with_policy(
19346 &address,
19347 ProviderRuntimePolicy::raw_pty(),
19348 Some(profile_a.clone()),
19349 );
19350
19351 let refused = shared
19352 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19353 .unwrap_err();
19354 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19355
19356 let refused_none = shared
19358 .ensure_workspace_environment_profile_compatible(&primary, None)
19359 .unwrap_err();
19360 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19361
19362 shared
19364 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19365 .unwrap();
19366
19367 shared
19369 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19370 .unwrap();
19371 }
19372
19373 #[tokio::test]
19374 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19375 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19376 let primary = WorkspaceId::new("primary").unwrap();
19377 let address = SessionAddress {
19378 workspace_id: primary.clone(),
19379 session: SessionKey {
19380 instance_id: AgentInstanceId(41),
19381 generation: SessionGeneration(1),
19382 },
19383 };
19384 shared.bind_session_with_policy(
19385 &address,
19386 ProviderRuntimePolicy::raw_pty(),
19387 Some(profile_a),
19388 );
19389
19390 let failure = shared
19391 .spawn_session_with_deadline(
19392 primary,
19393 agent("claude"),
19394 SessionMode::Pty,
19395 gate4agent_types::TerminalSize {
19396 rows: 24,
19397 columns: 80,
19398 },
19399 None,
19400 Some(profile_b),
19401 None,
19402 None,
19403 None,
19404 None,
19405 SpawnRecordPolicy::ProviderIdentityOnly,
19406 Some(Instant::now() + Duration::from_secs(30)),
19407 &[],
19408 None,
19409 ApprovalLevel::default(),
19410 None,
19411 None,
19412 )
19413 .await
19414 .unwrap_err();
19415 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19416 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19418 assert!(shared.handle.snapshot().sessions.is_empty());
19419 }
19420}
19421
19422#[cfg(test)]
19423mod spawn_characterization_tests {
19424 use super::*;
19425 use crate::session_environment::{
19426 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19427 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19428 NodeSessionPathClass,
19429 };
19430 use gate4agent_catalog::EnvMutation;
19431 use gate4agent_runtime_native::{
19432 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19433 NativeLaunchProfile, NativeLaunchProfileId,
19434 };
19435 use std::ffi::OsString;
19436 use std::sync::Arc;
19437
19438 struct EmptyEnvironmentResolver;
19439
19440 struct FixtureSecretResolver {
19441 deny: Arc<AtomicBool>,
19442 }
19443
19444 impl NodeSecretResolver for FixtureSecretResolver {
19445 fn resolve(
19446 &self,
19447 _reference: &NodeSecretReference,
19448 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19449 if self.deny.load(Ordering::Acquire) {
19450 Err(NodeSecretResolveError::Denied)
19451 } else {
19452 NodeSecretValue::text("fixture-secret")
19453 .map_err(|_| NodeSecretResolveError::Unavailable)
19454 }
19455 }
19456 }
19457
19458 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19459 fn resolve_child_environment(
19460 &self,
19461 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19462 Ok(vec![EnvMutation {
19463 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19464 value: None,
19465 }])
19466 }
19467 }
19468
19469 fn agent(value: &str) -> AgentId {
19470 AgentId::new(value).unwrap()
19471 }
19472
19473 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19474 let unique = std::time::SystemTime::now()
19475 .duration_since(std::time::UNIX_EPOCH)
19476 .unwrap()
19477 .as_nanos();
19478 std::env::temp_dir().join(format!(
19479 "gate4agent-node-{label}-{}-{unique}",
19480 std::process::id(),
19481 ))
19482 }
19483
19484 fn materialization_test_shared(
19485 deny_secret: bool,
19486 ) -> (
19487 NodeShared,
19488 NativeRuntime,
19489 ResolvedEnvironmentProfileReceipt,
19490 PathBuf,
19491 Arc<AtomicBool>,
19492 ) {
19493 let workspace = WorkspaceConfig::new(
19494 WorkspaceId::new("primary").unwrap(),
19495 std::env::current_dir().unwrap(),
19496 )
19497 .unwrap();
19498 materialization_test_shared_with_workspace(deny_secret, workspace)
19499 }
19500
19501 fn materialization_test_shared_with_workspace(
19502 deny_secret: bool,
19503 workspace: WorkspaceConfig,
19504 ) -> (
19505 NodeShared,
19506 NativeRuntime,
19507 ResolvedEnvironmentProfileReceipt,
19508 PathBuf,
19509 Arc<AtomicBool>,
19510 ) {
19511 let root = temporary_workspace_root(if deny_secret {
19512 "materialization-denied"
19513 } else {
19514 "materialization-ready"
19515 });
19516 std::fs::create_dir_all(&root).unwrap();
19517 let materialization_root = root.join("session-environments");
19518 let deny = Arc::new(AtomicBool::new(deny_secret));
19519 let resolver = Arc::new(FixtureSecretResolver {
19520 deny: Arc::clone(&deny),
19521 });
19522 let materializer = SessionEnvironmentMaterializer::new(
19523 materialization_root,
19524 resolver,
19525 )
19526 .unwrap();
19527 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19528 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19529 "materialized-claude-r1",
19530 )
19531 .unwrap();
19532 let materialization = NodeSessionMaterializationProfile::new(
19533 vec![NodeSessionEnvironmentMutation::SetSecret {
19534 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19535 reference: NodeSecretReference::new("fixture-token").unwrap(),
19536 }],
19537 vec![NodeSessionPathBinding::new(
19538 "GATE4AGENT_PROVIDER_HOME",
19539 NodeSessionPathClass::ProviderHome,
19540 )
19541 .unwrap()],
19542 vec![NodeSessionFile::secret(
19543 NodeSessionPathClass::Config,
19544 "auth/token",
19545 NodeSecretReference::new("fixture-token").unwrap(),
19546 )
19547 .unwrap()],
19548 )
19549 .unwrap();
19550 let catalog = active_registry().unwrap();
19551 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19552 let control = runtime.native_launch_profile_control();
19553 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19554 profile_id.clone(),
19555 profile_revision.clone(),
19556 agent("claude"),
19557 [NativeLaunchProfile::new(
19558 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19559 agent("claude"),
19560 TransportKind::Pty,
19561 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19562 Arc::new(EmptyEnvironmentResolver),
19563 )
19564 .unwrap()],
19565 Some(materialization),
19566 )
19567 .unwrap();
19568 let (binding, native_profiles, materialization) = node_profile.into_parts();
19569 for native_profile in native_profiles {
19570 runtime.upsert_native_launch_profile(native_profile).unwrap();
19571 }
19572 let shared = NodeShared::new_with_incarnation(
19573 handle,
19574 "fixture-token".to_owned(),
19575 NodeId::new("node-materialization-test").unwrap(),
19576 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19577 vec![workspace],
19578 vec![agent("claude")],
19579 ProviderRuntimeStatuses::default(),
19580 None,
19581 Vec::new(),
19582 Vec::new(),
19583 SpawnProfileRegistry::default(),
19584 Some(control),
19585 None,
19586 Some(materializer),
19587 None,
19588 Vec::new(),
19589 Vec::new(),
19590 Vec::new(),
19591 Vec::new(),
19592 None,
19593 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19594 );
19595 shared
19596 .environment_profiles
19597 .write()
19598 .unwrap_or_else(|poisoned| poisoned.into_inner())
19599 .insert(binding.id.clone(), binding);
19600 shared
19601 .environment_materialization_profiles
19602 .write()
19603 .unwrap_or_else(|poisoned| poisoned.into_inner())
19604 .insert(profile_id.clone(), materialization.unwrap());
19605 (
19606 shared,
19607 runtime,
19608 ResolvedEnvironmentProfileReceipt {
19609 profile_id,
19610 profile_revision,
19611 network_allowlist: None,
19612 browser_profile_id: None,
19613 },
19614 root,
19615 deny,
19616 )
19617 }
19618
19619
19620 async fn characterization_spawn(
19623 shared: &NodeShared,
19624 mode: SessionMode,
19625 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19626 required: &[ProviderRuntimeCapability],
19627 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19628 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19629 shared
19630 .spawn_session_with_deadline(
19631 WorkspaceId::new("primary").unwrap(),
19632 agent("claude"),
19633 mode,
19634 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19635 None,
19636 environment_profile,
19637 None,
19638 None,
19639 None,
19640 Some(ProviderRuntimePolicy::raw_pty()),
19641 SpawnRecordPolicy::ProviderIdentityOnly,
19642 Some(Instant::now() + Duration::from_secs(30)),
19643 required,
19644 harness_mcp,
19645 ApprovalLevel::default(),
19646 None,
19647 None,
19648 )
19649 .await
19650 }
19651
19652 fn assert_no_session_state(shared: &NodeShared) {
19653 assert!(shared.handle.snapshot().sessions.is_empty());
19654 assert!(shared
19655 .session_bindings
19656 .lock()
19657 .unwrap_or_else(|poisoned| poisoned.into_inner())
19658 .is_empty());
19659 assert!(shared
19660 .materializations
19661 .lock()
19662 .unwrap_or_else(|poisoned| poisoned.into_inner())
19663 .is_empty());
19664 }
19665
19666 #[tokio::test]
19667 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19668 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19669 let error = characterization_spawn(
19670 &shared,
19671 SessionMode::Pty,
19672 None,
19673 &[ProviderRuntimeCapability::SemanticReadiness],
19674 None,
19675 )
19676 .await
19677 .unwrap_err();
19678 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19679 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19680 assert_no_session_state(&shared);
19681 drop(shared);
19682 drop(runtime);
19683 std::fs::remove_dir_all(root).unwrap();
19684 }
19685
19686 #[tokio::test]
19687 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19688 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19689 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19690 .await
19691 .unwrap_err();
19692 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19693 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19694 assert_no_session_state(&shared);
19695 drop(shared);
19696 drop(runtime);
19697 std::fs::remove_dir_all(root).unwrap();
19698 }
19699
19700 #[test]
19701 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19702 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19703 let address = SessionAddress {
19704 workspace_id: WorkspaceId::new("primary").unwrap(),
19705 session: SessionKey {
19706 instance_id: AgentInstanceId(77),
19707 generation: SessionGeneration(1),
19708 },
19709 };
19710 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19711 let removed = shared.remove_binding(&address).expect("binding removed");
19712 assert!(removed.environment_profile.is_some());
19713 assert!(shared.remove_binding(&address).is_none());
19714 assert_no_session_state(&shared);
19715 drop(shared);
19716 drop(runtime);
19717 std::fs::remove_dir_all(root).unwrap();
19718 }
19719
19720 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19721 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19722 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19723 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19724 .expect("review current test executable as fixture helper");
19725 let registry = HarnessMcpProxyRegistry::new(helper);
19726 let spec = crate::protocol::SpawnSpec {
19727 target: crate::protocol::SpawnTarget {
19728 node_id: NodeId::new("node-characterization").unwrap(),
19729 workspace_id: WorkspaceId::new("primary").unwrap(),
19730 worktree_id: None,
19731 },
19732 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19733 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19734 overrides: crate::protocol::SpawnOverrides::default(),
19735 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19736 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19737 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19738 };
19739 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19740 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19741 let launch = HarnessMcpLaunchV1 {
19742 server_name: "fixture-harness".to_owned(),
19743 args: vec!["--session-proxy".to_owned()],
19744 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19745 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19746 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19747 trace: None,
19748 scrub_env: Vec::new(),
19749 };
19750 let expires = unix_time_ms().saturating_add(30_000);
19751 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19752 registry
19753 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19754 .unwrap()
19755 }
19756
19757 #[tokio::test]
19758 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19759 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19760 let prepared = characterization_prepared_harness("claude").await;
19761 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19762 .await
19763 .unwrap_err();
19764 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19766 let mismatched = characterization_prepared_harness("codex").await;
19767 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19768 .await
19769 .unwrap_err();
19770 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19771 assert_no_session_state(&shared);
19772 drop(shared);
19773 drop(runtime);
19774 std::fs::remove_dir_all(root).unwrap();
19775 }
19776
19777 #[tokio::test]
19778 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19779 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19780 shared.native_launch_profile_control = None;
19781 let prepared = characterization_prepared_harness("claude").await;
19782 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19783 .await
19784 .unwrap_err();
19785 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19786 assert_no_session_state(&shared);
19787 drop(shared);
19788 drop(runtime);
19789 std::fs::remove_dir_all(root).unwrap();
19790 }
19791}
19792
19793#[cfg(all(test, windows))]
19794mod tests {
19795 use super::*;
19796 use gate4agent_types::ProviderActivity;
19797 use crate::session_environment::{
19798 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19799 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19800 NodeSessionPathClass,
19801 };
19802 use gate4agent_catalog::EnvMutation;
19803 use gate4agent_runtime_native::{
19804 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19805 NativeLaunchProfile, NativeLaunchProfileId,
19806 };
19807 use std::ffi::OsString;
19808 use std::sync::Arc;
19809
19810 struct EmptyEnvironmentResolver;
19811
19812 struct FixtureSecretResolver {
19813 deny: Arc<AtomicBool>,
19814 }
19815
19816 impl NodeSecretResolver for FixtureSecretResolver {
19817 fn resolve(
19818 &self,
19819 _reference: &NodeSecretReference,
19820 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19821 if self.deny.load(Ordering::Acquire) {
19822 Err(NodeSecretResolveError::Denied)
19823 } else {
19824 NodeSecretValue::text("fixture-secret")
19825 .map_err(|_| NodeSecretResolveError::Unavailable)
19826 }
19827 }
19828 }
19829
19830 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19831 fn resolve_child_environment(
19832 &self,
19833 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19834 Ok(vec![EnvMutation {
19835 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19836 value: None,
19837 }])
19838 }
19839 }
19840
19841 fn agent(value: &str) -> AgentId {
19842 AgentId::new(value).unwrap()
19843 }
19844
19845 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19846 let catalog = active_registry().unwrap();
19847 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19848 let workspace = WorkspaceConfig::new(
19849 WorkspaceId::new("primary").unwrap(),
19850 std::env::current_dir().unwrap(),
19851 )
19852 .unwrap()
19853 .with_worktree_service_mode(mode);
19854 NodeShared::new(
19855 handle,
19856 "fixture-token".to_owned(),
19857 NodeId::new("node-terminal-test").unwrap(),
19858 vec![workspace],
19859 vec![agent("claude")],
19860 )
19861 }
19862
19863 fn terminal_test_shared() -> NodeShared {
19864 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19865 }
19866
19867 #[cfg(feature = "fixture")]
19868 #[test]
19869 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19870 let probe = SpawnManagedWorktreeV2FailureProbe::default();
19871 assert_eq!(probe.latest_code(), None);
19872
19873 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19874 assert_eq!(
19875 probe.latest_code(),
19876 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19877 );
19878
19879 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19880 assert_eq!(
19881 probe.latest_code(),
19882 Some(NodeFailureCode::SpawnDeadlineExceeded),
19883 );
19884
19885 probe.record(None);
19886 assert_eq!(probe.latest_code(), None);
19887 }
19888
19889 #[test]
19890 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19891 let source = temporary_workspace_root("launch-inventory-source");
19892 let allocation = temporary_workspace_root("launch-inventory-allocation");
19893 std::fs::create_dir_all(&source).unwrap();
19894 std::fs::create_dir_all(&allocation).unwrap();
19895 let workspace_id = WorkspaceId::new("primary").unwrap();
19896 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19897 .unwrap()
19898 .with_worktree_service_mode(WorktreeServiceMode::Managed);
19899 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19900 workspace = workspace
19901 .with_managed_worktree_profile(
19902 ManagedWorktreeProfile::new(
19903 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19904 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19905 &allocation,
19906 "gate4agent",
19907 "HEAD",
19908 ManagedWorktreeRetention::RemoveWhenReleased,
19909 )
19910 .unwrap(),
19911 )
19912 .unwrap();
19913 }
19914 let overflow = workspace.clone().with_managed_worktree_profile(
19915 ManagedWorktreeProfile::new(
19916 WorktreeProfileId::new("overflow").unwrap(),
19917 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19918 &allocation,
19919 "gate4agent",
19920 "HEAD",
19921 ManagedWorktreeRetention::Retain,
19922 )
19923 .unwrap(),
19924 );
19925 assert!(matches!(
19926 overflow,
19927 Err(NodeServerError::ManagedWorktreeProfileCapacity {
19928 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19929 ..
19930 }),
19931 ));
19932
19933 let catalog = active_registry().unwrap();
19934 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19935 let shared = NodeShared::new(
19936 handle,
19937 "fixture-token".to_owned(),
19938 NodeId::new("node-launch-inventory").unwrap(),
19939 vec![workspace],
19940 vec![agent("claude")],
19941 );
19942 let snapshot = shared.snapshot();
19943 let launch = snapshot.launch_inventory.as_ref().unwrap();
19944 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19945 assert!(launch.bundles.as_ref().unwrap().is_empty());
19946 let profiles = snapshot.workspaces[0]
19947 .managed_worktree_profiles
19948 .as_ref()
19949 .unwrap();
19950 assert_eq!(
19951 snapshot.workspaces[0].worktree_service_mode,
19952 Some(WorktreeServiceMode::Managed),
19953 );
19954 assert_eq!(
19955 profiles.profiles.len(),
19956 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19957 );
19958 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19959 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19960 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19961 }
19962 let legacy = snapshot_for_wire(
19963 &shared,
19964 true,
19965 true,
19966 false,
19967 false,
19968 true,
19969 false,
19970 true,
19971 false,
19972 true,
19973 );
19974 assert!(legacy.launch_inventory.is_none());
19975 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19976
19977 drop(shared);
19978 std::fs::remove_dir_all(source).unwrap();
19979 std::fs::remove_dir_all(allocation).unwrap();
19980 }
19981
19982 #[test]
19983 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19984 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19985 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19986 }));
19987 let source = gate4agent_types::ProviderSource {
19988 family: AdapterFamily::PtySemantic,
19989 binding: AdapterBinding::new(
19990 gate4agent_types::AdapterId::new("claude").unwrap(),
19991 "fixture/v1",
19992 gate4agent_types::AdapterVerification::SyntheticFixture,
19993 )
19994 .unwrap(),
19995 };
19996 let provider = ProviderSnapshot {
19997 sequence: 17,
19998 session: Some(ProviderSessionIdentity {
19999 key: ProviderSessionKey::SessionId,
20000 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20001 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20002 }),
20003 model: Some("PRIVATE_MODEL".to_owned()),
20004 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20005 completed_turns: 3,
20006 usage: gate4agent_types::TokenUsage {
20007 input_tokens: 10,
20008 output_tokens: 20,
20009 cache_read_tokens: 30,
20010 cache_write_tokens: 40,
20011 reasoning_tokens: 50,
20012 context_window: Some(200_000),
20013 },
20014 lead_activity: ProviderActivity::WaitingForInput,
20015 activity: ProviderActivity::WaitingForInput,
20016 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20017 active_tools: (0..9)
20018 .map(|index| gate4agent_types::ActiveProviderTool {
20019 id: format!("private-tool-id-{index}"),
20020 name: if index == 0 {
20021 format!(" Read\u{0000}{}", "x".repeat(80))
20022 } else {
20023 format!("Tool-{index}")
20024 },
20025 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20026 })
20027 .collect(),
20028 interactions: vec![gate4agent_types::ProviderInteraction {
20029 id: gate4agent_types::ProviderInteractionId(17),
20030 source: source.clone(),
20031 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20032 interaction_kind: ProviderInteractionKind::Approval,
20033 tool_name: " Approve\u{0007}Tool ".to_owned(),
20034 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20035 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20036 resume_lead_activity: Some(ProviderActivity::Working),
20037 status: ProviderInteractionStatus::Pending,
20038 }],
20039 subagents: vec![gate4agent_types::ProviderSubagent {
20040 source: source.clone(),
20041 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20042 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20043 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20044 }],
20045 sources: vec![gate4agent_types::ProviderSourceCursor {
20046 source,
20047 sequence: 17,
20048 gap_count: 2,
20049 stale: true,
20050 }],
20051 last_event: Some(ProviderEvent::Error {
20052 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20053 }),
20054 gap_count: 2,
20055 stale: true,
20056 };
20057 let address = terminal_address(3);
20058 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20059 assert_eq!(entry.address, address);
20060 assert_eq!(entry.progress.provider_sequence, 17);
20061 assert_eq!(entry.progress.active_tool_count, 9);
20062 assert_eq!(
20063 entry.progress.active_tool_labels.len(),
20064 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20065 );
20066 assert_eq!(entry.progress.subagent_count, 1);
20067 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20068 assert!(entry.progress.truncated);
20069 let encoded = serde_json::to_string(&entry).unwrap();
20070 for forbidden in [
20071 "PRIVATE_PROVIDER_SESSION",
20072 "PRIVATE_PROVIDER_PATH",
20073 "PRIVATE_MODEL",
20074 "PRIVATE_TOOL_INVENTORY",
20075 "PRIVATE_CURRENT_PROMPT",
20076 "PRIVATE_TOOL_INPUT_JSON",
20077 "PRIVATE_PROVIDER_REQUEST",
20078 "PRIVATE_INTERACTION_PROMPT",
20079 "PRIVATE_INTERACTION_AGENT",
20080 "PRIVATE_SUBAGENT_ID",
20081 "PRIVATE_SUBAGENT_TYPE",
20082 "PRIVATE_SUBAGENT_DESCRIPTION",
20083 "PRIVATE_PROVIDER_ERROR",
20084 "input_json",
20085 "current_prompt",
20086 "session_id",
20087 ] {
20088 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20089 }
20090
20091 let snapshot = NodeSnapshot {
20092 node_id: NodeId::new("node-progress-test").unwrap(),
20093 enabled_providers: vec![agent("claude")],
20094 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20095 workspaces: Vec::new(),
20096 session_records: Vec::new(),
20097 managed_worktrees: Vec::new(),
20098 launch_inventory: None,
20099 agent_progress: vec![entry],
20100 };
20101 let mut legacy_reply = ResponseEnvelope {
20102 request_id: 1,
20103 result: Ok(NodeResponse::Snapshot {
20104 event_sequence: 0,
20105 controller: None,
20106 snapshot: snapshot.clone(),
20107 }),
20108 };
20109 project_response_without_agent_progress(&mut legacy_reply);
20110 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20111 panic!("agent progress projection changed response kind");
20112 };
20113 assert!(legacy.agent_progress.is_empty());
20114 assert_eq!(snapshot.agent_progress.len(), 1);
20115 }
20116
20117 fn terminal_address(generation: u64) -> SessionAddress {
20118 SessionAddress {
20119 workspace_id: WorkspaceId::new("primary").unwrap(),
20120 session: SessionKey {
20121 instance_id: AgentInstanceId(41),
20122 generation: SessionGeneration(generation),
20123 },
20124 }
20125 }
20126
20127 fn spawn_spec_fixture() -> SpawnSpec {
20128 SpawnSpec {
20129 target: crate::protocol::SpawnTarget {
20130 node_id: NodeId::new("node-terminal-test").unwrap(),
20131 workspace_id: WorkspaceId::new("primary").unwrap(),
20132 worktree_id: None,
20133 },
20134 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20135 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20136 .unwrap(),
20137 overrides: crate::protocol::SpawnOverrides::default(),
20138 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20139 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20140 required_capabilities: SpawnRequiredCapabilities::default(),
20141 }
20142 }
20143
20144 fn environment_profile_test_shared(
20145 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20146 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20147 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20148 "local-claude-r1",
20149 )
20150 .unwrap();
20151 let spawn_profiles = SpawnProfileRegistry::new([
20152 crate::protocol::SpawnProfileDefaults {
20153 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20154 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20155 provider: agent("claude"),
20156 mode: SessionMode::Pty,
20157 terminal_size: gate4agent_types::TerminalSize {
20158 rows: 24,
20159 columns: 80,
20160 },
20161 prompt: None,
20162 bundle_id: None,
20163 context_id: None,
20164 environment_profile_id: Some(profile_id.clone()),
20165 },
20166 ])
20167 .unwrap();
20168 let catalog = active_registry().unwrap();
20169 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20170 let control = runtime.native_launch_profile_control();
20171 let node_profile = NodeEnvironmentProfile::new(
20172 profile_id.clone(),
20173 profile_revision.clone(),
20174 agent("claude"),
20175 [NativeLaunchProfile::new(
20176 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20177 agent("claude"),
20178 TransportKind::Pty,
20179 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20180 Arc::new(EmptyEnvironmentResolver),
20181 )
20182 .unwrap()],
20183 )
20184 .unwrap();
20185 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20186 for native_profile in native_profiles {
20187 runtime.upsert_native_launch_profile(native_profile).unwrap();
20188 }
20189 let workspace = WorkspaceConfig::new(
20190 WorkspaceId::new("primary").unwrap(),
20191 std::env::current_dir().unwrap(),
20192 )
20193 .unwrap();
20194 let shared = NodeShared::new_with_incarnation(
20195 handle,
20196 "fixture-token".to_owned(),
20197 NodeId::new("node-terminal-test").unwrap(),
20198 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20199 vec![workspace],
20200 vec![agent("claude")],
20201 ProviderRuntimeStatuses::default(),
20202 None,
20203 Vec::new(),
20204 Vec::new(),
20205 spawn_profiles,
20206 Some(control),
20207 None,
20208 None,
20209 None,
20210 Vec::new(),
20211 Vec::new(),
20212 Vec::new(),
20213 Vec::new(),
20214 None,
20215 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20216 );
20217 shared
20218 .environment_profiles
20219 .write()
20220 .unwrap_or_else(|poisoned| poisoned.into_inner())
20221 .insert(binding.id.clone(), binding);
20222 (
20223 shared,
20224 runtime,
20225 ResolvedEnvironmentProfileReceipt {
20226 profile_id,
20227 profile_revision,
20228 network_allowlist: None,
20229 browser_profile_id: None,
20230 },
20231 )
20232 }
20233
20234 fn materialization_test_shared(
20235 deny_secret: bool,
20236 ) -> (
20237 NodeShared,
20238 NativeRuntime,
20239 ResolvedEnvironmentProfileReceipt,
20240 PathBuf,
20241 Arc<AtomicBool>,
20242 ) {
20243 let workspace = WorkspaceConfig::new(
20244 WorkspaceId::new("primary").unwrap(),
20245 std::env::current_dir().unwrap(),
20246 )
20247 .unwrap();
20248 materialization_test_shared_with_workspace(deny_secret, workspace)
20249 }
20250
20251 fn materialization_test_shared_with_workspace(
20252 deny_secret: bool,
20253 workspace: WorkspaceConfig,
20254 ) -> (
20255 NodeShared,
20256 NativeRuntime,
20257 ResolvedEnvironmentProfileReceipt,
20258 PathBuf,
20259 Arc<AtomicBool>,
20260 ) {
20261 let root = temporary_workspace_root(if deny_secret {
20262 "materialization-denied"
20263 } else {
20264 "materialization-ready"
20265 });
20266 std::fs::create_dir_all(&root).unwrap();
20267 let materialization_root = root.join("session-environments");
20268 let deny = Arc::new(AtomicBool::new(deny_secret));
20269 let resolver = Arc::new(FixtureSecretResolver {
20270 deny: Arc::clone(&deny),
20271 });
20272 let materializer = SessionEnvironmentMaterializer::new(
20273 materialization_root,
20274 resolver,
20275 )
20276 .unwrap();
20277 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20278 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20279 "materialized-claude-r1",
20280 )
20281 .unwrap();
20282 let materialization = NodeSessionMaterializationProfile::new(
20283 vec![NodeSessionEnvironmentMutation::SetSecret {
20284 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20285 reference: NodeSecretReference::new("fixture-token").unwrap(),
20286 }],
20287 vec![NodeSessionPathBinding::new(
20288 "GATE4AGENT_PROVIDER_HOME",
20289 NodeSessionPathClass::ProviderHome,
20290 )
20291 .unwrap()],
20292 vec![NodeSessionFile::secret(
20293 NodeSessionPathClass::Config,
20294 "auth/token",
20295 NodeSecretReference::new("fixture-token").unwrap(),
20296 )
20297 .unwrap()],
20298 )
20299 .unwrap();
20300 let catalog = active_registry().unwrap();
20301 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20302 let control = runtime.native_launch_profile_control();
20303 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20304 profile_id.clone(),
20305 profile_revision.clone(),
20306 agent("claude"),
20307 [NativeLaunchProfile::new(
20308 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20309 agent("claude"),
20310 TransportKind::Pty,
20311 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20312 Arc::new(EmptyEnvironmentResolver),
20313 )
20314 .unwrap()],
20315 Some(materialization),
20316 )
20317 .unwrap();
20318 let (binding, native_profiles, materialization) = node_profile.into_parts();
20319 for native_profile in native_profiles {
20320 runtime.upsert_native_launch_profile(native_profile).unwrap();
20321 }
20322 let shared = NodeShared::new_with_incarnation(
20323 handle,
20324 "fixture-token".to_owned(),
20325 NodeId::new("node-materialization-test").unwrap(),
20326 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20327 vec![workspace],
20328 vec![agent("claude")],
20329 ProviderRuntimeStatuses::default(),
20330 None,
20331 Vec::new(),
20332 Vec::new(),
20333 SpawnProfileRegistry::default(),
20334 Some(control),
20335 None,
20336 Some(materializer),
20337 None,
20338 Vec::new(),
20339 Vec::new(),
20340 Vec::new(),
20341 Vec::new(),
20342 None,
20343 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20344 );
20345 shared
20346 .environment_profiles
20347 .write()
20348 .unwrap_or_else(|poisoned| poisoned.into_inner())
20349 .insert(binding.id.clone(), binding);
20350 shared
20351 .environment_materialization_profiles
20352 .write()
20353 .unwrap_or_else(|poisoned| poisoned.into_inner())
20354 .insert(profile_id.clone(), materialization.unwrap());
20355 (
20356 shared,
20357 runtime,
20358 ResolvedEnvironmentProfileReceipt {
20359 profile_id,
20360 profile_revision,
20361 network_allowlist: None,
20362 browser_profile_id: None,
20363 },
20364 root,
20365 deny,
20366 )
20367 }
20368
20369 #[cfg(feature = "fixture")]
20370 #[derive(Clone, Copy)]
20371 enum CodexHomeProfile {
20372 Missing,
20373 WrongClass,
20374 Exact,
20375 }
20376
20377 #[cfg(feature = "fixture")]
20378 fn codex_bundle_test_shared(
20379 home_profile: CodexHomeProfile,
20380 ) -> (
20381 NodeShared,
20382 NativeRuntime,
20383 ResolvedEnvironmentProfileReceipt,
20384 NodeBundle,
20385 PathBuf,
20386 ) {
20387 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20388 const CLAUDE_MANIFEST: &[u8] =
20389 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20390 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20391 const DIGEST: &str =
20392 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20393
20394 let (shared, mut runtime, _claude_receipt, root, _deny) =
20395 materialization_test_shared(false);
20396 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20397 CodexHomeProfile::Missing => "codex-home-missing",
20398 CodexHomeProfile::WrongClass => "codex-home-wrong",
20399 CodexHomeProfile::Exact => "codex-home-exact",
20400 })
20401 .unwrap();
20402 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20403 "codex-home-r1",
20404 )
20405 .unwrap();
20406 let materialization = match home_profile {
20407 CodexHomeProfile::Missing => None,
20408 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20409 Some(NodeSessionMaterializationProfile::new(
20410 Vec::new(),
20411 vec![NodeSessionPathBinding::new(
20412 "CODEX_HOME",
20413 if matches!(home_profile, CodexHomeProfile::Exact) {
20414 NodeSessionPathClass::ProviderHome
20415 } else {
20416 NodeSessionPathClass::Config
20417 },
20418 )
20419 .unwrap()],
20420 Vec::new(),
20421 )
20422 .unwrap())
20423 }
20424 };
20425 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20426 profile_id.clone(),
20427 profile_revision.clone(),
20428 agent("codex"),
20429 [NativeLaunchProfile::new(
20430 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20431 agent("codex"),
20432 TransportKind::Pty,
20433 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20434 Arc::new(EmptyEnvironmentResolver),
20435 )
20436 .unwrap()],
20437 materialization,
20438 )
20439 .unwrap();
20440 let (binding, native_profiles, materialization) = node_profile.into_parts();
20441 for native_profile in native_profiles {
20442 runtime.upsert_native_launch_profile(native_profile).unwrap();
20443 }
20444 shared
20445 .environment_profiles
20446 .write()
20447 .unwrap_or_else(|poisoned| poisoned.into_inner())
20448 .insert(binding.id.clone(), binding);
20449 if let Some(materialization) = materialization {
20450 shared
20451 .environment_materialization_profiles
20452 .write()
20453 .unwrap_or_else(|poisoned| poisoned.into_inner())
20454 .insert(profile_id.clone(), materialization);
20455 }
20456
20457 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20458 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20459 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20460 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20461 std::fs::write(
20462 bundle_source.join(".claude-plugin/plugin.json"),
20463 CLAUDE_MANIFEST,
20464 )
20465 .unwrap();
20466 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20467 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20468 let bundle = NodeBundle::new(
20469 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20470 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20471 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20472 &bundle_source,
20473 )
20474 .unwrap();
20475 *shared
20476 .bundle_catalog
20477 .write()
20478 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20479 BundleCatalog::new([bundle.clone()]).unwrap();
20480 (
20481 shared,
20482 runtime,
20483 ResolvedEnvironmentProfileReceipt {
20484 profile_id,
20485 profile_revision,
20486 network_allowlist: None,
20487 browser_profile_id: None,
20488 },
20489 bundle,
20490 root,
20491 )
20492 }
20493
20494 #[cfg(feature = "fixture")]
20495 #[test]
20496 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20497 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20498 let (shared, runtime, receipt, bundle, root) =
20499 codex_bundle_test_shared(home_profile);
20500 let error = shared
20501 .bundle_layout(
20502 &agent("codex"),
20503 SessionMode::Pty,
20504 Some(&receipt),
20505 &bundle,
20506 )
20507 .unwrap_err();
20508 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20509 assert!(shared
20510 .materializations
20511 .lock()
20512 .unwrap_or_else(|poisoned| poisoned.into_inner())
20513 .is_empty());
20514 let materialization_root = root.join("session-environments");
20515 assert!(!std::fs::read_dir(&materialization_root)
20516 .unwrap()
20517 .filter_map(Result::ok)
20518 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20519 drop(shared);
20520 drop(runtime);
20521 let _ = std::fs::remove_dir_all(root);
20522 }
20523 }
20524
20525 #[cfg(feature = "fixture")]
20526 #[test]
20527 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20528 let (shared, runtime, receipt, bundle, root) =
20529 codex_bundle_test_shared(CodexHomeProfile::Exact);
20530 let address = SessionAddress {
20531 workspace_id: WorkspaceId::new("primary").unwrap(),
20532 session: SessionKey {
20533 instance_id: AgentInstanceId(310),
20534 generation: SessionGeneration(1),
20535 },
20536 };
20537 let (overlay, guard) = shared
20538 .prepare_session_materialization(
20539 &address,
20540 &agent("codex"),
20541 SessionMode::Pty,
20542 Some(&receipt),
20543 Some(&bundle.receipt()),
20544 None,
20545 None,
20546 )
20547 .unwrap()
20548 .unwrap();
20549 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20550 let ownership = shared
20551 .materializations
20552 .lock()
20553 .unwrap_or_else(|poisoned| poisoned.into_inner())
20554 .get(guard.id().unwrap())
20555 .unwrap()
20556 .clone();
20557 let (profile, _, _) = shared
20558 .materialization_profile(
20559 &agent("codex"),
20560 SessionMode::Pty,
20561 Some(&receipt),
20562 Some(&bundle.receipt()),
20563 None,
20564 )
20565 .unwrap()
20566 .unwrap();
20567 let environment = shared
20568 .session_environment_materializer
20569 .as_ref()
20570 .unwrap()
20571 .resolve_environment(&ownership, &profile)
20572 .unwrap();
20573 assert!(environment.iter().any(|mutation| {
20574 mutation.key == OsString::from("CODEX_HOME")
20575 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20576 }));
20577 assert_eq!(
20578 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20579 .unwrap(),
20580 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20581 );
20582 assert!(std::fs::read_dir(ownership.bundle_root())
20583 .unwrap()
20584 .next()
20585 .is_none());
20586 drop(guard);
20587 drop(shared);
20588 drop(runtime);
20589 let _ = std::fs::remove_dir_all(root);
20590 }
20591
20592 #[cfg(feature = "fixture")]
20593 #[test]
20594 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20595 let (shared, runtime, receipt, bundle, root) =
20596 codex_bundle_test_shared(CodexHomeProfile::Exact);
20597 let bundle_receipt = bundle.receipt();
20598 let address = SessionAddress {
20599 workspace_id: WorkspaceId::new("primary").unwrap(),
20600 session: SessionKey {
20601 instance_id: AgentInstanceId(311),
20602 generation: SessionGeneration(1),
20603 },
20604 };
20605 let (_overlay, guard) = shared
20606 .prepare_session_materialization(
20607 &address,
20608 &agent("codex"),
20609 SessionMode::Pty,
20610 Some(&receipt),
20611 Some(&bundle_receipt),
20612 None,
20613 None,
20614 )
20615 .unwrap()
20616 .unwrap();
20617 let materialization_id = guard.id().unwrap().clone();
20618 let skill_path = shared
20619 .materializations
20620 .lock()
20621 .unwrap_or_else(|poisoned| poisoned.into_inner())
20622 .get(&materialization_id)
20623 .unwrap()
20624 .provider_home()
20625 .join("skills/review-code/SKILL.md");
20626 let record_id = shared
20627 .bind_spawn_session_with_materialization(
20628 &address,
20629 agent("codex"),
20630 SessionMode::Pty,
20631 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20632 SpawnRecordPolicy::ProviderIdentityOnly,
20633 Some(receipt.clone()),
20634 Some(bundle_receipt.clone()),
20635 None,
20636 Some(materialization_id.clone()),
20637 )
20638 .unwrap()
20639 .unwrap();
20640 guard.retain();
20641 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20642
20643 let error = match shared.resolve_record_materialization(
20644 &record_id,
20645 &agent("codex"),
20646 SessionMode::Pty,
20647 Some(&receipt),
20648 Some(&bundle_receipt),
20649 None,
20650 ) {
20651 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20652 Err(error) => error,
20653 };
20654 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20655 assert_eq!(
20656 shared
20657 .materializations
20658 .lock()
20659 .unwrap_or_else(|poisoned| poisoned.into_inner())
20660 .get(&materialization_id)
20661 .unwrap()
20662 .state(),
20663 MaterializationState::RecoveryRequired,
20664 );
20665 drop(shared);
20666 drop(runtime);
20667 let _ = std::fs::remove_dir_all(root);
20668 }
20669
20670 fn bind_record_owned_materialization_fixture(
20671 shared: &NodeShared,
20672 receipt: &ResolvedEnvironmentProfileReceipt,
20673 instance_id: u64,
20674 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20675 let address = SessionAddress {
20676 workspace_id: WorkspaceId::new("primary").unwrap(),
20677 session: SessionKey {
20678 instance_id: AgentInstanceId(instance_id),
20679 generation: SessionGeneration(1),
20680 },
20681 };
20682 let (overlay, materialization_guard) = shared
20683 .prepare_session_materialization(
20684 &address,
20685 &agent("claude"),
20686 SessionMode::Pty,
20687 Some(receipt),
20688 None,
20689 None,
20690 None,
20691 )
20692 .unwrap()
20693 .unwrap();
20694 let materialization_id = materialization_guard.id().unwrap().clone();
20695 let materialization_root = shared
20696 .materializations
20697 .lock()
20698 .unwrap_or_else(|poisoned| poisoned.into_inner())
20699 .get(&materialization_id)
20700 .unwrap()
20701 .root()
20702 .to_path_buf();
20703 let selection = shared
20704 .select_environment_profile(
20705 address.session.instance_id,
20706 &agent("claude"),
20707 SessionMode::Pty,
20708 Some(receipt),
20709 )
20710 .unwrap()
20711 .unwrap();
20712 assert!(shared
20713 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20714 .unwrap()
20715 .is_none());
20716 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20717 let record_id = shared
20718 .bind_spawn_session_with_materialization(
20719 &address,
20720 agent("claude"),
20721 SessionMode::Pty,
20722 policy,
20723 SpawnRecordPolicy::ProviderIdentityOnly,
20724 Some(receipt.clone()),
20725 None,
20726 None,
20727 Some(materialization_id.clone()),
20728 )
20729 .unwrap()
20730 .unwrap();
20731 selection.retain();
20732 materialization_guard.retain();
20733 (address, record_id, materialization_id, materialization_root)
20734 }
20735
20736 #[test]
20737 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20738 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20739 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20740 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20741 }));
20742 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20743 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20744 let mut spec = spawn_spec_fixture();
20745 spec.expected_profile_revision =
20746 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20747 assert!(matches!(
20748 &spec.overrides.environment_profile_id,
20749 crate::protocol::SpawnOverride::Inherit,
20750 ));
20751 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20752 .requires_child_environment_profile_capability());
20753 assert!(request_requires_child_environment_profile(
20754 &shared,
20755 &NodeRequest::SpawnSpec { spec: spec.clone() },
20756 ));
20757
20758 let address = terminal_address(1);
20759 shared.bind_session_with_policy(
20760 &address,
20761 ProviderRuntimePolicy::raw_pty(),
20762 Some(environment_profile.clone()),
20763 );
20764 assert!(request_requires_child_environment_profile(
20765 &shared,
20766 &NodeRequest::Input {
20767 session: address.clone(),
20768 text: "x".to_owned(),
20769 },
20770 ));
20771
20772 let mut record = record("claude", "profile-record");
20773 record.active_session = Some(address.clone());
20774 record.environment_profile = Some(environment_profile.clone());
20775 let record_id = record.record_id.clone();
20776 shared.insert_record(record.clone()).unwrap();
20777 assert!(request_requires_child_environment_profile(
20778 &shared,
20779 &NodeRequest::ResumeSessionRecord {
20780 record_id,
20781 terminal_size: gate4agent_types::TerminalSize {
20782 rows: 24,
20783 columns: 80,
20784 },
20785 initial_prompt: None,
20786 },
20787 ));
20788
20789 let snapshot = snapshot_for_wire(
20790 &shared, false, true, true, true, false, false, false, false, true,
20791 );
20792 assert_eq!(snapshot.session_records.len(), 1);
20793 assert!(snapshot.session_records[0].environment_profile.is_none());
20794 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20795 sequence: 1,
20796 event: NodeEvent::SessionRecordUpserted { record },
20797 });
20798 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20799 panic!("record projection changed the event kind");
20800 };
20801 assert!(record.environment_profile.is_none());
20802
20803 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20804 let mut reply = ResponseEnvelope {
20805 request_id: 1,
20806 result: Ok(NodeResponse::SpawnSpecAccepted {
20807 receipt: resolved.receipt_with_environment(
20808 shared.incarnation_id,
20809 address,
20810 Some(environment_profile),
20811 ),
20812 }),
20813 };
20814 project_response_without_child_environment_profile(&mut reply);
20815 assert_eq!(
20816 reply.result.unwrap_err().code,
20817 NodeFailureCode::UnsupportedCapability,
20818 );
20819 }
20820
20821 #[test]
20822 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20823 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20824 let snapshot = shared.snapshot();
20825 let profiles = snapshot
20826 .launch_inventory
20827 .as_ref()
20828 .and_then(|inventory| inventory.spawn_profiles.as_ref())
20829 .unwrap();
20830 assert_eq!(profiles.len(), 1);
20831 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20832 assert!(snapshot.requires_child_environment_profile_capability());
20833
20834 let mut legacy = snapshot.clone();
20835 clear_snapshot_child_environment_profiles(&mut legacy);
20836 assert!(legacy
20837 .launch_inventory
20838 .as_ref()
20839 .unwrap()
20840 .spawn_profiles
20841 .as_ref()
20842 .unwrap()[0]
20843 .environment_profile
20844 .is_none());
20845 assert!(!legacy.requires_child_environment_profile_capability());
20846
20847 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20848 let bindings = shared
20849 .environment_profiles
20850 .read()
20851 .unwrap_or_else(|poisoned| poisoned.into_inner())
20852 .clone();
20853 let mut provider_mismatch = bindings.clone();
20854 provider_mismatch
20855 .values_mut()
20856 .next()
20857 .unwrap()
20858 .provider = agent("codex");
20859 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20860 let mut mode_mismatch = profile;
20861 mode_mismatch.mode = SessionMode::Inline;
20862 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20863
20864 shared
20865 .environment_profiles
20866 .write()
20867 .unwrap_or_else(|poisoned| poisoned.into_inner())
20868 .clear();
20869 assert!(shared
20870 .snapshot()
20871 .launch_inventory
20872 .unwrap()
20873 .spawn_profiles
20874 .unwrap()
20875 .is_empty());
20876 }
20877
20878 #[test]
20879 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20880 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20881 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20882 }));
20883 let shared = terminal_test_shared();
20884 let bundle = ResolvedBundleReceipt {
20885 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20886 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20887 digest: crate::protocol::SpawnBundleDigest::new(format!(
20888 "sha256:{}",
20889 "0".repeat(64),
20890 ))
20891 .unwrap(),
20892 };
20893 let mut bundled = record("claude", "bundle-record");
20894 bundled.bundle = Some(bundle.clone());
20895 let record_id = bundled.record_id.clone();
20896 shared.insert_record(bundled.clone()).unwrap();
20897 assert!(request_requires_session_bundle(
20898 &shared,
20899 &NodeRequest::ForgetSessionRecord {
20900 record_id: record_id.clone(),
20901 },
20902 ));
20903
20904 let snapshot = snapshot_for_wire(
20905 &shared, false, true, true, true, true, false, false, false, true,
20906 );
20907 assert!(snapshot.session_records[0].bundle.is_none());
20908 let launch = snapshot.launch_inventory.as_ref().unwrap();
20909 assert!(launch.spawn_profiles.is_some());
20910 assert!(launch.bundles.is_none());
20911 let event = project_event_without_session_bundle(NodeEventEnvelope {
20912 sequence: 1,
20913 event: NodeEvent::SessionRecordUpserted { record: bundled },
20914 });
20915 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20916 panic!("record projection changed the event kind");
20917 };
20918 assert!(record.bundle.is_none());
20919
20920 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20921 let mut reply = ResponseEnvelope {
20922 request_id: 1,
20923 result: Ok(NodeResponse::SpawnSpecAccepted {
20924 receipt: resolved.receipt_with_materialization(
20925 shared.incarnation_id,
20926 terminal_address(17),
20927 None,
20928 Some(bundle),
20929 None,
20930 ),
20931 }),
20932 };
20933 project_response_without_session_bundle(&mut reply);
20934 assert_eq!(
20935 reply.result.unwrap_err().code,
20936 NodeFailureCode::UnsupportedCapability,
20937 );
20938 }
20939
20940 #[test]
20941 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20942 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20943 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20944 }));
20945 let context_id = SpawnContextId::new("context-review").unwrap();
20946 let context = ResolvedContextPackReceipt {
20947 id: context_id.clone(),
20948 digest: crate::protocol::SpawnContextDigest::new(format!(
20949 "sha256:{}",
20950 "c".repeat(64),
20951 ))
20952 .unwrap(),
20953 lineage: ContextPackLineageReceipt {
20954 source_node_id: NodeId::new("node-source").unwrap(),
20955 source_session: terminal_address(1),
20956 source_provider: agent("kimi"),
20957 },
20958 source_message_count: 2,
20959 retained_message_count: 2,
20960 byte_len: 256,
20961 truncated: false,
20962 };
20963 assert!(context.is_valid());
20964 let mut bound_record = record("codex", "context-record");
20965 bound_record.context_id = Some(context_id);
20966 bound_record.context = Some(context.clone());
20967 let session = gate4agent_types::SessionSnapshot {
20968 instance_id: AgentInstanceId(91),
20969 agent_id: agent("kimi"),
20970 transport: TransportKind::Pty,
20971 generation: SessionGeneration(3),
20972 status: SessionStatus::Running,
20973 pending_operation: None,
20974 pending_input: None,
20975 process_id: Some(9001),
20976 terminal_size: None,
20977 terminal_frame: None,
20978 terminal_stale: None,
20979 session_options: None,
20980 capabilities: gate4agent_types::CapabilitySnapshot::default(),
20981 history: gate4agent_types::HistorySnapshot {
20982 pending: None,
20983 candidates: vec![HistoryCandidateSummary {
20984 id: "candidate-opaque".to_owned(),
20985 session_id_hint: "session-hint".to_owned(),
20986 modified_at_unix_ms: Some(1),
20987 }],
20988 loaded_candidate_id: Some("candidate-opaque".to_owned()),
20989 loaded: Some(HistorySessionRecord {
20990 session_id: "private-vendor-session".to_owned(),
20991 title: Some("private title".to_owned()),
20992 cwd: Some(r"C:\private\history-root".to_owned()),
20993 model: Some("private-model".to_owned()),
20994 message_count: 2,
20995 completed_turn_count: None,
20996 total_tokens: 19,
20997 messages: vec![
20998 gate4agent_types::HistoryMessageRecord {
20999 role: gate4agent_types::HistoryMessageRole::User,
21000 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21001 },
21002 gate4agent_types::HistoryMessageRecord {
21003 role: gate4agent_types::HistoryMessageRole::Assistant,
21004 text: "private answer".to_owned(),
21005 },
21006 ],
21007 }),
21008 last_error: None,
21009 },
21010 resume: gate4agent_types::ResumeSnapshot::default(),
21011 foreground: gate4agent_types::ForegroundSnapshot::default(),
21012 provider: gate4agent_types::ProviderSnapshot::default(),
21013 screen_state: gate4agent_types::PtyScreenState::default(),
21014 };
21015 let original = NodeSnapshot {
21016 node_id: NodeId::new("node-terminal-test").unwrap(),
21017 enabled_providers: vec![agent("kimi"), agent("codex")],
21018 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21019 workspaces: vec![WorkspaceSnapshot {
21020 workspace_id: WorkspaceId::new("primary").unwrap(),
21021 canonical_root: opaque_windows_path(
21022 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21023 ),
21024 sessions: vec![session],
21025 worktree_service_mode: None,
21026 managed_worktree_profiles: None,
21027 }],
21028 session_records: vec![bound_record],
21029 managed_worktrees: Vec::new(),
21030 launch_inventory: None,
21031 agent_progress: Vec::new(),
21032 };
21033
21034 let mut negotiated = original.clone();
21035 project_snapshot_history_for_wire(&mut negotiated, true);
21036 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21037 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21038 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21039 assert!(!serde_json::to_string(&negotiated)
21040 .unwrap()
21041 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21042
21043 let mut legacy = original;
21044 project_snapshot_history_for_wire(&mut legacy, false);
21045 clear_snapshot_context_packs(&mut legacy);
21046 assert_eq!(
21047 legacy.workspaces[0].sessions[0].history,
21048 gate4agent_types::HistorySnapshot::default(),
21049 );
21050 assert!(legacy.session_records[0].context_id.is_none());
21051 assert!(legacy.session_records[0].context.is_none());
21052 let encoded = serde_json::to_string(&legacy).unwrap();
21053 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21054 assert!(!encoded.contains("context-review"));
21055
21056 let mut reply = ResponseEnvelope {
21057 request_id: 7,
21058 result: Ok(NodeResponse::ContextPackExported { context }),
21059 };
21060 project_response_without_context_pack(&mut reply);
21061 assert_eq!(
21062 reply.result.unwrap_err().code,
21063 NodeFailureCode::UnsupportedCapability,
21064 );
21065 }
21066
21067 #[test]
21068 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21069 let shared = terminal_test_shared();
21070 let history = HistorySessionRecord {
21071 session_id: "fixture-history".to_owned(),
21072 title: None,
21073 cwd: None,
21074 model: None,
21075 message_count: 1,
21076 completed_turn_count: None,
21077 total_tokens: 0,
21078 messages: vec![gate4agent_types::HistoryMessageRecord {
21079 role: gate4agent_types::HistoryMessageRole::User,
21080 text: "bounded handoff".to_owned(),
21081 }],
21082 };
21083 let pack = NodeContextPack::export(
21084 ContextPackLineageReceipt {
21085 source_node_id: shared.node_id.clone(),
21086 source_session: terminal_address(1),
21087 source_provider: agent("claude"),
21088 },
21089 &history,
21090 )
21091 .unwrap();
21092 let context = shared
21093 .context_catalog
21094 .write()
21095 .unwrap_or_else(|poisoned| poisoned.into_inner())
21096 .insert(pack)
21097 .unwrap();
21098 let mut retained = record("codex", "context-retained-record");
21099 retained.context_id = Some(context.id.clone());
21100 retained.context = Some(context.clone());
21101 let record_id = retained.record_id.clone();
21102 shared.insert_record(retained).unwrap();
21103 assert_eq!(
21104 shared.forget_context_pack(&context.id).unwrap_err().code,
21105 NodeFailureCode::ContextPackBusy,
21106 );
21107 assert!(shared
21108 .context_catalog
21109 .read()
21110 .unwrap_or_else(|poisoned| poisoned.into_inner())
21111 .get(&context.id)
21112 .is_some());
21113 shared
21114 .session_records
21115 .lock()
21116 .unwrap_or_else(|poisoned| poisoned.into_inner())
21117 .records
21118 .remove(&record_id);
21119
21120 let mut spec = spawn_spec_fixture();
21121 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21122 value: context.id.clone(),
21123 };
21124 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21125 let receipt = resolved.receipt_with_materialization(
21126 shared.incarnation_id,
21127 terminal_address(2),
21128 None,
21129 None,
21130 Some(context.clone()),
21131 );
21132 assert!(receipt.context_binding_is_valid());
21133 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21134
21135 shared.forget_context_pack(&context.id).unwrap();
21136 assert!(shared
21137 .context_catalog
21138 .read()
21139 .unwrap_or_else(|poisoned| poisoned.into_inner())
21140 .get(&context.id)
21141 .is_none());
21142 assert!(shared
21143 .replay_spawn_spec(&spec, Instant::now())
21144 .unwrap()
21145 .is_none());
21146 }
21147
21148 #[test]
21149 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21150 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21151 let address = terminal_address(1);
21152 let selection = shared
21153 .select_environment_profile(
21154 address.session.instance_id,
21155 &agent("claude"),
21156 SessionMode::Pty,
21157 Some(&environment_profile),
21158 )
21159 .unwrap()
21160 .unwrap();
21161 shared.bind_session_with_policy(
21162 &address,
21163 ProviderRuntimePolicy::raw_pty(),
21164 Some(environment_profile.clone()),
21165 );
21166 selection.retain();
21167 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21168 assert!(matches!(
21169 runtime.remove_native_launch_profile(&native_id),
21170 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21171 ));
21172 assert!(shared.remove_binding(&address).is_some());
21173 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21174
21175 let mismatched = ResolvedEnvironmentProfileReceipt {
21176 profile_id: environment_profile.profile_id,
21177 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21178 "local-claude-r2",
21179 )
21180 .unwrap(),
21181 network_allowlist: None,
21182 browser_profile_id: None,
21183 };
21184 let error = match shared.select_environment_profile(
21185 AgentInstanceId(42),
21186 &agent("claude"),
21187 SessionMode::Pty,
21188 Some(&mismatched),
21189 ) {
21190 Ok(_) => panic!("mismatched environment profile revision was selected"),
21191 Err(error) => error,
21192 };
21193 assert_eq!(
21194 error.code,
21195 NodeFailureCode::EnvironmentProfileBindingMismatch,
21196 );
21197 assert!(!shared
21198 .session_bindings
21199 .lock()
21200 .unwrap_or_else(|poisoned| poisoned.into_inner())
21201 .contains_key(&AgentInstanceId(42)));
21202 }
21203
21204 #[test]
21205 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21206 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21207 let address = terminal_address(1);
21208 let selection = shared
21209 .select_environment_profile(
21210 address.session.instance_id,
21211 &agent("claude"),
21212 SessionMode::Pty,
21213 Some(&environment_profile),
21214 )
21215 .unwrap()
21216 .unwrap();
21217 let overlay = NativeInstanceLaunchOverlay::new(
21218 agent("claude"),
21219 TransportKind::Pty,
21220 vec![EnvMutation {
21221 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21222 value: Some(OsString::from("fixture-secret")),
21223 }],
21224 vec![
21225 OsString::from("--plugin-dir"),
21226 std::env::current_dir().unwrap().into_os_string(),
21227 ],
21228 )
21229 .unwrap();
21230 let instance_overlay = shared
21231 .install_prepared_launch_overlay(
21232 address.session.instance_id,
21233 PreparedNativeLaunchOverlay::Instance(overlay),
21234 )
21235 .unwrap()
21236 .unwrap();
21237 let bundle = ResolvedBundleReceipt {
21238 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21239 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21240 digest: crate::protocol::SpawnBundleDigest::new(format!(
21241 "sha256:{}",
21242 "0".repeat(64),
21243 ))
21244 .unwrap(),
21245 };
21246 shared.bind_session_with_materialization(
21247 &address,
21248 ProviderRuntimePolicy::raw_pty(),
21249 Some(environment_profile),
21250 Some(bundle),
21251 None,
21252 None,
21253 );
21254 selection.retain();
21255 instance_overlay.retain();
21256
21257 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21258 assert!(matches!(
21259 runtime.remove_native_launch_profile(&native_id),
21260 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21261 ));
21262 assert!(shared.remove_binding(&address).is_some());
21263 assert!(!shared
21264 .native_launch_profile_control
21265 .as_ref()
21266 .unwrap()
21267 .clear_native_instance_launch_overlay(address.session.instance_id));
21268 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21269 }
21270
21271 #[test]
21272 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21273 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21274 let address = SessionAddress {
21275 workspace_id: WorkspaceId::new("primary").unwrap(),
21276 session: SessionKey {
21277 instance_id: AgentInstanceId(201),
21278 generation: SessionGeneration(1),
21279 },
21280 };
21281 let error = match shared.prepare_session_materialization(
21282 &address,
21283 &agent("claude"),
21284 SessionMode::Pty,
21285 Some(&receipt),
21286 None,
21287 None,
21288 None,
21289 ) {
21290 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21291 Err(error) => error,
21292 };
21293 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21294 assert!(shared.handle.snapshot().sessions.is_empty());
21295 assert!(shared
21296 .session_bindings
21297 .lock()
21298 .unwrap_or_else(|poisoned| poisoned.into_inner())
21299 .is_empty());
21300 assert!(shared
21301 .materializations
21302 .lock()
21303 .unwrap_or_else(|poisoned| poisoned.into_inner())
21304 .is_empty());
21305 drop(shared);
21306 drop(runtime);
21307 std::fs::remove_dir_all(root).unwrap();
21308 }
21309
21310 #[test]
21311 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21312 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21313 let address = SessionAddress {
21314 workspace_id: WorkspaceId::new("primary").unwrap(),
21315 session: SessionKey {
21316 instance_id: AgentInstanceId(202),
21317 generation: SessionGeneration(1),
21318 },
21319 };
21320 let (overlay, materialization_guard) = shared
21321 .prepare_session_materialization(
21322 &address,
21323 &agent("claude"),
21324 SessionMode::Pty,
21325 Some(&receipt),
21326 None,
21327 None,
21328 None,
21329 )
21330 .unwrap()
21331 .unwrap();
21332 let materialization_id = materialization_guard.id().unwrap().clone();
21333 let materialization_root = shared
21334 .materializations
21335 .lock()
21336 .unwrap_or_else(|poisoned| poisoned.into_inner())
21337 .get(&materialization_id)
21338 .unwrap()
21339 .root()
21340 .to_path_buf();
21341 let selection = shared
21342 .select_environment_profile(
21343 address.session.instance_id,
21344 &agent("claude"),
21345 SessionMode::Pty,
21346 Some(&receipt),
21347 )
21348 .unwrap()
21349 .unwrap();
21350 assert!(shared
21351 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21352 .unwrap()
21353 .is_none());
21354 shared.bind_session_with_materialization(
21355 &address,
21356 ProviderRuntimePolicy::raw_pty(),
21357 Some(receipt),
21358 None,
21359 None,
21360 Some(materialization_id),
21361 );
21362 selection.retain();
21363 materialization_guard.retain();
21364
21365 assert!(materialization_root.is_dir());
21366 assert!(shared
21367 .session_bindings
21368 .lock()
21369 .unwrap_or_else(|poisoned| poisoned.into_inner())
21370 .contains_key(&address.session.instance_id));
21371
21372 let removed = shared.remove_binding(&address).unwrap();
21373 shared
21374 .cleanup_session_owned_materialization(&address, &removed)
21375 .unwrap();
21376 assert!(!materialization_root.exists());
21377 assert!(shared
21378 .materializations
21379 .lock()
21380 .unwrap_or_else(|poisoned| poisoned.into_inner())
21381 .is_empty());
21382 drop(shared);
21383 drop(runtime);
21384 std::fs::remove_dir_all(root).unwrap();
21385 }
21386
21387 #[tokio::test]
21388 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21389 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21390 let address = SessionAddress {
21391 workspace_id: WorkspaceId::new("primary").unwrap(),
21392 session: SessionKey {
21393 instance_id: AgentInstanceId(203),
21394 generation: SessionGeneration(1),
21395 },
21396 };
21397 let (overlay, materialization_guard) = shared
21398 .prepare_session_materialization(
21399 &address,
21400 &agent("claude"),
21401 SessionMode::Pty,
21402 Some(&receipt),
21403 None,
21404 None,
21405 None,
21406 )
21407 .unwrap()
21408 .unwrap();
21409 let materialization_id = materialization_guard.id().unwrap().clone();
21410 let materialization_root = shared
21411 .materializations
21412 .lock()
21413 .unwrap_or_else(|poisoned| poisoned.into_inner())
21414 .get(&materialization_id)
21415 .unwrap()
21416 .root()
21417 .to_path_buf();
21418 let selection = shared
21419 .select_environment_profile(
21420 address.session.instance_id,
21421 &agent("claude"),
21422 SessionMode::Pty,
21423 Some(&receipt),
21424 )
21425 .unwrap()
21426 .unwrap();
21427 assert!(shared
21428 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21429 .unwrap()
21430 .is_none());
21431 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21432 let record_id = shared
21433 .bind_spawn_session_with_materialization(
21434 &address,
21435 agent("claude"),
21436 SessionMode::Pty,
21437 policy,
21438 SpawnRecordPolicy::ProviderIdentityOnly,
21439 Some(receipt.clone()),
21440 None,
21441 None,
21442 Some(materialization_id.clone()),
21443 )
21444 .unwrap()
21445 .unwrap();
21446 selection.retain();
21447 materialization_guard.retain();
21448 assert_eq!(
21449 shared
21450 .materializations
21451 .lock()
21452 .unwrap_or_else(|poisoned| poisoned.into_inner())
21453 .get(&materialization_id)
21454 .unwrap()
21455 .owner(),
21456 &MaterializationOwner::Record {
21457 record_id: record_id.clone(),
21458 },
21459 );
21460
21461 let removed = shared.remove_binding(&address).unwrap();
21462 shared
21463 .cleanup_session_owned_materialization(&address, &removed)
21464 .unwrap();
21465 assert!(materialization_root.is_dir());
21466 assert!(shared
21467 .resolve_record_materialization(
21468 &record_id,
21469 &agent("claude"),
21470 SessionMode::Pty,
21471 Some(&receipt),
21472 None,
21473 None,
21474 )
21475 .unwrap()
21476 .is_some());
21477 {
21478 let mut records = shared
21479 .session_records
21480 .lock()
21481 .unwrap_or_else(|poisoned| poisoned.into_inner());
21482 let record = records.records.get_mut(&record_id).unwrap();
21483 record.active_session = None;
21484 record.state = ManagedSessionState::Unavailable;
21485 }
21486 shared.forget_session_record(&record_id).await.unwrap();
21487 assert!(!materialization_root.exists());
21488 assert!(shared
21489 .materializations
21490 .lock()
21491 .unwrap_or_else(|poisoned| poisoned.into_inner())
21492 .is_empty());
21493 drop(shared);
21494 drop(runtime);
21495 std::fs::remove_dir_all(root).unwrap();
21496 }
21497
21498 #[test]
21499 fn record_materialization_revalidation_failure_persists_recovery_required() {
21500 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21501 let state_path = root.join("node-state-v6.json");
21502 shared.state_path = Some(state_path.clone());
21503 let (_address, _record_id, materialization_id, materialization_root) =
21504 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21505 std::fs::write(
21506 materialization_root.join(".gate4agent-materialization-owner"),
21507 b"tampered",
21508 )
21509 .unwrap();
21510
21511 let error = match shared.resolve_record_materialization(
21512 &_record_id,
21513 &agent("claude"),
21514 SessionMode::Pty,
21515 Some(&receipt),
21516 None,
21517 None,
21518 ) {
21519 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21520 Err(error) => error,
21521 };
21522 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21523 assert_eq!(
21524 shared
21525 .materializations
21526 .lock()
21527 .unwrap_or_else(|poisoned| poisoned.into_inner())
21528 .get(&materialization_id)
21529 .unwrap()
21530 .state(),
21531 MaterializationState::RecoveryRequired,
21532 );
21533 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21534 assert_eq!(loaded.materializations.len(), 1);
21535 assert_eq!(
21536 loaded.materializations[0].state(),
21537 MaterializationState::RecoveryRequired,
21538 );
21539 drop(shared);
21540 drop(runtime);
21541 std::fs::remove_dir_all(root).unwrap();
21542 }
21543
21544 #[test]
21545 fn transient_record_materialization_resolution_failure_remains_ready() {
21546 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21547 let (_address, record_id, materialization_id, _materialization_root) =
21548 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21549 deny.store(true, Ordering::Release);
21550
21551 let error = match shared.resolve_record_materialization(
21552 &record_id,
21553 &agent("claude"),
21554 SessionMode::Pty,
21555 Some(&receipt),
21556 None,
21557 None,
21558 ) {
21559 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21560 Err(error) => error,
21561 };
21562 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21563 assert_eq!(
21564 shared
21565 .materializations
21566 .lock()
21567 .unwrap_or_else(|poisoned| poisoned.into_inner())
21568 .get(&materialization_id)
21569 .unwrap()
21570 .state(),
21571 MaterializationState::Ready,
21572 );
21573 drop(shared);
21574 drop(runtime);
21575 std::fs::remove_dir_all(root).unwrap();
21576 }
21577
21578 #[test]
21579 fn startup_missing_record_materialization_is_not_resumable() {
21580 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21581 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21582 shared
21583 .insert_record(ManagedSessionRecord {
21584 record_id: record_id.clone(),
21585 display_name: "missing materialization".to_owned(),
21586 provider: agent("claude"),
21587 mode: SessionMode::Pty,
21588 state: ManagedSessionState::Dormant,
21589 workspace_id: WorkspaceId::new("primary").unwrap(),
21590 canonical_root: opaque_windows_path(
21591 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21592 ),
21593 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21594 key: gate4agent_types::ProviderSessionKey::SessionId,
21595 id: "provider-session-missing-materialization".to_owned(),
21596 transcript_path: None,
21597 }),
21598 active_session: None,
21599 environment_profile: Some(receipt),
21600 bundle: None,
21601 context_id: None,
21602 context: None,
21603 exported_context: None,
21604 task_binding: None,
21605 created_at_unix_ms: 1,
21606 updated_at_unix_ms: 1,
21607 last_error: None,
21608 })
21609 .unwrap();
21610
21611 shared.reconcile_materializations();
21612 let record = shared.record(&record_id).unwrap();
21613 assert_eq!(record.state, ManagedSessionState::Unavailable);
21614 assert!(record.provider_session.is_none());
21615 assert_eq!(
21616 record.last_error.as_deref(),
21617 Some("environment-profile-unavailable"),
21618 );
21619 drop(shared);
21620 drop(runtime);
21621 std::fs::remove_dir_all(root).unwrap();
21622 }
21623
21624 #[tokio::test]
21625 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21626 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21627 let lease = install_managed_lease(&shared);
21628 let address = SessionAddress {
21629 workspace_id: WorkspaceId::new("primary").unwrap(),
21630 session: SessionKey {
21631 instance_id: AgentInstanceId(204),
21632 generation: SessionGeneration(1),
21633 },
21634 };
21635 let (overlay, materialization_guard) = shared
21636 .prepare_session_materialization(
21637 &address,
21638 &agent("claude"),
21639 SessionMode::Pty,
21640 Some(&receipt),
21641 None,
21642 None,
21643 Some(lease.lease_id.clone()),
21644 )
21645 .unwrap()
21646 .unwrap();
21647 let materialization_id = materialization_guard.id().unwrap().clone();
21648 let materialization_root = shared
21649 .materializations
21650 .lock()
21651 .unwrap_or_else(|poisoned| poisoned.into_inner())
21652 .get(&materialization_id)
21653 .unwrap()
21654 .root()
21655 .to_path_buf();
21656 let selection = shared
21657 .select_environment_profile(
21658 address.session.instance_id,
21659 &agent("claude"),
21660 SessionMode::Pty,
21661 Some(&receipt),
21662 )
21663 .unwrap()
21664 .unwrap();
21665 assert!(shared
21666 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21667 .unwrap()
21668 .is_none());
21669 shared.bind_session_with_materialization(
21670 &address,
21671 ProviderRuntimePolicy::raw_pty(),
21672 Some(receipt),
21673 None,
21674 None,
21675 Some(materialization_id),
21676 );
21677 selection.retain();
21678 materialization_guard.retain();
21679
21680 let blocked = shared
21681 .cleanup_managed_worktree(&lease.lease_id, false)
21682 .await
21683 .unwrap_err();
21684 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21685 assert!(materialization_root.is_dir());
21686 let removed = shared.remove_binding(&address).unwrap();
21687 shared
21688 .cleanup_session_owned_materialization(&address, &removed)
21689 .unwrap();
21690 assert!(!materialization_root.exists());
21691 drop(shared);
21692 drop(runtime);
21693 std::fs::remove_dir_all(root).unwrap();
21694 }
21695
21696 #[tokio::test]
21697 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21698 let git_root = temporary_workspace_root("raw-managed-inventory");
21699 let source = git_root.join("source");
21700 let allocation = git_root.join("allocation");
21701 std::fs::create_dir_all(&source).unwrap();
21702 std::fs::create_dir_all(&allocation).unwrap();
21703 run_test_git(&source, &["init"]);
21704 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21705 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21706 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21707 run_test_git(&source, &["add", "seed.txt"]);
21708 run_test_git(&source, &["commit", "-m", "seed"]);
21709 let profile = ManagedWorktreeProfile::new(
21710 WorktreeProfileId::new("default").unwrap(),
21711 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21712 &allocation,
21713 "gate4agent",
21714 "HEAD",
21715 ManagedWorktreeRetention::RemoveWhenReleased,
21716 )
21717 .unwrap();
21718 let workspace = WorkspaceConfig::new(
21719 WorkspaceId::new("primary").unwrap(),
21720 &source,
21721 )
21722 .unwrap()
21723 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21724 .with_managed_worktree_profile(profile.clone())
21725 .unwrap();
21726 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21727 materialization_test_shared_with_workspace(false, workspace);
21728 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21729 let source_text = source.to_string_lossy().into_owned();
21730 let target_text = target.to_string_lossy().into_owned();
21731 let created = create_git_worktree(
21732 &source_text,
21733 &target_text,
21734 "gate4agent/mw-raw-inventory",
21735 Some("HEAD"),
21736 )
21737 .await
21738 .unwrap();
21739 let lease = ManagedWorktreeLeaseRecord {
21740 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21741 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21742 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21743 profile_id: profile.profile_id().clone(),
21744 profile_revision: profile.revision().clone(),
21745 target_root: created.path.clone(),
21746 branch: "gate4agent/mw-raw-inventory".to_owned(),
21747 base_commit: created.head.clone(),
21748 expected_head: Some(created.head.clone()),
21749 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21750 state: ManagedWorktreeLeaseState::Ready,
21751 session_holders: Vec::new(),
21752 record_holders: Vec::new(),
21753 cleanup_failure: None,
21754 created_at_unix_ms: 1,
21755 updated_at_unix_ms: 1,
21756 };
21757 shared
21758 .workspaces
21759 .write()
21760 .unwrap_or_else(|poisoned| poisoned.into_inner())
21761 .insert(lease.workspace_id.clone(), created.path.clone());
21762 *shared
21763 .managed_worktrees
21764 .lock()
21765 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21766 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21767 let address = SessionAddress {
21768 workspace_id: lease.workspace_id.clone(),
21769 session: SessionKey {
21770 instance_id: AgentInstanceId(205),
21771 generation: SessionGeneration(1),
21772 },
21773 };
21774 let (overlay, materialization_guard) = shared
21775 .prepare_session_materialization(
21776 &address,
21777 &agent("claude"),
21778 SessionMode::Pty,
21779 Some(&receipt),
21780 None,
21781 None,
21782 Some(lease.lease_id.clone()),
21783 )
21784 .unwrap()
21785 .unwrap();
21786 let materialization_id = materialization_guard.id().unwrap().clone();
21787 let materialization_root = shared
21788 .materializations
21789 .lock()
21790 .unwrap_or_else(|poisoned| poisoned.into_inner())
21791 .get(&materialization_id)
21792 .unwrap()
21793 .root()
21794 .to_path_buf();
21795 let selection = shared
21796 .select_environment_profile(
21797 address.session.instance_id,
21798 &agent("claude"),
21799 SessionMode::Pty,
21800 Some(&receipt),
21801 )
21802 .unwrap()
21803 .unwrap();
21804 assert!(shared
21805 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21806 .unwrap()
21807 .is_none());
21808 let record_id = shared
21809 .bind_spawn_session_with_materialization(
21810 &address,
21811 agent("claude"),
21812 SessionMode::Pty,
21813 ProviderRuntimePolicy::raw_pty(),
21814 SpawnRecordPolicy::Always,
21815 Some(receipt.clone()),
21816 None,
21817 None,
21818 Some(materialization_id.clone()),
21819 )
21820 .unwrap()
21821 .unwrap();
21822 selection.retain();
21823 materialization_guard.retain();
21824 let record = shared.record(&record_id).unwrap();
21825 assert_eq!(record.state, ManagedSessionState::Live);
21826 assert!(record.provider_session.is_none());
21827 assert!(record.environment_profile.is_none());
21828 assert!(record.bundle.is_none());
21829 assert!(record.context_id.is_none());
21830 assert!(record.context.is_none());
21831 assert_eq!(record.active_session.as_ref(), Some(&address));
21832 assert_eq!(
21833 shared
21834 .materializations
21835 .lock()
21836 .unwrap_or_else(|poisoned| poisoned.into_inner())
21837 .get(&materialization_id)
21838 .unwrap()
21839 .owner(),
21840 &MaterializationOwner::Session {
21841 incarnation_id: shared.incarnation_id,
21842 instance_id: address.session.instance_id,
21843 generation: address.session.generation,
21844 },
21845 );
21846 {
21847 let mut bindings = shared
21848 .session_bindings
21849 .lock()
21850 .unwrap_or_else(|poisoned| poisoned.into_inner());
21851 bindings
21852 .get_mut(&address.session.instance_id)
21853 .unwrap()
21854 .managed_worktree_lease_id = Some(lease.lease_id.clone());
21855 }
21856 let lease_snapshot = shared
21857 .managed_worktrees
21858 .lock()
21859 .unwrap_or_else(|poisoned| poisoned.into_inner())
21860 .bind_session(
21861 &lease.lease_id,
21862 ManagedWorktreeSessionHolder {
21863 incarnation_id: shared.incarnation_id,
21864 instance_id: address.session.instance_id,
21865 generation: address.session.generation,
21866 },
21867 None,
21868 unix_time_ms(),
21869 )
21870 .unwrap();
21871 assert_eq!(lease_snapshot.active_session_count, 1);
21872 assert_eq!(lease_snapshot.managed_record_count, 0);
21873 shared.persist_state().unwrap();
21874 assert!(shared
21875 .managed_worktrees
21876 .lock()
21877 .unwrap_or_else(|poisoned| poisoned.into_inner())
21878 .get(&lease.lease_id)
21879 .unwrap()
21880 .record_holders
21881 .is_empty());
21882 shared
21883 .handle
21884 .dispatch(shared.prepare_command(ControlCommand::Register {
21885 instance_id: address.session.instance_id,
21886 agent_id: agent("claude"),
21887 transport: TransportKind::Pty,
21888 }))
21889 .unwrap();
21890 runtime.tick().await;
21891 shared.reconcile_managed_record(
21892 &record_id,
21893 &address,
21894 &ControlEvent {
21895 sequence: 1,
21896 command_id: None,
21897 instance_id: address.session.instance_id,
21898 generation: address.session.generation,
21899 event: ControlEventKind::Removed,
21900 },
21901 false,
21902 false,
21903 );
21904 let remove = shared.remove_session(&address);
21905 let drive_runtime = async {
21906 runtime.tick().await;
21907 };
21908 let (removed, ()) = tokio::join!(remove, drive_runtime);
21909 removed.unwrap();
21910
21911 assert!(!materialization_root.exists());
21912 assert!(!Path::new(&created.path).exists());
21913 assert_eq!(
21914 shared
21915 .managed_worktrees
21916 .lock()
21917 .unwrap_or_else(|poisoned| poisoned.into_inner())
21918 .get(&lease.lease_id)
21919 .unwrap()
21920 .state,
21921 ManagedWorktreeLeaseState::Removed,
21922 );
21923 let renamed = shared
21924 .rename_session_record(&record_id, "raw managed inventory".to_owned())
21925 .unwrap();
21926 assert_eq!(renamed.record_id, record_id);
21927 assert_eq!(renamed.display_name, "raw managed inventory");
21928 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21929 assert!(renamed.active_session.is_none());
21930
21931 drop(shared);
21932 drop(runtime);
21933 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21934 std::fs::remove_dir_all(git_root).unwrap();
21935 }
21936
21937 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21938 let root = std::env::current_dir().unwrap()
21939 .join("managed-test-target")
21940 .to_string_lossy()
21941 .into_owned();
21942 let lease = ManagedWorktreeLeaseRecord {
21943 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21944 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21945 workspace_id: WorkspaceId::new("managed-test").unwrap(),
21946 profile_id: WorktreeProfileId::new("default").unwrap(),
21947 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21948 target_root: root.clone(),
21949 branch: "gate4agent/mw-test".to_owned(),
21950 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21951 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21952 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21953 state: ManagedWorktreeLeaseState::Ready,
21954 session_holders: Vec::new(),
21955 record_holders: Vec::new(),
21956 cleanup_failure: None,
21957 created_at_unix_ms: 1,
21958 updated_at_unix_ms: 1,
21959 };
21960 shared.workspaces.write()
21961 .unwrap_or_else(|poisoned| poisoned.into_inner())
21962 .insert(lease.workspace_id.clone(), root);
21963 *shared.managed_worktrees.lock()
21964 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21965 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21966 lease
21967 }
21968
21969 #[cfg(feature = "fixture")]
21970 #[test]
21971 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21972 let mut shared = terminal_test_shared();
21973 shared.fixture_semantic_hook_policy = true;
21974 let lease = install_managed_lease(&shared);
21975 let address = SessionAddress {
21976 workspace_id: lease.workspace_id.clone(),
21977 session: SessionKey {
21978 instance_id: AgentInstanceId(206),
21979 generation: SessionGeneration(1),
21980 },
21981 };
21982 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21983 let effective_runtime_policy =
21984 shared.effective_spawn_runtime_policy(pre_admission_policy);
21985 assert_ne!(effective_runtime_policy, pre_admission_policy);
21986 assert!(effective_runtime_policy.provider_session_identity);
21987
21988 let record_id = shared
21989 .bind_spawn_session(
21990 &address,
21991 agent("claude"),
21992 SessionMode::Pty,
21993 effective_runtime_policy,
21994 SpawnRecordPolicy::Always,
21995 None,
21996 )
21997 .unwrap()
21998 .unwrap();
21999 assert_eq!(
22000 shared.record(&record_id).unwrap().state,
22001 ManagedSessionState::IdentityPending,
22002 );
22003 let (record_holder, raw_inventory_record_id) = {
22004 let mut bindings = shared
22005 .session_bindings
22006 .lock()
22007 .unwrap_or_else(|poisoned| poisoned.into_inner());
22008 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22009 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22010 managed_spawn_record_ownership(binding, effective_runtime_policy)
22011 };
22012 assert_eq!(record_holder.as_ref(), Some(&record_id));
22013 assert!(raw_inventory_record_id.is_none());
22014 let snapshot = shared
22015 .managed_worktrees
22016 .lock()
22017 .unwrap_or_else(|poisoned| poisoned.into_inner())
22018 .bind_session(
22019 &lease.lease_id,
22020 ManagedWorktreeSessionHolder {
22021 incarnation_id: shared.incarnation_id,
22022 instance_id: address.session.instance_id,
22023 generation: address.session.generation,
22024 },
22025 record_holder,
22026 unix_time_ms(),
22027 )
22028 .unwrap();
22029 assert_eq!(snapshot.managed_record_count, 1);
22030 assert_eq!(
22031 shared
22032 .managed_worktrees
22033 .lock()
22034 .unwrap_or_else(|poisoned| poisoned.into_inner())
22035 .get(&lease.lease_id)
22036 .unwrap()
22037 .record_holders,
22038 vec![record_id.clone()],
22039 );
22040
22041 let identity = ProviderSessionIdentity {
22042 key: ProviderSessionKey::SessionId,
22043 id: "monitoring-fixture-managed-session".to_owned(),
22044 transcript_path: None,
22045 };
22046 shared.publish_control(ControlEvent {
22047 sequence: 1,
22048 command_id: None,
22049 instance_id: address.session.instance_id,
22050 generation: address.session.generation,
22051 event: ControlEventKind::ProviderEvent {
22052 sequence: 1,
22053 source: gate4agent_types::ProviderSource {
22054 family: AdapterFamily::Hook,
22055 binding: AdapterBinding::new(
22056 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22057 "fixture/v1",
22058 gate4agent_types::AdapterVerification::SyntheticFixture,
22059 )
22060 .unwrap(),
22061 },
22062 source_sequence: 1,
22063 event: ProviderEvent::SessionIdentityObserved {
22064 identity: identity.clone(),
22065 },
22066 },
22067 });
22068
22069 let live = shared.record(&record_id).unwrap();
22070 assert_eq!(live.state, ManagedSessionState::Live);
22071 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22072 assert_eq!(live.active_session.as_ref(), Some(&address));
22073 assert_eq!(
22074 shared
22075 .managed_worktrees
22076 .lock()
22077 .unwrap_or_else(|poisoned| poisoned.into_inner())
22078 .get(&lease.lease_id)
22079 .unwrap()
22080 .record_holders,
22081 vec![record_id],
22082 );
22083 }
22084
22085 fn managed_v2_test_shared(
22086 allocation_root: &Path,
22087 state_path: Option<PathBuf>,
22088 profile_revision: &str,
22089 ) -> NodeShared {
22090 let profile = ManagedWorktreeProfile::new(
22091 WorktreeProfileId::new("default").unwrap(),
22092 WorktreeProfileRevision::new(profile_revision).unwrap(),
22093 allocation_root,
22094 "gate4agent",
22095 "HEAD",
22096 ManagedWorktreeRetention::RemoveWhenReleased,
22097 )
22098 .unwrap();
22099 let workspace = WorkspaceConfig::new(
22100 WorkspaceId::new("primary").unwrap(),
22101 std::env::current_dir().unwrap(),
22102 )
22103 .unwrap()
22104 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22105 .with_managed_worktree_profile(profile)
22106 .unwrap();
22107 let catalog = active_registry().unwrap();
22108 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22109 let mut shared = NodeShared::new(
22110 handle,
22111 "fixture-token".to_owned(),
22112 NodeId::new("node-terminal-test").unwrap(),
22113 vec![workspace],
22114 vec![agent("claude")],
22115 );
22116 shared.state_path = state_path;
22117 shared
22118 }
22119
22120 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22121 let mut spawn_spec = spawn_spec_fixture();
22122 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22123 ManagedWorktreeSpawnRequestV2 {
22124 spawn_spec,
22125 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22126 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22127 }
22128 }
22129
22130 #[test]
22131 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22132 let shared = terminal_test_shared();
22133 let mut lease = install_managed_lease(&shared);
22134 let session = SessionAddress {
22135 workspace_id: lease.workspace_id.clone(),
22136 session: SessionKey {
22137 instance_id: AgentInstanceId(73),
22138 generation: SessionGeneration(4),
22139 },
22140 };
22141 lease.state = ManagedWorktreeLeaseState::InUse;
22142 lease.session_holders.push(ManagedWorktreeSessionHolder {
22143 incarnation_id: shared.incarnation_id,
22144 instance_id: session.session.instance_id,
22145 generation: session.session.generation,
22146 });
22147 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22148 assert!(resolved.target.worktree_id.is_none());
22149
22150 let receipt = managed_spawn_receipt(
22151 &resolved,
22152 shared.incarnation_id,
22153 session.clone(),
22154 lease.snapshot(),
22155 None,
22156 None,
22157 None,
22158 );
22159 assert!(resolved.target.worktree_id.is_none());
22160 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22161 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22162 assert_eq!(receipt.spawn.session, session);
22163 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22164 let encoded = serde_json::to_vec(&receipt).unwrap();
22165 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22166 assert_eq!(decoded, receipt);
22167 }
22168
22169 #[tokio::test]
22170 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22171 let root = temporary_workspace_root("managed-v2-revision");
22172 let allocation = root.join("allocation");
22173 std::fs::create_dir_all(&allocation).unwrap();
22174 let shared = managed_v2_test_shared(
22175 &allocation,
22176 Some(root.join("node-state.json")),
22177 "v1",
22178 );
22179 let mut request = managed_v2_request("managed-v2-revision");
22180 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22181
22182 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22183
22184 assert_eq!(
22185 failure.code,
22186 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22187 );
22188 assert!(shared
22189 .managed_worktrees
22190 .lock()
22191 .unwrap_or_else(|poisoned| poisoned.into_inner())
22192 .records()
22193 .is_empty());
22194 assert!(shared
22195 .managed_spawn_replays
22196 .lock()
22197 .unwrap_or_else(|poisoned| poisoned.into_inner())
22198 .is_empty());
22199 std::fs::remove_dir_all(root).unwrap();
22200 }
22201
22202 #[test]
22203 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22204 let root = temporary_workspace_root("managed-v2-concurrent");
22205 let allocation = root.join("allocation");
22206 std::fs::create_dir_all(&allocation).unwrap();
22207 let shared = Arc::new(managed_v2_test_shared(
22208 &allocation,
22209 Some(root.join("node-state.json")),
22210 "v1",
22211 ));
22212 let request = managed_v2_request("managed-v2-concurrent");
22213 let barrier = Arc::new(std::sync::Barrier::new(3));
22214 let mut joins = Vec::new();
22215 for _ in 0..2 {
22216 let shared = Arc::clone(&shared);
22217 let request = request.clone();
22218 let barrier = Arc::clone(&barrier);
22219 joins.push(std::thread::spawn(move || {
22220 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22221 barrier.wait();
22222 shared.acquire_durable_managed_spawn_v2(&request, digest)
22223 }));
22224 }
22225 barrier.wait();
22226 let results = joins
22227 .into_iter()
22228 .map(|join| join.join().unwrap())
22229 .collect::<Vec<_>>();
22230 assert_eq!(
22231 results
22232 .iter()
22233 .filter(|result| {
22234 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22235 })
22236 .count(),
22237 1,
22238 );
22239 assert_eq!(
22240 results
22241 .iter()
22242 .filter(|result| {
22243 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22244 })
22245 .count(),
22246 1,
22247 );
22248 assert_eq!(
22249 shared
22250 .managed_spawn_replays
22251 .lock()
22252 .unwrap_or_else(|poisoned| poisoned.into_inner())
22253 .len(),
22254 1,
22255 );
22256 assert!(shared
22257 .managed_worktrees
22258 .lock()
22259 .unwrap_or_else(|poisoned| poisoned.into_inner())
22260 .records()
22261 .is_empty());
22262 drop(shared);
22263 std::fs::remove_dir_all(root).unwrap();
22264 }
22265
22266 #[tokio::test]
22267 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22268 let root = temporary_workspace_root("managed-v2-linked-pending");
22269 let allocation = root.join("allocation");
22270 std::fs::create_dir_all(&allocation).unwrap();
22271 let state_path = root.join("node-state.json");
22272 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22273 let lease = install_managed_lease(&shared);
22274 let request = managed_v2_request("managed-v2-linked-pending");
22275 assert!(matches!(
22276 shared
22277 .acquire_durable_managed_spawn_v2(
22278 &request,
22279 managed_spawn_request_digest_v2(&request).unwrap(),
22280 )
22281 .unwrap(),
22282 DurableManagedSpawnReplayDecision::Reserved,
22283 ));
22284 {
22285 let _transaction = shared
22286 .state_transaction
22287 .lock()
22288 .unwrap_or_else(|poisoned| poisoned.into_inner());
22289 shared
22290 .link_durable_managed_spawn_v2_lease_locked(
22291 &request.spawn_spec.idempotency_key,
22292 &lease.lease_id,
22293 )
22294 .unwrap();
22295 shared.persist_state_locked().unwrap();
22296 }
22297 drop(shared);
22298
22299 let mut loaded = session_registry::load(
22300 Some(&state_path),
22301 &NodeId::new("node-terminal-test").unwrap(),
22302 )
22303 .unwrap();
22304 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22305 *reopened
22306 .managed_worktrees
22307 .lock()
22308 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22309 ManagedWorktreeRegistry::from_records(
22310 std::mem::take(&mut loaded.managed_worktrees),
22311 std::mem::take(&mut loaded.managed_worktree_tombstones),
22312 )
22313 .unwrap();
22314 *reopened
22315 .managed_spawn_replays
22316 .lock()
22317 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22318 .managed_spawn_replays
22319 .into_iter()
22320 .map(|record| (record.idempotency_key.clone(), record))
22321 .collect();
22322 let lease_count = reopened
22323 .managed_worktrees
22324 .lock()
22325 .unwrap_or_else(|poisoned| poisoned.into_inner())
22326 .records()
22327 .len();
22328
22329 let failure = reopened
22330 .spawn_managed_worktree_v2(request)
22331 .await
22332 .unwrap_err();
22333
22334 assert_eq!(
22335 failure.code,
22336 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22337 );
22338 assert_eq!(
22339 reopened
22340 .managed_worktrees
22341 .lock()
22342 .unwrap_or_else(|poisoned| poisoned.into_inner())
22343 .records()
22344 .len(),
22345 lease_count,
22346 );
22347 std::fs::remove_dir_all(root).unwrap();
22348 }
22349
22350 #[tokio::test]
22351 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22352 let root = temporary_workspace_root("managed-v2-reopen");
22353 let allocation = root.join("allocation");
22354 std::fs::create_dir_all(&allocation).unwrap();
22355 let state_path = root.join("node-state.json");
22356 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22357 let lease = install_managed_lease(&shared);
22358 let request = managed_v2_request("managed-v2-reopen");
22359 let session = SessionAddress {
22360 workspace_id: lease.workspace_id.clone(),
22361 session: SessionKey {
22362 instance_id: AgentInstanceId(91),
22363 generation: SessionGeneration(1),
22364 },
22365 };
22366 let snapshot = {
22367 let mut registry = shared
22368 .managed_worktrees
22369 .lock()
22370 .unwrap_or_else(|poisoned| poisoned.into_inner());
22371 let current = registry.get_mut(&lease.lease_id).unwrap();
22372 current.state = ManagedWorktreeLeaseState::InUse;
22373 current.session_holders.push(ManagedWorktreeSessionHolder {
22374 incarnation_id: shared.incarnation_id,
22375 instance_id: session.session.instance_id,
22376 generation: session.session.generation,
22377 });
22378 current.snapshot()
22379 };
22380 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22381 let receipt = managed_spawn_receipt(
22382 &resolved,
22383 shared.incarnation_id,
22384 session,
22385 snapshot,
22386 None,
22387 None,
22388 None,
22389 );
22390 assert!(matches!(
22391 shared
22392 .acquire_durable_managed_spawn_v2(
22393 &request,
22394 managed_spawn_request_digest_v2(&request).unwrap(),
22395 )
22396 .unwrap(),
22397 DurableManagedSpawnReplayDecision::Reserved,
22398 ));
22399 {
22400 let _transaction = shared
22401 .state_transaction
22402 .lock()
22403 .unwrap_or_else(|poisoned| poisoned.into_inner());
22404 shared
22405 .link_durable_managed_spawn_v2_lease_locked(
22406 &request.spawn_spec.idempotency_key,
22407 &lease.lease_id,
22408 )
22409 .unwrap();
22410 shared.persist_state_locked().unwrap();
22411 }
22412 shared
22413 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22414 .unwrap();
22415 let legacy_same_key = ManagedWorktreeSpawnRequest {
22416 spawn_spec: request.spawn_spec.clone(),
22417 worktree_profile_id: request.worktree_profile_id.clone(),
22418 };
22419 shared.remember_managed_spawn_attempt(
22420 Some(&request.spawn_spec.idempotency_key),
22421 legacy_same_key.clone(),
22422 Ok(receipt.clone()),
22423 Instant::now(),
22424 );
22425 assert!(shared
22426 .replay_managed_spawn(&legacy_same_key, Instant::now())
22427 .unwrap()
22428 .is_none());
22429 drop(shared);
22430
22431 let mut loaded = session_registry::load(
22432 Some(&state_path),
22433 &NodeId::new("node-terminal-test").unwrap(),
22434 )
22435 .unwrap();
22436 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22437 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22438 *reopened
22439 .managed_worktrees
22440 .lock()
22441 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22442 ManagedWorktreeRegistry::from_records(
22443 std::mem::take(&mut loaded.managed_worktrees),
22444 std::mem::take(&mut loaded.managed_worktree_tombstones),
22445 )
22446 .unwrap();
22447 *reopened
22448 .managed_spawn_replays
22449 .lock()
22450 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22451 .managed_spawn_replays
22452 .into_iter()
22453 .map(|record| (record.idempotency_key.clone(), record))
22454 .collect();
22455 let lease_count_before = reopened
22456 .managed_worktrees
22457 .lock()
22458 .unwrap_or_else(|poisoned| poisoned.into_inner())
22459 .records()
22460 .len();
22461
22462 assert_eq!(
22463 reopened
22464 .spawn_managed_worktree_v2(request.clone())
22465 .await
22466 .unwrap(),
22467 receipt,
22468 );
22469 assert_eq!(
22470 reopened
22471 .managed_worktrees
22472 .lock()
22473 .unwrap_or_else(|poisoned| poisoned.into_inner())
22474 .records()
22475 .len(),
22476 lease_count_before,
22477 );
22478 let mut changed = request;
22479 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22480 let failure = reopened
22481 .spawn_managed_worktree_v2(changed)
22482 .await
22483 .unwrap_err();
22484 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22485 assert_eq!(
22486 reopened
22487 .managed_worktrees
22488 .lock()
22489 .unwrap_or_else(|poisoned| poisoned.into_inner())
22490 .records()
22491 .len(),
22492 lease_count_before,
22493 );
22494 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22495 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22496 let failure = reopened
22497 .spawn_managed_worktree_v2(new_key)
22498 .await
22499 .unwrap_err();
22500 assert_eq!(
22501 failure.code,
22502 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22503 );
22504 assert_eq!(
22505 reopened
22506 .managed_worktrees
22507 .lock()
22508 .unwrap_or_else(|poisoned| poisoned.into_inner())
22509 .records()
22510 .len(),
22511 lease_count_before,
22512 );
22513 assert_eq!(
22514 reopened
22515 .managed_spawn_replays
22516 .lock()
22517 .unwrap_or_else(|poisoned| poisoned.into_inner())
22518 .len(),
22519 1,
22520 );
22521 std::fs::remove_dir_all(root).unwrap();
22522 }
22523
22524 #[test]
22525 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22526 let shared = terminal_test_shared();
22527 let request = ManagedWorktreeSpawnRequest {
22528 spawn_spec: spawn_spec_fixture(),
22529 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22530 };
22531 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22532 shared.remember_managed_spawn(
22533 request.clone(),
22534 Err(expected.clone()),
22535 Instant::now(),
22536 );
22537
22538 assert_eq!(
22539 shared
22540 .replay_managed_spawn(&request, Instant::now())
22541 .unwrap()
22542 .unwrap(),
22543 Err(expected),
22544 );
22545 assert!(shared
22546 .managed_spawn_replays
22547 .lock()
22548 .unwrap_or_else(|poisoned| poisoned.into_inner())
22549 .is_empty());
22550 }
22551
22552 #[tokio::test]
22553 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22554 let shared = terminal_test_shared();
22555 let lease = install_managed_lease(&shared);
22556 let raw = shared.spawn_session(
22557 lease.workspace_id.clone(),
22558 agent("claude"),
22559 SessionMode::Pty,
22560 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22561 None,
22562 ).await.unwrap_err();
22563 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22564 let mut spec = spawn_spec_fixture();
22565 spec.target.workspace_id = lease.workspace_id.clone();
22566 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22567 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22568 let mut selected = spawn_spec_fixture();
22569 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22570 selected.target.worktree_id = Some(lease.workspace_id);
22571 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22572 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22573 assert!(shared.handle.snapshot().sessions.is_empty());
22574 }
22575
22576 #[test]
22577 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22578 let shared = terminal_test_shared();
22579 let lease = install_managed_lease(&shared);
22580 let snapshot = snapshot_for_wire(
22581 &shared, true, true, true, false, true, true, false, false, true,
22582 );
22583 assert!(snapshot.managed_worktrees.is_empty());
22584 assert!(snapshot.workspaces.iter().all(|workspace| {
22585 workspace.managed_worktree_profiles.is_none()
22586 }));
22587 let mut reply = ResponseEnvelope {
22588 request_id: 1,
22589 result: Ok(NodeResponse::Resync {
22590 event_sequence: 1,
22591 oldest_available_sequence: 1,
22592 snapshot: shared.snapshot(),
22593 events: vec![NodeEventEnvelope {
22594 sequence: 1,
22595 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22596 }],
22597 }),
22598 };
22599 project_response_without_managed_worktrees(&mut reply);
22600 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22601 panic!("projection changed the response kind");
22602 };
22603 assert!(snapshot.managed_worktrees.is_empty());
22604 assert!(events.is_empty());
22605 let mut git = git_snapshot_for_parser();
22606 git.branch = Some(lease.branch.clone());
22607 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22608 lease_id: lease.lease_id.clone(),
22609 source_workspace_id: lease.source_workspace_id.clone(),
22610 branch: lease.branch.clone(),
22611 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22612 active_session_count: 1,
22613 managed_record_count: 0,
22614 });
22615 let mut inspection_reply = ResponseEnvelope {
22616 request_id: 2,
22617 result: Ok(NodeResponse::WorkspaceInspected {
22618 inspection: WorkspaceInspection {
22619 workspace_id: lease.workspace_id.clone(),
22620 entries: Vec::new(),
22621 tree_truncated: false,
22622 git,
22623 truncation: None,
22624 },
22625 }),
22626 };
22627 project_response_without_managed_worktrees(&mut inspection_reply);
22628 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22629 panic!("projection changed the inspection response kind");
22630 };
22631 assert!(inspection.git.managed_worktree.is_none());
22632 let failure = managed_git_worktree_failure(GitWorktreeError {
22633 kind: GitWorktreeErrorKind::Failed,
22634 message: r"git failed at C:\private\managed-target".to_owned(),
22635 });
22636 assert!(!failure.message.contains("private"));
22637 assert!(!failure.message.contains("managed-target"));
22638 }
22639
22640 #[tokio::test]
22641 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22642 let root = temporary_workspace_root("managed-restart");
22643 let source = root.join("source");
22644 let allocation = root.join("allocation");
22645 std::fs::create_dir_all(&source).unwrap();
22646 std::fs::create_dir_all(&allocation).unwrap();
22647 run_test_git(&source, &["init"]);
22648 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22649 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22650 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22651 run_test_git(&source, &["add", "seed.txt"]);
22652 run_test_git(&source, &["commit", "-m", "seed"]);
22653 let profile = ManagedWorktreeProfile::new(
22654 WorktreeProfileId::new("default").unwrap(),
22655 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22656 &allocation,
22657 "gate4agent",
22658 "HEAD",
22659 ManagedWorktreeRetention::Retain,
22660 ).unwrap();
22661 let workspace = WorkspaceConfig::new(
22662 WorkspaceId::new("primary").unwrap(),
22663 &source,
22664 ).unwrap()
22665 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22666 .with_managed_worktree_profile(profile.clone()).unwrap();
22667 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22668 let source_text = workspace.canonical_root().to_owned();
22669 let target_text = target.to_string_lossy().into_owned();
22670 let created = create_git_worktree(
22671 &source_text,
22672 &target_text,
22673 "gate4agent/mw-restart",
22674 Some("HEAD"),
22675 ).await.unwrap();
22676 let lease = ManagedWorktreeLeaseRecord {
22677 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22678 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22679 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22680 profile_id: profile.profile_id().clone(),
22681 profile_revision: profile.revision().clone(),
22682 target_root: created.path.clone(),
22683 branch: "gate4agent/mw-restart".to_owned(),
22684 base_commit: created.head.clone(),
22685 expected_head: None,
22686 retention: ManagedWorktreeRetention::Retain,
22687 state: ManagedWorktreeLeaseState::Allocating,
22688 session_holders: Vec::new(),
22689 record_holders: Vec::new(),
22690 cleanup_failure: None,
22691 created_at_unix_ms: 1,
22692 updated_at_unix_ms: 1,
22693 };
22694 let catalog = active_registry().unwrap();
22695 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22696 let shared = NodeShared::new_with_incarnation(
22697 handle,
22698 "fixture-token".to_owned(),
22699 NodeId::new("node-managed-restart").unwrap(),
22700 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22701 vec![workspace],
22702 vec![agent("claude")],
22703 ProviderRuntimeStatuses::default(),
22704 None,
22705 Vec::new(),
22706 Vec::new(),
22707 SpawnProfileRegistry::default(),
22708 None,
22709 None,
22710 None,
22711 None,
22712 Vec::new(),
22713 vec![lease.clone()],
22714 Vec::new(),
22715 Vec::new(),
22716 None,
22717 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22718 );
22719 shared.reconcile_managed_worktrees().await;
22720 let retained = shared.managed_worktrees.lock()
22721 .unwrap_or_else(|poisoned| poisoned.into_inner())
22722 .get(&lease.lease_id).unwrap().clone();
22723 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22724 assert!(shared.workspaces.read()
22725 .unwrap_or_else(|poisoned| poisoned.into_inner())
22726 .contains_key(&lease.workspace_id));
22727 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22728 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22729 assert!(!Path::new(&created.path).exists());
22730 std::fs::remove_dir_all(root).unwrap();
22731 }
22732
22733 fn run_test_git(root: &Path, arguments: &[&str]) {
22734 let output = std::process::Command::new("git")
22735 .arg("-C").arg(root).args(arguments).output().unwrap();
22736 assert!(
22737 output.status.success(),
22738 "git {:?} failed: {}",
22739 arguments,
22740 String::from_utf8_lossy(&output.stderr),
22741 );
22742 }
22743
22744 #[tokio::test]
22745 async fn spawn_spec_validation_fails_before_session_mutation() {
22746 let shared = terminal_test_shared();
22747 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22748 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22749 }));
22750 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22751 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22752 }));
22753 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22754 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22755 }));
22756
22757 let mut target_mismatch = spawn_spec_fixture();
22758 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22759 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22760 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22761
22762 let mut unknown_profile = spawn_spec_fixture();
22763 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22764 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22765 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22766 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22767
22768 let mut revision_mismatch = spawn_spec_fixture();
22769 revision_mismatch.idempotency_key =
22770 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22771 revision_mismatch.expected_profile_revision =
22772 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22773 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22774 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22775
22776 let mut unsupported_materializer = spawn_spec_fixture();
22777 unsupported_materializer.idempotency_key =
22778 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22779 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22780 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22781 };
22782 let failure = shared
22783 .spawn_from_spec(unsupported_materializer)
22784 .await
22785 .unwrap_err();
22786 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22787
22788 let failure = shared
22789 .spawn_session_with_deadline(
22790 WorkspaceId::new("primary").unwrap(),
22791 agent("claude"),
22792 SessionMode::Pty,
22793 gate4agent_types::TerminalSize {
22794 rows: 24,
22795 columns: 80,
22796 },
22797 None,
22798 None,
22799 None,
22800 None,
22801 None,
22802 None,
22803 SpawnRecordPolicy::ProviderIdentityOnly,
22804 Some(Instant::now()),
22805 &[],
22806 None,
22807 ApprovalLevel::default(),
22808 None,
22809 None,
22810 )
22811 .await
22812 .unwrap_err();
22813 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22814 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22815 assert!(shared.handle.snapshot().sessions.is_empty());
22816 }
22817
22818 #[test]
22819 fn worktree_selection_requires_its_separate_negotiated_capability() {
22820 let mut spec = spawn_spec_fixture();
22821 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22822 let request = NodeRequest::SpawnSpec { spec };
22823 let spawn_spec_capability =
22824 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22825 assert!(request_uses_unnegotiated_capability(
22826 &request,
22827 &[spawn_spec_capability.clone()],
22828 ));
22829 let worktree_capability =
22830 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22831 assert!(request_uses_unnegotiated_capability(
22832 &request,
22833 &[spawn_spec_capability.clone(), worktree_capability.clone()],
22834 ));
22835 let profile_revision_capability =
22836 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22837 assert!(request_uses_unnegotiated_capability(
22838 &request,
22839 &[
22840 spawn_spec_capability.clone(),
22841 worktree_capability.clone(),
22842 profile_revision_capability.clone(),
22843 ],
22844 ));
22845 let bundle_materialization_capability =
22852 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22853 assert!(request_uses_unnegotiated_capability(
22854 &request,
22855 &[
22856 spawn_spec_capability.clone(),
22857 worktree_capability.clone(),
22858 profile_revision_capability.clone(),
22859 bundle_materialization_capability.clone(),
22860 ],
22861 ));
22862 let history_context_pack_capability =
22863 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22864 assert!(!request_uses_unnegotiated_capability(
22865 &request,
22866 &[
22867 spawn_spec_capability,
22868 worktree_capability,
22869 profile_revision_capability,
22870 bundle_materialization_capability,
22871 history_context_pack_capability,
22872 ],
22873 ));
22874 }
22875
22876 #[tokio::test]
22877 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22878 let mut shared = terminal_test_shared();
22879 let spec = spawn_spec_fixture();
22880 let receipt = shared
22881 .resolve_spawn_spec(&spec)
22882 .unwrap()
22883 .receipt(shared.incarnation_id, terminal_address(1));
22884 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22885
22886 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22887 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22888 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22889
22890 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22891
22892 let mut revision_conflicting = spec.clone();
22893 revision_conflicting.expected_profile_revision =
22894 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22895 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22896 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22897
22898 let mut stale_cache_miss = spec.clone();
22899 stale_cache_miss.idempotency_key =
22900 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22901 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22902 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22903
22904 let mut conflicting = spec;
22905 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22906 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22907 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22908 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22909 assert!(shared.handle.snapshot().sessions.is_empty());
22910 }
22911
22912 #[tokio::test]
22913 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22914 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22915 let mut spec = spawn_spec_fixture();
22916 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22917 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22918 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22919
22920 let failure = shared
22921 .create_worktree(
22922 WorkspaceId::new("primary").unwrap(),
22923 WorkspaceId::new("new-worktree").unwrap(),
22924 "not-an-absolute-path".to_owned(),
22925 "invalid branch".to_owned(),
22926 None,
22927 )
22928 .await
22929 .unwrap_err();
22930 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22931
22932 let failure = shared
22933 .remove_worktree(
22934 WorkspaceId::new("primary").unwrap(),
22935 "not-an-absolute-path".to_owned(),
22936 )
22937 .await
22938 .unwrap_err();
22939 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22940 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22941 assert!(shared.handle.snapshot().sessions.is_empty());
22942 }
22943
22944 #[tokio::test]
22945 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22946 let root = temporary_workspace_root("manual-worktree-compensation");
22947 let source = root.join("source");
22948 let target = root.join("target");
22949 let invalid_state_parent = root.join("state-parent-is-a-file");
22950 let invalid_state_path = invalid_state_parent.join("state.json");
22951 std::fs::create_dir_all(&source).unwrap();
22952 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22953 run_test_git(&source, &["init"]);
22954 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22955 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22956 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22957 run_test_git(&source, &["add", "seed.txt"]);
22958 run_test_git(&source, &["commit", "-m", "seed"]);
22959
22960 let catalog = active_registry().unwrap();
22961 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22962 let workspace = WorkspaceConfig::new(
22963 WorkspaceId::new("primary").unwrap(),
22964 &source,
22965 ).unwrap();
22966 let mut shared = NodeShared::new(
22967 handle,
22968 "fixture-token".to_owned(),
22969 NodeId::new("node-worktree-compensation").unwrap(),
22970 vec![workspace],
22971 vec![agent("claude")],
22972 );
22973 shared.state_path = Some(invalid_state_path);
22974
22975 let error = shared.create_worktree(
22976 WorkspaceId::new("primary").unwrap(),
22977 WorkspaceId::new("compensated").unwrap(),
22978 target.to_string_lossy().into_owned(),
22979 "gate4agent/compensated".to_owned(),
22980 Some("HEAD".to_owned()),
22981 ).await.unwrap_err();
22982
22983 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22984 assert!(!target.exists());
22985 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22986 .iter()
22987 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22988 std::fs::remove_dir_all(root).unwrap();
22989 }
22990
22991 #[test]
22992 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22993 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22994 NativeGitWorktreeSnapshot {
22995 path: path.to_owned(),
22996 head: "0123456789abcdef".to_owned(),
22997 branch: Some("refs/heads/test".to_owned()),
22998 is_bare,
22999 is_main,
23000 locked: false,
23001 lock_reason: None,
23002 prunable: false,
23003 prunable_reason: None,
23004 workspace_id: None,
23005 }
23006 }
23007
23008 let source = r"C:\repo";
23009 let selected = r"C:\repo-worktree";
23010 let listed = vec![
23011 worktree(source, true, false),
23012 worktree(selected, false, false),
23013 ];
23014 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23015
23016 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23017 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23018
23019 let bare = vec![
23020 worktree(source, true, false),
23021 worktree(selected, false, true),
23022 ];
23023 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23024 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23025
23026 let foreign_failure =
23027 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23028 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23029 }
23030
23031 fn terminal_frame(sequence: u64) -> TerminalFrame {
23032 TerminalFrame {
23033 sequence,
23034 size: gate4agent_types::TerminalSize {
23035 rows: 24,
23036 columns: 80,
23037 },
23038 cursor_row: 2,
23039 cursor_column: 3,
23040 contents: format!("frame-{sequence}"),
23041 formatted: vec![1, 2, 3],
23042 scrollback_formatted: Vec::new(),
23043 alternate_screen: false,
23044 mouse_protocol_enabled: false,
23045 mouse_protocol_encoding: Default::default(),
23046 produced_at_unix_ms: 0,
23047 screen_state: gate4agent_types::PtyScreenState::default(),
23048 bracketed_paste: None,
23049 }
23050 }
23051
23052 #[test]
23053 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23054 let shared = terminal_test_shared();
23055 let mut receiver = shared.terminal_event_tx.subscribe();
23056 let original = terminal_address(1);
23057
23058 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23059 let first = receiver.try_recv().unwrap();
23060 assert_eq!(first.len(), 1);
23061 assert!(matches!(
23062 &first[0].event,
23063 NodeEvent::TerminalFrame { address, frame }
23064 if address == &original && frame.sequence == 7
23065 ));
23066
23067 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23068 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23069 assert!(matches!(
23070 receiver.try_recv(),
23071 Err(broadcast::error::TryRecvError::Empty)
23072 ));
23073
23074 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23075 let advanced = receiver.try_recv().unwrap();
23076 assert_eq!(advanced[0].sequence, 2);
23077
23078 let resumed = terminal_address(2);
23079 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23080 let reset = receiver.try_recv().unwrap();
23081 assert_eq!(reset[0].sequence, 3);
23082 assert!(matches!(
23083 &reset[0].event,
23084 NodeEvent::TerminalFrame { address, frame }
23085 if address == &resumed && frame.sequence == 1
23086 ));
23087 shared.clear_terminal_frame_watermark(&resumed);
23088 assert!(!shared
23089 .terminal_frame_watermarks
23090 .lock()
23091 .unwrap_or_else(|poisoned| poisoned.into_inner())
23092 .contains_key(&resumed.session.instance_id));
23093 }
23094
23095 #[test]
23096 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23097 let shared = terminal_test_shared();
23098 let mut receiver = shared.terminal_event_tx.subscribe();
23099 let address = terminal_address(1);
23100 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23101
23102 for sequence in 1..=count {
23103 shared.publish_terminal_frame_candidates(vec![(
23104 address.clone(),
23105 terminal_frame(sequence),
23106 )]);
23107 }
23108
23109 let history = shared
23110 .history
23111 .lock()
23112 .unwrap_or_else(|poisoned| poisoned.into_inner());
23113 assert!(history.events.is_empty());
23114 assert_eq!(history.last_sequence, count);
23115 assert_eq!(history.replay_floor_sequence, 1);
23116 drop(history);
23117 let NodeResponse::Resync {
23118 event_sequence,
23119 oldest_available_sequence,
23120 events,
23121 ..
23122 } = shared.resync(0) else {
23123 unreachable!("resync helper returned another response")
23124 };
23125 assert_eq!(event_sequence, count);
23126 assert_eq!(oldest_available_sequence, 1);
23127 assert!(events.is_empty());
23128 assert!(matches!(
23129 resync_required_event(&shared).event,
23130 NodeEvent::ResyncRequired {
23131 oldest_available_sequence: 1,
23132 }
23133 ));
23134 assert!(matches!(
23135 receiver.try_recv(),
23136 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23137 ));
23138 let latest = receiver.try_recv().unwrap();
23139 assert_eq!(latest.len(), 1);
23140 assert_eq!(latest[0].sequence, count);
23141 }
23142
23143 #[test]
23144 fn real_durable_eviction_advances_authoritative_replay_floor() {
23145 let shared = terminal_test_shared();
23146 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23147 shared.publish(NodeEvent::ControllerChanged { controller: None });
23148 }
23149
23150 let NodeResponse::Resync {
23151 event_sequence,
23152 oldest_available_sequence,
23153 events,
23154 ..
23155 } = shared.resync(0) else {
23156 unreachable!("resync helper returned another response")
23157 };
23158 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23159 assert_eq!(oldest_available_sequence, 2);
23160 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23161 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23162 assert!(matches!(
23163 resync_required_event(&shared).event,
23164 NodeEvent::ResyncRequired {
23165 oldest_available_sequence: 2,
23166 }
23167 ));
23168 }
23169
23170 #[test]
23171 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23172 let shared = terminal_test_shared();
23173 let without_capability = terminal_event_subscription(&shared, false);
23174 let with_capability = terminal_event_subscription(&shared, true);
23175 assert!(without_capability.is_none());
23176 assert!(with_capability.is_some());
23177 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23178 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23179 }));
23180 }
23181
23182 #[test]
23183 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23184 let mut pending = Vec::new();
23185 let mut event_sequence = 0_u64;
23186 for frame_sequence in 1..=4 {
23187 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23188 event_sequence += 1;
23189 let mut address = terminal_address(1);
23190 address.session.instance_id = AgentInstanceId(instance);
23191 queue_connection_event(
23192 &mut pending,
23193 NodeEventEnvelope {
23194 sequence: event_sequence,
23195 event: NodeEvent::TerminalFrame {
23196 address,
23197 frame: terminal_frame(frame_sequence),
23198 },
23199 },
23200 0,
23201 );
23202 }
23203 }
23204 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23205 pending.sort_unstable_by_key(|event| event.sequence);
23206
23207 let mut uninterrupted = pending.clone();
23208 let first_burst = connection_event_burst_len(uninterrupted.len());
23209 uninterrupted.drain(..first_burst);
23210 let second_burst = connection_event_burst_len(uninterrupted.len());
23211 uninterrupted.drain(..second_burst);
23212 assert!(uninterrupted.is_empty());
23213
23214 let (frame_tx, mut frame_rx) =
23215 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23216 frame_tx
23217 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23218 request_id: 77,
23219 request: NodeRequest::Snapshot,
23220 })))
23221 .unwrap();
23222 let burst_len = connection_event_burst_len(pending.len());
23223 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23224 pending.drain(..burst_len);
23225 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23226 assert!(matches!(
23227 frame_rx.try_recv(),
23228 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23229 request_id: 77,
23230 request: NodeRequest::Snapshot,
23231 })))
23232 ));
23233 }
23234
23235 #[test]
23236 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23237 let mut pending = Vec::new();
23238 for sequence in [7_u64, 8] {
23239 let mut address = terminal_address(1);
23240 address.session.instance_id = AgentInstanceId(sequence);
23241 queue_connection_event(
23242 &mut pending,
23243 NodeEventEnvelope {
23244 sequence,
23245 event: NodeEvent::TerminalFrame {
23246 address,
23247 frame: terminal_frame(sequence),
23248 },
23249 },
23250 0,
23251 );
23252 }
23253 let mut discard_through = 0;
23254 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23255 assert!(pending.is_empty());
23256
23257 let replayed = NodeEventEnvelope {
23258 sequence: 8,
23259 event: NodeEvent::TerminalFrame {
23260 address: terminal_address(1),
23261 frame: terminal_frame(8),
23262 },
23263 };
23264 let advanced = NodeEventEnvelope {
23265 sequence: 9,
23266 event: NodeEvent::TerminalFrame {
23267 address: terminal_address(2),
23268 frame: terminal_frame(1),
23269 },
23270 };
23271 queue_connection_event_batch(
23272 &mut pending,
23273 &[replayed, advanced.clone()],
23274 discard_through,
23275 );
23276 assert_eq!(pending, vec![advanced]);
23277 }
23278
23279 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23280 ManagedSessionRecord {
23281 record_id: SessionRecordId::new(record_id).unwrap(),
23282 display_name: format!("{provider} record"),
23283 provider: agent(provider),
23284 mode: SessionMode::Pty,
23285 state: ManagedSessionState::Unavailable,
23286 workspace_id: WorkspaceId::new("primary").unwrap(),
23287 canonical_root: opaque_windows_path(
23288 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23289 ),
23290 provider_session: None,
23291 active_session: None,
23292 environment_profile: None,
23293 bundle: None,
23294 context_id: None,
23295 context: None,
23296 exported_context: None,
23297 task_binding: None,
23298 created_at_unix_ms: 1,
23299 updated_at_unix_ms: 1,
23300 last_error: None,
23301 }
23302 }
23303
23304 fn retention_test_record(
23305 workspace_id: &str,
23306 record_id: &str,
23307 state: ManagedSessionState,
23308 updated_at_unix_ms: u64,
23309 ) -> ManagedSessionRecord {
23310 ManagedSessionRecord {
23311 record_id: SessionRecordId::new(record_id).unwrap(),
23312 display_name: format!("{record_id} display"),
23313 provider: agent("claude"),
23314 mode: SessionMode::Pty,
23315 state,
23316 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23317 canonical_root: opaque_windows_path(
23318 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23319 ),
23320 provider_session: None,
23321 active_session: None,
23322 environment_profile: None,
23323 bundle: None,
23324 context_id: None,
23325 context: None,
23326 exported_context: None,
23327 task_binding: None,
23328 created_at_unix_ms: updated_at_unix_ms,
23329 updated_at_unix_ms,
23330 last_error: None,
23331 }
23332 }
23333
23334 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23342 vec![
23343 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23344 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23345 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23346 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23347 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23348 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23349 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23350 retention_test_record(
23351 "workspace-a",
23352 "decoy-pending",
23353 ManagedSessionState::IdentityPending,
23354 now - 500_000,
23355 ),
23356 retention_test_record(
23357 "workspace-a",
23358 "decoy-dormant",
23359 ManagedSessionState::Dormant,
23360 now - 500_000,
23361 ),
23362 ]
23363 }
23364
23365 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23366 let root = temporary_workspace_root("session-record-retention");
23367 std::fs::create_dir_all(&root).unwrap();
23368 let catalog = active_registry().unwrap();
23369 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23370 let workspace_a =
23371 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23372 let workspace_b =
23373 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23374 let mut shared = NodeShared::new(
23375 handle,
23376 "fixture-token".to_owned(),
23377 NodeId::new("node-retention-test").unwrap(),
23378 vec![workspace_a, workspace_b],
23379 vec![agent("claude")],
23380 );
23381 shared.state_path = Some(root.join("node-state-v10.json"));
23382 let now = unix_time_ms();
23383 for record in retention_fixture_records(now) {
23384 shared.insert_record(record).unwrap();
23385 }
23386 shared
23387 .persist_state_locked()
23388 .expect("fixture-only, empty-history persist against a fresh temp state path");
23389 (shared, runtime, root, now)
23390 }
23391
23392 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23393 shared
23394 .history
23395 .lock()
23396 .unwrap_or_else(|poisoned| poisoned.into_inner())
23397 .events
23398 .iter()
23399 .filter_map(|envelope| match &envelope.event {
23400 NodeEvent::SessionRecordRemoved { record_id } => {
23401 Some(record_id.as_str().to_owned())
23402 }
23403 _ => None,
23404 })
23405 .collect()
23406 }
23407
23408 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23409 shared
23410 .session_records
23411 .lock()
23412 .unwrap_or_else(|poisoned| poisoned.into_inner())
23413 .records
23414 .keys()
23415 .map(|record_id| record_id.as_str().to_owned())
23416 .collect()
23417 }
23418
23419 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23420 values.iter().map(|value| (*value).to_owned()).collect()
23421 }
23422
23423 #[tokio::test]
23424 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23425 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23426 shared.session_record_retention = SessionRecordRetentionConfig {
23427 age_ms: 0,
23428 keep_per_workspace: 1,
23429 };
23430 shared.retire_unavailable_session_records().await;
23431
23432 assert_eq!(
23433 retention_remaining_record_ids(&shared),
23434 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23435 );
23436 assert_eq!(
23437 retention_removed_record_ids(&shared),
23438 owned(&["a1", "a2", "b1", "b2"]),
23439 );
23440 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23441
23442 drop(shared);
23443 drop(runtime);
23444 std::fs::remove_dir_all(root).unwrap();
23445 }
23446
23447 #[tokio::test]
23453 async fn session_record_retention_both_set_retires_only_the_intersection() {
23454 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23455 shared.session_record_retention = SessionRecordRetentionConfig {
23456 age_ms: 250_000,
23457 keep_per_workspace: 1,
23458 };
23459 shared.retire_unavailable_session_records().await;
23460
23461 assert_eq!(
23462 retention_remaining_record_ids(&shared),
23463 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23464 );
23465 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23466 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23467
23468 drop(shared);
23469 drop(runtime);
23470 std::fs::remove_dir_all(root).unwrap();
23471 }
23472
23473 #[tokio::test]
23474 async fn session_record_retention_both_disabled_is_a_no_op() {
23475 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23476 shared.session_record_retention = SessionRecordRetentionConfig::default();
23477 shared.retire_unavailable_session_records().await;
23478
23479 assert_eq!(
23480 retention_remaining_record_ids(&shared),
23481 owned(&[
23482 "a1", "a2", "a3", "b1", "b2", "b3",
23483 "decoy-live", "decoy-pending", "decoy-dormant",
23484 ]),
23485 );
23486 assert!(retention_removed_record_ids(&shared).is_empty());
23487 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23488
23489 drop(shared);
23490 drop(runtime);
23491 std::fs::remove_dir_all(root).unwrap();
23492 }
23493
23494 #[test]
23495 fn session_record_context_export_selects_one_exact_history_identity() {
23496 let candidates = vec![
23497 HistoryCandidateSummary {
23498 id: "candidate-other".to_owned(),
23499 session_id_hint: "provider-other".to_owned(),
23500 modified_at_unix_ms: Some(1),
23501 },
23502 HistoryCandidateSummary {
23503 id: "candidate-exact".to_owned(),
23504 session_id_hint: "provider-exact".to_owned(),
23505 modified_at_unix_ms: Some(2),
23506 },
23507 ];
23508 assert_eq!(
23509 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23510 "candidate-exact",
23511 );
23512 }
23513
23514 #[test]
23515 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23516 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23517 id: id.to_owned(),
23518 session_id_hint: hint.to_owned(),
23519 modified_at_unix_ms: None,
23520 };
23521 let missing = select_history_candidate(
23522 &[candidate("candidate-other", "provider-other")],
23523 Some("provider-exact"),
23524 )
23525 .unwrap_err();
23526 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23527 let ambiguous = select_history_candidate(
23528 &[
23529 candidate("candidate-a", "provider-exact"),
23530 candidate("candidate-b", "provider-exact"),
23531 ],
23532 Some("provider-exact"),
23533 )
23534 .unwrap_err();
23535 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23536 }
23537
23538 #[test]
23546 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23547 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23548 id: id.to_owned(),
23549 session_id_hint: hint.to_owned(),
23550 modified_at_unix_ms: None,
23551 };
23552 assert_eq!(
23553 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23554 .unwrap(),
23555 "candidate-sole",
23556 );
23557 let missing = select_history_candidate(&[], None).unwrap_err();
23558 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23559 let ambiguous = select_history_candidate(
23560 &[
23561 candidate("candidate-a", "sidecar-session-a"),
23562 candidate("candidate-b", "sidecar-session-b"),
23563 ],
23564 None,
23565 )
23566 .unwrap_err();
23567 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23568 }
23569
23570 #[test]
23571 fn session_record_context_export_revalidation_rejects_record_change() {
23572 let session = terminal_address(1);
23573 let identity = ProviderSessionIdentity {
23574 key: ProviderSessionKey::SessionId,
23575 id: "provider-exact".to_owned(),
23576 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23577 };
23578 let mut expected = record("claude", "record-context-export");
23579 expected.state = ManagedSessionState::Live;
23580 expected.provider_session = Some(identity.clone());
23581 expected.active_session = Some(session.clone());
23582 let binding = SessionBinding {
23583 workspace_id: session.workspace_id.clone(),
23584 generation: session.session.generation,
23585 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23586 pending_resume: None,
23587 record_id: Some(expected.record_id.clone()),
23588 managed_worktree_lease_id: None,
23589 environment_profile: None,
23590 bundle: None,
23591 context: None,
23592 materialization_id: None,
23593 };
23594 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23595 assert!(session_record_context_export_binding_is_exact(
23596 &expected,
23597 &expected,
23598 Some(&identity),
23599 &session,
23600 &binding,
23601 &expected.provider,
23602 ¤t_root,
23603 false,
23604 ));
23605 let mut changed = expected.clone();
23606 changed.updated_at_unix_ms += 1;
23607 assert!(!session_record_context_export_binding_is_exact(
23608 &expected,
23609 &changed,
23610 Some(&identity),
23611 &session,
23612 &binding,
23613 &expected.provider,
23614 ¤t_root,
23615 false,
23616 ));
23617 assert!(!session_record_context_export_binding_is_exact(
23622 &expected,
23623 &changed,
23624 Some(&identity),
23625 &session,
23626 &binding,
23627 &expected.provider,
23628 ¤t_root,
23629 true,
23630 ));
23631 let mut detached = expected.clone();
23632 detached.state = ManagedSessionState::Dormant;
23633 detached.active_session = None;
23634 detached.updated_at_unix_ms += 1;
23635 assert!(!session_record_context_export_binding_is_exact(
23636 &expected,
23637 &detached,
23638 Some(&identity),
23639 &session,
23640 &binding,
23641 &expected.provider,
23642 ¤t_root,
23643 false,
23644 ));
23645 assert!(session_record_context_export_binding_is_exact(
23646 &expected,
23647 &detached,
23648 Some(&identity),
23649 &session,
23650 &binding,
23651 &expected.provider,
23652 ¤t_root,
23653 true,
23654 ));
23655 }
23656
23657 #[test]
23664 fn session_record_context_export_binding_is_exact_without_identity() {
23665 let session = terminal_address(1);
23666 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23667 expected.state = ManagedSessionState::Live;
23668 expected.active_session = Some(session.clone());
23669 let binding = SessionBinding {
23670 workspace_id: session.workspace_id.clone(),
23671 generation: session.session.generation,
23672 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23673 pending_resume: None,
23674 record_id: Some(expected.record_id.clone()),
23675 managed_worktree_lease_id: None,
23676 environment_profile: None,
23677 bundle: None,
23678 context: None,
23679 materialization_id: None,
23680 };
23681 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23682 assert!(session_record_context_export_binding_is_exact(
23683 &expected,
23684 &expected,
23685 None,
23686 &session,
23687 &binding,
23688 &expected.provider,
23689 ¤t_root,
23690 false,
23691 ));
23692 let mut drifted = expected.clone();
23696 drifted.provider_session = Some(ProviderSessionIdentity {
23697 key: ProviderSessionKey::SessionId,
23698 id: "unexpected-identity".to_owned(),
23699 transcript_path: None,
23700 });
23701 assert!(!session_record_context_export_binding_is_exact(
23702 &expected,
23703 &drifted,
23704 None,
23705 &session,
23706 &binding,
23707 &expected.provider,
23708 ¤t_root,
23709 false,
23710 ));
23711 }
23712
23713 #[test]
23714 fn session_record_context_export_preflight_requires_live_usable_source() {
23715 let mut source = record("claude", "record-context-export-preflight");
23716 source.state = ManagedSessionState::Live;
23717 assert!(session_record_context_export_source_is_usable(
23718 &source,
23719 &SessionStatus::Running,
23720 false,
23721 ));
23722 assert!(session_record_context_export_source_is_usable(
23723 &source,
23724 &SessionStatus::Exited { exit_code: Some(0) },
23725 false,
23726 ));
23727
23728 source.state = ManagedSessionState::Dormant;
23729 assert!(!session_record_context_export_source_is_usable(
23730 &source,
23731 &SessionStatus::Running,
23732 false,
23733 ));
23734 assert!(session_record_context_export_source_is_usable(
23738 &source,
23739 &SessionStatus::Exited { exit_code: Some(0) },
23740 true,
23741 ));
23742 assert!(!session_record_context_export_source_is_usable(
23743 &source,
23744 &SessionStatus::Exited { exit_code: Some(0) },
23745 false,
23746 ));
23747 source.state = ManagedSessionState::Live;
23748 assert!(!session_record_context_export_source_is_usable(
23749 &source,
23750 &SessionStatus::Starting,
23751 false,
23752 ));
23753 }
23754
23755 #[test]
23756 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23757 let catalog = active_registry().unwrap();
23758 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23759 let workspace = WorkspaceConfig::new(
23760 WorkspaceId::new("primary").unwrap(),
23761 std::env::current_dir().unwrap(),
23762 )
23763 .unwrap();
23764 let shared = NodeShared::new(
23765 handle,
23766 "fixture-token".to_owned(),
23767 NodeId::new("node-context-export-conflict").unwrap(),
23768 vec![workspace],
23769 vec![agent("claude")],
23770 );
23771 let history = HistorySessionRecord {
23772 session_id: "provider-exact".to_owned(),
23773 title: None,
23774 cwd: None,
23775 model: None,
23776 message_count: 1,
23777 completed_turn_count: None,
23778 total_tokens: 0,
23779 messages: vec![gate4agent_types::HistoryMessageRecord {
23780 role: gate4agent_types::HistoryMessageRole::User,
23781 text: "bounded context".to_owned(),
23782 }],
23783 };
23784 let pack = NodeContextPack::export(
23785 ContextPackLineageReceipt {
23786 source_node_id: shared.node_id.clone(),
23787 source_session: terminal_address(1),
23788 source_provider: agent("claude"),
23789 },
23790 &history,
23791 )
23792 .unwrap();
23793 let context_id = pack.receipt().id.clone();
23794 let session = terminal_address(1);
23795 let identity = ProviderSessionIdentity {
23796 key: ProviderSessionKey::SessionId,
23797 id: history.session_id.clone(),
23798 transcript_path: None,
23799 };
23800 let mut expected = record("claude", "record-context-export-final-conflict");
23801 expected.state = ManagedSessionState::Live;
23802 expected.provider_session = Some(identity.clone());
23803 expected.active_session = Some(session.clone());
23804 let mut changed = expected.clone();
23805 changed.updated_at_unix_ms += 1;
23806 shared.insert_record(changed).unwrap();
23807
23808 let error = shared
23809 .commit_context_pack_for_session_record(
23810 &expected,
23811 Some(&identity),
23812 &session,
23813 "candidate-exact",
23814 &history,
23815 pack,
23816 false,
23817 )
23818 .unwrap_err();
23819
23820 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23821 assert!(shared
23822 .context_catalog
23823 .read()
23824 .unwrap_or_else(|poisoned| poisoned.into_inner())
23825 .get(&context_id)
23826 .is_none());
23827 }
23828
23829 #[tokio::test]
23830 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23831 let root = temporary_workspace_root("session-task-binding");
23832 std::fs::create_dir_all(&root).unwrap();
23833 let state_path = root.join("node-state.json");
23834 let catalog = active_registry().unwrap();
23835 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23836 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23837 let node_id = NodeId::new("node-1").unwrap();
23838 let mut shared = NodeShared::new(
23839 handle,
23840 "fixture-token".to_owned(),
23841 node_id.clone(),
23842 vec![workspace],
23843 vec![agent("claude")],
23844 );
23845 shared.state_path = Some(state_path.clone());
23846 let record = record("claude", "sr-task");
23847 let record_id = record.record_id.clone();
23848 shared.insert_record(record).unwrap();
23849 shared.persist_state().unwrap();
23850 shared
23851 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23852 .unwrap();
23853
23854 let minted = process_request_inner(
23855 &shared,
23856 77,
23857 ClientRole::Operator,
23858 NodeRequest::SetSessionTask {
23859 record_id: record_id.clone(),
23860 expected_revision: 0,
23861 target: SessionTaskTargetV1::New,
23862 },
23863 )
23864 .await
23865 .unwrap();
23866 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23867 panic!("session task mutation returned another response");
23868 };
23869 let minted_binding = minted.task_binding.clone().unwrap();
23870 let minted_task_id = minted_binding.task_id.clone().unwrap();
23871 assert_eq!(minted_binding.revision, 1);
23872 assert!(minted_task_id.as_str().starts_with("task-"));
23873 assert_eq!(minted_task_id.as_str().len(), 29);
23874
23875 let event_count = shared.current_sequence();
23876 let repeated = process_request_inner(
23877 &shared,
23878 77,
23879 ClientRole::Operator,
23880 NodeRequest::SetSessionTask {
23881 record_id: record_id.clone(),
23882 expected_revision: 1,
23883 target: SessionTaskTargetV1::Existing {
23884 task_id: minted_task_id.clone(),
23885 },
23886 },
23887 )
23888 .await
23889 .unwrap();
23890 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23891 assert_eq!(shared.current_sequence(), event_count);
23892
23893 let stale = process_request_inner(
23894 &shared,
23895 77,
23896 ClientRole::Operator,
23897 NodeRequest::SetSessionTask {
23898 record_id: record_id.clone(),
23899 expected_revision: 0,
23900 target: SessionTaskTargetV1::Existing {
23901 task_id: minted_task_id,
23902 },
23903 },
23904 )
23905 .await
23906 .unwrap_err();
23907 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23908
23909 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23910 let assigned = process_request_inner(
23911 &shared,
23912 77,
23913 ClientRole::Operator,
23914 NodeRequest::SetSessionTask {
23915 record_id: record_id.clone(),
23916 expected_revision: 1,
23917 target: SessionTaskTargetV1::Existing {
23918 task_id: assigned_id.clone(),
23919 },
23920 },
23921 )
23922 .await
23923 .unwrap();
23924 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23925 panic!("session task assignment returned another response");
23926 };
23927 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23928 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23929
23930 let cleared = process_request_inner(
23931 &shared,
23932 77,
23933 ClientRole::Operator,
23934 NodeRequest::SetSessionTask {
23935 record_id: record_id.clone(),
23936 expected_revision: 2,
23937 target: SessionTaskTargetV1::Clear,
23938 },
23939 )
23940 .await
23941 .unwrap();
23942 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23943 panic!("session task clear returned another response");
23944 };
23945 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23946 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23947 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23948 assert_eq!(
23949 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23950 cleared.updated_at_unix_ms,
23951 );
23952
23953 let persisted = std::fs::read(&state_path).unwrap();
23954 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23955 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23956 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23957 std::fs::remove_dir_all(root).unwrap();
23958 }
23959
23960 #[test]
23961 fn session_task_capability_strips_snapshots_events_and_replies() {
23962 let mut bound = record("claude", "sr-task-projection");
23963 bound.updated_at_unix_ms = 2;
23964 bound.task_binding = Some(SessionTaskBindingV1 {
23965 revision: 1,
23966 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23967 changed_at_unix_ms: 2,
23968 });
23969 let request = NodeRequest::SetSessionTask {
23970 record_id: bound.record_id.clone(),
23971 expected_revision: 1,
23972 target: SessionTaskTargetV1::Clear,
23973 };
23974 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23975 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23976 }));
23977 assert!(!request_uses_unnegotiated_capability(
23978 &request,
23979 &baseline_capabilities().unwrap(),
23980 ));
23981 assert!(request_uses_unnegotiated_capability(&request, &[]));
23982
23983 let mut snapshot = terminal_test_shared().snapshot();
23984 snapshot.session_records.push(bound.clone());
23985 let mut reply = ResponseEnvelope {
23986 request_id: 1,
23987 result: Ok(NodeResponse::Resync {
23988 event_sequence: 1,
23989 oldest_available_sequence: 1,
23990 snapshot,
23991 events: vec![NodeEventEnvelope {
23992 sequence: 1,
23993 event: NodeEvent::SessionRecordUpserted {
23994 record: bound.clone(),
23995 },
23996 }],
23997 }),
23998 };
23999 project_response_without_session_task_binding(&mut reply);
24000 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24001 panic!("task projection changed the response kind");
24002 };
24003 assert!(snapshot.session_records[0].task_binding.is_none());
24004 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24005 panic!("task projection changed the event kind");
24006 };
24007 assert!(record.task_binding.is_none());
24008
24009 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24010 sequence: 2,
24011 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24012 });
24013 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24014 panic!("task event projection changed the event kind");
24015 };
24016 assert!(record.task_binding.is_none());
24017
24018 let mut direct = ResponseEnvelope {
24019 request_id: 2,
24020 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24021 };
24022 project_response_without_session_task_binding(&mut direct);
24023 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24024 panic!("task reply projection changed the response kind");
24025 };
24026 assert!(record.task_binding.is_none());
24027 }
24028
24029 #[test]
24030 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24031 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24032 let workspace = WorkspaceConfig::new(
24033 WorkspaceId::new("primary").unwrap(),
24034 std::env::current_dir().unwrap(),
24035 )
24036 .unwrap();
24037 let config = NodeServerConfig::new(
24038 DEFAULT_NODE_ENDPOINT,
24039 "fixture-token",
24040 NodeId::new("node-1").unwrap(),
24041 [workspace],
24042 )
24043 .unwrap();
24044 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24045 }
24046
24047 #[cfg(all(feature = "fixture", windows))]
24048 #[test]
24049 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24050 let root = temporary_workspace_root("provider-bundle-argv-hold");
24051 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24052 std::fs::create_dir_all(&root).unwrap();
24053 std::fs::create_dir_all(&outside).unwrap();
24054 let proof_path = root.join("provider.proof");
24055 let release_signal = root.join("release.signal");
24056 let config = NodeServerConfig::new(
24057 format!(
24058 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24059 std::process::id(),
24060 ),
24061 "fixture-token",
24062 NodeId::new("provider-bundle-hold-node").unwrap(),
24063 [WorkspaceConfig::new(
24064 WorkspaceId::new("primary").unwrap(),
24065 std::env::current_dir().unwrap(),
24066 )
24067 .unwrap()],
24068 )
24069 .unwrap()
24070 .with_spawn_profiles(SpawnProfileRegistry::new([
24071 crate::protocol::SpawnProfileDefaults {
24072 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24073 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24074 .unwrap(),
24075 provider: agent("kimi"),
24076 mode: SessionMode::Pty,
24077 terminal_size: gate4agent_types::TerminalSize {
24078 rows: 24,
24079 columns: 80,
24080 },
24081 prompt: None,
24082 bundle_id: None,
24083 context_id: None,
24084 environment_profile_id: None,
24085 },
24086 ])
24087 .unwrap());
24088 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24089 config,
24090 agent("kimi"),
24091 proof_path.clone(),
24092 release_signal.clone(),
24093 )
24094 .unwrap();
24095 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24096
24097 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24098 agent("kimi"),
24099 proof_path.clone(),
24100 Some(release_signal.clone()),
24101 )
24102 .unwrap();
24103 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24104 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24105 let release_wait = wrapper
24106 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24107 .unwrap();
24108 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24109 assert!(proof_write < release_wait);
24110 assert!(release_wait < fixture_ready);
24111 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24112 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24113 assert_eq!(
24114 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24115 &[
24116 proof_path.to_string_lossy().into_owned(),
24117 release_signal.to_string_lossy().into_owned(),
24118 ],
24119 );
24120
24121 let error = NodeServer::provider_bundle_argv_fixture_spec(
24122 agent("kimi"),
24123 proof_path,
24124 Some(outside.join("release.signal")),
24125 )
24126 .unwrap_err();
24127 assert!(matches!(
24128 error,
24129 NodeServerError::Registry(message)
24130 if message == "provider bundle argv release path must share the proof parent"
24131 ));
24132 drop(server);
24133 std::fs::remove_dir_all(root).unwrap();
24134 std::fs::remove_dir_all(outside).unwrap();
24135 }
24136
24137 #[cfg(feature = "fixture")]
24138 #[test]
24139 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24140 let root = temporary_workspace_root("context-fixture-registry");
24141 std::fs::create_dir_all(&root).unwrap();
24142 let proof_path = root.join("context.proof");
24143 let config = NodeServerConfig::new(
24144 format!(
24145 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24146 std::process::id(),
24147 ),
24148 "fixture-token",
24149 NodeId::new("context-fixture-node").unwrap(),
24150 [WorkspaceConfig::new(
24151 WorkspaceId::new("primary").unwrap(),
24152 std::env::current_dir().unwrap(),
24153 )
24154 .unwrap()],
24155 )
24156 .unwrap();
24157 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24158 let mut enabled = server
24159 .shared
24160 .enabled_providers
24161 .iter()
24162 .map(AgentId::as_str)
24163 .collect::<Vec<_>>();
24164 enabled.sort_unstable();
24165 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24166 for provider in &server.shared.enabled_providers {
24167 assert!(server
24168 .shared
24169 .provider_adapter_contracts
24170 .iter()
24171 .any(|contract| {
24172 &contract.provider == provider
24173 && contract.family == AdapterFamily::History
24174 }));
24175 }
24176 drop(server);
24177 std::fs::remove_dir_all(root).unwrap();
24178 }
24179
24180 #[cfg(feature = "fixture")]
24181 #[test]
24182 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24183 let root = temporary_workspace_root("monitoring-context-fixture");
24184 std::fs::create_dir_all(&root).unwrap();
24185 let proof_path = root.join("context.proof");
24186 let standard = NodeServer::context_pack_fixture_catalog(
24187 proof_path.clone(),
24188 None,
24189 false,
24190 ).unwrap();
24191 let monitoring = NodeServer::context_pack_fixture_catalog(
24192 proof_path.clone(),
24193 None,
24194 true,
24195 ).unwrap();
24196 let claude = AgentId::new("claude").unwrap();
24197 let standard_claude = standard.get(&claude).unwrap();
24198 let monitoring_claude = monitoring.get(&claude).unwrap();
24199 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24203 assert_eq!(
24204 monitoring_claude.capabilities.adapters.history,
24205 standard_claude.capabilities.adapters.history,
24206 );
24207 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24208 let codex = AgentId::new("codex").unwrap();
24209 let standard_codex = standard.get(&codex).unwrap();
24210 let monitoring_codex = monitoring.get(&codex).unwrap();
24211 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24212 assert_eq!(
24213 monitoring_codex.capabilities.adapters.history,
24214 standard_codex.capabilities.adapters.history,
24215 );
24216 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24217 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24218 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24219 .unwrap();
24220 for required in [
24221 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24222 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24223 "@('role', 'text')",
24224 "g4a-private-provider-session-canary",
24225 "private-provider-login-identity",
24226 "$rawProof",
24227 "$env:GATE4AGENT_HOOK_URL",
24228 "g4a-private-provider-session-canary",
24229 ] {
24230 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24231 }
24232 for provider in ["grok", "kimi"] {
24233 let provider = AgentId::new(provider).unwrap();
24234 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24235 }
24236
24237 let config = NodeServerConfig::new(
24238 format!(
24239 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24240 std::process::id(),
24241 ),
24242 "fixture-token",
24243 NodeId::new("monitoring-context-fixture-node").unwrap(),
24244 [WorkspaceConfig::new(
24245 WorkspaceId::new("primary").unwrap(),
24246 std::env::current_dir().unwrap(),
24247 )
24248 .unwrap()],
24249 )
24250 .unwrap();
24251 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24252 assert!(server.shared.fixture_semantic_hook_policy);
24253 drop(server);
24254 std::fs::remove_dir_all(root).unwrap();
24255 }
24256
24257 #[cfg(feature = "fixture")]
24258 #[test]
24259 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24260 let root = temporary_workspace_root("kimi-context-only-fixture");
24261 std::fs::create_dir_all(&root).unwrap();
24262 let proof_path = root.join("context.proof");
24263 let standard = NodeServer::context_pack_fixture_catalog(
24264 proof_path.clone(),
24265 None,
24266 false,
24267 ).unwrap();
24268 let kimi = AgentId::new("kimi").unwrap();
24269 let context_only = NodeServer::context_pack_fixture_catalog(
24270 proof_path,
24271 Some(&kimi),
24272 false,
24273 )
24274 .unwrap();
24275
24276 let codex = AgentId::new("codex").unwrap();
24277 assert_eq!(
24278 standard.get(&codex).unwrap().launch.fixed_args,
24279 context_only.get(&codex).unwrap().launch.fixed_args,
24280 );
24281 let script = context_only
24282 .get(&kimi)
24283 .unwrap()
24284 .launch
24285 .fixed_args
24286 .iter()
24287 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24288 .expect("Kimi context-only validation script is missing");
24289 for required in [
24290 "$bundleArgs.Count -ne 0",
24291 "GATE4AGENT_CONTEXT_ROOT",
24292 "context-pack.json",
24293 "$entries.Count -ne 1",
24294 "g4a-context-pack-v1",
24295 "$document.PSObject.Properties['cwd']",
24296 "'claude', 'codex', 'grok', 'kimi'",
24297 "$hasUser",
24298 "$hasAssistant",
24299 "SHA256",
24300 "context-only",
24301 ] {
24302 assert!(script.contains(required), "missing validation: {required}");
24303 }
24304 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24305 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24306 }
24307 assert!(!standard
24308 .get(&kimi)
24309 .unwrap()
24310 .launch
24311 .fixed_args
24312 .iter()
24313 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24314
24315 std::fs::remove_dir_all(root).unwrap();
24316 }
24317
24318 #[cfg(all(feature = "fixture", windows))]
24319 #[tokio::test]
24320 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24321 let root = temporary_workspace_root("kimi-context-only-spawn");
24322 let workspace_root = root.join("workspace");
24323 std::fs::create_dir_all(&workspace_root).unwrap();
24324 let materialization_root = root.join("private-materializations");
24325 let proof_path = root.join("context.proof");
24326 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24327 let workspace_id = WorkspaceId::new("primary").unwrap();
24328 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24329 let endpoint = format!(
24330 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24331 std::process::id(),
24332 unix_time_ms(),
24333 );
24334 let spawn_profiles = SpawnProfileRegistry::new([
24335 crate::protocol::SpawnProfileDefaults {
24336 profile_id: profile_id.clone(),
24337 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24338 provider: agent("kimi"),
24339 mode: SessionMode::Pty,
24340 terminal_size: gate4agent_types::TerminalSize {
24341 rows: 30,
24342 columns: 120,
24343 },
24344 prompt: None,
24345 bundle_id: None,
24346 context_id: None,
24347 environment_profile_id: None,
24348 },
24349 ])
24350 .unwrap();
24351 let mut config = NodeServerConfig::new(
24352 &endpoint,
24353 "fixture-token",
24354 node_id.clone(),
24355 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24356 )
24357 .unwrap()
24358 .with_spawn_profiles(spawn_profiles)
24359 .with_session_environment_materialization(
24360 materialization_root,
24361 Arc::new(FixtureSecretResolver {
24362 deny: Arc::new(AtomicBool::new(false)),
24363 }),
24364 )
24365 .unwrap();
24366 config.fixture_raw_pty_runtime = true;
24367 let server = NodeServer::new_context_only_proof_fixture(
24368 config,
24369 agent("kimi"),
24370 proof_path.clone(),
24371 )
24372 .unwrap();
24373 let shared = Arc::clone(&server.shared);
24374 let shutdown = server.shutdown_handle();
24375
24376 let history = HistorySessionRecord {
24377 session_id: "context-only-source-history".to_owned(),
24378 title: None,
24379 cwd: None,
24380 model: None,
24381 message_count: 2,
24382 completed_turn_count: None,
24383 total_tokens: 0,
24384 messages: vec![
24385 gate4agent_types::HistoryMessageRecord {
24386 role: gate4agent_types::HistoryMessageRole::User,
24387 text: "continue the bounded parent run through native C2".to_owned(),
24388 },
24389 gate4agent_types::HistoryMessageRecord {
24390 role: gate4agent_types::HistoryMessageRole::Assistant,
24391 text: "the exact parent context is ready for the child run".to_owned(),
24392 },
24393 ],
24394 };
24395 let pack = NodeContextPack::export(
24396 ContextPackLineageReceipt {
24397 source_node_id: node_id.clone(),
24398 source_session: SessionAddress {
24399 workspace_id: workspace_id.clone(),
24400 session: SessionKey {
24401 instance_id: AgentInstanceId(71),
24402 generation: SessionGeneration(1),
24403 },
24404 },
24405 source_provider: agent("codex"),
24406 },
24407 &history,
24408 )
24409 .unwrap();
24410 let expected_context_hash = digest(&SHA256, pack.bytes())
24411 .as_ref()
24412 .iter()
24413 .map(|byte| format!("{byte:02x}"))
24414 .collect::<String>();
24415 let context = shared
24416 .context_catalog
24417 .write()
24418 .unwrap_or_else(|poisoned| poisoned.into_inner())
24419 .insert(pack)
24420 .unwrap();
24421 let spec = SpawnSpec {
24422 target: crate::protocol::SpawnTarget {
24423 node_id,
24424 workspace_id: workspace_id.clone(),
24425 worktree_id: None,
24426 },
24427 profile_id,
24428 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24429 "target-kimi-r1",
24430 ).unwrap(),
24431 overrides: crate::protocol::SpawnOverrides {
24432 context_id: crate::protocol::SpawnOverride::Set {
24433 value: context.id.clone(),
24434 },
24435 ..crate::protocol::SpawnOverrides::default()
24436 },
24437 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24438 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24439 required_capabilities: SpawnRequiredCapabilities::default(),
24440 };
24441 let resolved = match shared.resolve_spawn_spec(&spec) {
24442 Ok(resolved) => resolved,
24443 Err(error) => panic!(
24444 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24445 error.code,
24446 error.message,
24447 ),
24448 };
24449 let resolved_context = match shared.resolve_context(&resolved) {
24450 Ok(Some(resolved_context)) => resolved_context,
24451 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24452 Err(error) => panic!(
24453 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24454 error.code,
24455 error.message,
24456 ),
24457 };
24458 assert_eq!(resolved_context, context);
24459 let runtime_policy = match shared
24460 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24461 .await
24462 {
24463 Ok(runtime_policy) => runtime_policy,
24464 Err(error) => panic!(
24465 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24466 error.code,
24467 error.message,
24468 ),
24469 };
24470 assert!(runtime_policy.raw_pty_lifecycle);
24471
24472 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24473 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24474 agent("kimi"),
24475 TransportKind::Pty,
24476 vec![EnvMutation {
24477 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24478 value: Some(OsString::from("must-remain-rejected")),
24479 }],
24480 )
24481 .unwrap();
24482 assert_eq!(
24483 launch_control
24484 .install_native_launch_environment_overlay(
24485 AgentInstanceId(72),
24486 generic_overlay,
24487 )
24488 .unwrap_err(),
24489 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24490 );
24491
24492 let diagnostic_address = SessionAddress {
24493 workspace_id: workspace_id.clone(),
24494 session: SessionKey {
24495 instance_id: AgentInstanceId(73),
24496 generation: SessionGeneration(1),
24497 },
24498 };
24499 let (diagnostic_overlay, diagnostic_materialization) = match shared
24500 .prepare_session_materialization(
24501 &diagnostic_address,
24502 &agent("kimi"),
24503 SessionMode::Pty,
24504 None,
24505 None,
24506 Some(&resolved_context),
24507 None,
24508 )
24509 {
24510 Ok(Some(prepared)) => prepared,
24511 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24512 Err(error) => panic!(
24513 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24514 error.code,
24515 error.message,
24516 ),
24517 };
24518 let diagnostic_overlay = diagnostic_overlay
24519 .expect("Kimi context-only materialization omitted its environment overlay");
24520 assert!(matches!(
24521 &diagnostic_overlay,
24522 PreparedNativeLaunchOverlay::Environment(_),
24523 ));
24524 match shared.install_prepared_launch_overlay(
24525 diagnostic_address.session.instance_id,
24526 diagnostic_overlay,
24527 ) {
24528 Ok(None) => {}
24529 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24530 Err(error) => panic!(
24531 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24532 error.code,
24533 error.message,
24534 ),
24535 }
24536 assert!(launch_control.clear_native_instance_launch_overlay(
24537 diagnostic_address.session.instance_id,
24538 ));
24539 drop(diagnostic_materialization);
24540 assert!(shared
24541 .materializations
24542 .lock()
24543 .unwrap_or_else(|poisoned| poisoned.into_inner())
24544 .is_empty());
24545
24546 let node_task = tokio::spawn(server.run());
24547 let ready_client = timeout(Duration::from_secs(5), async {
24548 loop {
24549 match gate4agent_node_wire::LocalNodeClient::connect(
24550 &endpoint,
24551 &shared.node_id,
24552 ClientRole::Observer,
24553 "fixture-token",
24554 )
24555 .await
24556 {
24557 Ok(client) => break client,
24558 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24559 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24560 }
24561 }
24562 })
24563 .await
24564 .expect("Kimi context-only Node did not become ready");
24565 assert_eq!(
24566 ready_client
24567 .hello()
24568 .snapshot
24569 .provider_runtime_statuses
24570 .iter()
24571 .find(|status| status.provider().as_str() == "kimi")
24572 .expect("Kimi runtime status is missing")
24573 .mode(),
24574 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24575 );
24576 drop(ready_client);
24577
24578 let receipt = match shared.spawn_from_spec(spec).await {
24579 Ok(receipt) => receipt,
24580 Err(error) => panic!(
24581 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24582 error.code,
24583 error.message,
24584 ),
24585 };
24586 assert_eq!(receipt.provider, agent("kimi"));
24587 assert_eq!(receipt.context.as_ref(), Some(&context));
24588 assert!(receipt.context_binding_is_valid());
24589 timeout(Duration::from_secs(5), async {
24590 while !proof_path.is_file() {
24591 sleep(Duration::from_millis(10)).await;
24592 }
24593 })
24594 .await
24595 .expect("Kimi context-only fixture did not write its proof");
24596
24597 let proof = std::fs::read_to_string(&proof_path).unwrap();
24598 let lines = proof.lines().collect::<Vec<_>>();
24599 assert_eq!(lines.len(), 7);
24600 assert_eq!(lines[0], "context-only");
24601 assert!(Path::new(lines[1]).is_absolute());
24602 assert_ne!(
24603 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24604 std::fs::canonicalize(&workspace_root).unwrap(),
24605 );
24606 assert_eq!(
24607 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24608 std::fs::canonicalize(&workspace_root).unwrap(),
24609 );
24610 assert_eq!(lines[3], expected_context_hash);
24611 assert_eq!(lines[4], "g4a-context-pack-v1");
24612 assert_eq!(lines[5], "codex");
24613 assert_eq!(lines[6], "2");
24614 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24615 session.instance_id == receipt.session.session.instance_id
24616 && session.agent_id.as_str() == "kimi"
24617 }));
24618
24619 shutdown.request_shutdown().await.unwrap();
24620 timeout(Duration::from_secs(5), node_task)
24621 .await
24622 .unwrap()
24623 .unwrap()
24624 .unwrap();
24625 drop(shutdown);
24626 drop(shared);
24627 std::fs::remove_dir_all(root).unwrap();
24628 }
24629
24630 #[cfg(feature = "fixture")]
24631 #[test]
24632 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24633 let root = temporary_workspace_root("clean-exit-constructor");
24634 std::fs::create_dir_all(&root).unwrap();
24635 let config = construction_test_config("clean-exit-constructor")
24636 .with_state_path(root.join("node-state.json"))
24637 .unwrap();
24638 let server = NodeServer::new_clean_exit_fixture(
24639 config,
24640 root.clone(),
24641 root.join("started.marker"),
24642 root.join("release.signal"),
24643 )
24644 .unwrap();
24645 assert_eq!(
24646 server
24647 .shared
24648 .enabled_providers
24649 .iter()
24650 .map(AgentId::as_str)
24651 .collect::<Vec<_>>(),
24652 ["claude"],
24653 );
24654 drop(server);
24655 std::fs::remove_dir_all(root).unwrap();
24656 }
24657
24658 #[test]
24659 fn production_registry_provider_contract_manifest_is_exact() {
24660 let registry = active_registry().unwrap();
24661 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24662 assert_eq!(
24663 providers
24664 .iter()
24665 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24666 .collect::<Vec<_>>(),
24667 vec![
24668 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24669 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24670 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24671 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24672 ]
24673 );
24674 assert_eq!(
24675 adapters
24676 .iter()
24677 .map(|contract| (
24678 contract.provider.as_str(),
24679 contract.family,
24680 contract.adapter_id.as_str(),
24681 contract.revision.as_str(),
24682 ))
24683 .collect::<Vec<_>>(),
24684 vec![
24685 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24686 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24687 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24688 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24689 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24690 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24691 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24692 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24693 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24694 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24695 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24696 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24697 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24698 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24699 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24700 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24701 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24702 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24703 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24704 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24705 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24706 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24707 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24708 ]
24709 );
24710 }
24711
24712 fn construction_test_config(name: &str) -> NodeServerConfig {
24713 let workspace = WorkspaceConfig::new(
24714 WorkspaceId::new("primary").unwrap(),
24715 std::env::current_dir().unwrap(),
24716 )
24717 .unwrap();
24718 NodeServerConfig::new(
24719 format!(r"\\.\pipe\gate4agent-{name}"),
24720 "fixture-token",
24721 NodeId::new(name).unwrap(),
24722 [workspace],
24723 )
24724 .unwrap()
24725 }
24726
24727 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24728 let adapter = AdapterBinding::new(
24729 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24730 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24731 gate4agent_types::AdapterVerification::SyntheticFixture,
24732 )
24733 .unwrap();
24734 let agent_ids = ["claude", "codex", "kimi"]
24735 .into_iter()
24736 .map(|agent_id| AgentId::new(agent_id).unwrap())
24737 .collect::<Vec<_>>();
24738 let adapters = gate4agent_catalog::AdapterRegistry::new(
24739 [
24740 AdapterFamily::PtySemantic,
24741 AdapterFamily::Pipe,
24742 AdapterFamily::OneShot,
24743 AdapterFamily::Acp,
24744 AdapterFamily::Hook,
24745 AdapterFamily::ManagedHook,
24746 AdapterFamily::History,
24747 AdapterFamily::Resume,
24748 AdapterFamily::SessionOptions,
24749 AdapterFamily::CapabilityProbe,
24750 ]
24751 .into_iter()
24752 .map(|family| gate4agent_catalog::AdapterDescriptor {
24753 family,
24754 binding: adapter.clone(),
24755 agents: agent_ids.clone(),
24756 }),
24757 )
24758 .unwrap();
24759 let specs = agent_ids
24760 .iter()
24761 .map(|agent_id| {
24762 let agent_id = agent_id.as_str();
24763 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24764 spec.revision =
24765 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24766 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24767 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24768 adapter: adapter.clone(),
24769 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24770 launch_override: None,
24771 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24772 });
24773 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24774 adapter: adapter.clone(),
24775 launch_override: None,
24776 });
24777 spec.capabilities.adapters.hook = Some(adapter.clone());
24778 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24779 spec.capabilities.adapters.one_shot = Some(adapter.clone());
24780 spec.capabilities.adapters.history = Some(adapter.clone());
24781 spec.capabilities.adapters.resume = Some(adapter.clone());
24782 spec.capabilities.adapters.session_options = Some(adapter.clone());
24783 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24784 spec
24785 })
24786 .collect::<Vec<_>>();
24787 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24788 }
24789
24790 #[test]
24798 fn node_server_construction_accepts_the_production_manifest() {
24799 let registry = active_registry().unwrap();
24800 let (providers, _) = provider_contract_manifest(®istry).unwrap();
24801 assert_eq!(providers.len(), registry.iter().count());
24802 assert!(!providers.is_empty());
24803 let server = NodeServer::new_with_registry(
24804 construction_test_config("manifest-production-bound"),
24805 registry,
24806 )
24807 .unwrap();
24808 drop(server);
24809 }
24810
24811 #[test]
24812 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24813 let registry = maximum_size_provider_manifest_registry();
24814 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24815 assert_eq!(providers.len(), 3);
24816 assert_eq!(adapters.len(), 30);
24817 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24818 let error = match NodeServer::new_with_registry(
24819 construction_test_config("manifest-handshake-overflow"),
24820 registry,
24821 ) {
24822 Ok(_) => panic!("oversized negotiated manifest was accepted"),
24823 Err(error) => error,
24824 };
24825 assert!(matches!(
24826 error,
24827 NodeServerError::ProviderContractManifest(message)
24828 if message.contains("authentication binding length")
24829 && message.contains("8192-byte limit")
24830 ));
24831 }
24832
24833 #[test]
24834 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24835 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24836 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24837 let registry = AgentRegistry::new([spec]).unwrap();
24838 assert!(matches!(
24839 provider_contract_manifest(®istry),
24840 Err(NodeServerError::ProviderContractManifest(message))
24841 if message.contains("exceeds the 128-byte limit")
24842 ));
24843 }
24844
24845 #[test]
24846 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24847 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24848 spec.revision = "invalid revision".to_owned();
24849 let registry = AgentRegistry::new([spec]).unwrap();
24850 assert!(matches!(
24851 provider_contract_manifest(®istry),
24852 Err(NodeServerError::ProviderContractManifest(message))
24853 if message.contains("bounded lowercase ASCII")
24854 ));
24855 }
24856
24857 #[test]
24858 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24859 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24860 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24861 let conflicting_binding = builtin_registry()
24862 .get_by_id("codex")
24863 .unwrap()
24864 .capabilities
24865 .adapters
24866 .one_shot
24867 .clone()
24868 .unwrap();
24869 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24870 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24871 pipe.adapter = transport_binding;
24872 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24873 let registry = AgentRegistry::new([spec]).unwrap();
24874 assert!(matches!(
24875 provider_contract_manifest(®istry),
24876 Err(NodeServerError::ProviderContractManifest(message))
24877 if message.contains("conflicting OneShot adapter bindings")
24878 ));
24879 }
24880
24881 #[test]
24882 fn public_node_failures_expose_only_fixed_categories() {
24883 let provider = failure(
24884 NodeFailureCode::BackendOperationFailed,
24885 r"provider bearer-secret failed at C:\private\session.jsonl",
24886 );
24887 assert_eq!(provider.message, "backend-operation-failed");
24888 assert!(!provider.message.contains("bearer-secret"));
24889 assert!(!provider.message.contains(r"C:\private"));
24890 let persistence = persistence_failure(io::Error::new(
24891 io::ErrorKind::Other,
24892 r"state write failed at C:\private\state-v1.json",
24893 ));
24894 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24895 assert!(!persistence.message.contains(r"C:\private"));
24896
24897 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24898 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24899 assert_eq!(
24900 generic.to_string(),
24901 "node durable state failed: durable-state-load-failed",
24902 );
24903 }
24904 }
24905
24906 #[test]
24907 fn terminal_byte_sequences_are_bounded_before_dispatch() {
24908 let sequence = b"\x1b[1;5D".to_vec();
24909 assert_eq!(
24910 terminal_bytes_action(sequence.clone()).unwrap(),
24911 InputAction::TerminalBytes(sequence),
24912 );
24913 assert_eq!(
24914 terminal_bytes_action(Vec::new()).unwrap_err().code,
24915 NodeFailureCode::InvalidRequest,
24916 );
24917 assert_eq!(
24918 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24919 .unwrap_err()
24920 .code,
24921 NodeFailureCode::InvalidRequest,
24922 );
24923 }
24924
24925 #[test]
24926 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24927 let root = temporary_workspace_root("exclusive-state-owner");
24928 std::fs::create_dir_all(&root).unwrap();
24929 let workspace = WorkspaceConfig::new(
24930 WorkspaceId::new("primary").unwrap(),
24931 &root,
24932 )
24933 .unwrap();
24934 let state_path = root.join("state-v1.json");
24935 let endpoint = format!(
24936 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24937 std::process::id(),
24938 std::time::SystemTime::now()
24939 .duration_since(std::time::UNIX_EPOCH)
24940 .unwrap()
24941 .as_nanos(),
24942 );
24943 let config = NodeServerConfig::new(
24944 endpoint,
24945 "fixture-token",
24946 NodeId::new("node-1").unwrap(),
24947 [workspace],
24948 )
24949 .unwrap()
24950 .with_state_path(&state_path)
24951 .unwrap();
24952
24953 let first = NodeServer::new(config.clone()).unwrap();
24954 let error = match NodeServer::new(config.clone()) {
24955 Ok(_) => panic!("a second node server acquired the same durable state path"),
24956 Err(error) => error,
24957 };
24958 assert!(matches!(
24959 &error,
24960 NodeServerError::DurableState(error)
24961 if error.kind() == io::ErrorKind::WouldBlock
24962 ));
24963 assert_eq!(
24964 error.to_string(),
24965 "node durable state failed: durable-state-lock-failed",
24966 );
24967 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24968 drop(first);
24969 let second = NodeServer::new(config).unwrap();
24970 drop(second);
24971 std::fs::remove_dir_all(root).unwrap();
24972 }
24973
24974 #[test]
24975 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24976 let catalog = active_registry().unwrap();
24977 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24978 let workspace_id = WorkspaceId::new("primary").unwrap();
24979 let workspace = WorkspaceConfig::new(
24980 workspace_id.clone(),
24981 std::env::current_dir().unwrap(),
24982 )
24983 .unwrap();
24984 let shared = NodeShared::new(
24985 handle,
24986 "fixture-token".to_owned(),
24987 NodeId::new("node-1").unwrap(),
24988 vec![workspace.clone()],
24989 vec![agent("claude"), agent("codex"), agent("kimi")],
24990 );
24991 let instance_id = AgentInstanceId(17);
24992 let generation = SessionGeneration(3);
24993 assert!(shared.address_for(instance_id, generation).is_none());
24994 shared.bind_session(instance_id, workspace_id.clone(), generation);
24995 let address = SessionAddress {
24996 workspace_id,
24997 session: SessionKey { instance_id, generation },
24998 };
24999 assert_eq!(
25000 shared.workspace_root(&address.workspace_id).unwrap(),
25001 workspace.canonical_root,
25002 );
25003 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25004 shared.remove_binding(&address);
25005 assert!(shared.address_for(instance_id, generation).is_none());
25006 }
25007
25008 #[test]
25009 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25010 let address = SessionAddress {
25011 workspace_id: WorkspaceId::new("primary").unwrap(),
25012 session: SessionKey {
25013 instance_id: AgentInstanceId(7),
25014 generation: SessionGeneration(2),
25015 },
25016 };
25017 let record_id = SessionRecordId::new("record-a").unwrap();
25018 let size = gate4agent_types::TerminalSize {
25019 rows: 24,
25020 columns: 80,
25021 };
25022 let address_requests = [
25023 NodeRequest::Resume {
25024 session: address.clone(),
25025 terminal_size: size,
25026 initial_prompt: None,
25027 },
25028 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25029 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25030 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25031 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25032 NodeRequest::TerminalControl {
25033 session: address.clone(),
25034 control: TerminalControl::Enter,
25035 },
25036 NodeRequest::Resize { session: address.clone(), size },
25037 NodeRequest::Interrupt { session: address.clone() },
25038 NodeRequest::Stop { session: address.clone(), force: false },
25039 NodeRequest::Remove { session: address },
25040 ];
25041 for request in &address_requests {
25042 assert!(request_requires_open_provider_ids_with(
25043 request,
25044 |_| Some(agent("third-party-agent")),
25045 |_| Some(agent("claude")),
25046 ));
25047 assert!(!request_requires_open_provider_ids_with(
25048 request,
25049 |_| Some(agent("claude")),
25050 |_| Some(agent("claude")),
25051 ));
25052 assert!(request_requires_open_provider_ids_with(
25053 request,
25054 |_| None,
25055 |_| Some(agent("claude")),
25056 ));
25057 }
25058
25059 let record_requests = [
25060 NodeRequest::RenameSessionRecord {
25061 record_id: record_id.clone(),
25062 display_name: "renamed".to_owned(),
25063 },
25064 NodeRequest::ResumeSessionRecord {
25065 record_id: record_id.clone(),
25066 terminal_size: size,
25067 initial_prompt: None,
25068 },
25069 NodeRequest::ForgetSessionRecord { record_id },
25070 ];
25071 for request in &record_requests {
25072 assert!(request_requires_open_provider_ids_with(
25073 request,
25074 |_| Some(agent("claude")),
25075 |_| Some(agent("grok")),
25076 ));
25077 assert!(!request_requires_open_provider_ids_with(
25078 request,
25079 |_| Some(agent("claude")),
25080 |_| Some(agent("kimi")),
25081 ));
25082 assert!(request_requires_open_provider_ids_with(
25083 request,
25084 |_| Some(agent("claude")),
25085 |_| None,
25086 ));
25087 }
25088
25089 assert!(request_requires_open_provider_ids_with(
25090 &NodeRequest::Spawn {
25091 workspace_id: WorkspaceId::new("primary").unwrap(),
25092 provider: agent("third-party-agent"),
25093 mode: SessionMode::Pty,
25094 terminal_size: size,
25095 initial_prompt: None,
25096 },
25097 |_| None,
25098 |_| None,
25099 ));
25100 assert!(!request_requires_open_provider_ids_with(
25101 &NodeRequest::Snapshot,
25102 |_| None,
25103 |_| None,
25104 ));
25105 assert!(request_requires_open_provider_ids_with(
25106 &NodeRequest::ForgetContextPack {
25107 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25108 },
25109 |_| None,
25110 |_| None,
25111 ));
25112 }
25113
25114 #[test]
25115 fn legacy_projection_filters_open_record_replies_and_event_history() {
25116 let catalog = active_registry().unwrap();
25117 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25118 let workspace = WorkspaceConfig::new(
25119 WorkspaceId::new("primary").unwrap(),
25120 std::env::current_dir().unwrap(),
25121 )
25122 .unwrap();
25123 let shared = NodeShared::new(
25124 handle,
25125 "fixture-token".to_owned(),
25126 NodeId::new("node-1").unwrap(),
25127 vec![workspace],
25128 vec![agent("claude"), agent("grok")],
25129 );
25130 let legacy = record("claude", "legacy-record");
25131 let open = record("grok", "open-record");
25132 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25133 record: legacy.clone(),
25134 });
25135 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25136 record: open.clone(),
25137 });
25138 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25139 record_id: legacy.record_id.clone(),
25140 });
25141 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25142 record_id: open.record_id.clone(),
25143 });
25144 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25145 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25146 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25147 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25148
25149 let mut record_reply = ResponseEnvelope {
25150 request_id: 1,
25151 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25152 };
25153 project_response_legacy_provider_ids(&shared, &mut record_reply);
25154 assert!(matches!(
25155 record_reply.result,
25156 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25157 ));
25158
25159 let mut resync = ResponseEnvelope {
25160 request_id: 2,
25161 result: Ok(NodeResponse::Resync {
25162 event_sequence: open_removed.sequence,
25163 oldest_available_sequence: 1,
25164 snapshot: shared.snapshot(),
25165 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25166 }),
25167 };
25168 project_response_legacy_provider_ids(&shared, &mut resync);
25169 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25170 panic!("legacy projection changed the resync response kind");
25171 };
25172 assert!(snapshot.session_records.iter().all(|item| {
25173 provider_id_is_legacy(&item.provider)
25174 }));
25175 assert_eq!(events.len(), 2);
25176 assert!(events.iter().all(|event| matches!(
25177 &event.event,
25178 NodeEvent::SessionRecordUpserted { record }
25179 if provider_id_is_legacy(&record.provider)
25180 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25181 }
25182
25183 #[test]
25184 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25185 let catalog = active_registry().unwrap();
25186 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25187 let workspace_id = WorkspaceId::new("primary").unwrap();
25188 let workspace = WorkspaceConfig::new(
25189 workspace_id.clone(),
25190 std::env::current_dir().unwrap(),
25191 )
25192 .unwrap();
25193 let shared = NodeShared::new(
25194 handle,
25195 "fixture-token".to_owned(),
25196 NodeId::new("node-1").unwrap(),
25197 vec![workspace],
25198 vec![agent("claude")],
25199 );
25200 for index in 0..CONTROL_SESSIONS_MAX {
25201 shared.bind_session(
25202 AgentInstanceId((index + 1) as u64),
25203 workspace_id.clone(),
25204 SessionGeneration(1),
25205 );
25206 }
25207 let error = shared.ensure_binding_capacity().unwrap_err();
25208 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25209 }
25210
25211 #[tokio::test]
25212 async fn provider_spawn_admission_rejects_before_session_mutation() {
25213 let catalog = active_registry().unwrap();
25214 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25215 let workspace = WorkspaceConfig::new(
25216 WorkspaceId::new("primary").unwrap(),
25217 std::env::current_dir().unwrap(),
25218 )
25219 .unwrap();
25220 let mut shared = NodeShared::new(
25221 handle,
25222 "fixture-token".to_owned(),
25223 NodeId::new("node-provider-admission").unwrap(),
25224 vec![workspace],
25225 vec![agent("claude")],
25226 );
25227 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25228 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25229 ])
25230 .unwrap();
25231 let terminal_size = gate4agent_types::TerminalSize {
25232 rows: 24,
25233 columns: 80,
25234 };
25235
25236 let unavailable = shared
25237 .spawn_session(
25238 WorkspaceId::new("primary").unwrap(),
25239 agent("claude"),
25240 SessionMode::Pty,
25241 terminal_size,
25242 None,
25243 )
25244 .await
25245 .unwrap_err();
25246 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25247 assert_eq!(unavailable.message, "backend-operation-failed");
25248
25249 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25250 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25251 agent("claude"),
25252 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25253 ),
25254 ])
25255 .unwrap();
25256 let semantic = shared
25257 .spawn_session(
25258 WorkspaceId::new("primary").unwrap(),
25259 agent("claude"),
25260 SessionMode::Inline,
25261 terminal_size,
25262 None,
25263 )
25264 .await
25265 .unwrap_err();
25266 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25267 assert_eq!(semantic.message, "unsupported-capability");
25268
25269 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25270 assert!(shared
25271 .session_bindings
25272 .lock()
25273 .unwrap_or_else(|poisoned| poisoned.into_inner())
25274 .is_empty());
25275 assert!(shared
25276 .session_records
25277 .lock()
25278 .unwrap_or_else(|poisoned| poisoned.into_inner())
25279 .records
25280 .is_empty());
25281 assert!(shared.handle.snapshot().sessions.is_empty());
25282 }
25283
25284 #[tokio::test]
25285 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25286 let catalog = active_registry().unwrap();
25287 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25288 let workspace_id = WorkspaceId::new("primary").unwrap();
25289 let workspace = WorkspaceConfig::new(
25290 workspace_id.clone(),
25291 std::env::current_dir().unwrap(),
25292 )
25293 .unwrap();
25294 let shared = NodeShared::new(
25295 handle,
25296 "fixture-token".to_owned(),
25297 NodeId::new("node-raw-spawn-record").unwrap(),
25298 vec![workspace],
25299 vec![agent("claude")],
25300 );
25301 let address = SessionAddress {
25302 workspace_id,
25303 session: SessionKey {
25304 instance_id: AgentInstanceId(71),
25305 generation: SessionGeneration::default(),
25306 },
25307 };
25308
25309 let record_id = shared
25310 .bind_spawn_session(
25311 &address,
25312 agent("claude"),
25313 SessionMode::Pty,
25314 ProviderRuntimePolicy::raw_pty(),
25315 SpawnRecordPolicy::Always,
25316 None,
25317 )
25318 .unwrap()
25319 .unwrap();
25320 shared
25321 .handle
25322 .dispatch(shared.prepare_command(ControlCommand::Register {
25323 instance_id: address.session.instance_id,
25324 agent_id: agent("claude"),
25325 transport: TransportKind::Pty,
25326 }))
25327 .unwrap();
25328 runtime.tick().await;
25329
25330 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25331 session.instance_id == address.session.instance_id
25332 && session.generation == address.session.generation
25333 }));
25334 let record = shared.record(&record_id).unwrap();
25335 assert_eq!(record.display_name, "claude #1");
25336 assert_eq!(record.state, ManagedSessionState::Live);
25337 assert_eq!(record.active_session.as_ref(), Some(&address));
25338 assert!(record.provider_session.is_none());
25339 let binding_record_id = shared
25340 .session_bindings
25341 .lock()
25342 .unwrap_or_else(|poisoned| poisoned.into_inner())
25343 .get(&address.session.instance_id)
25344 .and_then(|binding| binding.record_id.clone());
25345 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25346
25347 let renamed = shared
25348 .rename_session_record(&record_id, "raw review".to_owned())
25349 .unwrap();
25350 assert_eq!(renamed.record_id, record_id);
25351 assert_eq!(renamed.display_name, "raw review");
25352 shared.reconcile_managed_record(
25353 &record_id,
25354 &address,
25355 &ControlEvent {
25356 sequence: 1,
25357 command_id: None,
25358 instance_id: address.session.instance_id,
25359 generation: address.session.generation,
25360 event: ControlEventKind::Running { process_id: None },
25361 },
25362 false,
25363 false,
25364 );
25365 let running = shared.record(&record_id).unwrap();
25366 assert_eq!(running.state, ManagedSessionState::Live);
25367 assert_eq!(running.active_session.as_ref(), Some(&address));
25368 assert_eq!(running.display_name, "raw review");
25369
25370 shared.reconcile_managed_record(
25371 &record_id,
25372 &address,
25373 &ControlEvent {
25374 sequence: 2,
25375 command_id: None,
25376 instance_id: address.session.instance_id,
25377 generation: address.session.generation,
25378 event: ControlEventKind::Exited {
25379 exit_code: Some(0),
25380 forced: false,
25381 },
25382 },
25383 false,
25384 false,
25385 );
25386 let detached = shared.record(&record_id).unwrap();
25387 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25388 assert!(detached.active_session.is_none());
25389 assert_eq!(detached.display_name, "raw review");
25390 let renamed_detached = shared
25391 .rename_session_record(&record_id, "raw review detached".to_owned())
25392 .unwrap();
25393 assert_eq!(renamed_detached.record_id, record_id);
25394 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25395 assert!(renamed_detached.active_session.is_none());
25396 assert_eq!(renamed_detached.display_name, "raw review detached");
25397 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25398 }
25399
25400 #[test]
25401 fn legacy_raw_spawn_binding_remains_untracked() {
25402 let catalog = active_registry().unwrap();
25403 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25404 let workspace_id = WorkspaceId::new("primary").unwrap();
25405 let workspace = WorkspaceConfig::new(
25406 workspace_id.clone(),
25407 std::env::current_dir().unwrap(),
25408 )
25409 .unwrap();
25410 let shared = NodeShared::new(
25411 handle,
25412 "fixture-token".to_owned(),
25413 NodeId::new("node-legacy-raw-spawn").unwrap(),
25414 vec![workspace],
25415 vec![agent("claude")],
25416 );
25417 let address = SessionAddress {
25418 workspace_id,
25419 session: SessionKey {
25420 instance_id: AgentInstanceId(72),
25421 generation: SessionGeneration::default(),
25422 },
25423 };
25424
25425 let record_id = shared
25426 .bind_spawn_session(
25427 &address,
25428 agent("claude"),
25429 SessionMode::Pty,
25430 ProviderRuntimePolicy::raw_pty(),
25431 SpawnRecordPolicy::ProviderIdentityOnly,
25432 None,
25433 )
25434 .unwrap();
25435
25436 assert!(record_id.is_none());
25437 let binding_record_id = shared
25438 .session_bindings
25439 .lock()
25440 .unwrap_or_else(|poisoned| poisoned.into_inner())
25441 .get(&address.session.instance_id)
25442 .and_then(|binding| binding.record_id.clone());
25443 assert!(binding_record_id.is_none());
25444 assert!(shared.snapshot().session_records.is_empty());
25445 }
25446
25447 #[tokio::test]
25448 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25449 let catalog = active_registry().unwrap();
25450 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25451 let workspace_id = WorkspaceId::new("primary").unwrap();
25452 let workspace = WorkspaceConfig::new(
25453 workspace_id.clone(),
25454 std::env::current_dir().unwrap(),
25455 )
25456 .unwrap();
25457 let mut shared = NodeShared::new(
25458 handle,
25459 "fixture-token".to_owned(),
25460 NodeId::new("node-raw-semantic-gate").unwrap(),
25461 vec![workspace],
25462 vec![agent("claude")],
25463 );
25464 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25465 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25466 ])
25467 .unwrap();
25468 shared
25469 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25470 .unwrap();
25471 let address = SessionAddress {
25472 workspace_id,
25473 session: SessionKey {
25474 instance_id: AgentInstanceId(72),
25475 generation: SessionGeneration::default(),
25476 },
25477 };
25478 shared.bind_session_with_policy(
25479 &address,
25480 ProviderRuntimePolicy::raw_pty(),
25481 None,
25482 );
25483 shared
25484 .handle
25485 .dispatch(shared.prepare_command(ControlCommand::Register {
25486 instance_id: address.session.instance_id,
25487 agent_id: agent("claude"),
25488 transport: TransportKind::Pty,
25489 }))
25490 .unwrap();
25491 runtime.tick().await;
25492 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25493 let revision_before = shared.handle.snapshot().revision;
25494 let history_sequence_before = shared.current_sequence();
25495 let terminal_size = gate4agent_types::TerminalSize {
25496 rows: 24,
25497 columns: 80,
25498 };
25499
25500 for request in [
25501 NodeRequest::Prompt {
25502 session: address.clone(),
25503 text: "prompt".to_owned(),
25504 },
25505 NodeRequest::Paste {
25506 session: address.clone(),
25507 text: "paste".to_owned(),
25508 },
25509 NodeRequest::Resume {
25515 session: address.clone(),
25516 terminal_size,
25517 initial_prompt: Some("resume".to_owned()),
25518 },
25519 ] {
25520 let error = process_request_inner(
25521 &shared,
25522 77,
25523 ClientRole::Operator,
25524 request,
25525 )
25526 .await
25527 .unwrap_err();
25528 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25529 }
25530
25531 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25532 assert_eq!(shared.handle.snapshot().revision, revision_before);
25533 assert_eq!(shared.current_sequence(), history_sequence_before);
25534 let bindings = shared
25535 .session_bindings
25536 .lock()
25537 .unwrap_or_else(|poisoned| poisoned.into_inner());
25538 let binding = bindings.get(&address.session.instance_id).unwrap();
25539 assert_eq!(binding.generation, address.session.generation);
25540 assert!(binding.pending_resume.is_none());
25541 assert!(binding.record_id.is_none());
25542 }
25543
25544 #[tokio::test]
25545 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25546 let catalog = active_registry().unwrap();
25547 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25548 let primary = WorkspaceConfig::new(
25549 WorkspaceId::new("primary").unwrap(),
25550 std::env::current_dir().unwrap(),
25551 )
25552 .unwrap();
25553 let shared = NodeShared::new(
25554 handle,
25555 "fixture-token".to_owned(),
25556 NodeId::new("node-1").unwrap(),
25557 vec![primary],
25558 vec![agent("claude")],
25559 );
25560 shared
25561 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25562 .unwrap();
25563 let before = shared.snapshot();
25564 let error = process_request_inner(
25565 &shared,
25566 77,
25567 ClientRole::Operator,
25568 NodeRequest::RegisterWorkspace {
25569 workspace_id: WorkspaceId::new("foreign").unwrap(),
25570 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25571 },
25572 )
25573 .await
25574 .unwrap_err();
25575 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25576 assert_eq!(error.message, "invalid-request");
25577 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25578 }
25579
25580 #[tokio::test]
25581 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25582 let catalog = active_registry().unwrap();
25583 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25584 let primary_id = WorkspaceId::new("primary").unwrap();
25585 let primary = WorkspaceConfig::new(
25586 primary_id.clone(),
25587 std::env::current_dir().unwrap(),
25588 )
25589 .unwrap();
25590 let shared = NodeShared::new(
25591 handle,
25592 "fixture-token".to_owned(),
25593 NodeId::new("node-1").unwrap(),
25594 vec![primary.clone()],
25595 vec![agent("claude")],
25596 );
25597 let secondary_id = WorkspaceId::new("secondary").unwrap();
25598 let secondary = shared
25599 .register_workspace(
25600 secondary_id.clone(),
25601 std::env::temp_dir().to_string_lossy().into_owned(),
25602 )
25603 .await
25604 .unwrap();
25605 assert_eq!(secondary.workspace_id, secondary_id);
25606 assert!(secondary.sessions.is_empty());
25607 assert_eq!(shared.snapshot().workspaces.len(), 2);
25608
25609 let duplicate_id = shared
25610 .register_workspace(
25611 secondary_id.clone(),
25612 primary.canonical_root().to_owned(),
25613 )
25614 .await
25615 .unwrap_err();
25616 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25617 let duplicate_root = shared
25618 .register_workspace(
25619 WorkspaceId::new("duplicate-root").unwrap(),
25620 windows_path_text(&secondary.canonical_root).to_owned(),
25621 )
25622 .await
25623 .unwrap_err();
25624 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25625
25626 let address = SessionAddress {
25627 workspace_id: secondary_id.clone(),
25628 session: SessionKey {
25629 instance_id: AgentInstanceId(41),
25630 generation: SessionGeneration(1),
25631 },
25632 };
25633 shared.bind_session(
25634 address.session.instance_id,
25635 secondary_id.clone(),
25636 address.session.generation,
25637 );
25638 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25639 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25640 shared.remove_binding(&address);
25641 shared.unregister_workspace(&secondary_id).unwrap();
25642 assert_eq!(shared.snapshot().workspaces.len(), 1);
25643
25644 let last = shared.unregister_workspace(&primary_id).unwrap_err();
25645 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25646 let history = shared
25647 .history
25648 .lock()
25649 .unwrap_or_else(|poisoned| poisoned.into_inner());
25650 assert!(matches!(
25651 &history.events[0],
25652 NodeEventEnvelope {
25653 sequence: 1,
25654 event: NodeEvent::WorkspaceAdded { workspace },
25655 } if workspace.workspace_id == secondary_id
25656 ));
25657 assert!(matches!(
25658 &history.events[1],
25659 NodeEventEnvelope {
25660 sequence: 2,
25661 event: NodeEvent::WorkspaceRemoved { workspace_id },
25662 } if workspace_id == &secondary_id
25663 ));
25664 }
25665
25666 #[tokio::test]
25667 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25668 let secondary_root = temporary_workspace_root("provider-session-reference-index");
25669 std::fs::create_dir_all(&secondary_root).unwrap();
25670 let catalog = active_registry().unwrap();
25671 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25672 let primary_id = WorkspaceId::new("primary").unwrap();
25673 let secondary_id = WorkspaceId::new("secondary").unwrap();
25674 let shared = NodeShared::new(
25675 handle,
25676 "fixture-token".to_owned(),
25677 NodeId::new("node-session-reference-index").unwrap(),
25678 vec![
25679 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25680 .unwrap(),
25681 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25682 ],
25683 vec![agent("claude"), agent("codex")],
25684 );
25685 let identity = ProviderSessionIdentity {
25686 key: gate4agent_types::ProviderSessionKey::SessionId,
25687 id: "provider-session-42".to_owned(),
25688 transcript_path: None,
25689 };
25690 let index_request = |workspace_id: WorkspaceId,
25691 provider: AgentId,
25692 identity: ProviderSessionIdentity,
25693 display_name: &str| {
25694 NodeRequest::IndexProviderSession {
25695 workspace_id,
25696 provider,
25697 identity,
25698 display_name: display_name.to_owned(),
25699 }
25700 };
25701 let request = index_request(
25702 primary_id.clone(),
25703 agent("claude"),
25704 identity.clone(),
25705 "release shepherd",
25706 );
25707 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25708 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25709 }));
25710 let observer = process_request_inner(
25711 &shared,
25712 77,
25713 ClientRole::Observer,
25714 request.clone(),
25715 )
25716 .await
25717 .unwrap_err();
25718 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25719 let uncontrolled = process_request_inner(
25720 &shared,
25721 77,
25722 ClientRole::Operator,
25723 request.clone(),
25724 )
25725 .await
25726 .unwrap_err();
25727 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25728 shared
25729 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25730 .unwrap();
25731
25732 let indexed = process_request_inner(
25733 &shared,
25734 77,
25735 ClientRole::Operator,
25736 request.clone(),
25737 )
25738 .await
25739 .unwrap();
25740 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25741 panic!("provider session reference index returned another response");
25742 };
25743 assert_eq!(record.state, ManagedSessionState::Dormant);
25744 assert_eq!(record.mode, SessionMode::Pty);
25745 assert_eq!(record.workspace_id, primary_id);
25746 assert_eq!(record.provider_session.as_ref(), Some(&identity));
25747 assert!(record.active_session.is_none());
25748 assert!(record.environment_profile.is_none());
25749 assert!(record.bundle.is_none());
25750 assert!(record.context.is_none());
25751
25752 let repeated = process_request_inner(
25753 &shared,
25754 77,
25755 ClientRole::Operator,
25756 index_request(
25757 primary_id.clone(),
25758 agent("claude"),
25759 identity.clone(),
25760 "ignored replacement",
25761 ),
25762 )
25763 .await
25764 .unwrap();
25765 assert!(matches!(
25766 repeated,
25767 NodeResponse::ProviderSessionIndexed { record: ref existing }
25768 if existing.record_id == record.record_id
25769 && existing.display_name == "release shepherd"
25770 ));
25771 let conflicting_workspace = process_request_inner(
25772 &shared,
25773 77,
25774 ClientRole::Operator,
25775 index_request(
25776 secondary_id,
25777 agent("claude"),
25778 identity.clone(),
25779 "release shepherd",
25780 ),
25781 )
25782 .await
25783 .unwrap_err();
25784 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25785 let conflicting_provider = process_request_inner(
25786 &shared,
25787 77,
25788 ClientRole::Operator,
25789 index_request(
25790 primary_id.clone(),
25791 agent("codex"),
25792 identity.clone(),
25793 "release shepherd",
25794 ),
25795 )
25796 .await
25797 .unwrap_err();
25798 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25799 let transcript_rejected = process_request_inner(
25800 &shared,
25801 77,
25802 ClientRole::Operator,
25803 index_request(
25804 primary_id,
25805 agent("claude"),
25806 ProviderSessionIdentity {
25807 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25808 ..identity
25809 },
25810 "release shepherd",
25811 ),
25812 )
25813 .await
25814 .unwrap_err();
25815 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25816 let upserts = shared
25817 .history
25818 .lock()
25819 .unwrap_or_else(|poisoned| poisoned.into_inner())
25820 .events
25821 .iter()
25822 .filter(|event| matches!(
25823 &event.event,
25824 NodeEvent::SessionRecordUpserted { record: published }
25825 if published.record_id == record.record_id
25826 ))
25827 .count();
25828 assert_eq!(upserts, 1);
25829 std::fs::remove_dir_all(secondary_root).unwrap();
25830 }
25831
25832 #[tokio::test]
25833 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25834 let fixture_root = temporary_workspace_root("standalone-independent");
25835 let target = fixture_root.join("independent");
25836 std::fs::create_dir_all(&fixture_root).unwrap();
25837 let catalog = active_registry().unwrap();
25838 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25839 let selected = WorkspaceConfig::new(
25840 WorkspaceId::new("primary").unwrap(),
25841 std::env::current_dir().unwrap(),
25842 )
25843 .unwrap();
25844 let selected_root = selected.canonical_root().to_owned();
25845 let shared = NodeShared::new(
25846 handle,
25847 "fixture-token".to_owned(),
25848 NodeId::new("node-standalone").unwrap(),
25849 vec![selected],
25850 vec![agent("claude")],
25851 );
25852 let workspace_id = WorkspaceId::new("independent").unwrap();
25853 let request = NodeRequest::CreateStandaloneWorkspace {
25854 workspace_id: workspace_id.clone(),
25855 root: opaque_windows_path(target.to_string_lossy().into_owned()),
25856 initial_branch: Some("main".to_owned()),
25857 };
25858 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25859 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25860 }));
25861 assert!(!request_uses_unnegotiated_capability(
25862 &request,
25863 &baseline_capabilities().unwrap(),
25864 ));
25865 assert!(request_uses_unnegotiated_capability(&request, &[]));
25866
25867 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25868 .await
25869 .unwrap_err();
25870 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25871 assert!(!target.exists());
25872 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25873 .await
25874 .unwrap_err();
25875 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25876 assert!(!target.exists());
25877 shared
25878 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25879 .unwrap();
25880
25881 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25882 .await
25883 .unwrap();
25884 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25885 panic!("standalone workspace creation returned another response");
25886 };
25887 assert_eq!(workspace.workspace_id, workspace_id);
25888 assert!(target.join(".git").is_dir());
25889 let branch = run_git_bounded(
25890 target.to_str().unwrap(),
25891 &["branch", "--show-current"],
25892 GIT_OUTPUT_MAX_BYTES,
25893 )
25894 .await
25895 .unwrap();
25896 assert!(branch.success);
25897 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25898 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25899 assert_eq!(worktrees.len(), 1);
25900 let listed_root = WorkspaceConfig::new(
25901 WorkspaceId::new("listed-independent").unwrap(),
25902 &worktrees[0].path,
25903 )
25904 .unwrap();
25905 assert!(platform::roots_equal(
25906 listed_root.canonical_root(),
25907 windows_path_text(&workspace.canonical_root),
25908 ));
25909 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25910 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25911 snapshot.workspace_id == workspace_id
25912 && snapshot.canonical_root == workspace.canonical_root
25913 }));
25914 assert!(shared
25915 .history
25916 .lock()
25917 .unwrap_or_else(|poisoned| poisoned.into_inner())
25918 .events
25919 .iter()
25920 .any(|event| matches!(
25921 &event.event,
25922 NodeEvent::WorkspaceAdded { workspace }
25923 if workspace.workspace_id == workspace_id
25924 )));
25925 std::fs::remove_dir_all(fixture_root).unwrap();
25926 }
25927
25928 #[tokio::test]
25929 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25930 let fixture_root = temporary_workspace_root("standalone-compensation");
25931 let created_target = fixture_root.join("created-target");
25932 let existing_target = fixture_root.join("existing-target");
25933 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25934 let invalid_state_path = invalid_state_parent.join("state.json");
25935 std::fs::create_dir_all(&existing_target).unwrap();
25936 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25937 let catalog = active_registry().unwrap();
25938 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25939 let primary = WorkspaceConfig::new(
25940 WorkspaceId::new("primary").unwrap(),
25941 std::env::current_dir().unwrap(),
25942 )
25943 .unwrap();
25944 let mut shared = NodeShared::new(
25945 handle,
25946 "fixture-token".to_owned(),
25947 NodeId::new("node-standalone-compensation").unwrap(),
25948 vec![primary],
25949 vec![agent("claude")],
25950 );
25951 shared.state_path = Some(invalid_state_path);
25952
25953 let created_recovery = shared
25954 .create_standalone_workspace(
25955 WorkspaceId::new("compensated").unwrap(),
25956 created_target.to_string_lossy().into_owned(),
25957 Some("main".to_owned()),
25958 )
25959 .await
25960 .unwrap_err();
25961 assert_eq!(
25962 created_recovery.code,
25963 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25964 );
25965 assert!(created_target.join(".git").is_dir());
25966 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25967 workspace.workspace_id.as_str() != "compensated"
25968 }));
25969
25970 let existing_recovery = shared
25971 .create_standalone_workspace(
25972 WorkspaceId::new("restored").unwrap(),
25973 existing_target.to_string_lossy().into_owned(),
25974 None,
25975 )
25976 .await
25977 .unwrap_err();
25978 assert_eq!(
25979 existing_recovery.code,
25980 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25981 );
25982 assert!(existing_target.is_dir());
25983 assert!(existing_target.join(".git").is_dir());
25984 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25985 workspace.workspace_id.as_str() != "restored"
25986 }));
25987
25988 let registered_empty = fixture_root.join("registered-empty");
25989 std::fs::create_dir(®istered_empty).unwrap();
25990 let catalog = active_registry().unwrap();
25991 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25992 let registered = WorkspaceConfig::new(
25993 WorkspaceId::new("registered-empty").unwrap(),
25994 ®istered_empty,
25995 )
25996 .unwrap();
25997 let duplicate_shared = NodeShared::new(
25998 handle,
25999 "fixture-token".to_owned(),
26000 NodeId::new("node-standalone-duplicate-root").unwrap(),
26001 vec![registered],
26002 vec![agent("claude")],
26003 );
26004 let duplicate = duplicate_shared
26005 .create_standalone_workspace(
26006 WorkspaceId::new("duplicate-root").unwrap(),
26007 registered_empty.to_string_lossy().into_owned(),
26008 None,
26009 )
26010 .await
26011 .unwrap_err();
26012 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26013 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26014 std::fs::remove_dir_all(fixture_root).unwrap();
26015 }
26016
26017 #[test]
26018 fn managed_record_id_collision_preserves_the_existing_record() {
26019 let catalog = active_registry().unwrap();
26020 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26021 let workspace = WorkspaceConfig::new(
26022 WorkspaceId::new("primary").unwrap(),
26023 std::env::current_dir().unwrap(),
26024 )
26025 .unwrap();
26026 let shared = NodeShared::new(
26027 handle,
26028 "fixture-token".to_owned(),
26029 NodeId::new("node-1").unwrap(),
26030 vec![workspace.clone()],
26031 vec![agent("claude")],
26032 );
26033 let record_id = SessionRecordId::new("sr-collision").unwrap();
26034 let original = ManagedSessionRecord {
26035 record_id: record_id.clone(),
26036 display_name: "original".to_owned(),
26037 provider: agent("claude"),
26038 mode: SessionMode::Pty,
26039 state: ManagedSessionState::Dormant,
26040 workspace_id: workspace.workspace_id,
26041 canonical_root: opaque_windows_path(workspace.canonical_root),
26042 provider_session: None,
26043 active_session: None,
26044 environment_profile: None,
26045 bundle: None,
26046 context_id: None,
26047 context: None,
26048 exported_context: None,
26049 task_binding: None,
26050 created_at_unix_ms: 1,
26051 updated_at_unix_ms: 1,
26052 last_error: None,
26053 };
26054 shared.insert_record(original.clone()).unwrap();
26055 let mut collision = original.clone();
26056 collision.display_name = "replacement".to_owned();
26057 let error = shared.insert_record(collision).unwrap_err();
26058 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26059 assert_eq!(shared.record(&record_id).unwrap(), original);
26060 }
26061
26062 #[test]
26063 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26064 let catalog = active_registry().unwrap();
26065 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26066 let workspace = WorkspaceConfig::new(
26067 WorkspaceId::new("primary").unwrap(),
26068 std::env::current_dir().unwrap(),
26069 )
26070 .unwrap();
26071 let shared = NodeShared::new(
26072 handle,
26073 "fixture-token".to_owned(),
26074 NodeId::new("node-1").unwrap(),
26075 vec![workspace.clone()],
26076 vec![agent("claude")],
26077 );
26078 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26079 shared
26080 .insert_record(ManagedSessionRecord {
26081 record_id: record_id.clone(),
26082 display_name: "runtime error".to_owned(),
26083 provider: agent("claude"),
26084 mode: SessionMode::Pty,
26085 state: ManagedSessionState::IdentityPending,
26086 workspace_id: workspace.workspace_id,
26087 canonical_root: opaque_windows_path(workspace.canonical_root),
26088 provider_session: None,
26089 active_session: None,
26090 environment_profile: None,
26091 bundle: None,
26092 context_id: None,
26093 context: None,
26094 exported_context: None,
26095 task_binding: None,
26096 created_at_unix_ms: 1,
26097 updated_at_unix_ms: 1,
26098 last_error: None,
26099 })
26100 .unwrap();
26101
26102 shared
26103 .mark_record_error(
26104 &record_id,
26105 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26106 )
26107 .unwrap();
26108 let record = shared.record(&record_id).unwrap();
26109 assert_eq!(record.state, ManagedSessionState::Unavailable);
26110 assert_eq!(
26111 record.last_error.as_deref(),
26112 Some("provider-runtime-failed"),
26113 );
26114 let snapshot = shared.snapshot();
26115 assert_eq!(snapshot.session_records, vec![record]);
26116 }
26117
26118 #[test]
26127 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26128 let catalog = active_registry().unwrap();
26129 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26130 let workspace = WorkspaceConfig::new(
26131 WorkspaceId::new("primary").unwrap(),
26132 std::env::current_dir().unwrap(),
26133 )
26134 .unwrap();
26135 let shared = NodeShared::new(
26136 handle,
26137 "fixture-token".to_owned(),
26138 NodeId::new("node-1").unwrap(),
26139 vec![workspace],
26140 vec![agent("claude")],
26141 );
26142
26143 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26144
26145 shared.record_rejected_command(
26146 CommandId(1),
26147 "agent 'claude' does not support transport Acp".to_owned(),
26148 true,
26149 );
26150 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26152
26153 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26154 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26155 assert!(reason.unsupported_transport);
26156
26157 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26159
26160 shared.record_rejected_command(
26165 CommandId(3),
26166 "backend busy".to_owned(),
26167 false,
26168 );
26169 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26170 assert!(!unclassified.unsupported_transport);
26171 }
26172
26173 #[test]
26174 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26175 let catalog = active_registry().unwrap();
26176 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26177 let workspace = WorkspaceConfig::new(
26178 WorkspaceId::new("primary").unwrap(),
26179 std::env::current_dir().unwrap(),
26180 )
26181 .unwrap();
26182 let shared = NodeShared::new(
26183 handle,
26184 "fixture-token".to_owned(),
26185 NodeId::new("node-identity-policy").unwrap(),
26186 vec![workspace.clone()],
26187 vec![agent("claude")],
26188 );
26189 let address = SessionAddress {
26190 workspace_id: workspace.workspace_id.clone(),
26191 session: SessionKey {
26192 instance_id: AgentInstanceId(90),
26193 generation: SessionGeneration(1),
26194 },
26195 };
26196 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26197 shared
26198 .insert_record(ManagedSessionRecord {
26199 record_id: record_id.clone(),
26200 display_name: "policy denied".to_owned(),
26201 provider: agent("claude"),
26202 mode: SessionMode::Pty,
26203 state: ManagedSessionState::IdentityPending,
26204 workspace_id: workspace.workspace_id,
26205 canonical_root: opaque_windows_path(workspace.canonical_root),
26206 provider_session: None,
26207 active_session: Some(address.clone()),
26208 environment_profile: None,
26209 bundle: None,
26210 context_id: None,
26211 context: None,
26212 exported_context: None,
26213 task_binding: None,
26214 created_at_unix_ms: 1,
26215 updated_at_unix_ms: 1,
26216 last_error: None,
26217 })
26218 .unwrap();
26219 let event = ControlEvent {
26220 sequence: 1,
26221 command_id: None,
26222 instance_id: address.session.instance_id,
26223 generation: address.session.generation,
26224 event: ControlEventKind::ProviderEvent {
26225 sequence: 1,
26226 source: gate4agent_types::ProviderSource {
26227 family: AdapterFamily::PtySemantic,
26228 binding: AdapterBinding::new(
26229 gate4agent_types::AdapterId::new("claude").unwrap(),
26230 "fixture/v1",
26231 gate4agent_types::AdapterVerification::SyntheticFixture,
26232 )
26233 .unwrap(),
26234 },
26235 source_sequence: 1,
26236 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26237 identity: gate4agent_types::ProviderSessionIdentity {
26238 key: gate4agent_types::ProviderSessionKey::SessionId,
26239 id: "provider-session".to_owned(),
26240 transcript_path: None,
26241 },
26242 },
26243 },
26244 };
26245
26246 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26247
26248 let record = shared.record(&record_id).unwrap();
26249 assert!(record.provider_session.is_none());
26250 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26251 assert_eq!(record.active_session.as_ref(), Some(&address));
26252 }
26253
26254 #[test]
26255 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26256 let secondary_root = temporary_workspace_root("identity-scope");
26257 std::fs::create_dir_all(&secondary_root).unwrap();
26258 let catalog = active_registry().unwrap();
26259 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26260 let primary = WorkspaceConfig::new(
26261 WorkspaceId::new("primary").unwrap(),
26262 std::env::current_dir().unwrap(),
26263 )
26264 .unwrap();
26265 let secondary = WorkspaceConfig::new(
26266 WorkspaceId::new("secondary").unwrap(),
26267 &secondary_root,
26268 )
26269 .unwrap();
26270 let shared = NodeShared::new(
26271 handle,
26272 "fixture-token".to_owned(),
26273 NodeId::new("node-1").unwrap(),
26274 vec![primary.clone(), secondary.clone()],
26275 vec![agent("claude")],
26276 );
26277 let identity = gate4agent_types::ProviderSessionIdentity {
26278 key: gate4agent_types::ProviderSessionKey::SessionId,
26279 id: "provider-session".to_owned(),
26280 transcript_path: None,
26281 };
26282 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26283 shared
26284 .insert_record(ManagedSessionRecord {
26285 record_id: dormant_id.clone(),
26286 display_name: "dormant".to_owned(),
26287 provider: agent("claude"),
26288 mode: SessionMode::Pty,
26289 state: ManagedSessionState::Dormant,
26290 workspace_id: primary.workspace_id,
26291 canonical_root: opaque_windows_path(primary.canonical_root),
26292 provider_session: Some(identity.clone()),
26293 active_session: None,
26294 environment_profile: None,
26295 bundle: None,
26296 context_id: None,
26297 context: None,
26298 exported_context: None,
26299 task_binding: None,
26300 created_at_unix_ms: 1,
26301 updated_at_unix_ms: 1,
26302 last_error: None,
26303 })
26304 .unwrap();
26305 let address = SessionAddress {
26306 workspace_id: secondary.workspace_id.clone(),
26307 session: SessionKey {
26308 instance_id: AgentInstanceId(91),
26309 generation: SessionGeneration(1),
26310 },
26311 };
26312 let current_id = SessionRecordId::new("sr-current").unwrap();
26313 shared
26314 .insert_record(ManagedSessionRecord {
26315 record_id: current_id.clone(),
26316 display_name: "current".to_owned(),
26317 provider: agent("claude"),
26318 mode: SessionMode::Pty,
26319 state: ManagedSessionState::IdentityPending,
26320 workspace_id: secondary.workspace_id,
26321 canonical_root: opaque_windows_path(secondary.canonical_root),
26322 provider_session: None,
26323 active_session: Some(address.clone()),
26324 environment_profile: None,
26325 bundle: None,
26326 context_id: None,
26327 context: None,
26328 exported_context: None,
26329 task_binding: None,
26330 created_at_unix_ms: 2,
26331 updated_at_unix_ms: 2,
26332 last_error: None,
26333 })
26334 .unwrap();
26335 shared.bind_managed_session(
26336 &address,
26337 current_id.clone(),
26338 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26339 None,
26340 );
26341 let event = ControlEvent {
26342 sequence: 1,
26343 command_id: None,
26344 instance_id: address.session.instance_id,
26345 generation: address.session.generation,
26346 event: ControlEventKind::ProviderEvent {
26347 sequence: 1,
26348 source: gate4agent_types::ProviderSource {
26349 family: gate4agent_types::AdapterFamily::PtySemantic,
26350 binding: gate4agent_types::AdapterBinding::new(
26351 gate4agent_types::AdapterId::new("claude").unwrap(),
26352 "fixture/v1",
26353 gate4agent_types::AdapterVerification::SyntheticFixture,
26354 )
26355 .unwrap(),
26356 },
26357 source_sequence: 1,
26358 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26359 identity,
26360 },
26361 },
26362 };
26363 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26364
26365 let binding = shared
26366 .session_bindings
26367 .lock()
26368 .unwrap_or_else(|poisoned| poisoned.into_inner())
26369 .get(&address.session.instance_id)
26370 .cloned()
26371 .unwrap();
26372 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26373 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26374 let current = shared.record(¤t_id).unwrap();
26375 assert_eq!(current.state, ManagedSessionState::Unavailable);
26376 assert_eq!(current.active_session.as_ref(), Some(&address));
26377 assert_eq!(
26378 current.last_error.as_deref(),
26379 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26380 );
26381 std::fs::remove_dir_all(secondary_root).unwrap();
26382 }
26383
26384 #[tokio::test]
26385 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26386 let root = temporary_workspace_root("semantic-provider-session");
26387 std::fs::create_dir_all(&root).unwrap();
26388 let state_path = root.join("state-v1.json");
26389 let catalog = active_registry().unwrap();
26390 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26391 let workspace = WorkspaceConfig::new(
26392 WorkspaceId::new("primary").unwrap(),
26393 &root,
26394 )
26395 .unwrap();
26396 let node_id = NodeId::new("node-semantic-identity").unwrap();
26397 let shared = NodeShared::new_with_incarnation(
26398 handle,
26399 "fixture-token".to_owned(),
26400 node_id.clone(),
26401 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26402 vec![workspace.clone()],
26403 vec![agent("claude")],
26404 ProviderRuntimeStatuses::default(),
26405 None,
26406 Vec::new(),
26407 Vec::new(),
26408 SpawnProfileRegistry::default(),
26409 None,
26410 None,
26411 None,
26412 Some(state_path.clone()),
26413 Vec::new(),
26414 Vec::new(),
26415 Vec::new(),
26416 Vec::new(),
26417 None,
26418 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26419 );
26420 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26421 key: gate4agent_types::ProviderSessionKey::SessionId,
26422 id: "provider-session".to_owned(),
26423 transcript_path: None,
26424 };
26425 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26426 shared
26427 .insert_record(ManagedSessionRecord {
26428 record_id: durable_id.clone(),
26429 display_name: "durable".to_owned(),
26430 provider: agent("claude"),
26431 mode: SessionMode::Pty,
26432 state: ManagedSessionState::Dormant,
26433 workspace_id: workspace.workspace_id.clone(),
26434 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26435 provider_session: Some(persisted_identity.clone()),
26436 active_session: None,
26437 environment_profile: None,
26438 bundle: None,
26439 context_id: None,
26440 context: None,
26441 exported_context: None,
26442 task_binding: None,
26443 created_at_unix_ms: 1,
26444 updated_at_unix_ms: 1,
26445 last_error: None,
26446 })
26447 .unwrap();
26448 let address = SessionAddress {
26449 workspace_id: workspace.workspace_id.clone(),
26450 session: SessionKey {
26451 instance_id: AgentInstanceId(92),
26452 generation: SessionGeneration(2),
26453 },
26454 };
26455 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26456 shared
26457 .insert_record(ManagedSessionRecord {
26458 record_id: pending_id.clone(),
26459 display_name: "pending".to_owned(),
26460 provider: agent("claude"),
26461 mode: SessionMode::Pty,
26462 state: ManagedSessionState::IdentityPending,
26463 workspace_id: workspace.workspace_id.clone(),
26464 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26465 provider_session: None,
26466 active_session: Some(address.clone()),
26467 environment_profile: None,
26468 bundle: None,
26469 context_id: None,
26470 context: None,
26471 exported_context: None,
26472 task_binding: None,
26473 created_at_unix_ms: 2,
26474 updated_at_unix_ms: 2,
26475 last_error: None,
26476 })
26477 .unwrap();
26478 shared.bind_managed_session(
26479 &address,
26480 pending_id.clone(),
26481 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26482 None,
26483 );
26484 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26485 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26486 ..persisted_identity
26487 };
26488 let event = ControlEvent {
26489 sequence: 1,
26490 command_id: None,
26491 instance_id: address.session.instance_id,
26492 generation: address.session.generation,
26493 event: ControlEventKind::ProviderEvent {
26494 sequence: 1,
26495 source: gate4agent_types::ProviderSource {
26496 family: gate4agent_types::AdapterFamily::PtySemantic,
26497 binding: gate4agent_types::AdapterBinding::new(
26498 gate4agent_types::AdapterId::new("claude").unwrap(),
26499 "fixture/v1",
26500 gate4agent_types::AdapterVerification::SyntheticFixture,
26501 )
26502 .unwrap(),
26503 },
26504 source_sequence: 1,
26505 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26506 identity: observed_identity.clone(),
26507 },
26508 },
26509 };
26510 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26511
26512 let snapshot = shared.snapshot();
26513 assert_eq!(snapshot.session_records.len(), 1);
26514 let live = &snapshot.session_records[0];
26515 assert_eq!(live.record_id, durable_id);
26516 assert_eq!(live.state, ManagedSessionState::Live);
26517 assert_eq!(live.active_session.as_ref(), Some(&address));
26518 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26519 let binding = shared
26520 .session_bindings
26521 .lock()
26522 .unwrap_or_else(|poisoned| poisoned.into_inner())
26523 .get(&address.session.instance_id)
26524 .cloned()
26525 .unwrap();
26526 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26527
26528 let mut duplicate = live.clone();
26529 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26530 duplicate.display_name = "duplicate".to_owned();
26531 duplicate.state = ManagedSessionState::Dormant;
26532 duplicate.active_session = None;
26533 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26534 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26535 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26536 assert_eq!(shared.snapshot().session_records.len(), 1);
26537
26538 let terminal_size = gate4agent_types::TerminalSize {
26539 rows: 24,
26540 columns: 80,
26541 };
26542 let (first_busy, second_busy) = tokio::join!(
26543 shared.resume_session_record(&durable_id, terminal_size, None),
26544 shared.resume_session_record(&durable_id, terminal_size, None),
26545 );
26546 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26547 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26548 assert_eq!(busy.message, "session-record-busy");
26549 }
26550
26551 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26552 assert_eq!(loaded.records.len(), 1);
26553 assert_eq!(loaded.records[0].record_id, durable_id);
26554 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26555 assert_eq!(
26556 loaded.records[0]
26557 .provider_session
26558 .as_ref()
26559 .unwrap()
26560 .transcript_path,
26561 None,
26562 );
26563 let catalog = active_registry().unwrap();
26564 let (reloaded_handle, _reloaded_runtime) =
26565 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26566 let reloaded = NodeShared::new_with_incarnation(
26567 reloaded_handle,
26568 "fixture-token".to_owned(),
26569 node_id,
26570 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26571 vec![workspace],
26572 vec![agent("claude")],
26573 ProviderRuntimeStatuses::default(),
26574 None,
26575 Vec::new(),
26576 Vec::new(),
26577 SpawnProfileRegistry::default(),
26578 None,
26579 None,
26580 None,
26581 None,
26582 loaded.records,
26583 loaded.managed_worktrees,
26584 loaded.managed_worktree_tombstones,
26585 loaded.materializations,
26586 loaded.warning,
26587 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26588 );
26589 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26590 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26591 std::fs::remove_dir_all(root).unwrap();
26592 }
26593
26594 #[tokio::test]
26595 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26596 let secondary_root = temporary_workspace_root("workspace-restore");
26597 let other_root = temporary_workspace_root("workspace-conflict");
26598 std::fs::create_dir_all(&secondary_root).unwrap();
26599 std::fs::create_dir_all(&other_root).unwrap();
26600 let catalog = active_registry().unwrap();
26601 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26602 let primary = WorkspaceConfig::new(
26603 WorkspaceId::new("primary").unwrap(),
26604 std::env::current_dir().unwrap(),
26605 )
26606 .unwrap();
26607 let secondary = WorkspaceConfig::new(
26608 WorkspaceId::new("secondary").unwrap(),
26609 &secondary_root,
26610 )
26611 .unwrap();
26612 let shared = NodeShared::new(
26613 handle,
26614 "fixture-token".to_owned(),
26615 NodeId::new("node-1").unwrap(),
26616 vec![primary, secondary.clone()],
26617 vec![agent("claude")],
26618 );
26619 let record_id = SessionRecordId::new("sr-workspace").unwrap();
26620 shared
26621 .insert_record(ManagedSessionRecord {
26622 record_id: record_id.clone(),
26623 display_name: "workspace record".to_owned(),
26624 provider: agent("claude"),
26625 mode: SessionMode::Pty,
26626 state: ManagedSessionState::Dormant,
26627 workspace_id: secondary.workspace_id.clone(),
26628 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26629 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26630 key: gate4agent_types::ProviderSessionKey::SessionId,
26631 id: "provider-session".to_owned(),
26632 transcript_path: None,
26633 }),
26634 active_session: None,
26635 environment_profile: None,
26636 bundle: None,
26637 context_id: None,
26638 context: None,
26639 exported_context: None,
26640 task_binding: None,
26641 created_at_unix_ms: 1,
26642 updated_at_unix_ms: 1,
26643 last_error: None,
26644 })
26645 .unwrap();
26646 shared.unregister_workspace(&secondary.workspace_id).unwrap();
26647 let unavailable = shared.record(&record_id).unwrap();
26648 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26649 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26650
26651 let conflict = shared
26652 .register_workspace(
26653 secondary.workspace_id.clone(),
26654 other_root.to_string_lossy().into_owned(),
26655 )
26656 .await
26657 .unwrap_err();
26658 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26659 shared
26660 .register_workspace(
26661 secondary.workspace_id,
26662 secondary_root.to_string_lossy().into_owned(),
26663 )
26664 .await
26665 .unwrap();
26666 let restored = shared.record(&record_id).unwrap();
26667 assert_eq!(restored.state, ManagedSessionState::Dormant);
26668 assert!(restored.last_error.is_none());
26669 std::fs::remove_dir_all(secondary_root).unwrap();
26670 std::fs::remove_dir_all(other_root).unwrap();
26671 }
26672
26673 #[test]
26674 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26675 let error = validate_workspace_request_root(
26676 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26677 )
26678 .unwrap_err();
26679 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26680 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26681 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26682 }
26683
26684 #[test]
26685 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26686 let catalog = active_registry().unwrap();
26687 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26688 let workspace = WorkspaceConfig::new(
26689 WorkspaceId::new("primary").unwrap(),
26690 std::env::current_dir().unwrap(),
26691 )
26692 .unwrap();
26693 let shared = NodeShared::new(
26694 handle,
26695 "fixture-token".to_owned(),
26696 NodeId::new("node-1").unwrap(),
26697 vec![workspace],
26698 vec![agent("claude")],
26699 );
26700 let publishers = 4_u64;
26701 let events_per_publisher = 256_u64;
26702 let start = std::sync::Barrier::new(publishers as usize + 1);
26703 let finished = AtomicU64::new(0);
26704
26705 std::thread::scope(|scope| {
26706 for _ in 0..publishers {
26707 scope.spawn(|| {
26708 start.wait();
26709 for _ in 0..events_per_publisher {
26710 shared.publish(NodeEvent::ControllerChanged { controller: None });
26711 }
26712 finished.fetch_add(1, Ordering::Release);
26713 });
26714 }
26715 start.wait();
26716 while finished.load(Ordering::Acquire) != publishers {
26717 assert_resync_history_is_linearized(shared.resync(0));
26718 std::thread::yield_now();
26719 }
26720 });
26721 let response = shared.resync(0);
26722 assert_resync_history_is_linearized(response.clone());
26723 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26724 unreachable!("resync helper returned another response");
26725 };
26726 assert_eq!(event_sequence, publishers * events_per_publisher);
26727 assert_eq!(events.len() as u64, event_sequence);
26728 }
26729
26730 fn assert_resync_history_is_linearized(response: NodeResponse) {
26731 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26732 unreachable!("resync helper returned another response");
26733 };
26734 assert!(events.windows(2).all(|pair| {
26735 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26736 }));
26737 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26738 }
26739
26740 #[test]
26741 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26742 assert_eq!(
26743 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26744 r"C:\repo\worktree",
26745 );
26746 assert_eq!(
26747 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26748 r"\\server\share\repo",
26749 );
26750 assert!(validate_workspace_root(
26751 &WorkspaceId::new("network").unwrap(),
26752 r"\\server\share\repo",
26753 )
26754 .is_err());
26755 let workspace = WorkspaceConfig::new(
26756 WorkspaceId::new("primary").unwrap(),
26757 std::env::current_dir().unwrap(),
26758 )
26759 .unwrap();
26760 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26761 }
26762
26763 #[test]
26764 fn resume_rebinds_only_on_the_expected_authorized_generation() {
26765 let catalog = active_registry().unwrap();
26766 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26767 let workspace_id = WorkspaceId::new("primary").unwrap();
26768 let workspace = WorkspaceConfig::new(
26769 workspace_id.clone(),
26770 std::env::current_dir().unwrap(),
26771 )
26772 .unwrap();
26773 let shared = NodeShared::new(
26774 handle,
26775 "fixture-token".to_owned(),
26776 NodeId::new("node-1").unwrap(),
26777 vec![workspace],
26778 vec![agent("claude")],
26779 );
26780 let instance_id = AgentInstanceId(17);
26781 let original = SessionAddress {
26782 workspace_id,
26783 session: SessionKey {
26784 instance_id,
26785 generation: SessionGeneration(3),
26786 },
26787 };
26788 shared.bind_session(
26789 instance_id,
26790 original.workspace_id.clone(),
26791 original.session.generation,
26792 );
26793 shared
26794 .arm_resume(
26795 &original,
26796 CommandId(41),
26797 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26798 )
26799 .unwrap();
26800
26801 shared.publish_control(ControlEvent {
26802 sequence: 1,
26803 command_id: None,
26804 instance_id,
26805 generation: SessionGeneration(4),
26806 event: ControlEventKind::Running { process_id: None },
26807 });
26808 assert_eq!(
26809 shared.address_for(instance_id, SessionGeneration(3)),
26810 Some(original.clone()),
26811 );
26812 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26813
26814 shared.publish_control(ControlEvent {
26815 sequence: 2,
26816 command_id: Some(CommandId(42)),
26817 instance_id,
26818 generation: SessionGeneration(3),
26819 event: ControlEventKind::CommandRejected {
26820 message: "unrelated command".to_owned(),
26821 },
26822 });
26823
26824 shared.publish_control(ControlEvent {
26825 sequence: 3,
26826 command_id: None,
26827 instance_id,
26828 generation: SessionGeneration(4),
26829 event: ControlEventKind::ResumeAuthorized {
26830 session: gate4agent_types::ResumeSessionSummary {
26831 key: gate4agent_types::ProviderSessionKey::SessionId,
26832 id: "fixture-session".to_owned(),
26833 },
26834 },
26835 });
26836 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26837 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26838 assert_eq!(rebound.workspace_id, original.workspace_id);
26839 let history = shared
26840 .history
26841 .lock()
26842 .unwrap_or_else(|poisoned| poisoned.into_inner());
26843 assert!(history.events.iter().any(|envelope| matches!(
26844 &envelope.event,
26845 NodeEvent::Control { address, event }
26846 if address == &rebound
26847 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26848 )));
26849 }
26850
26851 #[test]
26852 fn all_node_pty_prompts_use_bracketed_paste_framing() {
26853 for agent in ["claude", "codex", "kimi"] {
26854 assert_eq!(
26855 prompt_framing(&AgentId::new(agent).unwrap()),
26856 PromptFraming::BracketedPaste
26857 );
26858 }
26859 }
26860
26861 fn generous_walk_deadline() -> Instant {
26862 Instant::now() + Duration::from_secs(30)
26863 }
26864
26865 #[test]
26866 fn workspace_tree_is_relative_and_skips_heavy_directories() {
26867 let root = temporary_workspace_root("tree");
26868 std::fs::create_dir_all(root.join("src")).unwrap();
26869 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26870 std::fs::create_dir_all(root.join("target/debug")).unwrap();
26871 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26872 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26873 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26874 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26880 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26881 std::fs::create_dir_all(root.join("target-notes")).unwrap();
26885 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26886
26887 let (entries, truncated, _budget) =
26888 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26889 assert!(!truncated);
26890 assert!(entries.iter().any(|entry| {
26891 entry.relative_path.as_utf8() == Some("src")
26892 && entry.kind == WorkspaceEntryKind::Directory
26893 }));
26894 assert!(entries.iter().any(|entry| {
26895 entry.relative_path.as_utf8() == Some("src/lib.rs")
26896 && entry.kind == WorkspaceEntryKind::File
26897 }));
26898 assert!(entries.iter().any(|entry| {
26899 entry.relative_path.as_utf8() == Some("target-notes")
26900 && entry.kind == WorkspaceEntryKind::Directory
26901 }));
26902 assert!(entries.iter().any(|entry| {
26903 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26904 && entry.kind == WorkspaceEntryKind::File
26905 }));
26906 assert!(entries.iter().all(|entry| {
26907 let path = entry.relative_path.as_utf8().unwrap();
26908 !path.starts_with(".git")
26909 && !(path == "target" || path.starts_with("target/"))
26910 && !(path == "target-a2" || path.starts_with("target-a2/"))
26911 && !path.starts_with("node_modules")
26912 }));
26913
26914 std::fs::remove_dir_all(&root).unwrap();
26915 }
26916
26917 #[test]
26918 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26919 let root = temporary_workspace_root("tree-entry-cap");
26920 std::fs::create_dir_all(&root).unwrap();
26921 for index in 0..20 {
26922 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26923 }
26924
26925 let (entries, truncated, budget) =
26926 collect_workspace_entries(&root, generous_walk_deadline(), 5);
26927 assert!(truncated);
26928 assert!(budget.entry_cap_exceeded);
26929 assert!(!budget.time_budget_exceeded);
26930 assert!(entries.len() <= 5);
26931
26932 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26933 .expect("an entry-cap-truncated walk must report a truncation marker");
26934 assert!(inspection_truncation.walk_entry_cap_exceeded);
26935 assert!(!inspection_truncation.walk_time_budget_exceeded);
26936 assert!(!inspection_truncation.git_time_budget_exceeded);
26937 assert!(inspection_truncation.entries_visited >= 5);
26938
26939 std::fs::remove_dir_all(&root).unwrap();
26940 }
26941
26942 #[test]
26943 fn workspace_walk_within_budget_reports_no_truncation() {
26944 let root = temporary_workspace_root("tree-no-truncation");
26945 std::fs::create_dir_all(&root).unwrap();
26946 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26947
26948 let (_entries, truncated, budget) =
26949 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26950 assert!(!truncated);
26951 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26952
26953 std::fs::remove_dir_all(&root).unwrap();
26954 }
26955
26956 #[test]
26957 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26958 let root = temporary_workspace_root("tree-prefix-siblings");
26959 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26960 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26961 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26962 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26963
26964 let (entries, truncated, _budget) =
26965 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26966 assert!(!truncated);
26967 let paths = entries
26968 .iter()
26969 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26970 .collect::<Vec<_>>();
26971 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26972 assert_eq!(
26973 paths,
26974 vec![
26975 "gate4agent-c2".to_owned(),
26976 "gate4agent-c2-client".to_owned(),
26977 "gate4agent-c2-client/lib.rs".to_owned(),
26978 "gate4agent-c2/src".to_owned(),
26979 "gate4agent-c2/src/lib.rs".to_owned(),
26980 ],
26981 );
26982
26983 std::fs::remove_dir_all(&root).unwrap();
26984 }
26985
26986 #[test]
26987 fn git_parsers_cap_status_and_commit_results() {
26988 let mut status_output = Vec::new();
26989 for index in 0..=GIT_STATUS_MAX_ENTRIES {
26990 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26991 }
26992 let mut snapshot = git_snapshot_for_parser();
26993 parse_git_status(&status_output, &mut snapshot);
26994 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26995 assert!(snapshot.truncated);
26996 assert_eq!(snapshot.status[0].index_status, " ");
26997 assert_eq!(snapshot.status[0].worktree_status, "M");
26998
26999 snapshot.truncated = false;
27000 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27001 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27002 .collect::<String>();
27003 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27004 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27005 assert!(snapshot.truncated);
27006 }
27007
27008 #[test]
27009 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27010 let mut snapshot = git_snapshot_for_parser();
27011 parse_git_status(
27012 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27013 &mut snapshot,
27014 );
27015
27016 assert!(!snapshot.truncated);
27017 assert_eq!(snapshot.status.len(), 3);
27018 assert_eq!(snapshot.status[0].index_status, "R");
27019 assert_eq!(snapshot.status[0].worktree_status, " ");
27020 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27021 assert_eq!(
27022 snapshot.status[0]
27023 .previous_path
27024 .as_ref()
27025 .and_then(RepositoryPath::as_utf8),
27026 Some("src/previous name.rs"),
27027 );
27028 assert_eq!(snapshot.status[1].index_status, " ");
27029 assert_eq!(snapshot.status[1].worktree_status, "C");
27030 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27031 assert_eq!(
27032 snapshot.status[1]
27033 .previous_path
27034 .as_ref()
27035 .and_then(RepositoryPath::as_utf8),
27036 Some("src/original.rs"),
27037 );
27038 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27039 assert!(snapshot.status[2].previous_path.is_none());
27040 }
27041
27042 #[test]
27043 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27044 let mut snapshot = git_snapshot_for_parser();
27045 parse_git_status(
27046 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27047 &mut snapshot,
27048 );
27049
27050 assert!(snapshot.truncated);
27051 assert_eq!(snapshot.status.len(), 1);
27052 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27053 assert!(snapshot.status[0].previous_path.is_none());
27054 }
27055
27056 #[tokio::test]
27057 async fn observer_can_browse_host_directories_without_controller() {
27058 let root = temporary_workspace_root("host-directory-observer");
27059 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27060 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27061 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27062 let workspace_id = WorkspaceId::new("primary").unwrap();
27063 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27064 let catalog = active_registry().unwrap();
27065 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27066 let shared = NodeShared::new(
27067 handle,
27068 "fixture-token".to_owned(),
27069 NodeId::new("node-1").unwrap(),
27070 vec![workspace],
27071 vec![agent("claude")],
27072 );
27073 let request = NodeRequest::BrowseHostDirectories {
27074 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27075 after: None,
27076 };
27077 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27078 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27079 }));
27080 assert!(!request_uses_unnegotiated_capability(
27081 &request,
27082 &baseline_capabilities().unwrap(),
27083 ));
27084 assert!(request_uses_unnegotiated_capability(&request, &[]));
27085
27086 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27087 .await
27088 .unwrap();
27089 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27090 panic!("host directory browse returned another response");
27091 };
27092 assert_eq!(
27093 listing
27094 .entries
27095 .iter()
27096 .map(|entry| entry.display_name.as_str())
27097 .collect::<Vec<_>>(),
27098 vec!["directory-a", "directory-b"],
27099 );
27100 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27101 assert!(!listing.incomplete);
27102 std::fs::remove_dir_all(root).unwrap();
27103 }
27104
27105 #[tokio::test]
27106 async fn observer_can_inspect_only_a_registered_workspace() {
27107 let root = temporary_workspace_root("observer");
27108 std::fs::create_dir_all(root.join("src")).unwrap();
27109 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27110 let workspace_id = WorkspaceId::new("primary").unwrap();
27111 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27112 let catalog = active_registry().unwrap();
27113 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27114 let shared = NodeShared::new(
27115 handle,
27116 "fixture-token".to_owned(),
27117 NodeId::new("node-1").unwrap(),
27118 vec![workspace],
27119 vec![agent("claude")],
27120 );
27121
27122 let response = process_request_inner(
27123 &shared,
27124 77,
27125 ClientRole::Observer,
27126 NodeRequest::InspectWorkspace {
27127 workspace_id: workspace_id.clone(),
27128 },
27129 )
27130 .await
27131 .unwrap();
27132 let NodeResponse::WorkspaceInspected { inspection } = response else {
27133 panic!("workspace inspection returned another response");
27134 };
27135 assert_eq!(inspection.workspace_id, workspace_id);
27136 assert!(inspection.entries.iter().any(|entry| {
27137 entry.relative_path.as_utf8() == Some("src/main.rs")
27138 }));
27139
27140 let unknown = process_request_inner(
27141 &shared,
27142 77,
27143 ClientRole::Observer,
27144 NodeRequest::InspectWorkspace {
27145 workspace_id: WorkspaceId::new("unknown").unwrap(),
27146 },
27147 )
27148 .await
27149 .unwrap_err();
27150 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27151 std::fs::remove_dir_all(&root).unwrap();
27152 }
27153
27154 #[test]
27155 fn context_pack_source_accepts_only_running_or_successful_exit() {
27156 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27157 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27158 exit_code: Some(0),
27159 }));
27160 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27161 exit_code: Some(1),
27162 }));
27163 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27164 exit_code: None,
27165 }));
27166 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27167 message: "provider failed".to_owned(),
27168 }));
27169 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27170 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27171 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27172 }
27173
27174 #[test]
27175 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27176 let git = git_snapshot_for_parser();
27177 let first = ContextPackRepositoryObservation {
27178 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27179 git: git.clone(),
27180 files: Vec::new(),
27181 };
27182 let second = ContextPackRepositoryObservation {
27183 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27184 git,
27185 files: Vec::new(),
27186 };
27187
27188 let error = stable_context_pack_repository(first, second).unwrap_err();
27189
27190 assert_eq!(
27191 error.code,
27192 NodeFailureCode::ContextPackMaterializationFailed,
27193 );
27194 }
27195
27196 #[test]
27197 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27198 let git = git_snapshot_for_parser();
27199 let first = ContextPackRepositoryObservation {
27200 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27201 git: git.clone(),
27202 files: vec![ContextPackRepositoryFileSource::utf8(
27203 "README.md",
27204 "first\n".to_owned(),
27205 6,
27206 )],
27207 };
27208 let second = ContextPackRepositoryObservation {
27209 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27210 git,
27211 files: vec![ContextPackRepositoryFileSource::utf8(
27212 "README.md",
27213 "other\n".to_owned(),
27214 6,
27215 )],
27216 };
27217
27218 let error = stable_context_pack_repository(first, second).unwrap_err();
27219
27220 assert_eq!(
27221 error.code,
27222 NodeFailureCode::ContextPackMaterializationFailed,
27223 );
27224 }
27225
27226 #[tokio::test]
27227 async fn native_session_catalog_zero_active_session_fixture() {
27228 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27229 let history_root = temporary_workspace_root("native-catalog-history");
27230 let outside_root = temporary_workspace_root("native-catalog-outside");
27231 std::fs::create_dir_all(&workspace_root).unwrap();
27232 std::fs::create_dir_all(&history_root).unwrap();
27233 std::fs::create_dir_all(&outside_root).unwrap();
27234 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27235 [
27236 serde_json::json!({
27237 "type": "user",
27238 "sessionId": session_id,
27239 "cwd": cwd.to_str().unwrap(),
27240 "message": { "content": "raw user text must not cross the catalog wire" }
27241 }),
27242 serde_json::json!({
27243 "type": "assistant",
27244 "sessionId": session_id,
27245 "cwd": cwd.to_str().unwrap(),
27246 "message": { "model": "claude-sonnet", "content": "raw answer" }
27247 }),
27248 serde_json::json!({
27249 "type": "ai-title",
27250 "sessionId": session_id,
27251 "aiTitle": title
27252 }),
27253 ]
27254 .into_iter()
27255 .map(|record| record.to_string())
27256 .collect::<Vec<_>>()
27257 .join("\n")
27258 };
27259 std::fs::write(
27260 history_root.join("stable-session.jsonl"),
27261 transcript("stable-session", &workspace_root, "Workspace review"),
27262 )
27263 .unwrap();
27264 std::fs::write(
27265 history_root.join("outside-session.jsonl"),
27266 transcript("outside-session", &outside_root, "Outside review"),
27267 )
27268 .unwrap();
27269 let history = NativeHistoryConfig::new(vec![
27270 NativeHistoryRoot::new(
27271 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27272 HistorySourceLayout::SingleNdjson,
27273 history_root.clone(),
27274 )
27275 .unwrap(),
27276 ])
27277 .unwrap();
27278 let catalog = active_registry().unwrap();
27279 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27280 assert!(handle.snapshot().sessions.is_empty());
27281 let workspace_id = WorkspaceId::new("primary").unwrap();
27282 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27283 let mut shared = NodeShared::new(
27284 handle,
27285 "fixture-token".to_owned(),
27286 NodeId::new("node-native-catalog").unwrap(),
27287 vec![workspace],
27288 vec![agent("claude")],
27289 );
27290 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27291 NativeSessionCatalogAuthority::new(history),
27292 )));
27293 let route = NativeSessionCatalogRoute::workspace(
27294 workspace_id.clone(),
27295 agent("claude"),
27296 );
27297 let request = NodeRequest::CatalogNativeSessions {
27298 route: route.clone(),
27299 limit: 2,
27300 };
27301 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27302 .await
27303 .unwrap_err();
27304 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27305 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27306 .await
27307 .unwrap();
27308 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27309 panic!("native catalog returned a different response");
27310 };
27311 let summary = summary.expect("node must emit native session catalog summary metadata");
27312 assert_eq!(entries.len(), 1);
27313 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27314 assert_eq!(entries[0].external_group, None);
27315 assert_eq!(entries[0].record_id, None);
27316 assert_eq!(entries[0].title, None);
27317 assert_eq!(entries[0].model, None);
27318 assert_eq!(entries[0].message_count, 0);
27319 let encoded_entries = serde_json::to_string(&entries).unwrap();
27320 assert!(!encoded_entries.contains("stable-session"));
27321 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27322 let page_request = NodeRequest::PageNativeSessions {
27323 route: route.clone(),
27324 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27325 catalog_revision: summary.catalog_revision,
27326 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27327 after_selection_id: None,
27328 limit: 2,
27329 };
27330 let observer = process_request_inner(
27331 &shared,
27332 7,
27333 ClientRole::Observer,
27334 page_request.clone(),
27335 )
27336 .await
27337 .unwrap_err();
27338 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27339 let page_response = process_request_inner(
27340 &shared,
27341 7,
27342 ClientRole::Operator,
27343 page_request.clone(),
27344 )
27345 .await
27346 .unwrap();
27347 assert!(matches!(
27348 page_response,
27349 NodeResponse::NativeSessionsPaged { page, .. }
27350 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27351 ));
27352 let stale = process_request_inner(
27353 &shared,
27354 7,
27355 ClientRole::Operator,
27356 NodeRequest::PageNativeSessions {
27357 route: route.clone(),
27358 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27359 catalog_revision: summary.catalog_revision.wrapping_add(1),
27360 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27361 after_selection_id: None,
27362 limit: 2,
27363 },
27364 )
27365 .await
27366 .unwrap_err();
27367 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27368 let selection = NativeSessionSelection {
27369 route: route.clone(),
27370 catalog_revision: summary.catalog_revision,
27371 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27372 selection_id: entries[0].selection_id.clone(),
27373 };
27374 let preview_response = process_request_inner(
27375 &shared,
27376 7,
27377 ClientRole::Operator,
27378 NodeRequest::PreviewNativeSession {
27379 selection: selection.clone(),
27380 message_limit: 1,
27381 },
27382 )
27383 .await
27384 .unwrap();
27385 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27386 panic!("native preview returned a different response");
27387 };
27388 assert_eq!(preview.messages.len(), 1);
27389 assert!(preview.message_count_exact);
27390 assert_eq!(preview.messages[0].text, "raw answer");
27391 assert!(preview.truncated);
27392
27393 let index_request = NodeRequest::IndexNativeSession {
27394 selection: selection.clone(),
27395 display_name: "Saved Claude".to_owned(),
27396 };
27397 let observer = process_request_inner(
27398 &shared,
27399 7,
27400 ClientRole::Observer,
27401 index_request.clone(),
27402 )
27403 .await
27404 .unwrap_err();
27405 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27406 let without_controller = process_request_inner(
27407 &shared,
27408 7,
27409 ClientRole::Operator,
27410 index_request.clone(),
27411 )
27412 .await
27413 .unwrap_err();
27414 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27415 let acquired = process_request_inner(
27416 &shared,
27417 7,
27418 ClientRole::Operator,
27419 NodeRequest::AcquireController { lease_ms: 5_000 },
27420 )
27421 .await
27422 .unwrap();
27423 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27424 let indexed = process_request_inner(
27425 &shared,
27426 7,
27427 ClientRole::Operator,
27428 index_request,
27429 )
27430 .await
27431 .unwrap();
27432 assert!(indexed.native_session_index_contract_is_valid());
27433 let NodeResponse::NativeSessionIndexed {
27434 selection: echoed_selection,
27435 record,
27436 } = indexed else {
27437 panic!("native session index returned a different response");
27438 };
27439 assert_eq!(echoed_selection, selection);
27440 let recataloged = process_request_inner(
27441 &shared,
27442 7,
27443 ClientRole::Operator,
27444 NodeRequest::CatalogNativeSessions {
27445 route,
27446 limit: 2,
27447 },
27448 )
27449 .await
27450 .unwrap();
27451 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27452 panic!("native catalog returned a different response after indexing");
27453 };
27454 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27455
27456 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27457 let external_catalog = process_request_inner(
27458 &shared,
27459 7,
27460 ClientRole::Operator,
27461 NodeRequest::CatalogNativeSessions {
27462 route: unregistered_route.clone(),
27463 limit: 2,
27464 },
27465 )
27466 .await
27467 .unwrap();
27468 let external_json = serde_json::to_string(&external_catalog).unwrap();
27469 assert!(!external_json.contains("outside-session"));
27470 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27471 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27472 assert!(!external_json.contains("\"session_id\""));
27473 assert!(!external_json.contains("\"cwd\""));
27474 assert!(!external_json.contains("\"path\""));
27475 let NodeResponse::NativeSessionsCataloged {
27476 route: echoed_route,
27477 entries: external_entries,
27478 summary: Some(external_summary),
27479 } = external_catalog
27480 else {
27481 panic!("unregistered native catalog returned a different response");
27482 };
27483 assert_eq!(echoed_route, unregistered_route);
27484 assert_eq!(external_entries.len(), 1);
27485 assert_eq!(external_entries[0].record_id, None);
27486 let external_group = external_entries[0]
27487 .external_group
27488 .as_ref()
27489 .expect("unregistered session must have an external group");
27490 assert_eq!(
27491 external_group.kind,
27492 gate4agent_types::NativeSessionExternalGroupKind::Project,
27493 );
27494 assert!(external_group.group_id.starts_with("external-"));
27495 assert_eq!(
27496 external_group.display_name,
27497 outside_root.file_name().unwrap().to_string_lossy(),
27498 );
27499 let external_selection = NativeSessionSelection {
27500 route: unregistered_route,
27501 catalog_revision: external_summary.catalog_revision,
27502 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27503 selection_id: external_entries[0].selection_id.clone(),
27504 };
27505 let external_preview = process_request_inner(
27506 &shared,
27507 7,
27508 ClientRole::Operator,
27509 NodeRequest::PreviewNativeSession {
27510 selection: external_selection.clone(),
27511 message_limit: 1,
27512 },
27513 )
27514 .await
27515 .unwrap();
27516 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27517 assert!(!external_preview_json.contains("outside-session"));
27518 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27519 assert!(!external_preview_json.contains("\"session_id\""));
27520 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27521 panic!("unregistered native preview returned a different response");
27522 };
27523 assert_eq!(preview.messages.len(), 1);
27524 let external_index = process_request_inner(
27525 &shared,
27526 7,
27527 ClientRole::Operator,
27528 NodeRequest::IndexNativeSession {
27529 selection: external_selection,
27530 display_name: "External Claude".to_owned(),
27531 },
27532 )
27533 .await
27534 .unwrap_err();
27535 assert_eq!(
27536 external_index.code,
27537 NodeFailureCode::WorkspaceRegistrationRequired,
27538 );
27539 let record_response = process_request_inner(
27540 &shared,
27541 7,
27542 ClientRole::Operator,
27543 NodeRequest::PreviewSessionRecord {
27544 record_id: record.record_id.clone(),
27545 message_limit: 2,
27546 },
27547 )
27548 .await
27549 .unwrap();
27550 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27551 panic!("record preview returned a different response");
27552 };
27553 assert_eq!(record_id, record.record_id);
27554 assert_eq!(preview.messages.len(), 2);
27555 let encoded = serde_json::to_string(&preview).unwrap();
27556 assert!(!encoded.contains("stable-session"));
27557 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27558 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27559 panic!("record preview history summary returned a different response");
27560 };
27561 let summaries = events
27562 .iter()
27563 .filter_map(|envelope| match &envelope.event {
27564 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27565 if record_id == &record.record_id =>
27566 {
27567 Some(*summary)
27568 }
27569 _ => None,
27570 })
27571 .collect::<Vec<_>>();
27572 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27573 let summary_json = serde_json::to_string(&summaries).unwrap();
27574 for private in [
27575 "stable-session",
27576 "raw user text must not cross the catalog wire",
27577 "raw answer",
27578 "Workspace review",
27579 "claude-sonnet",
27580 ] {
27581 assert!(!summary_json.contains(private), "{private}");
27582 }
27583 let identity = record.provider_session.clone().unwrap();
27584 assert_eq!(
27585 shared.revalidate_session_record_preview(&record, &identity),
27586 Ok(())
27587 );
27588 shared.remove_record_memory(&record.record_id);
27589 assert_eq!(
27590 shared
27591 .revalidate_session_record_preview(&record, &identity)
27592 .unwrap_err()
27593 .code,
27594 NodeFailureCode::UnknownSessionRecord
27595 );
27596 let released = process_request_inner(
27597 &shared,
27598 7,
27599 ClientRole::Operator,
27600 NodeRequest::ReleaseController,
27601 )
27602 .await
27603 .unwrap();
27604 assert!(matches!(released, NodeResponse::Controller { controller: None }));
27605 assert!(shared.controller_state().is_none());
27606 std::fs::remove_dir_all(workspace_root).unwrap();
27607 std::fs::remove_dir_all(history_root).unwrap();
27608 std::fs::remove_dir_all(outside_root).unwrap();
27609 }
27610
27611 #[tokio::test]
27612 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27613 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27614 std::fs::create_dir_all(&history_root).unwrap();
27615 let history = NativeHistoryConfig::new(vec![
27616 NativeHistoryRoot::new(
27617 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27618 HistorySourceLayout::SingleNdjson,
27619 history_root.clone(),
27620 )
27621 .unwrap(),
27622 ])
27623 .unwrap();
27624 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27625 let held = catalog.lock().unwrap();
27626 let result = timeout(
27627 Duration::from_secs(1),
27628 run_native_session_catalog_operation(
27629 Arc::clone(&catalog),
27630 "native session catalog",
27631 |_| Ok::<(), std::convert::Infallible>(()),
27632 ),
27633 )
27634 .await
27635 .expect("concurrent native catalog request must not wait for the operation deadline")
27636 .unwrap_err();
27637 assert_eq!(result.code, NodeFailureCode::BackendBusy);
27638 drop(held);
27639 std::fs::remove_dir_all(history_root).unwrap();
27640 }
27641
27642 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27643 let unique = std::time::SystemTime::now()
27644 .duration_since(std::time::UNIX_EPOCH)
27645 .unwrap()
27646 .as_nanos();
27647 std::env::temp_dir().join(format!(
27648 "gate4agent-node-{label}-{}-{unique}",
27649 std::process::id(),
27650 ))
27651 }
27652
27653 fn git_snapshot_for_parser() -> GitSnapshot {
27654 GitSnapshot {
27655 is_repository: true,
27656 branch: Some("main".to_owned()),
27657 status: Vec::new(),
27658 recent_commits: Vec::new(),
27659 worktrees: Vec::new(),
27660 managed_worktree: None,
27661 truncated: false,
27662 diagnostic: None,
27663 }
27664 }
27665
27666 #[tokio::test]
27667 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27668 let root = temporary_workspace_root("managed-git-scope");
27669 std::fs::create_dir_all(&root).unwrap();
27670 run_test_git(&root, &["init"]);
27671 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27672 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27673 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27674 run_test_git(&root, &["add", "seed.txt"]);
27675 run_test_git(&root, &["commit", "-m", "seed"]);
27676 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27677
27678 let workspace_id = WorkspaceId::new("managed-a").unwrap();
27679 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27680 let catalog = active_registry().unwrap();
27681 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27682 let shared = NodeShared::new(
27683 handle,
27684 "fixture-token".to_owned(),
27685 NodeId::new("node-1").unwrap(),
27686 vec![workspace],
27687 vec![agent("claude")],
27688 );
27689 let mut lease = ManagedWorktreeLeaseRecord {
27690 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27691 source_workspace_id: WorkspaceId::new("primary").unwrap(),
27692 workspace_id: workspace_id.clone(),
27693 profile_id: WorktreeProfileId::new("default").unwrap(),
27694 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27695 target_root: root.to_string_lossy().into_owned(),
27696 branch: "gate4agent/a".to_owned(),
27697 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27698 expected_head: None,
27699 retention: ManagedWorktreeRetention::Retain,
27700 state: ManagedWorktreeLeaseState::InUse,
27701 session_holders: vec![ManagedWorktreeSessionHolder {
27702 incarnation_id: shared.incarnation_id,
27703 instance_id: AgentInstanceId(7),
27704 generation: SessionGeneration(1),
27705 }],
27706 record_holders: Vec::new(),
27707 cleanup_failure: None,
27708 created_at_unix_ms: 1,
27709 updated_at_unix_ms: 1,
27710 };
27711 *shared
27712 .managed_worktrees
27713 .lock()
27714 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27715 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27716
27717 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27718 let scope = inspection.git.managed_worktree.unwrap();
27719 assert_eq!(scope.lease_id, lease.lease_id);
27720 assert_eq!(scope.branch, "gate4agent/a");
27721 assert_eq!(scope.active_session_count, 1);
27722
27723 lease.branch = "gate4agent/b".to_owned();
27724 *shared
27725 .managed_worktrees
27726 .lock()
27727 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27728 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27729 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27730 assert!(mismatch.git.managed_worktree.is_none());
27731 std::fs::remove_dir_all(root).unwrap();
27732 }
27733
27734 #[tokio::test]
27735 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27736 let root = temporary_workspace_root("workspace-file-write-controller");
27737 std::fs::create_dir_all(root.join("src")).unwrap();
27738 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27739 let workspace_id = WorkspaceId::new("primary").unwrap();
27740 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27741 let catalog = active_registry().unwrap();
27742 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27743 let shared = NodeShared::new(
27744 handle,
27745 "fixture-token".to_owned(),
27746 NodeId::new("node-1").unwrap(),
27747 vec![workspace],
27748 vec![agent("claude")],
27749 );
27750 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27751 let expected_revision = workspace_file_revision(b"old\n");
27752 let request = NodeRequest::WriteWorkspaceFile {
27753 workspace_id: workspace_id.clone(),
27754 path: path.clone(),
27755 expected_revision: expected_revision.clone(),
27756 text: "new\n".to_owned(),
27757 };
27758 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27759 .await
27760 .unwrap_err();
27761 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27762 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27763 .await
27764 .unwrap_err();
27765 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27766 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27767 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27768 let NodeResponse::WorkspaceFileWritten { file } = response else {
27769 panic!("workspace file write returned another response");
27770 };
27771 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27772 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27773 let stale = process_request_inner(
27774 &shared,
27775 77,
27776 ClientRole::Operator,
27777 NodeRequest::WriteWorkspaceFile {
27778 workspace_id,
27779 path,
27780 expected_revision,
27781 text: "lost update\n".to_owned(),
27782 },
27783 )
27784 .await
27785 .unwrap_err();
27786 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27787 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27788 std::fs::remove_dir_all(root).unwrap();
27789 }
27790
27791 #[tokio::test]
27792 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27793 let root = temporary_workspace_root("workspace-entry-create-controller");
27794 std::fs::create_dir_all(root.join("src")).unwrap();
27795 let workspace_id = WorkspaceId::new("primary").unwrap();
27796 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27797 let catalog = active_registry().unwrap();
27798 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27799 let shared = NodeShared::new(
27800 handle,
27801 "fixture-token".to_owned(),
27802 NodeId::new("node-1").unwrap(),
27803 vec![workspace],
27804 vec![agent("claude")],
27805 );
27806 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27807 let request = NodeRequest::CreateWorkspaceFile {
27808 workspace_id: workspace_id.clone(),
27809 path: path.clone(),
27810 };
27811 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27812 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27813 }));
27814 assert!(!request_uses_unnegotiated_capability(
27815 &request,
27816 &baseline_capabilities().unwrap(),
27817 ));
27818 assert!(request_uses_unnegotiated_capability(&request, &[]));
27819 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27820 .await
27821 .unwrap_err();
27822 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27823 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27824 .await
27825 .unwrap_err();
27826 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27827 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27828
27829 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27830 .await
27831 .unwrap();
27832 let NodeResponse::WorkspaceFileCreated { file } = response else {
27833 panic!("workspace file create returned another response");
27834 };
27835 assert_eq!(file.workspace_id, workspace_id);
27836 assert_eq!(file.path, path);
27837 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27838 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27839
27840 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27841 .await
27842 .unwrap_err();
27843 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27844
27845 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27846 let response = process_request_inner(
27847 &shared,
27848 77,
27849 ClientRole::Operator,
27850 NodeRequest::CreateWorkspaceDirectory {
27851 workspace_id: workspace_id.clone(),
27852 path: directory_path.clone(),
27853 },
27854 )
27855 .await
27856 .unwrap();
27857 assert_eq!(
27858 response,
27859 NodeResponse::WorkspaceDirectoryCreated {
27860 workspace_id: workspace_id.clone(),
27861 entry: WorkspaceEntry {
27862 relative_path: directory_path,
27863 kind: WorkspaceEntryKind::Directory,
27864 },
27865 },
27866 );
27867 assert!(root.join("src/nested").is_dir());
27868
27869 let missing_parent = process_request_inner(
27870 &shared,
27871 77,
27872 ClientRole::Operator,
27873 NodeRequest::CreateWorkspaceFile {
27874 workspace_id: workspace_id.clone(),
27875 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27876 },
27877 )
27878 .await
27879 .unwrap_err();
27880 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27881 let non_directory_parent = process_request_inner(
27882 &shared,
27883 77,
27884 ClientRole::Operator,
27885 NodeRequest::CreateWorkspaceFile {
27886 workspace_id,
27887 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27888 },
27889 )
27890 .await
27891 .unwrap_err();
27892 assert_eq!(
27893 non_directory_parent.code,
27894 NodeFailureCode::RepositoryParentNotDirectory,
27895 );
27896 std::fs::remove_dir_all(root).unwrap();
27897 }
27898
27899 #[tokio::test]
27900 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27901 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27902 std::fs::create_dir_all(root.join("src")).unwrap();
27903 #[cfg(windows)]
27904 let root = std::fs::canonicalize(root).unwrap();
27905 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27906 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27907 let barrier = Arc::new(std::sync::Barrier::new(2));
27908 let done = Arc::new(AtomicBool::new(false));
27909 let task_commit_state = Arc::clone(&commit_state);
27910 let task_barrier = Arc::clone(&barrier);
27911 let task_done = Arc::clone(&done);
27912 let task_root = root.clone();
27913 let task = tokio::task::spawn_blocking(move || {
27914 task_barrier.wait();
27915 let result = create_workspace_file_on_disk(
27916 &task_root,
27917 &path,
27918 &task_commit_state,
27919 );
27920 task_done.store(true, Ordering::Release);
27921 result
27922 });
27923
27924 let error = settle_workspace_entry_create(
27925 task,
27926 Arc::clone(&commit_state),
27927 Duration::from_millis(10),
27928 "fixture deadline",
27929 "fixture task failed",
27930 )
27931 .await
27932 .unwrap_err();
27933 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27934 assert_eq!(
27935 commit_state.load(Ordering::Acquire),
27936 WORKSPACE_ENTRY_CREATE_CANCELED,
27937 );
27938
27939 barrier.wait();
27940 timeout(Duration::from_secs(1), async {
27941 while !done.load(Ordering::Acquire) {
27942 tokio::task::yield_now().await;
27943 }
27944 })
27945 .await
27946 .expect("canceled blocking create did not settle");
27947 assert!(!root.join("src/late.rs").exists());
27948 std::fs::remove_dir_all(root).unwrap();
27949 }
27950}
27951
27952#[cfg(test)]
27953mod provisional_spawn_tests {
27954 use super::*;
27955 use std::sync::Arc;
27956
27957 fn shared() -> NodeShared {
27958 let catalog = active_registry().unwrap();
27959 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27960 let workspace = WorkspaceConfig::new(
27961 WorkspaceId::new("primary").unwrap(),
27962 std::env::current_dir().unwrap(),
27963 )
27964 .unwrap()
27965 .with_worktree_service_mode(WorktreeServiceMode::Manual);
27966 NodeShared::new(
27967 handle,
27968 "fixture-token".to_owned(),
27969 NodeId::new("node-provisional-test").unwrap(),
27970 vec![workspace],
27971 vec![AgentId::new("claude").unwrap()],
27972 )
27973 }
27974
27975 #[test]
27978 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27979 let shared = shared();
27980 let ws = WorkspaceId::new("primary").unwrap();
27981 let mut ids = Vec::with_capacity(50);
27982 for i in 1u64..=50 {
27983 let id = AgentInstanceId(i);
27984 ids.push(id);
27985 let address = SessionAddress {
27986 workspace_id: ws.clone(),
27987 session: SessionKey {
27988 instance_id: id,
27989 generation: SessionGeneration(1),
27990 },
27991 };
27992 shared.mark_spawn_provisional(address);
27993 }
27994 assert_eq!(
27995 shared
27996 .provisional_spawns
27997 .lock()
27998 .unwrap_or_else(|p| p.into_inner())
27999 .len(),
28000 50
28001 );
28002 for id in ids.iter().step_by(2) {
28003 shared.sessionize_spawn(*id);
28004 }
28005 assert_eq!(
28006 shared
28007 .provisional_spawns
28008 .lock()
28009 .unwrap_or_else(|p| p.into_inner())
28010 .len(),
28011 25
28012 );
28013 assert!(shared.take_due_provisional_spawns().is_empty());
28014 {
28015 let mut map = shared
28016 .provisional_spawns
28017 .lock()
28018 .unwrap_or_else(|p| p.into_inner());
28019 let past = Instant::now() - Duration::from_secs(30);
28020 for (_, (_, at)) in map.iter_mut() {
28021 *at = past;
28022 }
28023 }
28024 let due = shared.take_due_provisional_spawns();
28025 assert_eq!(due.len(), 25);
28026 let due_ids: std::collections::BTreeSet<_> =
28027 due.iter().map(|a| a.session.instance_id.0).collect();
28028 let expected: std::collections::BTreeSet<_> =
28029 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28030 assert_eq!(due_ids, expected);
28031 assert!(shared
28032 .provisional_spawns
28033 .lock()
28034 .unwrap_or_else(|p| p.into_inner())
28035 .is_empty());
28036 }
28037
28038 #[test]
28041 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28042 let shared = Arc::new(shared());
28043 let ws = WorkspaceId::new("primary").unwrap();
28044 let mut handles = Vec::new();
28045 for i in 1u64..=20 {
28046 let shared = Arc::clone(&shared);
28047 let ws = ws.clone();
28048 handles.push(std::thread::spawn(move || {
28049 let id = AgentInstanceId(i);
28050 let address = SessionAddress {
28051 workspace_id: ws,
28052 session: SessionKey {
28053 instance_id: id,
28054 generation: SessionGeneration(1),
28055 },
28056 };
28057 shared.mark_spawn_provisional(address);
28058 id
28059 }));
28060 }
28061 let mut ids = Vec::new();
28062 for h in handles {
28063 ids.push(h.join().unwrap());
28064 }
28065 ids.sort_by_key(|id| id.0);
28066 assert_eq!(ids.len(), 20);
28067 ids.dedup();
28068 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28069 assert_eq!(
28070 shared
28071 .provisional_spawns
28072 .lock()
28073 .unwrap_or_else(|p| p.into_inner())
28074 .len(),
28075 20
28076 );
28077 shared.sessionize_spawn(AgentInstanceId(7));
28078 let left: Vec<_> = shared
28079 .provisional_spawns
28080 .lock()
28081 .unwrap_or_else(|p| p.into_inner())
28082 .keys()
28083 .map(|id| id.0)
28084 .collect();
28085 assert!(!left.contains(&7));
28086 assert_eq!(left.len(), 19);
28087 }
28088}
28089
28090
28091#[cfg(unix)]
28092extern "C" {
28093 #[link_name = "geteuid"]
28094 fn libc_geteuid() -> u32;
28095}
28096
28097#[cfg(test)]
28098mod call_home_link_tests {
28099 use super::*;
28100
28101 #[test]
28102 fn call_home_backoff_is_capped_and_monotonic() {
28103 let mut last = Duration::ZERO;
28104 for attempt in 1..20 {
28105 let next = call_home_backoff(attempt);
28106 assert!(next >= last);
28107 assert!(next <= Duration::from_secs(8));
28108 last = next;
28109 }
28110 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28111 }
28112
28113 #[tokio::test]
28114 async fn call_home_socket_gets_keepalive() {
28115 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28116 let addr = listener.local_addr().unwrap();
28117 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28118 let client = client.unwrap();
28119 enable_call_home_keepalive(&client).unwrap();
28120 let sock = socket2::SockRef::from(&client);
28121 assert!(sock.keepalive().unwrap());
28122 #[cfg(any(target_os = "linux", target_os = "macos"))]
28123 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28124 }
28125}