1use crate::git_worktree::{
2 create_worktree as create_git_worktree,
3 create_worktree_with_timeout as create_git_worktree_with_timeout,
4 list_worktrees as list_git_worktrees,
5 list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6 paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7 removal_lookup_path as normalize_worktree_removal_target,
8 resolve_base_commit_with_timeout,
9 run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10 NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13 browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16 EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22 bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25 ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26 ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29 HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30 ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33 MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34 MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35 SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38 exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39 ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42 self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43 ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44 MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48 create_workspace_directory as create_workspace_directory_on_disk,
49 create_workspace_file as create_workspace_file_on_disk,
50 read_workspace_file as read_workspace_file_from_disk,
51 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54 WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58 create_workspace_directory as create_workspace_directory_on_disk,
59 create_workspace_file as create_workspace_file_on_disk,
60 read_workspace_file as read_workspace_file_from_disk,
61 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64 WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68 require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69 ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73 prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76 read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77 validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78 provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79 AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80 CapabilityId, ClientFrame, ClientRole,
81 ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82 DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83 AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84 AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85 GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86 GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87 GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88 HostDirectoryListing,
89 NodeCompatibilitySupport, NodeEvent,
90 NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91 NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92 ProtocolRange,
93 ProviderAdapterContractSupport,
94 ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95 ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96 ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97 ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98 ResolvedSpawnSpec, ResponseEnvelope,
99 ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100 ManagedSessionRecord,
101 ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102 SessionRecordRetentionConfig,
103 ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104 ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105 ManagedWorktreeSpawnRequestV2, SessionRecordId,
106 NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107 SessionTaskTargetV1, TaskId,
108 BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109 StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110 SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111 SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112 WorkspaceFileContent, WorkspaceFileRevision,
113 WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114 WorkspaceSnapshot,
115 WorktreeServiceMode,
116 LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117 WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118 DEFAULT_CONTROLLER_LEASE_MS,
119 MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122 NODE_PROVIDER_ID_OPEN_CAPABILITY,
123 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125 NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126 NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127 NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129 NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130 NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136 NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138 NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139 NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140 NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141 NODE_WORKTREE_SELECTION_CAPABILITY,
142 BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144 MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145 MAX_REPOSITORY_PATH_BYTES,
146 MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147 MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148 MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150 SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151 SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157 interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158 truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163 NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164 NativeLaunchEnvironmentOverlay,
165 NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166 NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167 ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176 auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177 AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180 AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181 AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182 DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183 CommandEnvelope, CommandId,
184 validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185 HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186 HostDecisionAuthority as ProviderHostDecisionAuthority,
187 HostRequestDecision as ProviderHostRequestDecision,
188 InputAction,
189 PromptFraming, PromptPayload, ResumeLaunchRequest,
190 ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191 ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192 ProviderSnapshot, ProviderStopReason,
193 ResumeTarget,
194 SessionGeneration, StartRequest,
195 TerminalControl, TerminalText,
196 SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197 HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod backend_paths;
213pub mod node_wake;
214
215tokio::task_local! {
216 static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
219}
220
221fn spawn_selection(
223 options: &gate4agent_node_protocol::SpawnSessionOptions,
224) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
225 options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
226 match (&options.model, &options.effort) {
227 (None, None) => Ok(None),
228 (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
229 (Some(model), effort) => {
230 let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
231 if let Some(e) = effort {
232 sel = sel.with_value("effort", e.clone());
233 }
234 Ok(Some(sel))
235 }
236 }
237}
238
239mod browser_bridge;
240
241use gate4agent_node_core::drive_loop_profile;
242
243#[path = "bundle_delivery.rs"]
244mod bundle_delivery;
245
246#[path = "context_pack_store.rs"]
247mod context_pack_store;
248
249#[cfg(windows)]
250pub use crate::platform::DEFAULT_NODE_ENDPOINT;
251
252const NODE_EVENT_HISTORY_MAX: usize = 4_096;
253const NODE_BROADCAST_CAPACITY: usize = 1_024;
254const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
255const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
261const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
268const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
269const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
270const MAX_PREAUTH_CONNECTIONS: usize = 32;
271const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
272const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
273const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
274const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
275const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
276const PROVISIONAL_SPAWN_GRACE_MS: u64 = 30_000;
282const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
290const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
298const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
305const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
310const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
311const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
312const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
313const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
314const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
315const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
316const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
317const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
318const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
319const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
320const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
321const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
322const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
325const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
332const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
335const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
336const GIT_STATUS_MAX_ENTRIES: usize = 128;
337const GIT_COMMIT_MAX_ENTRIES: usize = 12;
338const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
339const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
340const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
341const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
342const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
343const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
344const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
345const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
346const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
347const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
348 "provider-session-identity-allocation-failed";
349const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
350const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
351const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
352const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
353const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
354const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
355const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
356 "durable-state-path-semantics-unsupported";
357
358fn opaque_windows_path(value: String) -> OpaqueHostPath {
359 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
360}
361
362fn agent_progress_from_provider_snapshot(
363 address: SessionAddress,
364 provider: &ProviderSnapshot,
365) -> Option<SessionAgentProgress> {
366 let mut truncated = false;
367 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
368 let mut active_tool_labels = Vec::with_capacity(
369 provider
370 .active_tools
371 .len()
372 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
373 );
374 for tool in &provider.active_tools {
375 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
376 truncated = true;
377 break;
378 }
379 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
380 active_tool_labels.push(label);
381 }
382 }
383 let attention = provider
384 .interactions
385 .iter()
386 .find(|interaction| {
387 matches!(
388 interaction.status,
389 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
390 )
391 })
392 .map(|interaction| AgentProgressAttentionV1 {
393 kind: match interaction.interaction_kind {
394 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
395 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
396 },
397 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
398 });
399 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
400 let usage = (provider.completed_turns > 0
401 || provider.usage.input_tokens > 0
402 || provider.usage.output_tokens > 0
403 || provider.usage.cache_read_tokens > 0
404 || provider.usage.cache_write_tokens > 0
405 || provider.usage.reasoning_tokens > 0)
406 .then_some(AgentProgressUsageV1 {
407 input_tokens: provider.usage.input_tokens,
408 output_tokens: provider.usage.output_tokens,
409 cache_read_tokens: provider.usage.cache_read_tokens,
410 cache_write_tokens: provider.usage.cache_write_tokens,
411 reasoning_tokens: provider.usage.reasoning_tokens,
412 });
413 let progress = AgentProgressV1 {
414 provider_sequence: provider.sequence,
415 activity: provider.activity,
416 completed_turns: provider.completed_turns,
417 usage,
418 current: AgentProgressCurrentV1::from(provider.activity),
419 active_tool_labels,
420 active_tool_count,
421 attention,
422 subagent_count,
423 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
424 gap_count: provider.gap_count,
425 stale: provider.stale,
426 truncated,
427 };
428 let entry = SessionAgentProgress { address, progress };
429 serde_json::to_vec(&entry)
430 .ok()
431 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
432 .map(|_| entry)
433}
434
435fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
436 match u32::try_from(count) {
437 Ok(count) => count,
438 Err(_) => {
439 *truncated = true;
440 u32::MAX
441 }
442 }
443}
444
445fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
446 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
447 return None;
448 }
449 Some(match event {
450 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
451 ProviderEvent::SessionIdentityObserved { .. } => {
452 AgentProgressEventKindV1::SessionIdentityObserved
453 }
454 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
455 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
456 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
457 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
458 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
459 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
460 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
461 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
462 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
463 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
464 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
465 ProviderEvent::InteractionRequested { .. } => {
466 AgentProgressEventKindV1::InteractionRequested
467 }
468 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
469 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
470 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
471 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
472 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
473 ProviderEvent::UnrecognizedNotification { .. } => {
474 AgentProgressEventKindV1::UnrecognizedNotification
475 }
476 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
477 ProviderEvent::UserMessage { .. }
488 | ProviderEvent::Plan { .. }
489 | ProviderEvent::AvailableCommandsUpdated { .. }
490 | ProviderEvent::ModeChanged { .. }
491 | ProviderEvent::SessionInfoUpdated { .. }
492 | ProviderEvent::UsageUpdated { .. }
493 | ProviderEvent::ConfigOptionsUpdated { .. } => {
494 AgentProgressEventKindV1::UnrecognizedNotification
495 }
496 })
497}
498
499fn provider_family_is_live(family: AdapterFamily) -> bool {
512 match family {
513 AdapterFamily::PtySemantic
514 | AdapterFamily::Hook
515 | AdapterFamily::ManagedHook
516 | AdapterFamily::Pipe
517 | AdapterFamily::OneShot
518 | AdapterFamily::Acp => true,
519 AdapterFamily::History
520 | AdapterFamily::Resume
521 | AdapterFamily::SessionOptions
522 | AdapterFamily::CapabilityProbe => false,
523 }
524}
525
526fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
527 match by {
528 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
529 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
530 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
531 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
532 }
533}
534
535fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
547 match authority {
548 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
549 BlockAuthorityV1::HostPolicy => "denied by host policy",
550 BlockAuthorityV1::HostDeadline => {
551 "denied by host policy after the operator deadline expired"
552 }
553 BlockAuthorityV1::Operator => "declined by the operator",
554 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
555 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
556 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
557 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
558 BlockAuthorityV1::UserRejected => "rejected by the user",
559 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
560 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
561 }
562}
563
564fn host_request_denied_block(
567 method: &str,
568 by: ProviderHostDecisionAuthority,
569 reason: Option<&str>,
570) -> (String, BlockAuthorityV1, String) {
571 let authority = block_authority(by);
572 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
573 let (reason_text, _) =
577 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
578 (tool_class_label(method), authority, reason_text)
579}
580
581fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
600 match kind? {
601 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
602 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
603 _ => None,
604 }
605}
606
607fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
619 let trimmed = text.trim();
620 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
621 return (trimmed.to_owned(), None);
622 };
623 let tail = after_reason.trim_start();
624 let Some((reason, help)) = tail.split_once(". ") else {
625 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
629 return (reason.to_owned(), None);
630 };
631 let help = help.trim();
632 if help.is_empty() {
633 (reason.trim().to_owned(), None)
634 } else {
635 (reason.trim().to_owned(), Some(help.to_owned()))
636 }
637}
638
639fn tool_class_from_denial_help(help: Option<&str>) -> String {
649 help.and_then(|text| text.split("add a ").nth(1))
650 .and_then(|tail| tail.split(' ').next())
651 .filter(|word| !word.is_empty())
652 .map(str::to_owned)
653 .unwrap_or_else(|| "Tool".to_owned())
654}
655
656fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
664 fn names_quota(s: &str) -> bool {
665 let lower = s.to_ascii_lowercase();
666 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
667 }
668 vendor_code.is_some_and(names_quota) || names_quota(message)
669}
670
671fn provider_session_end_block(
687 stop_reason: &ProviderStopReason,
688) -> Option<(BlockAuthorityV1, String, String)> {
689 match stop_reason {
690 ProviderStopReason::Refusal => Some((
691 BlockAuthorityV1::ProviderRefusal,
692 "refusal".to_owned(),
693 "the provider ended the turn with stopReason: refusal".to_owned(),
694 )),
695 ProviderStopReason::ProviderError { message, vendor_code, .. }
696 if provider_error_is_quota(vendor_code.as_deref(), message) =>
697 {
698 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
699 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
700 }
701 _ => None,
702 }
703}
704
705fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
746 let ControlEventKind::ProviderEvent {
747 sequence: provider_sequence,
748 source,
749 source_sequence,
750 event: provider_event,
751 } = &event.event
752 else {
753 return None;
754 };
755 if !provider_family_is_live(source.family) {
756 return None;
757 }
758 let kind = match provider_event {
759 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
760 text: text.clone(),
761 is_delta: *is_delta,
762 },
763 ProviderEvent::Thinking { text } => {
764 AgentStreamChunkKindV1::Thinking { text: text.clone() }
765 }
766 ProviderEvent::InteractionRequested {
767 interaction_kind,
768 tool_name,
769 title,
770 prompt,
771 options,
772 ..
773 } => AgentStreamChunkKindV1::InteractionPrompt {
774 correlation_id: interaction_correlation(
775 event.instance_id,
776 event.generation,
777 *provider_sequence,
778 ),
779 interaction_kind: *interaction_kind,
780 tool_name: tool_name.clone(),
781 title: title.clone(),
790 prompt: prompt.clone(),
791 options: options
792 .iter()
793 .map(|option| AgentStreamInteractionOptionV1 {
794 option_id: option.option_id.clone(),
795 name: option.name.clone(),
796 kind: option.kind.clone(),
797 })
798 .collect(),
799 },
800 ProviderEvent::ConfigOptionsUpdated { options } => {
801 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
802 }
803 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
814 current: Some(mode_id.clone()),
815 available: available
816 .iter()
817 .map(|mode| AgentStreamNamedIdV1 {
818 id: mode.id.clone(),
819 name: mode.name.clone(),
820 description: mode.description.clone(),
821 })
822 .collect(),
823 },
824 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
828 let ProviderHostRequestDecision::Denied { by } = decision else {
829 return None;
830 };
831 let (tool_class, authority, reason_text) =
832 host_request_denied_block(method, *by, reason.as_deref());
833 AgentStreamChunkKindV1::Blocked {
834 correlation_id: None,
835 tool_class,
836 authority,
837 reason_kind: None,
838 reason: reason_text,
839 help: None,
840 }
841 }
842 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
846 let (authority, reason_kind) =
847 provider_tool_block_authority(non_execution_kind.as_deref())?;
848 let (reason, help) = split_provider_denial_text(output);
849 let (reason, _) =
850 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
851 let help = help.map(|help| {
852 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
853 });
854 AgentStreamChunkKindV1::Blocked {
855 correlation_id: Some(tool_correlation(event, source, id)),
856 tool_class: tool_class_from_denial_help(help.as_deref()),
857 authority,
858 reason_kind: Some(reason_kind.to_owned()),
859 reason,
860 help,
861 }
862 }
863 ProviderEvent::SessionEnded { stop_reason, .. } => {
867 let (authority, reason_kind, reason) =
868 stop_reason.as_ref().and_then(provider_session_end_block)?;
869 let (reason, _) =
870 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
871 AgentStreamChunkKindV1::Blocked {
872 correlation_id: None,
873 tool_class: "Session".to_owned(),
874 authority,
875 reason_kind: Some(reason_kind),
876 reason,
877 help: None,
878 }
879 }
880 ProviderEvent::SessionStarted { .. }
881 | ProviderEvent::SessionIdentityObserved { .. }
882 | ProviderEvent::TurnStarted { .. }
883 | ProviderEvent::WorkingObserved
884 | ProviderEvent::ToolStarted { .. }
885 | ProviderEvent::TurnCompleted { .. }
886 | ProviderEvent::ContextWindowUsage { .. }
887 | ProviderEvent::TurnInterrupted
888 | ProviderEvent::Error { .. }
889 | ProviderEvent::Ready
890 | ProviderEvent::InteractionResolved { .. }
891 | ProviderEvent::SubagentStarted { .. }
892 | ProviderEvent::SubagentStopped { .. }
893 | ProviderEvent::RateLimited { .. }
894 | ProviderEvent::UnrecognizedNotification { .. }
895 | ProviderEvent::UserMessage { .. }
896 | ProviderEvent::Plan { .. }
897 | ProviderEvent::AvailableCommandsUpdated { .. }
898 | ProviderEvent::SessionInfoUpdated { .. }
899 | ProviderEvent::UsageUpdated { .. } => return None,
900 };
901 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
902}
903
904async fn run_native_session_catalog_operation<T, E, F>(
905 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
906 operation: &'static str,
907 execute: F,
908) -> Result<T, NodeFailure>
909where
910 T: Send + 'static,
911 E: std::fmt::Display + Send + 'static,
912 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
913{
914 let task = tokio::task::spawn_blocking(move || {
915 let mut catalog = match catalog.try_lock() {
916 Ok(catalog) => catalog,
917 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
918 Err(std::sync::TryLockError::WouldBlock) => {
919 return Err(failure(
920 NodeFailureCode::BackendBusy,
921 "native session catalog operation is already running",
922 ));
923 }
924 };
925 execute(&mut catalog).map_err(|error| {
926 failure(
927 NodeFailureCode::BackendOperationFailed,
928 &format!("{operation} failed: {error}"),
929 )
930 })
931 });
932 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
933 .await
934 .map_err(|_| {
935 failure(
936 NodeFailureCode::BackendBusy,
937 &format!("{operation} exceeded its bounded deadline"),
938 )
939 })?
940 .map_err(|_| {
941 failure(
942 NodeFailureCode::BackendOperationFailed,
943 &format!("{operation} task failed"),
944 )
945 })?
946}
947
948fn windows_path_text(path: &OpaqueHostPath) -> &str {
949 path.as_utf8()
950 .expect("Windows node only creates UTF-8 opaque host paths")
951}
952
953fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
954 path.as_utf8()
955 .map(str::to_owned)
956 .ok_or_else(|| failure(
957 NodeFailureCode::InvalidRequest,
958 "this node requires UTF-8 host paths",
959 ))
960}
961
962fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
963 GitWorktreeSnapshot {
964 path: opaque_windows_path(worktree.path),
965 head: worktree.head,
966 branch: worktree.branch,
967 is_bare: worktree.is_bare,
968 is_main: worktree.is_main,
969 locked: worktree.locked,
970 lock_reason: worktree.lock_reason,
971 prunable: worktree.prunable,
972 prunable_reason: worktree.prunable_reason,
973 workspace_id: worktree.workspace_id.take(),
974 }
975}
976
977#[derive(Clone, Debug, Eq, PartialEq)]
978pub struct WorkspaceConfig {
979 workspace_id: WorkspaceId,
980 canonical_root: String,
981 worktree_service_mode: WorktreeServiceMode,
982 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
983}
984
985impl WorkspaceConfig {
986 pub fn new(
987 workspace_id: WorkspaceId,
988 root: impl AsRef<Path>,
989 ) -> Result<Self, NodeServerError> {
990 let root = root.as_ref();
991 if !root.is_absolute() {
992 return Err(NodeServerError::InvalidWorkspaceRoot {
993 workspace_id,
994 path: root.to_string_lossy().into_owned(),
995 message: "path is not absolute".to_owned(),
996 });
997 }
998 let canonical = std::fs::canonicalize(root).map_err(|error| {
999 NodeServerError::InvalidWorkspaceRoot {
1000 workspace_id: workspace_id.clone(),
1001 path: root.to_string_lossy().into_owned(),
1002 message: error.to_string(),
1003 }
1004 })?;
1005 if !canonical.is_dir() {
1006 return Err(NodeServerError::InvalidWorkspaceRoot {
1007 workspace_id,
1008 path: root.to_string_lossy().into_owned(),
1009 message: "path is not a directory".to_owned(),
1010 });
1011 }
1012 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1013 NodeServerError::InvalidWorkspaceRoot {
1014 workspace_id: workspace_id.clone(),
1015 path: path.to_string_lossy().into_owned(),
1016 message: "canonical path is not valid Unicode".to_owned(),
1017 }
1018 })?;
1019 let canonical_root = platform::normalize_canonical_root(canonical_root);
1020 validate_workspace_root(&workspace_id, &canonical_root)?;
1021 Ok(Self {
1022 workspace_id,
1023 canonical_root,
1024 worktree_service_mode: WorktreeServiceMode::Manual,
1025 managed_worktree_profiles: BTreeMap::new(),
1026 })
1027 }
1028
1029 pub fn workspace_id(&self) -> &WorkspaceId {
1030 &self.workspace_id
1031 }
1032
1033 pub fn canonical_root(&self) -> &str {
1034 &self.canonical_root
1035 }
1036
1037 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1038 self.worktree_service_mode = mode;
1039 self
1040 }
1041
1042 pub fn with_managed_worktree_profile(
1043 mut self,
1044 profile: ManagedWorktreeProfile,
1045 ) -> Result<Self, NodeServerError> {
1046 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1047 NodeServerError::InvalidManagedWorktreeProfile {
1048 workspace_id: self.workspace_id.clone(),
1049 profile_id: profile.profile_id().clone(),
1050 message,
1051 }
1052 })?;
1053 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1054 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1055 workspace_id: self.workspace_id,
1056 profile_id: profile.profile_id().clone(),
1057 });
1058 }
1059 if self.managed_worktree_profiles.len()
1060 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1061 {
1062 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1063 workspace_id: self.workspace_id,
1064 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1065 });
1066 }
1067 self.managed_worktree_profiles
1068 .insert(profile.profile_id().clone(), profile);
1069 Ok(self)
1070 }
1071}
1072
1073#[derive(Clone)]
1074pub struct NodeServerConfig {
1075 pub endpoint: String,
1076 api_listen: Option<std::net::SocketAddr>,
1077 bridge_listen: Option<std::net::SocketAddr>,
1080 bridge_token: Option<String>,
1083 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1086 call_home: Option<std::net::SocketAddr>,
1089 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1093 pub node_id: NodeId,
1094 pub workspaces: Vec<WorkspaceConfig>,
1095 access_token: String,
1096 pub runtime: NativeRuntimeConfig,
1097 session_record_retention: SessionRecordRetentionConfig,
1098 state_path: Option<PathBuf>,
1099 spawn_profiles: SpawnProfileRegistry,
1100 session_environment: Option<NodeSessionEnvironmentConfig>,
1101 history: Option<NativeHistoryConfig>,
1102 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1103 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1107 #[cfg(feature = "fixture")]
1108 fixture_raw_pty_runtime: bool,
1109}
1110
1111#[derive(Clone)]
1112struct NodeSessionEnvironmentConfig {
1113 root: PathBuf,
1114 resolver: Arc<dyn NodeSecretResolver>,
1115}
1116
1117impl NodeServerConfig {
1118 pub fn new(
1119 endpoint: impl Into<String>,
1120 access_token: impl Into<String>,
1121 node_id: NodeId,
1122 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1123 ) -> Result<Self, NodeServerError> {
1124 let endpoint = endpoint.into();
1125 let access_token = access_token.into();
1126 if !platform::validate_endpoint(&endpoint) {
1127 return Err(NodeServerError::InvalidEndpoint);
1128 }
1129 if access_token.is_empty() || access_token.len() > 4_096 {
1130 return Err(NodeServerError::InvalidAccessToken);
1131 }
1132 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1133 if workspaces.is_empty() {
1134 return Err(NodeServerError::NoWorkspaces);
1135 }
1136 let mut workspace_ids = BTreeMap::new();
1137 let mut workspace_roots = BTreeMap::new();
1138 for workspace in &workspaces {
1139 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1140 && workspace.managed_worktree_profiles.is_empty()
1141 {
1142 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1143 workspace.workspace_id.clone(),
1144 ));
1145 }
1146 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1147 && !workspace.managed_worktree_profiles.is_empty()
1148 {
1149 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1150 workspace.workspace_id.clone(),
1151 ));
1152 }
1153 if workspace_ids
1154 .insert(workspace.workspace_id.clone(), ())
1155 .is_some()
1156 {
1157 return Err(NodeServerError::DuplicateWorkspaceId(
1158 workspace.workspace_id.clone(),
1159 ));
1160 }
1161 let root_key = platform::root_identity(&workspace.canonical_root);
1162 if let Some(existing) = workspace_roots.insert(
1163 root_key,
1164 workspace.workspace_id.clone(),
1165 ) {
1166 return Err(NodeServerError::DuplicateWorkspaceRoot {
1167 first: existing,
1168 second: workspace.workspace_id.clone(),
1169 root: workspace.canonical_root.clone(),
1170 });
1171 }
1172 }
1173 Ok(Self {
1174 endpoint,
1175 api_listen: None,
1176 bridge_listen: None,
1177 bridge_token: None,
1178 bridge_underlay: None,
1179 call_home: None,
1180 call_home_identity: None,
1181 node_id,
1182 workspaces,
1183 access_token,
1184 runtime: NativeRuntimeConfig {
1187 worker_poll_interval_ms: 4,
1188 ..NativeRuntimeConfig::default()
1189 },
1190 session_record_retention: SessionRecordRetentionConfig::default(),
1191 state_path: None,
1192 spawn_profiles: SpawnProfileRegistry::default(),
1193 session_environment: None,
1194 history: None,
1195 harness_mcp_helper: None,
1196 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1197 #[cfg(feature = "fixture")]
1198 fixture_raw_pty_runtime: false,
1199 })
1200 }
1201
1202 pub fn with_api_listen(
1203 mut self,
1204 api_listen: std::net::SocketAddr,
1205 ) -> Result<Self, NodeServerError> {
1206 if !api_listen.ip().is_loopback() {
1207 return Err(NodeServerError::InvalidApiListen(api_listen));
1208 }
1209 self.api_listen = Some(api_listen);
1210 Ok(self)
1211 }
1212
1213 pub fn with_bridge_listen(
1217 mut self,
1218 bridge_listen: std::net::SocketAddr,
1219 ) -> Result<Self, NodeServerError> {
1220 if !bridge_listen.ip().is_loopback() {
1221 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1222 }
1223 self.bridge_listen = Some(bridge_listen);
1224 Ok(self)
1225 }
1226
1227 pub fn with_bridge_token(
1230 mut self,
1231 bridge_token: impl Into<String>,
1232 ) -> Result<Self, NodeServerError> {
1233 let bridge_token = bridge_token.into();
1234 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1235 return Err(NodeServerError::InvalidBridgeToken);
1236 }
1237 self.bridge_token = Some(bridge_token);
1238 Ok(self)
1239 }
1240
1241 pub fn with_bridge_underlay(
1246 mut self,
1247 bind: std::net::SocketAddr,
1248 transport_key: [u8; 32],
1249 underlay_token: impl Into<String>,
1250 ) -> Result<Self, NodeServerError> {
1251 if self.bridge_listen.is_none() {
1252 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1253 }
1254 if self.bridge_token.is_none() {
1255 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1256 }
1257 let underlay_token = underlay_token.into();
1258 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1259 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1260 }
1261 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1263 bind,
1264 transport_key,
1265 underlay_token,
1266 });
1267 Ok(self)
1268 }
1269
1270 pub fn with_call_home(
1291 mut self,
1292 relay: std::net::SocketAddr,
1293 ) -> Result<Self, NodeServerError> {
1294 if relay.port() == 0 || relay.ip().is_unspecified() {
1297 return Err(NodeServerError::InvalidCallHome(relay));
1298 }
1299 self.call_home = Some(relay);
1300 Ok(self)
1301 }
1302
1303 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1305 self.call_home_identity = Some(identity);
1306 self
1307 }
1308
1309 pub fn with_state_path(
1310 mut self,
1311 state_path: impl Into<PathBuf>,
1312 ) -> Result<Self, NodeServerError> {
1313 let state_path = state_path.into();
1314 if !state_path.is_absolute() || state_path.file_name().is_none() {
1315 return Err(NodeServerError::InvalidStatePath(
1316 state_path.to_string_lossy().into_owned(),
1317 ));
1318 }
1319 self.state_path = Some(state_path);
1320 Ok(self)
1321 }
1322
1323 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1324 self.spawn_profiles = spawn_profiles;
1325 self
1326 }
1327
1328 pub fn with_session_record_retention(
1334 mut self,
1335 retention: SessionRecordRetentionConfig,
1336 ) -> Self {
1337 self.session_record_retention = retention;
1338 self
1339 }
1340
1341 pub fn with_session_environment_materialization(
1342 mut self,
1343 root: impl Into<PathBuf>,
1344 resolver: Arc<dyn NodeSecretResolver>,
1345 ) -> Result<Self, NodeServerError> {
1346 let root = root.into();
1347 if !root.is_absolute() {
1348 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1349 }
1350 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1351 Ok(self)
1352 }
1353
1354 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1357 self.history = Some(history);
1358 self
1359 }
1360
1361 pub fn with_harness_mcp_helper(
1367 mut self,
1368 helper_program: impl Into<PathBuf>,
1369 ) -> Result<Self, NodeServerError> {
1370 self.harness_mcp_helper = Some(
1371 ReviewedHarnessMcpProgram::review(helper_program.into())
1372 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1373 );
1374 Ok(self)
1375 }
1376
1377 pub fn with_network_allowlist_catalog(
1382 mut self,
1383 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1384 ) -> Result<Self, NodeServerError> {
1385 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1386 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1387 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1388 });
1389 }
1390 self.network_allowlist_catalog = catalog;
1391 Ok(self)
1392 }
1393
1394 pub fn with_network_allowlist_catalog_file(
1398 self,
1399 path: impl AsRef<std::path::Path>,
1400 ) -> Result<Self, NodeServerError> {
1401 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1402 .map_err(NodeServerError::from)?;
1403 self.with_network_allowlist_catalog(catalog)
1404 }
1405}
1406
1407pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1408 platform::default_state_path(node_id.as_str())
1409 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1410}
1411
1412pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1413 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1414}
1415
1416fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1417 let parent = state_path.parent()?;
1418 let mut name = state_path.file_name()?.to_os_string();
1419 name.push(".delivery-store");
1420 Some(parent.join(name))
1421}
1422
1423fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1424 let parent = state_path.parent()?;
1425 let mut name = state_path.file_name()?.to_os_string();
1426 name.push(".context-pack-store");
1427 Some(parent.join(name))
1428}
1429
1430pub struct NodeServer {
1431 config: NodeServerConfig,
1432 runtime: NativeRuntime,
1433 shared: Arc<NodeShared>,
1434 events: EventSubscription,
1435 state_path_lock: Option<session_registry::StatePathLock>,
1436}
1437
1438#[cfg(feature = "fixture")]
1439#[derive(Clone, Default)]
1440pub struct SpawnManagedWorktreeV2FailureProbe {
1441 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1442}
1443
1444#[cfg(feature = "fixture")]
1445impl SpawnManagedWorktreeV2FailureProbe {
1446 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1447 *self
1448 .latest_code
1449 .lock()
1450 .unwrap_or_else(|poisoned| poisoned.into_inner())
1451 }
1452
1453 fn record(&self, code: Option<NodeFailureCode>) {
1454 *self
1455 .latest_code
1456 .lock()
1457 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1458 }
1459}
1460
1461#[cfg(all(feature = "fixture", windows))]
1462struct IsolatedCodexHomeFixtureEnvironment;
1463
1464#[cfg(all(feature = "fixture", windows))]
1465impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1466 for IsolatedCodexHomeFixtureEnvironment
1467{
1468 fn resolve_child_environment(
1469 &self,
1470 ) -> Result<
1471 Vec<gate4agent_catalog::EnvMutation>,
1472 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1473 > {
1474 Ok(vec![gate4agent_catalog::EnvMutation {
1475 key: OsString::from("USERPROFILE"),
1476 value: None,
1477 }])
1478 }
1479}
1480
1481impl NodeServer {
1482 #[cfg(unix)]
1486 pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1487 self.shared.signer_proxy.set(proxy).is_ok()
1488 }
1489
1490 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1491 self.shared.m4a_accounts.as_ref()?;
1492 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1493 }
1494
1495 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1496 let catalog = active_registry()?;
1497 Self::new_with_registry(config, catalog)
1498 }
1499
1500 #[cfg(feature = "fixture")]
1501 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1502 let mut spec = gate4agent_testkit::interactive_agent_spec();
1503 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1504 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1505 Self::new_fixture_with_spec(config, spec)
1506 }
1507
1508 #[cfg(feature = "fixture")]
1509 pub fn new_clean_exit_fixture(
1510 config: NodeServerConfig,
1511 fixture_root: PathBuf,
1512 started_marker: PathBuf,
1513 release_signal: PathBuf,
1514 ) -> Result<Self, NodeServerError> {
1515 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1516 &fixture_root,
1517 &started_marker,
1518 &release_signal,
1519 )
1520 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1521 spec.id = AgentId::new("claude")
1522 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1523 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1524 Self::new_fixture_with_spec(config, spec)
1525 }
1526
1527 #[cfg(all(feature = "fixture", windows))]
1537 pub fn new_durable_context_pack_clean_exit_fixture(
1538 config: NodeServerConfig,
1539 fixture_root: PathBuf,
1540 started_marker: PathBuf,
1541 release_signal: PathBuf,
1542 session_id: &str,
1543 ) -> Result<Self, NodeServerError> {
1544 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1545 &fixture_root,
1546 &started_marker,
1547 &release_signal,
1548 session_id,
1549 )
1550 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1551 let source_id = AgentId::new("claude")
1552 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1554 NodeServerError::Registry(
1555 "durable context pack clean-exit fixture source adapter is unavailable"
1556 .to_owned(),
1557 )
1558 })?;
1559 source_spec.capabilities.adapters.history =
1560 source_provider.capabilities.adapters.history.clone();
1561 source_spec.id = source_id;
1562 source_spec.display_name =
1563 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1564 source_spec.detection.command =
1570 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1571 source_spec.detection.aliases.clear();
1572
1573 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1574 target_spec.id = AgentId::new("codex")
1575 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1576 target_spec.display_name =
1577 "Controlled Codex durable context-pack target fixture".to_owned();
1578 target_spec.detection.command =
1579 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1580 target_spec.detection.aliases.clear();
1581
1582 let catalog = AgentRegistry::new([source_spec, target_spec])
1583 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1584 let mut server = Self::new_with_registry(config, catalog)?;
1585 Arc::get_mut(&mut server.shared)
1586 .expect("fixture Node shared state must remain uniquely owned before run")
1587 .fixture_semantic_hook_policy = true;
1588 Ok(server)
1589 }
1590
1591 #[cfg(feature = "fixture")]
1592 pub fn new_monitoring_hook_fixture(
1593 config: NodeServerConfig,
1594 ) -> Result<Self, NodeServerError> {
1595 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1596 spec.id = AgentId::new("claude")
1597 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1598 let mut server = Self::new_fixture_with_spec(config, spec)?;
1599 Arc::get_mut(&mut server.shared)
1600 .expect("fixture Node shared state must remain uniquely owned before run")
1601 .fixture_semantic_hook_policy = true;
1602 Ok(server)
1603 }
1604
1605 #[cfg(all(feature = "fixture", windows))]
1606 pub fn new_provider_bundle_argv_fixture(
1607 config: NodeServerConfig,
1608 agent_id: AgentId,
1609 proof_path: PathBuf,
1610 ) -> Result<Self, NodeServerError> {
1611 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1612 Self::new_fixture_with_spec(config, spec)
1613 }
1614
1615 #[cfg(all(feature = "fixture", windows))]
1616 pub fn new_provider_bundle_argv_hold_fixture(
1617 config: NodeServerConfig,
1618 agent_id: AgentId,
1619 proof_path: PathBuf,
1620 release_signal: PathBuf,
1621 ) -> Result<Self, NodeServerError> {
1622 let spec = Self::provider_bundle_argv_fixture_spec(
1623 agent_id,
1624 proof_path,
1625 Some(release_signal),
1626 )?;
1627 Self::new_fixture_with_spec(config, spec)
1628 }
1629
1630 #[cfg(all(feature = "fixture", windows))]
1631 fn provider_bundle_argv_fixture_spec(
1632 agent_id: AgentId,
1633 proof_path: PathBuf,
1634 release_signal: Option<PathBuf>,
1635 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1636 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1637 return Err(NodeServerError::Registry(
1638 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1639 ));
1640 }
1641 let mut spec = gate4agent_testkit::interactive_agent_spec();
1642 spec.id = agent_id.clone();
1643 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1644 let provider = builtin_registry()
1645 .get(&agent_id)
1646 .ok_or_else(|| {
1647 NodeServerError::Registry(
1648 "provider bundle argv fixture adapter is unavailable".to_owned(),
1649 )
1650 })?;
1651 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1652 if !proof_path.is_absolute()
1653 || !proof_path
1654 .parent()
1655 .is_some_and(|parent| parent.is_dir())
1656 {
1657 return Err(NodeServerError::Registry(
1658 "provider bundle argv proof path requires an existing absolute parent"
1659 .to_owned(),
1660 ));
1661 }
1662 if let Some(release_signal) = release_signal.as_ref() {
1663 if !release_signal.is_absolute()
1664 || !release_signal
1665 .parent()
1666 .is_some_and(|parent| parent.is_dir())
1667 {
1668 return Err(NodeServerError::Registry(
1669 "provider bundle argv release path requires an existing absolute parent"
1670 .to_owned(),
1671 ));
1672 }
1673 if release_signal.exists() {
1674 return Err(NodeServerError::Registry(
1675 "provider bundle argv release path must not exist".to_owned(),
1676 ));
1677 }
1678 let proof_parent = std::fs::canonicalize(
1679 proof_path
1680 .parent()
1681 .expect("validated provider bundle proof parent"),
1682 )
1683 .map_err(|_| {
1684 NodeServerError::Registry(
1685 "provider bundle argv proof parent is unavailable".to_owned(),
1686 )
1687 })?;
1688 let release_parent = std::fs::canonicalize(
1689 release_signal
1690 .parent()
1691 .expect("validated provider bundle release parent"),
1692 )
1693 .map_err(|_| {
1694 NodeServerError::Registry(
1695 "provider bundle argv release parent is unavailable".to_owned(),
1696 )
1697 })?;
1698 if proof_parent != release_parent {
1699 return Err(NodeServerError::Registry(
1700 "provider bundle argv release path must share the proof parent".to_owned(),
1701 ));
1702 }
1703 }
1704 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1705 NodeServerError::Registry(
1706 "provider bundle argv proof path is not valid Unicode".to_owned(),
1707 )
1708 })?;
1709 let release_signal = release_signal.map(|release_signal| {
1710 release_signal.into_os_string().into_string().map_err(|_| {
1711 NodeServerError::Registry(
1712 "provider bundle argv release path is not valid Unicode".to_owned(),
1713 )
1714 })
1715 }).transpose()?;
1716 let script = spec
1717 .launch
1718 .fixed_args
1719 .pop()
1720 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1721 let bundle_validation = if agent_id.as_str() == "codex" {
1722 "if ($bundleArgs.Count -ne 0) { exit 91 }; $codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process'); $cwd = (Get-Location).ProviderPath; if ([string]::IsNullOrEmpty($codexHome)) { [IO.File]::WriteAllLines($proofPath, [string[]]@('unbundled', $cwd)); [Console]::Out.WriteLine('F63_CODEX_UNBUNDLED_VALIDATED') } elseif (-not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 } else { $skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'; if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }; $files = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File); if ($files.Count -ne 1 -or [IO.Path]::GetFullPath($files[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }; $sha = [Security.Cryptography.SHA256]::Create(); try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }; if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }; [IO.File]::WriteAllLines($proofPath, [string[]]@('bundled', $codexHome, $cwd, $skillHash)); [Console]::Out.WriteLine('F63_CODEX_BUNDLE_HOME_VALIDATED') }"
1723 } else if agent_id.as_str() == "claude" {
1724 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--plugin-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if (-not (Test-Path -LiteralPath (Join-Path $bundleArgs[1] '.claude-plugin/plugin.json') -PathType Leaf)) { exit 92 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1725 } else {
1726 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--skills-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if ((Split-Path -Leaf $bundleArgs[1]) -ne 'skills') { exit 93 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1727 };
1728 if let Some(release_signal) = release_signal {
1729 spec.launch.fixed_args.push(format!(
1730 "& {{ param([string]$proofPath, [string]$releaseSignal, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; $deadline = [DateTime]::UtcNow.AddSeconds(45); while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf)) {{ if ([DateTime]::UtcNow -ge $deadline) {{ exit 96 }}; Start-Sleep -Milliseconds 20 }}; {script} }}",
1731 ));
1732 spec.launch.fixed_args.push(proof_path);
1733 spec.launch.fixed_args.push(release_signal);
1734 } else {
1735 spec.launch.fixed_args.push(format!(
1736 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1737 ));
1738 spec.launch.fixed_args.push(proof_path);
1739 }
1740 Ok(spec)
1741 }
1742
1743 #[cfg(all(feature = "fixture", windows))]
1744 pub fn new_context_pack_fixture(
1745 config: NodeServerConfig,
1746 proof_path: PathBuf,
1747 ) -> Result<Self, NodeServerError> {
1748 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1749 Self::new_with_registry(config, catalog)
1750 }
1751
1752 #[cfg(all(feature = "fixture", windows))]
1753 pub fn new_monitoring_context_pack_fixture(
1754 config: NodeServerConfig,
1755 proof_path: PathBuf,
1756 ) -> Result<Self, NodeServerError> {
1757 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1758 let mut server = Self::new_with_registry(config, catalog)?;
1759 Arc::get_mut(&mut server.shared)
1760 .expect("fixture Node shared state must remain uniquely owned before run")
1761 .fixture_semantic_hook_policy = true;
1762 Ok(server)
1763 }
1764
1765 #[cfg(all(feature = "fixture", windows))]
1766 pub fn install_isolated_codex_home_environment_fixture(
1767 &mut self,
1768 profile_id: SpawnEnvironmentProfileId,
1769 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1770 ) -> Result<(), NodeServerError> {
1771 let provider = AgentId::new("codex")
1772 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1773 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1774 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1775 "{}-pty",
1776 profile_id.as_str(),
1777 ))
1778 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1779 provider.clone(),
1780 TransportKind::Pty,
1781 vec![OsString::from("USERPROFILE")],
1782 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1783 )
1784 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1785 let materialization = NodeSessionMaterializationProfile::new(
1786 Vec::new(),
1787 vec![crate::session_environment::NodeSessionPathBinding::new(
1788 "CODEX_HOME",
1789 crate::session_environment::NodeSessionPathClass::ProviderHome,
1790 )
1791 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1792 Vec::new(),
1793 )
1794 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1795 self.install_environment_profile(
1796 NodeEnvironmentProfile::new_with_materialization(
1797 profile_id,
1798 profile_revision,
1799 provider,
1800 [native_profile],
1801 Some(materialization),
1802 )
1803 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1804 )
1805 }
1806
1807 #[cfg(all(feature = "fixture", windows))]
1808 pub fn new_context_only_proof_fixture(
1809 config: NodeServerConfig,
1810 target_provider: AgentId,
1811 proof_path: PathBuf,
1812 ) -> Result<Self, NodeServerError> {
1813 if target_provider.as_str() != "kimi" {
1814 return Err(NodeServerError::Registry(
1815 "context-only proof fixture requires Kimi".to_owned(),
1816 ));
1817 }
1818 let catalog = Self::context_pack_fixture_catalog(
1819 proof_path,
1820 Some(&target_provider),
1821 false,
1822 )?;
1823 Self::new_with_registry(config, catalog)
1824 }
1825
1826 #[cfg(all(feature = "fixture", windows))]
1827 fn context_pack_fixture_catalog(
1828 proof_path: PathBuf,
1829 context_only_target: Option<&AgentId>,
1830 monitoring_claude_source: bool,
1831 ) -> Result<AgentRegistry, NodeServerError> {
1832 if !proof_path.is_absolute()
1833 || !proof_path
1834 .parent()
1835 .is_some_and(|parent| parent.is_dir())
1836 {
1837 return Err(NodeServerError::Registry(
1838 "context pack proof path requires an existing absolute parent".to_owned(),
1839 ));
1840 }
1841 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1842 NodeServerError::Registry(
1843 "context pack proof path is not valid Unicode".to_owned(),
1844 )
1845 })?;
1846 let providers = ["claude", "codex", "grok", "kimi"];
1847 let builtins = builtin_registry();
1848 let mut specs = Vec::with_capacity(providers.len());
1849 for provider_id in providers {
1850 let agent_id = AgentId::new(provider_id)
1851 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1852 let provider = builtins.get(&agent_id).ok_or_else(|| {
1853 NodeServerError::Registry(
1854 "context pack fixture provider adapter is unavailable".to_owned(),
1855 )
1856 })?;
1857 let monitored_provider =
1858 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1859 let mut spec = if monitored_provider {
1860 gate4agent_testkit::monitoring_hook_agent_spec()
1861 } else {
1862 gate4agent_testkit::interactive_agent_spec()
1863 };
1864 spec.id = agent_id.clone();
1865 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1866 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1867 spec.detection.aliases.clear();
1868 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1869 if provider_id == "codex" {
1870 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1871 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1872 })?;
1873 let validation = r#"
1874$bundleArgs = @($bundleArgs)
1875if ($bundleArgs.Count -ne 0) { exit 91 }
1876$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1877$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1878$cwd = (Get-Location).ProviderPath
1879if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1880$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1881if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1882$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1883if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1884$sha = [Security.Cryptography.SHA256]::Create()
1885try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1886if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1887if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1888$contextPath = Join-Path $contextRoot 'context-pack.json'
1889if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1890$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1891if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1892$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1893$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1894$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1895$separator = [IO.Path]::DirectorySeparatorChar
1896if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1897if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1898$rawContext = [IO.File]::ReadAllText($contextPath)
1899if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1900try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1901$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1902$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1903foreach ($property in @($document.PSObject.Properties.Name)) {
1904 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1905}
1906foreach ($property in $requiredTopLevel) {
1907 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1908}
1909if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1910if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1911$messages = @($document.retained_messages)
1912if ($messages.Count -eq 0) { exit 107 }
1913foreach ($message in $messages) {
1914 $messageProperties = @($message.PSObject.Properties.Name)
1915 foreach ($property in $messageProperties) {
1916 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1917 }
1918 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1919}
1920$sha = [Security.Cryptography.SHA256]::Create()
1921try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1922[IO.File]::WriteAllLines($proofPath, [string[]]@('contextual', $codexHome, $cwd, $skillHash, $contextRoot, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count, [string]$messages[0].text, [string]$messages[$messages.Count - 1].text))
1923$rawProof = [IO.File]::ReadAllText($proofPath)
1924if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1925[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1926"#;
1927 spec.launch.fixed_args.push(format!(
1928 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1929 ));
1930 spec.launch.fixed_args.push(proof_path.clone());
1931 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1932 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1933 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1934 })?;
1935 let validation = r#"
1936$bundleArgs = @($bundleArgs)
1937if ($bundleArgs.Count -ne 0) { exit 91 }
1938$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1939$cwd = (Get-Location).ProviderPath
1940if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1941$contextPath = Join-Path $contextRoot 'context-pack.json'
1942if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1943$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1944if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1945$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1946$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1947$separator = [IO.Path]::DirectorySeparatorChar
1948if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1949try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1950if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1951if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1952$messages = @($document.retained_messages)
1953if ($messages.Count -eq 0) { exit 99 }
1954$hasUser = $false
1955$hasAssistant = $false
1956foreach ($message in $messages) {
1957 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1958 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1959 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1960 else { exit 101 }
1961}
1962if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1963$sha = [Security.Cryptography.SHA256]::Create()
1964try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1965[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1966[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1967"#;
1968 spec.launch.fixed_args.push(format!(
1969 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1970 ));
1971 spec.launch.fixed_args.push(proof_path.clone());
1972 }
1973 specs.push(spec);
1974 }
1975 let catalog = AgentRegistry::new(specs)
1976 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1977 Ok(catalog)
1978 }
1979
1980 #[cfg(feature = "fixture")]
1981 pub fn new_exact_launcher_fixture(
1982 config: NodeServerConfig,
1983 agent_id: AgentId,
1984 launcher: String,
1985 ) -> Result<Self, NodeServerError> {
1986 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1987 return Err(NodeServerError::Registry(
1988 "exact launcher fixture requires a supported provider".to_owned(),
1989 ));
1990 }
1991 if launcher.contains('\0') {
1992 return Err(NodeServerError::Registry(
1993 "exact launcher fixture path contains NUL".to_owned(),
1994 ));
1995 }
1996 let launcher_path = Path::new(&launcher);
1997 if !launcher_path.is_absolute() {
1998 return Err(NodeServerError::Registry(
1999 "exact launcher fixture path must be absolute".to_owned(),
2000 ));
2001 }
2002 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2003 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2004 })?;
2005 if !metadata.is_file() {
2006 return Err(NodeServerError::Registry(
2007 "exact launcher fixture path must be a regular file".to_owned(),
2008 ));
2009 }
2010 let mut spec = builtin_registry()
2011 .get(&agent_id)
2012 .ok_or_else(|| {
2013 NodeServerError::Registry(
2014 "exact launcher fixture provider is unavailable".to_owned(),
2015 )
2016 })?
2017 .clone();
2018 spec.launch.program = launcher;
2019 Self::new_fixture_with_spec(config, spec)
2020 }
2021
2022 #[cfg(feature = "fixture")]
2031 fn h3b_provider_spec_with_program_and_args(
2032 agent_id: AgentId,
2033 provider_program: PathBuf,
2034 fixed_args: Vec<OsString>,
2035 ) -> Result<AgentSpec, NodeServerError> {
2036 if !provider_program.is_absolute() || fixed_args.len() > 32 {
2037 return Err(NodeServerError::Registry(
2038 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2039 ));
2040 }
2041 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2042 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2043 })?;
2044 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2045 return Err(NodeServerError::Registry(
2046 "H3B fixture provider must be an exact regular file".to_owned(),
2047 ));
2048 }
2049 let program = provider_program.into_os_string().into_string().map_err(|_| {
2050 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2051 })?;
2052 let fixed_args = fixed_args.into_iter().map(|argument| {
2053 argument.into_string().map_err(|_| NodeServerError::Registry(
2054 "H3B fixture argument is not Unicode".to_owned(),
2055 ))
2056 }).collect::<Result<Vec<_>, _>>()?;
2057 let argument_bytes = fixed_args.iter()
2058 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2059 if program.contains('\0')
2060 || fixed_args.iter().any(|argument| argument.contains('\0'))
2061 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2062 {
2063 return Err(NodeServerError::Registry(
2064 "H3B fixture provider or arguments are invalid".to_owned(),
2065 ));
2066 }
2067 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2068 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2069 })?.clone();
2070 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2071 .ok_or_else(|| NodeServerError::Registry(
2072 "H3B fixture provider basename is unavailable".to_owned(),
2073 ))?.to_owned();
2074 spec.detection.command = process_name.clone();
2075 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2076 name: process_name,
2077 }];
2078 spec.launch.program = program;
2079 spec.launch.fixed_args = fixed_args;
2080 Ok(spec)
2081 }
2082
2083 #[cfg(feature = "fixture")]
2084 pub fn new_harness_mcp_proxy_fixture(
2085 config: NodeServerConfig,
2086 provider_program: PathBuf,
2087 fixed_args: Vec<OsString>,
2088 harness_mcp_program: PathBuf,
2089 ) -> Result<Self, NodeServerError> {
2090 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2091 let agent_id = AgentId::new("codex")
2092 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2093 let mut spec =
2094 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2095 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2096 config.fixture_raw_pty_runtime = true;
2097 Self::new_fixture_with_spec(config, spec)
2098 }
2099
2100 #[cfg(feature = "fixture")]
2124 pub fn new_harness_mcp_acp_launcher_fixture(
2125 config: NodeServerConfig,
2126 helper_program: PathBuf,
2127 provider: AgentId,
2128 program: PathBuf,
2129 args: Vec<OsString>,
2130 ) -> Result<Self, NodeServerError> {
2131 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2132 return Err(NodeServerError::Registry(
2133 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2134 ));
2135 }
2136 let config = config.with_harness_mcp_helper(helper_program)?;
2137 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2138 let launch = spec.launch.clone();
2139 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2140 NodeServerError::Registry(
2141 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2142 .to_owned(),
2143 )
2144 })?;
2145 acp.launch_override = Some(launch);
2153 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2154 Self::new_fixture_with_spec(config, spec)
2155 }
2156
2157 #[cfg(feature = "fixture")]
2158 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2159 let mut spec = gate4agent_testkit::interactive_agent_spec();
2160 let claude_id = AgentId::new("claude")
2161 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2162 let claude = builtin_registry()
2163 .get(&claude_id)
2164 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2165 spec.id = claude_id;
2166 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2167 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2168 name: "claude".to_owned(),
2169 }];
2170 spec.capabilities.transports.pty_adapter = claude
2171 .capabilities
2172 .transports
2173 .pty_adapter
2174 .clone();
2175 spec.capabilities.adapters.resume = claude
2176 .capabilities
2177 .adapters
2178 .resume
2179 .clone();
2180 let script = spec
2181 .launch
2182 .fixed_args
2183 .pop()
2184 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2185 spec.launch.fixed_args.push(format!(
2186 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2187 ));
2188 Self::new_fixture_with_spec(config, spec)
2189 }
2190
2191 #[cfg(feature = "fixture")]
2192 pub fn new_cmd_cwd_fixture(
2193 config: NodeServerConfig,
2194 command: String,
2195 ) -> Result<Self, NodeServerError> {
2196 let mut spec = gate4agent_testkit::interactive_agent_spec();
2197 spec.id = AgentId::new("claude")
2198 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2199 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2200 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2201 spec.launch.program = command;
2202 spec.launch.fixed_args.clear();
2203 Self::new_fixture_with_spec(config, spec)
2204 }
2205
2206 #[cfg(feature = "fixture")]
2207 fn new_fixture_with_spec(
2208 config: NodeServerConfig,
2209 spec: gate4agent_types::AgentSpec,
2210 ) -> Result<Self, NodeServerError> {
2211 let catalog = AgentRegistry::new([spec])
2212 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2213 Self::new_with_registry(config, catalog)
2214 }
2215
2216 fn new_with_registry(
2217 config: NodeServerConfig,
2218 catalog: AgentRegistry,
2219 ) -> Result<Self, NodeServerError> {
2220 for profile in config.spawn_profiles.iter() {
2221 if catalog.get(&profile.provider).is_none() {
2222 return Err(NodeServerError::Registry(
2223 "spawn profile references an unavailable provider".to_owned(),
2224 ));
2225 }
2226 }
2227 let input_settle_timeout_ms = catalog
2228 .iter()
2229 .map(|spec| spec.readiness.timeout_ms)
2230 .max()
2231 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2232 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2233 .max(MUTATION_SETTLE_TIMEOUT_MS);
2234 let (provider_contracts, provider_adapter_contracts) =
2235 provider_contract_manifest(&catalog)?;
2236 let compatibility_support = node_compatibility_support_for_manifest(
2237 &provider_contracts,
2238 &provider_adapter_contracts,
2239 )?;
2240 validate_node_negotiated_handshake_capacity(&compatibility_support)
2241 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2242 let enabled_providers = provider_contracts
2243 .iter()
2244 .map(|contract| contract.provider.clone())
2245 .collect();
2246 #[cfg(feature = "fixture")]
2247 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2248 #[cfg(not(feature = "fixture"))]
2249 let fixture_raw_pty_runtime = false;
2250 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2251 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2252 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2253 })).expect("fixture provider catalog remains bounded");
2254 (None, statuses)
2255 } else {
2256 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2257 let statuses = monitor.collect();
2258 (Some(monitor), statuses)
2259 };
2260 let state_path_lock =
2261 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2262 .map_err(|error| {
2263 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2264 })?;
2265 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2266 Some(state_path) => {
2267 let delivery_root = delivery_store_root_for_state_path(state_path)
2268 .ok_or_else(|| {
2269 NodeServerError::DeliveryStore(
2270 "delivery store root could not be derived from state path".to_owned(),
2271 )
2272 })?;
2273 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2274 let detail = match &error {
2275 DeliveryStoreError::Storage(io_error) => {
2276 format!("{error}: {io_error}")
2277 }
2278 _ => error.to_string(),
2279 };
2280 NodeServerError::DeliveryStore(detail)
2281 })?;
2282 (Some(store), bundles)
2283 }
2284 None => (None, Vec::new()),
2285 };
2286 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2287 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2288 })?;
2289 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2290 Some(state_path) => {
2291 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2292 .ok_or(NodeServerError::ContextPackStore)?;
2293 let (store, packs) = ContextPackStore::open(context_pack_root)
2294 .map_err(|_| NodeServerError::ContextPackStore)?;
2295 (Some(store), packs)
2296 }
2297 None => (None, Vec::new()),
2298 };
2299 let mut durable_context_catalog = ContextPackCatalog::default();
2300 for pack in durable_context_packs {
2301 durable_context_catalog
2302 .insert(pack)
2303 .map_err(|_| NodeServerError::ContextPackStore)?;
2304 }
2305 let session_environment_materializer = config
2306 .session_environment
2307 .as_ref()
2308 .map(|environment| {
2309 SessionEnvironmentMaterializer::new(
2310 environment.root.clone(),
2311 Arc::clone(&environment.resolver),
2312 )
2313 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2314 })
2315 .transpose()?;
2316 #[cfg(unix)]
2319 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2320 let native_session_catalog = config.history.clone().map(|history| {
2321 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2322 });
2323 let (handle, runtime) = match config.history.clone() {
2324 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2325 None => NativeRuntime::new(catalog, config.runtime),
2326 };
2327 let native_launch_profile_control = runtime.native_launch_profile_control();
2328 let harness_mcp_registry = config
2329 .harness_mcp_helper
2330 .clone()
2331 .map(HarnessMcpProxyRegistry::new);
2332 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2333 let incarnation_id = random_incarnation_id()
2334 .map_err(NodeServerError::IncarnationIdentity)?;
2335 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2336 .map_err(durable_state_load_error)?;
2337 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2338 let managed_worktree_tombstones =
2339 std::mem::take(&mut loaded.managed_worktree_tombstones);
2340 let materializations = std::mem::take(&mut loaded.materializations);
2341 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2342 let (workspaces, records, persistence_warning) =
2343 merge_durable_state(&config.workspaces, loaded)?;
2344 let mut shared = NodeShared::new_with_incarnation(
2345 handle,
2346 config.access_token.clone(),
2347 config.node_id.clone(),
2348 incarnation_id,
2349 workspaces,
2350 enabled_providers,
2351 provider_runtime_statuses,
2352 provider_runtime_monitor,
2353 provider_contracts,
2354 provider_adapter_contracts,
2355 config.spawn_profiles.clone(),
2356 Some(native_launch_profile_control),
2357 native_session_catalog,
2358 session_environment_materializer,
2359 config.state_path.clone(),
2360 records,
2361 managed_worktrees,
2362 managed_worktree_tombstones,
2363 materializations,
2364 persistence_warning,
2365 input_settle_timeout_ms,
2366 );
2367 shared.harness_mcp_registry = harness_mcp_registry.clone();
2368 shared.session_record_retention = config.session_record_retention;
2369 shared.network_allowlist_catalog =
2370 RwLock::new(config.network_allowlist_catalog.clone());
2371 shared.bundle_catalog = RwLock::new(delivered_catalog);
2372 shared.delivery_store = Mutex::new(delivery_store);
2373 shared.context_catalog = RwLock::new(durable_context_catalog);
2374 shared.context_pack_store = Mutex::new(context_pack_store);
2375 shared.managed_spawn_replays = Mutex::new(
2376 managed_spawn_replays
2377 .into_iter()
2378 .map(|record| (record.idempotency_key.clone(), record))
2379 .collect(),
2380 );
2381 let shared = Arc::new(shared);
2382 if let Some(registry) = harness_mcp_registry {
2383 let weak = Arc::downgrade(&shared);
2384 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2385 Some(shared) => shared.publish_transient(event),
2386 None => false,
2387 }));
2388 }
2389 if shared.persistence_error().is_none() {
2390 shared.persist_state().map_err(|error| {
2391 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2392 })?;
2393 }
2394 Ok(Self {
2395 config,
2396 runtime,
2397 shared,
2398 events,
2399 state_path_lock,
2400 })
2401 }
2402
2403 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2404 NodeShutdownHandle {
2405 shared: Arc::clone(&self.shared),
2406 }
2407 }
2408
2409 #[cfg(feature = "fixture")]
2410 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2411 &self,
2412 ) -> SpawnManagedWorktreeV2FailureProbe {
2413 self.shared
2414 .fixture_spawn_managed_worktree_v2_failure_probe
2415 .clone()
2416 }
2417
2418 pub fn install_environment_profile(
2423 &mut self,
2424 profile: NodeEnvironmentProfile,
2425 ) -> Result<(), NodeServerError> {
2426 if !self.shared.enabled_providers.contains(profile.provider()) {
2427 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2428 profile.provider().clone(),
2429 ));
2430 }
2431 let (binding, native_profiles, materialization) = profile.into_parts();
2432 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2433 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2434 }
2435 let mut profiles = self
2436 .shared
2437 .environment_profiles
2438 .write()
2439 .unwrap_or_else(|poisoned| poisoned.into_inner());
2440 if profiles.contains_key(&binding.id) {
2441 return Err(NodeServerError::DuplicateEnvironmentProfile(
2442 binding.id.clone(),
2443 ));
2444 }
2445 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2446 return Err(NodeServerError::EnvironmentProfileCapacity {
2447 max: MAX_NODE_ENVIRONMENT_PROFILES,
2448 });
2449 }
2450 for native_id in binding.native_profile_ids() {
2451 if profiles.values().any(|current| {
2452 current.native_profile_ids().any(|current_id| current_id == native_id)
2453 }) {
2454 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2455 native_id.to_string(),
2456 ));
2457 }
2458 }
2459
2460 let mut installed = Vec::with_capacity(native_profiles.len());
2461 for native_profile in native_profiles {
2462 let native_id = native_profile.id().clone();
2463 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2464 for installed_id in installed.into_iter().rev() {
2465 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2466 }
2467 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2468 }
2469 installed.push(native_id);
2470 }
2471 if let Some(materialization) = materialization {
2472 self.shared
2473 .environment_materialization_profiles
2474 .write()
2475 .unwrap_or_else(|poisoned| poisoned.into_inner())
2476 .insert(binding.id.clone(), materialization);
2477 }
2478 profiles.insert(binding.id.clone(), binding);
2479 Ok(())
2480 }
2481
2482 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2484 if self.shared.session_environment_materializer.is_none() {
2485 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2486 }
2487 let mut catalog = self
2488 .shared
2489 .bundle_catalog
2490 .write()
2491 .unwrap_or_else(|poisoned| poisoned.into_inner());
2492 catalog
2493 .insert_idempotent(bundle)
2494 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2495 Ok(())
2496 }
2497
2498 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2499 let shutdown = self.shutdown_handle();
2500 let run = self.run();
2501 tokio::pin!(run);
2502 tokio::select! {
2503 result = &mut run => result,
2504 signal = wait_for_ctrl_signal() => {
2505 signal?;
2506 let shutdown_result = shutdown.request_shutdown().await;
2507 let run_result = run.await;
2508 shutdown_result.and(run_result)
2509 }
2510 }
2511 }
2512
2513 pub async fn run(self) -> Result<(), NodeServerError> {
2514 let NodeServer {
2515 config,
2516 mut runtime,
2517 shared,
2518 events,
2519 state_path_lock: _state_path_lock,
2520 } = self;
2521 shared.reconcile_materializations();
2522 shared.reconcile_managed_worktrees().await;
2523 shared.retire_unavailable_session_records().await;
2524 shared.reconcile_context_pack_exports().await;
2525 let endpoint = config.endpoint.clone();
2529 let call_home = config.call_home;
2530 let call_home_identity = config.call_home_identity.clone();
2531 let api_listen = config.api_listen;
2532 let bridge_listen = config.bridge_listen;
2533 let bridge_underlay = config.bridge_underlay;
2534 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2535 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2536 let accept_shared = Arc::clone(&shared);
2537 let api_shared = Arc::clone(&shared);
2538 let bridge_shared = Arc::clone(&shared);
2539 let shutdown_shared = Arc::clone(&shared);
2540 let shutdown_timeout = Duration::from_millis(
2541 config.runtime.provider_shutdown_timeout_ms.max(1),
2542 );
2543 let result = {
2544 let runtime_loop = drive_runtime_until_shutdown(
2545 &mut runtime,
2546 events,
2547 Arc::clone(&shared),
2548 shutdown_timeout,
2549 );
2550 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2551 let api_loop = http_api::run(api_listen, api_shared);
2552 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2553 tokio::pin!(runtime_loop);
2554 tokio::pin!(accept_loop);
2555 tokio::pin!(api_loop);
2556 tokio::pin!(bridge_loop);
2557 tokio::select! {
2558 runtime_result = &mut runtime_loop => {
2559 let shutdown_result = shutdown_shared
2560 .begin_shutdown()
2561 .await
2562 .map_err(NodeServerError::ShutdownDispatch);
2563 let (accept_result, api_result, bridge_result) =
2564 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2565 match runtime_result {
2566 Err(error) => Err(error),
2567 Ok(()) => shutdown_result
2568 .and(accept_result)
2569 .and(api_result.map_err(NodeServerError::HttpApi))
2570 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2571 }
2572 }
2573 accept_result = &mut accept_loop => {
2574 let shutdown_result = shutdown_shared
2575 .begin_shutdown()
2576 .await
2577 .map_err(NodeServerError::ShutdownDispatch);
2578 let (runtime_result, api_result, bridge_result) =
2579 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2580 match accept_result {
2581 Err(error) => Err(error),
2582 Ok(()) => shutdown_result
2583 .and(runtime_result)
2584 .and(api_result.map_err(NodeServerError::HttpApi))
2585 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2586 }
2587 }
2588 api_result = &mut api_loop => {
2589 let shutdown_result = shutdown_shared
2590 .begin_shutdown()
2591 .await
2592 .map_err(NodeServerError::ShutdownDispatch);
2593 let (runtime_result, accept_result, bridge_result) =
2594 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2595 match api_result {
2596 Err(error) => Err(NodeServerError::HttpApi(error)),
2597 Ok(()) => shutdown_result
2598 .and(runtime_result)
2599 .and(accept_result)
2600 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2601 }
2602 }
2603 bridge_result = &mut bridge_loop => {
2604 let shutdown_result = shutdown_shared
2605 .begin_shutdown()
2606 .await
2607 .map_err(NodeServerError::ShutdownDispatch);
2608 let (runtime_result, accept_result, api_result) =
2609 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2610 match bridge_result {
2611 Err(error) => Err(NodeServerError::BridgeApi(error)),
2612 Ok(()) => shutdown_result
2613 .and(runtime_result)
2614 .and(accept_result)
2615 .and(api_result.map_err(NodeServerError::HttpApi)),
2616 }
2617 }
2618 }
2619 };
2620 if let Ok(mut guard) = shared.delivery_store.lock() {
2627 *guard = None;
2628 }
2629 if let Ok(mut guard) = shared.context_pack_store.lock() {
2630 *guard = None;
2631 }
2632 result
2633 }
2634}
2635
2636async fn drive_runtime_until_shutdown(
2637 runtime: &mut NativeRuntime,
2638 events: EventSubscription,
2639 shared: Arc<NodeShared>,
2640 shutdown_timeout: Duration,
2641) -> Result<(), NodeServerError> {
2642 let _ = shared
2646 .shell_efficiency_profile
2647 .set(runtime.shell_efficiency_profile());
2648 let mut shutdown_started = None;
2649 let mut last_retention_sweep = Instant::now();
2650 let mut last_provisional_reap = Instant::now();
2651 loop {
2652 let runtime_tick_start = Instant::now();
2659 let tick_result = runtime.tick().await;
2660 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2661
2662 let event_drain_start = Instant::now();
2663 for outcome in &tick_result.command_outcomes {
2671 if let Err(error) = &outcome.result {
2672 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2673 tracing::warn!(
2674 command_id = outcome.command_id.0,
2675 cause = %error,
2676 "control command rejected"
2677 );
2678 shared.record_rejected_command(
2679 outcome.command_id,
2680 error.to_string(),
2681 error.is_unsupported_transport(),
2682 );
2683 }
2684 }
2685 let mut events_drained: u64 = 0;
2686 while let Ok(event) = events.try_recv() {
2687 events_drained += 1;
2688 let clean_exit = matches!(
2689 event.event,
2690 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2691 );
2692 let instance_id = event.instance_id;
2693 let generation = event.generation;
2694 if clean_exit {
2706 if let Some((record_id, session)) =
2707 shared.managed_record_export_target_for_instance(instance_id, generation)
2708 {
2709 let export_shared = Arc::clone(&shared);
2710 tokio::spawn(async move {
2711 export_shared
2712 .reconcile_context_pack_export_for_record(&record_id, session)
2713 .await;
2714 });
2715 }
2716 }
2717 shared.publish_control(event);
2718 }
2719 let event_drain_elapsed = event_drain_start.elapsed();
2720
2721 let publish_terminal_frames_start = Instant::now();
2722 shared.publish_terminal_frames();
2723 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2724
2725 shared
2731 .native_session_gauge
2732 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2733 *shared
2734 .runtime_tick_profile
2735 .lock()
2736 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2737
2738 let remainder_start = Instant::now();
2739 if shared.shutdown.load(Ordering::Acquire) {
2740 let started = *shutdown_started.get_or_insert_with(Instant::now);
2741 let snapshot = shared.handle.snapshot();
2742 let sessions_settled = snapshot.sessions.iter().all(|session| {
2743 !matches!(
2744 session.status,
2745 gate4agent_types::SessionStatus::Starting
2746 | gate4agent_types::SessionStatus::Running
2747 | gate4agent_types::SessionStatus::Stopping
2748 )
2749 });
2750 if runtime.active_native_sessions() == 0 && sessions_settled {
2751 return Ok(());
2752 }
2753 if started.elapsed() >= shutdown_timeout {
2754 return Err(NodeServerError::ShutdownTimedOut {
2755 active_native_sessions: runtime.active_native_sessions(),
2756 });
2757 }
2758 }
2759 let remainder_elapsed = remainder_start.elapsed();
2760
2761 {
2762 let mut profile = shared
2763 .drive_loop_profile
2764 .lock()
2765 .unwrap_or_else(|poisoned| poisoned.into_inner());
2766 profile.record_runtime_tick(runtime_tick_elapsed);
2767 profile.record_event_drain(event_drain_elapsed);
2768 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2769 profile.record_remainder(remainder_elapsed);
2770 profile.note_iteration(Instant::now());
2771 }
2772
2773 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2785 if tick_result.observations_applied == 0 && events_drained == 0 {
2786 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2787 }
2788
2789 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2794 shared.retire_unavailable_session_records().await;
2795 last_retention_sweep = Instant::now();
2796 }
2797 if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2799 for address in shared.take_due_provisional_spawns() {
2800 shared.reap_provisional_spawn(address).await;
2801 }
2802 last_provisional_reap = Instant::now();
2803 }
2804
2805 let hot = shared
2808 .pty_input_marks
2809 .lock()
2810 .unwrap_or_else(|poisoned| poisoned.into_inner())
2811 .values()
2812 .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2813 if hot {
2814 sleep(Duration::from_millis(1)).await;
2815 } else {
2816 tokio::select! {
2817 _ = sleep(Duration::from_millis(10)) => {}
2818 _ = shared.pty_input_wake.notified() => {}
2819 }
2820 }
2821 }
2822}
2823
2824#[derive(Clone)]
2825pub struct NodeShutdownHandle {
2826 shared: Arc<NodeShared>,
2827}
2828
2829impl NodeShutdownHandle {
2830 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2831 self.shared
2832 .begin_shutdown()
2833 .await
2834 .map_err(NodeServerError::ShutdownDispatch)
2835 }
2836}
2837
2838async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2839 #[cfg(windows)]
2840 {
2841 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2842 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2843 return tokio::select! {
2844 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2845 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2846 };
2847 }
2848 #[cfg(unix)]
2849 {
2850 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2851 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2852 tokio::select! {
2853 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2854 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2855 }
2856 }
2857}
2858
2859fn shell_host_spec() -> AgentSpec {
2873 #[cfg(windows)]
2877 let (detect_command, program, fixed_args, process_name) = {
2878 let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2879 r"C:\Windows\System32\cmd.exe".to_owned()
2880 });
2881 ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2882 };
2883 #[cfg(not(windows))]
2884 let (detect_command, program, fixed_args, process_name) = (
2885 "bash".to_owned(),
2886 "/bin/bash".to_owned(),
2887 vec!["-i".to_owned()],
2888 "bash".to_owned(),
2889 );
2890 AgentSpec {
2891 id: AgentId::new("shell").expect("shell agent id"),
2892 revision: "hatchery:shell-pty-1".to_owned(),
2893 display_name: "Host shell".to_owned(),
2894 detection: DetectionSpec {
2895 command: detect_command,
2896 aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2897 required_commands: Vec::new(),
2898 unsupported_platforms: Vec::new(),
2899 },
2900 launch: LaunchSpec {
2901 program,
2902 fixed_args,
2903 },
2904 expected_processes: vec![ProcessMatcher::Exact {
2905 name: process_name,
2906 }],
2907 prompt: PromptSpec {
2908 initial: InitialPromptMode::None,
2909 native_draft: None,
2910 },
2911 readiness: AgentReadinessSpec::default(),
2912 capabilities: AgentCapabilities {
2913 agent_commands: None,
2914 transports: AgentTransportCapabilities {
2915 pty: true,
2916 pty_adapter: None,
2917 pipe: None,
2918 acp: None,
2919 },
2920 adapters: AgentAdapterCapabilities::default(),
2921 },
2922 verification: SpecVerification::Gate4AgentVerified,
2923 }
2924}
2925
2926fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2927 let mut specs = vec![shell_host_spec()];
2928 specs.extend(
2929 builtin_registry()
2930 .iter()
2931 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2932 .cloned(),
2933 );
2934 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2935}
2936
2937fn provider_contract_manifest(
2938 catalog: &AgentRegistry,
2939) -> Result<
2940 (
2941 Vec<ProviderContractSupport>,
2942 Vec<ProviderAdapterContractSupport>,
2943 ),
2944 NodeServerError,
2945> {
2946 let mut provider_contracts = Vec::new();
2947 let mut provider_adapter_contracts = Vec::new();
2948 for spec in catalog.iter() {
2949 let provider = spec.id.clone();
2950 let revision = ProviderContractRevision::new(spec.revision.clone())
2951 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2952 provider_contracts.push(ProviderContractSupport {
2953 provider: provider.clone(),
2954 revision,
2955 });
2956 for (family, binding) in declared_adapter_bindings(spec)? {
2957 let revision = AdapterContractRevision::new(binding.revision.clone())
2958 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2959 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2960 provider: provider.clone(),
2961 family,
2962 adapter_id: binding.id.clone(),
2963 revision,
2964 });
2965 }
2966 }
2967 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2968 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2969 Ok((provider_contracts, provider_adapter_contracts))
2970}
2971
2972fn declared_adapter_bindings(
2973 spec: &AgentSpec,
2974) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2975 let mut bindings = Vec::new();
2976 push_declared_binding(
2977 &mut bindings,
2978 AdapterFamily::PtySemantic,
2979 spec.capabilities.transports.pty_adapter.as_ref(),
2980 )?;
2981 push_declared_binding(
2982 &mut bindings,
2983 AdapterFamily::Pipe,
2984 spec.capabilities.adapters.pty_sidecar.as_ref(),
2985 )?;
2986 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2987 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2988 gate4agent_types::PipeProtocol::SemanticNdjson
2989 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2990 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2991 });
2992 push_declared_binding(
2993 &mut bindings,
2994 AdapterFamily::Pipe,
2995 pipe_transport
2996 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2997 .map(|transport| &transport.adapter),
2998 )?;
2999 push_declared_binding(
3000 &mut bindings,
3001 AdapterFamily::Acp,
3002 spec.capabilities
3003 .transports
3004 .acp
3005 .as_ref()
3006 .map(|transport| &transport.adapter),
3007 )?;
3008 push_declared_binding(
3009 &mut bindings,
3010 AdapterFamily::Hook,
3011 spec.capabilities.adapters.hook.as_ref(),
3012 )?;
3013 push_declared_binding(
3014 &mut bindings,
3015 AdapterFamily::ManagedHook,
3016 spec.capabilities.adapters.managed_hook.as_ref(),
3017 )?;
3018 push_declared_binding(
3019 &mut bindings,
3020 AdapterFamily::OneShot,
3021 pipe_transport
3022 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3023 .map(|transport| &transport.adapter),
3024 )?;
3025 push_declared_binding(
3026 &mut bindings,
3027 AdapterFamily::OneShot,
3028 spec.capabilities.adapters.one_shot.as_ref(),
3029 )?;
3030 push_declared_binding(
3031 &mut bindings,
3032 AdapterFamily::History,
3033 spec.capabilities.adapters.history.as_ref(),
3034 )?;
3035 push_declared_binding(
3036 &mut bindings,
3037 AdapterFamily::Resume,
3038 spec.capabilities.adapters.resume.as_ref(),
3039 )?;
3040 push_declared_binding(
3041 &mut bindings,
3042 AdapterFamily::SessionOptions,
3043 spec.capabilities.adapters.session_options.as_ref(),
3044 )?;
3045 push_declared_binding(
3046 &mut bindings,
3047 AdapterFamily::CapabilityProbe,
3048 spec.capabilities.adapters.capability_probe.as_ref(),
3049 )?;
3050 Ok(bindings)
3051}
3052
3053fn push_declared_binding<'a>(
3054 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3055 family: AdapterFamily,
3056 binding: Option<&'a AdapterBinding>,
3057) -> Result<(), NodeServerError> {
3058 let Some(binding) = binding else {
3059 return Ok(());
3060 };
3061 if let Some((_, existing)) = bindings
3062 .iter()
3063 .find(|(existing_family, _)| *existing_family == family)
3064 {
3065 if *existing == binding {
3066 return Ok(());
3067 }
3068 return Err(NodeServerError::ProviderContractManifest(format!(
3069 "provider declares conflicting {family:?} adapter bindings"
3070 )));
3071 }
3072 bindings.push((family, binding));
3073 Ok(())
3074}
3075
3076fn merge_durable_state(
3077 configured: &[WorkspaceConfig],
3078 mut loaded: LoadedNodeState,
3079) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3080 let mut workspaces = configured
3081 .iter()
3082 .cloned()
3083 .map(|workspace| (workspace.workspace_id.clone(), workspace))
3084 .collect::<BTreeMap<_, _>>();
3085 let mut roots = workspaces
3086 .values()
3087 .map(|workspace| {
3088 (
3089 platform::root_identity(&workspace.canonical_root),
3090 workspace.workspace_id.clone(),
3091 )
3092 })
3093 .collect::<BTreeMap<_, _>>();
3094 let mut warning = loaded
3095 .warning
3096 .take()
3097 .map(|message| session_registry::sanitized_persistence_summary(&message));
3098
3099 for (workspace_id, canonical_root) in loaded.workspaces {
3100 if let Some(existing) = workspaces.get(&workspace_id) {
3101 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3102 return Err(NodeServerError::DurableState(io::Error::new(
3103 io::ErrorKind::InvalidData,
3104 DURABLE_STATE_CONFLICT_ERROR,
3105 )));
3106 }
3107 continue;
3108 }
3109 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3110 return Err(NodeServerError::DurableState(io::Error::new(
3111 io::ErrorKind::InvalidData,
3112 DURABLE_STATE_CONFLICT_ERROR,
3113 )));
3114 }
3115 if !Path::new(&canonical_root).is_dir() {
3116 warning.get_or_insert_with(|| {
3117 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3118 });
3119 continue;
3120 }
3121 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3122 NodeServerError::DurableState(io::Error::new(
3123 io::ErrorKind::InvalidData,
3124 DURABLE_STATE_CONFLICT_ERROR,
3125 ))
3126 })?;
3127 roots.insert(
3128 platform::root_identity(&workspace.canonical_root),
3129 workspace.workspace_id.clone(),
3130 );
3131 workspaces.insert(workspace_id, workspace);
3132 }
3133
3134 for record in &mut loaded.records {
3135 match workspaces.get(&record.workspace_id) {
3136 Some(workspace)
3137 if platform::roots_equal(
3138 &workspace.canonical_root,
3139 windows_path_text(&record.canonical_root),
3140 ) => {}
3141 Some(_) => {
3142 return Err(NodeServerError::DurableState(io::Error::new(
3143 io::ErrorKind::InvalidData,
3144 DURABLE_STATE_CONFLICT_ERROR,
3145 )));
3146 }
3147 None => {
3148 record.state = ManagedSessionState::Unavailable;
3149 record.active_session = None;
3150 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3151 }
3152 }
3153 }
3154 Ok((workspaces.into_values().collect(), loaded.records, warning))
3155}
3156
3157struct RejectedCommandReason {
3160 message: String,
3161 unsupported_transport: bool,
3168 expires_at: Instant,
3169}
3170
3171struct NodeShared {
3172 handle: Gate4AgentHandle,
3173 access_token: String,
3174 node_id: NodeId,
3175 incarnation_id: NodeIncarnationId,
3176 started_at_unix_ms: u64,
3177 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3178 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3179 managed_worktree_profiles:
3180 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3181 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3182 enabled_providers: Vec<AgentId>,
3183 provider_runtime_statuses: ProviderRuntimeStatuses,
3184 provider_runtime_status_updates:
3185 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3186 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3187 provider_contracts: Vec<ProviderContractSupport>,
3188 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3189 spawn_profiles: SpawnProfileRegistry,
3190 environment_profiles:
3191 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3192 environment_materialization_profiles:
3193 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3194 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3202 #[cfg(feature = "dig2-station-probe")]
3206 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3207 bundle_catalog: RwLock<BundleCatalog>,
3208 delivery_store: Mutex<Option<DeliveryStore>>,
3209 context_pack_store: Mutex<Option<ContextPackStore>>,
3210 context_catalog: RwLock<ContextPackCatalog>,
3211 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3212 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3213 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3214 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3215 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3217 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3219 grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3221 #[cfg(unix)]
3224 signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3225 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3226 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3227 managed_spawn_replays:
3228 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3229 controller: Mutex<Option<ControllerLease>>,
3230 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3233 pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3235 history: Mutex<NodeEventHistory>,
3236 event_tx: broadcast::Sender<NodeEventEnvelope>,
3237 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3238 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3246 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3255 terminal_frame_watermarks:
3256 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3257 pty_input_wake: tokio::sync::Notify,
3259 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3262 next_connection_id: AtomicU64,
3263 next_instance_id: AtomicU64,
3264 next_command_id: AtomicU64,
3265 shutdown: AtomicBool,
3266 shutdown_notify: Notify,
3267 preauth_slots: Arc<Semaphore>,
3268 authenticated_slots: Arc<Semaphore>,
3269 inspection_slots: Arc<Semaphore>,
3270 mutation_gate: AsyncMutex<()>,
3271 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3272 session_records: Mutex<SessionRecords>,
3273 state_path: Option<PathBuf>,
3274 persistence_error: RwLock<Option<String>>,
3275 state_transaction: Mutex<()>,
3276 input_settle_timeout_ms: u64,
3277 drive_loop_profile: Mutex<DriveLoopProfiler>,
3281 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3287 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3299 native_session_gauge: AtomicUsize,
3306 connection_loop_iterations: AtomicU64,
3317 connection_events_sent: AtomicU64,
3318 rejected_commands_total: AtomicU64,
3327 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3347 drive_loop_iterations_total: AtomicU64,
3348 drive_loop_iterations_idle: AtomicU64,
3349 session_record_retention: SessionRecordRetentionConfig,
3353 retired_records_total: AtomicU64,
3359 #[cfg(feature = "fixture")]
3360 fixture_semantic_hook_policy: bool,
3361 #[cfg(feature = "fixture")]
3362 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3363}
3364
3365impl Drop for NodeShared {
3366 fn drop(&mut self) {
3367 SessionLifecycleHook::on_shutdown(self);
3368 }
3369}
3370
3371#[derive(Clone, Debug, Eq, PartialEq)]
3372struct SessionBinding {
3373 workspace_id: WorkspaceId,
3374 generation: SessionGeneration,
3375 runtime_policy: ProviderRuntimePolicy,
3376 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3377 record_id: Option<SessionRecordId>,
3378 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3379 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3380 bundle: Option<ResolvedBundleReceipt>,
3381 context: Option<ResolvedContextPackReceipt>,
3382 materialization_id: Option<MaterializationId>,
3383}
3384
3385#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3386enum SpawnRecordPolicy {
3387 ProviderIdentityOnly,
3388 Always,
3389}
3390
3391struct NativeEnvironmentSelectionGuard {
3392 control: Option<NativeLaunchProfileControl>,
3393 instance_id: AgentInstanceId,
3394}
3395
3396struct NativeInstanceOverlayGuard {
3397 control: Option<NativeLaunchProfileControl>,
3398 instance_id: AgentInstanceId,
3399}
3400
3401struct NativeHarnessMcpOverlayGuard {
3402 control: NativeLaunchProfileControl,
3403 instance_id: AgentInstanceId,
3404}
3405
3406impl NativeHarnessMcpOverlayGuard {
3407 fn retain(self) { std::mem::forget(self); }
3408}
3409
3410impl Drop for NativeHarnessMcpOverlayGuard {
3411 fn drop(&mut self) {
3412 self.control
3413 .clear_native_mcp_server_launch_overlay(self.instance_id);
3414 }
3415}
3416
3417impl NativeInstanceOverlayGuard {
3418 fn retain(mut self) {
3419 self.control = None;
3420 }
3421}
3422
3423impl Drop for NativeInstanceOverlayGuard {
3424 fn drop(&mut self) {
3425 if let Some(control) = self.control.as_ref() {
3426 control.clear_native_instance_launch_overlay(self.instance_id);
3427 }
3428 }
3429}
3430
3431enum PreparedNativeLaunchOverlay {
3432 Environment(NativeLaunchEnvironmentOverlay),
3433 Instance(NativeInstanceLaunchOverlay),
3434}
3435
3436impl NativeEnvironmentSelectionGuard {
3437 fn retain(mut self) {
3438 self.control = None;
3439 }
3440}
3441
3442impl Drop for NativeEnvironmentSelectionGuard {
3443 fn drop(&mut self) {
3444 if let Some(control) = self.control.as_ref() {
3445 control.clear_native_launch_profile_selection(self.instance_id);
3446 }
3447 }
3448}
3449
3450struct SessionMaterializationGuard<'a> {
3451 shared: &'a NodeShared,
3452 id: Option<MaterializationId>,
3453 provider_home: Option<PathBuf>,
3456}
3457
3458impl SessionMaterializationGuard<'_> {
3459 fn id(&self) -> Option<&MaterializationId> {
3460 self.id.as_ref()
3461 }
3462
3463 fn provider_home(&self) -> Option<&Path> {
3464 self.provider_home.as_deref()
3465 }
3466
3467 fn retain(mut self) {
3468 self.id = None;
3469 }
3470}
3471
3472impl Drop for SessionMaterializationGuard<'_> {
3473 fn drop(&mut self) {
3474 if let Some(id) = self.id.take() {
3475 let _ = self.shared.cleanup_materialization(&id);
3476 }
3477 }
3478}
3479
3480
3481trait SessionLifecycleHook {
3484 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3486 fn on_session_ended(&self, address: &SessionAddress);
3488 fn on_shutdown(&self);
3490 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3492 fn cleanup_session_materialization(
3494 &self,
3495 address: &SessionAddress,
3496 binding: &SessionBinding,
3497 ) -> Result<(), NodeFailure>;
3498}
3499
3500impl SessionLifecycleHook for NodeShared {
3501 fn on_session_ended(&self, address: &SessionAddress) {
3502 self.retire_session_grok_leader(address.session.instance_id);
3503 if let Some(accounts) = self.m4a_accounts.as_ref() {
3504 let instance = address.session.instance_id;
3505 let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3506 for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3507 accounts.remove_session_file(nick);
3508 }
3509 }
3510 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3511 registry.revoke_session(address);
3512 }
3513 if let Some(control) = self.native_launch_profile_control.as_ref() {
3514 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3515 }
3516 }
3517
3518 fn on_shutdown(&self) {
3519 let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3520 .into_values()
3521 .collect();
3522 for socket in leaders {
3523 crate::grok_leader::retire(&socket);
3524 }
3525 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3526 let instances = registry.shutdown();
3527 if let Some(control) = self.native_launch_profile_control.as_ref() {
3528 for instance_id in instances {
3529 control.clear_native_mcp_server_launch_overlay(instance_id);
3530 }
3531 }
3532 }
3533 }
3534
3535 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3536 if let Some(control) = self.native_launch_profile_control.as_ref() {
3537 control.clear_native_mcp_server_launch_overlay(instance_id);
3538 }
3539 }
3540
3541 fn cleanup_session_materialization(
3542 &self,
3543 address: &SessionAddress,
3544 binding: &SessionBinding,
3545 ) -> Result<(), NodeFailure> {
3546 self.cleanup_session_owned_materialization(address, binding)
3547 }
3548
3549 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3550 #[cfg(feature = "dig2-station-probe")]
3553 {
3554 crate::dig2_station_lease::release_lease_for_holder(
3555 &self.browser_station_leases,
3556 address.session.instance_id,
3557 );
3558 }
3559 if let Some(control) = self.native_launch_profile_control.as_ref() {
3560 control.clear_native_mcp_server_launch_overlay(
3561 address.session.instance_id,
3562 );
3563 }
3564 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3565 registry.revoke_session(address);
3566 }
3567 if removed
3568 .is_some_and(|binding| {
3569 binding.environment_profile.is_some()
3570 || binding.bundle.is_some()
3571 || binding.context.is_some()
3572 })
3573 {
3574 if let Some(control) = self.native_launch_profile_control.as_ref() {
3575 control.clear_native_launch_profile_selection(
3576 address.session.instance_id,
3577 );
3578 control.clear_native_instance_launch_overlay(
3579 address.session.instance_id,
3580 );
3581 }
3582 }
3583 }
3584}
3585
3586struct SpawnPrepareRequest<'r> {
3587 instance_id: AgentInstanceId,
3588 address: &'r SessionAddress,
3589 provider: &'r AgentId,
3590 mode: SessionMode,
3591 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3592 bundle: Option<&'r ResolvedBundleReceipt>,
3593 context: Option<&'r ResolvedContextPackReceipt>,
3594 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3595 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3596 approval_level: ApprovalLevel,
3597 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3598 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3599}
3600
3601struct PreparedSpawn<'a> {
3605 instance_overlay: Option<NativeInstanceOverlayGuard>,
3606 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3607 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3608 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3609 #[cfg(feature = "dig2-station-probe")]
3610 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3611 materialization_id: Option<MaterializationId>,
3612}
3613
3614impl PreparedSpawn<'_> {
3615 fn retain_session_guards(&mut self) {
3617 if let Some(selection) = self.environment_selection.take() {
3618 selection.retain();
3619 }
3620 if let Some(overlay) = self.instance_overlay.take() {
3621 overlay.retain();
3622 }
3623 if let Some(guard) = self.materialization_guard.take() {
3624 guard.retain();
3625 }
3626 }
3627
3628 fn retain_launch_guards(&mut self) {
3630 if let Some(overlay) = self.harness_mcp_overlay.take() {
3631 overlay.retain();
3632 }
3633 #[cfg(feature = "dig2-station-probe")]
3634 if let Some(guard) = self.browser_station_lease_guard.take() {
3635 guard.retain();
3636 }
3637 }
3638}
3639
3640trait SpawnPreparer {
3644 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3645 fn select_environment(
3647 &self,
3648 instance_id: AgentInstanceId,
3649 provider: &AgentId,
3650 mode: SessionMode,
3651 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3652 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3653 fn install_launch_overlay(
3655 &self,
3656 instance_id: AgentInstanceId,
3657 overlay: PreparedNativeLaunchOverlay,
3658 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3659}
3660
3661impl SpawnPreparer for NodeShared {
3662 fn select_environment(
3663 &self,
3664 instance_id: AgentInstanceId,
3665 provider: &AgentId,
3666 mode: SessionMode,
3667 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3668 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3669 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3670 }
3671
3672 fn install_launch_overlay(
3673 &self,
3674 instance_id: AgentInstanceId,
3675 overlay: PreparedNativeLaunchOverlay,
3676 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3677 self.install_prepared_launch_overlay(instance_id, overlay)
3678 }
3679
3680 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3681 let SpawnPrepareRequest {
3682 instance_id,
3683 address,
3684 provider,
3685 mode,
3686 environment_profile,
3687 bundle,
3688 context,
3689 managed_authority,
3690 harness_mcp,
3691 approval_level,
3692 network_allowlist,
3693 browser_profile_id,
3694 } = request;
3695 #[cfg(feature = "dig2-station-probe")]
3700 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3701 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3702 match acquire_lease(
3703 &self.browser_station_leases,
3704 profile_id,
3705 instance_id,
3706 unix_time_ms(),
3707 ) {
3708 Ok(guard) => Some(guard),
3709 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3710 return Err(failure(
3711 NodeFailureCode::BrowserStationProfileBusy,
3712 "dig2browser station profile is already leased by another session",
3713 ));
3714 }
3715 Err(BrowserStationLeaseError::CapacityExceeded) => {
3716 return Err(failure(
3717 NodeFailureCode::BrowserStationProfileBusy,
3718 "dig2browser station profile lease capacity exhausted",
3719 ));
3720 }
3721 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3722 return Err(failure(
3723 NodeFailureCode::BrowserStationProfileBusy,
3724 "session already holds a dig2browser station profile lease",
3725 ));
3726 }
3727 }
3728 } else {
3729 None
3730 };
3731 #[cfg(not(feature = "dig2-station-probe"))]
3732 let _ = browser_profile_id;
3733 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3734 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3743 {
3744 return Err(failure(
3745 NodeFailureCode::BindingMismatch,
3746 "harness MCP reservation does not match the exact PTY/ACP provider",
3747 ));
3748 }
3749 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3750 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3751 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3752 })?;
3753 let launch = prepared.launch();
3758 let mut env_entries = vec![
3759 (
3760 OsString::from(launch.endpoint_env.as_str()),
3761 prepared.endpoint().as_os_str().to_os_string(),
3762 ),
3763 (
3764 OsString::from(launch.token_env.as_str()),
3765 prepared.token().expose().into(),
3766 ),
3767 (
3768 OsString::from(launch.program_env.as_str()),
3769 prepared.helper_program().as_os_str().to_os_string(),
3770 ),
3771 ];
3772 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3773 env_entries.push(trace_entry);
3774 }
3775 if let Some(door) = crate::door::door_listen_addr() {
3776 env_entries.push((
3777 OsString::from("HATCHERY_NODE_DOOR"),
3778 OsString::from(door.to_string()),
3779 ));
3780 let session_key = format!(
3784 "pty-{}-{}-{}",
3785 self.incarnation_id,
3786 address.workspace_id.as_str(),
3787 address.session.instance_id.0
3788 );
3789 env_entries.push((
3790 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3791 OsString::from(session_key),
3792 ));
3793 }
3794 let overlay = NativeMcpServerLaunchOverlay::new(
3795 provider.clone(),
3796 launch.server_name.as_str(),
3797 prepared.helper_program().as_os_str().to_os_string(),
3798 launch.args.iter().map(OsString::from).collect(),
3799 env_entries,
3800 launch.scrub_env.iter().map(OsString::from).collect(),
3801 ).map_err(|_| failure(
3802 NodeFailureCode::HarnessMcpUnavailable,
3803 "harness MCP launch overlay is unavailable",
3804 ))?;
3805 control
3806 .install_native_mcp_server_launch_overlay(instance_id, overlay)
3807 .map_err(|_| failure(
3808 NodeFailureCode::HarnessMcpUnavailable,
3809 "harness MCP launch overlay could not be installed",
3810 ))?;
3811 tracing::info!(
3817 node_id = %self.node_id,
3818 instance_id = ?instance_id,
3819 provider = %provider,
3820 mode = ?mode,
3821 "harness MCP overlay installed for spawn",
3822 );
3823 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3824 } else {
3825 None
3826 };
3827 let prepared_materialization = self.prepare_session_materialization(
3828 &address,
3829 &provider,
3830 mode,
3831 environment_profile,
3832 bundle,
3833 context,
3834 managed_authority.cloned(),
3835 )?;
3836 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3837 Some((overlay, guard)) => (Some(overlay), Some(guard)),
3838 None => (None, None),
3839 };
3840 let materialization_id = materialization_guard
3841 .as_ref()
3842 .and_then(SessionMaterializationGuard::id)
3843 .cloned();
3844 let mut environment_selection = SpawnPreparer::select_environment(self,
3845 instance_id,
3846 &provider,
3847 mode,
3848 environment_profile,
3849 )?;
3850 let provider_home = materialization_guard
3853 .as_ref()
3854 .and_then(SessionMaterializationGuard::provider_home);
3855 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3856 &provider,
3857 mode,
3858 approval_level,
3859 network_allowlist,
3860 provider_home,
3861 )?;
3862 let mut network_overlay_args = network_overlay_args;
3863 if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
3864 network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
3865 }
3866 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3867 (prepared, args) if args.is_empty() => prepared,
3868 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3869 overlay.append_extra_args(args).map_err(|_| {
3870 failure(
3871 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3872 "network allowlist overlay could not merge with instance launch args",
3873 )
3874 })?;
3875 Some(PreparedNativeLaunchOverlay::Instance(overlay))
3876 }
3877 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3878 Some(PreparedNativeLaunchOverlay::Instance(
3879 overlay
3880 .into_instance_with_extra_args(args)
3881 .map_err(|_| {
3882 failure(
3883 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3884 "network allowlist overlay could not merge with environment overlay",
3885 )
3886 })?,
3887 ))
3888 }
3889 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3890 NativeInstanceLaunchOverlay::new(
3891 provider.clone(),
3892 match mode {
3893 SessionMode::Pty => TransportKind::Pty,
3894 SessionMode::Acp => TransportKind::Acp,
3895 SessionMode::Inline => TransportKind::Pipe,
3896 },
3897 Vec::new(),
3898 args,
3899 )
3900 .map_err(|_| {
3901 failure(
3902 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3903 "network allowlist overlay is invalid for this transport",
3904 )
3905 })?,
3906 )),
3907 };
3908 #[cfg(unix)]
3911 let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
3913 (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
3914 });
3915 #[cfg(unix)]
3916 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
3917 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
3918 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3919 let uid = unsafe { libc_geteuid() };
3920 let handle = proxy.reserve(&session_key, m4a_provider, uid);
3921 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
3922 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
3923 Some(vec![
3924 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
3925 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
3926 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
3927 ])
3928 }
3929 _ => None,
3930 };
3931 #[cfg(not(unix))]
3932 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
3933 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
3934 (SessionMode::Pty, _, _, Some(identity)) => {
3935 let merged = match environment_overlay {
3936 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
3937 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3938 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3939 }
3940 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3941 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3942 }
3943 }
3944 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
3945 Some(merged)
3946 }
3947 (SessionMode::Pty, Some(accounts), Some(m4a_provider), None) => {
3948 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3949 match accounts.provision(m4a_provider, &session_key) {
3950 Ok(account) => {
3951 let identity = account.env_mutations();
3952 let merged = match environment_overlay {
3953 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3954 .map(PreparedNativeLaunchOverlay::Instance),
3955 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3956 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3957 }
3958 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3959 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3960 }
3961 }
3962 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3963 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3964 let mut merged = merged;
3965 match accounts.write_session_file(&account) {
3966 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3967 Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3968 Ok(args) if !args.is_empty() => {
3969 merged = match merged {
3970 PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3971 .append_extra_args(args)
3972 .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3973 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3974 PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3975 .into_instance_with_extra_args(args)
3976 .map(PreparedNativeLaunchOverlay::Instance)
3977 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3978 };
3979 tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3980 }
3981 Ok(_) => {}
3982 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3983 },
3984 }
3985 self.m4a_live
3986 .lock()
3987 .unwrap_or_else(|p| p.into_inner())
3988 .insert(account.nick.clone(), (instance_id, provider.clone()));
3989 Some(merged)
3990 }
3991 Err(error) => {
3992 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3993 environment_overlay
3994 }
3995 }
3996 }
3997 (SessionMode::Pty, Some(_), None, _) => {
3998 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3999 environment_overlay
4000 }
4001 _ => environment_overlay,
4002 };
4003 let mut instance_overlay = environment_overlay
4004 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4005 .transpose()?
4006 .flatten();
4007 Ok(PreparedSpawn {
4008 instance_overlay,
4009 environment_selection,
4010 materialization_guard,
4011 harness_mcp_overlay,
4012 #[cfg(feature = "dig2-station-probe")]
4013 browser_station_lease_guard,
4014 materialization_id,
4015 })
4016 }
4017}
4018
4019struct SessionRecords {
4020 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4021}
4022
4023struct SpawnIdempotencyCache {
4024 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4025}
4026
4027struct SpawnIdempotencyEntry {
4028 value: SpawnIdempotencyValue,
4029 expires_at: Instant,
4030}
4031
4032enum SpawnIdempotencyValue {
4033 Standard {
4034 spec: SpawnSpec,
4035 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4036 },
4037 Managed {
4038 request: ManagedWorktreeSpawnRequest,
4039 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4040 },
4041 HarnessMcp,
4042}
4043
4044enum DurableManagedSpawnReplayDecision {
4045 Reserved,
4046 PendingLinked,
4047 Committed(ManagedWorktreeSpawnReceipt),
4048}
4049
4050impl SpawnIdempotencyValue {
4051 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4052 match self {
4053 Self::Standard { result, .. } => result
4054 .as_ref()
4055 .ok()
4056 .and_then(|receipt| receipt.context.as_ref()),
4057 Self::Managed { result, .. } => result
4058 .as_ref()
4059 .ok()
4060 .and_then(|receipt| receipt.spawn.context.as_ref()),
4061 Self::HarnessMcp => None,
4062 }
4063 .is_some_and(|receipt| &receipt.id == context_id)
4064 }
4065}
4066
4067#[derive(Clone, Copy)]
4068struct ControllerLease {
4069 connection_id: u64,
4070 expires_at: Instant,
4071}
4072
4073struct NodeEventHistory {
4074 last_sequence: u64,
4075 replay_floor_sequence: u64,
4076 events: VecDeque<NodeEventEnvelope>,
4077 record_providers: BTreeMap<SessionRecordId, AgentId>,
4078 removed_record_providers: BTreeMap<u64, AgentId>,
4079}
4080
4081impl NodeEventHistory {
4082 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4083 Self {
4084 last_sequence: 0,
4085 replay_floor_sequence: 1,
4086 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4087 record_providers,
4088 removed_record_providers: BTreeMap::new(),
4089 }
4090 }
4091}
4092
4093impl NodeShared {
4094 fn session_grok_leader_socket(
4098 &self,
4099 provider: &AgentId,
4100 mode: SessionMode,
4101 instance_id: AgentInstanceId,
4102 ) -> Option<std::path::PathBuf> {
4103 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4104 return None;
4105 }
4106 let root = self
4107 .m4a_accounts
4108 .as_ref()
4109 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4110 .unwrap_or_else(std::env::temp_dir);
4111 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4112 if let Some(previous) = self
4113 .grok_leaders
4114 .lock()
4115 .unwrap_or_else(|p| p.into_inner())
4116 .insert(instance_id, socket.clone())
4117 {
4118 crate::grok_leader::retire(&previous);
4119 }
4120 Some(socket)
4121 }
4122
4123 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4125 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4126 if let Some(socket) = socket {
4127 crate::grok_leader::retire(&socket);
4128 }
4129 }
4130
4131 fn new_with_incarnation(
4132 handle: Gate4AgentHandle,
4133 access_token: String,
4134 node_id: NodeId,
4135 incarnation_id: NodeIncarnationId,
4136 workspaces: Vec<WorkspaceConfig>,
4137 enabled_providers: Vec<AgentId>,
4138 provider_runtime_statuses: ProviderRuntimeStatuses,
4139 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4140 provider_contracts: Vec<ProviderContractSupport>,
4141 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4142 spawn_profiles: SpawnProfileRegistry,
4143 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4144 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4145 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4146 state_path: Option<PathBuf>,
4147 records: Vec<ManagedSessionRecord>,
4148 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4149 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4150 materializations: Vec<MaterializationOwnershipRecord>,
4151 persistence_warning: Option<String>,
4152 input_settle_timeout_ms: u64,
4153 ) -> Self {
4154 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4155 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4156 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4157 let (harness_mcp_proxy_event_tx, _) =
4158 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4159 let record_providers = records
4160 .iter()
4161 .map(|record| (record.record_id.clone(), record.provider.clone()))
4162 .collect();
4163 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4164 .iter()
4165 .map(|workspace| {
4166 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4167 })
4168 .collect();
4169 for lease in &managed_worktrees {
4170 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4171 }
4172 let managed_worktree_profiles = workspaces.iter()
4173 .map(|workspace| (
4174 workspace.workspace_id.clone(),
4175 workspace.managed_worktree_profiles.clone(),
4176 ))
4177 .collect();
4178 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4179 managed_worktrees,
4180 managed_worktree_tombstones,
4181 ).expect("durable managed worktree registry was validated while loading");
4182 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4183 managed_worktrees.reattach_record_holders(
4184 &managed_worktree_record_holders(&records),
4185 unix_time_ms(),
4186 );
4187 Self {
4188 handle,
4189 access_token,
4190 node_id,
4191 incarnation_id,
4192 started_at_unix_ms: std::time::SystemTime::now()
4193 .duration_since(std::time::UNIX_EPOCH)
4194 .unwrap_or_default()
4195 .as_millis()
4196 .min(u64::MAX as u128) as u64,
4197 workspaces: RwLock::new(
4198 workspaces
4199 .into_iter()
4200 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4201 .collect(),
4202 ),
4203 worktree_service_modes,
4204 managed_worktree_profiles,
4205 managed_worktrees: Mutex::new(managed_worktrees),
4206 enabled_providers,
4207 provider_runtime_statuses,
4208 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4209 provider_runtime_monitor,
4210 provider_contracts,
4211 provider_adapter_contracts,
4212 spawn_profiles,
4213 environment_profiles: RwLock::new(BTreeMap::new()),
4214 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4215 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4216 #[cfg(feature = "dig2-station-probe")]
4217 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4218 bundle_catalog: RwLock::new(BundleCatalog::default()),
4219 delivery_store: Mutex::new(None),
4220 context_pack_store: Mutex::new(None),
4221 context_catalog: RwLock::new(ContextPackCatalog::default()),
4222 native_launch_profile_control,
4223 harness_mcp_registry: None,
4224 native_session_catalog,
4225 session_environment_materializer,
4226 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4227 m4a_live: Mutex::new(BTreeMap::new()),
4228 grok_leaders: Mutex::new(BTreeMap::new()),
4229 #[cfg(unix)]
4230 signer_proxy: std::sync::OnceLock::new(),
4231 materializations: Mutex::new(
4232 materializations
4233 .into_iter()
4234 .map(|record| (record.id().clone(), record))
4235 .collect(),
4236 ),
4237 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4238 entries: BTreeMap::new(),
4239 }),
4240 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4241 controller: Mutex::new(None),
4242 provisional_spawns: Mutex::new(BTreeMap::new()),
4243 pending_acp_options: Mutex::new(BTreeMap::new()),
4244 history: Mutex::new(NodeEventHistory::new(record_providers)),
4245 event_tx,
4246 terminal_event_tx,
4247 agent_stream_event_tx,
4248 harness_mcp_proxy_event_tx,
4249 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4250 pty_input_wake: tokio::sync::Notify::new(),
4251 pty_input_marks: Mutex::new(BTreeMap::new()),
4252 next_connection_id: AtomicU64::new(1),
4253 next_instance_id: AtomicU64::new(1),
4254 next_command_id: AtomicU64::new(1),
4255 shutdown: AtomicBool::new(false),
4256 shutdown_notify: Notify::new(),
4257 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4258 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4259 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4260 mutation_gate: AsyncMutex::new(()),
4261 session_bindings: Mutex::new(BTreeMap::new()),
4262 session_records: Mutex::new(SessionRecords {
4263 records: records
4264 .into_iter()
4265 .map(|record| (record.record_id.clone(), record))
4266 .collect(),
4267 }),
4268 state_path,
4269 persistence_error: RwLock::new(
4270 persistence_warning
4271 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4272 ),
4273 state_transaction: Mutex::new(()),
4274 input_settle_timeout_ms,
4275 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4276 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4277 shell_efficiency_profile: OnceLock::new(),
4278 native_session_gauge: AtomicUsize::new(0),
4279 connection_loop_iterations: AtomicU64::new(0),
4280 connection_events_sent: AtomicU64::new(0),
4281 rejected_commands_total: AtomicU64::new(0),
4282 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4283 drive_loop_iterations_total: AtomicU64::new(0),
4284 drive_loop_iterations_idle: AtomicU64::new(0),
4285 session_record_retention: SessionRecordRetentionConfig::default(),
4286 retired_records_total: AtomicU64::new(0),
4287 #[cfg(feature = "fixture")]
4288 fixture_semantic_hook_policy: false,
4289 #[cfg(feature = "fixture")]
4290 fixture_spawn_managed_worktree_v2_failure_probe:
4291 SpawnManagedWorktreeV2FailureProbe::default(),
4292 }
4293 }
4294
4295 #[cfg(test)]
4296 fn new(
4297 handle: Gate4AgentHandle,
4298 access_token: String,
4299 node_id: NodeId,
4300 workspaces: Vec<WorkspaceConfig>,
4301 enabled_providers: Vec<AgentId>,
4302 ) -> Self {
4303 Self::new_with_incarnation(
4304 handle,
4305 access_token,
4306 node_id,
4307 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4308 workspaces,
4309 enabled_providers,
4310 ProviderRuntimeStatuses::default(),
4311 None,
4312 Vec::new(),
4313 Vec::new(),
4314 SpawnProfileRegistry::default(),
4315 None,
4316 None,
4317 None,
4318 None,
4319 Vec::new(),
4320 Vec::new(),
4321 Vec::new(),
4322 Vec::new(),
4323 None,
4324 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4325 )
4326 }
4327
4328 fn persistence_error(&self) -> Option<String> {
4329 self.persistence_error
4330 .read()
4331 .unwrap_or_else(|poisoned| poisoned.into_inner())
4332 .clone()
4333 }
4334
4335 async fn admit_provider_runtime(
4336 &self,
4337 provider: &AgentId,
4338 requirement: ProviderRuntimeRequirement,
4339 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4340 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4341 let pipe_transport = monitor.supports_pipe_transport(provider);
4342 let acp_transport = monitor.supports_acp_transport(provider);
4343 let provider = provider.clone();
4344 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4345 let (status, admission) = timeout(
4346 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4347 refresh,
4348 )
4349 .await
4350 .map_err(|_| failure(
4351 NodeFailureCode::BackendBusy,
4352 "provider runtime probe exceeded its bounded deadline",
4353 ))?
4354 .map_err(|_| failure(
4355 NodeFailureCode::BackendOperationFailed,
4356 "provider runtime probe task failed",
4357 ))?;
4358 if let Some(status) = status {
4359 self
4360 .provider_runtime_status_updates
4361 .write()
4362 .unwrap_or_else(|poisoned| poisoned.into_inner())
4363 .insert(status.provider().clone(), status);
4364 }
4365 admission.and_then(|policy| {
4366 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4367 })
4368 } else {
4369 crate::provider_runtime::admit_status(
4375 &self.provider_runtime_statuses,
4376 provider,
4377 false,
4378 false,
4379 requirement,
4380 )
4381 };
4382 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4383 admission.map_err(|error| match error {
4384 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4385 NodeFailureCode::BackendOperationFailed,
4386 "provider launcher is unavailable",
4387 ),
4388 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4389 NodeFailureCode::UnsupportedCapability,
4390 "provider semantic capability is not verified",
4391 ),
4392 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4393 NodeFailureCode::BackendBusy,
4394 "provider runtime probe is already in progress",
4395 ),
4396 })
4397 }
4398
4399 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4405 match self.provider_runtime_monitor.as_deref() {
4406 Some(monitor) => monitor.supports_pipe_transport(provider),
4407 None => false,
4408 }
4409 }
4410
4411 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4417 match self.provider_runtime_monitor.as_deref() {
4418 Some(monitor) => monitor.supports_acp_transport(provider),
4419 None => false,
4420 }
4421 }
4422
4423 fn admit_declared_hook_semantics(
4439 &self,
4440 provider: &AgentId,
4441 policy: ProviderRuntimePolicy,
4442 ) -> ProviderRuntimePolicy {
4443 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4444 return policy;
4445 }
4446 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4447 contract.provider == *provider && contract.family == AdapterFamily::Hook
4448 });
4449 if !declares_hook {
4450 return policy;
4451 }
4452 ProviderRuntimePolicy::new(
4453 policy.raw_pty_lifecycle,
4454 policy.semantic_readiness,
4455 policy.structured_prompt,
4456 policy.provider_session_identity,
4457 policy.semantic_resume,
4458 true,
4459 )
4460 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4461 }
4462
4463 fn resolve_network_allowlist_overlay_args(
4478 &self,
4479 provider: &AgentId,
4480 mode: SessionMode,
4481 approval_level: ApprovalLevel,
4482 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4483 provider_home: Option<&Path>,
4484 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4485 let Some(allowlist_id) = network_allowlist else {
4486 return Ok(Vec::new());
4487 };
4488 let catalog = self
4489 .network_allowlist_catalog
4490 .read()
4491 .unwrap_or_else(|poisoned| poisoned.into_inner());
4492 let Some(entry) = catalog.get(allowlist_id) else {
4493 return Err(failure(
4494 NodeFailureCode::UnknownNetworkAllowlist,
4495 "spawn network allowlist is unavailable on this node",
4496 ));
4497 };
4498
4499 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4501 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4502 |message| {
4503 failure(
4504 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4505 &message,
4506 )
4507 },
4508 )?;
4509 if !matches!(mode, SessionMode::Pty) {
4510 return Err(failure(
4511 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4512 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4513 ));
4514 }
4515 let Some(home) = provider_home else {
4516 return Err(failure(
4517 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4518 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4519 ));
4520 };
4521 let domains =
4522 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4523 if domains.is_empty() {
4524 return Err(failure(
4525 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4526 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4527 ));
4528 }
4529 let settings_path = home.join(
4530 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4531 );
4532 let json =
4533 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4534 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4535 |_| {
4536 failure(
4537 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4538 "Claude station network settings sidecar could not be written under ProviderHome",
4539 )
4540 },
4541 )?;
4542 let overlay =
4543 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4544 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4545 }
4546
4547 let Some(native) = entry.provider_native.as_ref() else {
4548 return Ok(Vec::new());
4549 };
4550 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4551 provider.as_str(),
4552 approval_level,
4553 native,
4554 )
4555 .map_err(|message| {
4556 failure(
4557 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4558 &message,
4559 )
4560 })?;
4561 if overlay.is_empty() {
4562 return Ok(Vec::new());
4563 }
4564 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4565 return Err(failure(
4566 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4567 "Codex network_access -c overlay requires PTY or ACP transport",
4568 ));
4569 }
4570 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4571 }
4572
4573 fn resolve_codex_network_access_overlay_args(
4575 &self,
4576 provider: &AgentId,
4577 mode: SessionMode,
4578 approval_level: ApprovalLevel,
4579 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4580 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4581 self.resolve_network_allowlist_overlay_args(
4582 provider,
4583 mode,
4584 approval_level,
4585 network_allowlist,
4586 None,
4587 )
4588 }
4589
4590 fn resolve_environment_profile(
4591 &self,
4592 resolved: &ResolvedSpawnSpec,
4593 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4594 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4600 let catalog = self
4601 .network_allowlist_catalog
4602 .read()
4603 .unwrap_or_else(|poisoned| poisoned.into_inner());
4604 let Some(entry) = catalog.get(allowlist_id) else {
4605 return Err(failure(
4606 NodeFailureCode::UnknownNetworkAllowlist,
4607 "spawn network allowlist is unavailable on this node",
4608 ));
4609 };
4610 if let Some(native) = entry.provider_native.as_ref() {
4613 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4614 resolved.provider.as_str(),
4615 resolved.approval_level,
4616 native,
4617 ) {
4618 return Err(failure(
4619 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4620 &message,
4621 ));
4622 }
4623 }
4624 }
4625 #[cfg(feature = "dig2-station-probe")]
4634 if resolved.browser_profile_id.is_some() {
4635 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4636 match probe_station_reachable() {
4637 Ok(()) => {}
4638 Err(StationProbeError::UnsupportedPlatform)
4639 | Err(StationProbeError::InvalidPipeSuffix) => {
4640 return Err(failure(
4641 NodeFailureCode::BrowserStationProbeUnavailable,
4642 "dig2browser station probe unavailable on this platform",
4643 ));
4644 }
4645 Err(StationProbeError::Unreachable) => {
4646 return Err(failure(
4647 NodeFailureCode::BrowserStationUnreachable,
4648 "dig2browser station named-pipe is unreachable",
4649 ));
4650 }
4651 }
4652 }
4653 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4654 return Ok(None);
4655 };
4656 let profiles = self
4657 .environment_profiles
4658 .read()
4659 .unwrap_or_else(|poisoned| poisoned.into_inner());
4660 let binding = profiles.get(profile_id).ok_or_else(|| {
4661 failure(
4662 NodeFailureCode::UnknownEnvironmentProfile,
4663 "spawn environment profile is unavailable on this node",
4664 )
4665 })?;
4666 if binding.provider != resolved.provider
4667 || binding.native_profile_id(resolved.mode).is_none()
4668 {
4669 return Err(failure(
4670 NodeFailureCode::EnvironmentProfileBindingMismatch,
4671 "spawn environment profile does not match the provider and mode",
4672 ));
4673 }
4674 Ok(Some(ResolvedEnvironmentProfileReceipt {
4678 profile_id: binding.id.clone(),
4679 profile_revision: binding.revision.clone(),
4680 network_allowlist: resolved.network_allowlist.clone(),
4681 browser_profile_id: resolved.browser_profile_id.clone(),
4682 }))
4683 }
4684
4685 fn resolve_bundle(
4686 &self,
4687 resolved: &ResolvedSpawnSpec,
4688 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4689 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4690 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4691 return Ok(None);
4692 };
4693 let catalog = self.bundle_catalog
4694 .read()
4695 .unwrap_or_else(|poisoned| poisoned.into_inner());
4696 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4697 failure(
4698 NodeFailureCode::UnknownBundle,
4699 "spawn bundle is unavailable on this node",
4700 )
4701 })?;
4702 self.bundle_layout(
4703 &resolved.provider,
4704 resolved.mode,
4705 environment_profile,
4706 bundle,
4707 )?;
4708 Ok(Some(bundle.receipt()))
4709 }
4710
4711 fn begin_delivery_stage(
4712 &self,
4713 manifest: DeliveryBundleManifestV2,
4714 ) -> Result<NodeResponse, NodeFailure> {
4715 let mut store = self
4716 .delivery_store
4717 .lock()
4718 .unwrap_or_else(|poisoned| poisoned.into_inner());
4719 let store = store.as_mut().ok_or_else(|| {
4720 failure(
4721 NodeFailureCode::DeliveryStageStorageFailed,
4722 "node delivery store is unavailable",
4723 )
4724 })?;
4725 let begun = store.begin(manifest).map_err(delivery_failure)?;
4726 Ok(NodeResponse::DeliveryStageBegun {
4727 stage_id: begun.stage_id,
4728 manifest_digest: begun.manifest_digest,
4729 missing_blobs: begun.missing_blobs,
4730 })
4731 }
4732
4733 fn put_delivery_blob_chunk(
4734 &self,
4735 stage_id: DeliveryStageId,
4736 blob_digest: DeliveryBlobDigestV1,
4737 offset: u64,
4738 chunk_hex: DeliveryBlobChunkHexV1,
4739 ) -> Result<NodeResponse, NodeFailure> {
4740 let mut store = self
4741 .delivery_store
4742 .lock()
4743 .unwrap_or_else(|poisoned| poisoned.into_inner());
4744 let store = store.as_mut().ok_or_else(|| {
4745 failure(
4746 NodeFailureCode::DeliveryStageStorageFailed,
4747 "node delivery store is unavailable",
4748 )
4749 })?;
4750 let accepted = store
4751 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4752 .map_err(delivery_failure)?;
4753 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4754 stage_id: accepted.stage_id,
4755 blob_digest: accepted.blob_digest,
4756 next_offset: accepted.next_offset,
4757 })
4758 }
4759
4760 fn commit_delivery_stage(
4761 &self,
4762 stage_id: DeliveryStageId,
4763 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4764 let mut store = self
4765 .delivery_store
4766 .lock()
4767 .unwrap_or_else(|poisoned| poisoned.into_inner());
4768 let store = store.as_mut().ok_or_else(|| {
4769 failure(
4770 NodeFailureCode::DeliveryStageStorageFailed,
4771 "node delivery store is unavailable",
4772 )
4773 })?;
4774 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4775 let mut catalog = self
4776 .bundle_catalog
4777 .write()
4778 .unwrap_or_else(|poisoned| poisoned.into_inner());
4779 let mut next_catalog = catalog.clone();
4780 next_catalog
4781 .insert_idempotent(prepared.bundle().clone())
4782 .map_err(|_| {
4783 failure(
4784 NodeFailureCode::DeliveryStageConflict,
4785 "delivery bundle identity conflicts with installed content",
4786 )
4787 })?;
4788 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4789 *catalog = next_catalog;
4790 Ok(receipt)
4791 }
4792
4793 fn abort_delivery_stage(
4794 &self,
4795 stage_id: &DeliveryStageId,
4796 ) -> Result<(), NodeFailure> {
4797 let mut store = self
4798 .delivery_store
4799 .lock()
4800 .unwrap_or_else(|poisoned| poisoned.into_inner());
4801 let store = store.as_mut().ok_or_else(|| {
4802 failure(
4803 NodeFailureCode::DeliveryStageStorageFailed,
4804 "node delivery store is unavailable",
4805 )
4806 })?;
4807 store.abort(stage_id).map_err(delivery_failure)
4808 }
4809
4810 fn resolve_context(
4811 &self,
4812 resolved: &ResolvedSpawnSpec,
4813 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4814 let Some(context_id) = resolved.context_id.as_ref() else {
4815 return Ok(None);
4816 };
4817 self.context_catalog
4818 .read()
4819 .unwrap_or_else(|poisoned| poisoned.into_inner())
4820 .get(context_id)
4821 .map(|context| context.receipt().clone())
4822 .ok_or_else(|| {
4823 failure(
4824 NodeFailureCode::UnknownContextPack,
4825 "spawn context pack is unavailable on this node",
4826 )
4827 })
4828 .map(Some)
4829 }
4830
4831 fn bundle_layout(
4832 &self,
4833 provider: &AgentId,
4834 mode: SessionMode,
4835 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4836 bundle: &NodeBundle,
4837 ) -> Result<BundleProviderLayout, NodeFailure> {
4838 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4839 failure(
4840 NodeFailureCode::BundleBindingMismatch,
4841 "spawn bundle does not match the provider and mode",
4842 )
4843 })?;
4844 if layout != BundleProviderLayout::Codex {
4845 return Ok(layout);
4846 }
4847 let profile = environment_profile.and_then(|receipt| {
4848 let profiles = self
4849 .environment_profiles
4850 .read()
4851 .unwrap_or_else(|poisoned| poisoned.into_inner());
4852 let binding = profiles.get(&receipt.profile_id)?;
4853 if binding.revision != receipt.profile_revision
4854 || &binding.provider != provider
4855 || binding.native_profile_id(mode).is_none()
4856 {
4857 return None;
4858 }
4859 self.environment_materialization_profiles
4860 .read()
4861 .unwrap_or_else(|poisoned| poisoned.into_inner())
4862 .get(&receipt.profile_id)
4863 .cloned()
4864 });
4865 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4866 return Err(failure(
4867 NodeFailureCode::BundleBindingMismatch,
4868 "Codex bundle requires an exact isolated CODEX_HOME profile",
4869 ));
4870 }
4871 Ok(layout)
4872 }
4873
4874 fn select_environment_profile(
4875 &self,
4876 instance_id: AgentInstanceId,
4877 provider: &AgentId,
4878 mode: SessionMode,
4879 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4880 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4881 let Some(environment_profile) = environment_profile else {
4882 return Ok(None);
4883 };
4884 let native_profile_id = {
4885 let profiles = self
4886 .environment_profiles
4887 .read()
4888 .unwrap_or_else(|poisoned| poisoned.into_inner());
4889 let binding = profiles
4890 .get(&environment_profile.profile_id)
4891 .ok_or_else(|| {
4892 failure(
4893 NodeFailureCode::UnknownEnvironmentProfile,
4894 "session environment profile is unavailable on this node",
4895 )
4896 })?;
4897 if &binding.revision != &environment_profile.profile_revision
4898 || &binding.provider != provider
4899 {
4900 return Err(failure(
4901 NodeFailureCode::EnvironmentProfileBindingMismatch,
4902 "session environment profile revision or provider changed",
4903 ));
4904 }
4905 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4906 failure(
4907 NodeFailureCode::EnvironmentProfileBindingMismatch,
4908 "session environment profile does not support this mode",
4909 )
4910 })?
4911 };
4912 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4913 failure(
4914 NodeFailureCode::BackendOperationFailed,
4915 "native environment profile controller is unavailable",
4916 )
4917 })?;
4918 control
4919 .select_native_launch_profile(instance_id, native_profile_id)
4920 .map_err(|_| {
4921 failure(
4922 NodeFailureCode::BackendOperationFailed,
4923 "native environment profile selection failed",
4924 )
4925 })?;
4926 Ok(Some(NativeEnvironmentSelectionGuard {
4927 control: Some(control),
4928 instance_id,
4929 }))
4930 }
4931
4932 fn install_prepared_launch_overlay(
4933 &self,
4934 instance_id: AgentInstanceId,
4935 overlay: PreparedNativeLaunchOverlay,
4936 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4937 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4938 failure(
4939 NodeFailureCode::BackendOperationFailed,
4940 "native launch profile controller is unavailable",
4941 )
4942 })?;
4943 match overlay {
4944 PreparedNativeLaunchOverlay::Environment(overlay) => {
4945 control
4946 .install_native_launch_environment_overlay(instance_id, overlay)
4947 .map_err(|_| failure(
4948 NodeFailureCode::BackendOperationFailed,
4949 "session environment overlay installation failed",
4950 ))?;
4951 Ok(None)
4952 }
4953 PreparedNativeLaunchOverlay::Instance(overlay) => {
4954 control
4955 .install_native_instance_launch_overlay(instance_id, overlay)
4956 .map_err(|_| failure(
4957 NodeFailureCode::BundleBindingMismatch,
4958 "session bundle launch overlay installation failed",
4959 ))?;
4960 Ok(Some(NativeInstanceOverlayGuard {
4961 control: Some(control),
4962 instance_id,
4963 }))
4964 }
4965 }
4966 }
4967
4968 fn environment_materialization_profile(
4969 &self,
4970 environment_profile: &ResolvedEnvironmentProfileReceipt,
4971 ) -> Option<NodeSessionMaterializationProfile> {
4972 self.environment_materialization_profiles
4973 .read()
4974 .unwrap_or_else(|poisoned| poisoned.into_inner())
4975 .get(&environment_profile.profile_id)
4976 .cloned()
4977 }
4978
4979 fn materialization_profile(
4980 &self,
4981 provider: &AgentId,
4982 mode: SessionMode,
4983 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4984 bundle: Option<&ResolvedBundleReceipt>,
4985 context: Option<&ResolvedContextPackReceipt>,
4986 ) -> Result<Option<(
4987 NodeSessionMaterializationProfile,
4988 bool,
4989 Option<BundleProviderLayout>,
4990 )>, NodeFailure> {
4991 let environment = environment_profile
4992 .and_then(|receipt| self.environment_materialization_profile(receipt));
4993 let (bundle, bundle_layout) = match bundle {
4994 Some(receipt) => {
4995 let catalog = self.bundle_catalog.read()
4996 .unwrap_or_else(|poisoned| poisoned.into_inner());
4997 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4998 NodeFailureCode::UnknownBundle,
4999 "session bundle is unavailable on this node",
5000 ))?;
5001 if installed.receipt() != *receipt {
5002 return Err(failure(
5003 NodeFailureCode::BundleBindingMismatch,
5004 "session bundle revision or digest changed",
5005 ));
5006 }
5007 let layout = self.bundle_layout(
5008 provider,
5009 mode,
5010 environment_profile,
5011 installed,
5012 )?;
5013 (Some(installed.clone()), Some(layout))
5014 }
5015 None => (None, None),
5016 };
5017 let has_environment_overlay = environment.is_some() || context.is_some();
5018 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5019 (None, None, None) => None,
5020 (Some(profile), None, None) => Some(Ok(profile)),
5021 (None, Some(bundle), Some(layout)) => {
5022 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5023 }
5024 (Some(profile), Some(bundle), Some(layout)) => {
5025 Some(profile.with_bundle(bundle, layout))
5026 }
5027 _ => {
5028 return Err(failure(
5029 NodeFailureCode::BundleBindingMismatch,
5030 "session bundle provider layout is unavailable",
5031 ));
5032 }
5033 };
5034 let profile = profile
5035 .transpose()
5036 .map_err(|_| failure(
5037 NodeFailureCode::BundleMaterializationFailed,
5038 "session bundle materialization profile is invalid",
5039 ))?;
5040 let profile = match (profile, context) {
5041 (Some(profile), Some(receipt)) => {
5042 let catalog = self.context_catalog
5043 .read()
5044 .unwrap_or_else(|poisoned| poisoned.into_inner());
5045 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5046 NodeFailureCode::UnknownContextPack,
5047 "session context pack is unavailable on this node",
5048 ))?;
5049 if pack.receipt() != receipt {
5050 return Err(failure(
5051 NodeFailureCode::ContextPackMaterializationFailed,
5052 "session context pack identity changed",
5053 ));
5054 }
5055 profile.with_context(pack)
5056 }
5057 (None, Some(receipt)) => {
5058 let catalog = self.context_catalog
5059 .read()
5060 .unwrap_or_else(|poisoned| poisoned.into_inner());
5061 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5062 NodeFailureCode::UnknownContextPack,
5063 "session context pack is unavailable on this node",
5064 ))?;
5065 if pack.receipt() != receipt {
5066 return Err(failure(
5067 NodeFailureCode::ContextPackMaterializationFailed,
5068 "session context pack identity changed",
5069 ));
5070 }
5071 NodeSessionMaterializationProfile::from_context(pack)
5072 }
5073 (Some(profile), None) => Ok(profile),
5074 (None, None) => return Ok(None),
5075 }
5076 .map_err(|_| failure(
5077 NodeFailureCode::ContextPackMaterializationFailed,
5078 "session context pack materialization profile is invalid",
5079 ))?;
5080 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5081 }
5082
5083 fn prepare_session_materialization<'a>(
5084 &'a self,
5085 address: &SessionAddress,
5086 provider: &AgentId,
5087 mode: SessionMode,
5088 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5089 bundle: Option<&ResolvedBundleReceipt>,
5090 context: Option<&ResolvedContextPackReceipt>,
5091 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5092 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5093 let Some((profile, has_environment_overlay, bundle_layout)) =
5094 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5095 else {
5096 return Ok(None);
5097 };
5098 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5099 failure(
5100 NodeFailureCode::BackendOperationFailed,
5101 "session environment materializer is unavailable",
5102 )
5103 })?;
5104 let materialization_failure_code = if context.is_some() {
5105 NodeFailureCode::ContextPackMaterializationFailed
5106 } else if bundle.is_some() {
5107 NodeFailureCode::BundleMaterializationFailed
5108 } else {
5109 NodeFailureCode::BackendOperationFailed
5110 };
5111 let id = MaterializationId::new(format!(
5112 "mat-{}-{}-{}",
5113 self.incarnation_id,
5114 address.session.instance_id.0,
5115 address.session.generation.0,
5116 ))
5117 .map_err(|_| {
5118 failure(
5119 NodeFailureCode::BackendOperationFailed,
5120 "session environment materialization identity failed",
5121 )
5122 })?;
5123 let owner = MaterializationOwner::Session {
5124 incarnation_id: self.incarnation_id,
5125 instance_id: address.session.instance_id,
5126 generation: address.session.generation,
5127 };
5128 let mut ownership = materializer
5129 .begin(
5130 id.clone(),
5131 environment_profile.cloned(),
5132 bundle.cloned(),
5133 context.cloned(),
5134 owner,
5135 managed_lease_id,
5136 &profile,
5137 unix_time_ms(),
5138 )
5139 .map_err(|_| {
5140 failure(
5141 materialization_failure_code,
5142 "session materialization preparation failed",
5143 )
5144 })?;
5145 {
5146 let _transaction = self
5147 .state_transaction
5148 .lock()
5149 .unwrap_or_else(|poisoned| poisoned.into_inner());
5150 let duplicate = self
5151 .materializations
5152 .lock()
5153 .unwrap_or_else(|poisoned| poisoned.into_inner())
5154 .insert(id.clone(), ownership.clone());
5155 if duplicate.is_some() {
5156 return Err(failure(
5157 NodeFailureCode::BackendOperationFailed,
5158 "session environment materialization identity conflict",
5159 ));
5160 }
5161 if let Err(error) = self.persist_state_locked() {
5162 self.materializations
5163 .lock()
5164 .unwrap_or_else(|poisoned| poisoned.into_inner())
5165 .remove(&id);
5166 return Err(persistence_failure(error));
5167 }
5168 }
5169 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5170 Ok(environment) => environment,
5171 Err(_) => {
5172 let transaction = self
5173 .state_transaction
5174 .lock()
5175 .unwrap_or_else(|poisoned| poisoned.into_inner());
5176 self.materializations
5177 .lock()
5178 .unwrap_or_else(|poisoned| poisoned.into_inner())
5179 .insert(id.clone(), ownership.clone());
5180 if self.persist_state_locked().is_err() {
5181 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5182 }
5183 drop(transaction);
5184 if ownership.state() == MaterializationState::CleanupRequired {
5185 let _ = self.cleanup_materialization(&id);
5186 }
5187 return Err(failure(
5188 materialization_failure_code,
5189 "session materialization failed",
5190 ));
5191 }
5192 };
5193 {
5194 let _transaction = self
5195 .state_transaction
5196 .lock()
5197 .unwrap_or_else(|poisoned| poisoned.into_inner());
5198 self.materializations
5199 .lock()
5200 .unwrap_or_else(|poisoned| poisoned.into_inner())
5201 .insert(id.clone(), ownership.clone());
5202 if let Err(error) = self.persist_state_locked() {
5203 drop(_transaction);
5204 let _ = self.cleanup_materialization(&id);
5205 return Err(persistence_failure(error));
5206 }
5207 }
5208 let guard = SessionMaterializationGuard {
5209 shared: self,
5210 id: Some(id),
5211 provider_home: Some(ownership.provider_home().to_path_buf()),
5212 };
5213 let bundle_arguments = match bundle {
5214 Some(receipt) => {
5215 let catalog = self.bundle_catalog.read()
5216 .unwrap_or_else(|poisoned| poisoned.into_inner());
5217 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5218 NodeFailureCode::UnknownBundle,
5219 "session bundle is unavailable on this node",
5220 ))?;
5221 if installed.receipt() != *receipt {
5222 return Err(failure(
5223 NodeFailureCode::BundleBindingMismatch,
5224 "session bundle revision or digest changed",
5225 ));
5226 }
5227 let layout = bundle_layout.ok_or_else(|| failure(
5228 NodeFailureCode::BundleBindingMismatch,
5229 "materialized bundle provider layout is unavailable",
5230 ))?;
5231 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5232 failure(
5233 NodeFailureCode::BundleMaterializationFailed,
5234 "session bundle byte revalidation failed",
5235 )
5236 })?;
5237 Some(bundle_launch_arguments(
5238 layout,
5239 ownership.bundle_root(),
5240 ownership.provider_home(),
5241 ).map_err(|_| failure(
5242 NodeFailureCode::BundleBindingMismatch,
5243 "session bundle launch binding is invalid",
5244 ))?)
5245 }
5246 None => None,
5247 };
5248 if let Some(receipt) = context {
5249 materializer
5250 .revalidate_context(&ownership, receipt)
5251 .map_err(|_| failure(
5252 NodeFailureCode::ContextPackMaterializationFailed,
5253 "session context pack byte revalidation failed",
5254 ))?;
5255 }
5256 let transport = match mode {
5257 SessionMode::Pty => TransportKind::Pty,
5258 SessionMode::Inline => TransportKind::Pipe,
5259 SessionMode::Acp => TransportKind::Acp,
5260 };
5261 let overlay = if let Some(extra_args) = bundle_arguments {
5262 Some(PreparedNativeLaunchOverlay::Instance(
5263 NativeInstanceLaunchOverlay::new(
5264 provider.clone(),
5265 transport,
5266 if has_environment_overlay {
5267 environment
5268 } else {
5269 Vec::new()
5270 },
5271 extra_args,
5272 )
5273 .map_err(|_| {
5274 failure(
5275 NodeFailureCode::BundleBindingMismatch,
5276 "session bundle launch overlay is invalid",
5277 )
5278 })?,
5279 ))
5280 } else if has_environment_overlay {
5281 let context_only = environment_profile.is_none()
5282 && bundle.is_none()
5283 && context.is_some();
5284 let overlay = if context_only {
5285 NativeLaunchEnvironmentOverlay::new_context_root(
5286 provider.clone(),
5287 transport,
5288 environment,
5289 )
5290 .map_err(|_| {
5291 failure(
5292 NodeFailureCode::ContextPackMaterializationFailed,
5293 "context-only launch overlay is invalid",
5294 )
5295 })?
5296 } else {
5297 NativeLaunchEnvironmentOverlay::new(
5298 provider.clone(),
5299 transport,
5300 environment,
5301 )
5302 .map_err(|_| {
5303 failure(
5304 NodeFailureCode::BackendOperationFailed,
5305 "session environment overlay is invalid",
5306 )
5307 })?
5308 };
5309 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5310 } else {
5311 None
5312 };
5313 Ok(Some((overlay, guard)))
5314 }
5315
5316 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5317 let Some(mut ownership) = self
5318 .materializations
5319 .lock()
5320 .unwrap_or_else(|poisoned| poisoned.into_inner())
5321 .get(id)
5322 .cloned()
5323 else {
5324 return Ok(());
5325 };
5326 if ownership.state() != MaterializationState::CleanupRequired {
5327 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5328 failure(
5329 NodeFailureCode::BackendOperationFailed,
5330 "session environment cleanup state is invalid",
5331 )
5332 })?;
5333 }
5334 {
5335 let _transaction = self
5336 .state_transaction
5337 .lock()
5338 .unwrap_or_else(|poisoned| poisoned.into_inner());
5339 let previous = self
5340 .materializations
5341 .lock()
5342 .unwrap_or_else(|poisoned| poisoned.into_inner())
5343 .insert(id.clone(), ownership.clone());
5344 if let Err(error) = self.persist_state_locked() {
5345 let mut materializations = self
5346 .materializations
5347 .lock()
5348 .unwrap_or_else(|poisoned| poisoned.into_inner());
5349 if let Some(previous) = previous {
5350 materializations.insert(id.clone(), previous);
5351 }
5352 return Err(persistence_failure(error));
5353 }
5354 }
5355 let cleanup = self
5356 .session_environment_materializer
5357 .as_ref()
5358 .ok_or(())
5359 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5360 if cleanup.is_err() {
5361 let _ = ownership.mark_recovery_required(unix_time_ms());
5362 self.materializations
5363 .lock()
5364 .unwrap_or_else(|poisoned| poisoned.into_inner())
5365 .insert(id.clone(), ownership);
5366 if self.persist_state().is_err() {
5367 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5368 }
5369 return Err(failure(
5370 NodeFailureCode::BackendOperationFailed,
5371 "session environment cleanup requires recovery",
5372 ));
5373 }
5374 let _transaction = self
5375 .state_transaction
5376 .lock()
5377 .unwrap_or_else(|poisoned| poisoned.into_inner());
5378 self.materializations
5379 .lock()
5380 .unwrap_or_else(|poisoned| poisoned.into_inner())
5381 .remove(id);
5382 if let Err(error) = self.persist_state_locked() {
5383 self.materializations
5384 .lock()
5385 .unwrap_or_else(|poisoned| poisoned.into_inner())
5386 .insert(id.clone(), ownership);
5387 return Err(persistence_failure(error));
5388 }
5389 Ok(())
5390 }
5391
5392 fn resolve_record_materialization(
5393 &self,
5394 record_id: &SessionRecordId,
5395 provider: &AgentId,
5396 mode: SessionMode,
5397 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5398 bundle: Option<&ResolvedBundleReceipt>,
5399 context: Option<&ResolvedContextPackReceipt>,
5400 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5401 let ownership = {
5402 let materializations = self
5403 .materializations
5404 .lock()
5405 .unwrap_or_else(|poisoned| poisoned.into_inner());
5406 let mut matches = materializations.values().filter(|record| {
5407 record.owner()
5408 == &MaterializationOwner::Record {
5409 record_id: record_id.clone(),
5410 }
5411 });
5412 let ownership = matches.next().cloned();
5413 if matches.next().is_some() {
5414 return Err(failure(
5415 NodeFailureCode::BackendOperationFailed,
5416 "managed session has conflicting environment ownership",
5417 ));
5418 }
5419 ownership
5420 };
5421 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5422 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5423 failure(
5424 NodeFailureCode::ContextPackMaterializationFailed,
5425 "session context materializer is unavailable",
5426 )
5427 })?;
5428 let pack = materializer
5429 .revalidate_context(ownership, receipt)
5430 .map_err(|_| {
5431 self.mark_materialization_recovery_required(ownership.id());
5432 failure(
5433 NodeFailureCode::ContextPackMaterializationFailed,
5434 "managed session context pack revalidation failed",
5435 )
5436 })?;
5437 self.context_catalog
5438 .write()
5439 .unwrap_or_else(|poisoned| poisoned.into_inner())
5440 .insert(pack)
5441 .map_err(|_| failure(
5442 NodeFailureCode::ContextPackMaterializationFailed,
5443 "managed session context pack catalog restore failed",
5444 ))?;
5445 }
5446 let materialization = self.materialization_profile(
5447 provider,
5448 mode,
5449 environment_profile,
5450 bundle,
5451 context,
5452 )?;
5453 match (ownership, materialization) {
5454 (None, None) => Ok(None),
5455 (None, Some(_)) | (Some(_), None) => Err(failure(
5456 NodeFailureCode::BackendOperationFailed,
5457 "managed session materialization is unavailable",
5458 )),
5459 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5460 if ownership.environment_profile() != environment_profile
5461 || ownership.bundle() != bundle
5462 || ownership.context() != context
5463 || ownership.state() != MaterializationState::Ready
5464 {
5465 self.mark_materialization_recovery_required(ownership.id());
5466 return Err(failure(
5467 NodeFailureCode::BackendOperationFailed,
5468 "managed session environment ownership requires recovery",
5469 ));
5470 }
5471 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5472 failure(
5473 NodeFailureCode::BackendOperationFailed,
5474 "session environment materializer is unavailable",
5475 )
5476 })?;
5477 if materializer.revalidate(&ownership).is_err() {
5478 self.mark_materialization_recovery_required(ownership.id());
5479 return Err(failure(
5480 NodeFailureCode::BackendOperationFailed,
5481 "managed session environment revalidation failed",
5482 ));
5483 }
5484 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5485 let catalog = self.bundle_catalog.read()
5486 .unwrap_or_else(|poisoned| poisoned.into_inner());
5487 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5488 return Err(failure(
5489 NodeFailureCode::UnknownBundle,
5490 "managed session bundle is unavailable",
5491 ));
5492 };
5493 let layout = bundle_layout.ok_or_else(|| failure(
5494 NodeFailureCode::BundleBindingMismatch,
5495 "record bundle provider layout is unavailable",
5496 ))?;
5497 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5498 drop(catalog);
5499 self.mark_materialization_recovery_required(ownership.id());
5500 return Err(failure(
5501 NodeFailureCode::BundleMaterializationFailed,
5502 "managed session bundle revalidation failed",
5503 ));
5504 }
5505 Some(bundle_launch_arguments(
5506 layout,
5507 ownership.bundle_root(),
5508 ownership.provider_home(),
5509 ).map_err(|_| failure(
5510 NodeFailureCode::BundleBindingMismatch,
5511 "managed session bundle launch binding is invalid",
5512 ))?)
5513 } else {
5514 None
5515 };
5516 if !has_environment_materialization {
5517 let overlay = bundle_arguments.map(|extra_args| {
5518 NativeInstanceLaunchOverlay::new(
5519 provider.clone(),
5520 TransportKind::Pty,
5521 Vec::new(),
5522 extra_args,
5523 )
5524 .map(PreparedNativeLaunchOverlay::Instance)
5525 .map_err(|_| failure(
5526 NodeFailureCode::BundleBindingMismatch,
5527 "managed session bundle launch overlay is invalid",
5528 ))
5529 }).transpose()?;
5530 return Ok(Some((ownership.id().clone(), overlay)));
5531 }
5532 let environment = match materializer.resolve_environment(&ownership, &profile) {
5533 Ok(environment) => environment,
5534 Err(
5535 SessionEnvironmentMaterializeError::SecretUnavailable
5536 | SessionEnvironmentMaterializeError::SecretDenied
5537 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5538 ) => {
5539 return Err(failure(
5540 NodeFailureCode::BackendOperationFailed,
5541 "managed session environment resolution failed",
5542 ));
5543 }
5544 Err(
5545 SessionEnvironmentMaterializeError::InvalidRoot
5546 | SessionEnvironmentMaterializeError::OwnershipMismatch
5547 | SessionEnvironmentMaterializeError::Filesystem(_)
5548 | SessionEnvironmentMaterializeError::Record(_),
5549 ) => {
5550 self.mark_materialization_recovery_required(ownership.id());
5551 return Err(failure(
5552 NodeFailureCode::BackendOperationFailed,
5553 "managed session environment resolution failed",
5554 ));
5555 }
5556 };
5557 let transport = match mode {
5558 SessionMode::Pty => TransportKind::Pty,
5559 SessionMode::Inline => TransportKind::Pipe,
5560 SessionMode::Acp => TransportKind::Acp,
5561 };
5562 let overlay = match bundle_arguments {
5563 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5564 NativeInstanceLaunchOverlay::new(
5565 provider.clone(),
5566 transport,
5567 environment,
5568 extra_args,
5569 )
5570 .map_err(|_| failure(
5571 NodeFailureCode::BundleBindingMismatch,
5572 "managed session bundle launch overlay is invalid",
5573 ))?,
5574 ),
5575 None => PreparedNativeLaunchOverlay::Environment(
5576 NativeLaunchEnvironmentOverlay::new(
5577 provider.clone(),
5578 transport,
5579 environment,
5580 )
5581 .map_err(|_| {
5582 failure(
5583 NodeFailureCode::BackendOperationFailed,
5584 "managed session environment overlay is invalid",
5585 )
5586 })?,
5587 ),
5588 };
5589 Ok(Some((ownership.id().clone(), Some(overlay))))
5590 }
5591 }
5592 }
5593
5594 fn destroy_record_materialization(
5595 &self,
5596 record_id: &SessionRecordId,
5597 ) -> Result<bool, NodeFailure> {
5598 let ids = self
5599 .materializations
5600 .lock()
5601 .unwrap_or_else(|poisoned| poisoned.into_inner())
5602 .values()
5603 .filter_map(|record| {
5604 (record.owner()
5605 == &MaterializationOwner::Record {
5606 record_id: record_id.clone(),
5607 })
5608 .then(|| record.id().clone())
5609 })
5610 .collect::<Vec<_>>();
5611 if ids.len() > 1 {
5612 return Err(failure(
5613 NodeFailureCode::BackendOperationFailed,
5614 "managed session has conflicting environment ownership",
5615 ));
5616 }
5617 let Some(id) = ids.first().cloned() else {
5618 return Ok(false);
5619 };
5620 let (terminal_record, mut cleanup_ownership) = {
5621 let _transaction = self
5622 .state_transaction
5623 .lock()
5624 .unwrap_or_else(|poisoned| poisoned.into_inner());
5625 let original_record = self.record(record_id)?;
5626 let mut terminal_record = original_record.clone();
5627 terminal_record.provider_session = None;
5628 terminal_record.active_session = None;
5629 terminal_record.state = ManagedSessionState::Unavailable;
5630 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5631 terminal_record.updated_at_unix_ms = unix_time_ms();
5632 let original_ownership = self
5633 .materializations
5634 .lock()
5635 .unwrap_or_else(|poisoned| poisoned.into_inner())
5636 .get(&id)
5637 .cloned()
5638 .ok_or_else(|| {
5639 failure(
5640 NodeFailureCode::BackendOperationFailed,
5641 "managed session environment ownership disappeared during cleanup",
5642 )
5643 })?;
5644 let mut cleanup_ownership = original_ownership.clone();
5645 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5646 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5647 failure(
5648 NodeFailureCode::BackendOperationFailed,
5649 "managed session environment cleanup state is invalid",
5650 )
5651 })?;
5652 }
5653 self.session_records
5654 .lock()
5655 .unwrap_or_else(|poisoned| poisoned.into_inner())
5656 .records
5657 .insert(record_id.clone(), terminal_record.clone());
5658 self.materializations
5659 .lock()
5660 .unwrap_or_else(|poisoned| poisoned.into_inner())
5661 .insert(id.clone(), cleanup_ownership.clone());
5662 if let Err(error) = self.persist_state_locked() {
5663 self.session_records
5664 .lock()
5665 .unwrap_or_else(|poisoned| poisoned.into_inner())
5666 .records
5667 .insert(record_id.clone(), original_record);
5668 self.materializations
5669 .lock()
5670 .unwrap_or_else(|poisoned| poisoned.into_inner())
5671 .insert(id.clone(), original_ownership);
5672 return Err(persistence_failure(error));
5673 }
5674 (terminal_record, cleanup_ownership)
5675 };
5676 let cleanup = self
5677 .session_environment_materializer
5678 .as_ref()
5679 .ok_or(())
5680 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5681 if cleanup.is_err() {
5682 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5683 self.materializations
5684 .lock()
5685 .unwrap_or_else(|poisoned| poisoned.into_inner())
5686 .insert(id.clone(), cleanup_ownership);
5687 if self.persist_state().is_err() {
5688 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5689 }
5690 return Err(failure(
5691 NodeFailureCode::BackendOperationFailed,
5692 "managed session environment cleanup requires recovery",
5693 ));
5694 }
5695 let _transaction = self
5696 .state_transaction
5697 .lock()
5698 .unwrap_or_else(|poisoned| poisoned.into_inner());
5699 self.remove_record_memory(record_id);
5700 self.materializations
5701 .lock()
5702 .unwrap_or_else(|poisoned| poisoned.into_inner())
5703 .remove(&id);
5704 if let Err(error) = self.persist_state_locked() {
5705 self.session_records
5706 .lock()
5707 .unwrap_or_else(|poisoned| poisoned.into_inner())
5708 .records
5709 .insert(record_id.clone(), terminal_record);
5710 self.materializations
5711 .lock()
5712 .unwrap_or_else(|poisoned| poisoned.into_inner())
5713 .insert(id, cleanup_ownership);
5714 return Err(persistence_failure(error));
5715 }
5716 Ok(true)
5717 }
5718
5719 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5720 let changed = {
5721 let mut materializations = self
5722 .materializations
5723 .lock()
5724 .unwrap_or_else(|poisoned| poisoned.into_inner());
5725 let Some(ownership) = materializations.get_mut(id) else {
5726 return;
5727 };
5728 ownership.mark_recovery_required(unix_time_ms()).is_ok()
5729 };
5730 if changed && self.persist_state().is_err() {
5731 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5732 }
5733 }
5734
5735 fn reconcile_materializations(&self) {
5736 let records = self
5737 .materializations
5738 .lock()
5739 .unwrap_or_else(|poisoned| poisoned.into_inner())
5740 .values()
5741 .cloned()
5742 .collect::<Vec<_>>();
5743 for mut ownership in records {
5744 let result = match (ownership.state(), ownership.owner()) {
5745 (MaterializationState::Preparing, _)
5746 | (MaterializationState::CleanupRequired, _) => {
5747 self.cleanup_materialization(ownership.id())
5748 }
5749 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5750 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5751 self.materializations
5752 .lock()
5753 .unwrap_or_else(|poisoned| poisoned.into_inner())
5754 .insert(ownership.id().clone(), ownership.clone());
5755 self.persist_state().map_err(persistence_failure)
5756 } else {
5757 Err(failure(
5758 NodeFailureCode::BackendOperationFailed,
5759 "session environment reconciliation failed",
5760 ))
5761 }
5762 }
5763 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5764 .session_environment_materializer
5765 .as_ref()
5766 .ok_or_else(|| {
5767 failure(
5768 NodeFailureCode::BackendOperationFailed,
5769 "session environment materializer is unavailable",
5770 )
5771 })
5772 .and_then(|materializer| {
5773 materializer.revalidate(&ownership).map_err(|_| failure(
5774 NodeFailureCode::BackendOperationFailed,
5775 "session materialization revalidation failed",
5776 ))?;
5777 let record = self.record(record_id)?;
5778 if record.environment_profile.as_ref() != ownership.environment_profile()
5779 || record.bundle.as_ref() != ownership.bundle()
5780 || record.context.as_ref() != ownership.context()
5781 {
5782 return Err(failure(
5783 NodeFailureCode::BackendOperationFailed,
5784 "session materialization ownership changed after restart",
5785 ));
5786 }
5787 if let Some(receipt) = ownership.bundle() {
5788 let catalog = self.bundle_catalog.read()
5789 .unwrap_or_else(|poisoned| poisoned.into_inner());
5790 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5791 NodeFailureCode::UnknownBundle,
5792 "session bundle is unavailable after restart",
5793 ))?;
5794 if bundle.receipt() != *receipt {
5795 return Err(failure(
5796 NodeFailureCode::BundleBindingMismatch,
5797 "session bundle changed after restart",
5798 ));
5799 }
5800 let layout = self.bundle_layout(
5801 &record.provider,
5802 record.mode,
5803 record.environment_profile.as_ref(),
5804 bundle,
5805 )?;
5806 materializer
5807 .revalidate_bundle(&ownership, bundle, layout)
5808 .map_err(|_| failure(
5809 NodeFailureCode::BundleMaterializationFailed,
5810 "session bundle revalidation failed",
5811 ))?;
5812 }
5813 if let Some(receipt) = ownership.context() {
5814 let pack = materializer
5815 .revalidate_context(&ownership, receipt)
5816 .map_err(|_| {
5817 failure(
5818 NodeFailureCode::ContextPackMaterializationFailed,
5819 "session context revalidation failed",
5820 )
5821 })?;
5822 self.context_catalog
5823 .write()
5824 .unwrap_or_else(|poisoned| poisoned.into_inner())
5825 .insert(pack)
5826 .map_err(|_| {
5827 failure(
5828 NodeFailureCode::ContextPackMaterializationFailed,
5829 "session context catalog restore failed",
5830 )
5831 })?;
5832 }
5833 Ok(())
5834 }),
5835 (MaterializationState::RecoveryRequired, _) => Ok(()),
5836 };
5837 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5838 let _ = ownership.mark_recovery_required(unix_time_ms());
5839 self.materializations
5840 .lock()
5841 .unwrap_or_else(|poisoned| poisoned.into_inner())
5842 .insert(ownership.id().clone(), ownership);
5843 if self.persist_state().is_err() {
5844 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5845 }
5846 }
5847 }
5848 let materialized_profiles = self
5849 .environment_materialization_profiles
5850 .read()
5851 .unwrap_or_else(|poisoned| poisoned.into_inner())
5852 .keys()
5853 .cloned()
5854 .collect::<Vec<_>>();
5855 let ownership = self
5856 .materializations
5857 .lock()
5858 .unwrap_or_else(|poisoned| poisoned.into_inner())
5859 .values()
5860 .cloned()
5861 .collect::<Vec<_>>();
5862 let changed = {
5863 let mut session_records = self
5864 .session_records
5865 .lock()
5866 .unwrap_or_else(|poisoned| poisoned.into_inner());
5867 let mut changed = false;
5868 for record in session_records.records.values_mut() {
5869 let environment_profile = record.environment_profile.as_ref();
5870 if environment_profile.is_none()
5871 && record.bundle.is_none()
5872 && record.context.is_none()
5873 {
5874 continue;
5875 }
5876 if record.bundle.is_none()
5877 && environment_profile.is_some_and(|profile| {
5878 !materialized_profiles.contains(&profile.profile_id)
5879 })
5880 {
5881 continue;
5882 }
5883 let has_exact_ownership = ownership.iter().any(|candidate| {
5884 candidate.state() == MaterializationState::Ready
5885 && candidate.environment_profile() == environment_profile
5886 && candidate.bundle() == record.bundle.as_ref()
5887 && candidate.context() == record.context.as_ref()
5888 && candidate.owner()
5889 == &MaterializationOwner::Record {
5890 record_id: record.record_id.clone(),
5891 }
5892 });
5893 if !has_exact_ownership {
5894 record.provider_session = None;
5895 record.active_session = None;
5896 record.state = ManagedSessionState::Unavailable;
5897 record.last_error = Some(if record.bundle.is_some() {
5898 "bundle-unavailable".to_owned()
5899 } else if record.context.is_some() {
5900 "context-unavailable".to_owned()
5901 } else {
5902 "environment-profile-unavailable".to_owned()
5903 });
5904 record.updated_at_unix_ms = unix_time_ms();
5905 changed = true;
5906 }
5907 }
5908 changed
5909 };
5910 if changed && self.persist_state().is_err() {
5911 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5912 }
5913 }
5914
5915 fn replay_spawn_spec(
5916 &self,
5917 spec: &SpawnSpec,
5918 now: Instant,
5919 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5920 let mut cache = self
5921 .spawn_idempotency
5922 .lock()
5923 .unwrap_or_else(|poisoned| poisoned.into_inner());
5924 cache.entries.retain(|_, entry| entry.expires_at > now);
5925 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5926 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5927 return Err(failure(
5928 NodeFailureCode::SpawnIdempotencyConflict,
5929 "spawn idempotency key was reused across spawn kinds",
5930 ));
5931 };
5932 if existing != spec {
5933 return Err(failure(
5934 NodeFailureCode::SpawnIdempotencyConflict,
5935 "spawn idempotency key was reused with a different specification",
5936 ));
5937 }
5938 return Ok(Some(result.clone()));
5939 }
5940 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5941 return Err(failure(
5942 NodeFailureCode::SpawnIdempotencyCapacity,
5943 "spawn idempotency cache reached its bounded capacity",
5944 ));
5945 }
5946 Ok(None)
5947 }
5948
5949 fn remember_spawn_spec(
5950 &self,
5951 spec: SpawnSpec,
5952 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5953 accepted_at: Instant,
5954 ) {
5955 self
5956 .spawn_idempotency
5957 .lock()
5958 .unwrap_or_else(|poisoned| poisoned.into_inner())
5959 .entries
5960 .insert(
5961 spec.idempotency_key.clone(),
5962 SpawnIdempotencyEntry {
5963 value: SpawnIdempotencyValue::Standard { spec, result },
5964 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5965 },
5966 );
5967 }
5968
5969 fn claim_harness_mcp_spawn(
5970 &self,
5971 _reservation_id: &HarnessMcpReservationId,
5972 _activation_digest: &HarnessMcpActivationDigest,
5973 spec: &SpawnSpec,
5974 now: Instant,
5975 ) -> Result<(), NodeFailure> {
5976 let mut cache = self.spawn_idempotency.lock()
5977 .unwrap_or_else(|poisoned| poisoned.into_inner());
5978 cache.entries.retain(|_, entry| entry.expires_at > now);
5979 if cache.entries.contains_key(&spec.idempotency_key) {
5980 return Err(failure(
5981 NodeFailureCode::SpawnIdempotencyConflict,
5982 "spawn idempotency key was reused across spawn kinds",
5983 ));
5984 }
5985 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5986 return Err(failure(
5987 NodeFailureCode::SpawnIdempotencyCapacity,
5988 "spawn idempotency cache reached its bounded capacity",
5989 ));
5990 }
5991 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5992 value: SpawnIdempotencyValue::HarnessMcp,
5993 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5994 });
5995 Ok(())
5996 }
5997
5998 fn replay_managed_spawn(
5999 &self,
6000 request: &ManagedWorktreeSpawnRequest,
6001 now: Instant,
6002 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6003 let mut cache = self.spawn_idempotency.lock()
6004 .unwrap_or_else(|poisoned| poisoned.into_inner());
6005 cache.entries.retain(|_, entry| entry.expires_at > now);
6006 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6007 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6008 return Err(failure(
6009 NodeFailureCode::SpawnIdempotencyConflict,
6010 "spawn idempotency key was reused across spawn kinds",
6011 ));
6012 };
6013 if existing != request {
6014 return Err(failure(
6015 NodeFailureCode::SpawnIdempotencyConflict,
6016 "spawn idempotency key was reused with a different managed specification",
6017 ));
6018 }
6019 return Ok(Some(result.clone()));
6020 }
6021 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6022 return Err(failure(
6023 NodeFailureCode::SpawnIdempotencyCapacity,
6024 "spawn idempotency cache reached its bounded capacity",
6025 ));
6026 }
6027 Ok(None)
6028 }
6029
6030 fn remember_managed_spawn(
6031 &self,
6032 request: ManagedWorktreeSpawnRequest,
6033 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6034 accepted_at: Instant,
6035 ) {
6036 self.spawn_idempotency.lock()
6037 .unwrap_or_else(|poisoned| poisoned.into_inner())
6038 .entries
6039 .insert(
6040 request.spawn_spec.idempotency_key.clone(),
6041 SpawnIdempotencyEntry {
6042 value: SpawnIdempotencyValue::Managed { request, result },
6043 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6044 },
6045 );
6046 }
6047
6048 fn remember_managed_spawn_attempt(
6049 &self,
6050 durable_v2_key: Option<&SpawnIdempotencyKey>,
6051 request: ManagedWorktreeSpawnRequest,
6052 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6053 accepted_at: Instant,
6054 ) {
6055 if durable_v2_key.is_none() {
6056 self.remember_managed_spawn(request, result, accepted_at);
6057 }
6058 }
6059
6060 fn acquire_durable_managed_spawn_v2(
6061 &self,
6062 request: &ManagedWorktreeSpawnRequestV2,
6063 request_digest: String,
6064 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6065 if self.state_path.is_none() {
6066 return Err(failure(
6067 NodeFailureCode::BackendOperationFailed,
6068 "managed worktree V2 requires durable node state",
6069 ));
6070 }
6071 let _transaction = self.state_transaction
6072 .lock()
6073 .unwrap_or_else(|poisoned| poisoned.into_inner());
6074 let key = request.spawn_spec.idempotency_key.clone();
6075 let mut previous = None;
6076 {
6077 let mut replays = self.managed_spawn_replays
6078 .lock()
6079 .unwrap_or_else(|poisoned| poisoned.into_inner());
6080 if let Some(record) = replays.get_mut(&key) {
6081 if record.request_digest != request_digest {
6082 return Err(failure(
6083 NodeFailureCode::SpawnIdempotencyConflict,
6084 "managed worktree V2 idempotency key was reused with a different request",
6085 ));
6086 }
6087 match &record.state {
6088 ManagedWorktreeSpawnReplayStateV10::Pending {
6089 owner_incarnation_id,
6090 lease_id: None,
6091 } if owner_incarnation_id != &self.incarnation_id => {
6092 previous = Some(record.clone());
6093 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6094 owner_incarnation_id: self.incarnation_id,
6095 lease_id: None,
6096 };
6097 record.updated_at_unix_ms = unix_time_ms();
6098 }
6099 ManagedWorktreeSpawnReplayStateV10::Pending {
6100 lease_id: None,
6101 ..
6102 } => {
6103 return Err(failure(
6104 NodeFailureCode::BackendBusy,
6105 "managed worktree V2 reservation is already active",
6106 ));
6107 }
6108 ManagedWorktreeSpawnReplayStateV10::Pending {
6109 lease_id: Some(_),
6110 ..
6111 } => {
6112 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6113 }
6114 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6115 return Ok(DurableManagedSpawnReplayDecision::Committed(
6116 receipt.clone(),
6117 ));
6118 }
6119 }
6120 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6121 return Err(failure(
6122 NodeFailureCode::SpawnIdempotencyCapacity,
6123 "durable managed worktree replay ledger reached its bounded capacity",
6124 ));
6125 } else {
6126 let now = unix_time_ms();
6127 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6128 idempotency_key: key.clone(),
6129 request_digest,
6130 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6131 profile_id: request.worktree_profile_id.clone(),
6132 expected_profile_revision: request.expected_profile_revision.clone(),
6133 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6134 owner_incarnation_id: self.incarnation_id,
6135 lease_id: None,
6136 },
6137 created_at_unix_ms: now,
6138 updated_at_unix_ms: now,
6139 });
6140 }
6141 }
6142 if let Err(error) = self.persist_state_locked() {
6143 let mut replays = self.managed_spawn_replays
6144 .lock()
6145 .unwrap_or_else(|poisoned| poisoned.into_inner());
6146 match previous {
6147 Some(previous) => {
6148 replays.insert(key, previous);
6149 }
6150 None => {
6151 replays.remove(&key);
6152 }
6153 }
6154 return Err(persistence_failure(error));
6155 }
6156 Ok(DurableManagedSpawnReplayDecision::Reserved)
6157 }
6158
6159 fn link_durable_managed_spawn_v2_lease_locked(
6160 &self,
6161 key: &SpawnIdempotencyKey,
6162 lease_id: &ManagedWorktreeLeaseId,
6163 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6164 let mut replays = self.managed_spawn_replays
6165 .lock()
6166 .unwrap_or_else(|poisoned| poisoned.into_inner());
6167 let record = replays.get_mut(key).ok_or_else(|| {
6168 failure(
6169 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6170 "managed worktree V2 reservation disappeared before allocation",
6171 )
6172 })?;
6173 let previous = record.clone();
6174 match &mut record.state {
6175 ManagedWorktreeSpawnReplayStateV10::Pending {
6176 lease_id: current,
6177 ..
6178 }
6179 if current.is_none() =>
6180 {
6181 *current = Some(lease_id.clone());
6182 record.updated_at_unix_ms = unix_time_ms();
6183 Ok(previous)
6184 }
6185 _ => Err(failure(
6186 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6187 "managed worktree V2 reservation is not allocation-ready",
6188 )),
6189 }
6190 }
6191
6192 fn restore_durable_managed_spawn_v2_locked(
6193 &self,
6194 key: &SpawnIdempotencyKey,
6195 previous: ManagedWorktreeSpawnReplayRecordV10,
6196 ) {
6197 self.managed_spawn_replays
6198 .lock()
6199 .unwrap_or_else(|poisoned| poisoned.into_inner())
6200 .insert(key.clone(), previous);
6201 }
6202
6203 fn commit_durable_managed_spawn_v2(
6204 &self,
6205 request: &ManagedWorktreeSpawnRequestV2,
6206 receipt: ManagedWorktreeSpawnReceipt,
6207 ) -> Result<(), NodeFailure> {
6208 let _transaction = self.state_transaction
6209 .lock()
6210 .unwrap_or_else(|poisoned| poisoned.into_inner());
6211 let key = &request.spawn_spec.idempotency_key;
6212 let previous = {
6213 let mut replays = self.managed_spawn_replays
6214 .lock()
6215 .unwrap_or_else(|poisoned| poisoned.into_inner());
6216 let record = replays.get_mut(key).ok_or_else(|| {
6217 failure(
6218 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6219 "managed worktree V2 reservation disappeared before commit",
6220 )
6221 })?;
6222 let previous = record.clone();
6223 match &record.state {
6224 ManagedWorktreeSpawnReplayStateV10::Pending {
6225 lease_id: Some(lease_id),
6226 ..
6227 } if lease_id == &receipt.lease.lease_id => {}
6228 _ => {
6229 return Err(failure(
6230 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6231 "managed worktree V2 reservation is not linked to the spawned lease",
6232 ));
6233 }
6234 }
6235 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6236 record.updated_at_unix_ms = unix_time_ms();
6237 previous
6238 };
6239 if let Err(error) = self.persist_state_locked() {
6240 self.restore_durable_managed_spawn_v2_locked(key, previous);
6241 return Err(persistence_failure(error));
6242 }
6243 Ok(())
6244 }
6245
6246 fn remove_unallocated_durable_managed_spawn_v2(
6247 &self,
6248 request: &ManagedWorktreeSpawnRequestV2,
6249 ) -> Result<(), NodeFailure> {
6250 let _transaction = self.state_transaction
6251 .lock()
6252 .unwrap_or_else(|poisoned| poisoned.into_inner());
6253 let key = &request.spawn_spec.idempotency_key;
6254 let removed = {
6255 let mut replays = self.managed_spawn_replays
6256 .lock()
6257 .unwrap_or_else(|poisoned| poisoned.into_inner());
6258 match replays.get(key).map(|record| &record.state) {
6259 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6260 lease_id: None,
6261 ..
6262 }) => {
6263 replays.remove(key)
6264 }
6265 _ => None,
6266 }
6267 };
6268 let Some(removed) = removed else {
6269 return Ok(());
6270 };
6271 if let Err(error) = self.persist_state_locked() {
6272 self.managed_spawn_replays
6273 .lock()
6274 .unwrap_or_else(|poisoned| poisoned.into_inner())
6275 .insert(key.clone(), removed);
6276 return Err(persistence_failure(error));
6277 }
6278 Ok(())
6279 }
6280
6281 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6282 if spec.target.node_id != self.node_id {
6283 return Err(failure(
6284 NodeFailureCode::SpawnTargetMismatch,
6285 "spawn target does not match this node",
6286 ));
6287 }
6288 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6289 failure(
6290 NodeFailureCode::UnknownSpawnProfile,
6291 "spawn profile is unavailable on this node",
6292 )
6293 })?;
6294 let resolved = spec.resolve(defaults).map_err(|error| match error {
6295 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6296 NodeFailureCode::SpawnProfileRevisionMismatch,
6297 "spawn profile revision does not match the loaded profile",
6298 ),
6299 _ => failure(
6300 NodeFailureCode::InvalidRequest,
6301 "spawn specification could not be resolved",
6302 ),
6303 })?;
6304 if resolved.target.worktree_id.is_some() {
6305 self.require_worktree_service(&resolved.target.workspace_id)?;
6306 }
6307 let environment_profile = self.resolve_environment_profile(&resolved)?;
6308 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6309 self.resolve_context(&resolved)?;
6310 Ok(resolved)
6311 }
6312
6313 async fn resolve_spawn_workspace(
6314 &self,
6315 resolved: &ResolvedSpawnSpec,
6316 deadline: Instant,
6317 ) -> Result<WorkspaceId, NodeFailure> {
6318 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6319 return Ok(resolved.target.workspace_id.clone());
6320 };
6321 if self.managed_worktrees.lock()
6322 .unwrap_or_else(|poisoned| poisoned.into_inner())
6323 .lease_for_workspace(worktree_id)
6324 .is_some()
6325 {
6326 return Err(failure(
6327 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6328 "managed worktree targets may only be spawned through their lease request",
6329 ));
6330 }
6331 self.require_worktree_service(&resolved.target.workspace_id)?;
6332 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6333 let selected_root = self.workspace_root(worktree_id)?;
6334 let remaining = spawn_deadline_remaining(deadline)?;
6335 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6336 .await
6337 .map_err(|_| {
6338 failure(
6339 NodeFailureCode::SpawnDeadlineExceeded,
6340 "Git worktree selection exceeded the spawn deadline",
6341 )
6342 })?
6343 .map_err(git_worktree_failure)?;
6344 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6345 Ok(worktree_id.clone())
6346 }
6347
6348 async fn spawn_from_spec(
6349 &self,
6350 spec: SpawnSpec,
6351 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6352 self.spawn_from_spec_with_proxy(spec, None).await
6353 }
6354
6355 async fn spawn_from_spec_with_proxy(
6356 &self,
6357 spec: SpawnSpec,
6358 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6359 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6360 let accepted_at = Instant::now();
6361 if harness_mcp.is_none() {
6362 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6363 return replayed;
6364 }
6365 }
6366 let resolved = self.resolve_spawn_spec(&spec)?;
6367 let environment_profile = self.resolve_environment_profile(&resolved)?;
6368 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6369 let context = self.resolve_context(&resolved)?;
6370 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6371 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6372 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6373 let spawn_workspace_id = match spawn_workspace_id {
6374 Ok(workspace_id) => workspace_id,
6375 Err(error) => {
6376 let result = Err(error);
6377 if harness_mcp.is_none() {
6378 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6379 }
6380 return result;
6381 }
6382 };
6383 if self.managed_worktrees.lock()
6384 .unwrap_or_else(|poisoned| poisoned.into_inner())
6385 .lease_for_workspace(&spawn_workspace_id)
6386 .is_some()
6387 {
6388 let result = Err(failure(
6389 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6390 "managed worktree targets may only be spawned through their lease request",
6391 ));
6392 if harness_mcp.is_none() {
6393 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6394 }
6395 return result;
6396 }
6397 let result = self
6398 .spawn_session_with_deadline(
6399 spawn_workspace_id,
6400 resolved.provider.clone(),
6401 resolved.mode,
6402 resolved.terminal_size,
6403 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6404 environment_profile.clone(),
6405 bundle.clone(),
6406 context.clone(),
6407 None,
6408 None,
6409 SpawnRecordPolicy::Always,
6410 Some(deadline),
6411 &required_capabilities,
6412 harness_mcp,
6413 resolved.approval_level,
6414 resolved.network_allowlist.clone(),
6415 resolved.browser_profile_id.clone(),
6416 )
6417 .await
6418 .map(|(session, _runtime_policy)| {
6419 resolved.receipt_with_materialization(
6420 self.incarnation_id,
6421 session,
6422 environment_profile,
6423 bundle,
6424 context,
6425 )
6426 });
6427 if harness_mcp.is_none() {
6428 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6429 }
6430 result
6431 }
6432
6433 async fn spawn_from_spec_with_harness_mcp(
6434 &self,
6435 reservation_id: HarnessMcpReservationId,
6436 activation_digest: HarnessMcpActivationDigest,
6437 spec: SpawnSpec,
6438 deadline_unix_ms: u64,
6439 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6440 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6441 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6442 })?;
6443 if let Some(receipt) = registry
6444 .replay_spawn(&reservation_id, &activation_digest, &spec)
6445 .map_err(harness_mcp_failure)?
6446 {
6447 return Ok(receipt);
6448 }
6449 let resolved = self.resolve_spawn_spec(&spec)?;
6450 let prepared = registry
6451 .prepare_spawn(
6452 &reservation_id,
6453 &activation_digest,
6454 &spec,
6455 deadline_unix_ms,
6456 resolved.provider.clone(),
6457 )
6458 .map_err(harness_mcp_failure)?;
6459 self.claim_harness_mcp_spawn(
6460 &reservation_id,
6461 &activation_digest,
6462 &spec,
6463 Instant::now(),
6464 )?;
6465 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6466 let mut receipt = match spawn {
6467 Ok(receipt) => receipt,
6468 Err(error) => {
6469 let _ = registry.abort(&reservation_id, &activation_digest);
6470 return Err(error);
6471 }
6472 };
6473 let record_id = self
6474 .session_bindings
6475 .lock()
6476 .unwrap_or_else(|poisoned| poisoned.into_inner())
6477 .get(&receipt.session.session.instance_id)
6478 .and_then(|binding| binding.record_id.clone());
6479 let record_id = match record_id {
6480 Some(record_id) => record_id,
6481 None => {
6482 let rollback = self.remove_session(&receipt.session).await;
6483 let _ = registry.abort(&reservation_id, &activation_digest);
6484 if let Err(rollback_error) = rollback {
6485 return Err(rollback_error);
6486 }
6487 return Err(failure(
6488 NodeFailureCode::BindingMismatch,
6489 "harness MCP spawn did not retain its exact managed record",
6490 ));
6491 }
6492 };
6493 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6494 reservation_id: reservation_id.clone(),
6495 activation_digest: activation_digest.clone(),
6496 });
6497 if let Err(error) = registry.mark_spawned(
6498 &prepared,
6499 receipt.session.clone(),
6500 record_id,
6501 receipt.clone(),
6502 ) {
6503 let rollback = self.remove_session(&receipt.session).await;
6504 let _ = registry.abort(&reservation_id, &activation_digest);
6505 if let Err(rollback_error) = rollback {
6506 return Err(rollback_error);
6507 }
6508 return Err(harness_mcp_failure(error));
6509 }
6510 Ok(receipt)
6511 }
6512
6513 async fn spawn_managed_worktree(
6514 &self,
6515 request: ManagedWorktreeSpawnRequest,
6516 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6517 self.spawn_managed_worktree_inner(request, None).await
6518 }
6519
6520 async fn spawn_managed_worktree_inner(
6521 &self,
6522 request: ManagedWorktreeSpawnRequest,
6523 durable_v2_key: Option<&SpawnIdempotencyKey>,
6524 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6525 let accepted_at = Instant::now();
6526 if durable_v2_key.is_none() {
6527 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6528 return replayed;
6529 }
6530 }
6531 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6532 if request.spawn_spec.target.worktree_id.is_some() {
6533 let result = Err(failure(
6534 NodeFailureCode::InvalidRequest,
6535 "managed worktree spawn must not select a caller-provided worktree",
6536 ));
6537 self.remember_managed_spawn_attempt(
6538 durable_v2_key,
6539 request,
6540 result.clone(),
6541 accepted_at,
6542 );
6543 return result;
6544 }
6545 let environment_profile = self.resolve_environment_profile(&resolved)?;
6546 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6547 let context = self.resolve_context(&resolved)?;
6548 let source_workspace_id = resolved.target.workspace_id.clone();
6549 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6550 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6551 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6552 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6553 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6554 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6555 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6556 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6557 };
6558 let runtime_policy = timeout(
6559 spawn_deadline_remaining(deadline)?,
6560 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6561 ).await.map_err(|_| failure(
6562 NodeFailureCode::SpawnDeadlineExceeded,
6563 "provider admission exceeded the managed spawn deadline",
6564 ))??;
6565 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6566 return Err(failure(
6567 NodeFailureCode::UnsupportedSpawnCapability,
6568 "provider runtime does not admit a required spawn capability",
6569 ));
6570 }
6571 self.ensure_binding_capacity()?;
6572 let source_root = self.workspace_root(&source_workspace_id)?;
6573 profile.validate_for_workspace(&source_root)
6574 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6575 let base_timeout_ms = u64::try_from(
6576 spawn_deadline_remaining(deadline)?.as_millis(),
6577 ).unwrap_or(u64::MAX).max(1);
6578 let base_commit = resolve_base_commit_with_timeout(
6579 &source_root,
6580 profile.base(),
6581 base_timeout_ms,
6582 ).await.map_err(|error| {
6583 if Instant::now() >= deadline || error.message.contains("timed out") {
6584 failure(
6585 NodeFailureCode::SpawnDeadlineExceeded,
6586 "managed worktree base resolution exceeded the spawn deadline",
6587 )
6588 } else {
6589 managed_git_worktree_failure(error)
6590 }
6591 })?;
6592
6593 let lease = {
6594 let _transaction = self.state_transaction.lock()
6595 .unwrap_or_else(|poisoned| poisoned.into_inner());
6596 let previous = self.managed_worktrees.lock()
6597 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6598 let lease = self.managed_worktrees.lock()
6599 .unwrap_or_else(|poisoned| poisoned.into_inner())
6600 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6601 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6602 let previous_replay = match durable_v2_key
6603 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6604 .transpose()
6605 {
6606 Ok(previous) => previous,
6607 Err(error) => {
6608 *self.managed_worktrees.lock()
6609 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6610 return Err(error);
6611 }
6612 };
6613 if let Err(error) = self.persist_state_locked() {
6614 *self.managed_worktrees.lock()
6615 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6616 if let (Some(key), Some(previous_replay)) =
6617 (durable_v2_key, previous_replay)
6618 {
6619 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6620 }
6621 return Err(persistence_failure(error));
6622 }
6623 lease
6624 };
6625 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6626 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6627 self.managed_worktrees.lock()
6628 .unwrap_or_else(|poisoned| poisoned.into_inner())
6629 .remove_unmutated(&lease.lease_id);
6630 if self.persist_state().is_err() {
6631 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6632 }
6633 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6634 self.remember_managed_spawn_attempt(
6635 durable_v2_key,
6636 request,
6637 result.clone(),
6638 accepted_at,
6639 );
6640 return result;
6641 }
6642 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6643 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6644 Err(error) => {
6645 self.reconcile_failed_allocation(&lease, &source_root).await;
6646 let result = Err(error);
6647 self.remember_managed_spawn_attempt(
6648 durable_v2_key,
6649 request,
6650 result.clone(),
6651 accepted_at,
6652 );
6653 return result;
6654 }
6655 };
6656 let created = match create_git_worktree_with_timeout(
6657 &source_root,
6658 &lease.target_root,
6659 &lease.branch,
6660 Some(&base_commit),
6661 mutation_timeout_ms,
6662 ).await {
6663 Ok(created) => created,
6664 Err(error) => {
6665 self.reconcile_failed_allocation(&lease, &source_root).await;
6666 let result = Err(if Instant::now() >= deadline
6667 || error.message.contains("timed out")
6668 || error.message.contains("deadline")
6669 {
6670 failure(
6671 NodeFailureCode::SpawnDeadlineExceeded,
6672 "managed worktree creation exceeded the spawn deadline",
6673 )
6674 } else {
6675 managed_git_worktree_failure(error)
6676 });
6677 self.remember_managed_spawn_attempt(
6678 durable_v2_key,
6679 request,
6680 result.clone(),
6681 accepted_at,
6682 );
6683 return result;
6684 }
6685 };
6686 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6687 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6688 let result = Err(failure(
6689 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6690 "created worktree identity did not match its durable lease",
6691 ));
6692 self.remember_managed_spawn_attempt(
6693 durable_v2_key,
6694 request,
6695 result.clone(),
6696 accepted_at,
6697 );
6698 return result;
6699 }
6700 {
6701 let mut registry = self.managed_worktrees.lock()
6702 .unwrap_or_else(|poisoned| poisoned.into_inner());
6703 let current = registry.get_mut(&lease.lease_id)
6704 .expect("allocated managed worktree lease remains present");
6705 current.expected_head = Some(created.head.clone());
6706 current.state = ManagedWorktreeLeaseState::Ready;
6707 current.updated_at_unix_ms = unix_time_ms();
6708 }
6709 if let Err(error) = self.persist_state() {
6710 self.mark_managed_recovery_required(
6711 &lease.lease_id,
6712 ManagedWorktreeCleanupFailure::Backend,
6713 );
6714 let result = Err(persistence_failure(error));
6715 self.remember_managed_spawn_attempt(
6716 durable_v2_key,
6717 request,
6718 result.clone(),
6719 accepted_at,
6720 );
6721 return result;
6722 }
6723 let ready = self.managed_worktrees.lock()
6724 .unwrap_or_else(|poisoned| poisoned.into_inner())
6725 .get(&lease.lease_id)
6726 .expect("ready managed worktree lease remains present")
6727 .snapshot();
6728 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6729 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6730 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6731 let result = Err(error);
6732 self.remember_managed_spawn_attempt(
6733 durable_v2_key,
6734 request,
6735 result.clone(),
6736 accepted_at,
6737 );
6738 return result;
6739 }
6740 let spawn = self.spawn_session_with_deadline(
6741 lease.workspace_id.clone(),
6742 resolved.provider.clone(),
6743 resolved.mode,
6744 resolved.terminal_size,
6745 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6746 environment_profile.clone(),
6747 bundle.clone(),
6748 context.clone(),
6749 Some(lease.lease_id.clone()),
6750 Some(runtime_policy),
6751 SpawnRecordPolicy::Always,
6752 Some(deadline),
6753 &required_capabilities,
6754 None,
6755 resolved.approval_level,
6756 resolved.network_allowlist.clone(),
6757 resolved.browser_profile_id.clone(),
6758 ).await;
6759 let (session, effective_runtime_policy) = match spawn {
6760 Ok(spawned) => spawned,
6761 Err(error) => {
6762 if self
6763 .cleanup_managed_worktree(&lease.lease_id, true)
6764 .await
6765 .is_err()
6766 {
6767 self.mark_managed_recovery_required(
6768 &lease.lease_id,
6769 ManagedWorktreeCleanupFailure::Backend,
6770 );
6771 }
6772 let result = Err(error);
6773 self.remember_managed_spawn_attempt(
6774 durable_v2_key,
6775 request,
6776 result.clone(),
6777 accepted_at,
6778 );
6779 return result;
6780 }
6781 };
6782 let (record_id, raw_inventory_record_id) = {
6783 let mut bindings = self.session_bindings.lock()
6784 .unwrap_or_else(|poisoned| poisoned.into_inner());
6785 let binding = bindings.get_mut(&session.session.instance_id)
6786 .expect("successful spawn retains its session binding");
6787 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6788 managed_spawn_record_ownership(binding, effective_runtime_policy)
6789 };
6790 let bound = self.managed_worktrees.lock()
6791 .unwrap_or_else(|poisoned| poisoned.into_inner())
6792 .bind_session(
6793 &lease.lease_id,
6794 ManagedWorktreeSessionHolder {
6795 incarnation_id: self.incarnation_id,
6796 instance_id: session.session.instance_id,
6797 generation: session.session.generation,
6798 },
6799 record_id,
6800 unix_time_ms(),
6801 )
6802 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6803 let snapshot = match bound {
6804 Ok(snapshot) => snapshot,
6805 Err(error) => {
6806 if raw_inventory_record_id
6807 .as_ref()
6808 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6809 {
6810 self.mark_managed_recovery_required(
6811 &lease.lease_id,
6812 ManagedWorktreeCleanupFailure::Backend,
6813 );
6814 }
6815 let _ = self.remove_session(&session).await;
6816 self.mark_managed_recovery_required(
6817 &lease.lease_id,
6818 ManagedWorktreeCleanupFailure::Backend,
6819 );
6820 let result = Err(error);
6821 self.remember_managed_spawn_attempt(
6822 durable_v2_key,
6823 request,
6824 result.clone(),
6825 accepted_at,
6826 );
6827 return result;
6828 }
6829 };
6830 if let Err(error) = self.persist_state() {
6831 if raw_inventory_record_id
6832 .as_ref()
6833 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6834 {
6835 self.mark_managed_recovery_required(
6836 &lease.lease_id,
6837 ManagedWorktreeCleanupFailure::Backend,
6838 );
6839 }
6840 let _ = self.remove_session(&session).await;
6841 if self
6842 .cleanup_managed_worktree(&lease.lease_id, true)
6843 .await
6844 .is_err()
6845 {
6846 self.mark_managed_recovery_required(
6847 &lease.lease_id,
6848 ManagedWorktreeCleanupFailure::Backend,
6849 );
6850 }
6851 let result = Err(persistence_failure(error));
6852 self.remember_managed_spawn_attempt(
6853 durable_v2_key,
6854 request,
6855 result.clone(),
6856 accepted_at,
6857 );
6858 return result;
6859 }
6860 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6861 let receipt = managed_spawn_receipt(
6862 &resolved,
6863 self.incarnation_id,
6864 session,
6865 snapshot,
6866 environment_profile,
6867 bundle,
6868 context,
6869 );
6870 let result = Ok(receipt);
6871 self.remember_managed_spawn_attempt(
6872 durable_v2_key,
6873 request,
6874 result.clone(),
6875 accepted_at,
6876 );
6877 result
6878 }
6879
6880 async fn spawn_managed_worktree_v2(
6881 &self,
6882 request: ManagedWorktreeSpawnRequestV2,
6883 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6884 if request.spawn_spec.target.node_id != self.node_id {
6885 return Err(failure(
6886 NodeFailureCode::SpawnTargetMismatch,
6887 "spawn target does not match this node",
6888 ));
6889 }
6890 if request.spawn_spec.target.worktree_id.is_some() {
6891 return Err(failure(
6892 NodeFailureCode::InvalidRequest,
6893 "managed worktree spawn must not select a caller-provided worktree",
6894 ));
6895 }
6896 let request_digest = managed_spawn_request_digest_v2(&request)?;
6897 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6898 match replay {
6899 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6900 DurableManagedSpawnReplayDecision::PendingLinked => {
6901 return Err(failure(
6902 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6903 "managed worktree V2 reservation has an incomplete durable allocation",
6904 ));
6905 }
6906 DurableManagedSpawnReplayDecision::Reserved => {}
6907 }
6908 let validated = (|| {
6909 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6910 let profile = self.managed_profile(
6911 &resolved.target.workspace_id,
6912 &request.worktree_profile_id,
6913 )?;
6914 if profile.revision() != &request.expected_profile_revision {
6915 return Err(failure(
6916 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6917 "managed worktree profile revision does not match the loaded profile",
6918 ));
6919 }
6920 Ok(())
6921 })();
6922 match validated {
6923 Ok(()) => {}
6924 Err(error) => {
6925 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6926 return Err(error);
6927 }
6928 }
6929 let legacy = ManagedWorktreeSpawnRequest {
6930 spawn_spec: request.spawn_spec.clone(),
6931 worktree_profile_id: request.worktree_profile_id.clone(),
6932 };
6933 match self
6934 .spawn_managed_worktree_inner(
6935 legacy,
6936 Some(&request.spawn_spec.idempotency_key),
6937 )
6938 .await
6939 {
6940 Ok(receipt) => {
6941 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6942 Ok(receipt)
6943 }
6944 Err(error) => {
6945 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6946 Err(error)
6947 }
6948 }
6949 }
6950
6951 fn managed_profile(
6952 &self,
6953 workspace_id: &WorkspaceId,
6954 profile_id: &WorktreeProfileId,
6955 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6956 if self.worktree_service_modes.get(workspace_id).copied()
6957 != Some(WorktreeServiceMode::Managed)
6958 {
6959 return Err(failure(
6960 NodeFailureCode::UnsupportedSpawnCapability,
6961 "workspace does not enable managed worktrees",
6962 ));
6963 }
6964 self.managed_worktree_profiles.get(workspace_id)
6965 .and_then(|profiles| profiles.get(profile_id))
6966 .cloned()
6967 .ok_or_else(|| failure(
6968 NodeFailureCode::InvalidRequest,
6969 "managed worktree profile is unavailable for this workspace",
6970 ))
6971 }
6972
6973 async fn reconcile_failed_allocation(
6974 &self,
6975 lease: &ManagedWorktreeLeaseRecord,
6976 source_root: &str,
6977 ) {
6978 match list_git_worktrees(source_root).await {
6979 Ok(worktrees) => {
6980 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6981 None => {
6982 match std::fs::symlink_metadata(&lease.target_root) {
6983 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6984 self.managed_worktrees.lock()
6985 .unwrap_or_else(|poisoned| poisoned.into_inner())
6986 .remove_unmutated(&lease.lease_id);
6987 let _ = self.persist_state();
6988 }
6989 _ => self.mark_managed_recovery_required(
6990 &lease.lease_id,
6991 ManagedWorktreeCleanupFailure::OwnershipConflict,
6992 ),
6993 }
6994 }
6995 Some(item) if exact_owned_worktree(
6996 lease,
6997 &item.path,
6998 item.branch.as_deref(),
6999 &item.head,
7000 ) => {
7001 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7002 }
7003 Some(_) => self.mark_managed_recovery_required(
7004 &lease.lease_id,
7005 ManagedWorktreeCleanupFailure::OwnershipConflict,
7006 ),
7007 }
7008 }
7009 Err(_) => self.mark_managed_recovery_required(
7010 &lease.lease_id,
7011 ManagedWorktreeCleanupFailure::Backend,
7012 ),
7013 }
7014 }
7015
7016 async fn reconcile_managed_worktrees(&self) {
7017 let leases = self.managed_worktrees.lock()
7018 .unwrap_or_else(|poisoned| poisoned.into_inner())
7019 .active_records()
7020 .cloned()
7021 .collect::<Vec<_>>();
7022 for lease in leases {
7023 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7024 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7025 _ => {
7026 self.mark_managed_recovery_required(
7027 &lease.lease_id,
7028 ManagedWorktreeCleanupFailure::OwnershipConflict,
7029 );
7030 continue;
7031 }
7032 };
7033 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7034 self.mark_managed_recovery_required(
7035 &lease.lease_id,
7036 ManagedWorktreeCleanupFailure::OwnershipConflict,
7037 );
7038 continue;
7039 }
7040 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7041 Ok(root) => root,
7042 Err(_) => {
7043 self.mark_managed_recovery_required(
7044 &lease.lease_id,
7045 ManagedWorktreeCleanupFailure::OwnershipConflict,
7046 );
7047 continue;
7048 }
7049 };
7050 let worktrees = match list_git_worktrees(&source_root).await {
7051 Ok(worktrees) => worktrees,
7052 Err(_) => {
7053 self.mark_managed_recovery_required(
7054 &lease.lease_id,
7055 ManagedWorktreeCleanupFailure::Backend,
7056 );
7057 continue;
7058 }
7059 };
7060 let listed = worktrees.iter()
7061 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7062 let Some(listed) = listed else {
7063 match std::fs::symlink_metadata(&lease.target_root) {
7064 Err(error) if error.kind() == io::ErrorKind::NotFound
7065 && lease.state == ManagedWorktreeLeaseState::Allocating
7066 && !lease.has_holders() => {
7067 let removed = self.managed_worktrees.lock()
7068 .unwrap_or_else(|poisoned| poisoned.into_inner())
7069 .tombstone(&lease.lease_id, unix_time_ms());
7070 if self.persist_state().is_ok() && removed.is_some() {
7071 self.publish(NodeEvent::ManagedWorktreeRemoved {
7072 lease_id: lease.lease_id.clone(),
7073 });
7074 }
7075 }
7076 _ => self.mark_managed_recovery_required(
7077 &lease.lease_id,
7078 ManagedWorktreeCleanupFailure::OwnershipConflict,
7079 ),
7080 }
7081 continue;
7082 };
7083 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7084 &lease,
7085 &listed.path,
7086 listed.branch.as_deref(),
7087 &listed.head,
7088 ) {
7089 self.mark_managed_recovery_required(
7090 &lease.lease_id,
7091 ManagedWorktreeCleanupFailure::OwnershipConflict,
7092 );
7093 continue;
7094 }
7095 if !self.workspaces.read()
7096 .unwrap_or_else(|poisoned| poisoned.into_inner())
7097 .contains_key(&lease.workspace_id)
7098 {
7099 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7100 self.mark_managed_recovery_required(
7101 &lease.lease_id,
7102 ManagedWorktreeCleanupFailure::Backend,
7103 );
7104 continue;
7105 }
7106 }
7107 let snapshot = {
7108 let mut registry = self.managed_worktrees.lock()
7109 .unwrap_or_else(|poisoned| poisoned.into_inner());
7110 let current = registry.get_mut(&lease.lease_id)
7111 .expect("reconciled managed lease remains present");
7112 current.expected_head = Some(listed.head.clone());
7113 current.cleanup_failure = None;
7114 current.state = if current.has_holders() {
7115 ManagedWorktreeLeaseState::InUse
7116 } else if current.retention == ManagedWorktreeRetention::Retain {
7117 ManagedWorktreeLeaseState::Retained
7118 } else {
7119 ManagedWorktreeLeaseState::Ready
7120 };
7121 current.updated_at_unix_ms = unix_time_ms();
7122 current.snapshot()
7123 };
7124 if self.persist_state().is_err() {
7125 self.mark_managed_recovery_required(
7126 &lease.lease_id,
7127 ManagedWorktreeCleanupFailure::Backend,
7128 );
7129 continue;
7130 }
7131 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7132 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7133 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7134 }
7135 }
7136 }
7137
7138 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7158 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7159 .session_records
7160 .lock()
7161 .unwrap_or_else(|poisoned| poisoned.into_inner())
7162 .records
7163 .values()
7164 .filter(|record| {
7165 record.state == ManagedSessionState::Live
7166 && record.exported_context.is_none()
7167 && record.provider_session.is_some()
7168 })
7169 .filter_map(|record| {
7170 record
7171 .active_session
7172 .clone()
7173 .map(|session| (record.record_id.clone(), session))
7174 })
7175 .collect();
7176 for (record_id, session) in candidates {
7177 let shared = Arc::clone(self);
7178 tokio::spawn(async move {
7179 shared
7180 .reconcile_context_pack_export_for_record(&record_id, session)
7181 .await;
7182 });
7183 }
7184 }
7185
7186 async fn reconcile_context_pack_export_for_record(
7205 &self,
7206 record_id: &SessionRecordId,
7207 session: SessionAddress,
7208 ) {
7209 let Ok(record) = self.record(record_id) else {
7210 return;
7211 };
7212 if !matches!(
7213 record.state,
7214 ManagedSessionState::Live | ManagedSessionState::Dormant
7215 ) || record.exported_context.is_some()
7216 || record.provider_session.is_none()
7217 {
7218 return;
7219 }
7220 let Ok(receipt) = self
7227 .export_context_pack_for_session_record(&record.record_id, &session, true)
7228 .await
7229 else {
7230 return;
7231 };
7232 self.set_exported_context(&record.record_id, receipt);
7233 }
7234
7235 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7240 let transaction = self
7241 .state_transaction
7242 .lock()
7243 .unwrap_or_else(|poisoned| poisoned.into_inner());
7244 let (previous, updated) = {
7245 let mut records = self
7246 .session_records
7247 .lock()
7248 .unwrap_or_else(|poisoned| poisoned.into_inner());
7249 let Some(record) = records.records.get_mut(record_id) else {
7250 return;
7251 };
7252 if record.exported_context.is_some() {
7253 return;
7254 }
7255 let previous = record.clone();
7256 record.exported_context = Some(receipt);
7257 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7258 (previous, record.clone())
7259 };
7260 if self.persist_state_locked().is_err() {
7261 self.session_records
7262 .lock()
7263 .unwrap_or_else(|poisoned| poisoned.into_inner())
7264 .records
7265 .insert(record_id.clone(), previous);
7266 return;
7267 }
7268 drop(transaction);
7269 self.publish_record(updated);
7270 }
7271
7272 fn managed_record_export_target_for_instance(
7282 &self,
7283 instance_id: AgentInstanceId,
7284 generation: SessionGeneration,
7285 ) -> Option<(SessionRecordId, SessionAddress)> {
7286 let bindings = self
7287 .session_bindings
7288 .lock()
7289 .unwrap_or_else(|poisoned| poisoned.into_inner());
7290 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7291 let record_id = binding.record_id.clone()?;
7292 Some((
7293 record_id,
7294 SessionAddress {
7295 workspace_id: binding.workspace_id.clone(),
7296 session: SessionKey { instance_id, generation },
7297 },
7298 ))
7299 }
7300
7301 fn mark_managed_recovery_required(
7302 &self,
7303 lease_id: &ManagedWorktreeLeaseId,
7304 failure_kind: ManagedWorktreeCleanupFailure,
7305 ) {
7306 let snapshot = {
7307 let mut registry = self.managed_worktrees.lock()
7308 .unwrap_or_else(|poisoned| poisoned.into_inner());
7309 let Some(lease) = registry.get_mut(lease_id) else { return };
7310 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7311 lease.cleanup_failure = Some(failure_kind);
7312 lease.updated_at_unix_ms = unix_time_ms();
7313 lease.snapshot()
7314 };
7315 let _ = self.persist_state();
7316 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7317 }
7318
7319 fn set_persistence_error(&self, error: Option<String>) {
7320 *self
7321 .persistence_error
7322 .write()
7323 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7324 .map(|message| session_registry::sanitized_persistence_summary(&message));
7325 }
7326
7327 fn persist_state(&self) -> io::Result<()> {
7328 let _transaction = self
7329 .state_transaction
7330 .lock()
7331 .unwrap_or_else(|poisoned| poisoned.into_inner());
7332 self.persist_state_locked()
7333 }
7334
7335 fn persist_state_locked(&self) -> io::Result<()> {
7336 let workspaces = self
7337 .workspaces
7338 .read()
7339 .unwrap_or_else(|poisoned| poisoned.into_inner())
7340 .clone();
7341 let records = self
7342 .session_records
7343 .lock()
7344 .unwrap_or_else(|poisoned| poisoned.into_inner())
7345 .records
7346 .values()
7347 .cloned()
7348 .collect::<Vec<_>>();
7349 let mut managed = self.managed_worktrees
7350 .lock()
7351 .unwrap_or_else(|poisoned| poisoned.into_inner());
7352 managed.reattach_record_holders(
7353 &managed_worktree_record_holders(&records),
7354 unix_time_ms(),
7355 );
7356 let materializations = self
7357 .materializations
7358 .lock()
7359 .unwrap_or_else(|poisoned| poisoned.into_inner())
7360 .values()
7361 .cloned()
7362 .collect::<Vec<_>>();
7363 let managed_spawn_replays = self.managed_spawn_replays
7364 .lock()
7365 .unwrap_or_else(|poisoned| poisoned.into_inner())
7366 .values()
7367 .cloned()
7368 .collect::<Vec<_>>();
7369 let result = session_registry::save_v10(
7370 self.state_path.as_deref(),
7371 &self.node_id,
7372 &workspaces,
7373 &records,
7374 &managed.records(),
7375 &managed.tombstones(),
7376 &materializations,
7377 &managed_spawn_replays,
7378 );
7379 match &result {
7380 Ok(warning) => self.set_persistence_error(warning.clone()),
7381 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7382 }
7383 result.map(|_| ())
7384 }
7385
7386 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7387 let _mutation_guard = self.mutation_gate.lock().await;
7388 self.begin_shutdown_locked().await
7389 }
7390
7391 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7392 if self.shutdown.swap(true, Ordering::AcqRel) {
7393 return Ok(());
7394 }
7395 SessionLifecycleHook::on_shutdown(self);
7396 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7397 let mut first_error = None;
7398 for session in &self.handle.snapshot().sessions {
7399 let remaining = deadline.saturating_duration_since(Instant::now());
7400 if remaining.is_zero() {
7401 first_error.get_or_insert_with(|| {
7402 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7403 });
7404 break;
7405 }
7406 if let Err(error) = self
7407 .dispatch_bounded(
7408 ControlCommand::Stop {
7409 instance_id: session.instance_id,
7410 force: true,
7411 },
7412 remaining,
7413 )
7414 .await
7415 {
7416 first_error.get_or_insert(error);
7417 }
7418 }
7419 self.shutdown_notify.notify_waiters();
7420 match first_error {
7421 Some(error) => Err(error),
7422 None => Ok(()),
7423 }
7424 }
7425
7426 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7427 let bindings = self
7428 .session_bindings
7429 .lock()
7430 .unwrap_or_else(|poisoned| poisoned.into_inner());
7431 if bindings.len() >= CONTROL_SESSIONS_MAX {
7432 Err(failure(
7433 NodeFailureCode::BackendBusy,
7434 "node session-binding capacity is full; remove an existing session first",
7435 ))
7436 } else {
7437 Ok(())
7438 }
7439 }
7440
7441 fn ensure_workspace_environment_profile_compatible(
7448 &self,
7449 workspace_id: &WorkspaceId,
7450 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7451 ) -> Result<(), NodeFailure> {
7452 let bindings = self
7453 .session_bindings
7454 .lock()
7455 .unwrap_or_else(|poisoned| poisoned.into_inner());
7456 for binding in bindings.values() {
7457 if &binding.workspace_id != workspace_id {
7458 continue;
7459 }
7460 if binding.environment_profile.as_ref() != environment_profile {
7461 return Err(failure(
7462 NodeFailureCode::WorkspaceBusy,
7463 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7464 ));
7465 }
7466 }
7467 Ok(())
7468 }
7469
7470 #[cfg(test)]
7471 fn bind_session(
7472 &self,
7473 instance_id: AgentInstanceId,
7474 workspace_id: WorkspaceId,
7475 generation: SessionGeneration,
7476 ) {
7477 self.bind_session_with_policy(
7478 &SessionAddress {
7479 workspace_id,
7480 session: SessionKey { instance_id, generation },
7481 },
7482 ProviderRuntimePolicy::raw_pty(),
7483 None,
7484 );
7485 }
7486
7487 #[cfg(test)]
7488 fn bind_session_with_policy(
7489 &self,
7490 address: &SessionAddress,
7491 runtime_policy: ProviderRuntimePolicy,
7492 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7493 ) {
7494 self.bind_session_with_materialization(
7495 address,
7496 runtime_policy,
7497 environment_profile,
7498 None,
7499 None,
7500 None,
7501 );
7502 }
7503
7504 fn bind_session_with_materialization(
7505 &self,
7506 address: &SessionAddress,
7507 runtime_policy: ProviderRuntimePolicy,
7508 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7509 bundle: Option<ResolvedBundleReceipt>,
7510 context: Option<ResolvedContextPackReceipt>,
7511 materialization_id: Option<MaterializationId>,
7512 ) {
7513 let mut bindings = self
7514 .session_bindings
7515 .lock()
7516 .unwrap_or_else(|poisoned| poisoned.into_inner());
7517 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7518 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7519 bindings.insert(address.session.instance_id, SessionBinding {
7520 workspace_id: address.workspace_id.clone(),
7521 generation: address.session.generation,
7522 runtime_policy,
7523 pending_resume: None,
7524 record_id: None,
7525 managed_worktree_lease_id: None,
7526 environment_profile,
7527 bundle,
7528 context,
7529 materialization_id,
7530 });
7531 }
7532
7533 #[cfg(test)]
7534 fn bind_managed_session(
7535 &self,
7536 address: &SessionAddress,
7537 record_id: SessionRecordId,
7538 runtime_policy: ProviderRuntimePolicy,
7539 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7540 ) {
7541 self.bind_managed_session_with_materialization(
7542 address,
7543 record_id,
7544 runtime_policy,
7545 environment_profile,
7546 None,
7547 None,
7548 None,
7549 );
7550 }
7551
7552 fn bind_managed_session_with_materialization(
7553 &self,
7554 address: &SessionAddress,
7555 record_id: SessionRecordId,
7556 runtime_policy: ProviderRuntimePolicy,
7557 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7558 bundle: Option<ResolvedBundleReceipt>,
7559 context: Option<ResolvedContextPackReceipt>,
7560 materialization_id: Option<MaterializationId>,
7561 ) {
7562 let mut bindings = self
7563 .session_bindings
7564 .lock()
7565 .unwrap_or_else(|poisoned| poisoned.into_inner());
7566 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7567 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7568 bindings.insert(
7569 address.session.instance_id,
7570 SessionBinding {
7571 workspace_id: address.workspace_id.clone(),
7572 generation: address.session.generation,
7573 runtime_policy,
7574 pending_resume: None,
7575 record_id: Some(record_id),
7576 managed_worktree_lease_id: None,
7577 environment_profile,
7578 bundle,
7579 context,
7580 materialization_id,
7581 },
7582 );
7583 }
7584
7585 #[cfg(test)]
7586 fn bind_spawn_session(
7587 &self,
7588 address: &SessionAddress,
7589 provider: AgentId,
7590 mode: SessionMode,
7591 runtime_policy: ProviderRuntimePolicy,
7592 record_policy: SpawnRecordPolicy,
7593 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7594 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7595 self.bind_spawn_session_with_materialization(
7596 address,
7597 provider,
7598 mode,
7599 runtime_policy,
7600 record_policy,
7601 environment_profile,
7602 None,
7603 None,
7604 None,
7605 )
7606 }
7607
7608 fn bind_spawn_session_with_materialization(
7609 &self,
7610 address: &SessionAddress,
7611 provider: AgentId,
7612 mode: SessionMode,
7613 runtime_policy: ProviderRuntimePolicy,
7614 record_policy: SpawnRecordPolicy,
7615 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7616 bundle: Option<ResolvedBundleReceipt>,
7617 context: Option<ResolvedContextPackReceipt>,
7618 materialization_id: Option<MaterializationId>,
7619 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7620 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7621 && !runtime_policy.provider_session_identity
7622 {
7623 self.bind_session_with_materialization(
7624 address,
7625 runtime_policy,
7626 environment_profile,
7627 bundle,
7628 context,
7629 materialization_id,
7630 );
7631 return Ok(None);
7632 }
7633 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7634 let record = self.new_record(
7635 address,
7636 provider,
7637 mode,
7638 active_record_state(runtime_policy.provider_session_identity, false),
7639 None,
7640 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7641 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7642 record_owns_lifecycle.then(|| context.clone()).flatten(),
7643 )?;
7644 let record_id = record.record_id.clone();
7645 let transaction = self
7646 .state_transaction
7647 .lock()
7648 .unwrap_or_else(|poisoned| poisoned.into_inner());
7649 self.insert_record(record.clone())?;
7650 let previous_materialization = if record_owns_lifecycle {
7651 if let Some(materialization_id) = materialization_id.as_ref() {
7652 let mut materializations = self
7653 .materializations
7654 .lock()
7655 .unwrap_or_else(|poisoned| poisoned.into_inner());
7656 let Some(ownership) = materializations.get_mut(materialization_id) else {
7657 drop(materializations);
7658 self.remove_record_memory(&record_id);
7659 return Err(failure(
7660 NodeFailureCode::BackendOperationFailed,
7661 "session environment ownership disappeared before binding",
7662 ));
7663 };
7664 let previous = ownership.clone();
7665 if ownership
7666 .transfer_to_record(record_id.clone(), unix_time_ms())
7667 .is_err()
7668 {
7669 drop(materializations);
7670 self.remove_record_memory(&record_id);
7671 return Err(failure(
7672 NodeFailureCode::BackendOperationFailed,
7673 "session environment ownership transfer failed",
7674 ));
7675 }
7676 Some(previous)
7677 } else {
7678 None
7679 }
7680 } else {
7681 None
7682 };
7683 self.bind_managed_session_with_materialization(
7684 address,
7685 record_id.clone(),
7686 runtime_policy,
7687 environment_profile,
7688 bundle,
7689 context,
7690 materialization_id.clone(),
7691 );
7692 if let Err(error) = self.persist_state_locked() {
7693 self.remove_binding(address);
7694 self.remove_record_memory(&record_id);
7695 if let (Some(materialization_id), Some(previous)) =
7696 (materialization_id.as_ref(), previous_materialization)
7697 {
7698 self.materializations
7699 .lock()
7700 .unwrap_or_else(|poisoned| poisoned.into_inner())
7701 .insert(materialization_id.clone(), previous);
7702 }
7703 return Err(persistence_failure(error));
7704 }
7705 drop(transaction);
7706 self.publish_record(record);
7707 Ok(Some(record_id))
7708 }
7709
7710 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7711 for _ in 0..8 {
7712 let bytes = random_nonce().map_err(|error| {
7713 failure(
7714 NodeFailureCode::BackendOperationFailed,
7715 &format!("session record identity generation failed: {error}"),
7716 )
7717 })?;
7718 let mut value = String::with_capacity(27);
7719 value.push_str("sr-");
7720 for byte in &bytes[..12] {
7721 use std::fmt::Write as _;
7722 let _ = write!(&mut value, "{byte:02x}");
7723 }
7724 let record_id = SessionRecordId::new(value).map_err(|error| {
7725 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7726 })?;
7727 let records = self
7728 .session_records
7729 .lock()
7730 .unwrap_or_else(|poisoned| poisoned.into_inner());
7731 if !records.records.contains_key(&record_id) {
7732 return Ok(record_id);
7733 }
7734 }
7735 Err(failure(
7736 NodeFailureCode::BackendBusy,
7737 "could not allocate a unique session record ID",
7738 ))
7739 }
7740
7741 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7742 for _ in 0..8 {
7743 let bytes = random_nonce().map_err(|error| {
7744 failure(
7745 NodeFailureCode::BackendOperationFailed,
7746 &format!("task identity generation failed: {error}"),
7747 )
7748 })?;
7749 let mut nonce = [0_u8; 12];
7750 nonce.copy_from_slice(&bytes[..12]);
7751 let task_id = TaskId::from_nonce(nonce);
7752 let records = self
7753 .session_records
7754 .lock()
7755 .unwrap_or_else(|poisoned| poisoned.into_inner());
7756 if !records.records.values().any(|record| {
7757 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7758 == Some(&task_id)
7759 }) {
7760 return Ok(task_id);
7761 }
7762 }
7763 Err(failure(
7764 NodeFailureCode::BackendBusy,
7765 "could not allocate a unique task ID",
7766 ))
7767 }
7768
7769 fn default_record_name(&self, provider: &AgentId) -> String {
7770 let records = self
7771 .session_records
7772 .lock()
7773 .unwrap_or_else(|poisoned| poisoned.into_inner());
7774 let ordinal = records
7775 .records
7776 .values()
7777 .filter(|record| &record.provider == provider)
7778 .count()
7779 .saturating_add(1);
7780 format!("{} #{ordinal}", provider.as_str())
7781 }
7782
7783 fn new_record(
7784 &self,
7785 address: &SessionAddress,
7786 provider: AgentId,
7787 mode: SessionMode,
7788 state: ManagedSessionState,
7789 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7790 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7791 bundle: Option<ResolvedBundleReceipt>,
7792 context: Option<ResolvedContextPackReceipt>,
7793 ) -> Result<ManagedSessionRecord, NodeFailure> {
7794 let canonical_root = self.workspace_root(&address.workspace_id)?;
7795 let now = unix_time_ms();
7796 Ok(ManagedSessionRecord {
7797 record_id: self.allocate_record_id()?,
7798 display_name: self.default_record_name(&provider),
7799 provider,
7800 mode,
7801 state,
7802 workspace_id: address.workspace_id.clone(),
7803 canonical_root: opaque_windows_path(canonical_root),
7804 provider_session,
7805 active_session: Some(address.clone()),
7806 environment_profile,
7807 bundle,
7808 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7809 context,
7810 exported_context: None,
7811 task_binding: None,
7812 created_at_unix_ms: now,
7813 updated_at_unix_ms: now,
7814 last_error: None,
7815 })
7816 }
7817
7818 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7819 let mut records = self
7820 .session_records
7821 .lock()
7822 .unwrap_or_else(|poisoned| poisoned.into_inner());
7823 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7824 return Err(failure(
7825 NodeFailureCode::BackendBusy,
7826 "managed session record capacity is full; forget an inactive record first",
7827 ));
7828 }
7829 if let Some(identity) = record.provider_session.as_ref() {
7830 if records.records.values().any(|candidate| {
7831 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7832 session_registry::same_provider_session(
7833 &candidate.provider,
7834 candidate_identity,
7835 &record.provider,
7836 identity,
7837 )
7838 })
7839 }) {
7840 return Err(failure(
7841 NodeFailureCode::SessionRecordConflict,
7842 "provider session is already represented by another managed record",
7843 ));
7844 }
7845 }
7846 match records.records.entry(record.record_id.clone()) {
7847 std::collections::btree_map::Entry::Vacant(entry) => {
7848 entry.insert(record);
7849 }
7850 std::collections::btree_map::Entry::Occupied(_) => {
7851 return Err(failure(
7852 NodeFailureCode::SessionRecordConflict,
7853 "session record ID collision",
7854 ));
7855 }
7856 }
7857 Ok(())
7858 }
7859
7860 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7861 self.session_records
7862 .lock()
7863 .unwrap_or_else(|poisoned| poisoned.into_inner())
7864 .records
7865 .get(record_id)
7866 .cloned()
7867 .ok_or_else(|| {
7868 failure(
7869 NodeFailureCode::UnknownSessionRecord,
7870 "managed session record does not exist",
7871 )
7872 })
7873 }
7874
7875 fn revalidate_session_record_preview(
7876 &self,
7877 expected: &ManagedSessionRecord,
7878 identity: &ProviderSessionIdentity,
7879 ) -> Result<(), NodeFailure> {
7880 let current = self.record(&expected.record_id)?;
7881 let current_root = self.workspace_root(¤t.workspace_id)?;
7882 if current.provider != expected.provider
7883 || current.workspace_id != expected.workspace_id
7884 || current.canonical_root != expected.canonical_root
7885 || current.provider_session.as_ref() != Some(identity)
7886 || !platform::roots_equal(
7887 ¤t_root,
7888 windows_path_text(¤t.canonical_root),
7889 )
7890 {
7891 return Err(failure(
7892 NodeFailureCode::SessionRecordConflict,
7893 "managed session changed while its preview was loading",
7894 ));
7895 }
7896 Ok(())
7897 }
7898
7899 fn revalidate_session_record_context_export(
7900 &self,
7901 expected: &ManagedSessionRecord,
7902 identity: Option<&ProviderSessionIdentity>,
7903 session: &SessionAddress,
7904 allow_clean_detachment: bool,
7905 ) -> Result<(), NodeFailure> {
7906 let current = self.record(&expected.record_id)?;
7907 let current_root = self.workspace_root(¤t.workspace_id)?;
7908 let snapshot = self.internal_session_snapshot(session)?;
7909 let binding = self
7910 .session_bindings
7911 .lock()
7912 .unwrap_or_else(|poisoned| poisoned.into_inner())
7913 .get(&session.session.instance_id)
7914 .cloned()
7915 .ok_or_else(|| {
7916 failure(
7917 NodeFailureCode::SessionRecordConflict,
7918 "managed session runtime binding disappeared during context export",
7919 )
7920 })?;
7921 if !session_record_context_export_binding_is_exact(
7922 expected,
7923 ¤t,
7924 identity,
7925 session,
7926 &binding,
7927 &snapshot.agent_id,
7928 ¤t_root,
7929 allow_clean_detachment,
7930 ) || !session_record_context_export_source_is_usable(
7931 ¤t,
7932 &snapshot.status,
7933 allow_clean_detachment,
7934 ) {
7935 return Err(failure(
7936 NodeFailureCode::SessionRecordConflict,
7937 "managed session binding changed during context export",
7938 ));
7939 }
7940 Ok(())
7941 }
7942
7943 fn revalidate_loaded_history(
7944 &self,
7945 session: &SessionAddress,
7946 candidate_id: &str,
7947 loaded: &HistorySessionRecord,
7948 ) -> Result<(), NodeFailure> {
7949 let snapshot = self.internal_session_snapshot(session)?;
7950 if snapshot.history.pending.is_some()
7951 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7952 || snapshot.history.loaded.as_ref() != Some(loaded)
7953 {
7954 return Err(failure(
7955 NodeFailureCode::SessionRecordConflict,
7956 "managed session history changed during context export",
7957 ));
7958 }
7959 Ok(())
7960 }
7961
7962 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7963 self.session_records
7964 .lock()
7965 .unwrap_or_else(|poisoned| poisoned.into_inner())
7966 .records
7967 .remove(record_id)
7968 }
7969
7970 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7971 if self.destroy_record_materialization(record_id)? {
7972 self.publish(NodeEvent::SessionRecordRemoved {
7973 record_id: record_id.clone(),
7974 });
7975 return Ok(());
7976 }
7977 let transaction = self
7978 .state_transaction
7979 .lock()
7980 .unwrap_or_else(|poisoned| poisoned.into_inner());
7981 let Some(removed) = self.remove_record_memory(record_id) else {
7982 return Ok(());
7983 };
7984 if let Err(error) = self.persist_state_locked() {
7985 let _ = self.insert_record(removed);
7986 return Err(persistence_failure(error));
7987 }
7988 drop(transaction);
7989 self.publish(NodeEvent::SessionRecordRemoved {
7990 record_id: record_id.clone(),
7991 });
7992 Ok(())
7993 }
7994
7995 fn publish_record(&self, record: ManagedSessionRecord) {
7996 self.publish(NodeEvent::SessionRecordUpserted { record });
7997 }
7998
7999 fn mark_record_error(
8000 &self,
8001 record_id: &SessionRecordId,
8002 message: &str,
8003 ) -> Result<(), NodeFailure> {
8004 let transaction = self
8005 .state_transaction
8006 .lock()
8007 .unwrap_or_else(|poisoned| poisoned.into_inner());
8008 let previous = {
8009 let mut records = self
8010 .session_records
8011 .lock()
8012 .unwrap_or_else(|poisoned| poisoned.into_inner());
8013 let Some(record) = records.records.get_mut(record_id) else {
8014 return Ok(());
8015 };
8016 let previous = record.clone();
8017 record.state = ManagedSessionState::Unavailable;
8018 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8019 record.updated_at_unix_ms = unix_time_ms();
8020 previous
8021 };
8022 if let Err(error) = self.persist_state_locked() {
8023 self.session_records
8024 .lock()
8025 .unwrap_or_else(|poisoned| poisoned.into_inner())
8026 .records
8027 .insert(record_id.clone(), previous);
8028 return Err(persistence_failure(error));
8029 }
8030 let record = self.record(record_id)?;
8031 drop(transaction);
8032 self.publish_record(record);
8033 Ok(())
8034 }
8035
8036 fn rename_session_record(
8037 &self,
8038 record_id: &SessionRecordId,
8039 display_name: String,
8040 ) -> Result<ManagedSessionRecord, NodeFailure> {
8041 validate_display_name(&display_name)
8042 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8043 let _transaction = self
8044 .state_transaction
8045 .lock()
8046 .unwrap_or_else(|poisoned| poisoned.into_inner());
8047 let previous = {
8048 let mut records = self
8049 .session_records
8050 .lock()
8051 .unwrap_or_else(|poisoned| poisoned.into_inner());
8052 let record = records.records.get_mut(record_id).ok_or_else(|| {
8053 failure(
8054 NodeFailureCode::UnknownSessionRecord,
8055 "managed session record does not exist",
8056 )
8057 })?;
8058 let previous = record.clone();
8059 record.display_name = display_name;
8060 record.updated_at_unix_ms = unix_time_ms();
8061 previous
8062 };
8063 if let Err(error) = self.persist_state_locked() {
8064 self.session_records
8065 .lock()
8066 .unwrap_or_else(|poisoned| poisoned.into_inner())
8067 .records
8068 .insert(record_id.clone(), previous);
8069 return Err(persistence_failure(error));
8070 }
8071 let updated = self.record(record_id)?;
8072 self.publish_record(updated.clone());
8073 Ok(updated)
8074 }
8075
8076 fn set_session_task(
8077 &self,
8078 record_id: &SessionRecordId,
8079 expected_revision: u64,
8080 target: SessionTaskTargetV1,
8081 ) -> Result<ManagedSessionRecord, NodeFailure> {
8082 let transaction = self
8083 .state_transaction
8084 .lock()
8085 .unwrap_or_else(|poisoned| poisoned.into_inner());
8086 let current = self.record(record_id)?;
8087 let current_task_id = current
8088 .task_binding
8089 .as_ref()
8090 .and_then(|binding| binding.task_id.as_ref());
8091 let current_revision = current
8092 .task_binding
8093 .as_ref()
8094 .map_or(0, |binding| binding.revision);
8095 if expected_revision != current_revision {
8096 return Err(failure(
8097 NodeFailureCode::SessionRecordConflict,
8098 "managed session task binding revision changed",
8099 ));
8100 }
8101 let exact = match &target {
8102 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8103 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8104 SessionTaskTargetV1::New => false,
8105 };
8106 if exact {
8107 return Ok(current);
8108 }
8109 let revision = current_revision.checked_add(1).ok_or_else(|| {
8110 failure(
8111 NodeFailureCode::SessionRecordConflict,
8112 "managed session task binding revision is exhausted",
8113 )
8114 })?;
8115 let task_id = match target {
8116 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8117 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8118 SessionTaskTargetV1::Clear => None,
8119 };
8120 let changed_at_unix_ms = unix_time_ms()
8121 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8122 failure(
8123 NodeFailureCode::SessionRecordConflict,
8124 "managed session record timestamp is exhausted",
8125 )
8126 })?);
8127 let updated = {
8128 let mut records = self
8129 .session_records
8130 .lock()
8131 .unwrap_or_else(|poisoned| poisoned.into_inner());
8132 let record = records.records.get_mut(record_id).ok_or_else(|| {
8133 failure(
8134 NodeFailureCode::UnknownSessionRecord,
8135 "managed session record does not exist",
8136 )
8137 })?;
8138 record.task_binding = Some(SessionTaskBindingV1 {
8139 revision,
8140 task_id,
8141 changed_at_unix_ms,
8142 });
8143 record.updated_at_unix_ms = changed_at_unix_ms;
8144 record.clone()
8145 };
8146 if let Err(error) = self.persist_state_locked() {
8147 self.session_records
8148 .lock()
8149 .unwrap_or_else(|poisoned| poisoned.into_inner())
8150 .records
8151 .insert(record_id.clone(), current);
8152 return Err(persistence_failure(error));
8153 }
8154 drop(transaction);
8155 self.publish_record(updated.clone());
8156 Ok(updated)
8157 }
8158
8159 fn index_provider_session(
8160 &self,
8161 workspace_id: WorkspaceId,
8162 provider: AgentId,
8163 identity: ProviderSessionIdentity,
8164 display_name: String,
8165 ) -> Result<ManagedSessionRecord, NodeFailure> {
8166 self.index_provider_session_with_policy(
8167 workspace_id,
8168 provider,
8169 identity,
8170 display_name,
8171 false,
8172 )
8173 }
8174
8175 fn index_provider_session_with_policy(
8176 &self,
8177 workspace_id: WorkspaceId,
8178 provider: AgentId,
8179 identity: ProviderSessionIdentity,
8180 display_name: String,
8181 allow_validated_transcript_path: bool,
8182 ) -> Result<ManagedSessionRecord, NodeFailure> {
8183 validate_display_name(&display_name)
8184 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8185 identity
8186 .validate()
8187 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8188 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8189 return Err(failure(
8190 NodeFailureCode::InvalidRequest,
8191 "provider session reference index v1 does not accept transcript paths",
8192 ));
8193 }
8194 let canonical_root = self.workspace_root(&workspace_id)?;
8195 if !self.enabled_providers.contains(&provider) {
8196 return Err(failure(
8197 NodeFailureCode::InvalidRequest,
8198 "provider is not enabled on this node",
8199 ));
8200 }
8201
8202 let transaction = self
8203 .state_transaction
8204 .lock()
8205 .unwrap_or_else(|poisoned| poisoned.into_inner());
8206 {
8207 let records = self
8208 .session_records
8209 .lock()
8210 .unwrap_or_else(|poisoned| poisoned.into_inner());
8211 if let Some(existing) = records.records.values().find(|candidate| {
8212 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8213 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8214 })
8215 }) {
8216 if existing.provider == provider && existing.workspace_id == workspace_id {
8217 return Ok(existing.clone());
8218 }
8219 return Err(failure(
8220 NodeFailureCode::SessionRecordConflict,
8221 "provider session reference conflicts with another provider or workspace",
8222 ));
8223 }
8224 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8225 return Err(failure(
8226 NodeFailureCode::BackendBusy,
8227 "managed session record capacity is full; forget an inactive record first",
8228 ));
8229 }
8230 }
8231
8232 let now = unix_time_ms();
8233 let record = ManagedSessionRecord {
8234 record_id: self.allocate_record_id()?,
8235 display_name,
8236 provider,
8237 mode: SessionMode::Pty,
8238 state: ManagedSessionState::Dormant,
8239 workspace_id,
8240 canonical_root: opaque_windows_path(canonical_root),
8241 provider_session: Some(identity),
8242 active_session: None,
8243 environment_profile: None,
8244 bundle: None,
8245 context_id: None,
8246 context: None,
8247 exported_context: None,
8248 task_binding: None,
8249 created_at_unix_ms: now,
8250 updated_at_unix_ms: now,
8251 last_error: None,
8252 };
8253 self.insert_record(record.clone())?;
8254 if let Err(error) = self.persist_state_locked() {
8255 self.remove_record_memory(&record.record_id);
8256 return Err(persistence_failure(error));
8257 }
8258 drop(transaction);
8259 self.publish_record(record.clone());
8260 Ok(record)
8261 }
8262
8263 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8264 self.session_bindings
8265 .lock()
8266 .unwrap_or_else(|poisoned| poisoned.into_inner())
8267 .iter()
8268 .find_map(|(instance_id, binding)| {
8269 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8270 workspace_id: binding.workspace_id.clone(),
8271 session: SessionKey {
8272 instance_id: *instance_id,
8273 generation: binding.generation,
8274 },
8275 })
8276 })
8277 }
8278
8279 async fn forget_session_record(
8280 &self,
8281 record_id: &SessionRecordId,
8282 ) -> Result<(), NodeFailure> {
8283 let record = self.record(record_id)?;
8284 if record.active_session.is_some()
8285 || matches!(
8286 record.state,
8287 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8288 )
8289 {
8290 return Err(failure(
8291 NodeFailureCode::SessionRecordBusy,
8292 "stop the managed session before forgetting it",
8293 ));
8294 }
8295 if let Some(address) = self.bound_address_for_record(record_id) {
8296 self.remove_session(&address).await?;
8297 }
8298 self.remove_managed_session_record(record_id, &record.workspace_id).await
8299 }
8300
8301 async fn remove_managed_session_record(
8316 &self,
8317 record_id: &SessionRecordId,
8318 workspace_id: &WorkspaceId,
8319 ) -> Result<(), NodeFailure> {
8320 let managed_lease_id = self.managed_worktrees.lock()
8321 .unwrap_or_else(|poisoned| poisoned.into_inner())
8322 .lease_for_workspace(workspace_id);
8323 if !self.destroy_record_materialization(record_id)? {
8324 let transaction = self
8325 .state_transaction
8326 .lock()
8327 .unwrap_or_else(|poisoned| poisoned.into_inner());
8328 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8329 failure(
8330 NodeFailureCode::UnknownSessionRecord,
8331 "managed session record does not exist",
8332 )
8333 })?;
8334 if let Err(error) = self.persist_state_locked() {
8335 let _ = self.insert_record(removed);
8336 return Err(persistence_failure(error));
8337 }
8338 drop(transaction);
8339 }
8340 self.publish(NodeEvent::SessionRecordRemoved {
8341 record_id: record_id.clone(),
8342 });
8343 if let Some(lease_id) = managed_lease_id {
8344 if let Some(snapshot) = self.managed_worktrees.lock()
8345 .unwrap_or_else(|poisoned| poisoned.into_inner())
8346 .get(&lease_id)
8347 .map(ManagedWorktreeLeaseRecord::snapshot)
8348 {
8349 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8350 }
8351 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8352 }
8353 Ok(())
8354 }
8355
8356 async fn retire_unavailable_session_records(&self) {
8369 let policy = self.session_record_retention;
8370 let age_enabled = policy.age_ms > 0;
8371 let keep_enabled = policy.keep_per_workspace > 0;
8372 if !age_enabled && !keep_enabled {
8373 return;
8374 }
8375 let now = unix_time_ms();
8376 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8377 self.session_records
8378 .lock()
8379 .unwrap_or_else(|poisoned| poisoned.into_inner())
8380 .records
8381 .values()
8382 .filter(|record| record.state == ManagedSessionState::Unavailable)
8383 .map(|record| {
8384 (
8385 record.record_id.clone(),
8386 record.workspace_id.clone(),
8387 record.provider.clone(),
8388 record.updated_at_unix_ms,
8389 )
8390 })
8391 .collect()
8392 };
8393 if candidates.is_empty() {
8394 return;
8395 }
8396 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8397 BTreeMap::new();
8398 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8399 by_workspace
8400 .entry(workspace_id)
8401 .or_default()
8402 .push((record_id, provider, updated_at_unix_ms));
8403 }
8404 let reason = if age_enabled && keep_enabled {
8405 "age+keep-n"
8406 } else if age_enabled {
8407 "age"
8408 } else {
8409 "keep-n"
8410 };
8411 let mut retired: u64 = 0;
8412 for (workspace_id, mut records) in by_workspace {
8413 records.sort_by(|left, right| right.2.cmp(&left.2));
8416 for (index, (record_id, provider, updated_at_unix_ms)) in
8417 records.into_iter().enumerate()
8418 {
8419 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8420 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8421 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8422 let should_retire = if age_enabled && keep_enabled {
8423 age_hit && keep_hit
8424 } else {
8425 age_hit || keep_hit
8426 };
8427 if !should_retire {
8428 continue;
8429 }
8430 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8431 Ok(()) => {
8432 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8433 retired += 1;
8434 tracing::info!(
8435 node_id = %self.node_id,
8436 record_id = %record_id,
8437 workspace_id = %workspace_id,
8438 provider = %provider,
8439 age_ms = record_age_ms,
8440 reason,
8441 "managed session record retired"
8442 );
8443 }
8444 Err(error) => tracing::warn!(
8445 node_id = %self.node_id,
8446 record_id = %record_id,
8447 workspace_id = %workspace_id,
8448 code = ?error.code,
8449 cause = %error.message,
8450 "session record retention sweep failed to retire a record"
8451 ),
8452 }
8453 }
8454 }
8455 if retired > 0 {
8456 let remaining_unavailable = self
8457 .session_records
8458 .lock()
8459 .unwrap_or_else(|poisoned| poisoned.into_inner())
8460 .records
8461 .values()
8462 .filter(|record| record.state == ManagedSessionState::Unavailable)
8463 .count();
8464 tracing::info!(
8465 node_id = %self.node_id,
8466 retired,
8467 remaining_unavailable,
8468 "session record retention sweep completed"
8469 );
8470 }
8471 }
8472
8473 async fn resume_session_record(
8474 &self,
8475 record_id: &SessionRecordId,
8476 terminal_size: gate4agent_types::TerminalSize,
8477 initial_prompt: Option<String>,
8478 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8479 let record = self.record(record_id)?;
8480 if record.active_session.is_some()
8481 || matches!(
8482 record.state,
8483 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8484 )
8485 {
8486 return Err(failure(
8487 NodeFailureCode::SessionRecordBusy,
8488 "managed session already has a live runtime binding",
8489 ));
8490 }
8491 let identity = record.provider_session.clone().ok_or_else(|| {
8492 failure(
8493 NodeFailureCode::SessionRecordNotResumable,
8494 "managed session has no verified provider session identity",
8495 )
8496 })?;
8497 let working_directory = self.workspace_root(&record.workspace_id)?;
8498 if !platform::roots_equal(
8499 &working_directory,
8500 windows_path_text(&record.canonical_root),
8501 ) {
8502 return Err(failure(
8503 NodeFailureCode::SessionRecordConflict,
8504 "managed session workspace root changed; refusing to resume in another directory",
8505 ));
8506 }
8507 {
8508 let records = self
8509 .session_records
8510 .lock()
8511 .unwrap_or_else(|poisoned| poisoned.into_inner());
8512 if records.records.values().any(|candidate| {
8513 candidate.record_id != *record_id
8514 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8515 session_registry::same_provider_session(
8516 &candidate.provider,
8517 candidate_identity,
8518 &record.provider,
8519 &identity,
8520 )
8521 })
8522 && (candidate.active_session.is_some()
8523 || matches!(
8524 candidate.state,
8525 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8526 ))
8527 }) {
8528 return Err(failure(
8529 NodeFailureCode::SessionRecordConflict,
8530 "provider session is already active through another managed record",
8531 ));
8532 }
8533 }
8534 let runtime_requirement = if initial_prompt.is_some() {
8535 ProviderRuntimeRequirement::ResumeWithPrompt
8536 } else {
8537 ProviderRuntimeRequirement::Resume
8538 };
8539 let runtime_policy = self.admit_provider_runtime(
8540 &record.provider,
8541 runtime_requirement,
8542 )
8543 .await?;
8544 let request = ResumeLaunchRequest {
8545 working_directory,
8546 terminal_size,
8547 initial_prompt,
8548 };
8549 request
8550 .validate()
8551 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8552 self.ensure_binding_capacity()?;
8553 let resolved_materialization = match self.resolve_record_materialization(
8554 record_id,
8555 &record.provider,
8556 record.mode,
8557 record.environment_profile.as_ref(),
8558 record.bundle.as_ref(),
8559 record.context.as_ref(),
8560 ) {
8561 Ok(materialization) => materialization,
8562 Err(error) => {
8563 let label = match error.code {
8564 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8565 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8566 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8567 _ => "environment-profile-unavailable",
8568 };
8569 self.mark_record_error(record_id, label)?;
8570 return Err(error);
8571 }
8572 };
8573 let (materialization_id, environment_overlay) = match resolved_materialization {
8574 Some((id, overlay)) => (Some(id), overlay),
8575 None => (None, None),
8576 };
8577 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8578 let mut environment_selection = match SpawnPreparer::select_environment(self,
8579 instance_id,
8580 &record.provider,
8581 record.mode,
8582 record.environment_profile.as_ref(),
8583 ) {
8584 Ok(selection) => selection,
8585 Err(error) => {
8586 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8587 return Err(error);
8588 }
8589 };
8590 let mut instance_overlay = environment_overlay
8591 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8592 .transpose()?
8593 .flatten();
8594 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8595 self.remove_session(&stale_address).await?;
8596 }
8597 let address = SessionAddress {
8598 workspace_id: record.workspace_id.clone(),
8599 session: SessionKey {
8600 instance_id,
8601 generation: SessionGeneration(1),
8602 },
8603 };
8604 let transaction = self
8605 .state_transaction
8606 .lock()
8607 .unwrap_or_else(|poisoned| poisoned.into_inner());
8608 let current = self.record(record_id)?;
8609 if current.active_session.is_some()
8610 || matches!(
8611 current.state,
8612 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8613 )
8614 {
8615 return Err(failure(
8616 NodeFailureCode::SessionRecordBusy,
8617 "managed session became active while resume was being prepared",
8618 ));
8619 }
8620 self.bind_managed_session_with_materialization(
8621 &address,
8622 record_id.clone(),
8623 runtime_policy,
8624 record.environment_profile.clone(),
8625 record.bundle.clone(),
8626 record.context.clone(),
8627 materialization_id,
8628 );
8629 if let Some(selection) = environment_selection.take() {
8630 selection.retain();
8631 }
8632 if let Some(overlay) = instance_overlay.take() {
8633 overlay.retain();
8634 }
8635 drop(transaction);
8636
8637 let transport = match record.mode {
8638 SessionMode::Pty => TransportKind::Pty,
8639 SessionMode::Inline => TransportKind::Pipe,
8640 SessionMode::Acp => TransportKind::Acp,
8641 };
8642 let agent_id = record.provider.clone();
8643 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8644 if let Err(error) = self
8645 .dispatch_bounded(
8646 ControlCommand::Register {
8647 instance_id,
8648 agent_id,
8649 transport,
8650 },
8651 dispatch_timeout,
8652 )
8653 .await
8654 {
8655 if let Some(binding) = self.remove_binding(&address) {
8656 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8657 }
8658 return Err(error);
8659 }
8660 let command = self.prepare_command(ControlCommand::Resume {
8661 instance_id,
8662 target: ResumeTarget::ProviderSession { identity },
8663 runtime_policy,
8664 request,
8665 });
8666 let started_after = self.current_sequence();
8667 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8668 self.rollback_spawn(&address, dispatch_timeout).await?;
8669 return Err(error);
8670 }
8671 if let Err(error) = self.dispatch_envelope(command) {
8672 self.clear_armed_resume(&address);
8673 self.rollback_spawn(&address, dispatch_timeout).await?;
8674 return Err(error);
8675 }
8676 match self
8677 .wait_for_record_resume(&address, started_after)
8678 .await
8679 {
8680 Ok(settled_address) => {
8681 let managed_lease_id = {
8682 self.managed_worktrees.lock()
8683 .unwrap_or_else(|poisoned| poisoned.into_inner())
8684 .lease_for_workspace(&record.workspace_id)
8685 };
8686 if let Some(lease_id) = managed_lease_id {
8687 {
8688 let mut bindings = self.session_bindings.lock()
8689 .unwrap_or_else(|poisoned| poisoned.into_inner());
8690 let binding = bindings.get_mut(&settled_address.session.instance_id)
8691 .ok_or_else(|| failure(
8692 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8693 "resumed managed session lost its exact binding",
8694 ))?;
8695 if binding.generation != settled_address.session.generation {
8696 return Err(failure(
8697 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8698 "resumed managed session generation diverged",
8699 ));
8700 }
8701 binding.managed_worktree_lease_id = Some(lease_id.clone());
8702 }
8703 let snapshot = self.managed_worktrees.lock()
8704 .unwrap_or_else(|poisoned| poisoned.into_inner())
8705 .bind_session(
8706 &lease_id,
8707 ManagedWorktreeSessionHolder {
8708 incarnation_id: self.incarnation_id,
8709 instance_id: settled_address.session.instance_id,
8710 generation: settled_address.session.generation,
8711 },
8712 Some(record_id.clone()),
8713 unix_time_ms(),
8714 ).map_err(|message| failure(
8715 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8716 &message,
8717 ))?;
8718 if let Err(error) = self.persist_state() {
8719 let _ = self.remove_session(&settled_address).await;
8720 return Err(persistence_failure(error));
8721 }
8722 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8723 }
8724 Ok((self.record(record_id)?, settled_address))
8725 }
8726 Err(error) => {
8727 if matches!(
8728 error.code,
8729 NodeFailureCode::SessionRecordNotResumable
8730 | NodeFailureCode::BackendOperationFailed
8731 ) {
8732 if let Some(bound) = self.bound_address_for_record(record_id) {
8733 let _ = self.remove_session(&bound).await;
8734 }
8735 }
8736 Err(error)
8737 }
8738 }
8739 }
8740
8741 async fn wait_for_record_resume(
8742 &self,
8743 initial_address: &SessionAddress,
8744 after_sequence: u64,
8745 ) -> Result<SessionAddress, NodeFailure> {
8746 let deadline = Instant::now()
8747 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8748 let mut scan_after = after_sequence;
8749 loop {
8750 let events = self
8751 .history
8752 .lock()
8753 .unwrap_or_else(|poisoned| poisoned.into_inner())
8754 .events
8755 .iter()
8756 .filter(|event| event.sequence > scan_after)
8757 .cloned()
8758 .collect::<Vec<_>>();
8759 for envelope in events {
8760 scan_after = scan_after.max(envelope.sequence);
8761 let NodeEvent::Control { address, event } = envelope.event else {
8762 continue;
8763 };
8764 if address.session.instance_id != initial_address.session.instance_id {
8765 continue;
8766 }
8767 match event.event {
8768 ControlEventKind::Resumed { .. } => return Ok(address),
8769 ControlEventKind::ResumeDenied { reason } => {
8770 return Err(failure(
8771 NodeFailureCode::SessionRecordNotResumable,
8772 &reason,
8773 ));
8774 }
8775 ControlEventKind::ResumeFailed { message }
8776 | ControlEventKind::CommandRejected { message } => {
8777 return Err(failure(
8778 NodeFailureCode::BackendOperationFailed,
8779 &message,
8780 ));
8781 }
8782 _ => {}
8783 }
8784 }
8785 if Instant::now() >= deadline {
8786 return Err(failure(
8787 NodeFailureCode::BackendBusy,
8788 "managed resume did not settle before the bounded deadline",
8789 ));
8790 }
8791 sleep(Duration::from_millis(5)).await;
8792 }
8793 }
8794
8795 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8796 self.workspaces
8797 .read()
8798 .unwrap_or_else(|poisoned| poisoned.into_inner())
8799 .get(workspace_id)
8800 .cloned()
8801 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8802 }
8803
8804 fn workspace_roots(&self) -> Vec<PathBuf> {
8805 self.workspaces
8806 .read()
8807 .unwrap_or_else(|poisoned| poisoned.into_inner())
8808 .values()
8809 .map(PathBuf::from)
8810 .collect()
8811 }
8812
8813 async fn inspect_workspace(
8814 &self,
8815 workspace_id: WorkspaceId,
8816 ) -> Result<WorkspaceInspection, NodeFailure> {
8817 let _permit = self
8818 .inspection_slots
8819 .clone()
8820 .try_acquire_owned()
8821 .map_err(|_| {
8822 tracing::warn!(
8823 workspace_id = %workspace_id,
8824 "workspace inspection rejected: inspection capacity is busy",
8825 );
8826 failure(
8827 NodeFailureCode::BackendBusy,
8828 "workspace inspection capacity is busy",
8829 )
8830 })?;
8831 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8832 tracing::warn!(
8833 workspace_id = %workspace_id,
8834 code = ?error.code,
8835 "workspace inspection rejected: workspace is not registered",
8836 );
8837 error
8838 })?;
8839 let tree_root = canonical_root.clone();
8840 let started_at = Instant::now();
8841 let time_budget_ms = workspace_inspection_time_budget_ms();
8842 let entry_cap = workspace_inspection_entry_cap();
8843 let deadline = started_at + Duration::from_millis(time_budget_ms);
8844 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8845 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8846 })
8847 .await
8848 .map_err(|error| {
8849 tracing::warn!(
8850 workspace_id = %workspace_id,
8851 error = %error,
8852 "workspace inspection rejected: walk task failed",
8853 );
8854 failure(
8855 NodeFailureCode::BackendOperationFailed,
8856 &format!("workspace inspection task failed: {error}"),
8857 )
8858 })?;
8859 let (mut git, git_time_budget_exceeded) =
8860 inspect_git_workspace(&canonical_root, deadline).await;
8861 if !git.worktrees.is_empty() {
8862 let registered = self
8863 .workspaces
8864 .read()
8865 .unwrap_or_else(|poisoned| poisoned.into_inner())
8866 .iter()
8867 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8868 .collect::<Vec<_>>();
8869 for worktree in &mut git.worktrees {
8870 worktree.workspace_id = registered
8871 .iter()
8872 .find(|(_, root)| {
8873 worktree_paths_equal(root, windows_path_text(&worktree.path))
8874 })
8875 .map(|(workspace_id, _)| workspace_id.clone());
8876 }
8877 }
8878 if git.is_repository {
8879 git.managed_worktree = self
8880 .managed_worktrees
8881 .lock()
8882 .unwrap_or_else(|poisoned| poisoned.into_inner())
8883 .git_scope(&workspace_id, git.branch.as_deref());
8884 }
8885 let truncation = workspace_inspection_truncation(
8886 &walk_budget,
8887 git_time_budget_exceeded,
8888 started_at.elapsed(),
8889 );
8890 if let Some(truncation) = &truncation {
8891 tracing::info!(
8892 workspace_id = %workspace_id,
8893 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8894 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8895 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8896 entries_visited = truncation.entries_visited,
8897 elapsed_ms = truncation.elapsed_ms,
8898 "workspace inspection truncated by its inner budget",
8899 );
8900 }
8901 Ok(WorkspaceInspection {
8902 workspace_id,
8903 entries,
8904 tree_truncated,
8905 git,
8906 truncation,
8907 })
8908 }
8909
8910 async fn browse_host_directories(
8911 &self,
8912 directory: Option<OpaqueHostPath>,
8913 after: Option<OpaqueHostPath>,
8914 ) -> Result<HostDirectoryListing, NodeFailure> {
8915 let permit = self
8916 .inspection_slots
8917 .clone()
8918 .try_acquire_owned()
8919 .map_err(|_| {
8920 failure(
8921 NodeFailureCode::BackendBusy,
8922 "host directory browse capacity is busy",
8923 )
8924 })?;
8925 let task = tokio::task::spawn_blocking(move || {
8926 let _permit = permit;
8927 browse_host_directories(directory, after)
8928 });
8929 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8930 .await
8931 .map_err(|_| {
8932 failure(
8933 NodeFailureCode::HostDirectoryReadTimedOut,
8934 "host directory browse exceeded its bounded deadline",
8935 )
8936 })?
8937 .map_err(|_| {
8938 failure(
8939 NodeFailureCode::HostDirectoryReadFailed,
8940 "host directory browse task failed",
8941 )
8942 })?
8943 .map_err(host_directory_failure)
8944 }
8945
8946 async fn read_workspace_file(
8947 &self,
8948 workspace_id: WorkspaceId,
8949 path: RepositoryPath,
8950 ) -> Result<WorkspaceFileRead, NodeFailure> {
8951 let canonical_root = self.workspace_root(&workspace_id)?;
8952 let permit = self
8953 .inspection_slots
8954 .clone()
8955 .try_acquire_owned()
8956 .map_err(|_| {
8957 failure(
8958 NodeFailureCode::BackendBusy,
8959 "workspace file read capacity is busy",
8960 )
8961 })?;
8962 let path_to_read = path.clone();
8963 let task = tokio::task::spawn_blocking(move || {
8964 let _permit = permit;
8965 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8966 });
8967 let content = timeout(
8968 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8969 task,
8970 )
8971 .await
8972 .map_err(|_| {
8973 failure(
8974 NodeFailureCode::RepositoryFileReadTimedOut,
8975 "repository file read exceeded its bounded deadline",
8976 )
8977 })?
8978 .map_err(|_| {
8979 failure(
8980 NodeFailureCode::RepositoryFileReadFailed,
8981 "repository file read task failed",
8982 )
8983 })?
8984 .map_err(workspace_file_failure)?;
8985 let content = match content {
8986 WorkspaceFileBytes::Utf8(text) => {
8987 let revision = Some(workspace_file_revision(text.as_bytes()));
8988 return Ok(WorkspaceFileRead {
8989 workspace_id,
8990 path,
8991 content: WorkspaceFileContent::Utf8 {
8992 byte_len: u32::try_from(text.len())
8993 .expect("bounded workspace text length must fit u32"),
8994 text,
8995 },
8996 revision,
8997 });
8998 }
8999 WorkspaceFileBytes::NonUtf8 { byte_length } => {
9000 WorkspaceFileContent::NonUtf8 {
9001 byte_len: u32::try_from(byte_length)
9002 .expect("bounded workspace file length must fit u32"),
9003 }
9004 }
9005 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9006 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9007 },
9008 };
9009 Ok(WorkspaceFileRead {
9010 workspace_id,
9011 path,
9012 content,
9013 revision: None,
9014 })
9015 }
9016
9017 async fn write_workspace_file(
9018 &self,
9019 workspace_id: WorkspaceId,
9020 path: RepositoryPath,
9021 expected_revision: WorkspaceFileRevision,
9022 text: String,
9023 ) -> Result<WorkspaceFileRead, NodeFailure> {
9024 let canonical_root = self.workspace_root(&workspace_id)?;
9025 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9026 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9027 })?;
9028 let path_to_write = path.clone();
9029 let expected = expected_revision.as_str().to_owned();
9030 let text_to_write = text.clone();
9031 let task = tokio::task::spawn_blocking(move || {
9032 let _permit = permit;
9033 write_workspace_file_to_disk(
9034 Path::new(&canonical_root),
9035 &path_to_write,
9036 &expected,
9037 &text_to_write,
9038 )
9039 });
9040 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9041 .await
9042 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9043 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9044 .map_err(workspace_file_write_failure)?;
9045 Ok(WorkspaceFileRead {
9046 workspace_id,
9047 path,
9048 content: WorkspaceFileContent::Utf8 {
9049 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9050 text,
9051 },
9052 revision: Some(WorkspaceFileRevision::new(revision)
9053 .expect("workspace writer returns a valid SHA-256 revision")),
9054 })
9055 }
9056
9057 async fn create_workspace_file(
9058 &self,
9059 workspace_id: WorkspaceId,
9060 path: RepositoryPath,
9061 ) -> Result<WorkspaceFileRead, NodeFailure> {
9062 let canonical_root = self.workspace_root(&workspace_id)?;
9063 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9064 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9065 })?;
9066 let path_to_create = path.clone();
9067 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9068 let task_commit_state = Arc::clone(&commit_state);
9069 let task = tokio::task::spawn_blocking(move || {
9070 let _permit = permit;
9071 create_workspace_file_on_disk(
9072 Path::new(&canonical_root),
9073 &path_to_create,
9074 &task_commit_state,
9075 )
9076 });
9077 let revision = settle_workspace_entry_create(
9078 task,
9079 commit_state,
9080 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9081 "repository file create exceeded its bounded deadline",
9082 "repository file create task failed",
9083 )
9084 .await?;
9085 Ok(WorkspaceFileRead {
9086 workspace_id,
9087 path,
9088 content: WorkspaceFileContent::Utf8 {
9089 text: String::new(),
9090 byte_len: 0,
9091 },
9092 revision: Some(WorkspaceFileRevision::new(revision)
9093 .expect("workspace creator returns a valid SHA-256 revision")),
9094 })
9095 }
9096
9097 async fn create_workspace_directory(
9098 &self,
9099 workspace_id: WorkspaceId,
9100 path: RepositoryPath,
9101 ) -> Result<WorkspaceEntry, NodeFailure> {
9102 let canonical_root = self.workspace_root(&workspace_id)?;
9103 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9104 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9105 })?;
9106 let path_to_create = path.clone();
9107 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9108 let task_commit_state = Arc::clone(&commit_state);
9109 let task = tokio::task::spawn_blocking(move || {
9110 let _permit = permit;
9111 create_workspace_directory_on_disk(
9112 Path::new(&canonical_root),
9113 &path_to_create,
9114 &task_commit_state,
9115 )
9116 });
9117 settle_workspace_entry_create(
9118 task,
9119 commit_state,
9120 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9121 "repository directory create exceeded its bounded deadline",
9122 "repository directory create task failed",
9123 )
9124 .await?;
9125 Ok(WorkspaceEntry {
9126 relative_path: path,
9127 kind: WorkspaceEntryKind::Directory,
9128 })
9129 }
9130
9131 async fn read_git_history(
9132 &self,
9133 workspace_id: WorkspaceId,
9134 path: Option<RepositoryPath>,
9135 before: Option<GitObjectId>,
9136 limit: u16,
9137 ) -> Result<GitHistoryPage, NodeFailure> {
9138 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9139 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9140 })?;
9141 let root = self.workspace_root(&workspace_id)?;
9142 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9143 }
9144
9145 async fn read_git_diff(
9146 &self,
9147 workspace_id: WorkspaceId,
9148 request: GitDiffRequest,
9149 ) -> Result<GitDiff, NodeFailure> {
9150 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9151 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9152 })?;
9153 let root = self.workspace_root(&workspace_id)?;
9154 read_git_diff_bounded(&root, request).await
9155 }
9156
9157 fn native_session_route_root(
9158 &self,
9159 route: &NativeSessionCatalogRoute,
9160 ) -> Result<Option<String>, NodeFailure> {
9161 route
9162 .validate()
9163 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9164 match route.scope {
9165 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9166 .workspace_id
9167 .as_ref()
9168 .map(|workspace_id| self.workspace_root(workspace_id))
9169 .transpose(),
9170 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9171 }
9172 }
9173
9174 fn project_native_session_entries(
9175 &self,
9176 route: &NativeSessionCatalogRoute,
9177 entries: Vec<ScopedNativeSessionCatalogEntry>,
9178 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9179 let current_root = route
9180 .workspace_id
9181 .as_ref()
9182 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9183 let records = self
9184 .session_records
9185 .lock()
9186 .unwrap_or_else(|poisoned| poisoned.into_inner());
9187 let mut seen_records = std::collections::HashSet::new();
9188 entries
9189 .into_iter()
9190 .map(|entry| {
9191 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9192 let matches = records
9193 .records
9194 .values()
9195 .filter(|record| {
9196 record.provider == route.provider
9197 && record.workspace_id == *workspace_id
9198 && current_root.as_deref().is_some_and(|root| {
9199 platform::roots_equal(
9200 root,
9201 windows_path_text(&record.canonical_root),
9202 )
9203 })
9204 && record.provider_session.as_ref().is_some_and(|identity| {
9205 session_registry::same_provider_session(
9206 &record.provider,
9207 identity,
9208 &route.provider,
9209 &entry.provider_session,
9210 )
9211 })
9212 })
9213 .map(|record| record.record_id.clone())
9214 .collect::<Vec<_>>();
9215 (matches.len() == 1
9216 && seen_records.insert(matches[0].clone()))
9217 .then(|| matches[0].clone())
9218 });
9219 let metadata = entry.metadata;
9220 crate::protocol::NativeSessionCatalogEntry {
9221 selection_id: metadata.selection_id,
9222 title: metadata.title,
9223 modified_at_unix_ms: metadata.modified_at_unix_ms,
9224 model: metadata.model,
9225 message_count: metadata.message_count,
9226 completed_turn_count: metadata.completed_turn_count,
9227 external_group: entry.external_group,
9228 record_id,
9229 }
9230 })
9231 .collect()
9232 }
9233
9234 async fn catalog_native_sessions(
9235 &self,
9236 route: NativeSessionCatalogRoute,
9237 limit: u16,
9238 ) -> Result<(
9239 Vec<crate::protocol::NativeSessionCatalogEntry>,
9240 crate::protocol::NativeSessionCatalogSummary,
9241 ), NodeFailure> {
9242 let canonical_root = self.native_session_route_root(&route)?;
9243 let registered_workspace_roots = self.workspace_roots();
9244 let provider = route.provider.clone();
9245 let scope = route.scope;
9246 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9247 failure(
9248 NodeFailureCode::InvalidRequest,
9249 "native session catalog is not configured",
9250 )
9251 })?;
9252 let _permit = Arc::clone(&self.inspection_slots)
9253 .try_acquire_owned()
9254 .map_err(|_| {
9255 failure(
9256 NodeFailureCode::BackendBusy,
9257 "native session catalog capacity is busy",
9258 )
9259 })?;
9260 let page = run_native_session_catalog_operation(
9261 catalog,
9262 "native session catalog",
9263 move |catalog| {
9264 catalog.catalog_initial_for_scope(
9265 &provider,
9266 scope,
9267 canonical_root.as_deref().map(Path::new),
9268 ®istered_workspace_roots,
9269 limit,
9270 )
9271 },
9272 )
9273 .await?;
9274 let summary = crate::protocol::NativeSessionCatalogSummary {
9275 catalog_revision: page.revision,
9276 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9277 recent_total_count: page.recent_total_count,
9278 older_total_count: page.older_total_count,
9279 recent_next_after_selection_id: page.next_after_selection_id,
9280 recent_has_more: page.remaining_count > 0,
9281 };
9282 Ok((self.project_native_session_entries(&route, page.entries), summary))
9283 }
9284
9285 async fn page_native_sessions(
9286 &self,
9287 route: NativeSessionCatalogRoute,
9288 window: crate::protocol::NativeSessionCatalogWindow,
9289 catalog_revision: u64,
9290 recent_cutoff_unix_ms: u64,
9291 after_selection_id: Option<String>,
9292 limit: u16,
9293 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9294 let canonical_root = self.native_session_route_root(&route)?;
9295 let registered_workspace_roots = self.workspace_roots();
9296 let provider = route.provider.clone();
9297 let scope = route.scope;
9298 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9299 failure(
9300 NodeFailureCode::InvalidRequest,
9301 "native session catalog is not configured",
9302 )
9303 })?;
9304 let _permit = Arc::clone(&self.inspection_slots)
9305 .try_acquire_owned()
9306 .map_err(|_| {
9307 failure(
9308 NodeFailureCode::BackendBusy,
9309 "native session catalog capacity is busy",
9310 )
9311 })?;
9312 let page = run_native_session_catalog_operation(
9313 catalog,
9314 "native session catalog page",
9315 move |catalog| {
9316 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9317 &provider,
9318 scope,
9319 canonical_root.as_deref().map(Path::new),
9320 ®istered_workspace_roots,
9321 window,
9322 catalog_revision,
9323 recent_cutoff_unix_ms,
9324 after_selection_id.as_deref(),
9325 limit,
9326 ))
9327 },
9328 )
9329 .await?;
9330 let page = page.map_err(|error| match error {
9331 NativeSessionCatalogError::StaleCatalog => failure(
9332 NodeFailureCode::StaleNativeSessionCatalog,
9333 "native session catalog revision or cursor is stale",
9334 ),
9335 _ => failure(
9336 NodeFailureCode::BackendOperationFailed,
9337 "native session catalog page failed",
9338 ),
9339 })?;
9340 let entries = self.project_native_session_entries(&route, page.entries);
9341 Ok(crate::protocol::NativeSessionCatalogPage {
9342 window: page.window,
9343 revision: page.revision,
9344 entries,
9345 next_after_selection_id: page.next_after_selection_id,
9346 remaining_count: page.remaining_count,
9347 has_more: page.remaining_count > 0,
9348 })
9349 }
9350
9351 async fn preview_native_session(
9352 &self,
9353 selection: NativeSessionSelection,
9354 message_limit: u16,
9355 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9356 selection
9357 .validate()
9358 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9359 let canonical_root = self.native_session_route_root(&selection.route)?;
9360 let registered_workspace_roots = self.workspace_roots();
9361 let provider = selection.route.provider.clone();
9362 let scope = selection.route.scope;
9363 let catalog_revision = selection.catalog_revision;
9364 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9365 let selection_id = selection.selection_id.clone();
9366 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9367 failure(
9368 NodeFailureCode::InvalidRequest,
9369 "native session preview is not configured",
9370 )
9371 })?;
9372 let _permit = Arc::clone(&self.inspection_slots)
9373 .try_acquire_owned()
9374 .map_err(|_| {
9375 failure(
9376 NodeFailureCode::BackendBusy,
9377 "native session preview capacity is busy",
9378 )
9379 })?;
9380 let preview = run_native_session_catalog_operation(
9381 catalog,
9382 "native session preview",
9383 move |catalog| {
9384 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9385 &provider,
9386 scope,
9387 canonical_root.as_deref().map(Path::new),
9388 ®istered_workspace_roots,
9389 catalog_revision,
9390 recent_cutoff_unix_ms,
9391 &selection_id,
9392 message_limit,
9393 ))
9394 },
9395 )
9396 .await?;
9397 preview
9398 .map(Into::into)
9399 .map_err(native_session_preview_failure)
9400 }
9401
9402 async fn index_native_session(
9403 &self,
9404 selection: NativeSessionSelection,
9405 display_name: String,
9406 ) -> Result<ManagedSessionRecord, NodeFailure> {
9407 selection
9408 .validate()
9409 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9410 if selection.route.scope
9411 != gate4agent_types::NativeSessionCatalogScope::Workspace
9412 {
9413 return Err(failure(
9414 NodeFailureCode::WorkspaceRegistrationRequired,
9415 "register the external project as a workspace before indexing or resuming it",
9416 ));
9417 }
9418 let workspace_id = selection
9419 .route
9420 .workspace_id
9421 .clone()
9422 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9423 let canonical_root = self
9424 .native_session_route_root(&selection.route)?
9425 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9426 let registered_workspace_roots = self.workspace_roots();
9427 let provider = selection.route.provider.clone();
9428 let catalog_revision = selection.catalog_revision;
9429 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9430 let selection_id = selection.selection_id.clone();
9431 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9432 failure(
9433 NodeFailureCode::InvalidRequest,
9434 "native session index is not configured",
9435 )
9436 })?;
9437 let _permit = Arc::clone(&self.inspection_slots)
9438 .try_acquire_owned()
9439 .map_err(|_| {
9440 failure(
9441 NodeFailureCode::BackendBusy,
9442 "native session index capacity is busy",
9443 )
9444 })?;
9445 let resolved = run_native_session_catalog_operation(
9446 catalog,
9447 "native session index resolution",
9448 move |catalog| {
9449 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9450 &provider,
9451 gate4agent_types::NativeSessionCatalogScope::Workspace,
9452 Some(Path::new(&canonical_root)),
9453 ®istered_workspace_roots,
9454 catalog_revision,
9455 recent_cutoff_unix_ms,
9456 &selection_id,
9457 ))
9458 },
9459 )
9460 .await?
9461 .map_err(native_session_preview_failure)?;
9462 self.index_provider_session_with_policy(
9463 workspace_id,
9464 selection.route.provider,
9465 resolved.identity,
9466 display_name,
9467 true,
9468 )
9469 }
9470
9471 async fn preview_native_session_by_session_id(
9472 &self,
9473 workspace_id: WorkspaceId,
9474 provider: AgentId,
9475 session_id: String,
9476 message_limit: u16,
9477 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9478 let canonical_root = self.workspace_root(&workspace_id)?;
9479 let registered_workspace_roots = self.workspace_roots();
9480 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9481 failure(
9482 NodeFailureCode::InvalidRequest,
9483 "native session preview is not configured",
9484 )
9485 })?;
9486 let _permit = Arc::clone(&self.inspection_slots)
9487 .try_acquire_owned()
9488 .map_err(|_| {
9489 failure(
9490 NodeFailureCode::BackendBusy,
9491 "native session preview capacity is busy",
9492 )
9493 })?;
9494 run_native_session_catalog_operation(
9495 catalog,
9496 "native session preview",
9497 move |catalog| {
9498 catalog.preview_session_id_for_workspace(
9499 &provider,
9500 Path::new(&canonical_root),
9501 ®istered_workspace_roots,
9502 &session_id,
9503 message_limit,
9504 )
9505 },
9506 )
9507 .await
9508 }
9509
9510 async fn register_workspace(
9511 &self,
9512 workspace_id: WorkspaceId,
9513 root: String,
9514 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9515 validate_workspace_request_root(&root)?;
9516 let canonicalize_workspace_id = workspace_id.clone();
9517 let workspace = tokio::task::spawn_blocking(move || {
9518 WorkspaceConfig::new(canonicalize_workspace_id, root)
9519 })
9520 .await
9521 .map_err(|error| {
9522 failure(
9523 NodeFailureCode::BackendOperationFailed,
9524 &format!("workspace canonicalization task failed: {error}"),
9525 )
9526 })?
9527 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9528 let transaction = self
9529 .state_transaction
9530 .lock()
9531 .unwrap_or_else(|poisoned| poisoned.into_inner());
9532 let mut workspaces = self
9533 .workspaces
9534 .write()
9535 .unwrap_or_else(|poisoned| poisoned.into_inner());
9536 if workspaces.contains_key(&workspace_id) {
9537 return Err(failure(
9538 NodeFailureCode::DuplicateWorkspaceId,
9539 "workspace ID is already registered",
9540 ));
9541 }
9542 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9543 platform::roots_equal(existing_root, workspace.canonical_root())
9544 }) {
9545 return Err(failure(
9546 NodeFailureCode::DuplicateWorkspaceRoot,
9547 &format!("workspace root is already registered as '{existing_id}'"),
9548 ));
9549 }
9550 {
9551 let records = self
9552 .session_records
9553 .lock()
9554 .unwrap_or_else(|poisoned| poisoned.into_inner());
9555 if let Some(record) = records.records.values().find(|record| {
9556 record.workspace_id == workspace_id
9557 && !record
9558 .canonical_root
9559 .as_utf8()
9560 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9561 }) {
9562 return Err(failure(
9563 NodeFailureCode::SessionRecordConflict,
9564 &format!(
9565 "workspace ID is retained by session record '{}' at another root",
9566 record.record_id,
9567 ),
9568 ));
9569 }
9570 }
9571 let snapshot = WorkspaceSnapshot {
9572 workspace_id: workspace_id.clone(),
9573 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9574 sessions: Vec::new(),
9575 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9576 managed_worktree_profiles: Some(WorktreeProfileInventory {
9577 profiles: Vec::new(),
9578 }),
9579 };
9580 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9581 drop(workspaces);
9582 let previous_records = {
9583 let mut records = self
9584 .session_records
9585 .lock()
9586 .unwrap_or_else(|poisoned| poisoned.into_inner());
9587 let previous = records.records.clone();
9588 for record in records.records.values_mut().filter(|record| {
9589 record.workspace_id == workspace_id
9590 && record
9591 .canonical_root
9592 .as_utf8()
9593 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9594 }) {
9595 if record.active_session.is_none() {
9596 record.state = detached_record_state(record);
9597 }
9598 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9599 record.last_error = None;
9600 }
9601 record.updated_at_unix_ms = unix_time_ms();
9602 }
9603 previous
9604 };
9605 if let Err(error) = self.persist_state_locked() {
9606 self.workspaces
9607 .write()
9608 .unwrap_or_else(|poisoned| poisoned.into_inner())
9609 .remove(&workspace_id);
9610 self.session_records
9611 .lock()
9612 .unwrap_or_else(|poisoned| poisoned.into_inner())
9613 .records = previous_records;
9614 return Err(persistence_failure(error));
9615 }
9616 let restored = self
9617 .session_records
9618 .lock()
9619 .unwrap_or_else(|poisoned| poisoned.into_inner())
9620 .records
9621 .values()
9622 .filter(|record| record.workspace_id == workspace_id)
9623 .cloned()
9624 .collect::<Vec<_>>();
9625 drop(transaction);
9626 self.publish(NodeEvent::WorkspaceAdded {
9627 workspace: snapshot.clone(),
9628 });
9629 for record in restored {
9630 self.publish_record(record);
9631 }
9632 Ok(snapshot)
9633 }
9634
9635 async fn create_standalone_workspace(
9636 &self,
9637 workspace_id: WorkspaceId,
9638 root: String,
9639 initial_branch: Option<String>,
9640 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9641 validate_workspace_request_root(&root)?;
9642 validate_git_revision("initial branch", initial_branch.as_deref())?;
9643 let preflight_workspace_id = workspace_id.clone();
9644 let preflight_root = root.clone();
9645 let canonical_candidate = tokio::task::spawn_blocking(move || {
9646 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9647 })
9648 .await
9649 .map_err(|_| failure(
9650 NodeFailureCode::BackendOperationFailed,
9651 "standalone workspace root preflight task failed",
9652 ))?
9653 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9654 {
9655 let workspaces = self
9656 .workspaces
9657 .read()
9658 .unwrap_or_else(|poisoned| poisoned.into_inner());
9659 if workspaces.contains_key(&workspace_id) {
9660 return Err(failure(
9661 NodeFailureCode::DuplicateWorkspaceId,
9662 "workspace ID is already registered",
9663 ));
9664 }
9665 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9666 platform::roots_equal(existing_root, &canonical_candidate)
9667 }) {
9668 return Err(failure(
9669 NodeFailureCode::DuplicateWorkspaceRoot,
9670 &format!("workspace root is already registered as '{existing_id}'"),
9671 ));
9672 }
9673 }
9674 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9675 .await
9676 .map_err(standalone_workspace_failure)?;
9677 prepared
9678 .verify_for_registration()
9679 .await
9680 .map_err(standalone_workspace_failure)?;
9681 match self
9682 .register_workspace(workspace_id, prepared.root().to_owned())
9683 .await
9684 {
9685 Ok(workspace) => Ok(workspace),
9686 Err(registration_error) => {
9687 if prepared.compensate_registration_failure().await {
9688 Err(registration_error)
9689 } else {
9690 Err(failure(
9691 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9692 "standalone workspace initialization succeeded but registration failed; recovery is required",
9693 ))
9694 }
9695 }
9696 }
9697 }
9698
9699 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9700 let bindings = self
9701 .session_bindings
9702 .lock()
9703 .unwrap_or_else(|poisoned| poisoned.into_inner());
9704 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9705 return Err(failure(
9706 NodeFailureCode::WorkspaceBusy,
9707 "workspace retains a bound session; remove every session before unregistering it",
9708 ));
9709 }
9710 drop(bindings);
9711 let transaction = self
9712 .state_transaction
9713 .lock()
9714 .unwrap_or_else(|poisoned| poisoned.into_inner());
9715 let mut workspaces = self
9716 .workspaces
9717 .write()
9718 .unwrap_or_else(|poisoned| poisoned.into_inner());
9719 if !workspaces.contains_key(workspace_id) {
9720 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9721 }
9722 if workspaces.len() == 1 {
9723 return Err(failure(
9724 NodeFailureCode::LastWorkspace,
9725 "node must retain at least one workspace",
9726 ));
9727 }
9728 let removed_root = workspaces
9729 .remove(workspace_id)
9730 .expect("workspace presence was validated");
9731 drop(workspaces);
9732 let previous_records = {
9733 let mut records = self
9734 .session_records
9735 .lock()
9736 .unwrap_or_else(|poisoned| poisoned.into_inner());
9737 let previous = records.records.clone();
9738 for record in records
9739 .records
9740 .values_mut()
9741 .filter(|record| &record.workspace_id == workspace_id)
9742 {
9743 record.active_session = None;
9744 record.state = ManagedSessionState::Unavailable;
9745 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9746 record.updated_at_unix_ms = unix_time_ms();
9747 }
9748 previous
9749 };
9750 if let Err(error) = self.persist_state_locked() {
9751 self.workspaces
9752 .write()
9753 .unwrap_or_else(|poisoned| poisoned.into_inner())
9754 .insert(workspace_id.clone(), removed_root);
9755 self.session_records
9756 .lock()
9757 .unwrap_or_else(|poisoned| poisoned.into_inner())
9758 .records = previous_records;
9759 return Err(persistence_failure(error));
9760 }
9761 let unavailable = self
9762 .session_records
9763 .lock()
9764 .unwrap_or_else(|poisoned| poisoned.into_inner())
9765 .records
9766 .values()
9767 .filter(|record| &record.workspace_id == workspace_id)
9768 .cloned()
9769 .collect::<Vec<_>>();
9770 drop(transaction);
9771 self.publish(NodeEvent::WorkspaceRemoved {
9772 workspace_id: workspace_id.clone(),
9773 });
9774 for record in unavailable {
9775 self.publish_record(record);
9776 }
9777 Ok(())
9778 }
9779
9780 async fn create_worktree(
9781 &self,
9782 source_workspace_id: WorkspaceId,
9783 workspace_id: WorkspaceId,
9784 target_root: String,
9785 branch: String,
9786 base: Option<String>,
9787 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9788 self.require_worktree_service(&source_workspace_id)?;
9789 validate_workspace_request_root(&target_root)?;
9790 validate_git_revision("worktree base", base.as_deref())?;
9791 let source_root = self.workspace_root(&source_workspace_id)?;
9792 {
9793 let workspaces = self
9794 .workspaces
9795 .read()
9796 .unwrap_or_else(|poisoned| poisoned.into_inner());
9797 if workspaces.contains_key(&workspace_id) {
9798 return Err(failure(
9799 NodeFailureCode::DuplicateWorkspaceId,
9800 "workspace ID is already registered",
9801 ));
9802 }
9803 if let Some((existing_id, _)) = workspaces
9804 .iter()
9805 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9806 {
9807 return Err(failure(
9808 NodeFailureCode::DuplicateWorkspaceRoot,
9809 &format!("workspace root is already registered as '{existing_id}'"),
9810 ));
9811 }
9812 }
9813 let mut worktree = create_git_worktree(
9814 &source_root,
9815 &target_root,
9816 &branch,
9817 base.as_deref(),
9818 )
9819 .await
9820 .map_err(git_worktree_failure)?;
9821 let workspace = match self
9822 .register_workspace(workspace_id, worktree.path.clone())
9823 .await
9824 {
9825 Ok(workspace) => workspace,
9826 Err(registration_error) => {
9827 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9828 return Err(failure(
9829 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9830 "worktree registration failed and compensation requires recovery",
9831 ));
9832 }
9833 return Err(failure(
9834 registration_error.code,
9835 ®istration_error.message,
9836 ));
9837 }
9838 };
9839 worktree.workspace_id = Some(workspace.workspace_id.clone());
9840 Ok((protocol_worktree(worktree), workspace))
9841 }
9842
9843 async fn remove_worktree(
9844 &self,
9845 source_workspace_id: WorkspaceId,
9846 target_root: String,
9847 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9848 self.require_worktree_service(&source_workspace_id)?;
9849 validate_workspace_request_root(&target_root)?;
9850 let source_root = self.workspace_root(&source_workspace_id)?;
9851 let target_root = normalize_worktree_removal_target(&target_root)
9852 .map_err(git_worktree_failure)?;
9853 let target = list_git_worktrees(&source_root)
9854 .await
9855 .map_err(git_worktree_failure)?
9856 .into_iter()
9857 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9858 .ok_or_else(|| {
9859 failure(
9860 NodeFailureCode::WorktreeProtected,
9861 "refusing to remove a path that is not in Git's worktree listing",
9862 )
9863 })?;
9864 let registered_workspace_id = {
9865 let workspaces = self
9866 .workspaces
9867 .read()
9868 .unwrap_or_else(|poisoned| poisoned.into_inner());
9869 let matched = workspaces
9870 .iter()
9871 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9872 .map(|(workspace_id, _)| workspace_id.clone());
9873 if matched.is_some() && workspaces.len() == 1 {
9874 return Err(failure(
9875 NodeFailureCode::LastWorkspace,
9876 "node must retain at least one workspace",
9877 ));
9878 }
9879 matched
9880 };
9881 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9882 let bindings = self
9883 .session_bindings
9884 .lock()
9885 .unwrap_or_else(|poisoned| poisoned.into_inner());
9886 if bindings
9887 .values()
9888 .any(|binding| &binding.workspace_id == workspace_id)
9889 {
9890 return Err(failure(
9891 NodeFailureCode::WorkspaceBusy,
9892 "worktree workspace retains a bound session; remove every session first",
9893 ));
9894 }
9895 }
9896 remove_git_worktree(&source_root, &target.path)
9897 .await
9898 .map_err(git_worktree_failure)?;
9899 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9900 self.unregister_workspace(workspace_id)
9901 .map_err(|error| failure(error.code, &error.message))?;
9902 }
9903 Ok(registered_workspace_id)
9904 }
9905
9906 async fn cleanup_managed_worktree(
9907 &self,
9908 lease_id: &ManagedWorktreeLeaseId,
9909 explicit: bool,
9910 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9911 let lease = self.managed_worktrees.lock()
9912 .unwrap_or_else(|poisoned| poisoned.into_inner())
9913 .get(lease_id)
9914 .cloned()
9915 .ok_or_else(|| failure(
9916 NodeFailureCode::UnknownManagedWorktreeLease,
9917 "managed worktree lease does not exist",
9918 ))?;
9919 if lease.state == ManagedWorktreeLeaseState::Removed {
9920 return Ok(lease.snapshot());
9921 }
9922 if lease.has_holders() {
9923 return Err(failure(
9924 NodeFailureCode::ManagedWorktreeBusy,
9925 "managed worktree lease still has a session or durable-record holder",
9926 ));
9927 }
9928 if self
9929 .materializations
9930 .lock()
9931 .unwrap_or_else(|poisoned| poisoned.into_inner())
9932 .values()
9933 .any(|record| record.managed_lease_id() == Some(lease_id))
9934 {
9935 return Err(failure(
9936 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9937 "managed worktree cleanup is blocked by session environment ownership",
9938 ));
9939 }
9940 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9941 let snapshot = {
9942 let mut registry = self.managed_worktrees.lock()
9943 .unwrap_or_else(|poisoned| poisoned.into_inner());
9944 let current = registry.get_mut(lease_id)
9945 .expect("managed worktree lease remains present");
9946 current.state = ManagedWorktreeLeaseState::Retained;
9947 current.cleanup_failure = None;
9948 current.updated_at_unix_ms = unix_time_ms();
9949 current.snapshot()
9950 };
9951 if self.persist_state().is_err() {
9952 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9953 }
9954 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9955 return Ok(snapshot);
9956 }
9957 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9958 if profile.revision() != &lease.profile_revision {
9959 self.mark_managed_recovery_required(
9960 lease_id,
9961 ManagedWorktreeCleanupFailure::OwnershipConflict,
9962 );
9963 return Err(failure(
9964 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9965 "managed worktree profile revision changed",
9966 ));
9967 }
9968 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9969 self.mark_managed_recovery_required(
9970 lease_id,
9971 ManagedWorktreeCleanupFailure::OwnershipConflict,
9972 );
9973 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9974 })?;
9975 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9976 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9977 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9978 managed_git_worktree_failure(error)
9979 })?;
9980 let target = listed.iter()
9981 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9982 if let Some(target) = target {
9983 if target.is_main || target.is_bare || !exact_owned_worktree(
9984 &lease,
9985 &target.path,
9986 target.branch.as_deref(),
9987 &target.head,
9988 ) {
9989 self.mark_managed_recovery_required(
9990 lease_id,
9991 ManagedWorktreeCleanupFailure::OwnershipConflict,
9992 );
9993 return Err(failure(
9994 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9995 "Git worktree identity no longer matches the managed lease",
9996 ));
9997 }
9998 if target.prunable {
9999 self.mark_managed_cleanup_blocked(
10000 lease_id,
10001 ManagedWorktreeCleanupFailure::Prunable,
10002 );
10003 return Err(failure(
10004 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10005 "managed worktree is prunable and requires recovery",
10006 ));
10007 }
10008 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10009 let failure_kind = match error.kind {
10010 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10011 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10012 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10013 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10014 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10015 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10016 };
10017 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10018 self.mark_managed_recovery_required(lease_id, failure_kind);
10019 } else {
10020 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10021 }
10022 return Err(managed_git_worktree_failure(error));
10023 }
10024 } else {
10025 match std::fs::symlink_metadata(&lease.target_root) {
10026 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10027 Ok(_) => {
10028 self.mark_managed_recovery_required(
10029 lease_id,
10030 ManagedWorktreeCleanupFailure::OwnershipConflict,
10031 );
10032 return Err(failure(
10033 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10034 "managed target exists but is not owned by Git",
10035 ));
10036 }
10037 Err(_) => {
10038 self.mark_managed_cleanup_blocked(
10039 lease_id,
10040 ManagedWorktreeCleanupFailure::Backend,
10041 );
10042 return Err(failure(
10043 NodeFailureCode::BackendOperationFailed,
10044 "managed target identity could not be inspected",
10045 ));
10046 }
10047 }
10048 }
10049 let registered = self.workspaces.read()
10050 .unwrap_or_else(|poisoned| poisoned.into_inner())
10051 .contains_key(&lease.workspace_id);
10052 if registered {
10053 self.unregister_workspace(&lease.workspace_id)?;
10054 }
10055 let removed = {
10056 self.managed_worktrees.lock()
10057 .unwrap_or_else(|poisoned| poisoned.into_inner())
10058 .tombstone(lease_id, unix_time_ms())
10059 .expect("managed worktree lease remains present during cleanup")
10060 };
10061 self.persist_state().map_err(persistence_failure)?;
10062 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10063 Ok(removed.snapshot())
10064 }
10065
10066 fn mark_managed_cleanup_blocked(
10067 &self,
10068 lease_id: &ManagedWorktreeLeaseId,
10069 failure_kind: ManagedWorktreeCleanupFailure,
10070 ) {
10071 let snapshot = {
10072 let mut registry = self.managed_worktrees.lock()
10073 .unwrap_or_else(|poisoned| poisoned.into_inner());
10074 let Some(lease) = registry.get_mut(lease_id) else { return };
10075 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10076 lease.cleanup_failure = Some(failure_kind);
10077 lease.updated_at_unix_ms = unix_time_ms();
10078 lease.snapshot()
10079 };
10080 let _ = self.persist_state();
10081 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10082 }
10083
10084 fn require_worktree_service(
10085 &self,
10086 source_workspace_id: &WorkspaceId,
10087 ) -> Result<(), NodeFailure> {
10088 if self.managed_worktrees.lock()
10089 .unwrap_or_else(|poisoned| poisoned.into_inner())
10090 .lease_for_workspace(source_workspace_id)
10091 .is_some()
10092 {
10093 return Err(failure(
10094 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10095 "managed worktree targets are internal-only workspaces",
10096 ));
10097 }
10098 match self
10099 .worktree_service_modes
10100 .get(source_workspace_id)
10101 .copied()
10102 .unwrap_or(WorktreeServiceMode::Manual)
10103 {
10104 WorktreeServiceMode::Manual => Ok(()),
10105 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10106 NodeFailureCode::UnsupportedSpawnCapability,
10107 "Git worktree service mode is unavailable in this node build",
10108 )),
10109 }
10110 }
10111
10112 fn managed_reservation_conflict(
10113 &self,
10114 workspace_id: Option<&WorkspaceId>,
10115 root: Option<&str>,
10116 ) -> bool {
10117 self.managed_worktrees.lock()
10118 .unwrap_or_else(|poisoned| poisoned.into_inner())
10119 .active_records()
10120 .any(|lease| {
10121 workspace_id.is_some_and(|workspace_id| {
10122 workspace_id == &lease.source_workspace_id
10123 || workspace_id == &lease.workspace_id
10124 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10125 })
10126 }
10127
10128 fn reject_managed_reservation(
10129 &self,
10130 workspace_id: Option<&WorkspaceId>,
10131 root: Option<&str>,
10132 ) -> Result<(), NodeFailure> {
10133 if self.managed_reservation_conflict(workspace_id, root) {
10134 Err(failure(
10135 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10136 "managed worktree ownership may only be mutated through its lease cleanup",
10137 ))
10138 } else {
10139 Ok(())
10140 }
10141 }
10142
10143 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10144 self.validate_address(address)?;
10145 self.workspace_root(&address.workspace_id)
10146 }
10147
10148 fn require_session_runtime_policy(
10182 &self,
10183 address: &SessionAddress,
10184 requirement: ProviderRuntimeRequirement,
10185 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10186 let runtime_policy = {
10187 let bindings = self
10188 .session_bindings
10189 .lock()
10190 .unwrap_or_else(|poisoned| poisoned.into_inner());
10191 let binding = bindings
10192 .get(&address.session.instance_id)
10193 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10194 if binding.workspace_id != address.workspace_id {
10195 return Err(failure(
10196 NodeFailureCode::SessionWorkspaceMismatch,
10197 "session belongs to another workspace",
10198 ));
10199 }
10200 if binding.generation != address.session.generation {
10201 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10202 }
10203 binding.runtime_policy
10204 };
10205 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10206 let snapshot = self.internal_session_snapshot(address)?;
10207 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10208 && snapshot.provider.lead_activity != ProviderActivity::Idle
10209 {
10210 return Err(failure(
10211 NodeFailureCode::TurnInFlight,
10212 "session already has a turn in flight; wait for it to complete before sending another prompt",
10213 ));
10214 }
10215 }
10216 require_policy(runtime_policy, false, false, requirement)
10217 .map(|()| runtime_policy)
10218 .map_err(|_| failure(
10219 NodeFailureCode::UnsupportedCapability,
10220 "session runtime policy does not admit this semantic operation",
10221 ))
10222 }
10223
10224 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10225 let mut bindings = self
10226 .session_bindings
10227 .lock()
10228 .unwrap_or_else(|poisoned| poisoned.into_inner());
10229 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10230 binding.workspace_id == address.workspace_id
10231 && binding.generation == address.session.generation
10232 }) {
10233 let removed = bindings.remove(&address.session.instance_id);
10234 self.clear_terminal_frame_watermark(address);
10235 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10236 return removed;
10237 }
10238 None
10239 }
10240
10241 fn cleanup_session_owned_materialization(
10242 &self,
10243 address: &SessionAddress,
10244 binding: &SessionBinding,
10245 ) -> Result<(), NodeFailure> {
10246 let Some(id) = binding.materialization_id.as_ref() else {
10247 return Ok(());
10248 };
10249 let owner = self
10250 .materializations
10251 .lock()
10252 .unwrap_or_else(|poisoned| poisoned.into_inner())
10253 .get(id)
10254 .map(|record| record.owner().clone());
10255 match owner {
10256 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10257 Some(MaterializationOwner::Session {
10258 incarnation_id,
10259 instance_id,
10260 generation,
10261 }) if incarnation_id == self.incarnation_id
10262 && instance_id == address.session.instance_id
10263 && generation == address.session.generation =>
10264 {
10265 self.cleanup_materialization(id)
10266 }
10267 Some(MaterializationOwner::Session { .. }) => Err(failure(
10268 NodeFailureCode::BackendOperationFailed,
10269 "session environment ownership requires recovery",
10270 )),
10271 }
10272 }
10273
10274 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10275 let mut watermarks = self
10276 .terminal_frame_watermarks
10277 .lock()
10278 .unwrap_or_else(|poisoned| poisoned.into_inner());
10279 if watermarks
10280 .get(&address.session.instance_id)
10281 .is_some_and(|(current, _)| current == address)
10282 {
10283 watermarks.remove(&address.session.instance_id);
10284 }
10285 }
10286
10287 #[cfg(test)]
10288 fn address_for(
10289 &self,
10290 instance_id: AgentInstanceId,
10291 generation: SessionGeneration,
10292 ) -> Option<SessionAddress> {
10293 let binding = self.session_bindings
10294 .lock()
10295 .unwrap_or_else(|poisoned| poisoned.into_inner())
10296 .get(&instance_id)
10297 .cloned()?;
10298 (binding.generation == generation).then_some(SessionAddress {
10299 workspace_id: binding.workspace_id,
10300 session: SessionKey { instance_id, generation },
10301 })
10302 }
10303
10304 fn arm_resume(
10305 &self,
10306 address: &SessionAddress,
10307 command_id: CommandId,
10308 runtime_policy: ProviderRuntimePolicy,
10309 ) -> Result<(), NodeFailure> {
10310 let mut bindings = self
10311 .session_bindings
10312 .lock()
10313 .unwrap_or_else(|poisoned| poisoned.into_inner());
10314 let binding = bindings
10315 .get_mut(&address.session.instance_id)
10316 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10317 if binding.workspace_id != address.workspace_id {
10318 return Err(failure(
10319 NodeFailureCode::SessionWorkspaceMismatch,
10320 "session belongs to another workspace",
10321 ));
10322 }
10323 if binding.generation != address.session.generation {
10324 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10325 }
10326 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10327 Ok(())
10328 }
10329
10330 fn clear_armed_resume(&self, address: &SessionAddress) {
10331 let mut bindings = self
10332 .session_bindings
10333 .lock()
10334 .unwrap_or_else(|poisoned| poisoned.into_inner());
10335 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10336 if binding.workspace_id == address.workspace_id
10337 && binding.generation == address.session.generation
10338 {
10339 binding.pending_resume = None;
10340 }
10341 }
10342 }
10343
10344 fn publish_control(&self, event: ControlEvent) {
10345 let revokes_harness_mcp = matches!(
10346 &event.event,
10347 ControlEventKind::Exited { .. }
10348 | ControlEventKind::Failed { .. }
10349 | ControlEventKind::Removed
10350 );
10351 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10352 let mut bindings = self
10353 .session_bindings
10354 .lock()
10355 .unwrap_or_else(|poisoned| poisoned.into_inner());
10356 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10357 return;
10358 };
10359 let previous_address = SessionAddress {
10360 workspace_id: binding.workspace_id.clone(),
10361 session: SessionKey {
10362 instance_id: event.instance_id,
10363 generation: binding.generation,
10364 },
10365 };
10366 let mut resume_command_rejected = false;
10367 if binding.generation == event.generation {
10368 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10369 let rejected_resume = matches!(
10370 &event.event,
10371 ControlEventKind::CommandRejected { .. }
10372 ) && event.command_id == Some(pending_command_id);
10373 let resume_failed = matches!(
10374 &event.event,
10375 ControlEventKind::ResumeDenied { .. }
10376 | ControlEventKind::ResumeFailed { .. }
10377 );
10378 if pending_generation == binding.generation
10379 && (rejected_resume || resume_failed)
10380 {
10381 resume_command_rejected = rejected_resume;
10382 binding.pending_resume = None;
10383 }
10384 }
10385 } else {
10386 let expected = binding.generation.0.checked_add(1);
10387 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10388 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10389 generation == binding.generation
10390 })
10391 || expected != Some(event.generation.0)
10392 || !authorized
10393 {
10394 return;
10395 }
10396 let (_, _, runtime_policy) = binding
10397 .pending_resume
10398 .expect("authorized resume retains its pending runtime policy");
10399 binding.generation = event.generation;
10400 binding.runtime_policy = runtime_policy;
10401 binding.pending_resume = None;
10402 }
10403 (
10404 SessionAddress {
10405 workspace_id: binding.workspace_id.clone(),
10406 session: SessionKey {
10407 instance_id: event.instance_id,
10408 generation: event.generation,
10409 },
10410 },
10411 (previous_address.session.generation != event.generation)
10412 .then_some(previous_address),
10413 binding.record_id.clone(),
10414 binding.runtime_policy.provider_session_identity,
10415 resume_command_rejected,
10416 )
10417 };
10418 if let Some(previous_address) = previous_address.as_ref() {
10419 self.clear_terminal_frame_watermark(previous_address);
10420 }
10421 if let Some(record_id) = record_id {
10422 self.reconcile_managed_record(
10423 &record_id,
10424 &address,
10425 &event,
10426 identity_admitted,
10427 resume_command_rejected,
10428 );
10429 }
10430 match &event.event {
10438 ControlEventKind::Running { process_id } => {
10439 #[cfg(unix)]
10440 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10441 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10442 }
10443 tracing::info!(
10444 node_id = %self.node_id,
10445 workspace_id = %address.workspace_id,
10446 session = ?address.session,
10447 pid = ?process_id,
10448 "session process is running",
10449 );
10450 }
10451 ControlEventKind::Exited { exit_code, forced } => {
10452 #[cfg(unix)]
10453 if let Some(proxy) = self.signer_proxy.get().cloned() {
10454 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10455 if proxy.has_session(&key) {
10456 std::thread::spawn(move || proxy.release(&key));
10458 }
10459 }
10460 tracing::info!(
10461 node_id = %self.node_id,
10462 workspace_id = %address.workspace_id,
10463 session = ?address.session,
10464 exit_code = ?exit_code,
10465 forced = forced,
10466 "session process exited",
10467 );
10468 }
10469 ControlEventKind::Failed { message } => {
10470 tracing::warn!(
10471 node_id = %self.node_id,
10472 workspace_id = %address.workspace_id,
10473 session = ?address.session,
10474 cause = %message,
10475 "session failed",
10476 );
10477 }
10478 ControlEventKind::CommandRejected { message } => {
10479 tracing::warn!(
10480 node_id = %self.node_id,
10481 workspace_id = %address.workspace_id,
10482 session = ?address.session,
10483 cause = %message,
10484 "session command rejected",
10485 );
10486 }
10487 ControlEventKind::Removed => {
10488 tracing::info!(
10489 node_id = %self.node_id,
10490 workspace_id = %address.workspace_id,
10491 session = ?address.session,
10492 "session removed",
10493 );
10494 }
10495 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10496 tracing::info!(
10497 node_id = %self.node_id,
10498 workspace_id = %address.workspace_id,
10499 session = ?address.session,
10500 provider_session = ?provider_session,
10501 "session resume authorized",
10502 );
10503 }
10504 ControlEventKind::Resumed { session: provider_session, process_id } => {
10505 tracing::info!(
10506 node_id = %self.node_id,
10507 workspace_id = %address.workspace_id,
10508 session = ?address.session,
10509 provider_session = ?provider_session,
10510 pid = ?process_id,
10511 "session resumed",
10512 );
10513 }
10514 ControlEventKind::ResumeDenied { reason } => {
10515 tracing::warn!(
10516 node_id = %self.node_id,
10517 workspace_id = %address.workspace_id,
10518 session = ?address.session,
10519 cause = %reason,
10520 "session resume denied",
10521 );
10522 }
10523 ControlEventKind::ResumeFailed { message } => {
10524 tracing::warn!(
10525 node_id = %self.node_id,
10526 workspace_id = %address.workspace_id,
10527 session = ?address.session,
10528 cause = %message,
10529 "session resume failed",
10530 );
10531 }
10532 _ => {}
10533 }
10534 self.apply_pending_acp_options(&address, &event);
10535 let stream_chunk = agent_stream_chunk(&event);
10536 self.publish(NodeEvent::Control {
10537 address: address.clone(),
10538 event,
10539 });
10540 if let Some(chunk) = stream_chunk {
10541 self.publish_agent_stream_chunk(address.clone(), chunk);
10542 }
10543 if revokes_harness_mcp {
10544 SessionLifecycleHook::on_session_ended(self, &address);
10545 }
10546 }
10547
10548 fn reconcile_managed_record(
10549 &self,
10550 record_id: &SessionRecordId,
10551 address: &SessionAddress,
10552 event: &ControlEvent,
10553 identity_admitted: bool,
10554 resume_command_rejected: bool,
10555 ) {
10556 let _transaction = self
10557 .state_transaction
10558 .lock()
10559 .unwrap_or_else(|poisoned| poisoned.into_inner());
10560 let observed_identity = match &event.event {
10561 ControlEventKind::ProviderEvent {
10562 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10563 ..
10564 } if identity_admitted => Some(identity.clone()),
10565 _ => None,
10566 };
10567 let identity_was_observed = observed_identity.is_some();
10568 let replacement_id = observed_identity
10569 .as_ref()
10570 .and_then(|_| self.allocate_record_id().ok());
10571 let mut upserts = Vec::new();
10572 let mut removals = Vec::new();
10573 let mut rebind = None;
10574 let original_records;
10575 let original_materializations = self
10576 .materializations
10577 .lock()
10578 .unwrap_or_else(|poisoned| poisoned.into_inner())
10579 .clone();
10580 {
10581 let mut records = self
10582 .session_records
10583 .lock()
10584 .unwrap_or_else(|poisoned| poisoned.into_inner());
10585 original_records = records.records.clone();
10586 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10587 return;
10588 };
10589 if let Some(identity) = observed_identity {
10590 let existing_id = records
10591 .records
10592 .values()
10593 .find(|record| {
10594 record.record_id != *record_id
10595 && record.provider_session.as_ref().is_some_and(|record_identity| {
10596 session_registry::same_provider_session(
10597 &record.provider,
10598 record_identity,
10599 ¤t_snapshot.provider,
10600 &identity,
10601 )
10602 })
10603 })
10604 .map(|record| record.record_id.clone());
10605 if let Some(existing_id) = existing_id {
10606 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10607 record.mode == current_snapshot.mode
10608 && record.workspace_id == current_snapshot.workspace_id
10609 && record.environment_profile == current_snapshot.environment_profile
10610 && record.bundle == current_snapshot.bundle
10611 && record.context_id == current_snapshot.context_id
10612 && record.context == current_snapshot.context
10613 && record
10614 .canonical_root
10615 .as_utf8()
10616 .zip(current_snapshot.canonical_root.as_utf8())
10617 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10618 });
10619 if !scope_matches {
10620 if let Some(current) = records.records.get_mut(record_id) {
10621 current.state = ManagedSessionState::Unavailable;
10622 current.last_error =
10623 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10624 current.updated_at_unix_ms = unix_time_ms();
10625 upserts.push(current.clone());
10626 }
10627 } else {
10628 let existing_is_active = records
10629 .records
10630 .get(&existing_id)
10631 .is_some_and(|record| record.active_session.is_some());
10632 if existing_is_active {
10633 if let Some(current) = records.records.get_mut(record_id) {
10634 current.state = ManagedSessionState::Unavailable;
10635 current.last_error =
10636 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10637 current.updated_at_unix_ms = unix_time_ms();
10638 upserts.push(current.clone());
10639 }
10640 } else {
10641 if current_snapshot.provider_session.is_some() {
10642 if let Some(current) = records.records.get_mut(record_id) {
10643 current.active_session = None;
10644 current.state = ManagedSessionState::Dormant;
10645 current.updated_at_unix_ms = unix_time_ms();
10646 upserts.push(current.clone());
10647 }
10648 } else {
10649 records.records.remove(record_id);
10650 removals.push(record_id.clone());
10651 }
10652 if let Some(existing) = records.records.get_mut(&existing_id) {
10653 existing.provider_session = Some(identity.clone());
10654 existing.active_session = Some(address.clone());
10655 existing.state = ManagedSessionState::Live;
10656 existing.updated_at_unix_ms = unix_time_ms();
10657 existing.last_error = None;
10658 upserts.push(existing.clone());
10659 }
10660 rebind = Some(existing_id);
10661 }
10662 }
10663 } else if current_snapshot
10664 .provider_session
10665 .as_ref()
10666 .is_some_and(|current| {
10667 !session_registry::same_provider_session(
10668 ¤t_snapshot.provider,
10669 current,
10670 ¤t_snapshot.provider,
10671 &identity,
10672 )
10673 })
10674 {
10675 if let Some(current) = records.records.get_mut(record_id) {
10676 current.active_session = None;
10677 if current.bundle.is_some() || current.context.is_some() {
10678 let had_bundle = current.bundle.is_some();
10679 current.provider_session = None;
10680 current.bundle = None;
10681 current.context_id = None;
10682 current.context = None;
10683 current.state = ManagedSessionState::Unavailable;
10684 current.last_error = Some(if had_bundle {
10685 "bundle-unavailable".to_owned()
10686 } else {
10687 "context-unavailable".to_owned()
10688 });
10689 } else {
10690 current.state = ManagedSessionState::Dormant;
10691 }
10692 current.updated_at_unix_ms = unix_time_ms();
10693 upserts.push(current.clone());
10694 }
10695 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10696 if let Some(current) = records.records.get_mut(record_id) {
10697 current.state = ManagedSessionState::Unavailable;
10698 current.last_error =
10699 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10700 current.updated_at_unix_ms = unix_time_ms();
10701 upserts.push(current.clone());
10702 }
10703 } else if let Some(new_record_id) = replacement_id {
10704 let now = unix_time_ms();
10705 let replacement = ManagedSessionRecord {
10706 record_id: new_record_id.clone(),
10707 display_name: format!(
10708 "{} #{}",
10709 current_snapshot.provider.as_str(),
10710 records
10711 .records
10712 .values()
10713 .filter(|record| {
10714 record.provider == current_snapshot.provider
10715 })
10716 .count()
10717 .saturating_add(1),
10718 ),
10719 provider: current_snapshot.provider.clone(),
10720 mode: current_snapshot.mode,
10721 state: ManagedSessionState::Live,
10722 workspace_id: current_snapshot.workspace_id,
10723 canonical_root: current_snapshot.canonical_root,
10724 provider_session: Some(identity),
10725 active_session: Some(address.clone()),
10726 environment_profile: current_snapshot.environment_profile,
10727 bundle: current_snapshot.bundle,
10728 context_id: current_snapshot.context_id,
10729 context: current_snapshot.context,
10730 exported_context: None,
10736 task_binding: current_snapshot.task_binding,
10737 created_at_unix_ms: now,
10738 updated_at_unix_ms: now,
10739 last_error: None,
10740 };
10741 records
10742 .records
10743 .insert(new_record_id.clone(), replacement.clone());
10744 upserts.push(replacement);
10745 rebind = Some(new_record_id);
10746 } else if let Some(current) = records.records.get_mut(record_id) {
10747 current.state = ManagedSessionState::Unavailable;
10748 current.last_error =
10749 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10750 upserts.push(current.clone());
10751 }
10752 } else if let Some(current) = records.records.get_mut(record_id) {
10753 current.provider_session = Some(identity);
10754 current.active_session = Some(address.clone());
10755 current.state = ManagedSessionState::Live;
10756 current.updated_at_unix_ms = unix_time_ms();
10757 current.last_error = None;
10758 upserts.push(current.clone());
10759 }
10760 } else if let Some(current) = records.records.get_mut(record_id) {
10761 if resume_command_rejected {
10762 current.active_session = None;
10763 current.state = detached_record_state(current);
10764 current.updated_at_unix_ms = unix_time_ms();
10765 current.last_error =
10766 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10767 upserts.push(current.clone());
10768 } else {
10769 match &event.event {
10770 ControlEventKind::Running { .. }
10771 | ControlEventKind::ResumeAuthorized { .. }
10772 | ControlEventKind::Resumed { .. } => {
10773 current.active_session = Some(address.clone());
10774 current.state = active_record_state(
10775 identity_admitted,
10776 current.provider_session.is_some(),
10777 );
10778 current.updated_at_unix_ms = unix_time_ms();
10779 current.last_error = None;
10780 upserts.push(current.clone());
10781 }
10782 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10783 current.active_session = None;
10784 current.state = detached_record_state(current);
10785 current.updated_at_unix_ms = unix_time_ms();
10786 upserts.push(current.clone());
10787 }
10788 ControlEventKind::Failed { message }
10789 | ControlEventKind::ResumeFailed { message } => {
10790 current.active_session = None;
10791 current.state = detached_record_state(current);
10792 current.updated_at_unix_ms = unix_time_ms();
10793 current.last_error = Some(
10794 session_registry::sanitized_record_error_summary(message),
10795 );
10796 upserts.push(current.clone());
10797 }
10798 ControlEventKind::ResumeDenied { reason } => {
10799 current.active_session = None;
10800 current.state = detached_record_state(current);
10801 current.updated_at_unix_ms = unix_time_ms();
10802 current.last_error = Some(
10803 session_registry::sanitized_record_error_summary(reason),
10804 );
10805 upserts.push(current.clone());
10806 }
10807 _ => {}
10808 }
10809 }
10810 }
10811 }
10812 let materialization_owner_record = identity_was_observed.then(|| {
10813 rebind.clone().unwrap_or_else(|| record_id.clone())
10814 }).and_then(|candidate| {
10815 self.session_records
10816 .lock()
10817 .unwrap_or_else(|poisoned| poisoned.into_inner())
10818 .records
10819 .get(&candidate)
10820 .filter(|record| {
10821 record.provider_session.is_some()
10822 && record.active_session.as_ref() == Some(address)
10823 })
10824 .map(|_| candidate)
10825 });
10826 if let Some(owner_record_id) = materialization_owner_record {
10827 let materialization_id = self
10828 .session_bindings
10829 .lock()
10830 .unwrap_or_else(|poisoned| poisoned.into_inner())
10831 .get(&address.session.instance_id)
10832 .and_then(|binding| binding.materialization_id.clone());
10833 if let Some(materialization_id) = materialization_id {
10834 let mut transfer_failed = false;
10835 {
10836 let mut materializations = self
10837 .materializations
10838 .lock()
10839 .unwrap_or_else(|poisoned| poisoned.into_inner());
10840 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10841 match ownership.owner() {
10842 MaterializationOwner::Session {
10843 incarnation_id,
10844 instance_id,
10845 generation,
10846 } if *incarnation_id == self.incarnation_id
10847 && *instance_id == address.session.instance_id
10848 && *generation == address.session.generation =>
10849 {
10850 transfer_failed = ownership
10851 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10852 .is_err();
10853 }
10854 MaterializationOwner::Record { record_id }
10855 if record_id == &owner_record_id => {}
10856 MaterializationOwner::Record { record_id } => {
10857 let expected = record_id.clone();
10858 transfer_failed = ownership
10859 .transfer_record_owner(
10860 &expected,
10861 owner_record_id.clone(),
10862 unix_time_ms(),
10863 )
10864 .is_err();
10865 }
10866 _ => transfer_failed = true,
10867 }
10868 if transfer_failed {
10869 let _ = ownership.mark_recovery_required(unix_time_ms());
10870 }
10871 } else {
10872 transfer_failed = true;
10873 }
10874 }
10875 if transfer_failed {
10876 if let Some(record) = self
10877 .session_records
10878 .lock()
10879 .unwrap_or_else(|poisoned| poisoned.into_inner())
10880 .records
10881 .get_mut(&owner_record_id)
10882 {
10883 record.state = ManagedSessionState::Unavailable;
10884 record.last_error = Some("environment-profile-unavailable".to_owned());
10885 record.updated_at_unix_ms = unix_time_ms();
10886 upserts.push(record.clone());
10887 }
10888 }
10889 }
10890 }
10891 if let Some(new_record_id) = rebind.clone() {
10892 if let Some(binding) = self
10893 .session_bindings
10894 .lock()
10895 .unwrap_or_else(|poisoned| poisoned.into_inner())
10896 .get_mut(&address.session.instance_id)
10897 {
10898 binding.record_id = Some(new_record_id);
10899 }
10900 }
10901 if upserts.is_empty() && removals.is_empty() {
10902 return;
10903 }
10904 if self.persist_state_locked().is_err() {
10905 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10906 let mut records = self
10907 .session_records
10908 .lock()
10909 .unwrap_or_else(|poisoned| poisoned.into_inner());
10910 records.records = original_records;
10911 *self
10912 .materializations
10913 .lock()
10914 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10915 if let Some(current) = records.records.get_mut(record_id) {
10916 current.state = ManagedSessionState::Unavailable;
10917 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10918 current.updated_at_unix_ms = unix_time_ms();
10919 upserts = vec![current.clone()];
10920 } else {
10921 upserts.clear();
10922 }
10923 removals.clear();
10924 drop(records);
10925 if let Some(binding) = self
10926 .session_bindings
10927 .lock()
10928 .unwrap_or_else(|poisoned| poisoned.into_inner())
10929 .get_mut(&address.session.instance_id)
10930 {
10931 binding.record_id = Some(record_id.clone());
10932 }
10933 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10934 }
10935 for record in upserts {
10936 self.publish_record(record);
10937 }
10938 for record_id in removals {
10939 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10940 }
10941 }
10942
10943 fn snapshot(&self) -> NodeSnapshot {
10944 let control = self.handle.snapshot();
10945 let bindings = self
10946 .session_bindings
10947 .lock()
10948 .unwrap_or_else(|poisoned| poisoned.into_inner())
10949 .clone();
10950 let workspace_roots = self
10951 .workspaces
10952 .read()
10953 .unwrap_or_else(|poisoned| poisoned.into_inner())
10954 .clone();
10955 let environment_profiles = self
10956 .environment_profiles
10957 .read()
10958 .unwrap_or_else(|poisoned| poisoned.into_inner());
10959 let mut workspaces = workspace_roots
10960 .iter()
10961 .map(|(workspace_id, canonical_root)| {
10962 (
10963 workspace_id.clone(),
10964 WorkspaceSnapshot {
10965 workspace_id: workspace_id.clone(),
10966 canonical_root: opaque_windows_path(canonical_root.clone()),
10967 sessions: Vec::new(),
10968 worktree_service_mode: Some(
10969 self.worktree_service_modes
10970 .get(workspace_id)
10971 .copied()
10972 .unwrap_or(WorktreeServiceMode::Manual),
10973 ),
10974 managed_worktree_profiles: Some(WorktreeProfileInventory {
10975 profiles: self
10976 .managed_worktree_profiles
10977 .get(workspace_id)
10978 .into_iter()
10979 .flat_map(BTreeMap::values)
10980 .map(|profile| ManagedWorktreeProfileSummary {
10981 id: profile.profile_id().clone(),
10982 revision: profile.revision().clone(),
10983 retention: profile.retention(),
10984 })
10985 .collect(),
10986 }),
10987 },
10988 )
10989 })
10990 .collect::<BTreeMap<_, _>>();
10991 let mut agent_progress = Vec::new();
10992 for session in &control.sessions {
10993 let Some(binding) = bindings.get(&session.instance_id) else {
10994 continue;
10995 };
10996 if binding.generation != session.generation {
10997 continue;
10998 }
10999 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11000 let address = SessionAddress {
11001 workspace_id: binding.workspace_id.clone(),
11002 session: SessionKey {
11003 instance_id: session.instance_id,
11004 generation: session.generation,
11005 },
11006 };
11007 if let Some(progress) =
11008 agent_progress_from_provider_snapshot(address, &session.provider)
11009 {
11010 agent_progress.push(progress);
11011 }
11012 workspace.sessions.push(session.clone());
11013 }
11014 }
11015 NodeSnapshot {
11016 node_id: self.node_id.clone(),
11017 enabled_providers: self.enabled_providers.clone(),
11018 provider_runtime_statuses: {
11019 let updates = self
11020 .provider_runtime_status_updates
11021 .read()
11022 .unwrap_or_else(|poisoned| poisoned.into_inner());
11023 ProviderRuntimeStatuses::new(
11024 self.provider_runtime_statuses
11025 .iter()
11026 .filter(|status| !updates.contains_key(status.provider()))
11027 .cloned()
11028 .chain(updates.values().cloned()),
11029 )
11030 .expect("refreshed provider runtime inventory remains bounded and unique")
11031 },
11032 workspaces: workspaces.into_values().collect(),
11033 session_records: self
11034 .session_records
11035 .lock()
11036 .unwrap_or_else(|poisoned| poisoned.into_inner())
11037 .records
11038 .values()
11039 .cloned()
11040 .collect(),
11041 managed_worktrees: self.managed_worktrees
11042 .lock()
11043 .unwrap_or_else(|poisoned| poisoned.into_inner())
11044 .snapshots(),
11045 launch_inventory: Some(LaunchInventory {
11046 spawn_profiles: Some(
11047 self.spawn_profiles
11048 .iter()
11049 .filter_map(|profile| {
11050 resolved_spawn_profile_summary(profile, &environment_profiles)
11051 })
11052 .collect(),
11053 ),
11054 bundles: Some(
11055 self.bundle_catalog
11056 .read()
11057 .unwrap_or_else(|poisoned| poisoned.into_inner())
11058 .iter()
11059 .map(NodeBundle::receipt)
11060 .collect(),
11061 ),
11062 network_allowlists: {
11065 let catalog = self
11066 .network_allowlist_catalog
11067 .read()
11068 .unwrap_or_else(|poisoned| poisoned.into_inner());
11069 if catalog.is_empty() {
11070 None
11071 } else {
11072 Some(catalog.ids().cloned().collect())
11073 }
11074 },
11075 }),
11076 agent_progress,
11077 }
11078 }
11079
11080 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11081 assert!(
11082 !matches!(
11083 &event,
11084 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11085 ),
11086 "terminal frame and agent stream events must use their own dedicated live channel",
11087 );
11088 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11089 let sequence = history
11090 .last_sequence
11091 .checked_add(1)
11092 .expect("node event sequence exhausted");
11093 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11094 if let Some(evicted) = history.events.pop_front() {
11095 history.replay_floor_sequence = evicted
11096 .sequence
11097 .checked_add(1)
11098 .expect("node replay floor sequence exhausted");
11099 history.removed_record_providers.remove(&evicted.sequence);
11100 }
11101 }
11102 match &event {
11103 NodeEvent::HarnessMcpReadCall { .. } => {
11104 panic!("transient harness MCP calls must not enter durable event history")
11105 }
11106 NodeEvent::SessionRecordUpserted { record } => {
11107 history
11108 .record_providers
11109 .insert(record.record_id.clone(), record.provider.clone());
11110 }
11111 NodeEvent::SessionRecordRemoved { record_id } => {
11112 if let Some(provider) = history.record_providers.remove(record_id) {
11113 history.removed_record_providers.insert(sequence, provider);
11114 }
11115 }
11116 NodeEvent::ManagedWorktreeUpserted { .. }
11117 | NodeEvent::ManagedWorktreeRemoved { .. }
11118 | NodeEvent::Control { .. }
11119 | NodeEvent::ControllerChanged { .. }
11120 | NodeEvent::SessionRecordHistorySummarized { .. }
11121 | NodeEvent::WorkspaceAdded { .. }
11122 | NodeEvent::WorkspaceRemoved { .. }
11123 | NodeEvent::ResyncRequired { .. }
11124 | NodeEvent::AgentStream { .. }
11125 | NodeEvent::TerminalFrame { .. } => {}
11126 }
11127 let envelope = NodeEventEnvelope { sequence, event };
11128 history.events.push_back(envelope.clone());
11129 history.last_sequence = sequence;
11130 let _ = self.event_tx.send(envelope.clone());
11131 drop(history);
11132 envelope
11133 }
11134
11135 fn publish_transient(&self, event: NodeEvent) -> bool {
11146 assert!(event.requires_harness_mcp_proxy_capability());
11147 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11148 let envelope = NodeEventEnvelope { sequence: 0, event };
11149 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11150 Ok(_) => true,
11151 Err(broadcast::error::SendError(undelivered)) => {
11152 if let Some(NodeEventEnvelope {
11153 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11154 ..
11155 }) = undelivered.first() {
11156 tracing::warn!(
11162 call_id = call_id.as_str(),
11163 content_type = ?request.content_type,
11164 cause = "no-subscribers",
11165 "harness MCP proxy event could not be published",
11166 );
11167 }
11168 false
11169 }
11170 }
11171 }
11172
11173 fn publish_terminal_frames(&self) {
11174 let snapshot = self.handle.snapshot();
11175 let bindings = self
11176 .session_bindings
11177 .lock()
11178 .unwrap_or_else(|poisoned| poisoned.into_inner());
11179 let candidates = snapshot
11180 .sessions
11181 .iter()
11182 .filter_map(|session| {
11183 let binding = bindings.get(&session.instance_id)?;
11184 if binding.generation != session.generation {
11185 return None;
11186 }
11187 let frame = session.terminal_frame.clone()?;
11188 crate::session_signals::feed(
11189 binding.workspace_id.as_str(),
11190 session.instance_id.0,
11191 session.agent_id.as_str(),
11192 &frame.contents,
11193 );
11194 Some((
11195 SessionAddress {
11196 workspace_id: binding.workspace_id.clone(),
11197 session: SessionKey {
11198 instance_id: session.instance_id,
11199 generation: session.generation,
11200 },
11201 },
11202 frame,
11203 ))
11204 })
11205 .collect();
11206 drop(bindings);
11207 self.publish_terminal_frame_candidates(candidates);
11208 }
11209
11210 fn publish_terminal_frame_candidates(
11211 &self,
11212 candidates: Vec<(SessionAddress, TerminalFrame)>,
11213 ) {
11214 let mut watermarks = self
11215 .terminal_frame_watermarks
11216 .lock()
11217 .unwrap_or_else(|poisoned| poisoned.into_inner());
11218 let mut events = Vec::with_capacity(candidates.len());
11219 for (address, frame) in candidates {
11220 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11221 let advanced = watermarks
11222 .get(&address.session.instance_id)
11223 .map_or(true, |(current_address, sequence)| {
11224 current_address != &address || frame.sequence > *sequence
11225 });
11226 if !advanced {
11227 continue;
11228 }
11229 if is_first_frame {
11230 if self.controller_state().is_none() {
11244 tracing::warn!(
11245 node_id = %self.node_id,
11246 workspace_id = %address.workspace_id,
11247 session = ?address.session,
11248 terminal_size = ?frame.size,
11249 "session producing output with no controller attached",
11250 );
11251 } else {
11252 tracing::info!(
11253 node_id = %self.node_id,
11254 workspace_id = %address.workspace_id,
11255 session = ?address.session,
11256 terminal_size = ?frame.size,
11257 "session produced its first output",
11258 );
11259 }
11260 }
11261 watermarks.insert(
11262 address.session.instance_id,
11263 (address.clone(), frame.sequence),
11264 );
11265 if let Some((at, reported)) = self
11266 .pty_input_marks
11267 .lock()
11268 .unwrap_or_else(|poisoned| poisoned.into_inner())
11269 .get_mut(&address.session.instance_id)
11270 {
11271 if !*reported && at.elapsed() < Duration::from_secs(5) {
11273 *reported = true;
11274 tracing::debug!(target: "pty_lat", instance_id = address.session.instance_id.0, node_us = at.elapsed().as_micros() as u64, "pty input to frame published");
11275 }
11276 }
11277 events.push(NodeEvent::TerminalFrame { address, frame });
11278 }
11279 drop(watermarks);
11280 if events.is_empty() {
11281 return;
11282 }
11283 let mut history = self
11284 .history
11285 .lock()
11286 .unwrap_or_else(|poisoned| poisoned.into_inner());
11287 let mut envelopes = Vec::with_capacity(events.len());
11288 for event in events {
11289 let sequence = history
11290 .last_sequence
11291 .checked_add(1)
11292 .expect("node event sequence exhausted");
11293 history.last_sequence = sequence;
11294 envelopes.push(NodeEventEnvelope { sequence, event });
11295 }
11296 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11297 drop(history);
11298 }
11299
11300 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11309 let mut history = self
11310 .history
11311 .lock()
11312 .unwrap_or_else(|poisoned| poisoned.into_inner());
11313 let sequence = history
11314 .last_sequence
11315 .checked_add(1)
11316 .expect("node event sequence exhausted");
11317 history.last_sequence = sequence;
11318 drop(history);
11319 tracing::debug!(
11327 source_sequence = chunk.source_sequence,
11328 kind = agent_stream_chunk_kind_label(&chunk.kind),
11329 envelope_sequence = sequence,
11330 "publishing agent stream chunk",
11331 );
11332 let envelope = NodeEventEnvelope {
11333 sequence,
11334 event: NodeEvent::AgentStream { address, chunk },
11335 };
11336 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11337 }
11338
11339 fn current_sequence(&self) -> u64 {
11340 self.history
11341 .lock()
11342 .unwrap_or_else(|poisoned| poisoned.into_inner())
11343 .last_sequence
11344 }
11345
11346 fn controller_state(&self) -> Option<ControllerState> {
11347 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11348 let lease = controller.as_ref().copied()?;
11349 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11350 if remaining.is_zero() {
11351 *controller = None;
11352 return None;
11353 }
11354 Some(ControllerState {
11355 connection_id: lease.connection_id,
11356 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11357 })
11358 }
11359
11360 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11361 if role != ClientRole::Operator {
11362 tracing::warn!(
11363 node_id = %self.node_id,
11364 connection_id,
11365 "controller lease rejected: observer connections cannot acquire control",
11366 );
11367 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11368 }
11369 let lease_ms = if lease_ms == 0 {
11370 DEFAULT_CONTROLLER_LEASE_MS
11371 } else {
11372 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11373 };
11374 let now = Instant::now();
11375 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11376 if let Some(active) = controller.as_ref() {
11377 if active.connection_id != connection_id && active.expires_at > now {
11378 tracing::warn!(
11379 node_id = %self.node_id,
11380 connection_id,
11381 holder_connection_id = active.connection_id,
11382 "controller lease rejected: another operator holds it",
11383 );
11384 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11385 }
11386 }
11387 let is_new_attach = match controller.as_ref() {
11394 Some(active) => active.connection_id != connection_id,
11395 None => true,
11396 };
11397 *controller = Some(ControllerLease {
11398 connection_id,
11399 expires_at: now + Duration::from_millis(lease_ms),
11400 });
11401 drop(controller);
11402 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11403 if is_new_attach {
11404 tracing::info!(
11405 node_id = %self.node_id,
11406 connection_id,
11407 lease_ms,
11408 "controller lease acquired",
11409 );
11410 } else {
11411 tracing::debug!(
11412 node_id = %self.node_id,
11413 connection_id,
11414 lease_ms,
11415 "controller lease renewed",
11416 );
11417 }
11418 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11419 Ok(state)
11420 }
11421
11422 fn release_controller(&self, connection_id: u64) -> bool {
11423 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11424 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11425 if released {
11426 *controller = None;
11427 }
11428 drop(controller);
11429 if released {
11430 tracing::info!(
11431 node_id = %self.node_id,
11432 connection_id,
11433 "controller lease released",
11434 );
11435 self.publish(NodeEvent::ControllerChanged { controller: None });
11436 }
11437 released
11438 }
11439
11440 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11449 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11450 return;
11451 };
11452 let id = address.session.instance_id;
11453 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11454 return;
11455 };
11456 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11457 .iter()
11458 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11459 .collect();
11460 let (writes, set_model, effort_gap) =
11461 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11462 if set_model {
11463 if let Some(model_id) = model.clone() {
11464 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11465 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11466 }
11467 }
11468 }
11469 for (option_id, value_json) in writes {
11470 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11471 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11472 }
11473 }
11474 if effort_gap {
11475 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11476 }
11477 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11478 }
11479
11480 fn mark_spawn_provisional(&self, address: SessionAddress) {
11481 let id = address.session.instance_id;
11482 self.provisional_spawns
11483 .lock()
11484 .unwrap_or_else(|poisoned| poisoned.into_inner())
11485 .insert(id, (address, Instant::now()));
11486 tracing::info!(
11487 node_id = %self.node_id,
11488 instance_id = id.0,
11489 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11490 "draft spawn; waiting for HQ view",
11491 );
11492 }
11493
11494 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11495 let removed = self
11496 .provisional_spawns
11497 .lock()
11498 .unwrap_or_else(|poisoned| poisoned.into_inner())
11499 .remove(&instance_id);
11500 if removed.is_some() {
11501 tracing::info!(
11502 node_id = %self.node_id,
11503 instance_id = instance_id.0,
11504 "draft spawn claimed by HQ view",
11505 );
11506 }
11507 }
11508
11509 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11510 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11511 let mut map = self
11512 .provisional_spawns
11513 .lock()
11514 .unwrap_or_else(|poisoned| poisoned.into_inner());
11515 let due: Vec<AgentInstanceId> = map
11516 .iter()
11517 .filter(|(_, (_, at))| at.elapsed() >= grace)
11518 .map(|(id, _)| *id)
11519 .collect();
11520 due.into_iter()
11521 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11522 .collect()
11523 }
11524
11525 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11526 let instance_id = address.session.instance_id;
11527 tracing::warn!(
11528 node_id = %self.node_id,
11529 instance_id = instance_id.0,
11530 workspace = %address.workspace_id,
11531 reason = "attach_never_arrived",
11532 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11533 "draft spawn never viewed; stopping PTY",
11534 );
11535 let _ = self.dispatch(ControlCommand::Stop {
11536 instance_id,
11537 force: true,
11538 });
11539 let _ = self.remove_session(&address).await;
11540 }
11541
11542 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11543 if role != ClientRole::Operator {
11544 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11545 }
11546 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11547 let now = Instant::now();
11548 match controller.as_ref().copied() {
11549 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11550 Some(active) if active.expires_at <= now => {
11551 *controller = None;
11552 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11553 }
11554 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11555 }
11556 }
11557
11558 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11559 if !self
11560 .workspaces
11561 .read()
11562 .unwrap_or_else(|poisoned| poisoned.into_inner())
11563 .contains_key(&address.workspace_id)
11564 {
11565 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11566 }
11567 let binding = self
11568 .session_bindings
11569 .lock()
11570 .unwrap_or_else(|poisoned| poisoned.into_inner())
11571 .get(&address.session.instance_id)
11572 .cloned()
11573 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11574 if binding.workspace_id != address.workspace_id {
11575 return Err(failure(
11576 NodeFailureCode::SessionWorkspaceMismatch,
11577 "session belongs to another workspace",
11578 ));
11579 }
11580 if binding.generation != address.session.generation {
11581 return Err(failure(
11582 NodeFailureCode::StaleGeneration,
11583 &format!(
11584 "stale session generation: expected {}, received {}",
11585 binding.generation.0,
11586 address.session.generation.0,
11587 ),
11588 ));
11589 }
11590 let snapshot = self.handle.snapshot();
11591 let Some(current) = snapshot
11592 .sessions
11593 .iter()
11594 .find(|item| item.instance_id == address.session.instance_id)
11595 else {
11596 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11597 };
11598 if current.generation != address.session.generation {
11599 return Err(failure(
11600 NodeFailureCode::StaleGeneration,
11601 &format!(
11602 "stale session generation: expected {}, received {}",
11603 current.generation.0,
11604 address.session.generation.0,
11605 ),
11606 ));
11607 }
11608 Ok(current.agent_id.clone())
11609 }
11610
11611 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11612 self.dispatch_envelope(self.prepare_command(command))
11613 }
11614
11615 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11616 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11617 CommandEnvelope {
11618 id: CommandId(id),
11619 command,
11620 }
11621 }
11622
11623 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11624 let command_id = envelope.id;
11625 self.handle
11626 .dispatch(envelope)
11627 .map_err(|error| match error {
11628 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11629 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11630 })?;
11631 Ok(command_id)
11632 }
11633
11634 fn record_rejected_command(
11643 &self,
11644 command_id: CommandId,
11645 message: String,
11646 unsupported_transport: bool,
11647 ) {
11648 let now = Instant::now();
11649 let mut reasons = self.rejected_command_reasons.lock()
11650 .unwrap_or_else(|poisoned| poisoned.into_inner());
11651 reasons.retain(|_, reason| reason.expires_at > now);
11652 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11653 if let Some(&oldest) = reasons.keys().next() {
11654 reasons.remove(&oldest);
11655 }
11656 }
11657 reasons.insert(
11658 command_id,
11659 RejectedCommandReason {
11660 message,
11661 unsupported_transport,
11662 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11663 },
11664 );
11665 }
11666
11667 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11674 let now = Instant::now();
11675 let mut reasons = self.rejected_command_reasons.lock()
11676 .unwrap_or_else(|poisoned| poisoned.into_inner());
11677 match reasons.remove(&command_id) {
11678 Some(reason) if reason.expires_at > now => Some(reason),
11679 _ => None,
11680 }
11681 }
11682
11683 async fn dispatch_bounded(
11684 &self,
11685 command: ControlCommand,
11686 timeout_duration: Duration,
11687 ) -> Result<CommandId, NodeFailure> {
11688 let deadline = Instant::now() + timeout_duration;
11689 loop {
11690 match self.dispatch(command.clone()) {
11691 Ok(command_id) => return Ok(command_id),
11692 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11693 sleep(Duration::from_millis(2)).await;
11694 }
11695 Err(error) => return Err(error),
11696 }
11697 }
11698 }
11699
11700 fn internal_session_snapshot(
11701 &self,
11702 address: &SessionAddress,
11703 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11704 self.validate_address(address)?;
11705 self.handle
11706 .snapshot()
11707 .sessions
11708 .clone()
11709 .into_iter()
11710 .find(|session| {
11711 session.instance_id == address.session.instance_id
11712 && session.generation == address.session.generation
11713 })
11714 .ok_or_else(|| {
11715 failure(
11716 NodeFailureCode::UnknownSession,
11717 "session snapshot is unavailable",
11718 )
11719 })
11720 }
11721
11722 fn require_acp_session(
11730 &self,
11731 address: &SessionAddress,
11732 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11733 let snapshot = self.internal_session_snapshot(address)?;
11734 if snapshot.transport != TransportKind::Acp {
11735 return Err(failure(
11736 NodeFailureCode::UnsupportedTransport,
11737 "this verb requires a session spawned over the ACP transport",
11738 ));
11739 }
11740 Ok(snapshot)
11741 }
11742
11743 async fn dispatch_history_bounded(
11744 &self,
11745 address: &SessionAddress,
11746 command: ControlCommand,
11747 operation: HistoryOperation,
11748 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11749 self.validate_address(address)?;
11750 let started_after = self.current_sequence();
11751 let command_id = self
11752 .dispatch_bounded(
11753 command,
11754 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11755 )
11756 .await?;
11757 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11758 let mut scan_after = started_after;
11759 let mut accepted_after = None;
11760 loop {
11761 let events = self
11762 .history
11763 .lock()
11764 .unwrap_or_else(|poisoned| poisoned.into_inner())
11765 .events
11766 .iter()
11767 .filter(|envelope| envelope.sequence > scan_after)
11768 .cloned()
11769 .collect::<Vec<_>>();
11770 for envelope in events {
11771 scan_after = scan_after.max(envelope.sequence);
11772 let NodeEvent::Control {
11773 address: event_address,
11774 event,
11775 } = envelope.event
11776 else {
11777 continue;
11778 };
11779 if event_address != *address {
11780 continue;
11781 }
11782 if event.command_id == Some(command_id) {
11783 match &event.event {
11784 ControlEventKind::CommandRejected { .. } => {
11785 return Err(failure(
11786 NodeFailureCode::InvalidRequest,
11787 "history request was rejected",
11788 ));
11789 }
11790 ControlEventKind::HistoryRequested {
11791 operation: requested,
11792 ..
11793 } if requested == &operation => {
11794 accepted_after = Some(envelope.sequence);
11795 continue;
11796 }
11797 _ => {}
11798 }
11799 }
11800 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11801 match (&operation, &event.event) {
11802 (
11803 HistoryOperation::Discover { .. },
11804 ControlEventKind::HistoryDiscovered { .. },
11805 )
11806 | (
11807 HistoryOperation::Load { .. },
11808 ControlEventKind::HistoryLoaded { .. },
11809 ) => return self.internal_session_snapshot(address),
11810 (_, ControlEventKind::HistoryFailed { .. }) => {
11811 return Err(failure(
11812 NodeFailureCode::BackendOperationFailed,
11813 "history operation failed",
11814 ));
11815 }
11816 _ => {}
11817 }
11818 }
11819 }
11820 if Instant::now() >= deadline {
11821 return Err(failure(
11822 NodeFailureCode::BackendBusy,
11823 if accepted_after.is_some() {
11824 "history operation did not settle before the bounded deadline"
11825 } else {
11826 "history operation was not accepted before the bounded deadline"
11827 },
11828 ));
11829 }
11830 sleep(Duration::from_millis(2)).await;
11831 }
11832 }
11833
11834 async fn discover_history(
11835 &self,
11836 address: &SessionAddress,
11837 limit: u16,
11838 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11839 let query = HistoryQuery {
11840 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11841 limit,
11842 };
11843 query
11844 .validate()
11845 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11846 let snapshot = self
11847 .dispatch_history_bounded(
11848 address,
11849 ControlCommand::DiscoverHistory {
11850 instance_id: address.session.instance_id,
11851 query: query.clone(),
11852 },
11853 HistoryOperation::Discover { query },
11854 )
11855 .await?;
11856 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11857 return Err(failure(
11858 NodeFailureCode::BackendOperationFailed,
11859 "history discovery settled into an invalid state",
11860 ));
11861 }
11862 Ok(snapshot.history.candidates)
11863 }
11864
11865 async fn load_history(
11866 &self,
11867 address: &SessionAddress,
11868 candidate_id: String,
11869 ) -> Result<HistorySessionRecord, NodeFailure> {
11870 validate_candidate_id(&candidate_id)
11871 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11872 let snapshot = self
11873 .dispatch_history_bounded(
11874 address,
11875 ControlCommand::LoadHistory {
11876 instance_id: address.session.instance_id,
11877 candidate_id: candidate_id.clone(),
11878 },
11879 HistoryOperation::Load {
11880 candidate_id: candidate_id.clone(),
11881 },
11882 )
11883 .await?;
11884 if snapshot.history.pending.is_some()
11885 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11886 {
11887 return Err(failure(
11888 NodeFailureCode::BackendOperationFailed,
11889 "history load settled into an invalid state",
11890 ));
11891 }
11892 snapshot.history.loaded.ok_or_else(|| {
11893 failure(
11894 NodeFailureCode::BackendOperationFailed,
11895 "history load produced no bounded session",
11896 )
11897 })
11898 }
11899
11900 async fn export_context_pack_for_session_record(
11907 &self,
11908 record_id: &SessionRecordId,
11909 session: &SessionAddress,
11910 allow_clean_detachment: bool,
11911 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11912 let outcome = self
11918 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11919 .await;
11920 if let Err(failure) = &outcome {
11921 tracing::warn!(
11922 record = %record_id,
11923 code = ?failure.code,
11924 reason = %failure.message,
11925 "session-record context-pack export refused",
11926 );
11927 }
11928 outcome
11929 }
11930
11931 async fn export_context_pack_for_session_record_inner(
11932 &self,
11933 record_id: &SessionRecordId,
11934 session: &SessionAddress,
11935 allow_clean_detachment: bool,
11936 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11937 let step = |name: &'static str| move |failure: NodeFailure| {
11942 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11943 failure
11944 };
11945 let record = self.record(record_id).map_err(step("record-lookup"))?;
11946 let identity = match record.provider_session.clone() {
11962 Some(identity) => {
11963 if identity.key != ProviderSessionKey::SessionId {
11964 return Err(step("provider-identity-key")(failure(
11965 NodeFailureCode::BackendOperationFailed,
11966 "managed session provider identity cannot bind history exactly",
11967 )));
11968 }
11969 Some(identity)
11970 }
11971 None if record.state == ManagedSessionState::IdentityPending => {
11972 return Err(step("provider-session-identity")(failure(
11973 NodeFailureCode::BackendOperationFailed,
11974 "managed session has not yet observed its provider session identity",
11975 )));
11976 }
11977 None => None,
11978 };
11979 self.revalidate_session_record_context_export(
11980 &record,
11981 identity.as_ref(),
11982 session,
11983 allow_clean_detachment,
11984 ).map_err(step("revalidate-before-discovery"))?;
11985 let candidates = self
11986 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11987 .await
11988 .map_err(step("discover-history"))?;
11989 let candidate_id = select_history_candidate(
11994 &candidates,
11995 identity.as_ref().map(|identity| identity.id.as_str()),
11996 ).map_err(step("unique-candidate"))?;
11997 let loaded = self.load_history(session, candidate_id.clone()).await
11998 .map_err(step("load-history"))?;
11999 if let Some(identity) = identity.as_ref() {
12000 if loaded.session_id != identity.id {
12001 return Err(failure(
12002 NodeFailureCode::SessionRecordConflict,
12003 "loaded history identity does not match the managed session record",
12004 ));
12005 }
12006 }
12007 self.revalidate_session_record_context_export(
12008 &record,
12009 identity.as_ref(),
12010 session,
12011 allow_clean_detachment,
12012 )?;
12013 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12014 .map_err(step("revalidate-loaded-history"))?;
12015 let pack = self.materialize_context_pack(session).await
12016 .map_err(step("materialize-context-pack"))?;
12017 self.commit_context_pack_for_session_record(
12018 &record,
12019 identity.as_ref(),
12020 session,
12021 &candidate_id,
12022 &loaded,
12023 pack,
12024 allow_clean_detachment,
12025 )
12026 }
12027
12028 async fn export_context_pack(
12029 &self,
12030 address: &SessionAddress,
12031 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12032 let pack = self.materialize_context_pack(address).await?;
12033 self.commit_context_pack(pack)
12034 }
12035
12036 async fn materialize_context_pack(
12037 &self,
12038 address: &SessionAddress,
12039 ) -> Result<NodeContextPack, NodeFailure> {
12040 let snapshot = self.internal_session_snapshot(address)?;
12041 if !context_pack_source_status_is_usable(&snapshot.status) {
12042 return Err(failure(
12043 NodeFailureCode::InvalidRequest,
12044 "context export requires a running or successfully exited source session",
12045 ));
12046 }
12047 if snapshot.history.pending.is_some()
12048 || snapshot.history.loaded_candidate_id.is_none()
12049 {
12050 return Err(failure(
12051 NodeFailureCode::InvalidRequest,
12052 "context export requires settled loaded history",
12053 ));
12054 }
12055 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12056 failure(
12057 NodeFailureCode::InvalidRequest,
12058 "context export requires settled loaded history",
12059 )
12060 })?;
12061 let source_provider = snapshot.agent_id.clone();
12062 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12063 let repository = self
12064 .context_pack_repository(&address.workspace_id)
12065 .await?;
12066 let current = self.internal_session_snapshot(address)?;
12067 if !context_pack_source_status_is_usable(¤t.status)
12068 || current.agent_id != source_provider
12069 || current.history.pending.is_some()
12070 || current.history.loaded_candidate_id != loaded_candidate_id
12071 || current.history.loaded.as_ref() != Some(&loaded)
12072 {
12073 return Err(failure(
12074 NodeFailureCode::ContextPackMaterializationFailed,
12075 "context source changed during bounded repository capture",
12076 ));
12077 }
12078 NodeContextPack::export_with_repository(
12079 ContextPackLineageReceipt {
12080 source_node_id: self.node_id.clone(),
12081 source_session: address.clone(),
12082 source_provider,
12083 },
12084 &loaded,
12085 Some(repository),
12086 )
12087 .map_err(|_| {
12088 failure(
12089 NodeFailureCode::ContextPackMaterializationFailed,
12090 "bounded context pack export failed",
12091 )
12092 })
12093 }
12094
12095 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12101 let mut store = self
12102 .context_pack_store
12103 .lock()
12104 .unwrap_or_else(|poisoned| poisoned.into_inner());
12105 let Some(store) = store.as_mut() else {
12106 return Ok(());
12107 };
12108 store.commit(pack).map_err(|_| {
12109 failure(
12110 NodeFailureCode::ContextPackMaterializationFailed,
12111 "durable context pack commit failed",
12112 )
12113 })
12114 }
12115
12116 fn commit_context_pack(
12117 &self,
12118 pack: NodeContextPack,
12119 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12120 self.commit_context_pack_durably(&pack)?;
12121 self.context_catalog
12122 .write()
12123 .unwrap_or_else(|poisoned| poisoned.into_inner())
12124 .insert(pack)
12125 .map_err(|_| {
12126 failure(
12127 NodeFailureCode::ContextPackMaterializationFailed,
12128 "context pack catalog is full",
12129 )
12130 })
12131 }
12132
12133 fn commit_context_pack_for_session_record(
12134 &self,
12135 expected: &ManagedSessionRecord,
12136 identity: Option<&ProviderSessionIdentity>,
12137 session: &SessionAddress,
12138 candidate_id: &str,
12139 loaded: &HistorySessionRecord,
12140 pack: NodeContextPack,
12141 allow_clean_detachment: bool,
12142 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12143 let current_root = self.workspace_root(&expected.workspace_id)?;
12144 let _transaction = self
12145 .state_transaction
12146 .lock()
12147 .unwrap_or_else(|poisoned| poisoned.into_inner());
12148 let records = self
12149 .session_records
12150 .lock()
12151 .unwrap_or_else(|poisoned| poisoned.into_inner());
12152 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12153 failure(
12154 NodeFailureCode::SessionRecordConflict,
12155 "managed session record disappeared during context export",
12156 )
12157 })?;
12158 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12159 return Err(failure(
12160 NodeFailureCode::SessionRecordConflict,
12161 "managed session record changed during context export",
12162 ));
12163 }
12164 let bindings = self
12165 .session_bindings
12166 .lock()
12167 .unwrap_or_else(|poisoned| poisoned.into_inner());
12168 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12169 failure(
12170 NodeFailureCode::SessionRecordConflict,
12171 "managed session runtime binding disappeared during context export",
12172 )
12173 })?;
12174 let runtime = self
12175 .handle
12176 .snapshot()
12177 .sessions
12178 .iter()
12179 .find(|snapshot| {
12180 snapshot.instance_id == session.session.instance_id
12181 && snapshot.generation == session.session.generation
12182 })
12183 .cloned()
12184 .ok_or_else(|| {
12185 failure(
12186 NodeFailureCode::SessionRecordConflict,
12187 "managed session runtime disappeared during context export",
12188 )
12189 })?;
12190 let receipt = pack.receipt();
12191 if !session_record_context_export_binding_is_exact(
12192 expected,
12193 current,
12194 identity,
12195 session,
12196 binding,
12197 &runtime.agent_id,
12198 ¤t_root,
12199 allow_clean_detachment,
12200 ) || !session_record_context_export_source_is_usable(
12201 current,
12202 &runtime.status,
12203 allow_clean_detachment,
12204 ) || runtime.history.pending.is_some()
12205 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12206 || runtime.history.loaded.as_ref() != Some(loaded)
12207 || receipt.lineage.source_node_id != self.node_id
12208 || receipt.lineage.source_session != *session
12209 || receipt.lineage.source_provider != current.provider
12210 || receipt.source_message_count != loaded.message_count
12211 {
12212 return Err(failure(
12213 NodeFailureCode::SessionRecordConflict,
12214 "managed session source changed before context catalog commit",
12215 ));
12216 }
12217 self.commit_context_pack_durably(&pack)?;
12218 self.context_catalog
12219 .write()
12220 .unwrap_or_else(|poisoned| poisoned.into_inner())
12221 .insert(pack)
12222 .map_err(|_| {
12223 failure(
12224 NodeFailureCode::ContextPackMaterializationFailed,
12225 "context pack catalog is full",
12226 )
12227 })
12228 }
12229
12230 async fn context_pack_repository(
12231 &self,
12232 workspace_id: &WorkspaceId,
12233 ) -> Result<ContextPackRepository, NodeFailure> {
12234 let first = self
12235 .context_pack_repository_observation(workspace_id)
12236 .await?;
12237 let second = self
12238 .context_pack_repository_observation(workspace_id)
12239 .await?;
12240 stable_context_pack_repository(first, second)
12241 }
12242
12243 async fn context_pack_repository_observation(
12244 &self,
12245 workspace_id: &WorkspaceId,
12246 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12247 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12248 let head = if git.is_repository {
12249 let root = self.workspace_root(workspace_id)?;
12250 match resolve_base_commit_with_timeout(
12251 &root,
12252 "HEAD",
12253 GIT_COMMAND_TIMEOUT_MS,
12254 )
12255 .await
12256 {
12257 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12258 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12259 ContextPackRepositoryHead::Unborn
12260 }
12261 Err(_) => {
12262 return Err(failure(
12263 NodeFailureCode::ContextPackMaterializationFailed,
12264 "context pack HEAD observation failed",
12265 ));
12266 }
12267 }
12268 } else {
12269 ContextPackRepositoryHead::NotRepository
12270 };
12271 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12272 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12273 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12274 failure(
12275 NodeFailureCode::InvalidRepositoryPath,
12276 "context pack selected file path is invalid",
12277 )
12278 })?;
12279 match self.read_workspace_file(workspace_id.clone(), path).await {
12280 Ok(WorkspaceFileRead {
12281 content: WorkspaceFileContent::Utf8 { text, byte_len },
12282 ..
12283 }) => files.push(ContextPackRepositoryFileSource::utf8(
12284 selected_path,
12285 text,
12286 byte_len,
12287 )),
12288 Ok(WorkspaceFileRead {
12289 content: WorkspaceFileContent::NonUtf8 { byte_len },
12290 ..
12291 }) => files.push(ContextPackRepositoryFileSource::skipped(
12292 selected_path,
12293 ContextPackSelectedFileSkipReason::NonUtf8,
12294 Some(byte_len),
12295 )),
12296 Ok(WorkspaceFileRead {
12297 content: WorkspaceFileContent::TooLarge { .. },
12298 ..
12299 }) => files.push(ContextPackRepositoryFileSource::skipped(
12300 selected_path,
12301 ContextPackSelectedFileSkipReason::TooLarge,
12302 None,
12303 )),
12304 Err(error) => {
12305 let reason = if matches!(
12306 error.code,
12307 NodeFailureCode::InvalidRepositoryPath
12308 | NodeFailureCode::RepositoryFileNotRegular
12309 | NodeFailureCode::RepositoryPathUnsafe
12310 ) {
12311 ContextPackSelectedFileSkipReason::Unsafe
12312 } else {
12313 ContextPackSelectedFileSkipReason::Unavailable
12314 };
12315 files.push(ContextPackRepositoryFileSource::skipped(
12316 selected_path,
12317 reason,
12318 None,
12319 ));
12320 }
12321 }
12322 }
12323 Ok(ContextPackRepositoryObservation { head, git, files })
12324 }
12325
12326 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12327 let referenced_by_binding = self
12328 .session_bindings
12329 .lock()
12330 .unwrap_or_else(|poisoned| poisoned.into_inner())
12331 .values()
12332 .any(|binding| {
12333 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12334 });
12335 let referenced_by_record = self
12336 .session_records
12337 .lock()
12338 .unwrap_or_else(|poisoned| poisoned.into_inner())
12339 .records
12340 .values()
12341 .any(|record| record.context_id.as_ref() == Some(context_id));
12342 let referenced_by_materialization = self
12343 .materializations
12344 .lock()
12345 .unwrap_or_else(|poisoned| poisoned.into_inner())
12346 .values()
12347 .any(|ownership| {
12348 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12349 });
12350 if referenced_by_binding
12351 || referenced_by_record
12352 || referenced_by_materialization
12353 {
12354 return Err(failure(
12355 NodeFailureCode::ContextPackBusy,
12356 "context pack is still referenced",
12357 ));
12358 }
12359 let mut catalog = self.context_catalog
12360 .write()
12361 .unwrap_or_else(|poisoned| poisoned.into_inner());
12362 if catalog.get(context_id).is_none() {
12363 return Err(failure(
12364 NodeFailureCode::UnknownContextPack,
12365 "context pack is unavailable",
12366 ));
12367 }
12368 self.spawn_idempotency
12369 .lock()
12370 .unwrap_or_else(|poisoned| poisoned.into_inner())
12371 .entries
12372 .retain(|_, entry| !entry.value.references_context(context_id));
12373 let removed = catalog.remove(context_id);
12374 debug_assert!(removed.is_some(), "validated context pack remains present");
12375 Ok(())
12376 }
12377
12378 async fn dispatch_input_bounded(
12379 &self,
12380 address: &SessionAddress,
12381 action: InputAction,
12382 ) -> Result<(), NodeFailure> {
12383 let started_after = self.current_sequence();
12384 self.pty_input_marks
12385 .lock()
12386 .unwrap_or_else(|poisoned| poisoned.into_inner())
12387 .insert(address.session.instance_id, (Instant::now(), false));
12388 self.pty_input_wake.notify_one();
12389 let command_id = self
12390 .dispatch_bounded(
12391 ControlCommand::SendInput {
12392 instance_id: address.session.instance_id,
12393 action,
12394 },
12395 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12396 )
12397 .await?;
12398 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12402 let mut scan_after = started_after;
12403 let mut accepted_after = None;
12404 loop {
12405 let events = self
12406 .history
12407 .lock()
12408 .unwrap_or_else(|poisoned| poisoned.into_inner())
12409 .events
12410 .iter()
12411 .filter(|envelope| envelope.sequence > scan_after)
12412 .cloned()
12413 .collect::<Vec<_>>();
12414 for envelope in events {
12415 scan_after = scan_after.max(envelope.sequence);
12416 let NodeEvent::Control {
12417 address: event_address,
12418 event,
12419 } = envelope.event
12420 else {
12421 continue;
12422 };
12423 if event_address != *address {
12424 continue;
12425 }
12426 if event.command_id == Some(command_id) {
12427 match &event.event {
12428 ControlEventKind::CommandRejected { message } => {
12429 return Err(failure(NodeFailureCode::InvalidRequest, message));
12430 }
12431 ControlEventKind::InputRequested { .. } => {
12432 accepted_after = Some(envelope.sequence);
12433 continue;
12434 }
12435 _ => {}
12436 }
12437 }
12438 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12439 match &event.event {
12440 ControlEventKind::InputCompleted { .. } => return Ok(()),
12441 ControlEventKind::InputFailed { message, .. } => {
12442 return Err(failure(
12443 NodeFailureCode::BackendOperationFailed,
12444 message,
12445 ));
12446 }
12447 _ => {}
12448 }
12449 }
12450 }
12451 if Instant::now() >= deadline {
12452 return Err(failure(
12453 NodeFailureCode::BackendBusy,
12454 if accepted_after.is_some() {
12455 "input was accepted but did not settle before the bounded deadline"
12456 } else {
12457 "input was not accepted before the bounded deadline"
12458 },
12459 ));
12460 }
12461 sleep(Duration::from_millis(2)).await;
12462 }
12463 }
12464
12465 async fn dispatch_resize_bounded(
12466 &self,
12467 address: &SessionAddress,
12468 size: gate4agent_types::TerminalSize,
12469 ) -> Result<(), NodeFailure> {
12470 let started_after = self.current_sequence();
12471 let command_id = self
12472 .dispatch_bounded(
12473 ControlCommand::Resize {
12474 instance_id: address.session.instance_id,
12475 size,
12476 },
12477 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12478 )
12479 .await?;
12480 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12481 let mut scan_after = started_after;
12482 let mut accepted_after = None;
12483 loop {
12484 let events = self
12485 .history
12486 .lock()
12487 .unwrap_or_else(|poisoned| poisoned.into_inner())
12488 .events
12489 .iter()
12490 .filter(|envelope| envelope.sequence > scan_after)
12491 .cloned()
12492 .collect::<Vec<_>>();
12493 for envelope in events {
12494 scan_after = scan_after.max(envelope.sequence);
12495 let NodeEvent::Control {
12496 address: event_address,
12497 event,
12498 } = envelope.event
12499 else {
12500 continue;
12501 };
12502 if event_address != *address {
12503 continue;
12504 }
12505 if event.command_id == Some(command_id) {
12506 match &event.event {
12507 ControlEventKind::CommandRejected { message } => {
12508 return Err(failure(NodeFailureCode::InvalidRequest, message));
12509 }
12510 ControlEventKind::ResizeRequested {
12511 size: requested_size,
12512 ..
12513 } if *requested_size == size => {
12514 accepted_after = Some(envelope.sequence);
12515 continue;
12516 }
12517 _ => {}
12518 }
12519 }
12520 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12521 match &event.event {
12522 ControlEventKind::Resized { size: settled_size }
12523 if *settled_size == size =>
12524 {
12525 return Ok(());
12526 }
12527 ControlEventKind::ResizeFailed { message } => {
12528 return Err(failure(
12529 NodeFailureCode::BackendOperationFailed,
12530 message,
12531 ));
12532 }
12533 _ => {}
12534 }
12535 }
12536 }
12537 if Instant::now() >= deadline {
12538 return Err(failure(
12539 NodeFailureCode::BackendBusy,
12540 if accepted_after.is_some() {
12541 "resize was accepted but did not settle before the bounded deadline"
12542 } else {
12543 "resize was not accepted before the bounded deadline"
12544 },
12545 ));
12546 }
12547 sleep(Duration::from_millis(2)).await;
12548 }
12549 }
12550
12551 async fn spawn_session(
12552 &self,
12553 workspace_id: WorkspaceId,
12554 provider: AgentId,
12555 mode: SessionMode,
12556 terminal_size: gate4agent_types::TerminalSize,
12557 initial_prompt: Option<String>,
12558 ) -> Result<SessionAddress, NodeFailure> {
12559 if self.managed_worktrees.lock()
12560 .unwrap_or_else(|poisoned| poisoned.into_inner())
12561 .lease_for_workspace(&workspace_id)
12562 .is_some()
12563 {
12564 return Err(failure(
12565 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12566 "managed worktree targets may only be spawned through their lease request",
12567 ));
12568 }
12569 self
12570 .spawn_session_with_deadline(
12571 workspace_id,
12572 provider,
12573 mode,
12574 terminal_size,
12575 initial_prompt,
12576 None,
12577 None,
12578 None,
12579 None,
12580 None,
12581 SpawnRecordPolicy::ProviderIdentityOnly,
12582 None,
12583 &[],
12584 None,
12585 ApprovalLevel::default(),
12591 None,
12592 None,
12593 )
12594 .await
12595 .map(|(session, _runtime_policy)| session)
12596 }
12597
12598 async fn spawn_session_with_deadline(
12599 &self,
12600 workspace_id: WorkspaceId,
12601 provider: AgentId,
12602 mode: SessionMode,
12603 terminal_size: gate4agent_types::TerminalSize,
12604 initial_prompt: Option<String>,
12605 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12606 bundle: Option<ResolvedBundleReceipt>,
12607 context: Option<ResolvedContextPackReceipt>,
12608 managed_authority: Option<ManagedWorktreeLeaseId>,
12609 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12610 record_policy: SpawnRecordPolicy,
12611 deadline: Option<Instant>,
12612 required_capabilities: &[ProviderRuntimeCapability],
12613 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12614 approval_level: ApprovalLevel,
12615 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12616 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12622 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12623 let reserved_lease = self.managed_worktrees.lock()
12624 .unwrap_or_else(|poisoned| poisoned.into_inner())
12625 .lease_for_workspace(&workspace_id);
12626 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12627 (Some(expected), Some(provided)) if expected == provided => {}
12628 (None, None) => {}
12629 _ => {
12630 return Err(failure(
12631 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12632 "spawn lacks exact managed worktree lease authority",
12633 ));
12634 }
12635 }
12636 self.ensure_workspace_environment_profile_compatible(
12640 &workspace_id,
12641 environment_profile.as_ref(),
12642 )?;
12643 let runtime_requirement = match (mode, initial_prompt.is_some()) {
12644 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12645 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12646 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12647 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12648 };
12649 let transport = match mode {
12655 SessionMode::Pty => TransportKind::Pty,
12656 SessionMode::Inline => TransportKind::Pipe,
12657 SessionMode::Acp => TransportKind::Acp,
12658 };
12659 let pipe_transport = self.provider_supports_pipe_transport(&provider);
12660 let acp_transport = self.provider_supports_acp_transport(&provider);
12661 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12662 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12663 NodeFailureCode::UnsupportedSpawnCapability,
12664 "pre-admitted provider runtime no longer satisfies spawn requirements",
12665 ))?;
12666 runtime_policy
12667 } else if let Some(deadline) = deadline {
12668 let remaining = spawn_deadline_remaining(deadline)?;
12669 timeout(
12670 remaining,
12671 self.admit_provider_runtime(&provider, runtime_requirement),
12672 )
12673 .await
12674 .map_err(|_| {
12675 failure(
12676 NodeFailureCode::SpawnDeadlineExceeded,
12677 "provider admission exceeded the spawn deadline",
12678 )
12679 })??
12680 } else {
12681 self.admit_provider_runtime(&provider, runtime_requirement).await?
12682 };
12683 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12684 let runtime_policy =
12696 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12697 if required_capabilities
12698 .iter()
12699 .any(|capability| !runtime_policy.admits(*capability))
12700 {
12701 return Err(failure(
12702 NodeFailureCode::UnsupportedSpawnCapability,
12703 "provider runtime does not admit a required spawn capability",
12704 ));
12705 }
12706 if let Some(deadline) = deadline {
12707 spawn_deadline_remaining(deadline)?;
12708 }
12709 self.ensure_binding_capacity()?;
12710 let working_directory = self.workspace_root(&workspace_id)?;
12711 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12712 let session = SessionKey {
12713 instance_id,
12714 generation: SessionGeneration(1),
12715 };
12716 let address = SessionAddress {
12717 workspace_id: workspace_id.clone(),
12718 session,
12719 };
12720 tracing::info!(
12728 node_id = %self.node_id,
12729 workspace_id = %workspace_id,
12730 provider = %provider,
12731 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12732 session = ?address.session,
12733 "session spawn request accepted",
12734 );
12735 let mut prepared_spawn = SpawnPreparer::prepare(
12736 self,
12737 SpawnPrepareRequest {
12738 instance_id,
12739 address: &address,
12740 provider: &provider,
12741 mode,
12742 environment_profile: environment_profile.as_ref(),
12743 bundle: bundle.as_ref(),
12744 context: context.as_ref(),
12745 managed_authority: managed_authority.as_ref(),
12746 harness_mcp,
12747 approval_level,
12748 network_allowlist: network_allowlist.as_ref(),
12749 browser_profile_id,
12750 },
12751 )?;
12752 let materialization_id = prepared_spawn.materialization_id.take();
12753 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12754 let record_id = self.bind_spawn_session_with_materialization(
12755 &address,
12756 provider.clone(),
12757 mode,
12758 runtime_policy,
12759 record_policy,
12760 environment_profile,
12761 bundle,
12762 context,
12763 materialization_id,
12764 )?;
12765 prepared_spawn.retain_session_guards();
12766 let agent_id = provider;
12770 let register_command_id = match self.dispatch_bounded(
12771 ControlCommand::Register {
12772 instance_id,
12773 agent_id,
12774 transport,
12775 },
12776 dispatch_timeout,
12777 )
12778 .await {
12779 Ok(command_id) => command_id,
12780 Err(error) => {
12781 if let Some(binding) = self.remove_binding(&address) {
12782 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12783 }
12784 if let Some(record_id) = record_id.as_ref() {
12785 self.discard_record(record_id)?;
12786 }
12787 return Err(spawn_dispatch_error(error, deadline));
12788 }
12789 };
12790 let start_timeout = match spawn_dispatch_timeout(deadline) {
12791 Ok(timeout) => timeout,
12792 Err(error) => {
12793 let recovery = self
12794 .rollback_spawn(
12795 &address,
12796 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12797 )
12798 .await;
12799 return match recovery {
12800 Ok(()) => {
12801 if let Some(record_id) = record_id.as_ref() {
12802 self.discard_record(record_id)?;
12803 }
12804 Err(error)
12805 }
12806 Err(recovery_error) => {
12807 if let Some(record_id) = record_id.as_ref() {
12808 self.mark_record_error(
12809 record_id,
12810 "spawn deadline elapsed and registration recovery failed",
12811 )?;
12812 }
12813 Err(recovery_error)
12814 }
12815 };
12816 }
12817 };
12818 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12819 if let Err(recovery_error) = self
12820 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12821 .await
12822 {
12823 return Err(recovery_error);
12824 }
12825 if let Some(record_id) = record_id.as_ref() {
12826 self.discard_record(record_id)?;
12827 }
12828 return Err(start_error);
12829 }
12830 let start_result = self
12831 .dispatch_bounded(
12832 ControlCommand::Start {
12833 instance_id,
12834 runtime_policy,
12835 request: StartRequest {
12836 working_directory,
12837 terminal_size,
12838 initial_prompt,
12839 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
12840 approval_level,
12841 },
12842 },
12843 start_timeout,
12844 )
12845 .await;
12846 let start_command_id = match start_result {
12847 Ok(command_id) => command_id,
12848 Err(start_error) => {
12849 let start_error = spawn_dispatch_error(start_error, deadline);
12850 let recovery = self
12851 .rollback_spawn(
12852 &address,
12853 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12854 )
12855 .await;
12856 return match recovery {
12857 Ok(()) => {
12858 if let Some(record_id) = record_id.as_ref() {
12859 self.discard_record(record_id)?;
12860 }
12861 Err(start_error)
12862 }
12863 Err(recovery_error) => {
12864 let diagnostic = format!(
12865 "start failed and registration recovery failed: {}",
12866 recovery_error.message,
12867 );
12868 if let Some(record_id) = record_id.as_ref() {
12869 self.mark_record_error(record_id, &diagnostic)?;
12870 }
12871 Err(failure(recovery_error.code, &diagnostic))
12872 }
12873 };
12874 }
12875 };
12876
12877 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12878 loop {
12879 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12888 .or_else(|| self.take_rejected_command_reason(start_command_id))
12889 {
12890 self.rollback_spawn(
12891 &address,
12892 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12893 ).await.map_err(|error| {
12894 failure(
12895 error.code,
12896 &format!(
12897 "spawn was rejected ({}) and registration recovery failed: {}",
12898 reason.message, error.message
12899 ),
12900 )
12901 })?;
12902 if let Some(record_id) = record_id.as_ref() {
12903 self.discard_record(record_id)?;
12904 }
12905 let code = if reason.unsupported_transport {
12906 NodeFailureCode::UnsupportedTransport
12907 } else {
12908 NodeFailureCode::BackendOperationFailed
12909 };
12910 return Err(failure(code, &reason.message));
12911 }
12912 if let Some(current) = self
12913 .handle
12914 .snapshot()
12915 .sessions
12916 .iter()
12917 .find(|current| current.instance_id == instance_id)
12918 {
12919 if current.generation != session.generation {
12920 let divergence = failure(
12921 NodeFailureCode::StaleGeneration,
12922 "spawn generation diverged",
12923 );
12924 return match self
12925 .rollback_spawn(
12926 &address,
12927 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12928 )
12929 .await
12930 {
12931 Ok(()) => {
12932 if let Some(record_id) = record_id.as_ref() {
12933 self.discard_record(record_id)?;
12934 }
12935 Err(divergence)
12936 }
12937 Err(recovery_error) => {
12938 if let Some(record_id) = record_id.as_ref() {
12939 self.mark_record_error(
12940 record_id,
12941 "spawn generation diverged and recovery failed",
12942 )?;
12943 }
12944 Err(recovery_error)
12945 }
12946 };
12947 }
12948 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12949 prepared_spawn.retain_launch_guards();
12950 return Ok((address, runtime_policy));
12951 }
12952 }
12953 if Instant::now() >= commit_deadline {
12954 self.rollback_spawn(
12955 &address,
12956 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12957 ).await.map_err(|error| {
12958 failure(
12959 error.code,
12960 &format!(
12961 "start did not commit and registration recovery failed: {}",
12962 error.message
12963 ),
12964 )
12965 })?;
12966 if let Some(record_id) = record_id.as_ref() {
12967 self.discard_record(record_id)?;
12968 }
12969 return Err(failure(
12970 if deadline.is_some() {
12971 NodeFailureCode::SpawnDeadlineExceeded
12972 } else {
12973 NodeFailureCode::BackendBusy
12974 },
12975 "start did not commit before the bounded deadline; registration was removed",
12976 ));
12977 }
12978 sleep(Duration::from_millis(2)).await;
12979 }
12980 }
12981
12982 fn effective_spawn_runtime_policy(
12983 &self,
12984 runtime_policy: ProviderRuntimePolicy,
12985 ) -> ProviderRuntimePolicy {
12986 #[cfg(feature = "fixture")]
12987 let runtime_policy = if self.fixture_semantic_hook_policy {
12988 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12989 .expect("monitoring Hook fixture policy is internally valid")
12990 } else {
12991 runtime_policy
12992 };
12993 runtime_policy
12994 }
12995
12996 fn verify_harness_mcp_before_start(
12997 &self,
12998 prepared: Option<&PreparedHarnessMcpSpawn>,
12999 ) -> Result<(), NodeFailure> {
13000 prepared
13001 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13002 .transpose()
13003 .map(|_| ())
13004 .map_err(harness_mcp_failure)
13005 }
13006
13007 async fn rollback_spawn(
13008 &self,
13009 address: &SessionAddress,
13010 commit_timeout: Duration,
13011 ) -> Result<(), NodeFailure> {
13012 self.dispatch_bounded(
13013 ControlCommand::Remove {
13014 instance_id: address.session.instance_id,
13015 },
13016 commit_timeout,
13017 )
13018 .await?;
13019 self.wait_until_removed(address, commit_timeout).await?;
13020 if let Some(binding) = self.remove_binding(address) {
13021 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13022 }
13023 Ok(())
13024 }
13025
13026 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13027 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13028 self.dispatch_bounded(
13029 ControlCommand::Remove {
13030 instance_id: address.session.instance_id,
13031 },
13032 commit_timeout,
13033 )
13034 .await?;
13035 self.wait_until_removed(address, commit_timeout).await?;
13036 let removed = self.remove_binding(address);
13037 if let Some(binding) = removed.as_ref() {
13038 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13039 }
13040 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13041 let snapshot = self.managed_worktrees.lock()
13042 .unwrap_or_else(|poisoned| poisoned.into_inner())
13043 .release_session(
13044 &lease_id,
13045 self.incarnation_id,
13046 address.session.instance_id,
13047 address.session.generation,
13048 unix_time_ms(),
13049 );
13050 if self.persist_state().is_err() {
13051 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13052 }
13053 if let Some(snapshot) = snapshot {
13054 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13055 }
13056 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13057 }
13058 Ok(())
13059 }
13060
13061 async fn wait_until_removed(
13107 &self,
13108 address: &SessionAddress,
13109 commit_timeout: Duration,
13110 ) -> Result<(), NodeFailure> {
13111 let deadline = Instant::now() + commit_timeout;
13112 let mut next_dispatch = Instant::now();
13113 loop {
13114 let snapshot = self.handle.snapshot();
13115 let status = snapshot
13116 .sessions
13117 .iter()
13118 .find(|session| session.instance_id == address.session.instance_id)
13119 .map(|session| session.status.clone());
13120 let Some(status) = status else {
13121 return Ok(());
13122 };
13123 let now = Instant::now();
13124 if now >= deadline {
13125 return Err(failure(
13126 NodeFailureCode::BackendBusy,
13127 "remove did not commit before the bounded deadline; session binding was retained",
13128 ));
13129 }
13130 if status.allows_remove() && now >= next_dispatch {
13131 let _ = self.dispatch(ControlCommand::Remove {
13132 instance_id: address.session.instance_id,
13133 });
13134 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13135 }
13136 sleep(Duration::from_millis(2)).await;
13137 }
13138 }
13139
13140 fn resync(&self, after_sequence: u64) -> NodeResponse {
13141 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13142 let event_sequence = history.last_sequence;
13143 let oldest_available_sequence = history.replay_floor_sequence;
13144 let events = history
13145 .events
13146 .iter()
13147 .filter(|event| event.sequence > after_sequence)
13148 .cloned()
13149 .collect();
13150 drop(history);
13151 NodeResponse::Resync {
13152 event_sequence,
13153 oldest_available_sequence,
13154 snapshot: self.snapshot(),
13155 events,
13156 }
13157 }
13158}
13159
13160fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13165 match kind {
13166 AgentStreamChunkKindV1::Text { .. } => "text",
13167 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13168 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13169 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13170 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13171 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13172 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13173 }
13174}
13175
13176async fn accept_connections(
13190 endpoint: &str,
13191 call_home: Option<std::net::SocketAddr>,
13192 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13193 shared: Arc<NodeShared>,
13194) -> Result<(), NodeServerError> {
13195 let mut connections = JoinSet::new();
13196 let result = match call_home {
13197 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13198 Some(relay) => {
13199 let listener =
13200 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13201 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13202 tokio::pin!(listener);
13203 tokio::pin!(dialler);
13204 tokio::select! {
13205 result = &mut listener => result,
13206 result = &mut dialler => result,
13207 }
13208 }
13209 };
13210 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13211 while !connections.is_empty() {
13212 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13213 if remaining.is_zero()
13214 || timeout(remaining, connections.join_next()).await.is_err()
13215 {
13216 break;
13217 }
13218 }
13219 connections.abort_all();
13220 while connections.join_next().await.is_some() {}
13221 result
13222}
13223
13224const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13228
13229async fn call_home_loop(
13243 relay: std::net::SocketAddr,
13244 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13245 shared: Arc<NodeShared>,
13246) -> Result<(), NodeServerError> {
13247 let mut failures = 0_usize;
13248 loop {
13249 if shared.shutdown.load(Ordering::Acquire) {
13250 return Ok(());
13251 }
13252 let permit = tokio::select! {
13253 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13254 permit.map_err(|_| NodeServerError::PreauthClosed)?
13255 }
13256 _ = shared.shutdown_notify.notified() => return Ok(()),
13257 };
13258 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13259 Ok(()) => {
13260 failures = 0;
13264 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13265 }
13266 Err(error) => {
13267 failures = failures.saturating_add(1);
13268 tracing::warn!(
13269 node_id = %shared.node_id,
13270 %relay,
13271 cause = %error,
13272 attempt = failures,
13273 "call-home connection failed",
13274 );
13275 let backoff = call_home_backoff(failures);
13276 tokio::select! {
13277 _ = sleep(backoff) => {}
13278 _ = shared.shutdown_notify.notified() => return Ok(()),
13279 }
13280 }
13281 }
13282 }
13283}
13284
13285const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13287const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13289
13290fn call_home_keepalive() -> socket2::TcpKeepalive {
13291 let keepalive = socket2::TcpKeepalive::new()
13292 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13293 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13294 #[cfg(not(windows))]
13295 let keepalive = keepalive.with_retries(3);
13296 keepalive
13297}
13298
13299fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13300 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13301}
13302
13303pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13305 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13306}
13307
13308fn call_home_backoff(attempt: usize) -> Duration {
13310 Duration::from_millis(
13311 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13312 )
13313}
13314
13315async fn dial_relay_and_serve(
13317 relay: std::net::SocketAddr,
13318 identity: Option<&hatchery_enroll::NodeIdentity>,
13319 shared: Arc<NodeShared>,
13320 preauth_permit: OwnedSemaphorePermit,
13321) -> Result<(), NodeServerError> {
13322 let mut stream = tokio::net::TcpStream::connect(relay)
13323 .await
13324 .map_err(NodeServerError::CallHomeTransport)?;
13325 let _ = stream.set_nodelay(true);
13329 if let Err(error) = enable_call_home_keepalive(&stream) {
13335 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13336 }
13337 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13338 .await
13339 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13340 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13341 let mut session_token = None;
13342 if let Some(identity) = identity {
13343 let mut magic = [0_u8; 4];
13347 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13348 .await
13349 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13350 .map_err(NodeServerError::CallHomeTransport)?;
13351 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13352 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13353 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13354 .await
13355 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13356 .map_err(NodeServerError::CallHomeTransport)?;
13357 let (answer, token) = identity
13358 .answer_challenge(shared.node_id.as_str(), &frame)
13359 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13360 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13361 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13362 session_token = Some(token);
13363 }
13364 }
13365 serve_connection(stream, shared, session_token, preauth_permit).await
13366}
13367
13368async fn accept_connections_inner(
13369 endpoint: &str,
13370 shared: Arc<NodeShared>,
13371 connections: &mut JoinSet<Result<(), NodeServerError>>,
13372) -> Result<(), NodeServerError> {
13373 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13374 loop {
13375 while connections.try_join_next().is_some() {}
13376 if shared.shutdown.load(Ordering::Acquire) {
13377 return Ok(());
13378 }
13379 let preauth_permit = tokio::select! {
13380 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13381 permit.map_err(|_| NodeServerError::PreauthClosed)?
13382 }
13383 _ = shared.shutdown_notify.notified() => return Ok(()),
13384 };
13385 let server = tokio::select! {
13386 result = listener.accept() => result?,
13387 _ = shared.shutdown_notify.notified() => return Ok(()),
13388 };
13389 let connection_shared = Arc::clone(&shared);
13390 connections.spawn(async move {
13391 serve_connection(server, connection_shared, None, preauth_permit).await
13392 });
13393 if shared.shutdown.load(Ordering::Acquire) {
13394 return Ok(());
13395 }
13396 }
13397}
13398
13399async fn serve_connection<S>(
13407 mut pipe: S,
13408 shared: Arc<NodeShared>,
13409 session_token: Option<String>,
13410 preauth_permit: OwnedSemaphorePermit,
13411) -> Result<(), NodeServerError>
13412where
13413 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13414{
13415 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13417 let hello_frame = timeout(
13418 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13419 read_json_frame_limited_body_timeout(
13420 &mut pipe,
13421 MAX_NODE_HELLO_FRAME_BYTES,
13422 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13423 ),
13424 )
13425 .await
13426 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13427 let ClientFrame::Hello(hello) = hello_frame else {
13428 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13429 };
13430 if hello.build_stamp != BUILD_STAMP {
13431 return Err(NodeServerError::Handshake(format!(
13432 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13433 hello.build_stamp,
13434 )));
13435 }
13436 let compatibility = match hello.compatibility.as_ref() {
13437 Some(offer) => {
13438 let mut selected = node_compatibility_support(&shared)?
13439 .negotiate(offer)
13440 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13441 project_negotiated_provider_ids(&mut selected);
13442 Some(selected)
13443 }
13444 None => None,
13445 };
13446 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13447 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13448 (Some(offer), Some(selected)) => negotiated_auth_proof(
13449 auth_token,
13450 AuthDirection::Server,
13451 hello.role,
13452 &hello.client_nonce,
13453 &server_nonce,
13454 offer,
13455 selected,
13456 ),
13457 (None, None) => auth_proof(
13458 auth_token,
13459 AuthDirection::Server,
13460 hello.role,
13461 &hello.client_nonce,
13462 &server_nonce,
13463 ),
13464 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13465 }
13466 .map_err(NodeServerError::Authentication)?;
13467 write_json_frame_limited(
13468 &mut pipe,
13469 &ServerFrame::Challenge(ServerChallenge {
13470 build_stamp: BUILD_STAMP.to_owned(),
13471 server_nonce,
13472 server_proof,
13473 compatibility: compatibility.clone(),
13474 }),
13475 MAX_NODE_HELLO_FRAME_BYTES,
13476 )
13477 .await?;
13478 let authentication = timeout(
13479 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13480 read_json_frame_limited_body_timeout(
13481 &mut pipe,
13482 MAX_NODE_HELLO_FRAME_BYTES,
13483 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13484 ),
13485 )
13486 .await
13487 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13488 let ClientFrame::Authenticate(authentication) = authentication else {
13489 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13490 };
13491 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13492 (Some(offer), Some(selected)) => negotiated_auth_proof(
13493 auth_token,
13494 AuthDirection::Client,
13495 hello.role,
13496 &hello.client_nonce,
13497 &server_nonce,
13498 offer,
13499 selected,
13500 ),
13501 (None, None) => auth_proof(
13502 auth_token,
13503 AuthDirection::Client,
13504 hello.role,
13505 &hello.client_nonce,
13506 &server_nonce,
13507 ),
13508 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13509 }
13510 .map_err(NodeServerError::Authentication)?;
13511 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13512 return Err(NodeServerError::Handshake("access denied".to_owned()));
13513 }
13514 let selected_capabilities = compatibility
13515 .as_ref()
13516 .map(|selected| selected.capabilities.clone())
13517 .unwrap_or_default();
13518 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13519 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13520 });
13521 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13522 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13523 });
13524 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13525 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13526 });
13527 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13528 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13529 });
13530 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13531 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13532 });
13533 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13534 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13535 }) && selected_capabilities.iter().any(|capability| {
13536 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13537 });
13538 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13539 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13540 });
13541 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13542 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13543 });
13544 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13545 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13546 });
13547 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13548 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13549 });
13550 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13551 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13552 });
13553 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13554 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13555 });
13556 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13557 .try_acquire_owned()
13558 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13559 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13560 let _release_guard = ControllerReleaseGuard {
13561 shared: Arc::clone(&shared),
13562 connection_id,
13563 };
13564 drop(preauth_permit);
13565 let _authenticated_permit = authenticated_permit;
13566 let mut event_rx = shared.event_tx.subscribe();
13567 let mut terminal_event_rx = terminal_event_subscription(
13568 &shared,
13569 include_terminal_frame_events,
13570 );
13571 let mut agent_stream_event_rx = agent_stream_event_subscription(
13572 &shared,
13573 include_agent_stream_events,
13574 );
13575 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13576 &shared,
13577 include_harness_mcp_proxy,
13578 );
13579 write_json_frame(
13580 &mut pipe,
13581 &ServerFrame::Hello(NodeHello {
13582 build_stamp: BUILD_STAMP.to_owned(),
13583 incarnation_id: shared.incarnation_id,
13584 connection_id,
13585 role: hello.role,
13586 event_sequence: shared.current_sequence(),
13587 controller: shared.controller_state(),
13588 snapshot: snapshot_for_wire(
13589 &shared,
13590 include_provider_runtime_status,
13591 include_open_provider_ids,
13592 include_spawn_profiles,
13593 include_managed_worktrees,
13594 include_child_environment_profiles,
13595 include_session_bundles,
13596 include_history_context_packs,
13597 include_agent_progress,
13598 include_session_task_correlation,
13599 ),
13600 compatibility,
13601 }),
13602 )
13603 .await?;
13604 let (mut reader, mut writer) = tokio::io::split(pipe);
13605 let (frame_tx, mut frame_rx) = mpsc::channel(16);
13606 let reader_task = tokio::spawn(async move {
13607 loop {
13608 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13609 &mut reader,
13610 MAX_NODE_CLIENT_FRAME_BYTES,
13611 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13612 )
13613 .await;
13614 let terminal = frame.is_err();
13615 if frame_tx.send(frame).await.is_err() || terminal {
13616 break;
13617 }
13618 }
13619 });
13620 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13621 let mut pending_events = Vec::new();
13622 let mut resync_required = false;
13623 let mut discard_events_through = 0_u64;
13624 loop {
13625 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13630 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13631 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13632 for _ in 0..durable_drain_budget {
13633 match event_rx.try_recv() {
13634 Ok(event) => queue_connection_event(
13635 &mut pending_events,
13636 event,
13637 discard_events_through,
13638 ),
13639 Err(broadcast::error::TryRecvError::Empty) => break,
13640 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13641 resync_required = true;
13642 break;
13643 }
13644 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13645 }
13646 }
13647 if let Some(receiver) = terminal_event_rx.as_mut() {
13648 match receiver.try_recv() {
13649 Ok(events) => queue_connection_event_batch(
13650 &mut pending_events,
13651 &events,
13652 discard_events_through,
13653 ),
13654 Err(broadcast::error::TryRecvError::Empty) => {}
13655 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13656 resync_required = true;
13657 }
13658 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13659 }
13660 }
13661 if let Some(receiver) = agent_stream_event_rx.as_mut() {
13662 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13672 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13673 for _ in 0..agent_stream_drain_budget {
13674 match receiver.try_recv() {
13675 Ok(events) => queue_connection_event_batch(
13676 &mut pending_events,
13677 &events,
13678 discard_events_through,
13679 ),
13680 Err(broadcast::error::TryRecvError::Empty) => break,
13681 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13682 tracing::warn!(
13683 connection_id,
13684 dropped,
13685 "agent stream event subscription lagged; forcing a resync",
13686 );
13687 resync_required = true;
13688 break;
13689 }
13690 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13691 }
13692 }
13693 }
13694 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13695 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13701 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13702 for _ in 0..harness_mcp_proxy_drain_budget {
13703 match receiver.try_recv() {
13704 Ok(events) => queue_connection_event_batch(
13705 &mut pending_events,
13706 &events,
13707 discard_events_through,
13708 ),
13709 Err(broadcast::error::TryRecvError::Empty) => break,
13710 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13711 tracing::warn!(
13712 connection_id,
13713 dropped,
13714 "harness MCP proxy event subscription lagged; forcing a resync",
13715 );
13716 resync_required = true;
13717 break;
13718 }
13719 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13720 }
13721 }
13722 }
13723 if resync_required {
13724 let event = resync_required_event(&shared);
13725 apply_connection_resync_watermark(
13726 &mut pending_events,
13727 &mut discard_events_through,
13728 event.sequence,
13729 );
13730 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13731 resync_required = false;
13732 continue;
13733 }
13734 if !pending_events.is_empty() {
13735 pending_events.sort_unstable_by_key(|event| event.sequence);
13736 let burst_len = connection_event_burst_len(pending_events.len());
13737 shared
13738 .connection_events_sent
13739 .fetch_add(burst_len as u64, Ordering::Relaxed);
13740 for event in pending_events.drain(..burst_len) {
13741 let event = if include_managed_worktrees {
13742 Some(event)
13743 } else {
13744 project_event_without_managed_worktrees(event)
13745 };
13746 let event = event.and_then(|event| if include_open_provider_ids {
13747 Some(event)
13748 } else {
13749 project_event_legacy_provider_ids(&shared, event)
13750 });
13751 let event = event.map(|event| if include_child_environment_profiles {
13752 event
13753 } else {
13754 project_event_without_child_environment_profile(event)
13755 });
13756 let event = event.map(|event| if include_session_bundles {
13757 event
13758 } else {
13759 project_event_without_session_bundle(event)
13760 });
13761 let event = event.map(|event| {
13762 project_event_history_for_wire(event, include_history_context_packs)
13763 });
13764 let event = event.map(|event| if include_history_context_packs {
13765 event
13766 } else {
13767 project_event_without_context_pack(event)
13768 });
13769 let event = event.map(|event| if include_session_task_correlation {
13770 event
13771 } else {
13772 project_event_without_session_task_binding(event)
13773 });
13774 let exact_harness_controller = hello.role == ClientRole::Operator
13775 && shared.controller_state().is_some_and(|controller| {
13776 controller.connection_id == connection_id
13777 });
13778 let event = event.and_then(|event| {
13779 if include_harness_mcp_proxy && exact_harness_controller {
13780 Some(event)
13781 } else {
13782 project_event_without_harness_mcp_proxy(event)
13783 }
13784 });
13785 if let Some(event) = event {
13786 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13787 }
13788 }
13789 }
13790 tokio::select! {
13791 biased;
13792 frame = frame_rx.recv() => {
13793 let frame = match frame {
13794 Some(frame) => frame,
13795 None => break,
13796 };
13797 let frame = match frame {
13798 Ok(frame) => frame,
13799 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13800 Err(error) => return Err(error.into()),
13801 };
13802 let ClientFrame::Request(request) = frame else {
13803 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13804 };
13805 #[cfg(feature = "fixture")]
13806 let probe_spawn_managed_worktree_v2 = matches!(
13807 &request.request,
13808 NodeRequest::SpawnManagedWorktreeV2 { .. }
13809 );
13810 let requires_open_provider_ids =
13811 request_requires_open_provider_ids(&shared, &request.request);
13812 let requires_child_environment_profile =
13813 request_requires_child_environment_profile(&shared, &request.request);
13814 let requires_session_bundle =
13815 request_requires_session_bundle(&shared, &request.request);
13816 let requires_history_context_pack =
13817 request_requires_history_context_pack(&shared, &request.request);
13818 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13819 ResponseEnvelope {
13820 request_id: request.request_id,
13821 result: Err(failure(
13822 NodeFailureCode::UnsupportedCapability,
13823 "open provider IDs were not negotiated",
13824 )),
13825 }
13826 } else if requires_child_environment_profile
13827 && !include_child_environment_profiles
13828 {
13829 ResponseEnvelope {
13830 request_id: request.request_id,
13831 result: Err(failure(
13832 NodeFailureCode::UnsupportedCapability,
13833 "child environment profiles were not negotiated",
13834 )),
13835 }
13836 } else if requires_session_bundle && !include_session_bundles {
13837 ResponseEnvelope {
13838 request_id: request.request_id,
13839 result: Err(failure(
13840 NodeFailureCode::UnsupportedCapability,
13841 "session bundles were not negotiated",
13842 )),
13843 }
13844 } else if requires_history_context_pack && !include_history_context_packs {
13845 ResponseEnvelope {
13846 request_id: request.request_id,
13847 result: Err(failure(
13848 NodeFailureCode::UnsupportedCapability,
13849 "history context packs were not negotiated",
13850 )),
13851 }
13852 } else if request_uses_unnegotiated_capability(
13853 &request.request,
13854 &selected_capabilities,
13855 )
13856 {
13857 ResponseEnvelope {
13858 request_id: request.request_id,
13859 result: Err(failure(
13860 NodeFailureCode::UnsupportedCapability,
13861 "request capability was not negotiated",
13862 )),
13863 }
13864 } else {
13865 process_request(&shared, connection_id, hello.role, request).await
13866 };
13867 #[cfg(feature = "fixture")]
13868 if probe_spawn_managed_worktree_v2 {
13869 shared
13870 .fixture_spawn_managed_worktree_v2_failure_probe
13871 .record(reply.result.as_ref().err().map(|failure| failure.code));
13872 }
13873 if !include_provider_runtime_status {
13874 clear_response_provider_runtime_status(&mut reply);
13875 }
13876 if !include_managed_worktrees {
13877 project_response_without_managed_worktrees(&mut reply);
13878 }
13879 if !include_open_provider_ids {
13880 project_response_legacy_provider_ids(&shared, &mut reply);
13881 }
13882 if !include_child_environment_profiles {
13883 project_response_without_child_environment_profile(&mut reply);
13884 }
13885 if !include_session_bundles {
13886 project_response_without_session_bundle(&mut reply);
13887 }
13888 project_response_history_for_wire(
13889 &mut reply,
13890 include_history_context_packs,
13891 );
13892 if !include_history_context_packs {
13893 project_response_without_context_pack(&mut reply);
13894 }
13895 if !include_agent_progress {
13896 project_response_without_agent_progress(&mut reply);
13897 }
13898 if !include_session_task_correlation {
13899 project_response_without_session_task_binding(&mut reply);
13900 }
13901 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13902 }
13903 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13904 event = event_rx.recv() => {
13905 match event {
13906 Ok(event) => queue_connection_event(
13907 &mut pending_events,
13908 event,
13909 discard_events_through,
13910 ),
13911 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13912 Err(broadcast::error::RecvError::Closed) => break,
13913 }
13914 }
13915 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13916 match events {
13917 Ok(events) => queue_connection_event_batch(
13918 &mut pending_events,
13919 &events,
13920 discard_events_through,
13921 ),
13922 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13923 Err(broadcast::error::RecvError::Closed) => break,
13924 }
13925 }
13926 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13927 match events {
13928 Ok(events) => queue_connection_event_batch(
13929 &mut pending_events,
13930 &events,
13931 discard_events_through,
13932 ),
13933 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13934 Err(broadcast::error::RecvError::Closed) => break,
13935 }
13936 }
13937 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13938 match events {
13939 Ok(events) => queue_connection_event_batch(
13940 &mut pending_events,
13941 &events,
13942 discard_events_through,
13943 ),
13944 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13945 Err(broadcast::error::RecvError::Closed) => break,
13946 }
13947 }
13948 _ = shared.shutdown_notify.notified() => break,
13949 }
13950 }
13951 Ok(())
13952}
13953
13954async fn receive_terminal_event_batch(
13955 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13956) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13957 match receiver {
13958 Some(receiver) => receiver.recv().await,
13959 None => std::future::pending().await,
13960 }
13961}
13962
13963fn terminal_event_subscription(
13964 shared: &NodeShared,
13965 enabled: bool,
13966) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13967 enabled.then(|| shared.terminal_event_tx.subscribe())
13968}
13969
13970fn agent_stream_event_subscription(
13974 shared: &NodeShared,
13975 enabled: bool,
13976) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13977 enabled.then(|| shared.agent_stream_event_tx.subscribe())
13978}
13979
13980fn harness_mcp_proxy_event_subscription(
13985 shared: &NodeShared,
13986 enabled: bool,
13987) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13988 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13989}
13990
13991fn queue_connection_event(
13992 pending: &mut Vec<NodeEventEnvelope>,
13993 event: NodeEventEnvelope,
13994 discard_through: u64,
13995) {
13996 if event.sequence <= discard_through
13997 && !event.event.requires_harness_mcp_proxy_capability()
13998 {
13999 return;
14000 }
14001 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14002 if let Some(index) = pending.iter().position(|current| {
14003 matches!(
14004 ¤t.event,
14005 NodeEvent::TerminalFrame {
14006 address: current_address,
14007 ..
14008 } if current_address == address
14009 )
14010 }) {
14011 pending.remove(index);
14012 }
14013 }
14014 pending.push(event);
14015}
14016
14017fn project_event_without_harness_mcp_proxy(
14018 envelope: NodeEventEnvelope,
14019) -> Option<NodeEventEnvelope> {
14020 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14021}
14022
14023fn queue_connection_event_batch(
14024 pending: &mut Vec<NodeEventEnvelope>,
14025 events: &[NodeEventEnvelope],
14026 discard_through: u64,
14027) {
14028 for event in events {
14029 queue_connection_event(pending, event.clone(), discard_through);
14030 }
14031}
14032
14033fn apply_connection_resync_watermark(
14034 pending: &mut Vec<NodeEventEnvelope>,
14035 discard_through: &mut u64,
14036 marker_sequence: u64,
14037) {
14038 *discard_through = (*discard_through).max(marker_sequence);
14039 pending.retain(|event| event.sequence > *discard_through);
14040}
14041
14042fn connection_event_burst_len(pending_len: usize) -> usize {
14043 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14044}
14045
14046fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14047 let history = shared
14048 .history
14049 .lock()
14050 .unwrap_or_else(|poisoned| poisoned.into_inner());
14051 let sequence = history.last_sequence;
14052 let oldest_available_sequence = history.replay_floor_sequence;
14053 NodeEventEnvelope {
14054 sequence,
14055 event: NodeEvent::ResyncRequired {
14056 oldest_available_sequence,
14057 },
14058 }
14059}
14060
14061fn node_compatibility_support(
14062 shared: &NodeShared,
14063) -> Result<NodeCompatibilitySupport, NodeServerError> {
14064 let mut support = node_compatibility_support_for_manifest(
14065 &shared.provider_contracts,
14066 &shared.provider_adapter_contracts,
14067 )?;
14068 if shared
14069 .delivery_store
14070 .lock()
14071 .unwrap_or_else(|poisoned| poisoned.into_inner())
14072 .is_some()
14073 {
14074 support.capabilities.push(
14075 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14076 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14077 );
14078 }
14079 if shared.harness_mcp_registry.is_some() {
14080 support.capabilities.push(
14081 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14082 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14083 );
14084 }
14085 Ok(support)
14086}
14087
14088fn node_compatibility_support_for_manifest(
14089 provider_contracts: &[ProviderContractSupport],
14090 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14091) -> Result<NodeCompatibilitySupport, NodeServerError> {
14092 Ok(NodeCompatibilitySupport {
14093 build_stamp: BUILD_STAMP.to_owned(),
14094 capabilities: baseline_capabilities()?,
14095 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14096 path_semantics: platform::path_semantics(),
14097 local_transport: platform::local_transport(),
14098 state_schema: StateSchemaSupport {
14099 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14100 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14101 },
14102 provider_contracts: provider_contracts.to_vec(),
14103 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14104 })
14105}
14106
14107fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14108 let capabilities = [
14109 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14110 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14111 NODE_REPOSITORY_PATH_CAPABILITY,
14112 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14113 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14114 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14115 NODE_GIT_READ_CAPABILITY,
14116 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14117 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14118 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14119 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14120 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14121 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14122 NODE_ACP_CONTROL_CAPABILITY,
14123 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14124 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14125 NODE_WORKTREE_SELECTION_CAPABILITY,
14126 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14127 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14128 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14129 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14130 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14131 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14132 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14133 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14134 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14135 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14136 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14137 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14138 ]
14139 .into_iter()
14140 .map(|capability| {
14141 CapabilityId::new(capability)
14142 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14143 })
14144 .collect::<Result<Vec<_>, _>>()?;
14145 #[cfg(windows)]
14146 let capabilities = {
14147 let mut capabilities = capabilities;
14148 capabilities.push(
14149 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14150 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14151 );
14152 capabilities
14153 };
14154 Ok(capabilities)
14155}
14156
14157fn request_uses_unnegotiated_capability(
14158 request: &NodeRequest,
14159 selected_capabilities: &[CapabilityId],
14160) -> bool {
14161 let selected = |required: &str| {
14162 selected_capabilities
14163 .iter()
14164 .any(|capability| capability.as_str() == required)
14165 };
14166 request
14167 .required_capability()
14168 .is_some_and(|required| !selected(required))
14169 || (request.requires_worktree_selection_capability()
14170 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14171 || (request.requires_spawn_spec_defaults_overrides_capability()
14172 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14173 || (request.requires_spawn_profile_revision_capability()
14174 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14175 || (request.requires_child_environment_profile_capability()
14176 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14177 || (request.requires_session_bundle_materialization_capability()
14178 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14179 || (request.requires_history_context_pack_capability()
14180 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14181}
14182
14183fn snapshot_for_wire(
14184 shared: &NodeShared,
14185 include_provider_runtime_status: bool,
14186 include_open_provider_ids: bool,
14187 include_spawn_profiles: bool,
14188 include_managed_worktrees: bool,
14189 include_child_environment_profiles: bool,
14190 include_session_bundles: bool,
14191 include_history_context_packs: bool,
14192 include_agent_progress: bool,
14193 include_session_task_correlation: bool,
14194) -> NodeSnapshot {
14195 let mut snapshot = shared.snapshot();
14196 if !include_provider_runtime_status {
14197 snapshot.provider_runtime_statuses.clear();
14198 }
14199 if !include_open_provider_ids {
14200 project_snapshot_legacy_provider_ids(&mut snapshot);
14201 }
14202 if !include_managed_worktrees {
14203 snapshot.managed_worktrees.clear();
14204 for workspace in &mut snapshot.workspaces {
14205 workspace.managed_worktree_profiles = None;
14206 }
14207 }
14208 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14209 if !include_spawn_profiles {
14210 inventory.spawn_profiles = None;
14211 }
14212 if !include_session_bundles {
14213 inventory.bundles = None;
14214 }
14215 if inventory.spawn_profiles.is_none()
14216 && inventory.bundles.is_none()
14217 && inventory.network_allowlists.is_none()
14218 {
14219 snapshot.launch_inventory = None;
14220 }
14221 }
14222 if !include_child_environment_profiles {
14223 clear_snapshot_child_environment_profiles(&mut snapshot);
14224 }
14225 if !include_session_bundles {
14226 clear_snapshot_session_bundles(&mut snapshot);
14227 }
14228 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14229 if !include_history_context_packs {
14230 clear_snapshot_context_packs(&mut snapshot);
14231 }
14232 if !include_agent_progress {
14233 snapshot.agent_progress.clear();
14234 }
14235 if !include_session_task_correlation {
14236 clear_snapshot_session_task_bindings(&mut snapshot);
14237 }
14238 snapshot
14239}
14240
14241fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14242 for record in &mut snapshot.session_records {
14243 record.task_binding = None;
14244 }
14245}
14246
14247fn project_event_without_session_task_binding(
14248 mut envelope: NodeEventEnvelope,
14249) -> NodeEventEnvelope {
14250 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14251 record.task_binding = None;
14252 }
14253 envelope
14254}
14255
14256fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14257 let Ok(response) = reply.result.as_mut() else { return };
14258 match response {
14259 NodeResponse::Snapshot { snapshot, .. } => {
14260 clear_snapshot_session_task_bindings(snapshot);
14261 }
14262 NodeResponse::Resync { snapshot, events, .. } => {
14263 clear_snapshot_session_task_bindings(snapshot);
14264 for event in events {
14265 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14266 record.task_binding = None;
14267 }
14268 }
14269 }
14270 NodeResponse::SessionRecordUpdated { record }
14271 | NodeResponse::ProviderSessionIndexed { record }
14272 | NodeResponse::NativeSessionIndexed { record, .. }
14273 | NodeResponse::SessionRecordResumed { record, .. } => {
14274 record.task_binding = None;
14275 }
14276 _ => {}
14277 }
14278}
14279
14280fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14281 let Ok(response) = reply.result.as_mut() else { return };
14282 match response {
14283 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14284 snapshot.agent_progress.clear();
14285 }
14286 _ => {}
14287 }
14288}
14289
14290fn project_event_without_managed_worktrees(
14291 mut envelope: NodeEventEnvelope,
14292) -> Option<NodeEventEnvelope> {
14293 if matches!(
14294 envelope.event,
14295 NodeEvent::ManagedWorktreeUpserted { .. }
14296 | NodeEvent::ManagedWorktreeRemoved { .. }
14297 ) {
14298 return None;
14299 }
14300 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14301 workspace.managed_worktree_profiles = None;
14302 }
14303 Some(envelope)
14304}
14305
14306fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14307 if matches!(
14308 reply.result,
14309 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14310 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14311 ) {
14312 reply.result = Err(failure(
14313 NodeFailureCode::UnsupportedCapability,
14314 "managed worktree capability was not negotiated",
14315 ));
14316 return;
14317 }
14318 let Ok(response) = reply.result.as_mut() else { return };
14319 match response {
14320 NodeResponse::Snapshot { snapshot, .. } => {
14321 snapshot.managed_worktrees.clear();
14322 for workspace in &mut snapshot.workspaces {
14323 workspace.managed_worktree_profiles = None;
14324 }
14325 }
14326 NodeResponse::Resync { snapshot, events, .. } => {
14327 snapshot.managed_worktrees.clear();
14328 for workspace in &mut snapshot.workspaces {
14329 workspace.managed_worktree_profiles = None;
14330 }
14331 *events = std::mem::take(events)
14332 .into_iter()
14333 .filter_map(project_event_without_managed_worktrees)
14334 .collect();
14335 }
14336 NodeResponse::WorkspaceRegistered { workspace }
14337 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14338 | NodeResponse::WorktreeCreated { workspace, .. } => {
14339 workspace.managed_worktree_profiles = None;
14340 }
14341 NodeResponse::WorkspaceInspected { inspection } => {
14342 inspection.git.managed_worktree = None;
14343 }
14344 _ => {}
14345 }
14346}
14347
14348fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14349 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14350 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14351 });
14352 if includes_open_provider_ids {
14353 return;
14354 }
14355 compatibility
14356 .provider_contracts
14357 .retain(|contract| provider_id_is_legacy(&contract.provider));
14358 compatibility
14359 .provider_adapter_contracts
14360 .retain(|contract| provider_id_is_legacy(&contract.provider));
14361}
14362
14363fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14364 if let Some(spec) = match request {
14365 NodeRequest::SpawnSpec { spec }
14366 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14367 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14368 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14369 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14370 _ => None,
14371 } {
14372 return shared
14373 .resolve_spawn_spec(spec)
14374 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14375 }
14376 request_requires_open_provider_ids_with(
14377 request,
14378 |session| shared.validate_address(session).ok(),
14379 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14380 )
14381}
14382
14383fn request_requires_child_environment_profile(
14384 shared: &NodeShared,
14385 request: &NodeRequest,
14386) -> bool {
14387 if let Some(spec) = match request {
14388 NodeRequest::SpawnSpec { spec }
14389 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14390 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14391 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14392 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14393 _ => None,
14394 } {
14395 return shared
14396 .resolve_spawn_spec(spec)
14397 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14398 }
14399 match request {
14400 NodeRequest::Resume { session, .. }
14401 | NodeRequest::Prompt { session, .. }
14402 | NodeRequest::Paste { session, .. }
14403 | NodeRequest::Input { session, .. }
14404 | NodeRequest::TerminalBytes { session, .. }
14405 | NodeRequest::TerminalControl { session, .. }
14406 | NodeRequest::Resize { session, .. }
14407 | NodeRequest::Interrupt { session }
14408 | NodeRequest::Stop { session, .. }
14409 | NodeRequest::Remove { session }
14410 | NodeRequest::ResolveInteraction { session, .. }
14411 | NodeRequest::SetSessionMode { session, .. }
14412 | NodeRequest::SetSessionConfigOption { session, .. }
14413 | NodeRequest::SetSessionModel { session, .. }
14414 | NodeRequest::DiscoverHistory { session, .. }
14415 | NodeRequest::LoadHistory { session, .. }
14416 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14417 | NodeRequest::ExportContextPack { session } => shared
14418 .session_bindings
14419 .lock()
14420 .unwrap_or_else(|poisoned| poisoned.into_inner())
14421 .get(&session.session.instance_id)
14422 .filter(|binding| {
14423 binding.workspace_id == session.workspace_id
14424 && binding.generation == session.session.generation
14425 })
14426 .map_or(true, |binding| binding.environment_profile.is_some()),
14427 NodeRequest::RenameSessionRecord { record_id, .. }
14428 | NodeRequest::SetSessionTask { record_id, .. }
14429 | NodeRequest::ResumeSessionRecord { record_id, .. }
14430 | NodeRequest::ForgetSessionRecord { record_id } => shared
14431 .record(record_id)
14432 .map_or(true, |record| record.environment_profile.is_some()),
14433 NodeRequest::ArmHarnessMcpReservation { .. }
14434 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14435 | NodeRequest::ActivateHarnessMcpReservation { .. }
14436 | NodeRequest::AbortHarnessMcpReservation { .. }
14437 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14438 | NodeRequest::RejectHarnessMcpCall { .. }
14439 | NodeRequest::Snapshot
14440 | NodeRequest::Resync { .. }
14441 | NodeRequest::BeginDeliveryStage { .. }
14442 | NodeRequest::PutDeliveryBlobChunk { .. }
14443 | NodeRequest::CommitDeliveryStage { .. }
14444 | NodeRequest::AbortDeliveryStage { .. }
14445 | NodeRequest::BrowseHostDirectories { .. }
14446 | NodeRequest::InspectWorkspace { .. }
14447 | NodeRequest::ReadWorkspaceFile { .. }
14448 | NodeRequest::WriteWorkspaceFile { .. }
14449 | NodeRequest::CreateWorkspaceFile { .. }
14450 | NodeRequest::CreateWorkspaceDirectory { .. }
14451 | NodeRequest::ReadGitHistory { .. }
14452 | NodeRequest::ReadGitDiff { .. }
14453 | NodeRequest::AcquireController { .. }
14454 | NodeRequest::ReleaseController
14455 | NodeRequest::Spawn { .. }
14456 | NodeRequest::SpawnWithOptions { .. }
14457 | NodeRequest::SpawnSpec { .. }
14458 | NodeRequest::RegisterWorkspace { .. }
14459 | NodeRequest::CreateStandaloneWorkspace { .. }
14460 | NodeRequest::UnregisterWorkspace { .. }
14461 | NodeRequest::CreateWorktree { .. }
14462 | NodeRequest::RemoveWorktree { .. }
14463 | NodeRequest::SpawnManagedWorktree { .. }
14464 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14465 | NodeRequest::CleanupManagedWorktree { .. }
14466 | NodeRequest::IndexProviderSession { .. }
14467 | NodeRequest::IndexNativeSession { .. }
14468 | NodeRequest::CatalogNativeSessions { .. }
14469 | NodeRequest::PageNativeSessions { .. }
14470 | NodeRequest::PreviewNativeSession { .. }
14471 | NodeRequest::PreviewSessionRecord { .. }
14472 | NodeRequest::ForgetContextPack { .. }
14473 | NodeRequest::ResolveDurableContextPack { .. }
14474 | NodeRequest::ReadContextPack { .. }
14475 | NodeRequest::Shutdown => false,
14476 }
14477}
14478
14479fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14480 if let Some(spec) = match request {
14481 NodeRequest::SpawnSpec { spec }
14482 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14483 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14484 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14485 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14486 _ => None,
14487 } {
14488 return shared
14489 .resolve_spawn_spec(spec)
14490 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14491 }
14492 match request {
14493 NodeRequest::Resume { session, .. }
14494 | NodeRequest::Prompt { session, .. }
14495 | NodeRequest::Paste { session, .. }
14496 | NodeRequest::Input { session, .. }
14497 | NodeRequest::TerminalBytes { session, .. }
14498 | NodeRequest::TerminalControl { session, .. }
14499 | NodeRequest::Resize { session, .. }
14500 | NodeRequest::Interrupt { session }
14501 | NodeRequest::Stop { session, .. }
14502 | NodeRequest::Remove { session }
14503 | NodeRequest::ResolveInteraction { session, .. }
14504 | NodeRequest::SetSessionMode { session, .. }
14505 | NodeRequest::SetSessionConfigOption { session, .. }
14506 | NodeRequest::SetSessionModel { session, .. }
14507 | NodeRequest::DiscoverHistory { session, .. }
14508 | NodeRequest::LoadHistory { session, .. }
14509 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14510 | NodeRequest::ExportContextPack { session } => shared
14511 .session_bindings
14512 .lock()
14513 .unwrap_or_else(|poisoned| poisoned.into_inner())
14514 .get(&session.session.instance_id)
14515 .filter(|binding| {
14516 binding.workspace_id == session.workspace_id
14517 && binding.generation == session.session.generation
14518 })
14519 .map_or(true, |binding| binding.bundle.is_some()),
14520 NodeRequest::RenameSessionRecord { record_id, .. }
14521 | NodeRequest::SetSessionTask { record_id, .. }
14522 | NodeRequest::ResumeSessionRecord { record_id, .. }
14523 | NodeRequest::ForgetSessionRecord { record_id } => shared
14524 .record(record_id)
14525 .map_or(true, |record| record.bundle.is_some()),
14526 NodeRequest::ArmHarnessMcpReservation { .. }
14527 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14528 | NodeRequest::ActivateHarnessMcpReservation { .. }
14529 | NodeRequest::AbortHarnessMcpReservation { .. }
14530 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14531 | NodeRequest::RejectHarnessMcpCall { .. }
14532 | NodeRequest::Snapshot
14533 | NodeRequest::Resync { .. }
14534 | NodeRequest::BeginDeliveryStage { .. }
14535 | NodeRequest::PutDeliveryBlobChunk { .. }
14536 | NodeRequest::CommitDeliveryStage { .. }
14537 | NodeRequest::AbortDeliveryStage { .. }
14538 | NodeRequest::BrowseHostDirectories { .. }
14539 | NodeRequest::InspectWorkspace { .. }
14540 | NodeRequest::ReadWorkspaceFile { .. }
14541 | NodeRequest::WriteWorkspaceFile { .. }
14542 | NodeRequest::CreateWorkspaceFile { .. }
14543 | NodeRequest::CreateWorkspaceDirectory { .. }
14544 | NodeRequest::ReadGitHistory { .. }
14545 | NodeRequest::ReadGitDiff { .. }
14546 | NodeRequest::AcquireController { .. }
14547 | NodeRequest::ReleaseController
14548 | NodeRequest::Spawn { .. }
14549 | NodeRequest::SpawnWithOptions { .. }
14550 | NodeRequest::SpawnSpec { .. }
14551 | NodeRequest::RegisterWorkspace { .. }
14552 | NodeRequest::CreateStandaloneWorkspace { .. }
14553 | NodeRequest::UnregisterWorkspace { .. }
14554 | NodeRequest::CreateWorktree { .. }
14555 | NodeRequest::RemoveWorktree { .. }
14556 | NodeRequest::SpawnManagedWorktree { .. }
14557 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14558 | NodeRequest::CleanupManagedWorktree { .. }
14559 | NodeRequest::IndexProviderSession { .. }
14560 | NodeRequest::IndexNativeSession { .. }
14561 | NodeRequest::CatalogNativeSessions { .. }
14562 | NodeRequest::PageNativeSessions { .. }
14563 | NodeRequest::PreviewNativeSession { .. }
14564 | NodeRequest::PreviewSessionRecord { .. }
14565 | NodeRequest::ForgetContextPack { .. }
14566 | NodeRequest::ResolveDurableContextPack { .. }
14567 | NodeRequest::ReadContextPack { .. }
14568 | NodeRequest::Shutdown => false,
14569 }
14570}
14571
14572fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14573 if request.requires_history_context_pack_capability() {
14574 return true;
14575 }
14576 if let Some(spec) = match request {
14577 NodeRequest::SpawnSpec { spec }
14578 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14579 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14580 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14581 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14582 _ => None,
14583 } {
14584 return shared
14585 .resolve_spawn_spec(spec)
14586 .map_or(true, |resolved| resolved.context_id.is_some());
14587 }
14588 match request {
14589 NodeRequest::Resume { session, .. }
14590 | NodeRequest::Prompt { session, .. }
14591 | NodeRequest::Paste { session, .. }
14592 | NodeRequest::Input { session, .. }
14593 | NodeRequest::TerminalBytes { session, .. }
14594 | NodeRequest::TerminalControl { session, .. }
14595 | NodeRequest::Resize { session, .. }
14596 | NodeRequest::Interrupt { session }
14597 | NodeRequest::Stop { session, .. }
14598 | NodeRequest::Remove { session }
14599 | NodeRequest::ResolveInteraction { session, .. }
14600 | NodeRequest::SetSessionMode { session, .. }
14601 | NodeRequest::SetSessionConfigOption { session, .. }
14602 | NodeRequest::SetSessionModel { session, .. } => shared
14603 .session_bindings
14604 .lock()
14605 .unwrap_or_else(|poisoned| poisoned.into_inner())
14606 .get(&session.session.instance_id)
14607 .filter(|binding| {
14608 binding.workspace_id == session.workspace_id
14609 && binding.generation == session.session.generation
14610 })
14611 .map_or(true, |binding| binding.context.is_some()),
14612 NodeRequest::RenameSessionRecord { record_id, .. }
14613 | NodeRequest::SetSessionTask { record_id, .. }
14614 | NodeRequest::ResumeSessionRecord { record_id, .. }
14615 | NodeRequest::ForgetSessionRecord { record_id } => shared
14616 .record(record_id)
14617 .map_or(true, |record| record.context.is_some()),
14618 NodeRequest::ArmHarnessMcpReservation { .. }
14619 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14620 | NodeRequest::ActivateHarnessMcpReservation { .. }
14621 | NodeRequest::AbortHarnessMcpReservation { .. }
14622 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14623 | NodeRequest::RejectHarnessMcpCall { .. }
14624 | NodeRequest::Snapshot
14625 | NodeRequest::Resync { .. }
14626 | NodeRequest::BeginDeliveryStage { .. }
14627 | NodeRequest::PutDeliveryBlobChunk { .. }
14628 | NodeRequest::CommitDeliveryStage { .. }
14629 | NodeRequest::AbortDeliveryStage { .. }
14630 | NodeRequest::BrowseHostDirectories { .. }
14631 | NodeRequest::InspectWorkspace { .. }
14632 | NodeRequest::ReadWorkspaceFile { .. }
14633 | NodeRequest::WriteWorkspaceFile { .. }
14634 | NodeRequest::CreateWorkspaceFile { .. }
14635 | NodeRequest::CreateWorkspaceDirectory { .. }
14636 | NodeRequest::ReadGitHistory { .. }
14637 | NodeRequest::ReadGitDiff { .. }
14638 | NodeRequest::AcquireController { .. }
14639 | NodeRequest::ReleaseController
14640 | NodeRequest::Spawn { .. }
14641 | NodeRequest::SpawnWithOptions { .. }
14642 | NodeRequest::SpawnSpec { .. }
14643 | NodeRequest::RegisterWorkspace { .. }
14644 | NodeRequest::CreateStandaloneWorkspace { .. }
14645 | NodeRequest::UnregisterWorkspace { .. }
14646 | NodeRequest::CreateWorktree { .. }
14647 | NodeRequest::RemoveWorktree { .. }
14648 | NodeRequest::SpawnManagedWorktree { .. }
14649 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14650 | NodeRequest::CleanupManagedWorktree { .. }
14651 | NodeRequest::IndexProviderSession { .. }
14652 | NodeRequest::IndexNativeSession { .. }
14653 | NodeRequest::CatalogNativeSessions { .. }
14654 | NodeRequest::PageNativeSessions { .. }
14655 | NodeRequest::PreviewNativeSession { .. }
14656 | NodeRequest::PreviewSessionRecord { .. }
14657 | NodeRequest::DiscoverHistory { .. }
14658 | NodeRequest::LoadHistory { .. }
14659 | NodeRequest::ExportContextPackForSessionRecord { .. }
14660 | NodeRequest::ExportContextPack { .. }
14661 | NodeRequest::ForgetContextPack { .. }
14662 | NodeRequest::ResolveDurableContextPack { .. }
14663 | NodeRequest::ReadContextPack { .. }
14664 | NodeRequest::Shutdown => false,
14665 }
14666}
14667
14668fn request_requires_open_provider_ids_with(
14669 request: &NodeRequest,
14670 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14671 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14672) -> bool {
14673 match request {
14674 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
14675 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14676 NodeRequest::CatalogNativeSessions { route, .. }
14677 | NodeRequest::PageNativeSessions { route, .. } => {
14678 !provider_id_is_legacy(&route.provider)
14679 }
14680 NodeRequest::PreviewNativeSession { selection, .. }
14681 | NodeRequest::IndexNativeSession { selection, .. } => {
14682 !provider_id_is_legacy(&selection.route.provider)
14683 }
14684 NodeRequest::SpawnSpec { .. }
14685 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14686 NodeRequest::SpawnManagedWorktree { .. }
14687 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14688 NodeRequest::Resume { session, .. }
14689 | NodeRequest::Prompt { session, .. }
14690 | NodeRequest::Paste { session, .. }
14691 | NodeRequest::Input { session, .. }
14692 | NodeRequest::TerminalBytes { session, .. }
14693 | NodeRequest::TerminalControl { session, .. }
14694 | NodeRequest::Resize { session, .. }
14695 | NodeRequest::Interrupt { session }
14696 | NodeRequest::Stop { session, .. }
14697 | NodeRequest::Remove { session }
14698 | NodeRequest::ResolveInteraction { session, .. }
14699 | NodeRequest::SetSessionMode { session, .. }
14700 | NodeRequest::SetSessionConfigOption { session, .. }
14701 | NodeRequest::SetSessionModel { session, .. }
14702 | NodeRequest::DiscoverHistory { session, .. }
14703 | NodeRequest::LoadHistory { session, .. }
14704 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14705 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14706 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14707 NodeRequest::RenameSessionRecord { record_id, .. }
14708 | NodeRequest::SetSessionTask { record_id, .. }
14709 | NodeRequest::ResumeSessionRecord { record_id, .. }
14710 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14711 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14712 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14713 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14714 NodeRequest::ArmHarnessMcpReservation { .. }
14715 | NodeRequest::ActivateHarnessMcpReservation { .. }
14716 | NodeRequest::AbortHarnessMcpReservation { .. }
14717 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14718 | NodeRequest::RejectHarnessMcpCall { .. }
14719 | NodeRequest::Snapshot
14720 | NodeRequest::Resync { .. }
14721 | NodeRequest::BeginDeliveryStage { .. }
14722 | NodeRequest::PutDeliveryBlobChunk { .. }
14723 | NodeRequest::CommitDeliveryStage { .. }
14724 | NodeRequest::AbortDeliveryStage { .. }
14725 | NodeRequest::BrowseHostDirectories { .. }
14726 | NodeRequest::InspectWorkspace { .. }
14727 | NodeRequest::ReadWorkspaceFile { .. }
14728 | NodeRequest::WriteWorkspaceFile { .. }
14729 | NodeRequest::CreateWorkspaceFile { .. }
14730 | NodeRequest::CreateWorkspaceDirectory { .. }
14731 | NodeRequest::ReadGitHistory { .. }
14732 | NodeRequest::ReadGitDiff { .. }
14733 | NodeRequest::AcquireController { .. }
14734 | NodeRequest::ReleaseController
14735 | NodeRequest::RegisterWorkspace { .. }
14736 | NodeRequest::CreateStandaloneWorkspace { .. }
14737 | NodeRequest::UnregisterWorkspace { .. }
14738 | NodeRequest::CreateWorktree { .. }
14739 | NodeRequest::RemoveWorktree { .. }
14740 | NodeRequest::CleanupManagedWorktree { .. }
14741 | NodeRequest::Shutdown => false,
14742 NodeRequest::ForgetContextPack { .. } => true,
14743 NodeRequest::ResolveDurableContextPack { .. }
14749 | NodeRequest::ReadContextPack { .. } => true,
14750 }
14751}
14752
14753fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14754 snapshot.enabled_providers.retain(provider_id_is_legacy);
14755 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14756 snapshot
14757 .provider_runtime_statuses
14758 .as_slice()
14759 .iter()
14760 .filter(|status| provider_id_is_legacy(status.provider()))
14761 .cloned(),
14762 )
14763 .expect("a filtered provider runtime projection remains valid");
14764 snapshot
14765 .session_records
14766 .retain(|record| provider_id_is_legacy(&record.provider));
14767 for workspace in &mut snapshot.workspaces {
14768 workspace
14769 .sessions
14770 .retain(|session| provider_id_is_legacy(&session.agent_id));
14771 }
14772}
14773
14774fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14775 for record in &mut snapshot.session_records {
14776 record.environment_profile = None;
14777 }
14778 if let Some(profiles) = snapshot
14779 .launch_inventory
14780 .as_mut()
14781 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14782 {
14783 for profile in profiles {
14784 profile.environment_profile = None;
14785 }
14786 }
14787}
14788
14789fn project_event_without_child_environment_profile(
14790 mut envelope: NodeEventEnvelope,
14791) -> NodeEventEnvelope {
14792 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14793 record.environment_profile = None;
14794 }
14795 envelope
14796}
14797
14798fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14799 let contains_environment_profile = match reply.result.as_ref() {
14800 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14801 receipt.environment_profile.is_some()
14802 }
14803 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14804 receipt.spawn.environment_profile.is_some()
14805 }
14806 _ => false,
14807 };
14808 if contains_environment_profile {
14809 reply.result = Err(failure(
14810 NodeFailureCode::UnsupportedCapability,
14811 "child environment profiles were not negotiated",
14812 ));
14813 return;
14814 }
14815 let Ok(response) = reply.result.as_mut() else {
14816 return;
14817 };
14818 match response {
14819 NodeResponse::Snapshot { snapshot, .. } => {
14820 clear_snapshot_child_environment_profiles(snapshot);
14821 }
14822 NodeResponse::Resync { snapshot, events, .. } => {
14823 clear_snapshot_child_environment_profiles(snapshot);
14824 for event in events {
14825 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14826 record.environment_profile = None;
14827 }
14828 }
14829 }
14830 NodeResponse::SessionRecordUpdated { record }
14831 | NodeResponse::ProviderSessionIndexed { record }
14832 | NodeResponse::NativeSessionIndexed { record, .. }
14833 | NodeResponse::SessionRecordResumed { record, .. } => {
14834 record.environment_profile = None;
14835 }
14836 NodeResponse::Armed { .. }
14837 | NodeResponse::Spawned { .. }
14838 | NodeResponse::Activated { .. }
14839 | NodeResponse::Aborted { .. }
14840 | NodeResponse::ReplyChunkAccepted { .. }
14841 | NodeResponse::CallRejected { .. }
14842 | NodeResponse::DeliveryStageBegun { .. }
14843 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14844 | NodeResponse::DeliveryCommitted { .. }
14845 | NodeResponse::DeliveryStageAborted { .. }
14846 | NodeResponse::WorkspaceInspected { .. }
14847 | NodeResponse::HostDirectoriesBrowsed { .. }
14848 | NodeResponse::WorkspaceFileRead { .. }
14849 | NodeResponse::WorkspaceFileWritten { .. }
14850 | NodeResponse::WorkspaceFileCreated { .. }
14851 | NodeResponse::WorkspaceDirectoryCreated { .. }
14852 | NodeResponse::GitHistoryRead { .. }
14853 | NodeResponse::GitDiffRead { .. }
14854 | NodeResponse::Controller { .. }
14855 | NodeResponse::SpawnAccepted { .. }
14856 | NodeResponse::SpawnSpecAccepted { .. }
14857 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14858 | NodeResponse::ManagedWorktreeCleanup { .. }
14859 | NodeResponse::NativeSessionsCataloged { .. }
14860 | NodeResponse::NativeSessionsPaged { .. }
14861 | NodeResponse::NativeSessionPreviewed { .. }
14862 | NodeResponse::SessionRecordPreviewed { .. }
14863 | NodeResponse::HistoryDiscovered { .. }
14864 | NodeResponse::HistoryLoaded { .. }
14865 | NodeResponse::ContextPackForSessionRecordExported { .. }
14866 | NodeResponse::ContextPackExported { .. }
14867 | NodeResponse::ContextPackForgotten { .. }
14868 | NodeResponse::DurableContextPackResolved { .. }
14869 | NodeResponse::ContextPackBytesRead { .. }
14870 | NodeResponse::SessionRecordForgotten { .. }
14871 | NodeResponse::WorkspaceRegistered { .. }
14872 | NodeResponse::StandaloneWorkspaceCreated { .. }
14873 | NodeResponse::WorkspaceUnregistered { .. }
14874 | NodeResponse::WorktreeCreated { .. }
14875 | NodeResponse::WorktreeRemoved { .. }
14876 | NodeResponse::Accepted
14877 | NodeResponse::ShuttingDown => {}
14878 }
14879}
14880
14881fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14882 for record in &mut snapshot.session_records {
14883 record.bundle = None;
14884 }
14885}
14886
14887fn project_event_without_session_bundle(
14888 mut envelope: NodeEventEnvelope,
14889) -> NodeEventEnvelope {
14890 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14891 record.bundle = None;
14892 }
14893 envelope
14894}
14895
14896fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14897 let contains_bundle = match reply.result.as_ref() {
14898 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14899 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14900 receipt.spawn.bundle.is_some()
14901 }
14902 _ => false,
14903 };
14904 if contains_bundle {
14905 reply.result = Err(failure(
14906 NodeFailureCode::UnsupportedCapability,
14907 "session bundles were not negotiated",
14908 ));
14909 return;
14910 }
14911 let Ok(response) = reply.result.as_mut() else {
14912 return;
14913 };
14914 match response {
14915 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14916 NodeResponse::Resync { snapshot, events, .. } => {
14917 clear_snapshot_session_bundles(snapshot);
14918 for event in events {
14919 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14920 record.bundle = None;
14921 }
14922 }
14923 }
14924 NodeResponse::SessionRecordUpdated { record }
14925 | NodeResponse::ProviderSessionIndexed { record }
14926 | NodeResponse::NativeSessionIndexed { record, .. }
14927 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14928 NodeResponse::Armed { .. }
14929 | NodeResponse::Spawned { .. }
14930 | NodeResponse::Activated { .. }
14931 | NodeResponse::Aborted { .. }
14932 | NodeResponse::ReplyChunkAccepted { .. }
14933 | NodeResponse::CallRejected { .. }
14934 | NodeResponse::DeliveryStageBegun { .. }
14935 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14936 | NodeResponse::DeliveryCommitted { .. }
14937 | NodeResponse::DeliveryStageAborted { .. }
14938 | NodeResponse::WorkspaceInspected { .. }
14939 | NodeResponse::HostDirectoriesBrowsed { .. }
14940 | NodeResponse::WorkspaceFileRead { .. }
14941 | NodeResponse::WorkspaceFileWritten { .. }
14942 | NodeResponse::WorkspaceFileCreated { .. }
14943 | NodeResponse::WorkspaceDirectoryCreated { .. }
14944 | NodeResponse::GitHistoryRead { .. }
14945 | NodeResponse::GitDiffRead { .. }
14946 | NodeResponse::Controller { .. }
14947 | NodeResponse::SpawnAccepted { .. }
14948 | NodeResponse::SpawnSpecAccepted { .. }
14949 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14950 | NodeResponse::ManagedWorktreeCleanup { .. }
14951 | NodeResponse::NativeSessionsCataloged { .. }
14952 | NodeResponse::NativeSessionsPaged { .. }
14953 | NodeResponse::NativeSessionPreviewed { .. }
14954 | NodeResponse::SessionRecordPreviewed { .. }
14955 | NodeResponse::HistoryDiscovered { .. }
14956 | NodeResponse::HistoryLoaded { .. }
14957 | NodeResponse::ContextPackForSessionRecordExported { .. }
14958 | NodeResponse::ContextPackExported { .. }
14959 | NodeResponse::ContextPackForgotten { .. }
14960 | NodeResponse::DurableContextPackResolved { .. }
14961 | NodeResponse::ContextPackBytesRead { .. }
14962 | NodeResponse::SessionRecordForgotten { .. }
14963 | NodeResponse::WorkspaceRegistered { .. }
14964 | NodeResponse::StandaloneWorkspaceCreated { .. }
14965 | NodeResponse::WorkspaceUnregistered { .. }
14966 | NodeResponse::WorktreeCreated { .. }
14967 | NodeResponse::WorktreeRemoved { .. }
14968 | NodeResponse::Accepted
14969 | NodeResponse::ShuttingDown => {}
14970 }
14971}
14972
14973fn project_snapshot_history_for_wire(
14974 snapshot: &mut NodeSnapshot,
14975 include_history_metadata: bool,
14976) {
14977 for workspace in &mut snapshot.workspaces {
14978 for session in &mut workspace.sessions {
14979 if include_history_metadata {
14980 session.history.loaded = None;
14981 } else {
14982 session.history = Default::default();
14983 }
14984 }
14985 }
14986}
14987
14988fn project_event_history_for_wire(
14989 mut envelope: NodeEventEnvelope,
14990 include_history_metadata: bool,
14991) -> NodeEventEnvelope {
14992 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14993 for session in &mut workspace.sessions {
14994 if include_history_metadata {
14995 session.history.loaded = None;
14996 } else {
14997 session.history = Default::default();
14998 }
14999 }
15000 }
15001 envelope
15002}
15003
15004fn project_response_history_for_wire(
15005 reply: &mut ResponseEnvelope,
15006 include_history_metadata: bool,
15007) {
15008 let Ok(response) = reply.result.as_mut() else {
15009 return;
15010 };
15011 match response {
15012 NodeResponse::Snapshot { snapshot, .. } => {
15013 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15014 }
15015 NodeResponse::Resync {
15016 snapshot, events, ..
15017 } => {
15018 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15019 for event in events {
15020 *event = project_event_history_for_wire(
15021 event.clone(),
15022 include_history_metadata,
15023 );
15024 }
15025 }
15026 NodeResponse::WorkspaceRegistered { workspace }
15027 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15028 | NodeResponse::WorktreeCreated { workspace, .. } => {
15029 for session in &mut workspace.sessions {
15030 if include_history_metadata {
15031 session.history.loaded = None;
15032 } else {
15033 session.history = Default::default();
15034 }
15035 }
15036 }
15037 _ => {}
15038 }
15039}
15040
15041fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15042 for record in &mut snapshot.session_records {
15043 record.context_id = None;
15044 record.context = None;
15045 record.exported_context = None;
15046 }
15047}
15048
15049fn project_event_without_context_pack(
15050 mut envelope: NodeEventEnvelope,
15051) -> NodeEventEnvelope {
15052 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15053 record.context_id = None;
15054 record.context = None;
15055 record.exported_context = None;
15056 }
15057 envelope
15058}
15059
15060fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15061 let contains_context = match reply.result.as_ref() {
15062 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15063 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15064 receipt.spawn.context.is_some()
15065 }
15066 Ok(NodeResponse::HistoryDiscovered { .. })
15067 | Ok(NodeResponse::HistoryLoaded { .. })
15068 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15069 | Ok(NodeResponse::ContextPackExported { .. })
15070 | Ok(NodeResponse::ContextPackForgotten { .. })
15071 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15072 _ => false,
15073 };
15074 if contains_context {
15075 reply.result = Err(failure(
15076 NodeFailureCode::UnsupportedCapability,
15077 "history context packs were not negotiated",
15078 ));
15079 return;
15080 }
15081 let Ok(response) = reply.result.as_mut() else {
15082 return;
15083 };
15084 match response {
15085 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15086 NodeResponse::Resync {
15087 snapshot, events, ..
15088 } => {
15089 clear_snapshot_context_packs(snapshot);
15090 for event in events {
15091 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15092 record.context_id = None;
15093 record.context = None;
15094 record.exported_context = None;
15095 }
15096 }
15097 }
15098 NodeResponse::SessionRecordUpdated { record }
15099 | NodeResponse::ProviderSessionIndexed { record }
15100 | NodeResponse::NativeSessionIndexed { record, .. }
15101 | NodeResponse::SessionRecordResumed { record, .. } => {
15102 record.context_id = None;
15103 record.context = None;
15104 record.exported_context = None;
15105 }
15106 _ => {}
15107 }
15108}
15109
15110fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15111 let contains_open_record = match reply.result.as_ref() {
15112 Ok(NodeResponse::SessionRecordUpdated { record })
15113 | Ok(NodeResponse::ProviderSessionIndexed { record })
15114 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15115 !provider_id_is_legacy(&record.provider)
15116 }
15117 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15118 !provider_id_is_legacy(&selection.route.provider)
15119 || !provider_id_is_legacy(&record.provider)
15120 }
15121 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15122 !provider_id_is_legacy(&receipt.provider)
15123 }
15124 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15125 !provider_id_is_legacy(&receipt.spawn.provider)
15126 }
15127 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15128 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15129 !provider_id_is_legacy(&route.provider)
15130 }
15131 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15132 !provider_id_is_legacy(&selection.route.provider)
15133 }
15134 _ => false,
15135 };
15136 if contains_open_record {
15137 reply.result = Err(failure(
15138 NodeFailureCode::UnsupportedCapability,
15139 "open provider IDs were not negotiated",
15140 ));
15141 return;
15142 }
15143 let Ok(response) = reply.result.as_mut() else {
15144 return;
15145 };
15146 match response {
15147 NodeResponse::Snapshot { snapshot, .. } => {
15148 project_snapshot_legacy_provider_ids(snapshot);
15149 }
15150 NodeResponse::Resync { snapshot, events, .. } => {
15151 project_snapshot_legacy_provider_ids(snapshot);
15152 *events = std::mem::take(events)
15153 .into_iter()
15154 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15155 .collect();
15156 }
15157 NodeResponse::WorkspaceRegistered { workspace }
15158 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15159 | NodeResponse::WorktreeCreated { workspace, .. } => {
15160 workspace
15161 .sessions
15162 .retain(|session| provider_id_is_legacy(&session.agent_id));
15163 }
15164 NodeResponse::Armed { .. }
15165 | NodeResponse::Spawned { .. }
15166 | NodeResponse::Activated { .. }
15167 | NodeResponse::Aborted { .. }
15168 | NodeResponse::ReplyChunkAccepted { .. }
15169 | NodeResponse::CallRejected { .. }
15170 | NodeResponse::DeliveryStageBegun { .. }
15171 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15172 | NodeResponse::DeliveryCommitted { .. }
15173 | NodeResponse::DeliveryStageAborted { .. }
15174 | NodeResponse::WorkspaceInspected { .. }
15175 | NodeResponse::HostDirectoriesBrowsed { .. }
15176 | NodeResponse::WorkspaceFileRead { .. }
15177 | NodeResponse::WorkspaceFileWritten { .. }
15178 | NodeResponse::WorkspaceFileCreated { .. }
15179 | NodeResponse::WorkspaceDirectoryCreated { .. }
15180 | NodeResponse::GitHistoryRead { .. }
15181 | NodeResponse::GitDiffRead { .. }
15182 | NodeResponse::Controller { .. }
15183 | NodeResponse::SpawnAccepted { .. }
15184 | NodeResponse::SpawnSpecAccepted { .. }
15185 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15186 | NodeResponse::ManagedWorktreeCleanup { .. }
15187 | NodeResponse::NativeSessionsCataloged { .. }
15188 | NodeResponse::NativeSessionsPaged { .. }
15189 | NodeResponse::NativeSessionPreviewed { .. }
15190 | NodeResponse::SessionRecordPreviewed { .. }
15191 | NodeResponse::HistoryDiscovered { .. }
15192 | NodeResponse::HistoryLoaded { .. }
15193 | NodeResponse::ContextPackForSessionRecordExported { .. }
15194 | NodeResponse::ContextPackExported { .. }
15195 | NodeResponse::ContextPackForgotten { .. }
15196 | NodeResponse::DurableContextPackResolved { .. }
15197 | NodeResponse::ContextPackBytesRead { .. }
15198 | NodeResponse::SessionRecordUpdated { .. }
15199 | NodeResponse::ProviderSessionIndexed { .. }
15200 | NodeResponse::NativeSessionIndexed { .. }
15201 | NodeResponse::SessionRecordResumed { .. }
15202 | NodeResponse::SessionRecordForgotten { .. }
15203 | NodeResponse::WorkspaceUnregistered { .. }
15204 | NodeResponse::WorktreeRemoved { .. }
15205 | NodeResponse::Accepted
15206 | NodeResponse::ShuttingDown => {}
15207 }
15208}
15209
15210fn project_event_legacy_provider_ids(
15211 shared: &NodeShared,
15212 mut envelope: NodeEventEnvelope,
15213) -> Option<NodeEventEnvelope> {
15214 let include = match &mut envelope.event {
15215 NodeEvent::HarnessMcpReadCall { .. } => true,
15216 NodeEvent::Control { address, .. }
15217 | NodeEvent::AgentStream { address, .. }
15218 | NodeEvent::TerminalFrame { address, .. } => shared
15219 .validate_address(address)
15220 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15221 NodeEvent::WorkspaceAdded { workspace } => {
15222 workspace
15223 .sessions
15224 .retain(|session| provider_id_is_legacy(&session.agent_id));
15225 true
15226 }
15227 NodeEvent::SessionRecordUpserted { record } => {
15228 provider_id_is_legacy(&record.provider)
15229 }
15230 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15231 NodeEvent::SessionRecordRemoved { .. } => shared
15232 .history
15233 .lock()
15234 .unwrap_or_else(|poisoned| poisoned.into_inner())
15235 .removed_record_providers
15236 .get(&envelope.sequence)
15237 .is_some_and(provider_id_is_legacy),
15238 NodeEvent::ControllerChanged { .. }
15239 | NodeEvent::ManagedWorktreeUpserted { .. }
15240 | NodeEvent::ManagedWorktreeRemoved { .. }
15241 | NodeEvent::WorkspaceRemoved { .. }
15242 | NodeEvent::ResyncRequired { .. } => true,
15243 };
15244 include.then_some(envelope)
15245}
15246
15247fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15248 let Ok(response) = reply.result.as_mut() else {
15249 return;
15250 };
15251 match response {
15252 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15253 snapshot.provider_runtime_statuses.clear();
15254 }
15255 NodeResponse::Armed { .. }
15256 | NodeResponse::Spawned { .. }
15257 | NodeResponse::Activated { .. }
15258 | NodeResponse::Aborted { .. }
15259 | NodeResponse::ReplyChunkAccepted { .. }
15260 | NodeResponse::CallRejected { .. }
15261 | NodeResponse::DeliveryStageBegun { .. }
15262 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15263 | NodeResponse::DeliveryCommitted { .. }
15264 | NodeResponse::DeliveryStageAborted { .. }
15265 | NodeResponse::WorkspaceInspected { .. }
15266 | NodeResponse::HostDirectoriesBrowsed { .. }
15267 | NodeResponse::WorkspaceFileRead { .. }
15268 | NodeResponse::WorkspaceFileWritten { .. }
15269 | NodeResponse::WorkspaceFileCreated { .. }
15270 | NodeResponse::WorkspaceDirectoryCreated { .. }
15271 | NodeResponse::GitHistoryRead { .. }
15272 | NodeResponse::GitDiffRead { .. }
15273 | NodeResponse::Controller { .. }
15274 | NodeResponse::SpawnAccepted { .. }
15275 | NodeResponse::SpawnSpecAccepted { .. }
15276 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15277 | NodeResponse::ManagedWorktreeCleanup { .. }
15278 | NodeResponse::NativeSessionsCataloged { .. }
15279 | NodeResponse::NativeSessionsPaged { .. }
15280 | NodeResponse::NativeSessionPreviewed { .. }
15281 | NodeResponse::SessionRecordPreviewed { .. }
15282 | NodeResponse::HistoryDiscovered { .. }
15283 | NodeResponse::HistoryLoaded { .. }
15284 | NodeResponse::ContextPackForSessionRecordExported { .. }
15285 | NodeResponse::ContextPackExported { .. }
15286 | NodeResponse::ContextPackForgotten { .. }
15287 | NodeResponse::DurableContextPackResolved { .. }
15288 | NodeResponse::ContextPackBytesRead { .. }
15289 | NodeResponse::SessionRecordUpdated { .. }
15290 | NodeResponse::ProviderSessionIndexed { .. }
15291 | NodeResponse::NativeSessionIndexed { .. }
15292 | NodeResponse::SessionRecordResumed { .. }
15293 | NodeResponse::SessionRecordForgotten { .. }
15294 | NodeResponse::WorkspaceRegistered { .. }
15295 | NodeResponse::StandaloneWorkspaceCreated { .. }
15296 | NodeResponse::WorkspaceUnregistered { .. }
15297 | NodeResponse::WorktreeCreated { .. }
15298 | NodeResponse::WorktreeRemoved { .. }
15299 | NodeResponse::Accepted
15300 | NodeResponse::ShuttingDown => {}
15301 }
15302}
15303
15304struct ControllerReleaseGuard {
15305 shared: Arc<NodeShared>,
15306 connection_id: u64,
15307}
15308
15309impl Drop for ControllerReleaseGuard {
15310 fn drop(&mut self) {
15311 self.shared.release_controller(self.connection_id);
15312 }
15313}
15314
15315struct AbortTaskOnDrop(AbortHandle);
15316
15317impl Drop for AbortTaskOnDrop {
15318 fn drop(&mut self) {
15319 self.0.abort();
15320 }
15321}
15322
15323fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15328 std::env::var(name)
15329 .ok()
15330 .and_then(|value| value.parse::<u64>().ok())
15331 .filter(|value| *value > 0)
15332 .unwrap_or(default)
15333 .min(max)
15334}
15335
15336fn workspace_inspection_time_budget_ms() -> u64 {
15337 env_config_bounded(
15338 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15339 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15340 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15341 )
15342}
15343
15344fn workspace_inspection_entry_cap() -> usize {
15345 env_config_bounded(
15346 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15347 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15348 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15349 ) as usize
15350}
15351
15352struct WorkspaceWalkBudget {
15358 deadline: Instant,
15359 entry_cap: usize,
15360 entries_visited: u64,
15361 time_budget_exceeded: bool,
15362 entry_cap_exceeded: bool,
15363}
15364
15365impl WorkspaceWalkBudget {
15366 fn new(deadline: Instant, entry_cap: usize) -> Self {
15367 Self {
15368 deadline,
15369 entry_cap,
15370 entries_visited: 0,
15371 time_budget_exceeded: false,
15372 entry_cap_exceeded: false,
15373 }
15374 }
15375
15376 fn is_exhausted(&self) -> bool {
15377 self.time_budget_exceeded || self.entry_cap_exceeded
15378 }
15379
15380 fn record_visit(&mut self) -> bool {
15383 self.entries_visited += 1;
15384 if self.entries_visited as usize >= self.entry_cap {
15385 self.entry_cap_exceeded = true;
15386 }
15387 if Instant::now() >= self.deadline {
15388 self.time_budget_exceeded = true;
15389 }
15390 self.is_exhausted()
15391 }
15392}
15393
15394fn collect_workspace_entries(
15395 root: &Path,
15396 deadline: Instant,
15397 entry_cap: usize,
15398) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15399 let mut entries = Vec::new();
15400 let mut truncated = false;
15401 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15402 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15403 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15407 (entries, truncated, budget)
15408}
15409
15410fn workspace_inspection_truncation(
15414 walk_budget: &WorkspaceWalkBudget,
15415 git_time_budget_exceeded: bool,
15416 elapsed: Duration,
15417) -> Option<WorkspaceInspectionTruncationV1> {
15418 if !walk_budget.time_budget_exceeded
15419 && !walk_budget.entry_cap_exceeded
15420 && !git_time_budget_exceeded
15421 {
15422 return None;
15423 }
15424 Some(WorkspaceInspectionTruncationV1 {
15425 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15426 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15427 git_time_budget_exceeded,
15428 entries_visited: walk_budget.entries_visited,
15429 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15430 })
15431}
15432
15433fn walk_workspace_directory(
15434 directory: &Path,
15435 relative_directory: Option<&RepositoryPath>,
15436 depth: usize,
15437 entries: &mut Vec<WorkspaceEntry>,
15438 truncated: &mut bool,
15439 budget: &mut WorkspaceWalkBudget,
15440) {
15441 if budget.is_exhausted() {
15442 *truncated = true;
15443 return;
15444 }
15445 let read_dir = match std::fs::read_dir(directory) {
15446 Ok(read_dir) => read_dir,
15447 Err(_) => {
15448 *truncated = true;
15449 return;
15450 }
15451 };
15452 let mut children = Vec::new();
15453 for child in read_dir {
15454 match child {
15455 Ok(child) => children.push(child),
15456 Err(_) => *truncated = true,
15457 }
15458 }
15459 children.sort_by_key(|child| child.file_name());
15460 for child in children {
15461 if budget.record_visit() {
15462 *truncated = true;
15463 return;
15464 }
15465 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15466 *truncated = true;
15467 return;
15468 }
15469 let file_type = match child.file_type() {
15470 Ok(file_type) => file_type,
15471 Err(_) => {
15472 *truncated = true;
15473 continue;
15474 }
15475 };
15476 if file_type.is_symlink() {
15477 continue;
15478 }
15479 let name = match child.file_name().into_string() {
15480 Ok(name) => name,
15481 Err(_) => {
15482 *truncated = true;
15483 continue;
15484 }
15485 };
15486 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15487 continue;
15488 }
15489 let relative_path = match relative_directory {
15490 None => windows_repository_path(name),
15491 Some(parent) => parent
15492 .as_utf8()
15493 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15494 };
15495 let Some(relative_path) = relative_path else {
15496 *truncated = true;
15497 continue;
15498 };
15499 let kind = if file_type.is_dir() {
15500 WorkspaceEntryKind::Directory
15501 } else if file_type.is_file() {
15502 WorkspaceEntryKind::File
15503 } else {
15504 continue;
15505 };
15506 entries.push(WorkspaceEntry {
15507 relative_path: relative_path.clone(),
15508 kind,
15509 });
15510 if file_type.is_dir() {
15511 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15512 walk_workspace_directory(
15513 &child.path(),
15514 Some(&relative_path),
15515 depth + 1,
15516 entries,
15517 truncated,
15518 budget,
15519 );
15520 } else {
15521 *truncated = true;
15522 }
15523 }
15524 }
15525}
15526
15527fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15528 if value.contains('\\') {
15529 return None;
15530 }
15531 RepositoryPath::utf8(value).ok()
15532}
15533
15534fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15535 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15536}
15537
15538fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15547 if name.eq_ignore_ascii_case(".git")
15548 || name.eq_ignore_ascii_case("target")
15549 || name.eq_ignore_ascii_case("node_modules")
15550 {
15551 return true;
15552 }
15553 path.join("CACHEDIR.TAG").is_file()
15554}
15555
15556fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15557 matches!(
15558 status,
15559 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15560 )
15561}
15562
15563#[derive(Clone, Debug, Eq, PartialEq)]
15564enum ContextPackRepositoryHead {
15565 NotRepository,
15566 Unborn,
15567 Commit(String),
15568}
15569
15570#[derive(Clone, Debug, Eq, PartialEq)]
15571struct ContextPackRepositoryObservation {
15572 head: ContextPackRepositoryHead,
15573 git: GitSnapshot,
15574 files: Vec<ContextPackRepositoryFileSource>,
15575}
15576
15577fn stable_context_pack_repository(
15578 first: ContextPackRepositoryObservation,
15579 second: ContextPackRepositoryObservation,
15580) -> Result<ContextPackRepository, NodeFailure> {
15581 if first != second {
15582 return Err(failure(
15583 NodeFailureCode::ContextPackMaterializationFailed,
15584 "context repository changed during bounded capture",
15585 ));
15586 }
15587 Ok(ContextPackRepository::from_sources(
15588 &second.git,
15589 second.files,
15590 ))
15591}
15592
15593fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15600 if Instant::now() < deadline {
15601 return false;
15602 }
15603 append_git_diagnostic(
15604 snapshot,
15605 &format!("{stage} skipped: workspace inspection time budget exhausted"),
15606 );
15607 true
15608}
15609
15610async fn run_git_bounded_with_deadline(
15619 root: &str,
15620 arguments: &[&str],
15621 output_limit: usize,
15622 deadline: Instant,
15623) -> io::Result<GitCommandOutput> {
15624 let remaining = deadline.checked_duration_since(Instant::now());
15625 let timeout_ms = remaining
15626 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15627 .unwrap_or(0)
15628 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15629 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15630}
15631
15632async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15639 let mut snapshot = GitSnapshot {
15640 is_repository: false,
15641 branch: None,
15642 status: Vec::new(),
15643 recent_commits: Vec::new(),
15644 worktrees: Vec::new(),
15645 managed_worktree: None,
15646 truncated: false,
15647 diagnostic: None,
15648 };
15649 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15650 return (snapshot, true);
15651 }
15652 let repository = match run_git_bounded_with_deadline(
15653 root,
15654 &["rev-parse", "--is-inside-work-tree"],
15655 4 * 1_024,
15656 deadline,
15657 )
15658 .await
15659 {
15660 Ok(output) => output,
15661 Err(error) => {
15662 append_git_diagnostic(
15663 &mut snapshot,
15664 &format!("git inspection unavailable: {error}"),
15665 );
15666 return (snapshot, false);
15667 }
15668 };
15669 snapshot.truncated |= repository.truncated;
15670 if repository.timed_out {
15671 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15672 return (snapshot, false);
15673 }
15674 if !repository.success
15675 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15676 {
15677 let stderr = String::from_utf8_lossy(&repository.stderr);
15678 if !stderr.to_ascii_lowercase().contains("not a git repository") {
15679 append_git_diagnostic(&mut snapshot, stderr.trim());
15680 }
15681 return (snapshot, false);
15682 }
15683 snapshot.is_repository = true;
15684
15685 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15686 return (snapshot, true);
15687 }
15688 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15689 Ok(output) => {
15690 snapshot.truncated |= output.truncated;
15691 if output.timed_out {
15692 append_git_diagnostic(&mut snapshot, "git branch query timed out");
15693 } else if output.success {
15694 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15695 if !branch.is_empty() {
15696 snapshot.branch = Some(truncate_git_field(
15697 &branch,
15698 MAX_REPOSITORY_PATH_BYTES,
15699 &mut snapshot.truncated,
15700 ));
15701 }
15702 }
15703 }
15704 Err(error) => append_git_diagnostic(
15705 &mut snapshot,
15706 &format!("git branch query failed: {error}"),
15707 ),
15708 }
15709 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15710 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15711 snapshot.truncated |= output.truncated;
15712 if output.success && !output.timed_out {
15713 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15714 if !head.is_empty() {
15715 snapshot.branch = Some(format!("detached:{head}"));
15716 }
15717 }
15718 }
15719 }
15720
15721 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15722 return (snapshot, true);
15723 }
15724 match run_git_bounded_with_deadline(
15725 root,
15726 &[
15727 "status",
15728 "--porcelain=v1",
15729 "-z",
15730 "--renames",
15731 "--untracked-files=normal",
15732 "--",
15733 ".",
15734 ],
15735 GIT_OUTPUT_MAX_BYTES,
15736 deadline,
15737 )
15738 .await
15739 {
15740 Ok(output) => {
15741 snapshot.truncated |= output.truncated;
15742 if output.timed_out {
15743 append_git_diagnostic(&mut snapshot, "git status timed out");
15744 } else if output.success {
15745 parse_git_status(&output.stdout, &mut snapshot);
15746 } else {
15747 append_git_diagnostic(
15748 &mut snapshot,
15749 String::from_utf8_lossy(&output.stderr).trim(),
15750 );
15751 }
15752 }
15753 Err(error) => append_git_diagnostic(
15754 &mut snapshot,
15755 &format!("git status failed: {error}"),
15756 ),
15757 }
15758
15759 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15760 return (snapshot, true);
15761 }
15762 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15763 match run_git_bounded_with_deadline(
15764 root,
15765 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15766 16 * 1_024,
15767 deadline,
15768 )
15769 .await
15770 {
15771 Ok(output) => {
15772 snapshot.truncated |= output.truncated;
15773 if output.timed_out {
15774 append_git_diagnostic(&mut snapshot, "git log timed out");
15775 } else if output.success {
15776 parse_git_commits(&output.stdout, &mut snapshot);
15777 } else {
15778 let stderr = String::from_utf8_lossy(&output.stderr);
15779 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15780 append_git_diagnostic(&mut snapshot, stderr.trim());
15781 }
15782 }
15783 }
15784 Err(error) => append_git_diagnostic(
15785 &mut snapshot,
15786 &format!("git log failed: {error}"),
15787 ),
15788 }
15789
15790 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15791 return (snapshot, true);
15792 }
15793 match list_git_worktrees_with_deadline(root, deadline).await {
15794 Ok(worktrees) => {
15795 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15796 }
15797 Err(error) => append_git_diagnostic(
15798 &mut snapshot,
15799 &format!("git worktree list failed: {}", error.message),
15800 ),
15801 }
15802 (snapshot, false)
15803}
15804
15805fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15806 let mut remaining = output;
15807 while !remaining.is_empty() {
15808 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15809 snapshot.truncated = true;
15810 break;
15811 }
15812 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15813 snapshot.truncated = true;
15814 break;
15815 };
15816 let record = &remaining[..record_end];
15817 remaining = &remaining[record_end + 1..];
15818 let has_previous_path = record
15819 .get(..2)
15820 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15821 let previous_path_bytes = if has_previous_path {
15822 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15823 snapshot.truncated = true;
15824 break;
15825 };
15826 let previous = &remaining[..previous_end];
15827 remaining = &remaining[previous_end + 1..];
15828 Some(previous)
15829 } else {
15830 None
15831 };
15832 if record.len() < 4
15833 || record[2] != b' '
15834 || !is_git_status_code(record[0])
15835 || !is_git_status_code(record[1])
15836 {
15837 snapshot.truncated = true;
15838 continue;
15839 }
15840 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15841 snapshot.truncated = true;
15842 continue;
15843 };
15844 let previous_path = match previous_path_bytes {
15845 Some(previous) => {
15846 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15847 snapshot.truncated = true;
15848 continue;
15849 };
15850 Some(previous)
15851 }
15852 None => None,
15853 };
15854 snapshot.status.push(GitStatusEntry {
15855 index_status: (record[0] as char).to_string(),
15856 worktree_status: (record[1] as char).to_string(),
15857 path,
15858 previous_path,
15859 });
15860 }
15861}
15862
15863fn is_git_status_code(byte: u8) -> bool {
15864 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15865}
15866
15867fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15868 for line in String::from_utf8_lossy(output).lines() {
15869 let Some((id, summary)) = line.split_once('\u{1f}') else {
15870 continue;
15871 };
15872 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15873 snapshot.truncated = true;
15874 break;
15875 }
15876 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15877 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15878 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15879 }
15880}
15881
15882fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15883 if value.len() <= max_bytes {
15884 return value.to_owned();
15885 }
15886 *truncated = true;
15887 let mut boundary = max_bytes;
15888 while boundary > 0 && !value.is_char_boundary(boundary) {
15889 boundary -= 1;
15890 }
15891 value[..boundary].to_owned()
15892}
15893
15894fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15895 if message.is_empty() {
15896 return;
15897 }
15898 let normalized = message
15899 .chars()
15900 .map(|character| if character.is_control() { ' ' } else { character })
15901 .collect::<String>();
15902 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15903 if !diagnostic.is_empty() {
15904 diagnostic.push_str("; ");
15905 }
15906 diagnostic.push_str(normalized.trim());
15907 snapshot.diagnostic = Some(truncate_git_field(
15908 &diagnostic,
15909 GIT_DIAGNOSTIC_MAX_BYTES,
15910 &mut snapshot.truncated,
15911 ));
15912}
15913
15914async fn run_git_bounded(
15915 root: &str,
15916 arguments: &[&str],
15917 output_limit: usize,
15918) -> io::Result<GitCommandOutput> {
15919 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15920}
15921
15922async fn read_git_history_bounded(
15923 root: &str,
15924 path: Option<&RepositoryPath>,
15925 before: Option<&GitObjectId>,
15926 limit: u16,
15927) -> Result<GitHistoryPage, NodeFailure> {
15928 let path = path
15929 .map(|path| {
15930 path.as_utf8().ok_or_else(|| {
15931 failure(
15932 NodeFailureCode::InvalidRepositoryPath,
15933 "git history path must use UTF-8",
15934 )
15935 })
15936 })
15937 .transpose()?
15938 .unwrap_or(".");
15939 let count = (usize::from(limit) + 1).to_string();
15940 let start = before
15941 .map(|revision| format!("{}^", revision.as_str()))
15942 .unwrap_or_else(|| "HEAD".to_owned());
15943 let arguments = [
15944 "--no-pager",
15945 "log",
15946 "--no-decorate",
15947 "--date=iso-strict",
15948 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15949 "-n",
15950 count.as_str(),
15951 start.as_str(),
15952 "--",
15953 path,
15954 ];
15955 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15956 .await
15957 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15958 if output.timed_out {
15959 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15960 }
15961 if !output.success {
15962 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15963 }
15964 let mut commits = parse_git_history(&output.stdout);
15965 let has_more = commits.len() > usize::from(limit) || output.truncated;
15966 commits.truncate(usize::from(limit));
15967 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15968 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15969}
15970
15971fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15972 String::from_utf8_lossy(output)
15973 .split('\u{1e}')
15974 .filter_map(|record| {
15975 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15976 if fields.len() != 9 {
15977 return None;
15978 }
15979 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15980 let parents = if fields[1].is_empty() {
15981 Vec::new()
15982 } else {
15983 fields[1]
15984 .split(' ')
15985 .map(|value| GitObjectId::new(value.to_owned()))
15986 .collect::<Result<Vec<_>, _>>()
15987 .ok()?
15988 };
15989 Some(GitCommitDetails {
15990 id,
15991 parents,
15992 subject: bounded_git_text(fields[2], 512),
15993 author_name: bounded_git_text(fields[3], 256),
15994 author_email: bounded_git_text(fields[4], 320),
15995 authored_at: bounded_git_text(fields[5], 64),
15996 committer_name: bounded_git_text(fields[6], 256),
15997 committer_email: bounded_git_text(fields[7], 320),
15998 committed_at: bounded_git_text(fields[8], 64),
15999 signature_status: GitSignatureStatus::NoSignature,
16000 signer: None,
16001 })
16002 })
16003 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16004 .collect()
16005}
16006
16007fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16008 let normalized = value
16009 .chars()
16010 .map(|character| if character.is_control() { ' ' } else { character })
16011 .collect::<String>();
16012 if normalized.len() <= max_bytes {
16013 return normalized;
16014 }
16015 let mut boundary = max_bytes;
16016 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16017 boundary -= 1;
16018 }
16019 normalized[..boundary].to_owned()
16020}
16021
16022async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16023 let path = request.path.as_ref().map(|path| {
16024 path.as_utf8()
16025 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16026 }).transpose()?;
16027 let mut arguments = match &request.mode {
16028 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16029 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16030 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16031 };
16032 if let GitDiffMode::Commit { revision } = &request.mode {
16033 arguments.push(revision.as_str());
16034 }
16035 arguments.push("--");
16036 if let Some(path) = path {
16037 arguments.push(path);
16038 }
16039 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16040 .await
16041 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16042 if output.timed_out {
16043 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16044 }
16045 if !output.success {
16046 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16047 }
16048 Ok(GitDiff {
16049 mode: request.mode,
16050 path: request.path,
16051 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16052 truncated: output.truncated,
16053 })
16054}
16055
16056async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16057 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16058 ResponseEnvelope { request_id: envelope.request_id, result }
16059}
16060
16061async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16062 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16063 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16064 }
16065 let read_only = matches!(
16066 &request,
16067 NodeRequest::Snapshot
16068 | NodeRequest::Resync { .. }
16069 | NodeRequest::BrowseHostDirectories { .. }
16070 | NodeRequest::InspectWorkspace { .. }
16071 | NodeRequest::ReadWorkspaceFile { .. }
16072 | NodeRequest::ReadGitHistory { .. }
16073 | NodeRequest::ReadGitDiff { .. }
16074 | NodeRequest::CatalogNativeSessions { .. }
16075 | NodeRequest::PageNativeSessions { .. }
16076 | NodeRequest::PreviewNativeSession { .. }
16077 | NodeRequest::PreviewSessionRecord { .. }
16078 );
16079 let _mutation_guard = if read_only {
16080 None
16081 } else {
16082 let guard = shared.mutation_gate.lock().await;
16083 if shared.shutdown.load(Ordering::Acquire) {
16084 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16085 }
16086 Some(guard)
16087 };
16088 match request {
16089 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16090 event_sequence: shared.current_sequence(),
16091 controller: shared.controller_state(),
16092 snapshot: shared.snapshot(),
16093 }),
16094 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16095 NodeRequest::ArmHarnessMcpReservation {
16096 reservation_id,
16097 activation_digest,
16098 spawn_spec,
16099 launch,
16100 expires_at_unix_ms,
16101 } => {
16102 shared.require_controller(connection_id, role)?;
16103 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16104 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16105 })?;
16106 let expires_at_unix_ms = registry
16107 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16108 .await
16109 .map_err(harness_mcp_failure)?;
16110 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16111 }
16112 NodeRequest::SpawnSpecWithHarnessMcp {
16113 reservation_id,
16114 activation_digest,
16115 spec,
16116 deadline_unix_ms,
16117 } => {
16118 shared.require_controller(connection_id, role)?;
16119 let receipt = shared
16120 .spawn_from_spec_with_harness_mcp(
16121 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16122 )
16123 .await?;
16124 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16125 }
16126 NodeRequest::ActivateHarnessMcpReservation {
16127 reservation_id,
16128 activation_digest,
16129 record_id,
16130 session,
16131 } => {
16132 shared.require_controller(connection_id, role)?;
16133 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16134 current.instance_id == session.session.instance_id
16135 && current.generation == session.session.generation
16136 }).and_then(|current| current.process_id).ok_or_else(|| {
16137 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16138 })?;
16139 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16140 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16141 })?.activate(
16142 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16143 ).map_err(harness_mcp_failure)?;
16144 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16145 }
16146 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16147 shared.require_controller(connection_id, role)?;
16148 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16149 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16150 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16151 if let Some(instance_id) = instance_id {
16152 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16153 }
16154 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16155 }
16156 NodeRequest::PutHarnessMcpReplyChunk {
16157 reservation_id, activation_digest, record_id, session, call_id,
16158 offset, final_chunk, chunk_hex,
16159 } => {
16160 shared.require_controller(connection_id, role)?;
16161 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16162 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16163 })?.put_reply_chunk(
16164 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16165 offset, final_chunk, &chunk_hex,
16166 ).map_err(harness_mcp_failure)?;
16167 Ok(NodeResponse::ReplyChunkAccepted {
16168 reservation_id, activation_digest, record_id, session, call_id,
16169 next_offset, completed,
16170 })
16171 }
16172 NodeRequest::RejectHarnessMcpCall {
16173 reservation_id, activation_digest, record_id, session, call_id, reason,
16174 } => {
16175 shared.require_controller(connection_id, role)?;
16176 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16177 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16178 })?.reject_call(
16179 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16180 ).map_err(harness_mcp_failure)?;
16181 Ok(NodeResponse::CallRejected {
16182 reservation_id, activation_digest, record_id, session, call_id,
16183 })
16184 }
16185 NodeRequest::BeginDeliveryStage { manifest } => {
16186 shared.require_controller(connection_id, role)?;
16187 shared.begin_delivery_stage(manifest)
16188 }
16189 NodeRequest::PutDeliveryBlobChunk {
16190 stage_id,
16191 blob_digest,
16192 offset,
16193 chunk_hex,
16194 } => {
16195 shared.require_controller(connection_id, role)?;
16196 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16197 }
16198 NodeRequest::CommitDeliveryStage { stage_id } => {
16199 shared.require_controller(connection_id, role)?;
16200 let receipt = shared.commit_delivery_stage(stage_id)?;
16201 Ok(NodeResponse::DeliveryCommitted { receipt })
16202 }
16203 NodeRequest::AbortDeliveryStage { stage_id } => {
16204 shared.require_controller(connection_id, role)?;
16205 shared.abort_delivery_stage(&stage_id)?;
16206 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16207 }
16208 NodeRequest::BrowseHostDirectories { directory, after } => {
16209 let listing = shared.browse_host_directories(directory, after).await?;
16210 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16211 }
16212 NodeRequest::InspectWorkspace { workspace_id } => {
16213 let inspection = shared.inspect_workspace(workspace_id).await?;
16214 Ok(NodeResponse::WorkspaceInspected { inspection })
16215 }
16216 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16217 let file = shared.read_workspace_file(workspace_id, path).await?;
16218 Ok(NodeResponse::WorkspaceFileRead { file })
16219 }
16220 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16221 shared.require_controller(connection_id, role)?;
16222 let file = shared
16223 .write_workspace_file(workspace_id, path, expected_revision, text)
16224 .await?;
16225 Ok(NodeResponse::WorkspaceFileWritten { file })
16226 }
16227 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16228 shared.require_controller(connection_id, role)?;
16229 let file = shared.create_workspace_file(workspace_id, path).await?;
16230 Ok(NodeResponse::WorkspaceFileCreated { file })
16231 }
16232 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16233 shared.require_controller(connection_id, role)?;
16234 let entry = shared
16235 .create_workspace_directory(workspace_id.clone(), path)
16236 .await?;
16237 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16238 }
16239 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16240 if role != ClientRole::Operator {
16241 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16242 }
16243 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16244 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16245 }
16246 let page = shared
16247 .read_git_history(workspace_id.clone(), path, before, limit)
16248 .await?;
16249 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16250 }
16251 NodeRequest::ReadGitDiff { workspace_id, request } => {
16252 if role != ClientRole::Operator {
16253 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16254 }
16255 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16256 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16257 }
16258 NodeRequest::CatalogNativeSessions { route, limit } => {
16259 if role != ClientRole::Operator {
16260 return Err(failure(
16261 NodeFailureCode::ObserverReadOnly,
16262 "native session catalog requires an operator connection",
16263 ));
16264 }
16265 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16266 return Err(failure(
16267 NodeFailureCode::InvalidRequest,
16268 "native session catalog limit is invalid",
16269 ));
16270 }
16271 let (entries, summary) = shared
16272 .catalog_native_sessions(route.clone(), limit)
16273 .await?;
16274 Ok(NodeResponse::NativeSessionsCataloged {
16275 route,
16276 entries,
16277 summary: Some(summary),
16278 })
16279 }
16280 NodeRequest::PageNativeSessions {
16281 route,
16282 window,
16283 catalog_revision,
16284 recent_cutoff_unix_ms,
16285 after_selection_id,
16286 limit,
16287 } => {
16288 if role != ClientRole::Operator {
16289 return Err(failure(
16290 NodeFailureCode::ObserverReadOnly,
16291 "native session catalog paging requires an operator connection",
16292 ));
16293 }
16294 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16295 || after_selection_id
16296 .as_deref()
16297 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16298 {
16299 return Err(failure(
16300 NodeFailureCode::InvalidRequest,
16301 "native session catalog page request is invalid",
16302 ));
16303 }
16304 let page = shared
16305 .page_native_sessions(
16306 route.clone(),
16307 window,
16308 catalog_revision,
16309 recent_cutoff_unix_ms,
16310 after_selection_id,
16311 limit,
16312 )
16313 .await?;
16314 Ok(NodeResponse::NativeSessionsPaged {
16315 route,
16316 page,
16317 })
16318 }
16319 NodeRequest::PreviewNativeSession {
16320 selection,
16321 message_limit,
16322 } => {
16323 if role != ClientRole::Operator {
16324 return Err(failure(
16325 NodeFailureCode::ObserverReadOnly,
16326 "native session preview requires an operator connection",
16327 ));
16328 }
16329 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16330 .contains(&message_limit)
16331 || selection.validate().is_err()
16332 {
16333 return Err(failure(
16334 NodeFailureCode::InvalidRequest,
16335 "native session preview request is invalid",
16336 ));
16337 }
16338 let preview = shared
16339 .preview_native_session(selection.clone(), message_limit)
16340 .await?;
16341 Ok(NodeResponse::NativeSessionPreviewed {
16342 selection,
16343 preview,
16344 })
16345 }
16346 NodeRequest::IndexNativeSession {
16347 selection,
16348 display_name,
16349 } => {
16350 shared.require_controller(connection_id, role)?;
16351 let record = shared
16352 .index_native_session(selection.clone(), display_name)
16353 .await?;
16354 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16355 }
16356 NodeRequest::PreviewSessionRecord {
16357 record_id,
16358 message_limit,
16359 } => {
16360 if role != ClientRole::Operator {
16361 return Err(failure(
16362 NodeFailureCode::ObserverReadOnly,
16363 "native session preview requires an operator connection",
16364 ));
16365 }
16366 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16367 .contains(&message_limit)
16368 {
16369 return Err(failure(
16370 NodeFailureCode::InvalidRequest,
16371 "native session preview request is invalid",
16372 ));
16373 }
16374 let record = shared.record(&record_id)?;
16375 let working_directory = shared.workspace_root(&record.workspace_id)?;
16376 if !platform::roots_equal(
16377 &working_directory,
16378 windows_path_text(&record.canonical_root),
16379 ) {
16380 return Err(failure(
16381 NodeFailureCode::SessionRecordConflict,
16382 "managed session workspace root changed; refusing to preview another directory",
16383 ));
16384 }
16385 let identity = record.provider_session.clone().ok_or_else(|| {
16386 failure(
16387 NodeFailureCode::BackendOperationFailed,
16388 "native session preview is unavailable for this session record",
16389 )
16390 })?;
16391 if identity.key != ProviderSessionKey::SessionId {
16392 return Err(failure(
16393 NodeFailureCode::BackendOperationFailed,
16394 "native session preview is unavailable for this session identity",
16395 ));
16396 }
16397 let preview = shared
16398 .preview_native_session_by_session_id(
16399 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16400 )
16401 .await?;
16402 if preview.session_id != identity.id {
16403 return Err(failure(
16404 NodeFailureCode::SessionRecordConflict,
16405 "native session preview identity changed while loading",
16406 ));
16407 }
16408 shared.revalidate_session_record_preview(&record, &identity)?;
16409 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16410 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16411 record_id: record_id.clone(),
16412 summary: SessionHistorySummaryV1::from(&preview),
16413 });
16414 Ok(NodeResponse::SessionRecordPreviewed {
16415 record_id,
16416 preview,
16417 })
16418 }
16419 NodeRequest::AcquireController { lease_ms } => {
16420 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16421 Ok(NodeResponse::Controller { controller: Some(controller) })
16422 }
16423 NodeRequest::ReleaseController => {
16424 shared.release_controller(connection_id);
16425 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16426 }
16427 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16428 shared.require_controller(connection_id, role)?;
16429 let root = require_windows_path(root)?;
16430 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16431 let workspace = shared.register_workspace(workspace_id, root).await?;
16432 Ok(NodeResponse::WorkspaceRegistered { workspace })
16433 }
16434 NodeRequest::CreateStandaloneWorkspace {
16435 workspace_id,
16436 root,
16437 initial_branch,
16438 } => {
16439 shared.require_controller(connection_id, role)?;
16440 let root = require_windows_path(root)?;
16441 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16442 let workspace = shared
16443 .create_standalone_workspace(workspace_id, root, initial_branch)
16444 .await?;
16445 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16446 }
16447 NodeRequest::UnregisterWorkspace { workspace_id } => {
16448 shared.require_controller(connection_id, role)?;
16449 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16450 shared.unregister_workspace(&workspace_id)?;
16451 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16452 }
16453 NodeRequest::CreateWorktree {
16454 source_workspace_id,
16455 workspace_id,
16456 target_root,
16457 branch,
16458 base,
16459 } => {
16460 shared.require_controller(connection_id, role)?;
16461 let target_root = require_windows_path(target_root)?;
16462 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16463 let (worktree, workspace) = shared
16464 .create_worktree(
16465 source_workspace_id,
16466 workspace_id,
16467 target_root,
16468 branch,
16469 base,
16470 )
16471 .await?;
16472 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16473 }
16474 NodeRequest::RemoveWorktree {
16475 source_workspace_id,
16476 target_root,
16477 } => {
16478 shared.require_controller(connection_id, role)?;
16479 let native_target_root = require_windows_path(target_root.clone())?;
16480 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16481 let workspace_id = shared
16482 .remove_worktree(source_workspace_id, native_target_root)
16483 .await?;
16484 Ok(NodeResponse::WorktreeRemoved {
16485 target_root,
16486 workspace_id,
16487 })
16488 }
16489 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16490 shared.require_controller(connection_id, role)?;
16491 if !terminal_size.is_valid() {
16492 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16493 }
16494 if let Some(prompt) = initial_prompt.as_deref() {
16495 validate_node_text("spawn initial prompt", prompt)?;
16496 }
16497 match shared
16498 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16499 .await
16500 {
16501 Ok(session) => {
16502 shared.mark_spawn_provisional(session.clone());
16503 Ok(NodeResponse::SpawnAccepted { session })
16504 }
16505 Err(err) => {
16506 tracing::warn!(
16507 %provider,
16508 %workspace_id,
16509 ?mode,
16510 code = ?err.code,
16511 message = %err.message,
16512 "session spawn refused"
16513 );
16514 Err(err)
16515 }
16516 }
16517 }
16518 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16519 shared.require_controller(connection_id, role)?;
16520 if !terminal_size.is_valid() {
16521 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16522 }
16523 if let Some(prompt) = initial_prompt.as_deref() {
16524 validate_node_text("spawn initial prompt", prompt)?;
16525 }
16526 let plan = backend_paths::plan_launch_options(
16527 provider.as_str(),
16528 mode,
16529 options.model.is_some(),
16530 options.effort.is_some(),
16531 )
16532 .map_err(|r| {
16533 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16534 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16535 })?;
16536 let selection = spawn_selection(&options)?;
16537 let pty_selection = match plan {
16538 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16539 _ => None,
16540 };
16541 let spawned = SPAWN_SESSION_OPTIONS
16542 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16543 .await;
16544 match spawned {
16545 Ok(session) => {
16546 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16547 shared
16548 .pending_acp_options
16549 .lock()
16550 .unwrap_or_else(|p| p.into_inner())
16551 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16552 }
16553 shared.mark_spawn_provisional(session.clone());
16554 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16555 Ok(NodeResponse::SpawnAccepted { session })
16556 }
16557 Err(err) => {
16558 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16559 Err(err)
16560 }
16561 }
16562 }
16563 NodeRequest::SpawnSpec { spec } => {
16564 shared.require_controller(connection_id, role)?;
16565 let receipt = shared.spawn_from_spec(spec).await?;
16566 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16567 }
16568 NodeRequest::SpawnManagedWorktree { request } => {
16569 shared.require_controller(connection_id, role)?;
16570 let receipt = shared.spawn_managed_worktree(request).await?;
16571 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16572 }
16573 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16574 shared.require_controller(connection_id, role)?;
16575 let receipt = shared.spawn_managed_worktree_v2(request).await?;
16576 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16577 }
16578 NodeRequest::CleanupManagedWorktree { lease_id } => {
16579 shared.require_controller(connection_id, role)?;
16580 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16581 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16582 }
16583 NodeRequest::DiscoverHistory { session, limit } => {
16584 controlled_session(shared, connection_id, role, &session)?;
16585 let candidates = shared.discover_history(&session, limit).await?;
16586 Ok(NodeResponse::HistoryDiscovered {
16587 session,
16588 candidates,
16589 })
16590 }
16591 NodeRequest::LoadHistory {
16592 session,
16593 candidate_id,
16594 } => {
16595 controlled_session(shared, connection_id, role, &session)?;
16596 let loaded = shared
16597 .load_history(&session, candidate_id)
16598 .await?;
16599 Ok(NodeResponse::HistoryLoaded {
16600 session,
16601 session_id: loaded.session_id,
16602 message_count: loaded.message_count,
16603 completed_turn_count: loaded.completed_turn_count,
16604 })
16605 }
16606 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16607 shared.require_controller(connection_id, role)?;
16608 let context = shared
16609 .export_context_pack_for_session_record(&record_id, &session, false)
16610 .await?;
16611 Ok(NodeResponse::ContextPackForSessionRecordExported {
16612 record_id,
16613 session,
16614 context,
16615 })
16616 }
16617 NodeRequest::ExportContextPack { session } => {
16618 controlled_session(shared, connection_id, role, &session)?;
16619 let context = shared.export_context_pack(&session).await?;
16620 Ok(NodeResponse::ContextPackExported { context })
16621 }
16622 NodeRequest::ForgetContextPack { context_id } => {
16623 shared.require_controller(connection_id, role)?;
16624 shared.forget_context_pack(&context_id)?;
16625 Ok(NodeResponse::ContextPackForgotten { context_id })
16626 }
16627 NodeRequest::ResolveDurableContextPack { context_id } => {
16628 let context = shared
16632 .context_catalog
16633 .read()
16634 .unwrap_or_else(|poisoned| poisoned.into_inner())
16635 .get(&context_id)
16636 .map(|pack| pack.receipt().clone())
16637 .ok_or_else(|| {
16638 failure(
16639 NodeFailureCode::UnknownContextPack,
16640 "context pack is not present",
16641 )
16642 })?;
16643 Ok(NodeResponse::DurableContextPackResolved { context })
16644 }
16645 NodeRequest::ReadContextPack { digest } => {
16646 let (id, byte_len, bytes) = shared
16651 .context_catalog
16652 .read()
16653 .unwrap_or_else(|poisoned| poisoned.into_inner())
16654 .get_by_digest(&digest)
16655 .map(|pack| {
16656 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16657 })
16658 .ok_or_else(|| {
16659 failure(
16660 NodeFailureCode::UnknownContextPack,
16661 &format!("context pack {digest} not on this node"),
16662 )
16663 })?;
16664 tracing::debug!(
16665 digest = digest.as_str(),
16666 byte_len,
16667 "read context pack bytes",
16668 );
16669 Ok(NodeResponse::ContextPackBytesRead {
16670 pack: ContextPackBytesRead {
16671 digest,
16672 id,
16673 byte_len,
16674 bytes,
16675 },
16676 })
16677 }
16678 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16679 let provider = controlled_session(shared, connection_id, role, &session)?;
16680 if let Some(prompt) = initial_prompt.as_deref() {
16681 validate_node_text("resume initial prompt", prompt)?;
16682 }
16683 let runtime_requirement = if initial_prompt.is_some() {
16684 ProviderRuntimeRequirement::ResumeWithPrompt
16685 } else {
16686 ProviderRuntimeRequirement::Resume
16687 };
16688 shared.require_session_runtime_policy(&session, runtime_requirement)?;
16689 let runtime_policy = shared
16690 .admit_provider_runtime(&provider, runtime_requirement)
16691 .await?;
16692 let working_directory = shared.bound_workspace_root(&session)?;
16693 let request = ResumeLaunchRequest {
16694 working_directory,
16695 terminal_size,
16696 initial_prompt,
16697 };
16698 request
16699 .validate()
16700 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16701 let command = shared.prepare_command(ControlCommand::Resume {
16702 instance_id: session.session.instance_id,
16703 target: ResumeTarget::CurrentProvider,
16704 runtime_policy,
16705 request,
16706 });
16707 shared.arm_resume(&session, command.id, runtime_policy)?;
16708 let dispatch = shared.dispatch_envelope(command);
16709 if let Err(error) = dispatch {
16710 shared.clear_armed_resume(&session);
16711 return Err(error);
16712 }
16713 Ok(NodeResponse::Accepted)
16714 }
16715 NodeRequest::RenameSessionRecord {
16716 record_id,
16717 display_name,
16718 } => {
16719 shared.require_controller(connection_id, role)?;
16720 let record = shared.rename_session_record(&record_id, display_name)?;
16721 Ok(NodeResponse::SessionRecordUpdated { record })
16722 }
16723 NodeRequest::SetSessionTask {
16724 record_id,
16725 expected_revision,
16726 target,
16727 } => {
16728 shared.require_controller(connection_id, role)?;
16729 let record = shared.set_session_task(&record_id, expected_revision, target)?;
16730 Ok(NodeResponse::SessionRecordUpdated { record })
16731 }
16732 NodeRequest::IndexProviderSession {
16733 workspace_id,
16734 provider,
16735 identity,
16736 display_name,
16737 } => {
16738 shared.require_controller(connection_id, role)?;
16739 let record = shared.index_provider_session(
16740 workspace_id,
16741 provider,
16742 identity,
16743 display_name,
16744 )?;
16745 Ok(NodeResponse::ProviderSessionIndexed { record })
16746 }
16747 NodeRequest::ResumeSessionRecord {
16748 record_id,
16749 terminal_size,
16750 initial_prompt,
16751 } => {
16752 shared.require_controller(connection_id, role)?;
16753 if let Some(prompt) = initial_prompt.as_deref() {
16754 validate_node_text("managed resume initial prompt", prompt)?;
16755 }
16756 let (record, session) = shared
16757 .resume_session_record(
16758 &record_id,
16759 terminal_size,
16760 initial_prompt,
16761 )
16762 .await?;
16763 Ok(NodeResponse::SessionRecordResumed { record, session })
16764 }
16765 NodeRequest::ForgetSessionRecord { record_id } => {
16766 shared.require_controller(connection_id, role)?;
16767 shared.forget_session_record(&record_id).await?;
16768 Ok(NodeResponse::SessionRecordForgotten { record_id })
16769 }
16770 NodeRequest::Prompt { session, text } => {
16771 let agent_id = controlled_session(shared, connection_id, role, &session)?;
16772 validate_node_text("prompt", &text)?;
16773 shared.require_session_runtime_policy(
16774 &session,
16775 ProviderRuntimeRequirement::SemanticPrompt,
16776 )?;
16777 let framing = prompt_framing(&agent_id);
16778 shared
16779 .dispatch_input_bounded(
16780 &session,
16781 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16782 )
16783 .await?;
16784 Ok(NodeResponse::Accepted)
16785 }
16786 NodeRequest::Paste { session, text } => {
16787 controlled_session(shared, connection_id, role, &session)?;
16788 validate_node_text("paste", &text)?;
16789 shared.require_session_runtime_policy(
16790 &session,
16791 ProviderRuntimeRequirement::SemanticPrompt,
16792 )?;
16793 shared
16794 .dispatch_input_bounded(
16795 &session,
16796 InputAction::InsertDraft(PromptPayload {
16797 text,
16798 framing: PromptFraming::BracketedPaste,
16799 }),
16800 )
16801 .await?;
16802 Ok(NodeResponse::Accepted)
16803 }
16804 NodeRequest::Input { session, text } => {
16805 controlled_session(shared, connection_id, role, &session)?;
16806 validate_node_text("terminal input", &text)?;
16807 shared
16808 .dispatch_input_bounded(
16809 &session,
16810 InputAction::TerminalText(TerminalText { text }),
16811 )
16812 .await?;
16813 Ok(NodeResponse::Accepted)
16814 }
16815 NodeRequest::TerminalBytes { session, bytes } => {
16816 controlled_session(shared, connection_id, role, &session)?;
16817 shared.sessionize_spawn(session.session.instance_id);
16818 let action = terminal_bytes_action(bytes)?;
16819 shared
16820 .dispatch_input_bounded(&session, action)
16821 .await?;
16822 Ok(NodeResponse::Accepted)
16823 }
16824 NodeRequest::TerminalControl { session, control } => {
16825 controlled_session(shared, connection_id, role, &session)?;
16826 shared
16827 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16828 .await?;
16829 Ok(NodeResponse::Accepted)
16830 }
16831 NodeRequest::Resize { session, size } => {
16832 controlled_session(shared, connection_id, role, &session)?;
16833 shared.sessionize_spawn(session.session.instance_id);
16834 if !size.is_valid() {
16835 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16836 }
16837 shared.dispatch_resize_bounded(&session, size).await?;
16838 Ok(NodeResponse::Accepted)
16839 }
16840 NodeRequest::Interrupt { session } => {
16841 controlled_session(shared, connection_id, role, &session)?;
16842 shared
16843 .dispatch_input_bounded(
16844 &session,
16845 InputAction::TerminalControl(TerminalControl::Interrupt),
16846 )
16847 .await?;
16848 Ok(NodeResponse::Accepted)
16849 }
16850 NodeRequest::Stop { session, force } => {
16851 controlled_session(shared, connection_id, role, &session)?;
16852 shared.sessionize_spawn(session.session.instance_id);
16853 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16854 shared.retire_session_grok_leader(session.session.instance_id);
16855 Ok(NodeResponse::Accepted)
16856 }
16857 NodeRequest::Remove { session } => {
16858 controlled_session(shared, connection_id, role, &session)?;
16859 shared.remove_session(&session).await?;
16860 Ok(NodeResponse::Accepted)
16861 }
16862 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16863 shared.require_controller(connection_id, role)?;
16864 let snapshot = shared.require_acp_session(&session)?;
16865 let interaction = snapshot
16866 .provider
16867 .interactions
16868 .iter()
16869 .find(|interaction| {
16870 interaction_correlation(
16871 session.session.instance_id,
16872 session.session.generation,
16873 interaction.id.0,
16874 ) == correlation_id
16875 })
16876 .ok_or_else(|| failure(
16877 NodeFailureCode::InvalidRequest,
16878 "unknown interaction correlation",
16879 ))?;
16880 if interaction.status != ProviderInteractionStatus::Pending {
16881 return Err(failure(
16882 NodeFailureCode::BackendOperationFailed,
16883 "interaction has already been resolved",
16884 ));
16885 }
16886 response
16887 .validate_for(interaction.interaction_kind)
16888 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16889 let interaction_id = interaction.id;
16890 let response_kind = response.kind();
16891 let response_outcome = response.outcome();
16892 shared.dispatch(ControlCommand::ResolveInteraction {
16893 instance_id: session.session.instance_id,
16894 generation: session.session.generation,
16895 interaction_id,
16896 response,
16897 })?;
16898 tracing::info!(
16904 node_id = %shared.node_id,
16905 session = ?session.session,
16906 correlation_id = %correlation_id,
16907 interaction_id = interaction_id.0,
16908 response_kind = ?response_kind,
16909 outcome = ?response_outcome,
16910 "interaction resolved",
16911 );
16912 Ok(NodeResponse::Accepted)
16913 }
16914 NodeRequest::SetSessionMode { session, mode_id } => {
16915 shared.require_controller(connection_id, role)?;
16916 shared.require_acp_session(&session)?;
16917 shared.dispatch(ControlCommand::SetSessionMode {
16918 instance_id: session.session.instance_id,
16919 mode_id,
16920 })?;
16921 Ok(NodeResponse::Accepted)
16922 }
16923 NodeRequest::SetSessionConfigOption {
16924 session,
16925 option_id,
16926 value_json,
16927 } => {
16928 shared.require_controller(connection_id, role)?;
16929 shared.require_acp_session(&session)?;
16930 shared.dispatch(ControlCommand::SetSessionConfigOption {
16931 instance_id: session.session.instance_id,
16932 option_id,
16933 value_json,
16934 })?;
16935 Ok(NodeResponse::Accepted)
16936 }
16937 NodeRequest::SetSessionModel { session, model_id } => {
16938 shared.require_controller(connection_id, role)?;
16939 shared.require_acp_session(&session)?;
16940 shared.dispatch(ControlCommand::SetSessionModel {
16941 instance_id: session.session.instance_id,
16942 model_id,
16943 })?;
16944 Ok(NodeResponse::Accepted)
16945 }
16946 NodeRequest::Shutdown => {
16947 shared.require_controller(connection_id, role)?;
16948 shared.begin_shutdown_locked().await?;
16949 Ok(NodeResponse::ShuttingDown)
16950 }
16951 }
16952}
16953
16954fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16955 shared.require_controller(connection_id, role)?;
16956 shared.validate_address(session)
16957}
16958
16959fn select_history_candidate(
16965 candidates: &[HistoryCandidateSummary],
16966 provider_session_id: Option<&str>,
16967) -> Result<String, NodeFailure> {
16968 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16969 Some(id) => candidate.session_id_hint == id,
16970 None => true,
16971 });
16972 let candidate = matches.next().ok_or_else(|| {
16973 failure(
16974 NodeFailureCode::SessionRecordConflict,
16975 if provider_session_id.is_some() {
16976 "no history candidate matches the managed provider session"
16977 } else {
16978 "no history candidate exists for the managed session"
16979 },
16980 )
16981 })?;
16982 if matches.next().is_some() {
16983 return Err(failure(
16984 NodeFailureCode::SessionRecordConflict,
16985 if provider_session_id.is_some() {
16986 "history identity is ambiguous for the managed provider session"
16987 } else {
16988 "history is ambiguous without a verified provider session identity"
16989 },
16990 ));
16991 }
16992 Ok(candidate.id.clone())
16993}
16994
16995fn resolved_spawn_profile_summary(
16996 profile: &SpawnProfileDefaults,
16997 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16998) -> Option<SpawnProfileSummary> {
16999 let environment_profile = match profile.environment_profile_id.as_ref() {
17000 None => None,
17001 Some(profile_id) => {
17002 let binding = environment_profiles.get(profile_id)?;
17003 if binding.provider != profile.provider
17004 || binding.native_profile_id(profile.mode).is_none()
17005 {
17006 return None;
17007 }
17008 Some(ResolvedEnvironmentProfileReceipt {
17009 profile_id: binding.id.clone(),
17010 profile_revision: binding.revision.clone(),
17011 network_allowlist: None,
17012 browser_profile_id: None,
17013 })
17014 }
17015 };
17016 Some(SpawnProfileSummary {
17017 id: profile.profile_id.clone(),
17018 revision: profile.revision.clone(),
17019 environment_profile,
17020 })
17021}
17022
17023fn session_record_export_target_matches(
17042 expected: &ManagedSessionRecord,
17043 current: &ManagedSessionRecord,
17044 session: &SessionAddress,
17045 allow_clean_detachment: bool,
17046) -> bool {
17047 if current == expected && current.active_session.as_ref() == Some(session) {
17048 return current.state == ManagedSessionState::Live;
17049 }
17050 allow_clean_detachment
17051 && current.state == ManagedSessionState::Dormant
17052 && current.active_session.is_none()
17053 && current.record_id == expected.record_id
17054 && current.display_name == expected.display_name
17055 && current.provider == expected.provider
17056 && current.mode == expected.mode
17057 && current.workspace_id == expected.workspace_id
17058 && current.canonical_root == expected.canonical_root
17059 && current.provider_session == expected.provider_session
17060 && current.environment_profile == expected.environment_profile
17061 && current.bundle == expected.bundle
17062 && current.context_id == expected.context_id
17063 && current.context == expected.context
17064 && current.exported_context == expected.exported_context
17065 && current.task_binding == expected.task_binding
17066 && current.created_at_unix_ms == expected.created_at_unix_ms
17067 && current.last_error == expected.last_error
17068}
17069
17070fn session_record_context_export_binding_is_exact(
17071 expected: &ManagedSessionRecord,
17072 current: &ManagedSessionRecord,
17073 identity: Option<&ProviderSessionIdentity>,
17074 session: &SessionAddress,
17075 binding: &SessionBinding,
17076 provider: &AgentId,
17077 current_root: &str,
17078 allow_clean_detachment: bool,
17079) -> bool {
17080 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17081 && current.provider_session.as_ref() == identity
17082 && current.workspace_id == session.workspace_id
17083 && provider == ¤t.provider
17084 && binding.workspace_id == session.workspace_id
17085 && binding.generation == session.session.generation
17086 && binding.record_id.as_ref() == Some(¤t.record_id)
17087 && platform::roots_equal(
17088 current_root,
17089 windows_path_text(¤t.canonical_root),
17090 )
17091}
17092
17093fn session_record_context_export_source_is_usable(
17094 record: &ManagedSessionRecord,
17095 status: &SessionStatus,
17096 allow_clean_detachment: bool,
17097) -> bool {
17098 (record.state == ManagedSessionState::Live
17099 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17100 && context_pack_source_status_is_usable(status)
17101}
17102
17103fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17104 PromptFraming::BracketedPaste
17105}
17106
17107fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17108 if text.len() > MAX_NODE_TEXT_BYTES {
17109 return Err(failure(
17110 NodeFailureCode::InvalidRequest,
17111 &format!(
17112 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17113 text.len(),
17114 ),
17115 ));
17116 }
17117 Ok(())
17118}
17119
17120fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17121 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17122 return Err(failure(
17123 NodeFailureCode::InvalidRequest,
17124 &format!(
17125 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17126 bytes.len(),
17127 ),
17128 ));
17129 }
17130 Ok(())
17131}
17132
17133fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17134 validate_terminal_bytes(&bytes)?;
17135 Ok(InputAction::TerminalBytes(bytes))
17136}
17137
17138fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17139 if root.is_empty()
17140 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17141 || root.chars().any(char::is_control)
17142 {
17143 return Err(failure(
17144 NodeFailureCode::InvalidWorkspaceRoot,
17145 &format!(
17146 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17147 ),
17148 ));
17149 }
17150 Ok(())
17151}
17152
17153fn canonical_standalone_workspace_candidate(
17154 workspace_id: &WorkspaceId,
17155 root: &str,
17156) -> Result<String, NodeServerError> {
17157 let path = Path::new(root);
17158 match std::fs::symlink_metadata(path) {
17159 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17160 .map(|workspace| workspace.canonical_root),
17161 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17162 if !path.is_absolute() {
17163 return Err(NodeServerError::InvalidWorkspaceRoot {
17164 workspace_id: workspace_id.clone(),
17165 path: root.to_owned(),
17166 message: "path is not absolute".to_owned(),
17167 });
17168 }
17169 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17170 workspace_id: workspace_id.clone(),
17171 path: root.to_owned(),
17172 message: "path has no standalone directory name".to_owned(),
17173 })?;
17174 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17175 workspace_id: workspace_id.clone(),
17176 path: root.to_owned(),
17177 message: "path has no parent directory".to_owned(),
17178 })?;
17179 let canonical = std::fs::canonicalize(parent)
17180 .map(|parent| parent.join(name))
17181 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17182 workspace_id: workspace_id.clone(),
17183 path: root.to_owned(),
17184 message: error.to_string(),
17185 })?;
17186 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17187 NodeServerError::InvalidWorkspaceRoot {
17188 workspace_id: workspace_id.clone(),
17189 path: path.to_string_lossy().into_owned(),
17190 message: "canonical path is not valid Unicode".to_owned(),
17191 }
17192 })?;
17193 let canonical_root = platform::normalize_canonical_root(canonical_root);
17194 validate_workspace_root(workspace_id, &canonical_root)?;
17195 Ok(canonical_root)
17196 }
17197 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17198 workspace_id: workspace_id.clone(),
17199 path: root.to_owned(),
17200 message: error.to_string(),
17201 }),
17202 }
17203}
17204
17205fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17206 let Some(revision) = revision else {
17207 return Ok(());
17208 };
17209 if revision.is_empty()
17210 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17211 || revision.chars().any(char::is_control)
17212 {
17213 return Err(failure(
17214 NodeFailureCode::InvalidRequest,
17215 &format!(
17216 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17217 ),
17218 ));
17219 }
17220 Ok(())
17221}
17222
17223fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17224 let code = match error.kind {
17225 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17226 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17227 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17228 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17229 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17230 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17231 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17232 };
17233 failure(code, &error.message)
17234}
17235
17236fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17237 let code = match error.kind {
17238 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17239 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17240 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17241 NodeFailureCode::BackendOperationFailed
17242 }
17243 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17244 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17245 }
17246 };
17247 failure(code, error.message)
17248}
17249
17250fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17251 let (code, message) = match error.kind {
17252 GitWorktreeErrorKind::Invalid => (
17253 NodeFailureCode::InvalidRequest,
17254 "managed Git input is invalid",
17255 ),
17256 GitWorktreeErrorKind::NotRepository => (
17257 NodeFailureCode::NotGitRepository,
17258 "managed source is not a Git repository",
17259 ),
17260 GitWorktreeErrorKind::Conflict => (
17261 NodeFailureCode::WorktreeConflict,
17262 "managed Git identity conflicts with existing repository state",
17263 ),
17264 GitWorktreeErrorKind::Protected => (
17265 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17266 "managed worktree ownership validation failed",
17267 ),
17268 GitWorktreeErrorKind::Dirty => (
17269 NodeFailureCode::WorktreeDirty,
17270 "managed worktree contains uncommitted changes",
17271 ),
17272 GitWorktreeErrorKind::Locked => (
17273 NodeFailureCode::WorktreeLocked,
17274 "managed worktree is locked",
17275 ),
17276 GitWorktreeErrorKind::Failed => (
17277 NodeFailureCode::BackendOperationFailed,
17278 "managed Git operation failed",
17279 ),
17280 };
17281 NodeFailure { code, message: message.to_owned() }
17282}
17283
17284fn managed_spawn_receipt(
17285 resolved: &ResolvedSpawnSpec,
17286 incarnation_id: NodeIncarnationId,
17287 session: SessionAddress,
17288 lease: ManagedWorktreeLeaseSnapshot,
17289 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17290 bundle: Option<ResolvedBundleReceipt>,
17291 context: Option<ResolvedContextPackReceipt>,
17292) -> ManagedWorktreeSpawnReceipt {
17293 let mut managed = resolved.clone();
17294 managed.target.worktree_id = Some(lease.workspace_id.clone());
17295 ManagedWorktreeSpawnReceipt {
17296 spawn: managed.receipt_with_materialization(
17297 incarnation_id,
17298 session,
17299 environment_profile,
17300 bundle,
17301 context,
17302 ),
17303 lease,
17304 }
17305}
17306
17307fn validate_selected_worktree(
17308 source_root: &str,
17309 selected_root: &str,
17310 worktrees: &[NativeGitWorktreeSnapshot],
17311) -> Result<(), NodeFailure> {
17312 if !worktrees
17313 .iter()
17314 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17315 {
17316 return Err(failure(
17317 NodeFailureCode::WorktreeProtected,
17318 "source workspace is not an authoritative non-bare Git worktree root",
17319 ));
17320 }
17321 if !worktrees.iter().any(|worktree| {
17322 worktree_paths_equal(&worktree.path, selected_root)
17323 && !worktree.is_main
17324 && !worktree.is_bare
17325 }) {
17326 return Err(failure(
17327 NodeFailureCode::WorktreeProtected,
17328 "selected workspace is not an eligible Git worktree in the source repository",
17329 ));
17330 }
17331 Ok(())
17332}
17333
17334fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17335 let code = match error.kind() {
17336 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17337 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17338 NodeFailureCode::RepositoryPathUnsafe
17339 }
17340 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17341 WorkspaceFileReadErrorKind::NotRegularFile => {
17342 NodeFailureCode::RepositoryFileNotRegular
17343 }
17344 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17345 NodeFailureCode::RepositoryFileReadFailed
17346 }
17347 WorkspaceFileReadErrorKind::RevisionConflict => {
17348 NodeFailureCode::RepositoryFileRevisionConflict
17349 }
17350 WorkspaceFileReadErrorKind::AlreadyExists
17351 | WorkspaceFileReadErrorKind::ParentNotFound
17352 | WorkspaceFileReadErrorKind::ParentNotDirectory
17353 | WorkspaceFileReadErrorKind::Canceled => {
17354 NodeFailureCode::RepositoryFileReadFailed
17355 }
17356 };
17357 failure(code, "repository file read failed")
17358}
17359
17360fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17361 let code = match error.kind() {
17362 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17363 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17364 NodeFailureCode::RepositoryPathUnsafe
17365 }
17366 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17367 WorkspaceFileReadErrorKind::NotRegularFile => {
17368 NodeFailureCode::RepositoryFileNotRegular
17369 }
17370 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17371 NodeFailureCode::RepositoryFileWriteFailed
17372 }
17373 WorkspaceFileReadErrorKind::RevisionConflict => {
17374 NodeFailureCode::RepositoryFileRevisionConflict
17375 }
17376 WorkspaceFileReadErrorKind::AlreadyExists
17377 | WorkspaceFileReadErrorKind::ParentNotFound
17378 | WorkspaceFileReadErrorKind::ParentNotDirectory
17379 | WorkspaceFileReadErrorKind::Canceled => {
17380 NodeFailureCode::RepositoryFileWriteFailed
17381 }
17382 };
17383 failure(code, "repository file write failed")
17384}
17385
17386async fn settle_workspace_entry_create<T>(
17387 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17388 commit_state: Arc<AtomicU8>,
17389 deadline: Duration,
17390 timeout_message: &'static str,
17391 task_failure_message: &'static str,
17392) -> Result<T, NodeFailure> {
17393 let joined = match timeout(deadline, &mut task).await {
17394 Ok(joined) => joined,
17395 Err(_) => {
17396 if commit_state
17397 .compare_exchange(
17398 WORKSPACE_ENTRY_CREATE_PENDING,
17399 WORKSPACE_ENTRY_CREATE_CANCELED,
17400 Ordering::AcqRel,
17401 Ordering::Acquire,
17402 )
17403 .is_ok()
17404 {
17405 return Err(failure(
17406 NodeFailureCode::RepositoryEntryCreateTimedOut,
17407 timeout_message,
17408 ));
17409 }
17410 debug_assert_eq!(
17411 commit_state.load(Ordering::Acquire),
17412 WORKSPACE_ENTRY_CREATE_COMMITTING,
17413 );
17414 task.await
17415 }
17416 };
17417 joined
17418 .map_err(|_| {
17419 failure(
17420 NodeFailureCode::RepositoryEntryCreateFailed,
17421 task_failure_message,
17422 )
17423 })?
17424 .map_err(workspace_entry_create_failure)
17425}
17426
17427fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17428 let code = match error.kind() {
17429 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17430 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17431 NodeFailureCode::RepositoryPathUnsafe
17432 }
17433 WorkspaceFileReadErrorKind::AlreadyExists => {
17434 NodeFailureCode::RepositoryEntryAlreadyExists
17435 }
17436 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17437 NodeFailureCode::RepositoryParentNotFound
17438 }
17439 WorkspaceFileReadErrorKind::ParentNotDirectory
17440 | WorkspaceFileReadErrorKind::NotRegularFile => {
17441 NodeFailureCode::RepositoryParentNotDirectory
17442 }
17443 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17444 NodeFailureCode::RepositoryEntryCreateFailed
17445 }
17446 WorkspaceFileReadErrorKind::RevisionConflict => {
17447 NodeFailureCode::RepositoryEntryCreateFailed
17448 }
17449 WorkspaceFileReadErrorKind::Canceled => {
17450 NodeFailureCode::RepositoryEntryCreateTimedOut
17451 }
17452 };
17453 failure(code, "repository entry create failed")
17454}
17455
17456fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17457 let value = digest(&SHA256, bytes)
17458 .as_ref()
17459 .iter()
17460 .map(|byte| format!("{byte:02x}"))
17461 .collect();
17462 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17463}
17464
17465fn managed_spawn_request_digest_v2(
17466 request: &ManagedWorktreeSpawnRequestV2,
17467) -> Result<String, NodeFailure> {
17468 let encoded = serde_json::to_vec(request).map_err(|_| {
17469 failure(
17470 NodeFailureCode::InvalidRequest,
17471 "managed worktree V2 request could not be canonicalized",
17472 )
17473 })?;
17474 let mut value = String::with_capacity(71);
17475 value.push_str("sha256:");
17476 for byte in digest(&SHA256, &encoded).as_ref() {
17477 use std::fmt::Write as _;
17478 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17479 }
17480 Ok(value)
17481}
17482
17483fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17484 let code = match error {
17485 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17486 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17487 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17488 NativeSessionPreviewError::UnsupportedProvider
17489 | NativeSessionPreviewError::PreviewUnavailable
17490 | NativeSessionPreviewError::SessionNotFound
17491 | NativeSessionPreviewError::AmbiguousSession => {
17492 NodeFailureCode::BackendOperationFailed
17493 }
17494 };
17495 failure(code, "native session preview failed")
17496}
17497
17498fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17499 let code = match error.kind() {
17500 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17501 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17502 };
17503 failure(code, "host directory browse failed")
17504}
17505
17506fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17507 let code = match error {
17508 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17509 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17510 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17511 NodeFailureCode::DeliveryStageConflict
17512 }
17513 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17514 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17515 NodeFailureCode::DeliveryChunkOutOfOrder
17516 }
17517 DeliveryStoreError::BlobDigestMismatch => {
17518 NodeFailureCode::DeliveryBlobDigestMismatch
17519 }
17520 DeliveryStoreError::BundleDigestMismatch => {
17521 NodeFailureCode::DeliveryBundleDigestMismatch
17522 }
17523 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17524 DeliveryStoreError::Unavailable
17525 | DeliveryStoreError::Corrupt
17526 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17527 };
17528 failure(code, "delivery operation failed")
17529}
17530
17531fn validate_workspace_root(
17532 workspace_id: &WorkspaceId,
17533 canonical_root: &str,
17534) -> Result<(), NodeServerError> {
17535 #[cfg(windows)]
17536 if canonical_root.starts_with(r"\\") {
17537 return Err(NodeServerError::InvalidWorkspaceRoot {
17538 workspace_id: workspace_id.clone(),
17539 path: canonical_root.to_owned(),
17540 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17541 });
17542 }
17543 if !platform::workspace_root_supported(canonical_root)
17544 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17545 {
17546 return Err(NodeServerError::InvalidWorkspaceRoot {
17547 workspace_id: workspace_id.clone(),
17548 path: canonical_root.to_owned(),
17549 message: "canonical path must be absolute, bounded, and free of control characters"
17550 .to_owned(),
17551 });
17552 }
17553 Ok(())
17554}
17555
17556fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17557 NodeFailure {
17558 code,
17559 message: node_failure_category(code).to_owned(),
17560 }
17561}
17562
17563fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17564 let code = match error {
17565 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17566 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17567 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17568 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17569 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17570 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17571 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17572 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17573 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17574 };
17575 failure(code, "harness MCP proxy operation failed")
17576}
17577
17578static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17582
17583fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17590 let trace = launch.trace.as_ref()?;
17591 let trace_dir = std::env::var(&trace.dir_env).ok()?;
17592 if trace_dir.is_empty() {
17593 return None;
17594 }
17595 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17596 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17597 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17598}
17599
17600fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17601 let remaining = deadline.saturating_duration_since(Instant::now());
17602 if remaining.is_zero() {
17603 Err(failure(
17604 NodeFailureCode::SpawnDeadlineExceeded,
17605 "spawn deadline elapsed",
17606 ))
17607 } else {
17608 Ok(remaining)
17609 }
17610}
17611
17612fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17613 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17614 match deadline {
17615 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17616 None => Ok(bounded),
17617 }
17618}
17619
17620fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17621 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17622 failure(
17623 NodeFailureCode::SpawnDeadlineExceeded,
17624 "spawn dispatch exceeded the spawn deadline",
17625 )
17626 } else {
17627 error
17628 }
17629}
17630
17631fn spawn_runtime_capabilities(
17632 capabilities: &SpawnRequiredCapabilities,
17633) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17634 capabilities
17635 .iter()
17636 .map(|capability| match capability.as_str() {
17637 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17638 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17639 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17640 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17641 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17642 }
17643 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17644 _ => Err(failure(
17645 NodeFailureCode::UnsupportedSpawnCapability,
17646 "spawn required capability is unknown",
17647 )),
17648 })
17649 .collect()
17650}
17651
17652fn persistence_failure(_error: io::Error) -> NodeFailure {
17653 NodeFailure {
17654 code: NodeFailureCode::BackendOperationFailed,
17655 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17656 }
17657}
17658
17659fn node_failure_category(code: NodeFailureCode) -> &'static str {
17660 match code {
17661 NodeFailureCode::InvalidRequest => "invalid-request",
17662 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17663 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17664 NodeFailureCode::ReservationNotFound => "reservation-not-found",
17665 NodeFailureCode::ReservationConflict => "reservation-conflict",
17666 NodeFailureCode::ReservationExpired => "reservation-expired",
17667 NodeFailureCode::BindingMismatch => "binding-mismatch",
17668 NodeFailureCode::NotActivated => "not-activated",
17669 NodeFailureCode::CallNotFound => "call-not-found",
17670 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17671 NodeFailureCode::ResponseTooLarge => "response-too-large",
17672 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17673 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17674 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17675 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17676 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17677 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17678 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17679 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17680 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17681 NodeFailureCode::Unauthorized => "unauthorized",
17682 NodeFailureCode::ObserverReadOnly => "observer-read-only",
17683 NodeFailureCode::ControllerBusy => "controller-busy",
17684 NodeFailureCode::ControllerRequired => "controller-required",
17685 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17686 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17687 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17688 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17689 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17690 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17691 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17692 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17693 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17694 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17695 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17696 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17697 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17698 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17699 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17700 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17701 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17702 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17703 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17704 NodeFailureCode::GitReadFailed => "git-read-failed",
17705 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17706 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17707 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17708 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17709 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17710 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17711 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17712 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17713 NodeFailureCode::TurnInFlight => "turn-in-flight",
17714 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17715 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17716 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17717 "unsupported-network-allowlist-mapping"
17718 }
17719 NodeFailureCode::BrowserStationProbeUnavailable => {
17720 "browser-station-probe-unavailable"
17721 }
17722 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17723 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17724 NodeFailureCode::EnvironmentProfileBindingMismatch => {
17725 "environment-profile-binding-mismatch"
17726 }
17727 NodeFailureCode::UnknownBundle => "unknown-bundle",
17728 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17729 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17730 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17731 NodeFailureCode::ContextPackBusy => "context-pack-busy",
17732 NodeFailureCode::ContextPackMaterializationFailed => {
17733 "context-pack-materialization-failed"
17734 }
17735 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17736 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17737 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17738 NodeFailureCode::WorkspaceBusy => "workspace-busy",
17739 NodeFailureCode::LastWorkspace => "last-workspace",
17740 NodeFailureCode::NotGitRepository => "not-git-repository",
17741 NodeFailureCode::WorktreeConflict => "worktree-conflict",
17742 NodeFailureCode::WorktreeProtected => "worktree-protected",
17743 NodeFailureCode::WorktreeDirty => "worktree-dirty",
17744 NodeFailureCode::WorktreeLocked => "worktree-locked",
17745 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17746 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17747 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17748 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17749 "managed-worktree-profile-revision-mismatch"
17750 }
17751 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17752 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17753 "standalone-workspace-recovery-required"
17754 }
17755 NodeFailureCode::UnknownSession => "unknown-session",
17756 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17757 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17758 NodeFailureCode::SessionRecordBusy => "session-record-busy",
17759 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17760 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17761 NodeFailureCode::WorkspaceRegistrationRequired => {
17762 "workspace-registration-required"
17763 }
17764 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17765 NodeFailureCode::StaleGeneration => "stale-generation",
17766 NodeFailureCode::BackendBusy => "backend-busy",
17767 NodeFailureCode::BackendDisconnected => "backend-disconnected",
17768 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17769 NodeFailureCode::ShuttingDown => "shutting-down",
17770 }
17771}
17772
17773fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17774 NodeServerError::DurableState(io::Error::new(error.kind(), category))
17775}
17776
17777fn durable_state_load_error(error: io::Error) -> NodeServerError {
17778 let category = match session_registry::state_load_refusal(&error) {
17779 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17780 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17781 }
17782 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17783 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17784 }
17785 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17786 DURABLE_STATE_CONFLICT_ERROR
17787 }
17788 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17789 };
17790 durable_state_server_error(error, category)
17791}
17792
17793fn unix_time_ms() -> u64 {
17794 std::time::SystemTime::now()
17795 .duration_since(std::time::UNIX_EPOCH)
17796 .unwrap_or_default()
17797 .as_millis()
17798 .min(u64::MAX as u128) as u64
17799}
17800
17801fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17802 if record.provider_session.is_some() {
17803 ManagedSessionState::Dormant
17804 } else {
17805 ManagedSessionState::Unavailable
17806 }
17807}
17808
17809fn active_record_state(
17810 identity_admitted: bool,
17811 provider_session_is_known: bool,
17812) -> ManagedSessionState {
17813 if identity_admitted && !provider_session_is_known {
17814 ManagedSessionState::IdentityPending
17815 } else {
17816 ManagedSessionState::Live
17817 }
17818}
17819
17820fn managed_worktree_record_holders(
17821 records: &[ManagedSessionRecord],
17822) -> Vec<(SessionRecordId, WorkspaceId)> {
17823 records
17824 .iter()
17825 .filter(|record| record.provider_session.is_some())
17826 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17827 .collect()
17828}
17829
17830fn managed_spawn_record_ownership(
17831 binding: &SessionBinding,
17832 effective_runtime_policy: ProviderRuntimePolicy,
17833) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17834 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17835 if effective_runtime_policy.provider_session_identity {
17836 (binding.record_id.clone(), None)
17837 } else {
17838 (None, binding.record_id.clone())
17839 }
17840}
17841
17842#[derive(Debug, Error)]
17843pub enum NodeServerError {
17844 #[error("node endpoint must be a bounded absolute local transport path")]
17845 InvalidEndpoint,
17846 #[error("node access token must contain 1..=4096 bytes")]
17847 InvalidAccessToken,
17848 #[error("node HTTP observer must listen on a loopback address: {0}")]
17849 InvalidApiListen(std::net::SocketAddr),
17850 #[error("node browser bridge must listen on a loopback address: {0}")]
17851 InvalidBridgeListen(std::net::SocketAddr),
17852 #[error("node browser bridge token must contain 1..=4096 bytes")]
17853 InvalidBridgeToken,
17854 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17855 BridgeUnderlayRequiresBridgeListen,
17856 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17857 BridgeUnderlayRequiresBridgeToken,
17858 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17859 InvalidBridgeUnderlayToken,
17860 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17861 InvalidBridgeUnderlayKey,
17862 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17863 InvalidCallHome(std::net::SocketAddr),
17864 #[error("node could not reach its call-home relay: {0}")]
17865 CallHomeTransport(#[source] std::io::Error),
17866 #[error("node durable state path must be an absolute file path: {0}")]
17867 InvalidStatePath(String),
17868 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17869 InvalidHarnessMcpHelper,
17870 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17871 InvalidNetworkAllowlistCatalogPath(String),
17872 #[error("network allowlist catalog could not be read: {0}")]
17873 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17874 #[error("network allowlist catalog line {line}: {message}")]
17875 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17876 #[error("network allowlist catalog: {0}")]
17877 InvalidNetworkAllowlistCatalog(String),
17878 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17879 NetworkAllowlistCatalogCapacity { max: usize },
17880 #[error("the local state directory is unavailable; supply an explicit state path")]
17881 LocalStateDirectoryUnavailable,
17882 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17883 LocalRuntimeDirectoryUnavailable,
17884 #[error("node durable state failed: {0}")]
17885 DurableState(io::Error),
17886 #[error("node requires at least one configured workspace")]
17887 NoWorkspaces,
17888 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17889 InvalidWorkspaceRoot {
17890 workspace_id: WorkspaceId,
17891 path: String,
17892 message: String,
17893 },
17894 #[error("duplicate workspace ID: {0}")]
17895 DuplicateWorkspaceId(WorkspaceId),
17896 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17897 DuplicateWorkspaceRoot {
17898 first: WorkspaceId,
17899 second: WorkspaceId,
17900 root: String,
17901 },
17902 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17903 ManagedWorktreeProfileRequired(WorkspaceId),
17904 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17905 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17906 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17907 DuplicateManagedWorktreeProfile {
17908 workspace_id: WorkspaceId,
17909 profile_id: WorktreeProfileId,
17910 },
17911 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17912 ManagedWorktreeProfileCapacity {
17913 workspace_id: WorkspaceId,
17914 max: usize,
17915 },
17916 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17917 InvalidManagedWorktreeProfile {
17918 workspace_id: WorkspaceId,
17919 profile_id: WorktreeProfileId,
17920 message: String,
17921 },
17922 #[error("duplicate node environment profile: {0}")]
17923 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17924 #[error("node environment profile provider is unavailable: {0}")]
17925 EnvironmentProfileProviderUnavailable(AgentId),
17926 #[error("node environment profile capacity is {max}")]
17927 EnvironmentProfileCapacity { max: usize },
17928 #[error("node environment profiles reuse native profile ID '{0}'")]
17929 DuplicateNativeEnvironmentProfile(String),
17930 #[error("node native environment profile is invalid: {0}")]
17931 NativeEnvironmentProfile(String),
17932 #[error("node session-environment materialization root must be absolute")]
17933 InvalidSessionEnvironmentRoot,
17934 #[error("node session-environment materializer is unavailable")]
17935 SessionEnvironmentMaterializerRequired,
17936 #[error("node session-environment materializer failed to initialize")]
17937 SessionEnvironmentMaterializer,
17938 #[error("node bundle catalog is invalid: {0}")]
17939 BundleCatalog(String),
17940 #[error("node delivery store failed to initialize: {0}")]
17941 DeliveryStore(String),
17942 #[error("node context pack store failed to initialize")]
17943 ContextPackStore,
17944 #[error("active agent registry failed: {0}")]
17945 Registry(String),
17946 #[error("node provider contract manifest is invalid: {0}")]
17947 ProviderContractManifest(String),
17948 #[error("named pipe I/O failed: {0}")]
17949 Io(#[from] io::Error),
17950 #[error("node HTTP observer failed: {0}")]
17951 HttpApi(io::Error),
17952 #[error("node browser bridge failed: {0}")]
17953 BridgeApi(io::Error),
17954 #[error(transparent)]
17955 Frame(#[from] FrameError),
17956 #[error("node handshake failed: {0}")]
17957 Handshake(String),
17958 #[error("node authentication frame was not received before the bounded deadline")]
17959 AuthenticationTimedOut,
17960 #[error("node authentication primitive failed: {0}")]
17961 Authentication(String),
17962 #[error("node incarnation identity generation failed: {0}")]
17963 IncarnationIdentity(String),
17964 #[error("node pre-authentication limiter is closed")]
17965 PreauthClosed,
17966 #[error("node authenticated connection limit was reached")]
17967 AuthenticatedConnectionLimit,
17968 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17969 SignalStreamClosed,
17970 #[error("node stop-all dispatch failed: {0:?}")]
17971 ShutdownDispatch(NodeFailure),
17972 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17973 ShutdownTimedOut { active_native_sessions: usize },
17974}
17975
17976impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17977 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17978 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17979 match error {
17980 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17981 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17982 E::InvalidEntry { line, message } => {
17983 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17984 }
17985 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17986 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17987 }
17988 }
17989}
17990
17991#[cfg(test)]
17992mod agent_stream_blocked_tests {
17993 use super::*;
17994 use gate4agent_types::{
17995 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17996 ProviderSource,
17997 };
17998
17999 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18000 ProviderSource {
18001 family,
18002 binding: AdapterBinding::new(
18003 AdapterId::new(adapter).unwrap(),
18004 "fixture/v1",
18005 AdapterVerification::SyntheticFixture,
18006 )
18007 .unwrap(),
18008 }
18009 }
18010
18011 fn provider_control_event_at(
18012 family: AdapterFamily,
18013 adapter: &str,
18014 source_sequence: u64,
18015 provider_event: ProviderEvent,
18016 ) -> ControlEvent {
18017 ControlEvent {
18018 sequence: 41,
18019 command_id: None,
18020 instance_id: AgentInstanceId(7),
18021 generation: SessionGeneration(3),
18022 event: ControlEventKind::ProviderEvent {
18023 sequence: 8,
18024 source: provider_source(family, adapter),
18025 source_sequence,
18026 event: provider_event,
18027 },
18028 }
18029 }
18030
18031 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18032 provider_control_event_at(family, "fixture", 9, provider_event)
18033 }
18034
18035 fn denied_host_request(
18036 by: ProviderHostDecisionAuthority,
18037 reason: Option<String>,
18038 ) -> ControlEvent {
18039 provider_control_event_at(
18040 AdapterFamily::Acp,
18041 "claude-code",
18042 2,
18043 ProviderEvent::HostRequestObserved {
18044 method: "terminal/create".to_owned(),
18045 params_json: String::new(),
18046 decision: ProviderHostRequestDecision::Denied { by },
18047 outcome: ProviderHostRequestOutcome::Executed,
18048 reason,
18049 },
18050 )
18051 }
18052
18053 fn blocked(
18054 event: &ControlEvent,
18055 ) -> (
18056 Option<String>,
18057 String,
18058 BlockAuthorityV1,
18059 Option<String>,
18060 String,
18061 Option<String>,
18062 ) {
18063 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18064 chunk.validate().expect("minted chunk must validate");
18065 let AgentStreamChunkKindV1::Blocked {
18066 correlation_id,
18067 tool_class,
18068 authority,
18069 reason_kind,
18070 reason,
18071 help,
18072 } = chunk.kind
18073 else {
18074 panic!("expected AgentStreamChunkKindV1::Blocked");
18075 };
18076 (correlation_id, tool_class, authority, reason_kind, reason, help)
18077 }
18078
18079 #[test]
18083 fn the_enabled_provider_matrix_is_exact() {
18084 let registry = active_registry().unwrap();
18085 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18086 assert_eq!(
18087 providers
18088 .iter()
18089 .map(|contract| contract.provider.as_str())
18090 .collect::<Vec<_>>(),
18091 vec!["shell", "claude", "codex", "grok", "kimi"],
18093 );
18094 assert!(adapters.iter().all(|contract| {
18095 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18096 }));
18097 }
18098
18099 #[test]
18102 fn gate_denied_host_request_mints_a_blocked_chunk() {
18103 let gate_text =
18104 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18105 let event = denied_host_request(
18106 ProviderHostDecisionAuthority::Gate,
18107 Some(gate_text.to_owned()),
18108 );
18109 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18110 assert_eq!(chunk.source_sequence, 2);
18111 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18112 assert_eq!(authority, BlockAuthorityV1::HostGate);
18113 assert_eq!(reason, gate_text);
18114 assert_eq!(correlation_id, None);
18115 assert_eq!(reason_kind, None);
18116 assert_eq!(help, None);
18117 assert_eq!(tool_class, "Write");
18118 }
18119
18120 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.";
18121
18122 #[test]
18128 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18129 let event = provider_control_event(
18130 AdapterFamily::Acp,
18131 ProviderEvent::ToolCompleted {
18132 id: "toolu_9".to_owned(),
18133 output: CLASSIFIER_SAMPLE.to_owned(),
18134 is_error: true,
18135 duration_ms: None,
18136 agent_id: None,
18137 non_execution_kind: Some("permission-rule".to_owned()),
18138 },
18139 );
18140 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18141 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18142 assert_eq!(reason, "Blocked by classifier");
18143 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18144 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18145 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18146 }
18147
18148 #[test]
18153 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18154 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.";
18155 let event = provider_control_event(
18156 AdapterFamily::Acp,
18157 ProviderEvent::SessionEnded {
18158 result: "JSON-RPC error -32603: Internal error".to_owned(),
18159 cost_usd: None,
18160 is_error: true,
18161 stop_reason: Some(ProviderStopReason::ProviderError {
18162 code: -32603,
18163 message: message.to_owned(),
18164 vendor_code: Some("usageLimitExceeded".to_owned()),
18165 }),
18166 },
18167 );
18168 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18169 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18170 assert_eq!(reason, message);
18171 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18172 assert_eq!(tool_class, "Session");
18173 }
18174
18175 #[test]
18178 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18179 let event = provider_control_event(
18180 AdapterFamily::Acp,
18181 ProviderEvent::SessionEnded {
18182 result: "refusal".to_owned(),
18183 cost_usd: None,
18184 is_error: true,
18185 stop_reason: Some(ProviderStopReason::Refusal),
18186 },
18187 );
18188 let (_, _, authority, _, _, _) = blocked(&event);
18189 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18190 }
18191
18192 #[test]
18196 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18197 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18198 let event = provider_control_event(
18199 AdapterFamily::Acp,
18200 ProviderEvent::ToolCompleted {
18201 id: "toolu_1".to_owned(),
18202 output: "command exited with status 1".to_owned(),
18203 is_error: true,
18204 duration_ms: Some(12),
18205 agent_id: None,
18206 non_execution_kind,
18207 },
18208 );
18209 assert!(agent_stream_chunk(&event).is_none());
18210 }
18211 let narrative = provider_control_event(
18212 AdapterFamily::Acp,
18213 ProviderEvent::ToolCompleted {
18214 id: "toolu_2".to_owned(),
18215 output: "the harness blocked the write, approval needed".to_owned(),
18216 is_error: false,
18217 duration_ms: Some(3),
18218 agent_id: None,
18219 non_execution_kind: None,
18220 },
18221 );
18222 assert!(agent_stream_chunk(&narrative).is_none());
18223 }
18224
18225 #[test]
18229 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18230 for (decision, outcome) in [
18231 (
18232 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18233 ProviderHostRequestOutcome::Executed,
18234 ),
18235 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18236 (
18237 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18238 ProviderHostRequestOutcome::Failed {
18239 error: "terminal/create spawn failed: os error 3".to_owned(),
18240 },
18241 ),
18242 ] {
18243 let event = provider_control_event_at(
18244 AdapterFamily::Acp,
18245 "claude-code",
18246 3,
18247 ProviderEvent::HostRequestObserved {
18248 method: "fs/read_text_file".to_owned(),
18249 params_json: String::new(),
18250 decision: decision.clone(),
18251 outcome,
18252 reason: None,
18253 },
18254 );
18255 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18256 }
18257 }
18258
18259 #[test]
18263 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18264 for (by, expected_authority, expected_reason) in [
18265 (
18266 ProviderHostDecisionAuthority::Operator,
18267 BlockAuthorityV1::Operator,
18268 "declined by the operator",
18269 ),
18270 (
18271 ProviderHostDecisionAuthority::DeadlinePolicy,
18272 BlockAuthorityV1::HostDeadline,
18273 "denied by host policy after the operator deadline expired",
18274 ),
18275 ] {
18276 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18277 assert_eq!(authority, expected_authority);
18278 assert_eq!(reason, expected_reason);
18279 }
18280 }
18281
18282 #[test]
18286 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18287 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18288 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18289 ProviderHostDecisionAuthority::Policy,
18290 Some(oversized),
18291 ));
18292 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18293 }
18294}
18295
18296#[cfg(test)]
18297mod standalone_capability_platform_tests {
18298 use super::*;
18299
18300 #[test]
18301 fn standalone_workspace_capability_requires_windows_identity_guards() {
18302 let capabilities = baseline_capabilities().unwrap();
18303 let advertised = capabilities.iter().any(|capability| {
18304 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18305 });
18306 assert_eq!(advertised, cfg!(windows));
18307
18308 let request = NodeRequest::CreateStandaloneWorkspace {
18309 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18310 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18311 initial_branch: None,
18312 };
18313 assert_eq!(
18314 request_uses_unnegotiated_capability(&request, &capabilities),
18315 !cfg!(windows),
18316 );
18317 }
18318}
18319
18320
18321#[cfg(test)]
18322mod station_knobs_resolve_tests {
18323 use super::*;
18324 use gate4agent_catalog::EnvMutation;
18325 use gate4agent_runtime_native::{
18326 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18327 NativeLaunchProfileId,
18328 };
18329 use std::ffi::OsString;
18330 use std::sync::Arc;
18331
18332 struct EmptyEnvironmentResolver;
18333
18334 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18335 fn resolve_child_environment(
18336 &self,
18337 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18338 Ok(vec![EnvMutation {
18339 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18340 value: None,
18341 }])
18342 }
18343 }
18344
18345 fn agent(value: &str) -> AgentId {
18346 AgentId::new(value).unwrap()
18347 }
18348
18349 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18350 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18351 let profile_revision =
18352 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18353 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18354 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18355 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18356 provider: agent("claude"),
18357 mode: SessionMode::Pty,
18358 terminal_size: gate4agent_types::TerminalSize {
18359 rows: 24,
18360 columns: 80,
18361 },
18362 prompt: None,
18363 bundle_id: None,
18364 context_id: None,
18365 environment_profile_id: Some(profile_id.clone()),
18366 };
18367 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18368 let catalog = active_registry().unwrap();
18369 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18370 let control = runtime.native_launch_profile_control();
18371 let node_profile = NodeEnvironmentProfile::new(
18372 profile_id.clone(),
18373 profile_revision,
18374 agent("claude"),
18375 [NativeLaunchProfile::new(
18376 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18377 agent("claude"),
18378 TransportKind::Pty,
18379 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18380 Arc::new(EmptyEnvironmentResolver),
18381 )
18382 .unwrap()],
18383 )
18384 .unwrap();
18385 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18386 for native_profile in native_profiles {
18387 runtime.upsert_native_launch_profile(native_profile).unwrap();
18388 }
18389 let workspace = WorkspaceConfig::new(
18390 WorkspaceId::new("primary").unwrap(),
18391 std::env::current_dir().unwrap(),
18392 )
18393 .unwrap();
18394 let shared = NodeShared::new_with_incarnation(
18395 handle,
18396 "fixture-token".to_owned(),
18397 NodeId::new("node-station-knobs-test").unwrap(),
18398 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18399 vec![workspace],
18400 vec![agent("claude")],
18401 ProviderRuntimeStatuses::default(),
18402 None,
18403 Vec::new(),
18404 Vec::new(),
18405 spawn_profiles,
18406 Some(control),
18407 None,
18408 None,
18409 None,
18410 Vec::new(),
18411 Vec::new(),
18412 Vec::new(),
18413 Vec::new(),
18414 None,
18415 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18416 );
18417 shared
18418 .environment_profiles
18419 .write()
18420 .unwrap_or_else(|poisoned| poisoned.into_inner())
18421 .insert(binding.id.clone(), binding);
18422 (shared, spawn_defaults)
18423 }
18424
18425 #[test]
18428 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18429 let (shared, defaults) = station_knobs_fixture();
18430 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18431 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18432 let spec = crate::protocol::SpawnSpec {
18433 target: crate::protocol::SpawnTarget {
18434 node_id: shared.node_id.clone(),
18435 workspace_id: WorkspaceId::new("primary").unwrap(),
18436 worktree_id: None,
18437 },
18438 profile_id: defaults.profile_id.clone(),
18439 expected_profile_revision: defaults.revision.clone(),
18440 overrides: crate::protocol::SpawnOverrides {
18441 network_allowlist: Some(unknown),
18442 browser_profile_id: Some(browser),
18443 ..crate::protocol::SpawnOverrides::default()
18444 },
18445 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18446 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18447 "station-knobs-unknown-allowlist",
18448 )
18449 .unwrap(),
18450 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18451 };
18452 let resolved = spec.resolve(&defaults).unwrap();
18453 let failure = shared
18454 .resolve_environment_profile(&resolved)
18455 .expect_err("unknown allowlist must refuse");
18456 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18457 }
18458
18459 #[test]
18465 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18466 let (shared, defaults) = station_knobs_fixture();
18467 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18468 #[cfg(not(feature = "dig2-station-probe"))]
18469 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18470 shared
18471 .network_allowlist_catalog
18472 .write()
18473 .unwrap_or_else(|poisoned| poisoned.into_inner())
18474 .insert_id(allowlist.clone())
18475 .unwrap();
18476 let spec = crate::protocol::SpawnSpec {
18477 target: crate::protocol::SpawnTarget {
18478 node_id: shared.node_id.clone(),
18479 workspace_id: WorkspaceId::new("primary").unwrap(),
18480 worktree_id: None,
18481 },
18482 profile_id: defaults.profile_id.clone(),
18483 expected_profile_revision: defaults.revision.clone(),
18484 overrides: crate::protocol::SpawnOverrides {
18485 network_allowlist: Some(allowlist.clone()),
18486 #[cfg(not(feature = "dig2-station-probe"))]
18487 browser_profile_id: Some(browser.clone()),
18488 ..crate::protocol::SpawnOverrides::default()
18489 },
18490 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18491 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18492 .unwrap(),
18493 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18494 };
18495 let resolved = spec.resolve(&defaults).unwrap();
18496 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18497 #[cfg(not(feature = "dig2-station-probe"))]
18498 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18499 let receipt = shared
18500 .resolve_environment_profile(&resolved)
18501 .expect("resolve ok")
18502 .expect("env profile present");
18503 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18504 #[cfg(not(feature = "dig2-station-probe"))]
18505 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18506 #[cfg(feature = "dig2-station-probe")]
18507 assert_eq!(receipt.browser_profile_id, None);
18508 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18509
18510 let omitted = crate::protocol::SpawnSpec {
18511 overrides: crate::protocol::SpawnOverrides::default(),
18512 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18513 .unwrap(),
18514 ..spec
18515 };
18516 let resolved_omit = omitted.resolve(&defaults).unwrap();
18517 let bare = shared
18518 .resolve_environment_profile(&resolved_omit)
18519 .expect("resolve ok")
18520 .expect("env profile present");
18521 assert_eq!(bare.network_allowlist, None);
18522 assert_eq!(bare.browser_profile_id, None);
18523 }
18524
18525 #[cfg(feature = "dig2-station-probe")]
18530 #[test]
18531 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18532 let (shared, defaults) = station_knobs_fixture();
18533 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18534 let spec = crate::protocol::SpawnSpec {
18535 target: crate::protocol::SpawnTarget {
18536 node_id: shared.node_id.clone(),
18537 workspace_id: WorkspaceId::new("primary").unwrap(),
18538 worktree_id: None,
18539 },
18540 profile_id: defaults.profile_id.clone(),
18541 expected_profile_revision: defaults.revision.clone(),
18542 overrides: crate::protocol::SpawnOverrides {
18543 browser_profile_id: Some(browser),
18544 ..crate::protocol::SpawnOverrides::default()
18545 },
18546 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18547 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18548 "station-knobs-probe-refuse",
18549 )
18550 .unwrap(),
18551 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18552 };
18553 let resolved = spec.resolve(&defaults).unwrap();
18554 let failure = shared
18555 .resolve_environment_profile(&resolved)
18556 .expect_err("browser_profile with failing probe must refuse");
18557 #[cfg(windows)]
18558 {
18559 assert!(
18560 matches!(
18561 failure.code,
18562 NodeFailureCode::BrowserStationUnreachable
18563 | NodeFailureCode::BrowserStationProbeUnavailable
18564 ),
18565 "unexpected code {:?}",
18566 failure.code
18567 );
18568 }
18569 #[cfg(not(windows))]
18570 {
18571 assert_eq!(
18572 failure.code,
18573 NodeFailureCode::BrowserStationProbeUnavailable
18574 );
18575 }
18576 }
18577
18578 #[cfg(feature = "dig2-station-probe")]
18580 #[test]
18581 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18582 let (shared, defaults) = station_knobs_fixture();
18583 let spec = crate::protocol::SpawnSpec {
18584 target: crate::protocol::SpawnTarget {
18585 node_id: shared.node_id.clone(),
18586 workspace_id: WorkspaceId::new("primary").unwrap(),
18587 worktree_id: None,
18588 },
18589 profile_id: defaults.profile_id.clone(),
18590 expected_profile_revision: defaults.revision.clone(),
18591 overrides: crate::protocol::SpawnOverrides::default(),
18592 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18593 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18594 "station-knobs-probe-omit",
18595 )
18596 .unwrap(),
18597 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18598 };
18599 let resolved = spec.resolve(&defaults).unwrap();
18600 let receipt = shared
18601 .resolve_environment_profile(&resolved)
18602 .expect("omit browser_profile must not probe")
18603 .expect("env profile present");
18604 assert_eq!(receipt.browser_profile_id, None);
18605 }
18606
18607 #[cfg(feature = "dig2-station-probe")]
18611 #[test]
18612 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18613 let (shared, _defaults) = station_knobs_fixture();
18614 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18615 let workspace_id = WorkspaceId::new("primary").unwrap();
18616 let address_a = SessionAddress {
18617 workspace_id: workspace_id.clone(),
18618 session: SessionKey {
18619 instance_id: AgentInstanceId(101),
18620 generation: SessionGeneration(1),
18621 },
18622 };
18623 let address_b = SessionAddress {
18624 workspace_id: workspace_id.clone(),
18625 session: SessionKey {
18626 instance_id: AgentInstanceId(102),
18627 generation: SessionGeneration(1),
18628 },
18629 };
18630 let guard_a = crate::dig2_station_lease::acquire_lease(
18631 &shared.browser_station_leases,
18632 profile.clone(),
18633 address_a.session.instance_id,
18634 1,
18635 )
18636 .expect("first lease");
18637 let busy = crate::dig2_station_lease::acquire_lease(
18638 &shared.browser_station_leases,
18639 profile.clone(),
18640 address_b.session.instance_id,
18641 2,
18642 )
18643 .expect_err("second holder must refuse");
18644 assert!(matches!(
18645 busy,
18646 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18647 ));
18648 shared.bind_session_with_policy(
18650 &address_a,
18651 ProviderRuntimePolicy::raw_pty(),
18652 None,
18653 );
18654 guard_a.retain();
18655 assert!(shared.remove_binding(&address_a).is_some());
18656 crate::dig2_station_lease::acquire_lease(
18657 &shared.browser_station_leases,
18658 profile,
18659 address_b.session.instance_id,
18660 3,
18661 )
18662 .expect("reacquire after clear")
18663 .retain();
18664 assert!(
18665 crate::dig2_station_lease::release_lease_for_holder(
18666 &shared.browser_station_leases,
18667 address_b.session.instance_id,
18668 )
18669 );
18670 }
18671
18672 #[cfg(not(feature = "dig2-station-probe"))]
18675 #[test]
18676 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18677 let (shared, defaults) = station_knobs_fixture();
18678 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18679 let spec = crate::protocol::SpawnSpec {
18680 target: crate::protocol::SpawnTarget {
18681 node_id: shared.node_id.clone(),
18682 workspace_id: WorkspaceId::new("primary").unwrap(),
18683 worktree_id: None,
18684 },
18685 profile_id: defaults.profile_id.clone(),
18686 expected_profile_revision: defaults.revision.clone(),
18687 overrides: crate::protocol::SpawnOverrides {
18688 browser_profile_id: Some(browser.clone()),
18689 ..crate::protocol::SpawnOverrides::default()
18690 },
18691 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18692 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18693 .unwrap(),
18694 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18695 };
18696 let resolved = spec.resolve(&defaults).unwrap();
18697 let receipt = shared
18698 .resolve_environment_profile(&resolved)
18699 .expect("stub resolve")
18700 .expect("env profile");
18701 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18702 }
18703
18704
18705 #[test]
18708 fn network_allowlist_catalog_loads_from_temp_file() {
18709 let unique = std::time::SystemTime::now()
18710 .duration_since(std::time::UNIX_EPOCH)
18711 .unwrap()
18712 .as_nanos();
18713 let path = std::env::temp_dir().join(format!(
18714 "gate4agent-node-allowlist-config-{}-{unique}.txt",
18715 std::process::id(),
18716 ));
18717 std::fs::write(
18718 &path,
18719 "# opaque station allowlist ids\negress-default\nlab-egress\n",
18720 )
18721 .unwrap();
18722 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18723 .expect("temp catalog file must load");
18724 let _ = std::fs::remove_file(&path);
18725 assert_eq!(loaded.len(), 2);
18726 let (shared, defaults) = station_knobs_fixture();
18727 {
18728 let mut catalog = shared
18729 .network_allowlist_catalog
18730 .write()
18731 .unwrap_or_else(|poisoned| poisoned.into_inner());
18732 *catalog = loaded;
18733 }
18734 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18735 let spec = crate::protocol::SpawnSpec {
18736 target: crate::protocol::SpawnTarget {
18737 node_id: shared.node_id.clone(),
18738 workspace_id: WorkspaceId::new("primary").unwrap(),
18739 worktree_id: None,
18740 },
18741 profile_id: defaults.profile_id.clone(),
18742 expected_profile_revision: defaults.revision.clone(),
18743 overrides: crate::protocol::SpawnOverrides {
18744 network_allowlist: Some(allowlist.clone()),
18745 ..crate::protocol::SpawnOverrides::default()
18746 },
18747 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18748 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18749 "station-knobs-catalog-file",
18750 )
18751 .unwrap(),
18752 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18753 };
18754 let resolved = spec.resolve(&defaults).unwrap();
18755 let receipt = shared
18756 .resolve_environment_profile(&resolved)
18757 .expect("registered via file load")
18758 .expect("env profile present");
18759 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18760 }
18761
18762 #[test]
18765 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18766 let (shared, _defaults) = station_knobs_fixture();
18767 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18768 shared
18769 .network_allowlist_catalog
18770 .write()
18771 .unwrap_or_else(|poisoned| poisoned.into_inner())
18772 .insert_id(allowlist.clone())
18773 .unwrap();
18774 let snapshot = shared.snapshot();
18775 let inventory = snapshot
18776 .launch_inventory
18777 .expect("launch inventory present");
18778 let ids = inventory
18779 .network_allowlists
18780 .expect("non-empty catalog surfaces ids");
18781 assert_eq!(ids, vec![allowlist]);
18782 }
18783
18784 #[test]
18788 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18789 let (shared, defaults) = station_knobs_fixture();
18790 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18791 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18792 id: allowlist.clone(),
18793 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18794 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18795 peer: "127.0.0.1:443".to_owned(),
18796 }],
18797 provider_native: Some(
18798 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18799 codex_network_access: Some(true),
18800 },
18801 ),
18802 };
18803 shared
18804 .network_allowlist_catalog
18805 .write()
18806 .unwrap_or_else(|poisoned| poisoned.into_inner())
18807 .insert(entry)
18808 .unwrap();
18809 let spec = crate::protocol::SpawnSpec {
18810 target: crate::protocol::SpawnTarget {
18811 node_id: shared.node_id.clone(),
18812 workspace_id: WorkspaceId::new("primary").unwrap(),
18813 worktree_id: None,
18814 },
18815 profile_id: defaults.profile_id.clone(),
18816 expected_profile_revision: defaults.revision.clone(),
18817 overrides: crate::protocol::SpawnOverrides {
18818 network_allowlist: Some(allowlist),
18819 ..crate::protocol::SpawnOverrides::default()
18820 },
18821 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18822 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18823 "station-knobs-unsupported-mapping",
18824 )
18825 .unwrap(),
18826 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18827 };
18828 let resolved = spec.resolve(&defaults).unwrap();
18829 assert_eq!(resolved.provider.as_str(), "claude");
18830 let failure = shared
18831 .resolve_environment_profile(&resolved)
18832 .expect_err("claude + codex_network_access must refuse");
18833 assert_eq!(
18834 failure.code,
18835 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18836 );
18837 }
18838
18839 #[test]
18843 fn resolve_codex_network_access_overlay_moderate_only() {
18844 let (shared, defaults) = station_knobs_fixture();
18845 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18846 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18847 id: allowlist.clone(),
18848 permits: Vec::new(),
18849 provider_native: Some(
18850 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18851 codex_network_access: Some(true),
18852 },
18853 ),
18854 };
18855 shared
18856 .network_allowlist_catalog
18857 .write()
18858 .unwrap_or_else(|poisoned| poisoned.into_inner())
18859 .insert(entry)
18860 .unwrap();
18861
18862 let overlay = shared
18863 .resolve_codex_network_access_overlay_args(
18864 &agent("codex"),
18865 SessionMode::Pty,
18866 ApprovalLevel::Moderate,
18867 Some(&allowlist),
18868 )
18869 .expect("Moderate Codex maps network_access");
18870 assert_eq!(
18871 overlay,
18872 vec![
18873 std::ffi::OsString::from("-c"),
18874 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18875 ]
18876 );
18877
18878 let acp = shared
18879 .resolve_codex_network_access_overlay_args(
18880 &agent("codex"),
18881 SessionMode::Acp,
18882 ApprovalLevel::Moderate,
18883 Some(&allowlist),
18884 )
18885 .unwrap();
18886 assert_eq!(acp, overlay);
18887
18888 let inline = shared
18889 .resolve_codex_network_access_overlay_args(
18890 &agent("codex"),
18891 SessionMode::Inline,
18892 ApprovalLevel::Moderate,
18893 Some(&allowlist),
18894 )
18895 .unwrap_err();
18896 assert_eq!(
18897 inline.code,
18898 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18899 );
18900
18901 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18902 let spec = crate::protocol::SpawnSpec {
18903 target: crate::protocol::SpawnTarget {
18904 node_id: shared.node_id.clone(),
18905 workspace_id: WorkspaceId::new("primary").unwrap(),
18906 worktree_id: None,
18907 },
18908 profile_id: defaults.profile_id.clone(),
18909 expected_profile_revision: defaults.revision.clone(),
18910 overrides: crate::protocol::SpawnOverrides {
18911 provider: crate::protocol::SpawnOverride::Set {
18912 value: agent("codex"),
18913 },
18914 approval_level: Some(level),
18915 network_allowlist: Some(allowlist.clone()),
18916 ..crate::protocol::SpawnOverrides::default()
18917 },
18918 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18919 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18920 "codex-net-refuse-{}",
18921 match level {
18922 ApprovalLevel::ReadOnly => "ro",
18923 ApprovalLevel::FullAuto => "fa",
18924 _ => "other",
18925 }
18926 ))
18927 .unwrap(),
18928 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18929 };
18930 let resolved = spec.resolve(&defaults).unwrap();
18931 let failure = shared
18932 .resolve_environment_profile(&resolved)
18933 .expect_err("RO/FullAuto must refuse");
18934 assert_eq!(
18935 failure.code,
18936 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18937 );
18938 }
18939 }
18940
18941 #[test]
18945 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18946 let (shared, _defaults) = station_knobs_fixture();
18947 let allowlist =
18948 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18949 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18950 id: allowlist.clone(),
18951 permits: vec![
18952 crate::network_allowlist_catalog::NetworkPermitSketch {
18953 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18954 peer: "github.com:443".to_owned(),
18955 },
18956 crate::network_allowlist_catalog::NetworkPermitSketch {
18957 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18958 peer: "*.npmjs.org".to_owned(),
18959 },
18960 ],
18961 provider_native: None,
18962 };
18963 shared
18964 .network_allowlist_catalog
18965 .write()
18966 .unwrap_or_else(|poisoned| poisoned.into_inner())
18967 .insert(entry)
18968 .unwrap();
18969
18970 let unique = std::time::SystemTime::now()
18971 .duration_since(std::time::UNIX_EPOCH)
18972 .unwrap()
18973 .as_nanos();
18974 let provider_home = std::env::temp_dir().join(format!(
18975 "gate4agent-claude-provider-home-{}-{unique}",
18976 std::process::id(),
18977 ));
18978 std::fs::create_dir_all(&provider_home).unwrap();
18979
18980 let overlay = shared
18981 .resolve_network_allowlist_overlay_args(
18982 &agent("claude"),
18983 SessionMode::Pty,
18984 ApprovalLevel::Moderate,
18985 Some(&allowlist),
18986 Some(provider_home.as_path()),
18987 )
18988 .expect("Claude PTY settings overlay on unix");
18989 let settings_path = provider_home.join(
18990 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18991 );
18992 assert_eq!(
18993 overlay,
18994 vec![
18995 std::ffi::OsString::from("--settings"),
18996 std::ffi::OsString::from(settings_path.as_os_str()),
18997 ]
18998 );
18999 let body = std::fs::read_to_string(&settings_path).unwrap();
19000 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19001 assert_eq!(value["sandbox"]["enabled"], true);
19002 assert_eq!(
19003 value["sandbox"]["network"]["allowedDomains"],
19004 serde_json::json!(["github.com", "*.npmjs.org"])
19005 );
19006 assert!(settings_path.starts_with(&provider_home));
19007
19008 let acp = shared
19009 .resolve_network_allowlist_overlay_args(
19010 &agent("claude"),
19011 SessionMode::Acp,
19012 ApprovalLevel::Moderate,
19013 Some(&allowlist),
19014 Some(provider_home.as_path()),
19015 )
19016 .expect_err("Claude ACP must refuse settings overlay");
19017 assert_eq!(
19018 acp.code,
19019 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19020 );
19021
19022 let no_home = shared
19023 .resolve_network_allowlist_overlay_args(
19024 &agent("claude"),
19025 SessionMode::Pty,
19026 ApprovalLevel::Moderate,
19027 Some(&allowlist),
19028 None,
19029 )
19030 .expect_err("Claude overlay requires ProviderHome");
19031 assert_eq!(
19032 no_home.code,
19033 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19034 );
19035
19036 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19037 shared
19038 .network_allowlist_catalog
19039 .write()
19040 .unwrap_or_else(|poisoned| poisoned.into_inner())
19041 .insert_id(bare.clone())
19042 .unwrap();
19043 let empty = shared
19044 .resolve_network_allowlist_overlay_args(
19045 &agent("claude"),
19046 SessionMode::Pty,
19047 ApprovalLevel::Moderate,
19048 Some(&bare),
19049 Some(provider_home.as_path()),
19050 )
19051 .unwrap();
19052 assert!(empty.is_empty());
19053
19054 let _ = std::fs::remove_dir_all(&provider_home);
19055 }
19056
19057 #[test]
19059 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19060 let (shared, _defaults) = station_knobs_fixture();
19061 let allowlist =
19062 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19063 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19064 id: allowlist.clone(),
19065 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19066 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19067 peer: "example.com:443".to_owned(),
19068 }],
19069 provider_native: None,
19070 };
19071 shared
19072 .network_allowlist_catalog
19073 .write()
19074 .unwrap_or_else(|poisoned| poisoned.into_inner())
19075 .insert(entry)
19076 .unwrap();
19077 for provider in ["kimi", "grok"] {
19078 let overlay = shared
19079 .resolve_network_allowlist_overlay_args(
19080 &agent(provider),
19081 SessionMode::Pty,
19082 ApprovalLevel::Moderate,
19083 Some(&allowlist),
19084 None,
19085 )
19086 .unwrap();
19087 assert!(
19088 overlay.is_empty(),
19089 "{provider} must not invent network argv from permits"
19090 );
19091 }
19092 }
19093
19094 #[test]
19096 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19097 let unique = std::time::SystemTime::now()
19098 .duration_since(std::time::UNIX_EPOCH)
19099 .unwrap()
19100 .as_nanos();
19101 let path = std::env::temp_dir().join(format!(
19102 "gate4agent-node-allowlist-json-{}-{unique}.json",
19103 std::process::id(),
19104 ));
19105 std::fs::write(
19106 &path,
19107 r#"{
19108 "schema_version": 2,
19109 "entries": [
19110 {
19111 "id": "egress-default",
19112 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19113 },
19114 { "id": "lab-egress" }
19115 ]
19116 }"#,
19117 )
19118 .unwrap();
19119 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19120 .expect("json v2 catalog must load");
19121 let _ = std::fs::remove_file(&path);
19122 assert_eq!(loaded.len(), 2);
19123 let entry = loaded
19124 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19125 .unwrap();
19126 assert_eq!(entry.permits.len(), 1);
19127 let (shared, defaults) = station_knobs_fixture();
19128 {
19129 let mut catalog = shared
19130 .network_allowlist_catalog
19131 .write()
19132 .unwrap_or_else(|poisoned| poisoned.into_inner());
19133 *catalog = loaded;
19134 }
19135 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19136 let spec = crate::protocol::SpawnSpec {
19137 target: crate::protocol::SpawnTarget {
19138 node_id: shared.node_id.clone(),
19139 workspace_id: WorkspaceId::new("primary").unwrap(),
19140 worktree_id: None,
19141 },
19142 profile_id: defaults.profile_id.clone(),
19143 expected_profile_revision: defaults.revision.clone(),
19144 overrides: crate::protocol::SpawnOverrides {
19145 network_allowlist: Some(allowlist.clone()),
19146 ..crate::protocol::SpawnOverrides::default()
19147 },
19148 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19149 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19150 "station-knobs-catalog-json",
19151 )
19152 .unwrap(),
19153 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19154 };
19155 let resolved = spec.resolve(&defaults).unwrap();
19156 let receipt = shared
19157 .resolve_environment_profile(&resolved)
19158 .expect("permits-only entry ok for claude")
19159 .expect("env profile present");
19160 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19161 let inventory = shared
19162 .snapshot()
19163 .launch_inventory
19164 .expect("launch inventory present");
19165 let ids = inventory.network_allowlists.expect("ids surface");
19166 assert_eq!(
19167 ids,
19168 vec![
19169 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19170 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19171 ]
19172 );
19173 }
19174
19175
19176}
19177
19178
19179#[cfg(test)]
19180mod workspace_environment_collision_tests {
19181 use super::*;
19182 use crate::session_environment::{
19183 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19184 };
19185 use gate4agent_catalog::EnvMutation;
19186 use gate4agent_runtime_native::{
19187 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19188 NativeLaunchProfileId,
19189 };
19190 use std::ffi::OsString;
19191 use std::sync::Arc;
19192
19193 struct EmptyEnvironmentResolver;
19194
19195 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19196 fn resolve_child_environment(
19197 &self,
19198 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19199 Ok(vec![EnvMutation {
19200 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19201 value: None,
19202 }])
19203 }
19204 }
19205
19206 fn agent(value: &str) -> AgentId {
19207 AgentId::new(value).unwrap()
19208 }
19209
19210 fn claude_config_dir_collision_fixture() -> (
19212 NodeShared,
19213 ResolvedEnvironmentProfileReceipt,
19214 ResolvedEnvironmentProfileReceipt,
19215 ) {
19216 let catalog = active_registry().unwrap();
19217 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19218 let control = runtime.native_launch_profile_control();
19219 let workspace = WorkspaceConfig::new(
19220 WorkspaceId::new("primary").unwrap(),
19221 std::env::current_dir().unwrap(),
19222 )
19223 .unwrap();
19224 let shared = NodeShared::new_with_incarnation(
19225 handle,
19226 "fixture-token".to_owned(),
19227 NodeId::new("node-env-collision-test").unwrap(),
19228 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19229 vec![workspace],
19230 vec![agent("claude")],
19231 ProviderRuntimeStatuses::default(),
19232 None,
19233 Vec::new(),
19234 Vec::new(),
19235 SpawnProfileRegistry::default(),
19236 Some(control),
19237 None,
19238 None,
19239 None,
19240 Vec::new(),
19241 Vec::new(),
19242 Vec::new(),
19243 Vec::new(),
19244 None,
19245 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19246 );
19247
19248 let mut receipts = Vec::new();
19249 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19250 let profile_id =
19251 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19252 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19253 "claude-config-dir-{suffix}-r1"
19254 ))
19255 .unwrap();
19256 let materialization = NodeSessionMaterializationProfile::new(
19257 Vec::new(),
19258 vec![NodeSessionPathBinding::new(
19259 "CLAUDE_CONFIG_DIR",
19260 NodeSessionPathClass::ProviderHome,
19261 )
19262 .unwrap()],
19263 Vec::new(),
19264 )
19265 .unwrap();
19266 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19267 profile_id.clone(),
19268 profile_revision.clone(),
19269 agent("claude"),
19270 [NativeLaunchProfile::new(
19271 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19272 .unwrap(),
19273 agent("claude"),
19274 TransportKind::Pty,
19275 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19276 Arc::new(EmptyEnvironmentResolver),
19277 )
19278 .unwrap()],
19279 Some(materialization),
19280 )
19281 .unwrap();
19282 let (binding, native_profiles, materialization) = node_profile.into_parts();
19283 for native_profile in native_profiles {
19284 runtime.upsert_native_launch_profile(native_profile).unwrap();
19285 }
19286 shared
19287 .environment_profiles
19288 .write()
19289 .unwrap_or_else(|poisoned| poisoned.into_inner())
19290 .insert(binding.id.clone(), binding);
19291 shared
19292 .environment_materialization_profiles
19293 .write()
19294 .unwrap_or_else(|poisoned| poisoned.into_inner())
19295 .insert(profile_id.clone(), materialization.unwrap());
19296 receipts.push(ResolvedEnvironmentProfileReceipt {
19297 profile_id,
19298 profile_revision,
19299 network_allowlist: None,
19300 browser_profile_id: None,
19301 });
19302 }
19303 let profile_b = receipts.pop().unwrap();
19304 let profile_a = receipts.pop().unwrap();
19305 (shared, profile_a, profile_b)
19306 }
19307
19308 #[test]
19309 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19310 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19311 let primary = WorkspaceId::new("primary").unwrap();
19312 let worktree = WorkspaceId::new("worktree-b").unwrap();
19313 let address = SessionAddress {
19314 workspace_id: primary.clone(),
19315 session: SessionKey {
19316 instance_id: AgentInstanceId(41),
19317 generation: SessionGeneration(1),
19318 },
19319 };
19320 shared.bind_session_with_policy(
19321 &address,
19322 ProviderRuntimePolicy::raw_pty(),
19323 Some(profile_a.clone()),
19324 );
19325
19326 let refused = shared
19327 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19328 .unwrap_err();
19329 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19330
19331 let refused_none = shared
19333 .ensure_workspace_environment_profile_compatible(&primary, None)
19334 .unwrap_err();
19335 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19336
19337 shared
19339 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19340 .unwrap();
19341
19342 shared
19344 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19345 .unwrap();
19346 }
19347
19348 #[tokio::test]
19349 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19350 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19351 let primary = WorkspaceId::new("primary").unwrap();
19352 let address = SessionAddress {
19353 workspace_id: primary.clone(),
19354 session: SessionKey {
19355 instance_id: AgentInstanceId(41),
19356 generation: SessionGeneration(1),
19357 },
19358 };
19359 shared.bind_session_with_policy(
19360 &address,
19361 ProviderRuntimePolicy::raw_pty(),
19362 Some(profile_a),
19363 );
19364
19365 let failure = shared
19366 .spawn_session_with_deadline(
19367 primary,
19368 agent("claude"),
19369 SessionMode::Pty,
19370 gate4agent_types::TerminalSize {
19371 rows: 24,
19372 columns: 80,
19373 },
19374 None,
19375 Some(profile_b),
19376 None,
19377 None,
19378 None,
19379 None,
19380 SpawnRecordPolicy::ProviderIdentityOnly,
19381 Some(Instant::now() + Duration::from_secs(30)),
19382 &[],
19383 None,
19384 ApprovalLevel::default(),
19385 None,
19386 None,
19387 )
19388 .await
19389 .unwrap_err();
19390 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19391 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19393 assert!(shared.handle.snapshot().sessions.is_empty());
19394 }
19395}
19396
19397#[cfg(test)]
19398mod spawn_characterization_tests {
19399 use super::*;
19400 use crate::session_environment::{
19401 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19402 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19403 NodeSessionPathClass,
19404 };
19405 use gate4agent_catalog::EnvMutation;
19406 use gate4agent_runtime_native::{
19407 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19408 NativeLaunchProfile, NativeLaunchProfileId,
19409 };
19410 use std::ffi::OsString;
19411 use std::sync::Arc;
19412
19413 struct EmptyEnvironmentResolver;
19414
19415 struct FixtureSecretResolver {
19416 deny: Arc<AtomicBool>,
19417 }
19418
19419 impl NodeSecretResolver for FixtureSecretResolver {
19420 fn resolve(
19421 &self,
19422 _reference: &NodeSecretReference,
19423 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19424 if self.deny.load(Ordering::Acquire) {
19425 Err(NodeSecretResolveError::Denied)
19426 } else {
19427 NodeSecretValue::text("fixture-secret")
19428 .map_err(|_| NodeSecretResolveError::Unavailable)
19429 }
19430 }
19431 }
19432
19433 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19434 fn resolve_child_environment(
19435 &self,
19436 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19437 Ok(vec![EnvMutation {
19438 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19439 value: None,
19440 }])
19441 }
19442 }
19443
19444 fn agent(value: &str) -> AgentId {
19445 AgentId::new(value).unwrap()
19446 }
19447
19448 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19449 let unique = std::time::SystemTime::now()
19450 .duration_since(std::time::UNIX_EPOCH)
19451 .unwrap()
19452 .as_nanos();
19453 std::env::temp_dir().join(format!(
19454 "gate4agent-node-{label}-{}-{unique}",
19455 std::process::id(),
19456 ))
19457 }
19458
19459 fn materialization_test_shared(
19460 deny_secret: bool,
19461 ) -> (
19462 NodeShared,
19463 NativeRuntime,
19464 ResolvedEnvironmentProfileReceipt,
19465 PathBuf,
19466 Arc<AtomicBool>,
19467 ) {
19468 let workspace = WorkspaceConfig::new(
19469 WorkspaceId::new("primary").unwrap(),
19470 std::env::current_dir().unwrap(),
19471 )
19472 .unwrap();
19473 materialization_test_shared_with_workspace(deny_secret, workspace)
19474 }
19475
19476 fn materialization_test_shared_with_workspace(
19477 deny_secret: bool,
19478 workspace: WorkspaceConfig,
19479 ) -> (
19480 NodeShared,
19481 NativeRuntime,
19482 ResolvedEnvironmentProfileReceipt,
19483 PathBuf,
19484 Arc<AtomicBool>,
19485 ) {
19486 let root = temporary_workspace_root(if deny_secret {
19487 "materialization-denied"
19488 } else {
19489 "materialization-ready"
19490 });
19491 std::fs::create_dir_all(&root).unwrap();
19492 let materialization_root = root.join("session-environments");
19493 let deny = Arc::new(AtomicBool::new(deny_secret));
19494 let resolver = Arc::new(FixtureSecretResolver {
19495 deny: Arc::clone(&deny),
19496 });
19497 let materializer = SessionEnvironmentMaterializer::new(
19498 materialization_root,
19499 resolver,
19500 )
19501 .unwrap();
19502 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19503 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19504 "materialized-claude-r1",
19505 )
19506 .unwrap();
19507 let materialization = NodeSessionMaterializationProfile::new(
19508 vec![NodeSessionEnvironmentMutation::SetSecret {
19509 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19510 reference: NodeSecretReference::new("fixture-token").unwrap(),
19511 }],
19512 vec![NodeSessionPathBinding::new(
19513 "GATE4AGENT_PROVIDER_HOME",
19514 NodeSessionPathClass::ProviderHome,
19515 )
19516 .unwrap()],
19517 vec![NodeSessionFile::secret(
19518 NodeSessionPathClass::Config,
19519 "auth/token",
19520 NodeSecretReference::new("fixture-token").unwrap(),
19521 )
19522 .unwrap()],
19523 )
19524 .unwrap();
19525 let catalog = active_registry().unwrap();
19526 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19527 let control = runtime.native_launch_profile_control();
19528 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19529 profile_id.clone(),
19530 profile_revision.clone(),
19531 agent("claude"),
19532 [NativeLaunchProfile::new(
19533 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19534 agent("claude"),
19535 TransportKind::Pty,
19536 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19537 Arc::new(EmptyEnvironmentResolver),
19538 )
19539 .unwrap()],
19540 Some(materialization),
19541 )
19542 .unwrap();
19543 let (binding, native_profiles, materialization) = node_profile.into_parts();
19544 for native_profile in native_profiles {
19545 runtime.upsert_native_launch_profile(native_profile).unwrap();
19546 }
19547 let shared = NodeShared::new_with_incarnation(
19548 handle,
19549 "fixture-token".to_owned(),
19550 NodeId::new("node-materialization-test").unwrap(),
19551 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19552 vec![workspace],
19553 vec![agent("claude")],
19554 ProviderRuntimeStatuses::default(),
19555 None,
19556 Vec::new(),
19557 Vec::new(),
19558 SpawnProfileRegistry::default(),
19559 Some(control),
19560 None,
19561 Some(materializer),
19562 None,
19563 Vec::new(),
19564 Vec::new(),
19565 Vec::new(),
19566 Vec::new(),
19567 None,
19568 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19569 );
19570 shared
19571 .environment_profiles
19572 .write()
19573 .unwrap_or_else(|poisoned| poisoned.into_inner())
19574 .insert(binding.id.clone(), binding);
19575 shared
19576 .environment_materialization_profiles
19577 .write()
19578 .unwrap_or_else(|poisoned| poisoned.into_inner())
19579 .insert(profile_id.clone(), materialization.unwrap());
19580 (
19581 shared,
19582 runtime,
19583 ResolvedEnvironmentProfileReceipt {
19584 profile_id,
19585 profile_revision,
19586 network_allowlist: None,
19587 browser_profile_id: None,
19588 },
19589 root,
19590 deny,
19591 )
19592 }
19593
19594
19595 async fn characterization_spawn(
19598 shared: &NodeShared,
19599 mode: SessionMode,
19600 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19601 required: &[ProviderRuntimeCapability],
19602 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19603 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19604 shared
19605 .spawn_session_with_deadline(
19606 WorkspaceId::new("primary").unwrap(),
19607 agent("claude"),
19608 mode,
19609 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19610 None,
19611 environment_profile,
19612 None,
19613 None,
19614 None,
19615 Some(ProviderRuntimePolicy::raw_pty()),
19616 SpawnRecordPolicy::ProviderIdentityOnly,
19617 Some(Instant::now() + Duration::from_secs(30)),
19618 required,
19619 harness_mcp,
19620 ApprovalLevel::default(),
19621 None,
19622 None,
19623 )
19624 .await
19625 }
19626
19627 fn assert_no_session_state(shared: &NodeShared) {
19628 assert!(shared.handle.snapshot().sessions.is_empty());
19629 assert!(shared
19630 .session_bindings
19631 .lock()
19632 .unwrap_or_else(|poisoned| poisoned.into_inner())
19633 .is_empty());
19634 assert!(shared
19635 .materializations
19636 .lock()
19637 .unwrap_or_else(|poisoned| poisoned.into_inner())
19638 .is_empty());
19639 }
19640
19641 #[tokio::test]
19642 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19643 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19644 let error = characterization_spawn(
19645 &shared,
19646 SessionMode::Pty,
19647 None,
19648 &[ProviderRuntimeCapability::SemanticReadiness],
19649 None,
19650 )
19651 .await
19652 .unwrap_err();
19653 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19654 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19655 assert_no_session_state(&shared);
19656 drop(shared);
19657 drop(runtime);
19658 std::fs::remove_dir_all(root).unwrap();
19659 }
19660
19661 #[tokio::test]
19662 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19663 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19664 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19665 .await
19666 .unwrap_err();
19667 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19668 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19669 assert_no_session_state(&shared);
19670 drop(shared);
19671 drop(runtime);
19672 std::fs::remove_dir_all(root).unwrap();
19673 }
19674
19675 #[test]
19676 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19677 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19678 let address = SessionAddress {
19679 workspace_id: WorkspaceId::new("primary").unwrap(),
19680 session: SessionKey {
19681 instance_id: AgentInstanceId(77),
19682 generation: SessionGeneration(1),
19683 },
19684 };
19685 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19686 let removed = shared.remove_binding(&address).expect("binding removed");
19687 assert!(removed.environment_profile.is_some());
19688 assert!(shared.remove_binding(&address).is_none());
19689 assert_no_session_state(&shared);
19690 drop(shared);
19691 drop(runtime);
19692 std::fs::remove_dir_all(root).unwrap();
19693 }
19694
19695 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19696 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19697 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19698 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19699 .expect("review current test executable as fixture helper");
19700 let registry = HarnessMcpProxyRegistry::new(helper);
19701 let spec = crate::protocol::SpawnSpec {
19702 target: crate::protocol::SpawnTarget {
19703 node_id: NodeId::new("node-characterization").unwrap(),
19704 workspace_id: WorkspaceId::new("primary").unwrap(),
19705 worktree_id: None,
19706 },
19707 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19708 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19709 overrides: crate::protocol::SpawnOverrides::default(),
19710 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19711 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19712 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19713 };
19714 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19715 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19716 let launch = HarnessMcpLaunchV1 {
19717 server_name: "fixture-harness".to_owned(),
19718 args: vec!["--session-proxy".to_owned()],
19719 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19720 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19721 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19722 trace: None,
19723 scrub_env: Vec::new(),
19724 };
19725 let expires = unix_time_ms().saturating_add(30_000);
19726 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19727 registry
19728 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19729 .unwrap()
19730 }
19731
19732 #[tokio::test]
19733 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19734 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19735 let prepared = characterization_prepared_harness("claude").await;
19736 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19737 .await
19738 .unwrap_err();
19739 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19741 let mismatched = characterization_prepared_harness("codex").await;
19742 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19743 .await
19744 .unwrap_err();
19745 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19746 assert_no_session_state(&shared);
19747 drop(shared);
19748 drop(runtime);
19749 std::fs::remove_dir_all(root).unwrap();
19750 }
19751
19752 #[tokio::test]
19753 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19754 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19755 shared.native_launch_profile_control = None;
19756 let prepared = characterization_prepared_harness("claude").await;
19757 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19758 .await
19759 .unwrap_err();
19760 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19761 assert_no_session_state(&shared);
19762 drop(shared);
19763 drop(runtime);
19764 std::fs::remove_dir_all(root).unwrap();
19765 }
19766}
19767
19768#[cfg(all(test, windows))]
19769mod tests {
19770 use super::*;
19771 use gate4agent_types::ProviderActivity;
19772 use crate::session_environment::{
19773 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19774 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19775 NodeSessionPathClass,
19776 };
19777 use gate4agent_catalog::EnvMutation;
19778 use gate4agent_runtime_native::{
19779 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19780 NativeLaunchProfile, NativeLaunchProfileId,
19781 };
19782 use std::ffi::OsString;
19783 use std::sync::Arc;
19784
19785 struct EmptyEnvironmentResolver;
19786
19787 struct FixtureSecretResolver {
19788 deny: Arc<AtomicBool>,
19789 }
19790
19791 impl NodeSecretResolver for FixtureSecretResolver {
19792 fn resolve(
19793 &self,
19794 _reference: &NodeSecretReference,
19795 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19796 if self.deny.load(Ordering::Acquire) {
19797 Err(NodeSecretResolveError::Denied)
19798 } else {
19799 NodeSecretValue::text("fixture-secret")
19800 .map_err(|_| NodeSecretResolveError::Unavailable)
19801 }
19802 }
19803 }
19804
19805 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19806 fn resolve_child_environment(
19807 &self,
19808 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19809 Ok(vec![EnvMutation {
19810 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19811 value: None,
19812 }])
19813 }
19814 }
19815
19816 fn agent(value: &str) -> AgentId {
19817 AgentId::new(value).unwrap()
19818 }
19819
19820 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19821 let catalog = active_registry().unwrap();
19822 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19823 let workspace = WorkspaceConfig::new(
19824 WorkspaceId::new("primary").unwrap(),
19825 std::env::current_dir().unwrap(),
19826 )
19827 .unwrap()
19828 .with_worktree_service_mode(mode);
19829 NodeShared::new(
19830 handle,
19831 "fixture-token".to_owned(),
19832 NodeId::new("node-terminal-test").unwrap(),
19833 vec![workspace],
19834 vec![agent("claude")],
19835 )
19836 }
19837
19838 fn terminal_test_shared() -> NodeShared {
19839 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19840 }
19841
19842 #[cfg(feature = "fixture")]
19843 #[test]
19844 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19845 let probe = SpawnManagedWorktreeV2FailureProbe::default();
19846 assert_eq!(probe.latest_code(), None);
19847
19848 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19849 assert_eq!(
19850 probe.latest_code(),
19851 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19852 );
19853
19854 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19855 assert_eq!(
19856 probe.latest_code(),
19857 Some(NodeFailureCode::SpawnDeadlineExceeded),
19858 );
19859
19860 probe.record(None);
19861 assert_eq!(probe.latest_code(), None);
19862 }
19863
19864 #[test]
19865 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19866 let source = temporary_workspace_root("launch-inventory-source");
19867 let allocation = temporary_workspace_root("launch-inventory-allocation");
19868 std::fs::create_dir_all(&source).unwrap();
19869 std::fs::create_dir_all(&allocation).unwrap();
19870 let workspace_id = WorkspaceId::new("primary").unwrap();
19871 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19872 .unwrap()
19873 .with_worktree_service_mode(WorktreeServiceMode::Managed);
19874 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19875 workspace = workspace
19876 .with_managed_worktree_profile(
19877 ManagedWorktreeProfile::new(
19878 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19879 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19880 &allocation,
19881 "gate4agent",
19882 "HEAD",
19883 ManagedWorktreeRetention::RemoveWhenReleased,
19884 )
19885 .unwrap(),
19886 )
19887 .unwrap();
19888 }
19889 let overflow = workspace.clone().with_managed_worktree_profile(
19890 ManagedWorktreeProfile::new(
19891 WorktreeProfileId::new("overflow").unwrap(),
19892 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19893 &allocation,
19894 "gate4agent",
19895 "HEAD",
19896 ManagedWorktreeRetention::Retain,
19897 )
19898 .unwrap(),
19899 );
19900 assert!(matches!(
19901 overflow,
19902 Err(NodeServerError::ManagedWorktreeProfileCapacity {
19903 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19904 ..
19905 }),
19906 ));
19907
19908 let catalog = active_registry().unwrap();
19909 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19910 let shared = NodeShared::new(
19911 handle,
19912 "fixture-token".to_owned(),
19913 NodeId::new("node-launch-inventory").unwrap(),
19914 vec![workspace],
19915 vec![agent("claude")],
19916 );
19917 let snapshot = shared.snapshot();
19918 let launch = snapshot.launch_inventory.as_ref().unwrap();
19919 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19920 assert!(launch.bundles.as_ref().unwrap().is_empty());
19921 let profiles = snapshot.workspaces[0]
19922 .managed_worktree_profiles
19923 .as_ref()
19924 .unwrap();
19925 assert_eq!(
19926 snapshot.workspaces[0].worktree_service_mode,
19927 Some(WorktreeServiceMode::Managed),
19928 );
19929 assert_eq!(
19930 profiles.profiles.len(),
19931 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19932 );
19933 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19934 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19935 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19936 }
19937 let legacy = snapshot_for_wire(
19938 &shared,
19939 true,
19940 true,
19941 false,
19942 false,
19943 true,
19944 false,
19945 true,
19946 false,
19947 true,
19948 );
19949 assert!(legacy.launch_inventory.is_none());
19950 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19951
19952 drop(shared);
19953 std::fs::remove_dir_all(source).unwrap();
19954 std::fs::remove_dir_all(allocation).unwrap();
19955 }
19956
19957 #[test]
19958 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19959 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19960 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19961 }));
19962 let source = gate4agent_types::ProviderSource {
19963 family: AdapterFamily::PtySemantic,
19964 binding: AdapterBinding::new(
19965 gate4agent_types::AdapterId::new("claude").unwrap(),
19966 "fixture/v1",
19967 gate4agent_types::AdapterVerification::SyntheticFixture,
19968 )
19969 .unwrap(),
19970 };
19971 let provider = ProviderSnapshot {
19972 sequence: 17,
19973 session: Some(ProviderSessionIdentity {
19974 key: ProviderSessionKey::SessionId,
19975 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19976 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19977 }),
19978 model: Some("PRIVATE_MODEL".to_owned()),
19979 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19980 completed_turns: 3,
19981 usage: gate4agent_types::TokenUsage {
19982 input_tokens: 10,
19983 output_tokens: 20,
19984 cache_read_tokens: 30,
19985 cache_write_tokens: 40,
19986 reasoning_tokens: 50,
19987 context_window: Some(200_000),
19988 },
19989 lead_activity: ProviderActivity::WaitingForInput,
19990 activity: ProviderActivity::WaitingForInput,
19991 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19992 active_tools: (0..9)
19993 .map(|index| gate4agent_types::ActiveProviderTool {
19994 id: format!("private-tool-id-{index}"),
19995 name: if index == 0 {
19996 format!(" Read\u{0000}{}", "x".repeat(80))
19997 } else {
19998 format!("Tool-{index}")
19999 },
20000 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20001 })
20002 .collect(),
20003 interactions: vec![gate4agent_types::ProviderInteraction {
20004 id: gate4agent_types::ProviderInteractionId(17),
20005 source: source.clone(),
20006 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20007 interaction_kind: ProviderInteractionKind::Approval,
20008 tool_name: " Approve\u{0007}Tool ".to_owned(),
20009 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20010 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20011 resume_lead_activity: Some(ProviderActivity::Working),
20012 status: ProviderInteractionStatus::Pending,
20013 }],
20014 subagents: vec![gate4agent_types::ProviderSubagent {
20015 source: source.clone(),
20016 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20017 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20018 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20019 }],
20020 sources: vec![gate4agent_types::ProviderSourceCursor {
20021 source,
20022 sequence: 17,
20023 gap_count: 2,
20024 stale: true,
20025 }],
20026 last_event: Some(ProviderEvent::Error {
20027 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20028 }),
20029 gap_count: 2,
20030 stale: true,
20031 };
20032 let address = terminal_address(3);
20033 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20034 assert_eq!(entry.address, address);
20035 assert_eq!(entry.progress.provider_sequence, 17);
20036 assert_eq!(entry.progress.active_tool_count, 9);
20037 assert_eq!(
20038 entry.progress.active_tool_labels.len(),
20039 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20040 );
20041 assert_eq!(entry.progress.subagent_count, 1);
20042 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20043 assert!(entry.progress.truncated);
20044 let encoded = serde_json::to_string(&entry).unwrap();
20045 for forbidden in [
20046 "PRIVATE_PROVIDER_SESSION",
20047 "PRIVATE_PROVIDER_PATH",
20048 "PRIVATE_MODEL",
20049 "PRIVATE_TOOL_INVENTORY",
20050 "PRIVATE_CURRENT_PROMPT",
20051 "PRIVATE_TOOL_INPUT_JSON",
20052 "PRIVATE_PROVIDER_REQUEST",
20053 "PRIVATE_INTERACTION_PROMPT",
20054 "PRIVATE_INTERACTION_AGENT",
20055 "PRIVATE_SUBAGENT_ID",
20056 "PRIVATE_SUBAGENT_TYPE",
20057 "PRIVATE_SUBAGENT_DESCRIPTION",
20058 "PRIVATE_PROVIDER_ERROR",
20059 "input_json",
20060 "current_prompt",
20061 "session_id",
20062 ] {
20063 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20064 }
20065
20066 let snapshot = NodeSnapshot {
20067 node_id: NodeId::new("node-progress-test").unwrap(),
20068 enabled_providers: vec![agent("claude")],
20069 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20070 workspaces: Vec::new(),
20071 session_records: Vec::new(),
20072 managed_worktrees: Vec::new(),
20073 launch_inventory: None,
20074 agent_progress: vec![entry],
20075 };
20076 let mut legacy_reply = ResponseEnvelope {
20077 request_id: 1,
20078 result: Ok(NodeResponse::Snapshot {
20079 event_sequence: 0,
20080 controller: None,
20081 snapshot: snapshot.clone(),
20082 }),
20083 };
20084 project_response_without_agent_progress(&mut legacy_reply);
20085 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20086 panic!("agent progress projection changed response kind");
20087 };
20088 assert!(legacy.agent_progress.is_empty());
20089 assert_eq!(snapshot.agent_progress.len(), 1);
20090 }
20091
20092 fn terminal_address(generation: u64) -> SessionAddress {
20093 SessionAddress {
20094 workspace_id: WorkspaceId::new("primary").unwrap(),
20095 session: SessionKey {
20096 instance_id: AgentInstanceId(41),
20097 generation: SessionGeneration(generation),
20098 },
20099 }
20100 }
20101
20102 fn spawn_spec_fixture() -> SpawnSpec {
20103 SpawnSpec {
20104 target: crate::protocol::SpawnTarget {
20105 node_id: NodeId::new("node-terminal-test").unwrap(),
20106 workspace_id: WorkspaceId::new("primary").unwrap(),
20107 worktree_id: None,
20108 },
20109 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20110 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20111 .unwrap(),
20112 overrides: crate::protocol::SpawnOverrides::default(),
20113 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20114 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20115 required_capabilities: SpawnRequiredCapabilities::default(),
20116 }
20117 }
20118
20119 fn environment_profile_test_shared(
20120 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20121 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20122 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20123 "local-claude-r1",
20124 )
20125 .unwrap();
20126 let spawn_profiles = SpawnProfileRegistry::new([
20127 crate::protocol::SpawnProfileDefaults {
20128 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20129 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20130 provider: agent("claude"),
20131 mode: SessionMode::Pty,
20132 terminal_size: gate4agent_types::TerminalSize {
20133 rows: 24,
20134 columns: 80,
20135 },
20136 prompt: None,
20137 bundle_id: None,
20138 context_id: None,
20139 environment_profile_id: Some(profile_id.clone()),
20140 },
20141 ])
20142 .unwrap();
20143 let catalog = active_registry().unwrap();
20144 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20145 let control = runtime.native_launch_profile_control();
20146 let node_profile = NodeEnvironmentProfile::new(
20147 profile_id.clone(),
20148 profile_revision.clone(),
20149 agent("claude"),
20150 [NativeLaunchProfile::new(
20151 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20152 agent("claude"),
20153 TransportKind::Pty,
20154 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20155 Arc::new(EmptyEnvironmentResolver),
20156 )
20157 .unwrap()],
20158 )
20159 .unwrap();
20160 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20161 for native_profile in native_profiles {
20162 runtime.upsert_native_launch_profile(native_profile).unwrap();
20163 }
20164 let workspace = WorkspaceConfig::new(
20165 WorkspaceId::new("primary").unwrap(),
20166 std::env::current_dir().unwrap(),
20167 )
20168 .unwrap();
20169 let shared = NodeShared::new_with_incarnation(
20170 handle,
20171 "fixture-token".to_owned(),
20172 NodeId::new("node-terminal-test").unwrap(),
20173 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20174 vec![workspace],
20175 vec![agent("claude")],
20176 ProviderRuntimeStatuses::default(),
20177 None,
20178 Vec::new(),
20179 Vec::new(),
20180 spawn_profiles,
20181 Some(control),
20182 None,
20183 None,
20184 None,
20185 Vec::new(),
20186 Vec::new(),
20187 Vec::new(),
20188 Vec::new(),
20189 None,
20190 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20191 );
20192 shared
20193 .environment_profiles
20194 .write()
20195 .unwrap_or_else(|poisoned| poisoned.into_inner())
20196 .insert(binding.id.clone(), binding);
20197 (
20198 shared,
20199 runtime,
20200 ResolvedEnvironmentProfileReceipt {
20201 profile_id,
20202 profile_revision,
20203 network_allowlist: None,
20204 browser_profile_id: None,
20205 },
20206 )
20207 }
20208
20209 fn materialization_test_shared(
20210 deny_secret: bool,
20211 ) -> (
20212 NodeShared,
20213 NativeRuntime,
20214 ResolvedEnvironmentProfileReceipt,
20215 PathBuf,
20216 Arc<AtomicBool>,
20217 ) {
20218 let workspace = WorkspaceConfig::new(
20219 WorkspaceId::new("primary").unwrap(),
20220 std::env::current_dir().unwrap(),
20221 )
20222 .unwrap();
20223 materialization_test_shared_with_workspace(deny_secret, workspace)
20224 }
20225
20226 fn materialization_test_shared_with_workspace(
20227 deny_secret: bool,
20228 workspace: WorkspaceConfig,
20229 ) -> (
20230 NodeShared,
20231 NativeRuntime,
20232 ResolvedEnvironmentProfileReceipt,
20233 PathBuf,
20234 Arc<AtomicBool>,
20235 ) {
20236 let root = temporary_workspace_root(if deny_secret {
20237 "materialization-denied"
20238 } else {
20239 "materialization-ready"
20240 });
20241 std::fs::create_dir_all(&root).unwrap();
20242 let materialization_root = root.join("session-environments");
20243 let deny = Arc::new(AtomicBool::new(deny_secret));
20244 let resolver = Arc::new(FixtureSecretResolver {
20245 deny: Arc::clone(&deny),
20246 });
20247 let materializer = SessionEnvironmentMaterializer::new(
20248 materialization_root,
20249 resolver,
20250 )
20251 .unwrap();
20252 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20253 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20254 "materialized-claude-r1",
20255 )
20256 .unwrap();
20257 let materialization = NodeSessionMaterializationProfile::new(
20258 vec![NodeSessionEnvironmentMutation::SetSecret {
20259 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20260 reference: NodeSecretReference::new("fixture-token").unwrap(),
20261 }],
20262 vec![NodeSessionPathBinding::new(
20263 "GATE4AGENT_PROVIDER_HOME",
20264 NodeSessionPathClass::ProviderHome,
20265 )
20266 .unwrap()],
20267 vec![NodeSessionFile::secret(
20268 NodeSessionPathClass::Config,
20269 "auth/token",
20270 NodeSecretReference::new("fixture-token").unwrap(),
20271 )
20272 .unwrap()],
20273 )
20274 .unwrap();
20275 let catalog = active_registry().unwrap();
20276 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20277 let control = runtime.native_launch_profile_control();
20278 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20279 profile_id.clone(),
20280 profile_revision.clone(),
20281 agent("claude"),
20282 [NativeLaunchProfile::new(
20283 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20284 agent("claude"),
20285 TransportKind::Pty,
20286 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20287 Arc::new(EmptyEnvironmentResolver),
20288 )
20289 .unwrap()],
20290 Some(materialization),
20291 )
20292 .unwrap();
20293 let (binding, native_profiles, materialization) = node_profile.into_parts();
20294 for native_profile in native_profiles {
20295 runtime.upsert_native_launch_profile(native_profile).unwrap();
20296 }
20297 let shared = NodeShared::new_with_incarnation(
20298 handle,
20299 "fixture-token".to_owned(),
20300 NodeId::new("node-materialization-test").unwrap(),
20301 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20302 vec![workspace],
20303 vec![agent("claude")],
20304 ProviderRuntimeStatuses::default(),
20305 None,
20306 Vec::new(),
20307 Vec::new(),
20308 SpawnProfileRegistry::default(),
20309 Some(control),
20310 None,
20311 Some(materializer),
20312 None,
20313 Vec::new(),
20314 Vec::new(),
20315 Vec::new(),
20316 Vec::new(),
20317 None,
20318 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20319 );
20320 shared
20321 .environment_profiles
20322 .write()
20323 .unwrap_or_else(|poisoned| poisoned.into_inner())
20324 .insert(binding.id.clone(), binding);
20325 shared
20326 .environment_materialization_profiles
20327 .write()
20328 .unwrap_or_else(|poisoned| poisoned.into_inner())
20329 .insert(profile_id.clone(), materialization.unwrap());
20330 (
20331 shared,
20332 runtime,
20333 ResolvedEnvironmentProfileReceipt {
20334 profile_id,
20335 profile_revision,
20336 network_allowlist: None,
20337 browser_profile_id: None,
20338 },
20339 root,
20340 deny,
20341 )
20342 }
20343
20344 #[cfg(feature = "fixture")]
20345 #[derive(Clone, Copy)]
20346 enum CodexHomeProfile {
20347 Missing,
20348 WrongClass,
20349 Exact,
20350 }
20351
20352 #[cfg(feature = "fixture")]
20353 fn codex_bundle_test_shared(
20354 home_profile: CodexHomeProfile,
20355 ) -> (
20356 NodeShared,
20357 NativeRuntime,
20358 ResolvedEnvironmentProfileReceipt,
20359 NodeBundle,
20360 PathBuf,
20361 ) {
20362 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20363 const CLAUDE_MANIFEST: &[u8] =
20364 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20365 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20366 const DIGEST: &str =
20367 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20368
20369 let (shared, mut runtime, _claude_receipt, root, _deny) =
20370 materialization_test_shared(false);
20371 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20372 CodexHomeProfile::Missing => "codex-home-missing",
20373 CodexHomeProfile::WrongClass => "codex-home-wrong",
20374 CodexHomeProfile::Exact => "codex-home-exact",
20375 })
20376 .unwrap();
20377 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20378 "codex-home-r1",
20379 )
20380 .unwrap();
20381 let materialization = match home_profile {
20382 CodexHomeProfile::Missing => None,
20383 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20384 Some(NodeSessionMaterializationProfile::new(
20385 Vec::new(),
20386 vec![NodeSessionPathBinding::new(
20387 "CODEX_HOME",
20388 if matches!(home_profile, CodexHomeProfile::Exact) {
20389 NodeSessionPathClass::ProviderHome
20390 } else {
20391 NodeSessionPathClass::Config
20392 },
20393 )
20394 .unwrap()],
20395 Vec::new(),
20396 )
20397 .unwrap())
20398 }
20399 };
20400 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20401 profile_id.clone(),
20402 profile_revision.clone(),
20403 agent("codex"),
20404 [NativeLaunchProfile::new(
20405 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20406 agent("codex"),
20407 TransportKind::Pty,
20408 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20409 Arc::new(EmptyEnvironmentResolver),
20410 )
20411 .unwrap()],
20412 materialization,
20413 )
20414 .unwrap();
20415 let (binding, native_profiles, materialization) = node_profile.into_parts();
20416 for native_profile in native_profiles {
20417 runtime.upsert_native_launch_profile(native_profile).unwrap();
20418 }
20419 shared
20420 .environment_profiles
20421 .write()
20422 .unwrap_or_else(|poisoned| poisoned.into_inner())
20423 .insert(binding.id.clone(), binding);
20424 if let Some(materialization) = materialization {
20425 shared
20426 .environment_materialization_profiles
20427 .write()
20428 .unwrap_or_else(|poisoned| poisoned.into_inner())
20429 .insert(profile_id.clone(), materialization);
20430 }
20431
20432 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20433 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20434 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20435 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20436 std::fs::write(
20437 bundle_source.join(".claude-plugin/plugin.json"),
20438 CLAUDE_MANIFEST,
20439 )
20440 .unwrap();
20441 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20442 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20443 let bundle = NodeBundle::new(
20444 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20445 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20446 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20447 &bundle_source,
20448 )
20449 .unwrap();
20450 *shared
20451 .bundle_catalog
20452 .write()
20453 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20454 BundleCatalog::new([bundle.clone()]).unwrap();
20455 (
20456 shared,
20457 runtime,
20458 ResolvedEnvironmentProfileReceipt {
20459 profile_id,
20460 profile_revision,
20461 network_allowlist: None,
20462 browser_profile_id: None,
20463 },
20464 bundle,
20465 root,
20466 )
20467 }
20468
20469 #[cfg(feature = "fixture")]
20470 #[test]
20471 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20472 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20473 let (shared, runtime, receipt, bundle, root) =
20474 codex_bundle_test_shared(home_profile);
20475 let error = shared
20476 .bundle_layout(
20477 &agent("codex"),
20478 SessionMode::Pty,
20479 Some(&receipt),
20480 &bundle,
20481 )
20482 .unwrap_err();
20483 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20484 assert!(shared
20485 .materializations
20486 .lock()
20487 .unwrap_or_else(|poisoned| poisoned.into_inner())
20488 .is_empty());
20489 let materialization_root = root.join("session-environments");
20490 assert!(!std::fs::read_dir(&materialization_root)
20491 .unwrap()
20492 .filter_map(Result::ok)
20493 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20494 drop(shared);
20495 drop(runtime);
20496 let _ = std::fs::remove_dir_all(root);
20497 }
20498 }
20499
20500 #[cfg(feature = "fixture")]
20501 #[test]
20502 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20503 let (shared, runtime, receipt, bundle, root) =
20504 codex_bundle_test_shared(CodexHomeProfile::Exact);
20505 let address = SessionAddress {
20506 workspace_id: WorkspaceId::new("primary").unwrap(),
20507 session: SessionKey {
20508 instance_id: AgentInstanceId(310),
20509 generation: SessionGeneration(1),
20510 },
20511 };
20512 let (overlay, guard) = shared
20513 .prepare_session_materialization(
20514 &address,
20515 &agent("codex"),
20516 SessionMode::Pty,
20517 Some(&receipt),
20518 Some(&bundle.receipt()),
20519 None,
20520 None,
20521 )
20522 .unwrap()
20523 .unwrap();
20524 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20525 let ownership = shared
20526 .materializations
20527 .lock()
20528 .unwrap_or_else(|poisoned| poisoned.into_inner())
20529 .get(guard.id().unwrap())
20530 .unwrap()
20531 .clone();
20532 let (profile, _, _) = shared
20533 .materialization_profile(
20534 &agent("codex"),
20535 SessionMode::Pty,
20536 Some(&receipt),
20537 Some(&bundle.receipt()),
20538 None,
20539 )
20540 .unwrap()
20541 .unwrap();
20542 let environment = shared
20543 .session_environment_materializer
20544 .as_ref()
20545 .unwrap()
20546 .resolve_environment(&ownership, &profile)
20547 .unwrap();
20548 assert!(environment.iter().any(|mutation| {
20549 mutation.key == OsString::from("CODEX_HOME")
20550 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20551 }));
20552 assert_eq!(
20553 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20554 .unwrap(),
20555 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20556 );
20557 assert!(std::fs::read_dir(ownership.bundle_root())
20558 .unwrap()
20559 .next()
20560 .is_none());
20561 drop(guard);
20562 drop(shared);
20563 drop(runtime);
20564 let _ = std::fs::remove_dir_all(root);
20565 }
20566
20567 #[cfg(feature = "fixture")]
20568 #[test]
20569 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20570 let (shared, runtime, receipt, bundle, root) =
20571 codex_bundle_test_shared(CodexHomeProfile::Exact);
20572 let bundle_receipt = bundle.receipt();
20573 let address = SessionAddress {
20574 workspace_id: WorkspaceId::new("primary").unwrap(),
20575 session: SessionKey {
20576 instance_id: AgentInstanceId(311),
20577 generation: SessionGeneration(1),
20578 },
20579 };
20580 let (_overlay, guard) = shared
20581 .prepare_session_materialization(
20582 &address,
20583 &agent("codex"),
20584 SessionMode::Pty,
20585 Some(&receipt),
20586 Some(&bundle_receipt),
20587 None,
20588 None,
20589 )
20590 .unwrap()
20591 .unwrap();
20592 let materialization_id = guard.id().unwrap().clone();
20593 let skill_path = shared
20594 .materializations
20595 .lock()
20596 .unwrap_or_else(|poisoned| poisoned.into_inner())
20597 .get(&materialization_id)
20598 .unwrap()
20599 .provider_home()
20600 .join("skills/review-code/SKILL.md");
20601 let record_id = shared
20602 .bind_spawn_session_with_materialization(
20603 &address,
20604 agent("codex"),
20605 SessionMode::Pty,
20606 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20607 SpawnRecordPolicy::ProviderIdentityOnly,
20608 Some(receipt.clone()),
20609 Some(bundle_receipt.clone()),
20610 None,
20611 Some(materialization_id.clone()),
20612 )
20613 .unwrap()
20614 .unwrap();
20615 guard.retain();
20616 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20617
20618 let error = match shared.resolve_record_materialization(
20619 &record_id,
20620 &agent("codex"),
20621 SessionMode::Pty,
20622 Some(&receipt),
20623 Some(&bundle_receipt),
20624 None,
20625 ) {
20626 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20627 Err(error) => error,
20628 };
20629 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20630 assert_eq!(
20631 shared
20632 .materializations
20633 .lock()
20634 .unwrap_or_else(|poisoned| poisoned.into_inner())
20635 .get(&materialization_id)
20636 .unwrap()
20637 .state(),
20638 MaterializationState::RecoveryRequired,
20639 );
20640 drop(shared);
20641 drop(runtime);
20642 let _ = std::fs::remove_dir_all(root);
20643 }
20644
20645 fn bind_record_owned_materialization_fixture(
20646 shared: &NodeShared,
20647 receipt: &ResolvedEnvironmentProfileReceipt,
20648 instance_id: u64,
20649 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20650 let address = SessionAddress {
20651 workspace_id: WorkspaceId::new("primary").unwrap(),
20652 session: SessionKey {
20653 instance_id: AgentInstanceId(instance_id),
20654 generation: SessionGeneration(1),
20655 },
20656 };
20657 let (overlay, materialization_guard) = shared
20658 .prepare_session_materialization(
20659 &address,
20660 &agent("claude"),
20661 SessionMode::Pty,
20662 Some(receipt),
20663 None,
20664 None,
20665 None,
20666 )
20667 .unwrap()
20668 .unwrap();
20669 let materialization_id = materialization_guard.id().unwrap().clone();
20670 let materialization_root = shared
20671 .materializations
20672 .lock()
20673 .unwrap_or_else(|poisoned| poisoned.into_inner())
20674 .get(&materialization_id)
20675 .unwrap()
20676 .root()
20677 .to_path_buf();
20678 let selection = shared
20679 .select_environment_profile(
20680 address.session.instance_id,
20681 &agent("claude"),
20682 SessionMode::Pty,
20683 Some(receipt),
20684 )
20685 .unwrap()
20686 .unwrap();
20687 assert!(shared
20688 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20689 .unwrap()
20690 .is_none());
20691 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20692 let record_id = shared
20693 .bind_spawn_session_with_materialization(
20694 &address,
20695 agent("claude"),
20696 SessionMode::Pty,
20697 policy,
20698 SpawnRecordPolicy::ProviderIdentityOnly,
20699 Some(receipt.clone()),
20700 None,
20701 None,
20702 Some(materialization_id.clone()),
20703 )
20704 .unwrap()
20705 .unwrap();
20706 selection.retain();
20707 materialization_guard.retain();
20708 (address, record_id, materialization_id, materialization_root)
20709 }
20710
20711 #[test]
20712 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20713 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20714 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20715 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20716 }));
20717 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20718 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20719 let mut spec = spawn_spec_fixture();
20720 spec.expected_profile_revision =
20721 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20722 assert!(matches!(
20723 &spec.overrides.environment_profile_id,
20724 crate::protocol::SpawnOverride::Inherit,
20725 ));
20726 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20727 .requires_child_environment_profile_capability());
20728 assert!(request_requires_child_environment_profile(
20729 &shared,
20730 &NodeRequest::SpawnSpec { spec: spec.clone() },
20731 ));
20732
20733 let address = terminal_address(1);
20734 shared.bind_session_with_policy(
20735 &address,
20736 ProviderRuntimePolicy::raw_pty(),
20737 Some(environment_profile.clone()),
20738 );
20739 assert!(request_requires_child_environment_profile(
20740 &shared,
20741 &NodeRequest::Input {
20742 session: address.clone(),
20743 text: "x".to_owned(),
20744 },
20745 ));
20746
20747 let mut record = record("claude", "profile-record");
20748 record.active_session = Some(address.clone());
20749 record.environment_profile = Some(environment_profile.clone());
20750 let record_id = record.record_id.clone();
20751 shared.insert_record(record.clone()).unwrap();
20752 assert!(request_requires_child_environment_profile(
20753 &shared,
20754 &NodeRequest::ResumeSessionRecord {
20755 record_id,
20756 terminal_size: gate4agent_types::TerminalSize {
20757 rows: 24,
20758 columns: 80,
20759 },
20760 initial_prompt: None,
20761 },
20762 ));
20763
20764 let snapshot = snapshot_for_wire(
20765 &shared, false, true, true, true, false, false, false, false, true,
20766 );
20767 assert_eq!(snapshot.session_records.len(), 1);
20768 assert!(snapshot.session_records[0].environment_profile.is_none());
20769 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20770 sequence: 1,
20771 event: NodeEvent::SessionRecordUpserted { record },
20772 });
20773 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20774 panic!("record projection changed the event kind");
20775 };
20776 assert!(record.environment_profile.is_none());
20777
20778 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20779 let mut reply = ResponseEnvelope {
20780 request_id: 1,
20781 result: Ok(NodeResponse::SpawnSpecAccepted {
20782 receipt: resolved.receipt_with_environment(
20783 shared.incarnation_id,
20784 address,
20785 Some(environment_profile),
20786 ),
20787 }),
20788 };
20789 project_response_without_child_environment_profile(&mut reply);
20790 assert_eq!(
20791 reply.result.unwrap_err().code,
20792 NodeFailureCode::UnsupportedCapability,
20793 );
20794 }
20795
20796 #[test]
20797 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20798 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20799 let snapshot = shared.snapshot();
20800 let profiles = snapshot
20801 .launch_inventory
20802 .as_ref()
20803 .and_then(|inventory| inventory.spawn_profiles.as_ref())
20804 .unwrap();
20805 assert_eq!(profiles.len(), 1);
20806 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20807 assert!(snapshot.requires_child_environment_profile_capability());
20808
20809 let mut legacy = snapshot.clone();
20810 clear_snapshot_child_environment_profiles(&mut legacy);
20811 assert!(legacy
20812 .launch_inventory
20813 .as_ref()
20814 .unwrap()
20815 .spawn_profiles
20816 .as_ref()
20817 .unwrap()[0]
20818 .environment_profile
20819 .is_none());
20820 assert!(!legacy.requires_child_environment_profile_capability());
20821
20822 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20823 let bindings = shared
20824 .environment_profiles
20825 .read()
20826 .unwrap_or_else(|poisoned| poisoned.into_inner())
20827 .clone();
20828 let mut provider_mismatch = bindings.clone();
20829 provider_mismatch
20830 .values_mut()
20831 .next()
20832 .unwrap()
20833 .provider = agent("codex");
20834 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20835 let mut mode_mismatch = profile;
20836 mode_mismatch.mode = SessionMode::Inline;
20837 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20838
20839 shared
20840 .environment_profiles
20841 .write()
20842 .unwrap_or_else(|poisoned| poisoned.into_inner())
20843 .clear();
20844 assert!(shared
20845 .snapshot()
20846 .launch_inventory
20847 .unwrap()
20848 .spawn_profiles
20849 .unwrap()
20850 .is_empty());
20851 }
20852
20853 #[test]
20854 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20855 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20856 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20857 }));
20858 let shared = terminal_test_shared();
20859 let bundle = ResolvedBundleReceipt {
20860 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20861 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20862 digest: crate::protocol::SpawnBundleDigest::new(format!(
20863 "sha256:{}",
20864 "0".repeat(64),
20865 ))
20866 .unwrap(),
20867 };
20868 let mut bundled = record("claude", "bundle-record");
20869 bundled.bundle = Some(bundle.clone());
20870 let record_id = bundled.record_id.clone();
20871 shared.insert_record(bundled.clone()).unwrap();
20872 assert!(request_requires_session_bundle(
20873 &shared,
20874 &NodeRequest::ForgetSessionRecord {
20875 record_id: record_id.clone(),
20876 },
20877 ));
20878
20879 let snapshot = snapshot_for_wire(
20880 &shared, false, true, true, true, true, false, false, false, true,
20881 );
20882 assert!(snapshot.session_records[0].bundle.is_none());
20883 let launch = snapshot.launch_inventory.as_ref().unwrap();
20884 assert!(launch.spawn_profiles.is_some());
20885 assert!(launch.bundles.is_none());
20886 let event = project_event_without_session_bundle(NodeEventEnvelope {
20887 sequence: 1,
20888 event: NodeEvent::SessionRecordUpserted { record: bundled },
20889 });
20890 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20891 panic!("record projection changed the event kind");
20892 };
20893 assert!(record.bundle.is_none());
20894
20895 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20896 let mut reply = ResponseEnvelope {
20897 request_id: 1,
20898 result: Ok(NodeResponse::SpawnSpecAccepted {
20899 receipt: resolved.receipt_with_materialization(
20900 shared.incarnation_id,
20901 terminal_address(17),
20902 None,
20903 Some(bundle),
20904 None,
20905 ),
20906 }),
20907 };
20908 project_response_without_session_bundle(&mut reply);
20909 assert_eq!(
20910 reply.result.unwrap_err().code,
20911 NodeFailureCode::UnsupportedCapability,
20912 );
20913 }
20914
20915 #[test]
20916 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20917 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20918 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20919 }));
20920 let context_id = SpawnContextId::new("context-review").unwrap();
20921 let context = ResolvedContextPackReceipt {
20922 id: context_id.clone(),
20923 digest: crate::protocol::SpawnContextDigest::new(format!(
20924 "sha256:{}",
20925 "c".repeat(64),
20926 ))
20927 .unwrap(),
20928 lineage: ContextPackLineageReceipt {
20929 source_node_id: NodeId::new("node-source").unwrap(),
20930 source_session: terminal_address(1),
20931 source_provider: agent("kimi"),
20932 },
20933 source_message_count: 2,
20934 retained_message_count: 2,
20935 byte_len: 256,
20936 truncated: false,
20937 };
20938 assert!(context.is_valid());
20939 let mut bound_record = record("codex", "context-record");
20940 bound_record.context_id = Some(context_id);
20941 bound_record.context = Some(context.clone());
20942 let session = gate4agent_types::SessionSnapshot {
20943 instance_id: AgentInstanceId(91),
20944 agent_id: agent("kimi"),
20945 transport: TransportKind::Pty,
20946 generation: SessionGeneration(3),
20947 status: SessionStatus::Running,
20948 pending_operation: None,
20949 pending_input: None,
20950 process_id: Some(9001),
20951 terminal_size: None,
20952 terminal_frame: None,
20953 terminal_stale: None,
20954 session_options: None,
20955 capabilities: gate4agent_types::CapabilitySnapshot::default(),
20956 history: gate4agent_types::HistorySnapshot {
20957 pending: None,
20958 candidates: vec![HistoryCandidateSummary {
20959 id: "candidate-opaque".to_owned(),
20960 session_id_hint: "session-hint".to_owned(),
20961 modified_at_unix_ms: Some(1),
20962 }],
20963 loaded_candidate_id: Some("candidate-opaque".to_owned()),
20964 loaded: Some(HistorySessionRecord {
20965 session_id: "private-vendor-session".to_owned(),
20966 title: Some("private title".to_owned()),
20967 cwd: Some(r"C:\private\history-root".to_owned()),
20968 model: Some("private-model".to_owned()),
20969 message_count: 2,
20970 completed_turn_count: None,
20971 total_tokens: 19,
20972 messages: vec![
20973 gate4agent_types::HistoryMessageRecord {
20974 role: gate4agent_types::HistoryMessageRole::User,
20975 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20976 },
20977 gate4agent_types::HistoryMessageRecord {
20978 role: gate4agent_types::HistoryMessageRole::Assistant,
20979 text: "private answer".to_owned(),
20980 },
20981 ],
20982 }),
20983 last_error: None,
20984 },
20985 resume: gate4agent_types::ResumeSnapshot::default(),
20986 foreground: gate4agent_types::ForegroundSnapshot::default(),
20987 provider: gate4agent_types::ProviderSnapshot::default(),
20988 screen_state: gate4agent_types::PtyScreenState::default(),
20989 };
20990 let original = NodeSnapshot {
20991 node_id: NodeId::new("node-terminal-test").unwrap(),
20992 enabled_providers: vec![agent("kimi"), agent("codex")],
20993 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20994 workspaces: vec![WorkspaceSnapshot {
20995 workspace_id: WorkspaceId::new("primary").unwrap(),
20996 canonical_root: opaque_windows_path(
20997 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20998 ),
20999 sessions: vec![session],
21000 worktree_service_mode: None,
21001 managed_worktree_profiles: None,
21002 }],
21003 session_records: vec![bound_record],
21004 managed_worktrees: Vec::new(),
21005 launch_inventory: None,
21006 agent_progress: Vec::new(),
21007 };
21008
21009 let mut negotiated = original.clone();
21010 project_snapshot_history_for_wire(&mut negotiated, true);
21011 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21012 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21013 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21014 assert!(!serde_json::to_string(&negotiated)
21015 .unwrap()
21016 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21017
21018 let mut legacy = original;
21019 project_snapshot_history_for_wire(&mut legacy, false);
21020 clear_snapshot_context_packs(&mut legacy);
21021 assert_eq!(
21022 legacy.workspaces[0].sessions[0].history,
21023 gate4agent_types::HistorySnapshot::default(),
21024 );
21025 assert!(legacy.session_records[0].context_id.is_none());
21026 assert!(legacy.session_records[0].context.is_none());
21027 let encoded = serde_json::to_string(&legacy).unwrap();
21028 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21029 assert!(!encoded.contains("context-review"));
21030
21031 let mut reply = ResponseEnvelope {
21032 request_id: 7,
21033 result: Ok(NodeResponse::ContextPackExported { context }),
21034 };
21035 project_response_without_context_pack(&mut reply);
21036 assert_eq!(
21037 reply.result.unwrap_err().code,
21038 NodeFailureCode::UnsupportedCapability,
21039 );
21040 }
21041
21042 #[test]
21043 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21044 let shared = terminal_test_shared();
21045 let history = HistorySessionRecord {
21046 session_id: "fixture-history".to_owned(),
21047 title: None,
21048 cwd: None,
21049 model: None,
21050 message_count: 1,
21051 completed_turn_count: None,
21052 total_tokens: 0,
21053 messages: vec![gate4agent_types::HistoryMessageRecord {
21054 role: gate4agent_types::HistoryMessageRole::User,
21055 text: "bounded handoff".to_owned(),
21056 }],
21057 };
21058 let pack = NodeContextPack::export(
21059 ContextPackLineageReceipt {
21060 source_node_id: shared.node_id.clone(),
21061 source_session: terminal_address(1),
21062 source_provider: agent("claude"),
21063 },
21064 &history,
21065 )
21066 .unwrap();
21067 let context = shared
21068 .context_catalog
21069 .write()
21070 .unwrap_or_else(|poisoned| poisoned.into_inner())
21071 .insert(pack)
21072 .unwrap();
21073 let mut retained = record("codex", "context-retained-record");
21074 retained.context_id = Some(context.id.clone());
21075 retained.context = Some(context.clone());
21076 let record_id = retained.record_id.clone();
21077 shared.insert_record(retained).unwrap();
21078 assert_eq!(
21079 shared.forget_context_pack(&context.id).unwrap_err().code,
21080 NodeFailureCode::ContextPackBusy,
21081 );
21082 assert!(shared
21083 .context_catalog
21084 .read()
21085 .unwrap_or_else(|poisoned| poisoned.into_inner())
21086 .get(&context.id)
21087 .is_some());
21088 shared
21089 .session_records
21090 .lock()
21091 .unwrap_or_else(|poisoned| poisoned.into_inner())
21092 .records
21093 .remove(&record_id);
21094
21095 let mut spec = spawn_spec_fixture();
21096 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21097 value: context.id.clone(),
21098 };
21099 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21100 let receipt = resolved.receipt_with_materialization(
21101 shared.incarnation_id,
21102 terminal_address(2),
21103 None,
21104 None,
21105 Some(context.clone()),
21106 );
21107 assert!(receipt.context_binding_is_valid());
21108 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21109
21110 shared.forget_context_pack(&context.id).unwrap();
21111 assert!(shared
21112 .context_catalog
21113 .read()
21114 .unwrap_or_else(|poisoned| poisoned.into_inner())
21115 .get(&context.id)
21116 .is_none());
21117 assert!(shared
21118 .replay_spawn_spec(&spec, Instant::now())
21119 .unwrap()
21120 .is_none());
21121 }
21122
21123 #[test]
21124 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21125 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21126 let address = terminal_address(1);
21127 let selection = shared
21128 .select_environment_profile(
21129 address.session.instance_id,
21130 &agent("claude"),
21131 SessionMode::Pty,
21132 Some(&environment_profile),
21133 )
21134 .unwrap()
21135 .unwrap();
21136 shared.bind_session_with_policy(
21137 &address,
21138 ProviderRuntimePolicy::raw_pty(),
21139 Some(environment_profile.clone()),
21140 );
21141 selection.retain();
21142 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21143 assert!(matches!(
21144 runtime.remove_native_launch_profile(&native_id),
21145 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21146 ));
21147 assert!(shared.remove_binding(&address).is_some());
21148 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21149
21150 let mismatched = ResolvedEnvironmentProfileReceipt {
21151 profile_id: environment_profile.profile_id,
21152 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21153 "local-claude-r2",
21154 )
21155 .unwrap(),
21156 network_allowlist: None,
21157 browser_profile_id: None,
21158 };
21159 let error = match shared.select_environment_profile(
21160 AgentInstanceId(42),
21161 &agent("claude"),
21162 SessionMode::Pty,
21163 Some(&mismatched),
21164 ) {
21165 Ok(_) => panic!("mismatched environment profile revision was selected"),
21166 Err(error) => error,
21167 };
21168 assert_eq!(
21169 error.code,
21170 NodeFailureCode::EnvironmentProfileBindingMismatch,
21171 );
21172 assert!(!shared
21173 .session_bindings
21174 .lock()
21175 .unwrap_or_else(|poisoned| poisoned.into_inner())
21176 .contains_key(&AgentInstanceId(42)));
21177 }
21178
21179 #[test]
21180 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21181 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21182 let address = terminal_address(1);
21183 let selection = shared
21184 .select_environment_profile(
21185 address.session.instance_id,
21186 &agent("claude"),
21187 SessionMode::Pty,
21188 Some(&environment_profile),
21189 )
21190 .unwrap()
21191 .unwrap();
21192 let overlay = NativeInstanceLaunchOverlay::new(
21193 agent("claude"),
21194 TransportKind::Pty,
21195 vec![EnvMutation {
21196 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21197 value: Some(OsString::from("fixture-secret")),
21198 }],
21199 vec![
21200 OsString::from("--plugin-dir"),
21201 std::env::current_dir().unwrap().into_os_string(),
21202 ],
21203 )
21204 .unwrap();
21205 let instance_overlay = shared
21206 .install_prepared_launch_overlay(
21207 address.session.instance_id,
21208 PreparedNativeLaunchOverlay::Instance(overlay),
21209 )
21210 .unwrap()
21211 .unwrap();
21212 let bundle = ResolvedBundleReceipt {
21213 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21214 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21215 digest: crate::protocol::SpawnBundleDigest::new(format!(
21216 "sha256:{}",
21217 "0".repeat(64),
21218 ))
21219 .unwrap(),
21220 };
21221 shared.bind_session_with_materialization(
21222 &address,
21223 ProviderRuntimePolicy::raw_pty(),
21224 Some(environment_profile),
21225 Some(bundle),
21226 None,
21227 None,
21228 );
21229 selection.retain();
21230 instance_overlay.retain();
21231
21232 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21233 assert!(matches!(
21234 runtime.remove_native_launch_profile(&native_id),
21235 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21236 ));
21237 assert!(shared.remove_binding(&address).is_some());
21238 assert!(!shared
21239 .native_launch_profile_control
21240 .as_ref()
21241 .unwrap()
21242 .clear_native_instance_launch_overlay(address.session.instance_id));
21243 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21244 }
21245
21246 #[test]
21247 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21248 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21249 let address = SessionAddress {
21250 workspace_id: WorkspaceId::new("primary").unwrap(),
21251 session: SessionKey {
21252 instance_id: AgentInstanceId(201),
21253 generation: SessionGeneration(1),
21254 },
21255 };
21256 let error = match shared.prepare_session_materialization(
21257 &address,
21258 &agent("claude"),
21259 SessionMode::Pty,
21260 Some(&receipt),
21261 None,
21262 None,
21263 None,
21264 ) {
21265 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21266 Err(error) => error,
21267 };
21268 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21269 assert!(shared.handle.snapshot().sessions.is_empty());
21270 assert!(shared
21271 .session_bindings
21272 .lock()
21273 .unwrap_or_else(|poisoned| poisoned.into_inner())
21274 .is_empty());
21275 assert!(shared
21276 .materializations
21277 .lock()
21278 .unwrap_or_else(|poisoned| poisoned.into_inner())
21279 .is_empty());
21280 drop(shared);
21281 drop(runtime);
21282 std::fs::remove_dir_all(root).unwrap();
21283 }
21284
21285 #[test]
21286 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21287 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21288 let address = SessionAddress {
21289 workspace_id: WorkspaceId::new("primary").unwrap(),
21290 session: SessionKey {
21291 instance_id: AgentInstanceId(202),
21292 generation: SessionGeneration(1),
21293 },
21294 };
21295 let (overlay, materialization_guard) = shared
21296 .prepare_session_materialization(
21297 &address,
21298 &agent("claude"),
21299 SessionMode::Pty,
21300 Some(&receipt),
21301 None,
21302 None,
21303 None,
21304 )
21305 .unwrap()
21306 .unwrap();
21307 let materialization_id = materialization_guard.id().unwrap().clone();
21308 let materialization_root = shared
21309 .materializations
21310 .lock()
21311 .unwrap_or_else(|poisoned| poisoned.into_inner())
21312 .get(&materialization_id)
21313 .unwrap()
21314 .root()
21315 .to_path_buf();
21316 let selection = shared
21317 .select_environment_profile(
21318 address.session.instance_id,
21319 &agent("claude"),
21320 SessionMode::Pty,
21321 Some(&receipt),
21322 )
21323 .unwrap()
21324 .unwrap();
21325 assert!(shared
21326 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21327 .unwrap()
21328 .is_none());
21329 shared.bind_session_with_materialization(
21330 &address,
21331 ProviderRuntimePolicy::raw_pty(),
21332 Some(receipt),
21333 None,
21334 None,
21335 Some(materialization_id),
21336 );
21337 selection.retain();
21338 materialization_guard.retain();
21339
21340 assert!(materialization_root.is_dir());
21341 assert!(shared
21342 .session_bindings
21343 .lock()
21344 .unwrap_or_else(|poisoned| poisoned.into_inner())
21345 .contains_key(&address.session.instance_id));
21346
21347 let removed = shared.remove_binding(&address).unwrap();
21348 shared
21349 .cleanup_session_owned_materialization(&address, &removed)
21350 .unwrap();
21351 assert!(!materialization_root.exists());
21352 assert!(shared
21353 .materializations
21354 .lock()
21355 .unwrap_or_else(|poisoned| poisoned.into_inner())
21356 .is_empty());
21357 drop(shared);
21358 drop(runtime);
21359 std::fs::remove_dir_all(root).unwrap();
21360 }
21361
21362 #[tokio::test]
21363 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21364 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21365 let address = SessionAddress {
21366 workspace_id: WorkspaceId::new("primary").unwrap(),
21367 session: SessionKey {
21368 instance_id: AgentInstanceId(203),
21369 generation: SessionGeneration(1),
21370 },
21371 };
21372 let (overlay, materialization_guard) = shared
21373 .prepare_session_materialization(
21374 &address,
21375 &agent("claude"),
21376 SessionMode::Pty,
21377 Some(&receipt),
21378 None,
21379 None,
21380 None,
21381 )
21382 .unwrap()
21383 .unwrap();
21384 let materialization_id = materialization_guard.id().unwrap().clone();
21385 let materialization_root = shared
21386 .materializations
21387 .lock()
21388 .unwrap_or_else(|poisoned| poisoned.into_inner())
21389 .get(&materialization_id)
21390 .unwrap()
21391 .root()
21392 .to_path_buf();
21393 let selection = shared
21394 .select_environment_profile(
21395 address.session.instance_id,
21396 &agent("claude"),
21397 SessionMode::Pty,
21398 Some(&receipt),
21399 )
21400 .unwrap()
21401 .unwrap();
21402 assert!(shared
21403 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21404 .unwrap()
21405 .is_none());
21406 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21407 let record_id = shared
21408 .bind_spawn_session_with_materialization(
21409 &address,
21410 agent("claude"),
21411 SessionMode::Pty,
21412 policy,
21413 SpawnRecordPolicy::ProviderIdentityOnly,
21414 Some(receipt.clone()),
21415 None,
21416 None,
21417 Some(materialization_id.clone()),
21418 )
21419 .unwrap()
21420 .unwrap();
21421 selection.retain();
21422 materialization_guard.retain();
21423 assert_eq!(
21424 shared
21425 .materializations
21426 .lock()
21427 .unwrap_or_else(|poisoned| poisoned.into_inner())
21428 .get(&materialization_id)
21429 .unwrap()
21430 .owner(),
21431 &MaterializationOwner::Record {
21432 record_id: record_id.clone(),
21433 },
21434 );
21435
21436 let removed = shared.remove_binding(&address).unwrap();
21437 shared
21438 .cleanup_session_owned_materialization(&address, &removed)
21439 .unwrap();
21440 assert!(materialization_root.is_dir());
21441 assert!(shared
21442 .resolve_record_materialization(
21443 &record_id,
21444 &agent("claude"),
21445 SessionMode::Pty,
21446 Some(&receipt),
21447 None,
21448 None,
21449 )
21450 .unwrap()
21451 .is_some());
21452 {
21453 let mut records = shared
21454 .session_records
21455 .lock()
21456 .unwrap_or_else(|poisoned| poisoned.into_inner());
21457 let record = records.records.get_mut(&record_id).unwrap();
21458 record.active_session = None;
21459 record.state = ManagedSessionState::Unavailable;
21460 }
21461 shared.forget_session_record(&record_id).await.unwrap();
21462 assert!(!materialization_root.exists());
21463 assert!(shared
21464 .materializations
21465 .lock()
21466 .unwrap_or_else(|poisoned| poisoned.into_inner())
21467 .is_empty());
21468 drop(shared);
21469 drop(runtime);
21470 std::fs::remove_dir_all(root).unwrap();
21471 }
21472
21473 #[test]
21474 fn record_materialization_revalidation_failure_persists_recovery_required() {
21475 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21476 let state_path = root.join("node-state-v6.json");
21477 shared.state_path = Some(state_path.clone());
21478 let (_address, _record_id, materialization_id, materialization_root) =
21479 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21480 std::fs::write(
21481 materialization_root.join(".gate4agent-materialization-owner"),
21482 b"tampered",
21483 )
21484 .unwrap();
21485
21486 let error = match shared.resolve_record_materialization(
21487 &_record_id,
21488 &agent("claude"),
21489 SessionMode::Pty,
21490 Some(&receipt),
21491 None,
21492 None,
21493 ) {
21494 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21495 Err(error) => error,
21496 };
21497 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21498 assert_eq!(
21499 shared
21500 .materializations
21501 .lock()
21502 .unwrap_or_else(|poisoned| poisoned.into_inner())
21503 .get(&materialization_id)
21504 .unwrap()
21505 .state(),
21506 MaterializationState::RecoveryRequired,
21507 );
21508 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21509 assert_eq!(loaded.materializations.len(), 1);
21510 assert_eq!(
21511 loaded.materializations[0].state(),
21512 MaterializationState::RecoveryRequired,
21513 );
21514 drop(shared);
21515 drop(runtime);
21516 std::fs::remove_dir_all(root).unwrap();
21517 }
21518
21519 #[test]
21520 fn transient_record_materialization_resolution_failure_remains_ready() {
21521 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21522 let (_address, record_id, materialization_id, _materialization_root) =
21523 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21524 deny.store(true, Ordering::Release);
21525
21526 let error = match shared.resolve_record_materialization(
21527 &record_id,
21528 &agent("claude"),
21529 SessionMode::Pty,
21530 Some(&receipt),
21531 None,
21532 None,
21533 ) {
21534 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21535 Err(error) => error,
21536 };
21537 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21538 assert_eq!(
21539 shared
21540 .materializations
21541 .lock()
21542 .unwrap_or_else(|poisoned| poisoned.into_inner())
21543 .get(&materialization_id)
21544 .unwrap()
21545 .state(),
21546 MaterializationState::Ready,
21547 );
21548 drop(shared);
21549 drop(runtime);
21550 std::fs::remove_dir_all(root).unwrap();
21551 }
21552
21553 #[test]
21554 fn startup_missing_record_materialization_is_not_resumable() {
21555 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21556 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21557 shared
21558 .insert_record(ManagedSessionRecord {
21559 record_id: record_id.clone(),
21560 display_name: "missing materialization".to_owned(),
21561 provider: agent("claude"),
21562 mode: SessionMode::Pty,
21563 state: ManagedSessionState::Dormant,
21564 workspace_id: WorkspaceId::new("primary").unwrap(),
21565 canonical_root: opaque_windows_path(
21566 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21567 ),
21568 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21569 key: gate4agent_types::ProviderSessionKey::SessionId,
21570 id: "provider-session-missing-materialization".to_owned(),
21571 transcript_path: None,
21572 }),
21573 active_session: None,
21574 environment_profile: Some(receipt),
21575 bundle: None,
21576 context_id: None,
21577 context: None,
21578 exported_context: None,
21579 task_binding: None,
21580 created_at_unix_ms: 1,
21581 updated_at_unix_ms: 1,
21582 last_error: None,
21583 })
21584 .unwrap();
21585
21586 shared.reconcile_materializations();
21587 let record = shared.record(&record_id).unwrap();
21588 assert_eq!(record.state, ManagedSessionState::Unavailable);
21589 assert!(record.provider_session.is_none());
21590 assert_eq!(
21591 record.last_error.as_deref(),
21592 Some("environment-profile-unavailable"),
21593 );
21594 drop(shared);
21595 drop(runtime);
21596 std::fs::remove_dir_all(root).unwrap();
21597 }
21598
21599 #[tokio::test]
21600 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21601 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21602 let lease = install_managed_lease(&shared);
21603 let address = SessionAddress {
21604 workspace_id: WorkspaceId::new("primary").unwrap(),
21605 session: SessionKey {
21606 instance_id: AgentInstanceId(204),
21607 generation: SessionGeneration(1),
21608 },
21609 };
21610 let (overlay, materialization_guard) = shared
21611 .prepare_session_materialization(
21612 &address,
21613 &agent("claude"),
21614 SessionMode::Pty,
21615 Some(&receipt),
21616 None,
21617 None,
21618 Some(lease.lease_id.clone()),
21619 )
21620 .unwrap()
21621 .unwrap();
21622 let materialization_id = materialization_guard.id().unwrap().clone();
21623 let materialization_root = shared
21624 .materializations
21625 .lock()
21626 .unwrap_or_else(|poisoned| poisoned.into_inner())
21627 .get(&materialization_id)
21628 .unwrap()
21629 .root()
21630 .to_path_buf();
21631 let selection = shared
21632 .select_environment_profile(
21633 address.session.instance_id,
21634 &agent("claude"),
21635 SessionMode::Pty,
21636 Some(&receipt),
21637 )
21638 .unwrap()
21639 .unwrap();
21640 assert!(shared
21641 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21642 .unwrap()
21643 .is_none());
21644 shared.bind_session_with_materialization(
21645 &address,
21646 ProviderRuntimePolicy::raw_pty(),
21647 Some(receipt),
21648 None,
21649 None,
21650 Some(materialization_id),
21651 );
21652 selection.retain();
21653 materialization_guard.retain();
21654
21655 let blocked = shared
21656 .cleanup_managed_worktree(&lease.lease_id, false)
21657 .await
21658 .unwrap_err();
21659 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21660 assert!(materialization_root.is_dir());
21661 let removed = shared.remove_binding(&address).unwrap();
21662 shared
21663 .cleanup_session_owned_materialization(&address, &removed)
21664 .unwrap();
21665 assert!(!materialization_root.exists());
21666 drop(shared);
21667 drop(runtime);
21668 std::fs::remove_dir_all(root).unwrap();
21669 }
21670
21671 #[tokio::test]
21672 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21673 let git_root = temporary_workspace_root("raw-managed-inventory");
21674 let source = git_root.join("source");
21675 let allocation = git_root.join("allocation");
21676 std::fs::create_dir_all(&source).unwrap();
21677 std::fs::create_dir_all(&allocation).unwrap();
21678 run_test_git(&source, &["init"]);
21679 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21680 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21681 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21682 run_test_git(&source, &["add", "seed.txt"]);
21683 run_test_git(&source, &["commit", "-m", "seed"]);
21684 let profile = ManagedWorktreeProfile::new(
21685 WorktreeProfileId::new("default").unwrap(),
21686 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21687 &allocation,
21688 "gate4agent",
21689 "HEAD",
21690 ManagedWorktreeRetention::RemoveWhenReleased,
21691 )
21692 .unwrap();
21693 let workspace = WorkspaceConfig::new(
21694 WorkspaceId::new("primary").unwrap(),
21695 &source,
21696 )
21697 .unwrap()
21698 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21699 .with_managed_worktree_profile(profile.clone())
21700 .unwrap();
21701 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21702 materialization_test_shared_with_workspace(false, workspace);
21703 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21704 let source_text = source.to_string_lossy().into_owned();
21705 let target_text = target.to_string_lossy().into_owned();
21706 let created = create_git_worktree(
21707 &source_text,
21708 &target_text,
21709 "gate4agent/mw-raw-inventory",
21710 Some("HEAD"),
21711 )
21712 .await
21713 .unwrap();
21714 let lease = ManagedWorktreeLeaseRecord {
21715 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21716 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21717 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21718 profile_id: profile.profile_id().clone(),
21719 profile_revision: profile.revision().clone(),
21720 target_root: created.path.clone(),
21721 branch: "gate4agent/mw-raw-inventory".to_owned(),
21722 base_commit: created.head.clone(),
21723 expected_head: Some(created.head.clone()),
21724 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21725 state: ManagedWorktreeLeaseState::Ready,
21726 session_holders: Vec::new(),
21727 record_holders: Vec::new(),
21728 cleanup_failure: None,
21729 created_at_unix_ms: 1,
21730 updated_at_unix_ms: 1,
21731 };
21732 shared
21733 .workspaces
21734 .write()
21735 .unwrap_or_else(|poisoned| poisoned.into_inner())
21736 .insert(lease.workspace_id.clone(), created.path.clone());
21737 *shared
21738 .managed_worktrees
21739 .lock()
21740 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21741 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21742 let address = SessionAddress {
21743 workspace_id: lease.workspace_id.clone(),
21744 session: SessionKey {
21745 instance_id: AgentInstanceId(205),
21746 generation: SessionGeneration(1),
21747 },
21748 };
21749 let (overlay, materialization_guard) = shared
21750 .prepare_session_materialization(
21751 &address,
21752 &agent("claude"),
21753 SessionMode::Pty,
21754 Some(&receipt),
21755 None,
21756 None,
21757 Some(lease.lease_id.clone()),
21758 )
21759 .unwrap()
21760 .unwrap();
21761 let materialization_id = materialization_guard.id().unwrap().clone();
21762 let materialization_root = shared
21763 .materializations
21764 .lock()
21765 .unwrap_or_else(|poisoned| poisoned.into_inner())
21766 .get(&materialization_id)
21767 .unwrap()
21768 .root()
21769 .to_path_buf();
21770 let selection = shared
21771 .select_environment_profile(
21772 address.session.instance_id,
21773 &agent("claude"),
21774 SessionMode::Pty,
21775 Some(&receipt),
21776 )
21777 .unwrap()
21778 .unwrap();
21779 assert!(shared
21780 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21781 .unwrap()
21782 .is_none());
21783 let record_id = shared
21784 .bind_spawn_session_with_materialization(
21785 &address,
21786 agent("claude"),
21787 SessionMode::Pty,
21788 ProviderRuntimePolicy::raw_pty(),
21789 SpawnRecordPolicy::Always,
21790 Some(receipt.clone()),
21791 None,
21792 None,
21793 Some(materialization_id.clone()),
21794 )
21795 .unwrap()
21796 .unwrap();
21797 selection.retain();
21798 materialization_guard.retain();
21799 let record = shared.record(&record_id).unwrap();
21800 assert_eq!(record.state, ManagedSessionState::Live);
21801 assert!(record.provider_session.is_none());
21802 assert!(record.environment_profile.is_none());
21803 assert!(record.bundle.is_none());
21804 assert!(record.context_id.is_none());
21805 assert!(record.context.is_none());
21806 assert_eq!(record.active_session.as_ref(), Some(&address));
21807 assert_eq!(
21808 shared
21809 .materializations
21810 .lock()
21811 .unwrap_or_else(|poisoned| poisoned.into_inner())
21812 .get(&materialization_id)
21813 .unwrap()
21814 .owner(),
21815 &MaterializationOwner::Session {
21816 incarnation_id: shared.incarnation_id,
21817 instance_id: address.session.instance_id,
21818 generation: address.session.generation,
21819 },
21820 );
21821 {
21822 let mut bindings = shared
21823 .session_bindings
21824 .lock()
21825 .unwrap_or_else(|poisoned| poisoned.into_inner());
21826 bindings
21827 .get_mut(&address.session.instance_id)
21828 .unwrap()
21829 .managed_worktree_lease_id = Some(lease.lease_id.clone());
21830 }
21831 let lease_snapshot = shared
21832 .managed_worktrees
21833 .lock()
21834 .unwrap_or_else(|poisoned| poisoned.into_inner())
21835 .bind_session(
21836 &lease.lease_id,
21837 ManagedWorktreeSessionHolder {
21838 incarnation_id: shared.incarnation_id,
21839 instance_id: address.session.instance_id,
21840 generation: address.session.generation,
21841 },
21842 None,
21843 unix_time_ms(),
21844 )
21845 .unwrap();
21846 assert_eq!(lease_snapshot.active_session_count, 1);
21847 assert_eq!(lease_snapshot.managed_record_count, 0);
21848 shared.persist_state().unwrap();
21849 assert!(shared
21850 .managed_worktrees
21851 .lock()
21852 .unwrap_or_else(|poisoned| poisoned.into_inner())
21853 .get(&lease.lease_id)
21854 .unwrap()
21855 .record_holders
21856 .is_empty());
21857 shared
21858 .handle
21859 .dispatch(shared.prepare_command(ControlCommand::Register {
21860 instance_id: address.session.instance_id,
21861 agent_id: agent("claude"),
21862 transport: TransportKind::Pty,
21863 }))
21864 .unwrap();
21865 runtime.tick().await;
21866 shared.reconcile_managed_record(
21867 &record_id,
21868 &address,
21869 &ControlEvent {
21870 sequence: 1,
21871 command_id: None,
21872 instance_id: address.session.instance_id,
21873 generation: address.session.generation,
21874 event: ControlEventKind::Removed,
21875 },
21876 false,
21877 false,
21878 );
21879 let remove = shared.remove_session(&address);
21880 let drive_runtime = async {
21881 runtime.tick().await;
21882 };
21883 let (removed, ()) = tokio::join!(remove, drive_runtime);
21884 removed.unwrap();
21885
21886 assert!(!materialization_root.exists());
21887 assert!(!Path::new(&created.path).exists());
21888 assert_eq!(
21889 shared
21890 .managed_worktrees
21891 .lock()
21892 .unwrap_or_else(|poisoned| poisoned.into_inner())
21893 .get(&lease.lease_id)
21894 .unwrap()
21895 .state,
21896 ManagedWorktreeLeaseState::Removed,
21897 );
21898 let renamed = shared
21899 .rename_session_record(&record_id, "raw managed inventory".to_owned())
21900 .unwrap();
21901 assert_eq!(renamed.record_id, record_id);
21902 assert_eq!(renamed.display_name, "raw managed inventory");
21903 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21904 assert!(renamed.active_session.is_none());
21905
21906 drop(shared);
21907 drop(runtime);
21908 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21909 std::fs::remove_dir_all(git_root).unwrap();
21910 }
21911
21912 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21913 let root = std::env::current_dir().unwrap()
21914 .join("managed-test-target")
21915 .to_string_lossy()
21916 .into_owned();
21917 let lease = ManagedWorktreeLeaseRecord {
21918 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21919 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21920 workspace_id: WorkspaceId::new("managed-test").unwrap(),
21921 profile_id: WorktreeProfileId::new("default").unwrap(),
21922 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21923 target_root: root.clone(),
21924 branch: "gate4agent/mw-test".to_owned(),
21925 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21926 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21927 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21928 state: ManagedWorktreeLeaseState::Ready,
21929 session_holders: Vec::new(),
21930 record_holders: Vec::new(),
21931 cleanup_failure: None,
21932 created_at_unix_ms: 1,
21933 updated_at_unix_ms: 1,
21934 };
21935 shared.workspaces.write()
21936 .unwrap_or_else(|poisoned| poisoned.into_inner())
21937 .insert(lease.workspace_id.clone(), root);
21938 *shared.managed_worktrees.lock()
21939 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21940 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21941 lease
21942 }
21943
21944 #[cfg(feature = "fixture")]
21945 #[test]
21946 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21947 let mut shared = terminal_test_shared();
21948 shared.fixture_semantic_hook_policy = true;
21949 let lease = install_managed_lease(&shared);
21950 let address = SessionAddress {
21951 workspace_id: lease.workspace_id.clone(),
21952 session: SessionKey {
21953 instance_id: AgentInstanceId(206),
21954 generation: SessionGeneration(1),
21955 },
21956 };
21957 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21958 let effective_runtime_policy =
21959 shared.effective_spawn_runtime_policy(pre_admission_policy);
21960 assert_ne!(effective_runtime_policy, pre_admission_policy);
21961 assert!(effective_runtime_policy.provider_session_identity);
21962
21963 let record_id = shared
21964 .bind_spawn_session(
21965 &address,
21966 agent("claude"),
21967 SessionMode::Pty,
21968 effective_runtime_policy,
21969 SpawnRecordPolicy::Always,
21970 None,
21971 )
21972 .unwrap()
21973 .unwrap();
21974 assert_eq!(
21975 shared.record(&record_id).unwrap().state,
21976 ManagedSessionState::IdentityPending,
21977 );
21978 let (record_holder, raw_inventory_record_id) = {
21979 let mut bindings = shared
21980 .session_bindings
21981 .lock()
21982 .unwrap_or_else(|poisoned| poisoned.into_inner());
21983 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21984 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21985 managed_spawn_record_ownership(binding, effective_runtime_policy)
21986 };
21987 assert_eq!(record_holder.as_ref(), Some(&record_id));
21988 assert!(raw_inventory_record_id.is_none());
21989 let snapshot = shared
21990 .managed_worktrees
21991 .lock()
21992 .unwrap_or_else(|poisoned| poisoned.into_inner())
21993 .bind_session(
21994 &lease.lease_id,
21995 ManagedWorktreeSessionHolder {
21996 incarnation_id: shared.incarnation_id,
21997 instance_id: address.session.instance_id,
21998 generation: address.session.generation,
21999 },
22000 record_holder,
22001 unix_time_ms(),
22002 )
22003 .unwrap();
22004 assert_eq!(snapshot.managed_record_count, 1);
22005 assert_eq!(
22006 shared
22007 .managed_worktrees
22008 .lock()
22009 .unwrap_or_else(|poisoned| poisoned.into_inner())
22010 .get(&lease.lease_id)
22011 .unwrap()
22012 .record_holders,
22013 vec![record_id.clone()],
22014 );
22015
22016 let identity = ProviderSessionIdentity {
22017 key: ProviderSessionKey::SessionId,
22018 id: "monitoring-fixture-managed-session".to_owned(),
22019 transcript_path: None,
22020 };
22021 shared.publish_control(ControlEvent {
22022 sequence: 1,
22023 command_id: None,
22024 instance_id: address.session.instance_id,
22025 generation: address.session.generation,
22026 event: ControlEventKind::ProviderEvent {
22027 sequence: 1,
22028 source: gate4agent_types::ProviderSource {
22029 family: AdapterFamily::Hook,
22030 binding: AdapterBinding::new(
22031 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22032 "fixture/v1",
22033 gate4agent_types::AdapterVerification::SyntheticFixture,
22034 )
22035 .unwrap(),
22036 },
22037 source_sequence: 1,
22038 event: ProviderEvent::SessionIdentityObserved {
22039 identity: identity.clone(),
22040 },
22041 },
22042 });
22043
22044 let live = shared.record(&record_id).unwrap();
22045 assert_eq!(live.state, ManagedSessionState::Live);
22046 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22047 assert_eq!(live.active_session.as_ref(), Some(&address));
22048 assert_eq!(
22049 shared
22050 .managed_worktrees
22051 .lock()
22052 .unwrap_or_else(|poisoned| poisoned.into_inner())
22053 .get(&lease.lease_id)
22054 .unwrap()
22055 .record_holders,
22056 vec![record_id],
22057 );
22058 }
22059
22060 fn managed_v2_test_shared(
22061 allocation_root: &Path,
22062 state_path: Option<PathBuf>,
22063 profile_revision: &str,
22064 ) -> NodeShared {
22065 let profile = ManagedWorktreeProfile::new(
22066 WorktreeProfileId::new("default").unwrap(),
22067 WorktreeProfileRevision::new(profile_revision).unwrap(),
22068 allocation_root,
22069 "gate4agent",
22070 "HEAD",
22071 ManagedWorktreeRetention::RemoveWhenReleased,
22072 )
22073 .unwrap();
22074 let workspace = WorkspaceConfig::new(
22075 WorkspaceId::new("primary").unwrap(),
22076 std::env::current_dir().unwrap(),
22077 )
22078 .unwrap()
22079 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22080 .with_managed_worktree_profile(profile)
22081 .unwrap();
22082 let catalog = active_registry().unwrap();
22083 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22084 let mut shared = NodeShared::new(
22085 handle,
22086 "fixture-token".to_owned(),
22087 NodeId::new("node-terminal-test").unwrap(),
22088 vec![workspace],
22089 vec![agent("claude")],
22090 );
22091 shared.state_path = state_path;
22092 shared
22093 }
22094
22095 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22096 let mut spawn_spec = spawn_spec_fixture();
22097 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22098 ManagedWorktreeSpawnRequestV2 {
22099 spawn_spec,
22100 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22101 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22102 }
22103 }
22104
22105 #[test]
22106 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22107 let shared = terminal_test_shared();
22108 let mut lease = install_managed_lease(&shared);
22109 let session = SessionAddress {
22110 workspace_id: lease.workspace_id.clone(),
22111 session: SessionKey {
22112 instance_id: AgentInstanceId(73),
22113 generation: SessionGeneration(4),
22114 },
22115 };
22116 lease.state = ManagedWorktreeLeaseState::InUse;
22117 lease.session_holders.push(ManagedWorktreeSessionHolder {
22118 incarnation_id: shared.incarnation_id,
22119 instance_id: session.session.instance_id,
22120 generation: session.session.generation,
22121 });
22122 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22123 assert!(resolved.target.worktree_id.is_none());
22124
22125 let receipt = managed_spawn_receipt(
22126 &resolved,
22127 shared.incarnation_id,
22128 session.clone(),
22129 lease.snapshot(),
22130 None,
22131 None,
22132 None,
22133 );
22134 assert!(resolved.target.worktree_id.is_none());
22135 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22136 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22137 assert_eq!(receipt.spawn.session, session);
22138 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22139 let encoded = serde_json::to_vec(&receipt).unwrap();
22140 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22141 assert_eq!(decoded, receipt);
22142 }
22143
22144 #[tokio::test]
22145 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22146 let root = temporary_workspace_root("managed-v2-revision");
22147 let allocation = root.join("allocation");
22148 std::fs::create_dir_all(&allocation).unwrap();
22149 let shared = managed_v2_test_shared(
22150 &allocation,
22151 Some(root.join("node-state.json")),
22152 "v1",
22153 );
22154 let mut request = managed_v2_request("managed-v2-revision");
22155 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22156
22157 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22158
22159 assert_eq!(
22160 failure.code,
22161 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22162 );
22163 assert!(shared
22164 .managed_worktrees
22165 .lock()
22166 .unwrap_or_else(|poisoned| poisoned.into_inner())
22167 .records()
22168 .is_empty());
22169 assert!(shared
22170 .managed_spawn_replays
22171 .lock()
22172 .unwrap_or_else(|poisoned| poisoned.into_inner())
22173 .is_empty());
22174 std::fs::remove_dir_all(root).unwrap();
22175 }
22176
22177 #[test]
22178 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22179 let root = temporary_workspace_root("managed-v2-concurrent");
22180 let allocation = root.join("allocation");
22181 std::fs::create_dir_all(&allocation).unwrap();
22182 let shared = Arc::new(managed_v2_test_shared(
22183 &allocation,
22184 Some(root.join("node-state.json")),
22185 "v1",
22186 ));
22187 let request = managed_v2_request("managed-v2-concurrent");
22188 let barrier = Arc::new(std::sync::Barrier::new(3));
22189 let mut joins = Vec::new();
22190 for _ in 0..2 {
22191 let shared = Arc::clone(&shared);
22192 let request = request.clone();
22193 let barrier = Arc::clone(&barrier);
22194 joins.push(std::thread::spawn(move || {
22195 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22196 barrier.wait();
22197 shared.acquire_durable_managed_spawn_v2(&request, digest)
22198 }));
22199 }
22200 barrier.wait();
22201 let results = joins
22202 .into_iter()
22203 .map(|join| join.join().unwrap())
22204 .collect::<Vec<_>>();
22205 assert_eq!(
22206 results
22207 .iter()
22208 .filter(|result| {
22209 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22210 })
22211 .count(),
22212 1,
22213 );
22214 assert_eq!(
22215 results
22216 .iter()
22217 .filter(|result| {
22218 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22219 })
22220 .count(),
22221 1,
22222 );
22223 assert_eq!(
22224 shared
22225 .managed_spawn_replays
22226 .lock()
22227 .unwrap_or_else(|poisoned| poisoned.into_inner())
22228 .len(),
22229 1,
22230 );
22231 assert!(shared
22232 .managed_worktrees
22233 .lock()
22234 .unwrap_or_else(|poisoned| poisoned.into_inner())
22235 .records()
22236 .is_empty());
22237 drop(shared);
22238 std::fs::remove_dir_all(root).unwrap();
22239 }
22240
22241 #[tokio::test]
22242 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22243 let root = temporary_workspace_root("managed-v2-linked-pending");
22244 let allocation = root.join("allocation");
22245 std::fs::create_dir_all(&allocation).unwrap();
22246 let state_path = root.join("node-state.json");
22247 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22248 let lease = install_managed_lease(&shared);
22249 let request = managed_v2_request("managed-v2-linked-pending");
22250 assert!(matches!(
22251 shared
22252 .acquire_durable_managed_spawn_v2(
22253 &request,
22254 managed_spawn_request_digest_v2(&request).unwrap(),
22255 )
22256 .unwrap(),
22257 DurableManagedSpawnReplayDecision::Reserved,
22258 ));
22259 {
22260 let _transaction = shared
22261 .state_transaction
22262 .lock()
22263 .unwrap_or_else(|poisoned| poisoned.into_inner());
22264 shared
22265 .link_durable_managed_spawn_v2_lease_locked(
22266 &request.spawn_spec.idempotency_key,
22267 &lease.lease_id,
22268 )
22269 .unwrap();
22270 shared.persist_state_locked().unwrap();
22271 }
22272 drop(shared);
22273
22274 let mut loaded = session_registry::load(
22275 Some(&state_path),
22276 &NodeId::new("node-terminal-test").unwrap(),
22277 )
22278 .unwrap();
22279 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22280 *reopened
22281 .managed_worktrees
22282 .lock()
22283 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22284 ManagedWorktreeRegistry::from_records(
22285 std::mem::take(&mut loaded.managed_worktrees),
22286 std::mem::take(&mut loaded.managed_worktree_tombstones),
22287 )
22288 .unwrap();
22289 *reopened
22290 .managed_spawn_replays
22291 .lock()
22292 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22293 .managed_spawn_replays
22294 .into_iter()
22295 .map(|record| (record.idempotency_key.clone(), record))
22296 .collect();
22297 let lease_count = reopened
22298 .managed_worktrees
22299 .lock()
22300 .unwrap_or_else(|poisoned| poisoned.into_inner())
22301 .records()
22302 .len();
22303
22304 let failure = reopened
22305 .spawn_managed_worktree_v2(request)
22306 .await
22307 .unwrap_err();
22308
22309 assert_eq!(
22310 failure.code,
22311 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22312 );
22313 assert_eq!(
22314 reopened
22315 .managed_worktrees
22316 .lock()
22317 .unwrap_or_else(|poisoned| poisoned.into_inner())
22318 .records()
22319 .len(),
22320 lease_count,
22321 );
22322 std::fs::remove_dir_all(root).unwrap();
22323 }
22324
22325 #[tokio::test]
22326 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22327 let root = temporary_workspace_root("managed-v2-reopen");
22328 let allocation = root.join("allocation");
22329 std::fs::create_dir_all(&allocation).unwrap();
22330 let state_path = root.join("node-state.json");
22331 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22332 let lease = install_managed_lease(&shared);
22333 let request = managed_v2_request("managed-v2-reopen");
22334 let session = SessionAddress {
22335 workspace_id: lease.workspace_id.clone(),
22336 session: SessionKey {
22337 instance_id: AgentInstanceId(91),
22338 generation: SessionGeneration(1),
22339 },
22340 };
22341 let snapshot = {
22342 let mut registry = shared
22343 .managed_worktrees
22344 .lock()
22345 .unwrap_or_else(|poisoned| poisoned.into_inner());
22346 let current = registry.get_mut(&lease.lease_id).unwrap();
22347 current.state = ManagedWorktreeLeaseState::InUse;
22348 current.session_holders.push(ManagedWorktreeSessionHolder {
22349 incarnation_id: shared.incarnation_id,
22350 instance_id: session.session.instance_id,
22351 generation: session.session.generation,
22352 });
22353 current.snapshot()
22354 };
22355 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22356 let receipt = managed_spawn_receipt(
22357 &resolved,
22358 shared.incarnation_id,
22359 session,
22360 snapshot,
22361 None,
22362 None,
22363 None,
22364 );
22365 assert!(matches!(
22366 shared
22367 .acquire_durable_managed_spawn_v2(
22368 &request,
22369 managed_spawn_request_digest_v2(&request).unwrap(),
22370 )
22371 .unwrap(),
22372 DurableManagedSpawnReplayDecision::Reserved,
22373 ));
22374 {
22375 let _transaction = shared
22376 .state_transaction
22377 .lock()
22378 .unwrap_or_else(|poisoned| poisoned.into_inner());
22379 shared
22380 .link_durable_managed_spawn_v2_lease_locked(
22381 &request.spawn_spec.idempotency_key,
22382 &lease.lease_id,
22383 )
22384 .unwrap();
22385 shared.persist_state_locked().unwrap();
22386 }
22387 shared
22388 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22389 .unwrap();
22390 let legacy_same_key = ManagedWorktreeSpawnRequest {
22391 spawn_spec: request.spawn_spec.clone(),
22392 worktree_profile_id: request.worktree_profile_id.clone(),
22393 };
22394 shared.remember_managed_spawn_attempt(
22395 Some(&request.spawn_spec.idempotency_key),
22396 legacy_same_key.clone(),
22397 Ok(receipt.clone()),
22398 Instant::now(),
22399 );
22400 assert!(shared
22401 .replay_managed_spawn(&legacy_same_key, Instant::now())
22402 .unwrap()
22403 .is_none());
22404 drop(shared);
22405
22406 let mut loaded = session_registry::load(
22407 Some(&state_path),
22408 &NodeId::new("node-terminal-test").unwrap(),
22409 )
22410 .unwrap();
22411 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22412 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22413 *reopened
22414 .managed_worktrees
22415 .lock()
22416 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22417 ManagedWorktreeRegistry::from_records(
22418 std::mem::take(&mut loaded.managed_worktrees),
22419 std::mem::take(&mut loaded.managed_worktree_tombstones),
22420 )
22421 .unwrap();
22422 *reopened
22423 .managed_spawn_replays
22424 .lock()
22425 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22426 .managed_spawn_replays
22427 .into_iter()
22428 .map(|record| (record.idempotency_key.clone(), record))
22429 .collect();
22430 let lease_count_before = reopened
22431 .managed_worktrees
22432 .lock()
22433 .unwrap_or_else(|poisoned| poisoned.into_inner())
22434 .records()
22435 .len();
22436
22437 assert_eq!(
22438 reopened
22439 .spawn_managed_worktree_v2(request.clone())
22440 .await
22441 .unwrap(),
22442 receipt,
22443 );
22444 assert_eq!(
22445 reopened
22446 .managed_worktrees
22447 .lock()
22448 .unwrap_or_else(|poisoned| poisoned.into_inner())
22449 .records()
22450 .len(),
22451 lease_count_before,
22452 );
22453 let mut changed = request;
22454 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22455 let failure = reopened
22456 .spawn_managed_worktree_v2(changed)
22457 .await
22458 .unwrap_err();
22459 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22460 assert_eq!(
22461 reopened
22462 .managed_worktrees
22463 .lock()
22464 .unwrap_or_else(|poisoned| poisoned.into_inner())
22465 .records()
22466 .len(),
22467 lease_count_before,
22468 );
22469 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22470 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22471 let failure = reopened
22472 .spawn_managed_worktree_v2(new_key)
22473 .await
22474 .unwrap_err();
22475 assert_eq!(
22476 failure.code,
22477 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22478 );
22479 assert_eq!(
22480 reopened
22481 .managed_worktrees
22482 .lock()
22483 .unwrap_or_else(|poisoned| poisoned.into_inner())
22484 .records()
22485 .len(),
22486 lease_count_before,
22487 );
22488 assert_eq!(
22489 reopened
22490 .managed_spawn_replays
22491 .lock()
22492 .unwrap_or_else(|poisoned| poisoned.into_inner())
22493 .len(),
22494 1,
22495 );
22496 std::fs::remove_dir_all(root).unwrap();
22497 }
22498
22499 #[test]
22500 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22501 let shared = terminal_test_shared();
22502 let request = ManagedWorktreeSpawnRequest {
22503 spawn_spec: spawn_spec_fixture(),
22504 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22505 };
22506 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22507 shared.remember_managed_spawn(
22508 request.clone(),
22509 Err(expected.clone()),
22510 Instant::now(),
22511 );
22512
22513 assert_eq!(
22514 shared
22515 .replay_managed_spawn(&request, Instant::now())
22516 .unwrap()
22517 .unwrap(),
22518 Err(expected),
22519 );
22520 assert!(shared
22521 .managed_spawn_replays
22522 .lock()
22523 .unwrap_or_else(|poisoned| poisoned.into_inner())
22524 .is_empty());
22525 }
22526
22527 #[tokio::test]
22528 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22529 let shared = terminal_test_shared();
22530 let lease = install_managed_lease(&shared);
22531 let raw = shared.spawn_session(
22532 lease.workspace_id.clone(),
22533 agent("claude"),
22534 SessionMode::Pty,
22535 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22536 None,
22537 ).await.unwrap_err();
22538 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22539 let mut spec = spawn_spec_fixture();
22540 spec.target.workspace_id = lease.workspace_id.clone();
22541 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22542 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22543 let mut selected = spawn_spec_fixture();
22544 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22545 selected.target.worktree_id = Some(lease.workspace_id);
22546 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22547 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22548 assert!(shared.handle.snapshot().sessions.is_empty());
22549 }
22550
22551 #[test]
22552 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22553 let shared = terminal_test_shared();
22554 let lease = install_managed_lease(&shared);
22555 let snapshot = snapshot_for_wire(
22556 &shared, true, true, true, false, true, true, false, false, true,
22557 );
22558 assert!(snapshot.managed_worktrees.is_empty());
22559 assert!(snapshot.workspaces.iter().all(|workspace| {
22560 workspace.managed_worktree_profiles.is_none()
22561 }));
22562 let mut reply = ResponseEnvelope {
22563 request_id: 1,
22564 result: Ok(NodeResponse::Resync {
22565 event_sequence: 1,
22566 oldest_available_sequence: 1,
22567 snapshot: shared.snapshot(),
22568 events: vec![NodeEventEnvelope {
22569 sequence: 1,
22570 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22571 }],
22572 }),
22573 };
22574 project_response_without_managed_worktrees(&mut reply);
22575 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22576 panic!("projection changed the response kind");
22577 };
22578 assert!(snapshot.managed_worktrees.is_empty());
22579 assert!(events.is_empty());
22580 let mut git = git_snapshot_for_parser();
22581 git.branch = Some(lease.branch.clone());
22582 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22583 lease_id: lease.lease_id.clone(),
22584 source_workspace_id: lease.source_workspace_id.clone(),
22585 branch: lease.branch.clone(),
22586 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22587 active_session_count: 1,
22588 managed_record_count: 0,
22589 });
22590 let mut inspection_reply = ResponseEnvelope {
22591 request_id: 2,
22592 result: Ok(NodeResponse::WorkspaceInspected {
22593 inspection: WorkspaceInspection {
22594 workspace_id: lease.workspace_id.clone(),
22595 entries: Vec::new(),
22596 tree_truncated: false,
22597 git,
22598 truncation: None,
22599 },
22600 }),
22601 };
22602 project_response_without_managed_worktrees(&mut inspection_reply);
22603 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22604 panic!("projection changed the inspection response kind");
22605 };
22606 assert!(inspection.git.managed_worktree.is_none());
22607 let failure = managed_git_worktree_failure(GitWorktreeError {
22608 kind: GitWorktreeErrorKind::Failed,
22609 message: r"git failed at C:\private\managed-target".to_owned(),
22610 });
22611 assert!(!failure.message.contains("private"));
22612 assert!(!failure.message.contains("managed-target"));
22613 }
22614
22615 #[tokio::test]
22616 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22617 let root = temporary_workspace_root("managed-restart");
22618 let source = root.join("source");
22619 let allocation = root.join("allocation");
22620 std::fs::create_dir_all(&source).unwrap();
22621 std::fs::create_dir_all(&allocation).unwrap();
22622 run_test_git(&source, &["init"]);
22623 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22624 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22625 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22626 run_test_git(&source, &["add", "seed.txt"]);
22627 run_test_git(&source, &["commit", "-m", "seed"]);
22628 let profile = ManagedWorktreeProfile::new(
22629 WorktreeProfileId::new("default").unwrap(),
22630 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22631 &allocation,
22632 "gate4agent",
22633 "HEAD",
22634 ManagedWorktreeRetention::Retain,
22635 ).unwrap();
22636 let workspace = WorkspaceConfig::new(
22637 WorkspaceId::new("primary").unwrap(),
22638 &source,
22639 ).unwrap()
22640 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22641 .with_managed_worktree_profile(profile.clone()).unwrap();
22642 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22643 let source_text = workspace.canonical_root().to_owned();
22644 let target_text = target.to_string_lossy().into_owned();
22645 let created = create_git_worktree(
22646 &source_text,
22647 &target_text,
22648 "gate4agent/mw-restart",
22649 Some("HEAD"),
22650 ).await.unwrap();
22651 let lease = ManagedWorktreeLeaseRecord {
22652 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22653 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22654 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22655 profile_id: profile.profile_id().clone(),
22656 profile_revision: profile.revision().clone(),
22657 target_root: created.path.clone(),
22658 branch: "gate4agent/mw-restart".to_owned(),
22659 base_commit: created.head.clone(),
22660 expected_head: None,
22661 retention: ManagedWorktreeRetention::Retain,
22662 state: ManagedWorktreeLeaseState::Allocating,
22663 session_holders: Vec::new(),
22664 record_holders: Vec::new(),
22665 cleanup_failure: None,
22666 created_at_unix_ms: 1,
22667 updated_at_unix_ms: 1,
22668 };
22669 let catalog = active_registry().unwrap();
22670 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22671 let shared = NodeShared::new_with_incarnation(
22672 handle,
22673 "fixture-token".to_owned(),
22674 NodeId::new("node-managed-restart").unwrap(),
22675 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22676 vec![workspace],
22677 vec![agent("claude")],
22678 ProviderRuntimeStatuses::default(),
22679 None,
22680 Vec::new(),
22681 Vec::new(),
22682 SpawnProfileRegistry::default(),
22683 None,
22684 None,
22685 None,
22686 None,
22687 Vec::new(),
22688 vec![lease.clone()],
22689 Vec::new(),
22690 Vec::new(),
22691 None,
22692 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22693 );
22694 shared.reconcile_managed_worktrees().await;
22695 let retained = shared.managed_worktrees.lock()
22696 .unwrap_or_else(|poisoned| poisoned.into_inner())
22697 .get(&lease.lease_id).unwrap().clone();
22698 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22699 assert!(shared.workspaces.read()
22700 .unwrap_or_else(|poisoned| poisoned.into_inner())
22701 .contains_key(&lease.workspace_id));
22702 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22703 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22704 assert!(!Path::new(&created.path).exists());
22705 std::fs::remove_dir_all(root).unwrap();
22706 }
22707
22708 fn run_test_git(root: &Path, arguments: &[&str]) {
22709 let output = std::process::Command::new("git")
22710 .arg("-C").arg(root).args(arguments).output().unwrap();
22711 assert!(
22712 output.status.success(),
22713 "git {:?} failed: {}",
22714 arguments,
22715 String::from_utf8_lossy(&output.stderr),
22716 );
22717 }
22718
22719 #[tokio::test]
22720 async fn spawn_spec_validation_fails_before_session_mutation() {
22721 let shared = terminal_test_shared();
22722 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22723 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22724 }));
22725 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22726 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22727 }));
22728 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22729 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22730 }));
22731
22732 let mut target_mismatch = spawn_spec_fixture();
22733 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22734 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22735 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22736
22737 let mut unknown_profile = spawn_spec_fixture();
22738 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22739 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22740 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22741 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22742
22743 let mut revision_mismatch = spawn_spec_fixture();
22744 revision_mismatch.idempotency_key =
22745 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22746 revision_mismatch.expected_profile_revision =
22747 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22748 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22749 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22750
22751 let mut unsupported_materializer = spawn_spec_fixture();
22752 unsupported_materializer.idempotency_key =
22753 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22754 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22755 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22756 };
22757 let failure = shared
22758 .spawn_from_spec(unsupported_materializer)
22759 .await
22760 .unwrap_err();
22761 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22762
22763 let failure = shared
22764 .spawn_session_with_deadline(
22765 WorkspaceId::new("primary").unwrap(),
22766 agent("claude"),
22767 SessionMode::Pty,
22768 gate4agent_types::TerminalSize {
22769 rows: 24,
22770 columns: 80,
22771 },
22772 None,
22773 None,
22774 None,
22775 None,
22776 None,
22777 None,
22778 SpawnRecordPolicy::ProviderIdentityOnly,
22779 Some(Instant::now()),
22780 &[],
22781 None,
22782 ApprovalLevel::default(),
22783 None,
22784 None,
22785 )
22786 .await
22787 .unwrap_err();
22788 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22789 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22790 assert!(shared.handle.snapshot().sessions.is_empty());
22791 }
22792
22793 #[test]
22794 fn worktree_selection_requires_its_separate_negotiated_capability() {
22795 let mut spec = spawn_spec_fixture();
22796 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22797 let request = NodeRequest::SpawnSpec { spec };
22798 let spawn_spec_capability =
22799 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22800 assert!(request_uses_unnegotiated_capability(
22801 &request,
22802 &[spawn_spec_capability.clone()],
22803 ));
22804 let worktree_capability =
22805 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22806 assert!(request_uses_unnegotiated_capability(
22807 &request,
22808 &[spawn_spec_capability.clone(), worktree_capability.clone()],
22809 ));
22810 let profile_revision_capability =
22811 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22812 assert!(request_uses_unnegotiated_capability(
22813 &request,
22814 &[
22815 spawn_spec_capability.clone(),
22816 worktree_capability.clone(),
22817 profile_revision_capability.clone(),
22818 ],
22819 ));
22820 let bundle_materialization_capability =
22827 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22828 assert!(request_uses_unnegotiated_capability(
22829 &request,
22830 &[
22831 spawn_spec_capability.clone(),
22832 worktree_capability.clone(),
22833 profile_revision_capability.clone(),
22834 bundle_materialization_capability.clone(),
22835 ],
22836 ));
22837 let history_context_pack_capability =
22838 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22839 assert!(!request_uses_unnegotiated_capability(
22840 &request,
22841 &[
22842 spawn_spec_capability,
22843 worktree_capability,
22844 profile_revision_capability,
22845 bundle_materialization_capability,
22846 history_context_pack_capability,
22847 ],
22848 ));
22849 }
22850
22851 #[tokio::test]
22852 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22853 let mut shared = terminal_test_shared();
22854 let spec = spawn_spec_fixture();
22855 let receipt = shared
22856 .resolve_spawn_spec(&spec)
22857 .unwrap()
22858 .receipt(shared.incarnation_id, terminal_address(1));
22859 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22860
22861 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22862 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22863 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22864
22865 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22866
22867 let mut revision_conflicting = spec.clone();
22868 revision_conflicting.expected_profile_revision =
22869 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22870 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22871 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22872
22873 let mut stale_cache_miss = spec.clone();
22874 stale_cache_miss.idempotency_key =
22875 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22876 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22877 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22878
22879 let mut conflicting = spec;
22880 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22881 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22882 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22883 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22884 assert!(shared.handle.snapshot().sessions.is_empty());
22885 }
22886
22887 #[tokio::test]
22888 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22889 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22890 let mut spec = spawn_spec_fixture();
22891 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22892 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22893 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22894
22895 let failure = shared
22896 .create_worktree(
22897 WorkspaceId::new("primary").unwrap(),
22898 WorkspaceId::new("new-worktree").unwrap(),
22899 "not-an-absolute-path".to_owned(),
22900 "invalid branch".to_owned(),
22901 None,
22902 )
22903 .await
22904 .unwrap_err();
22905 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22906
22907 let failure = shared
22908 .remove_worktree(
22909 WorkspaceId::new("primary").unwrap(),
22910 "not-an-absolute-path".to_owned(),
22911 )
22912 .await
22913 .unwrap_err();
22914 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22915 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22916 assert!(shared.handle.snapshot().sessions.is_empty());
22917 }
22918
22919 #[tokio::test]
22920 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22921 let root = temporary_workspace_root("manual-worktree-compensation");
22922 let source = root.join("source");
22923 let target = root.join("target");
22924 let invalid_state_parent = root.join("state-parent-is-a-file");
22925 let invalid_state_path = invalid_state_parent.join("state.json");
22926 std::fs::create_dir_all(&source).unwrap();
22927 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22928 run_test_git(&source, &["init"]);
22929 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22930 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22931 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22932 run_test_git(&source, &["add", "seed.txt"]);
22933 run_test_git(&source, &["commit", "-m", "seed"]);
22934
22935 let catalog = active_registry().unwrap();
22936 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22937 let workspace = WorkspaceConfig::new(
22938 WorkspaceId::new("primary").unwrap(),
22939 &source,
22940 ).unwrap();
22941 let mut shared = NodeShared::new(
22942 handle,
22943 "fixture-token".to_owned(),
22944 NodeId::new("node-worktree-compensation").unwrap(),
22945 vec![workspace],
22946 vec![agent("claude")],
22947 );
22948 shared.state_path = Some(invalid_state_path);
22949
22950 let error = shared.create_worktree(
22951 WorkspaceId::new("primary").unwrap(),
22952 WorkspaceId::new("compensated").unwrap(),
22953 target.to_string_lossy().into_owned(),
22954 "gate4agent/compensated".to_owned(),
22955 Some("HEAD".to_owned()),
22956 ).await.unwrap_err();
22957
22958 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22959 assert!(!target.exists());
22960 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22961 .iter()
22962 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22963 std::fs::remove_dir_all(root).unwrap();
22964 }
22965
22966 #[test]
22967 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22968 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22969 NativeGitWorktreeSnapshot {
22970 path: path.to_owned(),
22971 head: "0123456789abcdef".to_owned(),
22972 branch: Some("refs/heads/test".to_owned()),
22973 is_bare,
22974 is_main,
22975 locked: false,
22976 lock_reason: None,
22977 prunable: false,
22978 prunable_reason: None,
22979 workspace_id: None,
22980 }
22981 }
22982
22983 let source = r"C:\repo";
22984 let selected = r"C:\repo-worktree";
22985 let listed = vec![
22986 worktree(source, true, false),
22987 worktree(selected, false, false),
22988 ];
22989 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22990
22991 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22992 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22993
22994 let bare = vec![
22995 worktree(source, true, false),
22996 worktree(selected, false, true),
22997 ];
22998 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22999 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23000
23001 let foreign_failure =
23002 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23003 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23004 }
23005
23006 fn terminal_frame(sequence: u64) -> TerminalFrame {
23007 TerminalFrame {
23008 sequence,
23009 size: gate4agent_types::TerminalSize {
23010 rows: 24,
23011 columns: 80,
23012 },
23013 cursor_row: 2,
23014 cursor_column: 3,
23015 contents: format!("frame-{sequence}"),
23016 formatted: vec![1, 2, 3],
23017 scrollback_formatted: Vec::new(),
23018 alternate_screen: false,
23019 mouse_protocol_enabled: false,
23020 mouse_protocol_encoding: Default::default(),
23021 produced_at_unix_ms: 0,
23022 screen_state: gate4agent_types::PtyScreenState::default(),
23023 bracketed_paste: None,
23024 }
23025 }
23026
23027 #[test]
23028 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23029 let shared = terminal_test_shared();
23030 let mut receiver = shared.terminal_event_tx.subscribe();
23031 let original = terminal_address(1);
23032
23033 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23034 let first = receiver.try_recv().unwrap();
23035 assert_eq!(first.len(), 1);
23036 assert!(matches!(
23037 &first[0].event,
23038 NodeEvent::TerminalFrame { address, frame }
23039 if address == &original && frame.sequence == 7
23040 ));
23041
23042 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23043 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23044 assert!(matches!(
23045 receiver.try_recv(),
23046 Err(broadcast::error::TryRecvError::Empty)
23047 ));
23048
23049 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23050 let advanced = receiver.try_recv().unwrap();
23051 assert_eq!(advanced[0].sequence, 2);
23052
23053 let resumed = terminal_address(2);
23054 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23055 let reset = receiver.try_recv().unwrap();
23056 assert_eq!(reset[0].sequence, 3);
23057 assert!(matches!(
23058 &reset[0].event,
23059 NodeEvent::TerminalFrame { address, frame }
23060 if address == &resumed && frame.sequence == 1
23061 ));
23062 shared.clear_terminal_frame_watermark(&resumed);
23063 assert!(!shared
23064 .terminal_frame_watermarks
23065 .lock()
23066 .unwrap_or_else(|poisoned| poisoned.into_inner())
23067 .contains_key(&resumed.session.instance_id));
23068 }
23069
23070 #[test]
23071 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23072 let shared = terminal_test_shared();
23073 let mut receiver = shared.terminal_event_tx.subscribe();
23074 let address = terminal_address(1);
23075 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23076
23077 for sequence in 1..=count {
23078 shared.publish_terminal_frame_candidates(vec![(
23079 address.clone(),
23080 terminal_frame(sequence),
23081 )]);
23082 }
23083
23084 let history = shared
23085 .history
23086 .lock()
23087 .unwrap_or_else(|poisoned| poisoned.into_inner());
23088 assert!(history.events.is_empty());
23089 assert_eq!(history.last_sequence, count);
23090 assert_eq!(history.replay_floor_sequence, 1);
23091 drop(history);
23092 let NodeResponse::Resync {
23093 event_sequence,
23094 oldest_available_sequence,
23095 events,
23096 ..
23097 } = shared.resync(0) else {
23098 unreachable!("resync helper returned another response")
23099 };
23100 assert_eq!(event_sequence, count);
23101 assert_eq!(oldest_available_sequence, 1);
23102 assert!(events.is_empty());
23103 assert!(matches!(
23104 resync_required_event(&shared).event,
23105 NodeEvent::ResyncRequired {
23106 oldest_available_sequence: 1,
23107 }
23108 ));
23109 assert!(matches!(
23110 receiver.try_recv(),
23111 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23112 ));
23113 let latest = receiver.try_recv().unwrap();
23114 assert_eq!(latest.len(), 1);
23115 assert_eq!(latest[0].sequence, count);
23116 }
23117
23118 #[test]
23119 fn real_durable_eviction_advances_authoritative_replay_floor() {
23120 let shared = terminal_test_shared();
23121 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23122 shared.publish(NodeEvent::ControllerChanged { controller: None });
23123 }
23124
23125 let NodeResponse::Resync {
23126 event_sequence,
23127 oldest_available_sequence,
23128 events,
23129 ..
23130 } = shared.resync(0) else {
23131 unreachable!("resync helper returned another response")
23132 };
23133 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23134 assert_eq!(oldest_available_sequence, 2);
23135 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23136 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23137 assert!(matches!(
23138 resync_required_event(&shared).event,
23139 NodeEvent::ResyncRequired {
23140 oldest_available_sequence: 2,
23141 }
23142 ));
23143 }
23144
23145 #[test]
23146 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23147 let shared = terminal_test_shared();
23148 let without_capability = terminal_event_subscription(&shared, false);
23149 let with_capability = terminal_event_subscription(&shared, true);
23150 assert!(without_capability.is_none());
23151 assert!(with_capability.is_some());
23152 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23153 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23154 }));
23155 }
23156
23157 #[test]
23158 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23159 let mut pending = Vec::new();
23160 let mut event_sequence = 0_u64;
23161 for frame_sequence in 1..=4 {
23162 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23163 event_sequence += 1;
23164 let mut address = terminal_address(1);
23165 address.session.instance_id = AgentInstanceId(instance);
23166 queue_connection_event(
23167 &mut pending,
23168 NodeEventEnvelope {
23169 sequence: event_sequence,
23170 event: NodeEvent::TerminalFrame {
23171 address,
23172 frame: terminal_frame(frame_sequence),
23173 },
23174 },
23175 0,
23176 );
23177 }
23178 }
23179 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23180 pending.sort_unstable_by_key(|event| event.sequence);
23181
23182 let mut uninterrupted = pending.clone();
23183 let first_burst = connection_event_burst_len(uninterrupted.len());
23184 uninterrupted.drain(..first_burst);
23185 let second_burst = connection_event_burst_len(uninterrupted.len());
23186 uninterrupted.drain(..second_burst);
23187 assert!(uninterrupted.is_empty());
23188
23189 let (frame_tx, mut frame_rx) =
23190 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23191 frame_tx
23192 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23193 request_id: 77,
23194 request: NodeRequest::Snapshot,
23195 })))
23196 .unwrap();
23197 let burst_len = connection_event_burst_len(pending.len());
23198 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23199 pending.drain(..burst_len);
23200 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23201 assert!(matches!(
23202 frame_rx.try_recv(),
23203 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23204 request_id: 77,
23205 request: NodeRequest::Snapshot,
23206 })))
23207 ));
23208 }
23209
23210 #[test]
23211 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23212 let mut pending = Vec::new();
23213 for sequence in [7_u64, 8] {
23214 let mut address = terminal_address(1);
23215 address.session.instance_id = AgentInstanceId(sequence);
23216 queue_connection_event(
23217 &mut pending,
23218 NodeEventEnvelope {
23219 sequence,
23220 event: NodeEvent::TerminalFrame {
23221 address,
23222 frame: terminal_frame(sequence),
23223 },
23224 },
23225 0,
23226 );
23227 }
23228 let mut discard_through = 0;
23229 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23230 assert!(pending.is_empty());
23231
23232 let replayed = NodeEventEnvelope {
23233 sequence: 8,
23234 event: NodeEvent::TerminalFrame {
23235 address: terminal_address(1),
23236 frame: terminal_frame(8),
23237 },
23238 };
23239 let advanced = NodeEventEnvelope {
23240 sequence: 9,
23241 event: NodeEvent::TerminalFrame {
23242 address: terminal_address(2),
23243 frame: terminal_frame(1),
23244 },
23245 };
23246 queue_connection_event_batch(
23247 &mut pending,
23248 &[replayed, advanced.clone()],
23249 discard_through,
23250 );
23251 assert_eq!(pending, vec![advanced]);
23252 }
23253
23254 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23255 ManagedSessionRecord {
23256 record_id: SessionRecordId::new(record_id).unwrap(),
23257 display_name: format!("{provider} record"),
23258 provider: agent(provider),
23259 mode: SessionMode::Pty,
23260 state: ManagedSessionState::Unavailable,
23261 workspace_id: WorkspaceId::new("primary").unwrap(),
23262 canonical_root: opaque_windows_path(
23263 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23264 ),
23265 provider_session: None,
23266 active_session: None,
23267 environment_profile: None,
23268 bundle: None,
23269 context_id: None,
23270 context: None,
23271 exported_context: None,
23272 task_binding: None,
23273 created_at_unix_ms: 1,
23274 updated_at_unix_ms: 1,
23275 last_error: None,
23276 }
23277 }
23278
23279 fn retention_test_record(
23280 workspace_id: &str,
23281 record_id: &str,
23282 state: ManagedSessionState,
23283 updated_at_unix_ms: u64,
23284 ) -> ManagedSessionRecord {
23285 ManagedSessionRecord {
23286 record_id: SessionRecordId::new(record_id).unwrap(),
23287 display_name: format!("{record_id} display"),
23288 provider: agent("claude"),
23289 mode: SessionMode::Pty,
23290 state,
23291 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23292 canonical_root: opaque_windows_path(
23293 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23294 ),
23295 provider_session: None,
23296 active_session: None,
23297 environment_profile: None,
23298 bundle: None,
23299 context_id: None,
23300 context: None,
23301 exported_context: None,
23302 task_binding: None,
23303 created_at_unix_ms: updated_at_unix_ms,
23304 updated_at_unix_ms,
23305 last_error: None,
23306 }
23307 }
23308
23309 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23317 vec![
23318 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23319 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23320 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23321 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23322 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23323 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23324 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23325 retention_test_record(
23326 "workspace-a",
23327 "decoy-pending",
23328 ManagedSessionState::IdentityPending,
23329 now - 500_000,
23330 ),
23331 retention_test_record(
23332 "workspace-a",
23333 "decoy-dormant",
23334 ManagedSessionState::Dormant,
23335 now - 500_000,
23336 ),
23337 ]
23338 }
23339
23340 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23341 let root = temporary_workspace_root("session-record-retention");
23342 std::fs::create_dir_all(&root).unwrap();
23343 let catalog = active_registry().unwrap();
23344 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23345 let workspace_a =
23346 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23347 let workspace_b =
23348 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23349 let mut shared = NodeShared::new(
23350 handle,
23351 "fixture-token".to_owned(),
23352 NodeId::new("node-retention-test").unwrap(),
23353 vec![workspace_a, workspace_b],
23354 vec![agent("claude")],
23355 );
23356 shared.state_path = Some(root.join("node-state-v10.json"));
23357 let now = unix_time_ms();
23358 for record in retention_fixture_records(now) {
23359 shared.insert_record(record).unwrap();
23360 }
23361 shared
23362 .persist_state_locked()
23363 .expect("fixture-only, empty-history persist against a fresh temp state path");
23364 (shared, runtime, root, now)
23365 }
23366
23367 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23368 shared
23369 .history
23370 .lock()
23371 .unwrap_or_else(|poisoned| poisoned.into_inner())
23372 .events
23373 .iter()
23374 .filter_map(|envelope| match &envelope.event {
23375 NodeEvent::SessionRecordRemoved { record_id } => {
23376 Some(record_id.as_str().to_owned())
23377 }
23378 _ => None,
23379 })
23380 .collect()
23381 }
23382
23383 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23384 shared
23385 .session_records
23386 .lock()
23387 .unwrap_or_else(|poisoned| poisoned.into_inner())
23388 .records
23389 .keys()
23390 .map(|record_id| record_id.as_str().to_owned())
23391 .collect()
23392 }
23393
23394 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23395 values.iter().map(|value| (*value).to_owned()).collect()
23396 }
23397
23398 #[tokio::test]
23399 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23400 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23401 shared.session_record_retention = SessionRecordRetentionConfig {
23402 age_ms: 0,
23403 keep_per_workspace: 1,
23404 };
23405 shared.retire_unavailable_session_records().await;
23406
23407 assert_eq!(
23408 retention_remaining_record_ids(&shared),
23409 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23410 );
23411 assert_eq!(
23412 retention_removed_record_ids(&shared),
23413 owned(&["a1", "a2", "b1", "b2"]),
23414 );
23415 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23416
23417 drop(shared);
23418 drop(runtime);
23419 std::fs::remove_dir_all(root).unwrap();
23420 }
23421
23422 #[tokio::test]
23428 async fn session_record_retention_both_set_retires_only_the_intersection() {
23429 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23430 shared.session_record_retention = SessionRecordRetentionConfig {
23431 age_ms: 250_000,
23432 keep_per_workspace: 1,
23433 };
23434 shared.retire_unavailable_session_records().await;
23435
23436 assert_eq!(
23437 retention_remaining_record_ids(&shared),
23438 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23439 );
23440 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23441 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23442
23443 drop(shared);
23444 drop(runtime);
23445 std::fs::remove_dir_all(root).unwrap();
23446 }
23447
23448 #[tokio::test]
23449 async fn session_record_retention_both_disabled_is_a_no_op() {
23450 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23451 shared.session_record_retention = SessionRecordRetentionConfig::default();
23452 shared.retire_unavailable_session_records().await;
23453
23454 assert_eq!(
23455 retention_remaining_record_ids(&shared),
23456 owned(&[
23457 "a1", "a2", "a3", "b1", "b2", "b3",
23458 "decoy-live", "decoy-pending", "decoy-dormant",
23459 ]),
23460 );
23461 assert!(retention_removed_record_ids(&shared).is_empty());
23462 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23463
23464 drop(shared);
23465 drop(runtime);
23466 std::fs::remove_dir_all(root).unwrap();
23467 }
23468
23469 #[test]
23470 fn session_record_context_export_selects_one_exact_history_identity() {
23471 let candidates = vec![
23472 HistoryCandidateSummary {
23473 id: "candidate-other".to_owned(),
23474 session_id_hint: "provider-other".to_owned(),
23475 modified_at_unix_ms: Some(1),
23476 },
23477 HistoryCandidateSummary {
23478 id: "candidate-exact".to_owned(),
23479 session_id_hint: "provider-exact".to_owned(),
23480 modified_at_unix_ms: Some(2),
23481 },
23482 ];
23483 assert_eq!(
23484 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23485 "candidate-exact",
23486 );
23487 }
23488
23489 #[test]
23490 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23491 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23492 id: id.to_owned(),
23493 session_id_hint: hint.to_owned(),
23494 modified_at_unix_ms: None,
23495 };
23496 let missing = select_history_candidate(
23497 &[candidate("candidate-other", "provider-other")],
23498 Some("provider-exact"),
23499 )
23500 .unwrap_err();
23501 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23502 let ambiguous = select_history_candidate(
23503 &[
23504 candidate("candidate-a", "provider-exact"),
23505 candidate("candidate-b", "provider-exact"),
23506 ],
23507 Some("provider-exact"),
23508 )
23509 .unwrap_err();
23510 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23511 }
23512
23513 #[test]
23521 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23522 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23523 id: id.to_owned(),
23524 session_id_hint: hint.to_owned(),
23525 modified_at_unix_ms: None,
23526 };
23527 assert_eq!(
23528 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23529 .unwrap(),
23530 "candidate-sole",
23531 );
23532 let missing = select_history_candidate(&[], None).unwrap_err();
23533 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23534 let ambiguous = select_history_candidate(
23535 &[
23536 candidate("candidate-a", "sidecar-session-a"),
23537 candidate("candidate-b", "sidecar-session-b"),
23538 ],
23539 None,
23540 )
23541 .unwrap_err();
23542 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23543 }
23544
23545 #[test]
23546 fn session_record_context_export_revalidation_rejects_record_change() {
23547 let session = terminal_address(1);
23548 let identity = ProviderSessionIdentity {
23549 key: ProviderSessionKey::SessionId,
23550 id: "provider-exact".to_owned(),
23551 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23552 };
23553 let mut expected = record("claude", "record-context-export");
23554 expected.state = ManagedSessionState::Live;
23555 expected.provider_session = Some(identity.clone());
23556 expected.active_session = Some(session.clone());
23557 let binding = SessionBinding {
23558 workspace_id: session.workspace_id.clone(),
23559 generation: session.session.generation,
23560 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23561 pending_resume: None,
23562 record_id: Some(expected.record_id.clone()),
23563 managed_worktree_lease_id: None,
23564 environment_profile: None,
23565 bundle: None,
23566 context: None,
23567 materialization_id: None,
23568 };
23569 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23570 assert!(session_record_context_export_binding_is_exact(
23571 &expected,
23572 &expected,
23573 Some(&identity),
23574 &session,
23575 &binding,
23576 &expected.provider,
23577 ¤t_root,
23578 false,
23579 ));
23580 let mut changed = expected.clone();
23581 changed.updated_at_unix_ms += 1;
23582 assert!(!session_record_context_export_binding_is_exact(
23583 &expected,
23584 &changed,
23585 Some(&identity),
23586 &session,
23587 &binding,
23588 &expected.provider,
23589 ¤t_root,
23590 false,
23591 ));
23592 assert!(!session_record_context_export_binding_is_exact(
23597 &expected,
23598 &changed,
23599 Some(&identity),
23600 &session,
23601 &binding,
23602 &expected.provider,
23603 ¤t_root,
23604 true,
23605 ));
23606 let mut detached = expected.clone();
23607 detached.state = ManagedSessionState::Dormant;
23608 detached.active_session = None;
23609 detached.updated_at_unix_ms += 1;
23610 assert!(!session_record_context_export_binding_is_exact(
23611 &expected,
23612 &detached,
23613 Some(&identity),
23614 &session,
23615 &binding,
23616 &expected.provider,
23617 ¤t_root,
23618 false,
23619 ));
23620 assert!(session_record_context_export_binding_is_exact(
23621 &expected,
23622 &detached,
23623 Some(&identity),
23624 &session,
23625 &binding,
23626 &expected.provider,
23627 ¤t_root,
23628 true,
23629 ));
23630 }
23631
23632 #[test]
23639 fn session_record_context_export_binding_is_exact_without_identity() {
23640 let session = terminal_address(1);
23641 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23642 expected.state = ManagedSessionState::Live;
23643 expected.active_session = Some(session.clone());
23644 let binding = SessionBinding {
23645 workspace_id: session.workspace_id.clone(),
23646 generation: session.session.generation,
23647 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23648 pending_resume: None,
23649 record_id: Some(expected.record_id.clone()),
23650 managed_worktree_lease_id: None,
23651 environment_profile: None,
23652 bundle: None,
23653 context: None,
23654 materialization_id: None,
23655 };
23656 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23657 assert!(session_record_context_export_binding_is_exact(
23658 &expected,
23659 &expected,
23660 None,
23661 &session,
23662 &binding,
23663 &expected.provider,
23664 ¤t_root,
23665 false,
23666 ));
23667 let mut drifted = expected.clone();
23671 drifted.provider_session = Some(ProviderSessionIdentity {
23672 key: ProviderSessionKey::SessionId,
23673 id: "unexpected-identity".to_owned(),
23674 transcript_path: None,
23675 });
23676 assert!(!session_record_context_export_binding_is_exact(
23677 &expected,
23678 &drifted,
23679 None,
23680 &session,
23681 &binding,
23682 &expected.provider,
23683 ¤t_root,
23684 false,
23685 ));
23686 }
23687
23688 #[test]
23689 fn session_record_context_export_preflight_requires_live_usable_source() {
23690 let mut source = record("claude", "record-context-export-preflight");
23691 source.state = ManagedSessionState::Live;
23692 assert!(session_record_context_export_source_is_usable(
23693 &source,
23694 &SessionStatus::Running,
23695 false,
23696 ));
23697 assert!(session_record_context_export_source_is_usable(
23698 &source,
23699 &SessionStatus::Exited { exit_code: Some(0) },
23700 false,
23701 ));
23702
23703 source.state = ManagedSessionState::Dormant;
23704 assert!(!session_record_context_export_source_is_usable(
23705 &source,
23706 &SessionStatus::Running,
23707 false,
23708 ));
23709 assert!(session_record_context_export_source_is_usable(
23713 &source,
23714 &SessionStatus::Exited { exit_code: Some(0) },
23715 true,
23716 ));
23717 assert!(!session_record_context_export_source_is_usable(
23718 &source,
23719 &SessionStatus::Exited { exit_code: Some(0) },
23720 false,
23721 ));
23722 source.state = ManagedSessionState::Live;
23723 assert!(!session_record_context_export_source_is_usable(
23724 &source,
23725 &SessionStatus::Starting,
23726 false,
23727 ));
23728 }
23729
23730 #[test]
23731 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23732 let catalog = active_registry().unwrap();
23733 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23734 let workspace = WorkspaceConfig::new(
23735 WorkspaceId::new("primary").unwrap(),
23736 std::env::current_dir().unwrap(),
23737 )
23738 .unwrap();
23739 let shared = NodeShared::new(
23740 handle,
23741 "fixture-token".to_owned(),
23742 NodeId::new("node-context-export-conflict").unwrap(),
23743 vec![workspace],
23744 vec![agent("claude")],
23745 );
23746 let history = HistorySessionRecord {
23747 session_id: "provider-exact".to_owned(),
23748 title: None,
23749 cwd: None,
23750 model: None,
23751 message_count: 1,
23752 completed_turn_count: None,
23753 total_tokens: 0,
23754 messages: vec![gate4agent_types::HistoryMessageRecord {
23755 role: gate4agent_types::HistoryMessageRole::User,
23756 text: "bounded context".to_owned(),
23757 }],
23758 };
23759 let pack = NodeContextPack::export(
23760 ContextPackLineageReceipt {
23761 source_node_id: shared.node_id.clone(),
23762 source_session: terminal_address(1),
23763 source_provider: agent("claude"),
23764 },
23765 &history,
23766 )
23767 .unwrap();
23768 let context_id = pack.receipt().id.clone();
23769 let session = terminal_address(1);
23770 let identity = ProviderSessionIdentity {
23771 key: ProviderSessionKey::SessionId,
23772 id: history.session_id.clone(),
23773 transcript_path: None,
23774 };
23775 let mut expected = record("claude", "record-context-export-final-conflict");
23776 expected.state = ManagedSessionState::Live;
23777 expected.provider_session = Some(identity.clone());
23778 expected.active_session = Some(session.clone());
23779 let mut changed = expected.clone();
23780 changed.updated_at_unix_ms += 1;
23781 shared.insert_record(changed).unwrap();
23782
23783 let error = shared
23784 .commit_context_pack_for_session_record(
23785 &expected,
23786 Some(&identity),
23787 &session,
23788 "candidate-exact",
23789 &history,
23790 pack,
23791 false,
23792 )
23793 .unwrap_err();
23794
23795 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23796 assert!(shared
23797 .context_catalog
23798 .read()
23799 .unwrap_or_else(|poisoned| poisoned.into_inner())
23800 .get(&context_id)
23801 .is_none());
23802 }
23803
23804 #[tokio::test]
23805 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23806 let root = temporary_workspace_root("session-task-binding");
23807 std::fs::create_dir_all(&root).unwrap();
23808 let state_path = root.join("node-state.json");
23809 let catalog = active_registry().unwrap();
23810 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23811 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23812 let node_id = NodeId::new("node-1").unwrap();
23813 let mut shared = NodeShared::new(
23814 handle,
23815 "fixture-token".to_owned(),
23816 node_id.clone(),
23817 vec![workspace],
23818 vec![agent("claude")],
23819 );
23820 shared.state_path = Some(state_path.clone());
23821 let record = record("claude", "sr-task");
23822 let record_id = record.record_id.clone();
23823 shared.insert_record(record).unwrap();
23824 shared.persist_state().unwrap();
23825 shared
23826 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23827 .unwrap();
23828
23829 let minted = process_request_inner(
23830 &shared,
23831 77,
23832 ClientRole::Operator,
23833 NodeRequest::SetSessionTask {
23834 record_id: record_id.clone(),
23835 expected_revision: 0,
23836 target: SessionTaskTargetV1::New,
23837 },
23838 )
23839 .await
23840 .unwrap();
23841 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23842 panic!("session task mutation returned another response");
23843 };
23844 let minted_binding = minted.task_binding.clone().unwrap();
23845 let minted_task_id = minted_binding.task_id.clone().unwrap();
23846 assert_eq!(minted_binding.revision, 1);
23847 assert!(minted_task_id.as_str().starts_with("task-"));
23848 assert_eq!(minted_task_id.as_str().len(), 29);
23849
23850 let event_count = shared.current_sequence();
23851 let repeated = process_request_inner(
23852 &shared,
23853 77,
23854 ClientRole::Operator,
23855 NodeRequest::SetSessionTask {
23856 record_id: record_id.clone(),
23857 expected_revision: 1,
23858 target: SessionTaskTargetV1::Existing {
23859 task_id: minted_task_id.clone(),
23860 },
23861 },
23862 )
23863 .await
23864 .unwrap();
23865 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23866 assert_eq!(shared.current_sequence(), event_count);
23867
23868 let stale = process_request_inner(
23869 &shared,
23870 77,
23871 ClientRole::Operator,
23872 NodeRequest::SetSessionTask {
23873 record_id: record_id.clone(),
23874 expected_revision: 0,
23875 target: SessionTaskTargetV1::Existing {
23876 task_id: minted_task_id,
23877 },
23878 },
23879 )
23880 .await
23881 .unwrap_err();
23882 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23883
23884 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23885 let assigned = process_request_inner(
23886 &shared,
23887 77,
23888 ClientRole::Operator,
23889 NodeRequest::SetSessionTask {
23890 record_id: record_id.clone(),
23891 expected_revision: 1,
23892 target: SessionTaskTargetV1::Existing {
23893 task_id: assigned_id.clone(),
23894 },
23895 },
23896 )
23897 .await
23898 .unwrap();
23899 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23900 panic!("session task assignment returned another response");
23901 };
23902 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23903 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23904
23905 let cleared = process_request_inner(
23906 &shared,
23907 77,
23908 ClientRole::Operator,
23909 NodeRequest::SetSessionTask {
23910 record_id: record_id.clone(),
23911 expected_revision: 2,
23912 target: SessionTaskTargetV1::Clear,
23913 },
23914 )
23915 .await
23916 .unwrap();
23917 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23918 panic!("session task clear returned another response");
23919 };
23920 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23921 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23922 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23923 assert_eq!(
23924 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23925 cleared.updated_at_unix_ms,
23926 );
23927
23928 let persisted = std::fs::read(&state_path).unwrap();
23929 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23930 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23931 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23932 std::fs::remove_dir_all(root).unwrap();
23933 }
23934
23935 #[test]
23936 fn session_task_capability_strips_snapshots_events_and_replies() {
23937 let mut bound = record("claude", "sr-task-projection");
23938 bound.updated_at_unix_ms = 2;
23939 bound.task_binding = Some(SessionTaskBindingV1 {
23940 revision: 1,
23941 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23942 changed_at_unix_ms: 2,
23943 });
23944 let request = NodeRequest::SetSessionTask {
23945 record_id: bound.record_id.clone(),
23946 expected_revision: 1,
23947 target: SessionTaskTargetV1::Clear,
23948 };
23949 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23950 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23951 }));
23952 assert!(!request_uses_unnegotiated_capability(
23953 &request,
23954 &baseline_capabilities().unwrap(),
23955 ));
23956 assert!(request_uses_unnegotiated_capability(&request, &[]));
23957
23958 let mut snapshot = terminal_test_shared().snapshot();
23959 snapshot.session_records.push(bound.clone());
23960 let mut reply = ResponseEnvelope {
23961 request_id: 1,
23962 result: Ok(NodeResponse::Resync {
23963 event_sequence: 1,
23964 oldest_available_sequence: 1,
23965 snapshot,
23966 events: vec![NodeEventEnvelope {
23967 sequence: 1,
23968 event: NodeEvent::SessionRecordUpserted {
23969 record: bound.clone(),
23970 },
23971 }],
23972 }),
23973 };
23974 project_response_without_session_task_binding(&mut reply);
23975 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23976 panic!("task projection changed the response kind");
23977 };
23978 assert!(snapshot.session_records[0].task_binding.is_none());
23979 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23980 panic!("task projection changed the event kind");
23981 };
23982 assert!(record.task_binding.is_none());
23983
23984 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23985 sequence: 2,
23986 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23987 });
23988 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23989 panic!("task event projection changed the event kind");
23990 };
23991 assert!(record.task_binding.is_none());
23992
23993 let mut direct = ResponseEnvelope {
23994 request_id: 2,
23995 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23996 };
23997 project_response_without_session_task_binding(&mut direct);
23998 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23999 panic!("task reply projection changed the response kind");
24000 };
24001 assert!(record.task_binding.is_none());
24002 }
24003
24004 #[test]
24005 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24006 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24007 let workspace = WorkspaceConfig::new(
24008 WorkspaceId::new("primary").unwrap(),
24009 std::env::current_dir().unwrap(),
24010 )
24011 .unwrap();
24012 let config = NodeServerConfig::new(
24013 DEFAULT_NODE_ENDPOINT,
24014 "fixture-token",
24015 NodeId::new("node-1").unwrap(),
24016 [workspace],
24017 )
24018 .unwrap();
24019 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24020 }
24021
24022 #[cfg(all(feature = "fixture", windows))]
24023 #[test]
24024 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24025 let root = temporary_workspace_root("provider-bundle-argv-hold");
24026 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24027 std::fs::create_dir_all(&root).unwrap();
24028 std::fs::create_dir_all(&outside).unwrap();
24029 let proof_path = root.join("provider.proof");
24030 let release_signal = root.join("release.signal");
24031 let config = NodeServerConfig::new(
24032 format!(
24033 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24034 std::process::id(),
24035 ),
24036 "fixture-token",
24037 NodeId::new("provider-bundle-hold-node").unwrap(),
24038 [WorkspaceConfig::new(
24039 WorkspaceId::new("primary").unwrap(),
24040 std::env::current_dir().unwrap(),
24041 )
24042 .unwrap()],
24043 )
24044 .unwrap()
24045 .with_spawn_profiles(SpawnProfileRegistry::new([
24046 crate::protocol::SpawnProfileDefaults {
24047 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24048 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24049 .unwrap(),
24050 provider: agent("kimi"),
24051 mode: SessionMode::Pty,
24052 terminal_size: gate4agent_types::TerminalSize {
24053 rows: 24,
24054 columns: 80,
24055 },
24056 prompt: None,
24057 bundle_id: None,
24058 context_id: None,
24059 environment_profile_id: None,
24060 },
24061 ])
24062 .unwrap());
24063 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24064 config,
24065 agent("kimi"),
24066 proof_path.clone(),
24067 release_signal.clone(),
24068 )
24069 .unwrap();
24070 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24071
24072 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24073 agent("kimi"),
24074 proof_path.clone(),
24075 Some(release_signal.clone()),
24076 )
24077 .unwrap();
24078 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24079 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24080 let release_wait = wrapper
24081 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24082 .unwrap();
24083 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24084 assert!(proof_write < release_wait);
24085 assert!(release_wait < fixture_ready);
24086 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24087 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24088 assert_eq!(
24089 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24090 &[
24091 proof_path.to_string_lossy().into_owned(),
24092 release_signal.to_string_lossy().into_owned(),
24093 ],
24094 );
24095
24096 let error = NodeServer::provider_bundle_argv_fixture_spec(
24097 agent("kimi"),
24098 proof_path,
24099 Some(outside.join("release.signal")),
24100 )
24101 .unwrap_err();
24102 assert!(matches!(
24103 error,
24104 NodeServerError::Registry(message)
24105 if message == "provider bundle argv release path must share the proof parent"
24106 ));
24107 drop(server);
24108 std::fs::remove_dir_all(root).unwrap();
24109 std::fs::remove_dir_all(outside).unwrap();
24110 }
24111
24112 #[cfg(feature = "fixture")]
24113 #[test]
24114 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24115 let root = temporary_workspace_root("context-fixture-registry");
24116 std::fs::create_dir_all(&root).unwrap();
24117 let proof_path = root.join("context.proof");
24118 let config = NodeServerConfig::new(
24119 format!(
24120 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24121 std::process::id(),
24122 ),
24123 "fixture-token",
24124 NodeId::new("context-fixture-node").unwrap(),
24125 [WorkspaceConfig::new(
24126 WorkspaceId::new("primary").unwrap(),
24127 std::env::current_dir().unwrap(),
24128 )
24129 .unwrap()],
24130 )
24131 .unwrap();
24132 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24133 let mut enabled = server
24134 .shared
24135 .enabled_providers
24136 .iter()
24137 .map(AgentId::as_str)
24138 .collect::<Vec<_>>();
24139 enabled.sort_unstable();
24140 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24141 for provider in &server.shared.enabled_providers {
24142 assert!(server
24143 .shared
24144 .provider_adapter_contracts
24145 .iter()
24146 .any(|contract| {
24147 &contract.provider == provider
24148 && contract.family == AdapterFamily::History
24149 }));
24150 }
24151 drop(server);
24152 std::fs::remove_dir_all(root).unwrap();
24153 }
24154
24155 #[cfg(feature = "fixture")]
24156 #[test]
24157 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24158 let root = temporary_workspace_root("monitoring-context-fixture");
24159 std::fs::create_dir_all(&root).unwrap();
24160 let proof_path = root.join("context.proof");
24161 let standard = NodeServer::context_pack_fixture_catalog(
24162 proof_path.clone(),
24163 None,
24164 false,
24165 ).unwrap();
24166 let monitoring = NodeServer::context_pack_fixture_catalog(
24167 proof_path.clone(),
24168 None,
24169 true,
24170 ).unwrap();
24171 let claude = AgentId::new("claude").unwrap();
24172 let standard_claude = standard.get(&claude).unwrap();
24173 let monitoring_claude = monitoring.get(&claude).unwrap();
24174 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24178 assert_eq!(
24179 monitoring_claude.capabilities.adapters.history,
24180 standard_claude.capabilities.adapters.history,
24181 );
24182 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24183 let codex = AgentId::new("codex").unwrap();
24184 let standard_codex = standard.get(&codex).unwrap();
24185 let monitoring_codex = monitoring.get(&codex).unwrap();
24186 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24187 assert_eq!(
24188 monitoring_codex.capabilities.adapters.history,
24189 standard_codex.capabilities.adapters.history,
24190 );
24191 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24192 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24193 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24194 .unwrap();
24195 for required in [
24196 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24197 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24198 "@('role', 'text')",
24199 "g4a-private-provider-session-canary",
24200 "private-provider-login-identity",
24201 "$rawProof",
24202 "$env:GATE4AGENT_HOOK_URL",
24203 "g4a-private-provider-session-canary",
24204 ] {
24205 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24206 }
24207 for provider in ["grok", "kimi"] {
24208 let provider = AgentId::new(provider).unwrap();
24209 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24210 }
24211
24212 let config = NodeServerConfig::new(
24213 format!(
24214 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24215 std::process::id(),
24216 ),
24217 "fixture-token",
24218 NodeId::new("monitoring-context-fixture-node").unwrap(),
24219 [WorkspaceConfig::new(
24220 WorkspaceId::new("primary").unwrap(),
24221 std::env::current_dir().unwrap(),
24222 )
24223 .unwrap()],
24224 )
24225 .unwrap();
24226 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24227 assert!(server.shared.fixture_semantic_hook_policy);
24228 drop(server);
24229 std::fs::remove_dir_all(root).unwrap();
24230 }
24231
24232 #[cfg(feature = "fixture")]
24233 #[test]
24234 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24235 let root = temporary_workspace_root("kimi-context-only-fixture");
24236 std::fs::create_dir_all(&root).unwrap();
24237 let proof_path = root.join("context.proof");
24238 let standard = NodeServer::context_pack_fixture_catalog(
24239 proof_path.clone(),
24240 None,
24241 false,
24242 ).unwrap();
24243 let kimi = AgentId::new("kimi").unwrap();
24244 let context_only = NodeServer::context_pack_fixture_catalog(
24245 proof_path,
24246 Some(&kimi),
24247 false,
24248 )
24249 .unwrap();
24250
24251 let codex = AgentId::new("codex").unwrap();
24252 assert_eq!(
24253 standard.get(&codex).unwrap().launch.fixed_args,
24254 context_only.get(&codex).unwrap().launch.fixed_args,
24255 );
24256 let script = context_only
24257 .get(&kimi)
24258 .unwrap()
24259 .launch
24260 .fixed_args
24261 .iter()
24262 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24263 .expect("Kimi context-only validation script is missing");
24264 for required in [
24265 "$bundleArgs.Count -ne 0",
24266 "GATE4AGENT_CONTEXT_ROOT",
24267 "context-pack.json",
24268 "$entries.Count -ne 1",
24269 "g4a-context-pack-v1",
24270 "$document.PSObject.Properties['cwd']",
24271 "'claude', 'codex', 'grok', 'kimi'",
24272 "$hasUser",
24273 "$hasAssistant",
24274 "SHA256",
24275 "context-only",
24276 ] {
24277 assert!(script.contains(required), "missing validation: {required}");
24278 }
24279 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24280 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24281 }
24282 assert!(!standard
24283 .get(&kimi)
24284 .unwrap()
24285 .launch
24286 .fixed_args
24287 .iter()
24288 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24289
24290 std::fs::remove_dir_all(root).unwrap();
24291 }
24292
24293 #[cfg(all(feature = "fixture", windows))]
24294 #[tokio::test]
24295 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24296 let root = temporary_workspace_root("kimi-context-only-spawn");
24297 let workspace_root = root.join("workspace");
24298 std::fs::create_dir_all(&workspace_root).unwrap();
24299 let materialization_root = root.join("private-materializations");
24300 let proof_path = root.join("context.proof");
24301 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24302 let workspace_id = WorkspaceId::new("primary").unwrap();
24303 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24304 let endpoint = format!(
24305 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24306 std::process::id(),
24307 unix_time_ms(),
24308 );
24309 let spawn_profiles = SpawnProfileRegistry::new([
24310 crate::protocol::SpawnProfileDefaults {
24311 profile_id: profile_id.clone(),
24312 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24313 provider: agent("kimi"),
24314 mode: SessionMode::Pty,
24315 terminal_size: gate4agent_types::TerminalSize {
24316 rows: 30,
24317 columns: 120,
24318 },
24319 prompt: None,
24320 bundle_id: None,
24321 context_id: None,
24322 environment_profile_id: None,
24323 },
24324 ])
24325 .unwrap();
24326 let mut config = NodeServerConfig::new(
24327 &endpoint,
24328 "fixture-token",
24329 node_id.clone(),
24330 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24331 )
24332 .unwrap()
24333 .with_spawn_profiles(spawn_profiles)
24334 .with_session_environment_materialization(
24335 materialization_root,
24336 Arc::new(FixtureSecretResolver {
24337 deny: Arc::new(AtomicBool::new(false)),
24338 }),
24339 )
24340 .unwrap();
24341 config.fixture_raw_pty_runtime = true;
24342 let server = NodeServer::new_context_only_proof_fixture(
24343 config,
24344 agent("kimi"),
24345 proof_path.clone(),
24346 )
24347 .unwrap();
24348 let shared = Arc::clone(&server.shared);
24349 let shutdown = server.shutdown_handle();
24350
24351 let history = HistorySessionRecord {
24352 session_id: "context-only-source-history".to_owned(),
24353 title: None,
24354 cwd: None,
24355 model: None,
24356 message_count: 2,
24357 completed_turn_count: None,
24358 total_tokens: 0,
24359 messages: vec![
24360 gate4agent_types::HistoryMessageRecord {
24361 role: gate4agent_types::HistoryMessageRole::User,
24362 text: "continue the bounded parent run through native C2".to_owned(),
24363 },
24364 gate4agent_types::HistoryMessageRecord {
24365 role: gate4agent_types::HistoryMessageRole::Assistant,
24366 text: "the exact parent context is ready for the child run".to_owned(),
24367 },
24368 ],
24369 };
24370 let pack = NodeContextPack::export(
24371 ContextPackLineageReceipt {
24372 source_node_id: node_id.clone(),
24373 source_session: SessionAddress {
24374 workspace_id: workspace_id.clone(),
24375 session: SessionKey {
24376 instance_id: AgentInstanceId(71),
24377 generation: SessionGeneration(1),
24378 },
24379 },
24380 source_provider: agent("codex"),
24381 },
24382 &history,
24383 )
24384 .unwrap();
24385 let expected_context_hash = digest(&SHA256, pack.bytes())
24386 .as_ref()
24387 .iter()
24388 .map(|byte| format!("{byte:02x}"))
24389 .collect::<String>();
24390 let context = shared
24391 .context_catalog
24392 .write()
24393 .unwrap_or_else(|poisoned| poisoned.into_inner())
24394 .insert(pack)
24395 .unwrap();
24396 let spec = SpawnSpec {
24397 target: crate::protocol::SpawnTarget {
24398 node_id,
24399 workspace_id: workspace_id.clone(),
24400 worktree_id: None,
24401 },
24402 profile_id,
24403 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24404 "target-kimi-r1",
24405 ).unwrap(),
24406 overrides: crate::protocol::SpawnOverrides {
24407 context_id: crate::protocol::SpawnOverride::Set {
24408 value: context.id.clone(),
24409 },
24410 ..crate::protocol::SpawnOverrides::default()
24411 },
24412 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24413 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24414 required_capabilities: SpawnRequiredCapabilities::default(),
24415 };
24416 let resolved = match shared.resolve_spawn_spec(&spec) {
24417 Ok(resolved) => resolved,
24418 Err(error) => panic!(
24419 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24420 error.code,
24421 error.message,
24422 ),
24423 };
24424 let resolved_context = match shared.resolve_context(&resolved) {
24425 Ok(Some(resolved_context)) => resolved_context,
24426 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24427 Err(error) => panic!(
24428 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24429 error.code,
24430 error.message,
24431 ),
24432 };
24433 assert_eq!(resolved_context, context);
24434 let runtime_policy = match shared
24435 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24436 .await
24437 {
24438 Ok(runtime_policy) => runtime_policy,
24439 Err(error) => panic!(
24440 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24441 error.code,
24442 error.message,
24443 ),
24444 };
24445 assert!(runtime_policy.raw_pty_lifecycle);
24446
24447 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24448 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24449 agent("kimi"),
24450 TransportKind::Pty,
24451 vec![EnvMutation {
24452 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24453 value: Some(OsString::from("must-remain-rejected")),
24454 }],
24455 )
24456 .unwrap();
24457 assert_eq!(
24458 launch_control
24459 .install_native_launch_environment_overlay(
24460 AgentInstanceId(72),
24461 generic_overlay,
24462 )
24463 .unwrap_err(),
24464 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24465 );
24466
24467 let diagnostic_address = SessionAddress {
24468 workspace_id: workspace_id.clone(),
24469 session: SessionKey {
24470 instance_id: AgentInstanceId(73),
24471 generation: SessionGeneration(1),
24472 },
24473 };
24474 let (diagnostic_overlay, diagnostic_materialization) = match shared
24475 .prepare_session_materialization(
24476 &diagnostic_address,
24477 &agent("kimi"),
24478 SessionMode::Pty,
24479 None,
24480 None,
24481 Some(&resolved_context),
24482 None,
24483 )
24484 {
24485 Ok(Some(prepared)) => prepared,
24486 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24487 Err(error) => panic!(
24488 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24489 error.code,
24490 error.message,
24491 ),
24492 };
24493 let diagnostic_overlay = diagnostic_overlay
24494 .expect("Kimi context-only materialization omitted its environment overlay");
24495 assert!(matches!(
24496 &diagnostic_overlay,
24497 PreparedNativeLaunchOverlay::Environment(_),
24498 ));
24499 match shared.install_prepared_launch_overlay(
24500 diagnostic_address.session.instance_id,
24501 diagnostic_overlay,
24502 ) {
24503 Ok(None) => {}
24504 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24505 Err(error) => panic!(
24506 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24507 error.code,
24508 error.message,
24509 ),
24510 }
24511 assert!(launch_control.clear_native_instance_launch_overlay(
24512 diagnostic_address.session.instance_id,
24513 ));
24514 drop(diagnostic_materialization);
24515 assert!(shared
24516 .materializations
24517 .lock()
24518 .unwrap_or_else(|poisoned| poisoned.into_inner())
24519 .is_empty());
24520
24521 let node_task = tokio::spawn(server.run());
24522 let ready_client = timeout(Duration::from_secs(5), async {
24523 loop {
24524 match gate4agent_node_wire::LocalNodeClient::connect(
24525 &endpoint,
24526 &shared.node_id,
24527 ClientRole::Observer,
24528 "fixture-token",
24529 )
24530 .await
24531 {
24532 Ok(client) => break client,
24533 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24534 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24535 }
24536 }
24537 })
24538 .await
24539 .expect("Kimi context-only Node did not become ready");
24540 assert_eq!(
24541 ready_client
24542 .hello()
24543 .snapshot
24544 .provider_runtime_statuses
24545 .iter()
24546 .find(|status| status.provider().as_str() == "kimi")
24547 .expect("Kimi runtime status is missing")
24548 .mode(),
24549 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24550 );
24551 drop(ready_client);
24552
24553 let receipt = match shared.spawn_from_spec(spec).await {
24554 Ok(receipt) => receipt,
24555 Err(error) => panic!(
24556 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24557 error.code,
24558 error.message,
24559 ),
24560 };
24561 assert_eq!(receipt.provider, agent("kimi"));
24562 assert_eq!(receipt.context.as_ref(), Some(&context));
24563 assert!(receipt.context_binding_is_valid());
24564 timeout(Duration::from_secs(5), async {
24565 while !proof_path.is_file() {
24566 sleep(Duration::from_millis(10)).await;
24567 }
24568 })
24569 .await
24570 .expect("Kimi context-only fixture did not write its proof");
24571
24572 let proof = std::fs::read_to_string(&proof_path).unwrap();
24573 let lines = proof.lines().collect::<Vec<_>>();
24574 assert_eq!(lines.len(), 7);
24575 assert_eq!(lines[0], "context-only");
24576 assert!(Path::new(lines[1]).is_absolute());
24577 assert_ne!(
24578 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24579 std::fs::canonicalize(&workspace_root).unwrap(),
24580 );
24581 assert_eq!(
24582 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24583 std::fs::canonicalize(&workspace_root).unwrap(),
24584 );
24585 assert_eq!(lines[3], expected_context_hash);
24586 assert_eq!(lines[4], "g4a-context-pack-v1");
24587 assert_eq!(lines[5], "codex");
24588 assert_eq!(lines[6], "2");
24589 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24590 session.instance_id == receipt.session.session.instance_id
24591 && session.agent_id.as_str() == "kimi"
24592 }));
24593
24594 shutdown.request_shutdown().await.unwrap();
24595 timeout(Duration::from_secs(5), node_task)
24596 .await
24597 .unwrap()
24598 .unwrap()
24599 .unwrap();
24600 drop(shutdown);
24601 drop(shared);
24602 std::fs::remove_dir_all(root).unwrap();
24603 }
24604
24605 #[cfg(feature = "fixture")]
24606 #[test]
24607 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24608 let root = temporary_workspace_root("clean-exit-constructor");
24609 std::fs::create_dir_all(&root).unwrap();
24610 let config = construction_test_config("clean-exit-constructor")
24611 .with_state_path(root.join("node-state.json"))
24612 .unwrap();
24613 let server = NodeServer::new_clean_exit_fixture(
24614 config,
24615 root.clone(),
24616 root.join("started.marker"),
24617 root.join("release.signal"),
24618 )
24619 .unwrap();
24620 assert_eq!(
24621 server
24622 .shared
24623 .enabled_providers
24624 .iter()
24625 .map(AgentId::as_str)
24626 .collect::<Vec<_>>(),
24627 ["claude"],
24628 );
24629 drop(server);
24630 std::fs::remove_dir_all(root).unwrap();
24631 }
24632
24633 #[test]
24634 fn production_registry_provider_contract_manifest_is_exact() {
24635 let registry = active_registry().unwrap();
24636 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24637 assert_eq!(
24638 providers
24639 .iter()
24640 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24641 .collect::<Vec<_>>(),
24642 vec![
24643 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24644 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24645 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24646 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24647 ]
24648 );
24649 assert_eq!(
24650 adapters
24651 .iter()
24652 .map(|contract| (
24653 contract.provider.as_str(),
24654 contract.family,
24655 contract.adapter_id.as_str(),
24656 contract.revision.as_str(),
24657 ))
24658 .collect::<Vec<_>>(),
24659 vec![
24660 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24661 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24662 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24663 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24664 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24665 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24666 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24667 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24668 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24669 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24670 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24671 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24672 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24673 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24674 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24675 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24676 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24677 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24678 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24679 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24680 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24681 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24682 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24683 ]
24684 );
24685 }
24686
24687 fn construction_test_config(name: &str) -> NodeServerConfig {
24688 let workspace = WorkspaceConfig::new(
24689 WorkspaceId::new("primary").unwrap(),
24690 std::env::current_dir().unwrap(),
24691 )
24692 .unwrap();
24693 NodeServerConfig::new(
24694 format!(r"\\.\pipe\gate4agent-{name}"),
24695 "fixture-token",
24696 NodeId::new(name).unwrap(),
24697 [workspace],
24698 )
24699 .unwrap()
24700 }
24701
24702 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24703 let adapter = AdapterBinding::new(
24704 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24705 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24706 gate4agent_types::AdapterVerification::SyntheticFixture,
24707 )
24708 .unwrap();
24709 let agent_ids = ["claude", "codex", "kimi"]
24710 .into_iter()
24711 .map(|agent_id| AgentId::new(agent_id).unwrap())
24712 .collect::<Vec<_>>();
24713 let adapters = gate4agent_catalog::AdapterRegistry::new(
24714 [
24715 AdapterFamily::PtySemantic,
24716 AdapterFamily::Pipe,
24717 AdapterFamily::OneShot,
24718 AdapterFamily::Acp,
24719 AdapterFamily::Hook,
24720 AdapterFamily::ManagedHook,
24721 AdapterFamily::History,
24722 AdapterFamily::Resume,
24723 AdapterFamily::SessionOptions,
24724 AdapterFamily::CapabilityProbe,
24725 ]
24726 .into_iter()
24727 .map(|family| gate4agent_catalog::AdapterDescriptor {
24728 family,
24729 binding: adapter.clone(),
24730 agents: agent_ids.clone(),
24731 }),
24732 )
24733 .unwrap();
24734 let specs = agent_ids
24735 .iter()
24736 .map(|agent_id| {
24737 let agent_id = agent_id.as_str();
24738 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24739 spec.revision =
24740 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24741 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24742 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24743 adapter: adapter.clone(),
24744 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24745 launch_override: None,
24746 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24747 });
24748 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24749 adapter: adapter.clone(),
24750 launch_override: None,
24751 });
24752 spec.capabilities.adapters.hook = Some(adapter.clone());
24753 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24754 spec.capabilities.adapters.one_shot = Some(adapter.clone());
24755 spec.capabilities.adapters.history = Some(adapter.clone());
24756 spec.capabilities.adapters.resume = Some(adapter.clone());
24757 spec.capabilities.adapters.session_options = Some(adapter.clone());
24758 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24759 spec
24760 })
24761 .collect::<Vec<_>>();
24762 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24763 }
24764
24765 #[test]
24773 fn node_server_construction_accepts_the_production_manifest() {
24774 let registry = active_registry().unwrap();
24775 let (providers, _) = provider_contract_manifest(®istry).unwrap();
24776 assert_eq!(providers.len(), registry.iter().count());
24777 assert!(!providers.is_empty());
24778 let server = NodeServer::new_with_registry(
24779 construction_test_config("manifest-production-bound"),
24780 registry,
24781 )
24782 .unwrap();
24783 drop(server);
24784 }
24785
24786 #[test]
24787 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24788 let registry = maximum_size_provider_manifest_registry();
24789 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24790 assert_eq!(providers.len(), 3);
24791 assert_eq!(adapters.len(), 30);
24792 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24793 let error = match NodeServer::new_with_registry(
24794 construction_test_config("manifest-handshake-overflow"),
24795 registry,
24796 ) {
24797 Ok(_) => panic!("oversized negotiated manifest was accepted"),
24798 Err(error) => error,
24799 };
24800 assert!(matches!(
24801 error,
24802 NodeServerError::ProviderContractManifest(message)
24803 if message.contains("authentication binding length")
24804 && message.contains("8192-byte limit")
24805 ));
24806 }
24807
24808 #[test]
24809 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24810 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24811 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24812 let registry = AgentRegistry::new([spec]).unwrap();
24813 assert!(matches!(
24814 provider_contract_manifest(®istry),
24815 Err(NodeServerError::ProviderContractManifest(message))
24816 if message.contains("exceeds the 128-byte limit")
24817 ));
24818 }
24819
24820 #[test]
24821 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24822 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24823 spec.revision = "invalid revision".to_owned();
24824 let registry = AgentRegistry::new([spec]).unwrap();
24825 assert!(matches!(
24826 provider_contract_manifest(®istry),
24827 Err(NodeServerError::ProviderContractManifest(message))
24828 if message.contains("bounded lowercase ASCII")
24829 ));
24830 }
24831
24832 #[test]
24833 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24834 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24835 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24836 let conflicting_binding = builtin_registry()
24837 .get_by_id("codex")
24838 .unwrap()
24839 .capabilities
24840 .adapters
24841 .one_shot
24842 .clone()
24843 .unwrap();
24844 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24845 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24846 pipe.adapter = transport_binding;
24847 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24848 let registry = AgentRegistry::new([spec]).unwrap();
24849 assert!(matches!(
24850 provider_contract_manifest(®istry),
24851 Err(NodeServerError::ProviderContractManifest(message))
24852 if message.contains("conflicting OneShot adapter bindings")
24853 ));
24854 }
24855
24856 #[test]
24857 fn public_node_failures_expose_only_fixed_categories() {
24858 let provider = failure(
24859 NodeFailureCode::BackendOperationFailed,
24860 r"provider bearer-secret failed at C:\private\session.jsonl",
24861 );
24862 assert_eq!(provider.message, "backend-operation-failed");
24863 assert!(!provider.message.contains("bearer-secret"));
24864 assert!(!provider.message.contains(r"C:\private"));
24865 let persistence = persistence_failure(io::Error::new(
24866 io::ErrorKind::Other,
24867 r"state write failed at C:\private\state-v1.json",
24868 ));
24869 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24870 assert!(!persistence.message.contains(r"C:\private"));
24871
24872 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24873 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24874 assert_eq!(
24875 generic.to_string(),
24876 "node durable state failed: durable-state-load-failed",
24877 );
24878 }
24879 }
24880
24881 #[test]
24882 fn terminal_byte_sequences_are_bounded_before_dispatch() {
24883 let sequence = b"\x1b[1;5D".to_vec();
24884 assert_eq!(
24885 terminal_bytes_action(sequence.clone()).unwrap(),
24886 InputAction::TerminalBytes(sequence),
24887 );
24888 assert_eq!(
24889 terminal_bytes_action(Vec::new()).unwrap_err().code,
24890 NodeFailureCode::InvalidRequest,
24891 );
24892 assert_eq!(
24893 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24894 .unwrap_err()
24895 .code,
24896 NodeFailureCode::InvalidRequest,
24897 );
24898 }
24899
24900 #[test]
24901 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24902 let root = temporary_workspace_root("exclusive-state-owner");
24903 std::fs::create_dir_all(&root).unwrap();
24904 let workspace = WorkspaceConfig::new(
24905 WorkspaceId::new("primary").unwrap(),
24906 &root,
24907 )
24908 .unwrap();
24909 let state_path = root.join("state-v1.json");
24910 let endpoint = format!(
24911 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24912 std::process::id(),
24913 std::time::SystemTime::now()
24914 .duration_since(std::time::UNIX_EPOCH)
24915 .unwrap()
24916 .as_nanos(),
24917 );
24918 let config = NodeServerConfig::new(
24919 endpoint,
24920 "fixture-token",
24921 NodeId::new("node-1").unwrap(),
24922 [workspace],
24923 )
24924 .unwrap()
24925 .with_state_path(&state_path)
24926 .unwrap();
24927
24928 let first = NodeServer::new(config.clone()).unwrap();
24929 let error = match NodeServer::new(config.clone()) {
24930 Ok(_) => panic!("a second node server acquired the same durable state path"),
24931 Err(error) => error,
24932 };
24933 assert!(matches!(
24934 &error,
24935 NodeServerError::DurableState(error)
24936 if error.kind() == io::ErrorKind::WouldBlock
24937 ));
24938 assert_eq!(
24939 error.to_string(),
24940 "node durable state failed: durable-state-lock-failed",
24941 );
24942 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24943 drop(first);
24944 let second = NodeServer::new(config).unwrap();
24945 drop(second);
24946 std::fs::remove_dir_all(root).unwrap();
24947 }
24948
24949 #[test]
24950 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24951 let catalog = active_registry().unwrap();
24952 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24953 let workspace_id = WorkspaceId::new("primary").unwrap();
24954 let workspace = WorkspaceConfig::new(
24955 workspace_id.clone(),
24956 std::env::current_dir().unwrap(),
24957 )
24958 .unwrap();
24959 let shared = NodeShared::new(
24960 handle,
24961 "fixture-token".to_owned(),
24962 NodeId::new("node-1").unwrap(),
24963 vec![workspace.clone()],
24964 vec![agent("claude"), agent("codex"), agent("kimi")],
24965 );
24966 let instance_id = AgentInstanceId(17);
24967 let generation = SessionGeneration(3);
24968 assert!(shared.address_for(instance_id, generation).is_none());
24969 shared.bind_session(instance_id, workspace_id.clone(), generation);
24970 let address = SessionAddress {
24971 workspace_id,
24972 session: SessionKey { instance_id, generation },
24973 };
24974 assert_eq!(
24975 shared.workspace_root(&address.workspace_id).unwrap(),
24976 workspace.canonical_root,
24977 );
24978 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24979 shared.remove_binding(&address);
24980 assert!(shared.address_for(instance_id, generation).is_none());
24981 }
24982
24983 #[test]
24984 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24985 let address = SessionAddress {
24986 workspace_id: WorkspaceId::new("primary").unwrap(),
24987 session: SessionKey {
24988 instance_id: AgentInstanceId(7),
24989 generation: SessionGeneration(2),
24990 },
24991 };
24992 let record_id = SessionRecordId::new("record-a").unwrap();
24993 let size = gate4agent_types::TerminalSize {
24994 rows: 24,
24995 columns: 80,
24996 };
24997 let address_requests = [
24998 NodeRequest::Resume {
24999 session: address.clone(),
25000 terminal_size: size,
25001 initial_prompt: None,
25002 },
25003 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25004 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25005 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25006 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25007 NodeRequest::TerminalControl {
25008 session: address.clone(),
25009 control: TerminalControl::Enter,
25010 },
25011 NodeRequest::Resize { session: address.clone(), size },
25012 NodeRequest::Interrupt { session: address.clone() },
25013 NodeRequest::Stop { session: address.clone(), force: false },
25014 NodeRequest::Remove { session: address },
25015 ];
25016 for request in &address_requests {
25017 assert!(request_requires_open_provider_ids_with(
25018 request,
25019 |_| Some(agent("third-party-agent")),
25020 |_| Some(agent("claude")),
25021 ));
25022 assert!(!request_requires_open_provider_ids_with(
25023 request,
25024 |_| Some(agent("claude")),
25025 |_| Some(agent("claude")),
25026 ));
25027 assert!(request_requires_open_provider_ids_with(
25028 request,
25029 |_| None,
25030 |_| Some(agent("claude")),
25031 ));
25032 }
25033
25034 let record_requests = [
25035 NodeRequest::RenameSessionRecord {
25036 record_id: record_id.clone(),
25037 display_name: "renamed".to_owned(),
25038 },
25039 NodeRequest::ResumeSessionRecord {
25040 record_id: record_id.clone(),
25041 terminal_size: size,
25042 initial_prompt: None,
25043 },
25044 NodeRequest::ForgetSessionRecord { record_id },
25045 ];
25046 for request in &record_requests {
25047 assert!(request_requires_open_provider_ids_with(
25048 request,
25049 |_| Some(agent("claude")),
25050 |_| Some(agent("grok")),
25051 ));
25052 assert!(!request_requires_open_provider_ids_with(
25053 request,
25054 |_| Some(agent("claude")),
25055 |_| Some(agent("kimi")),
25056 ));
25057 assert!(request_requires_open_provider_ids_with(
25058 request,
25059 |_| Some(agent("claude")),
25060 |_| None,
25061 ));
25062 }
25063
25064 assert!(request_requires_open_provider_ids_with(
25065 &NodeRequest::Spawn {
25066 workspace_id: WorkspaceId::new("primary").unwrap(),
25067 provider: agent("third-party-agent"),
25068 mode: SessionMode::Pty,
25069 terminal_size: size,
25070 initial_prompt: None,
25071 },
25072 |_| None,
25073 |_| None,
25074 ));
25075 assert!(!request_requires_open_provider_ids_with(
25076 &NodeRequest::Snapshot,
25077 |_| None,
25078 |_| None,
25079 ));
25080 assert!(request_requires_open_provider_ids_with(
25081 &NodeRequest::ForgetContextPack {
25082 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25083 },
25084 |_| None,
25085 |_| None,
25086 ));
25087 }
25088
25089 #[test]
25090 fn legacy_projection_filters_open_record_replies_and_event_history() {
25091 let catalog = active_registry().unwrap();
25092 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25093 let workspace = WorkspaceConfig::new(
25094 WorkspaceId::new("primary").unwrap(),
25095 std::env::current_dir().unwrap(),
25096 )
25097 .unwrap();
25098 let shared = NodeShared::new(
25099 handle,
25100 "fixture-token".to_owned(),
25101 NodeId::new("node-1").unwrap(),
25102 vec![workspace],
25103 vec![agent("claude"), agent("grok")],
25104 );
25105 let legacy = record("claude", "legacy-record");
25106 let open = record("grok", "open-record");
25107 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25108 record: legacy.clone(),
25109 });
25110 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25111 record: open.clone(),
25112 });
25113 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25114 record_id: legacy.record_id.clone(),
25115 });
25116 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25117 record_id: open.record_id.clone(),
25118 });
25119 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25120 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25121 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25122 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25123
25124 let mut record_reply = ResponseEnvelope {
25125 request_id: 1,
25126 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25127 };
25128 project_response_legacy_provider_ids(&shared, &mut record_reply);
25129 assert!(matches!(
25130 record_reply.result,
25131 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25132 ));
25133
25134 let mut resync = ResponseEnvelope {
25135 request_id: 2,
25136 result: Ok(NodeResponse::Resync {
25137 event_sequence: open_removed.sequence,
25138 oldest_available_sequence: 1,
25139 snapshot: shared.snapshot(),
25140 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25141 }),
25142 };
25143 project_response_legacy_provider_ids(&shared, &mut resync);
25144 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25145 panic!("legacy projection changed the resync response kind");
25146 };
25147 assert!(snapshot.session_records.iter().all(|item| {
25148 provider_id_is_legacy(&item.provider)
25149 }));
25150 assert_eq!(events.len(), 2);
25151 assert!(events.iter().all(|event| matches!(
25152 &event.event,
25153 NodeEvent::SessionRecordUpserted { record }
25154 if provider_id_is_legacy(&record.provider)
25155 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25156 }
25157
25158 #[test]
25159 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25160 let catalog = active_registry().unwrap();
25161 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25162 let workspace_id = WorkspaceId::new("primary").unwrap();
25163 let workspace = WorkspaceConfig::new(
25164 workspace_id.clone(),
25165 std::env::current_dir().unwrap(),
25166 )
25167 .unwrap();
25168 let shared = NodeShared::new(
25169 handle,
25170 "fixture-token".to_owned(),
25171 NodeId::new("node-1").unwrap(),
25172 vec![workspace],
25173 vec![agent("claude")],
25174 );
25175 for index in 0..CONTROL_SESSIONS_MAX {
25176 shared.bind_session(
25177 AgentInstanceId((index + 1) as u64),
25178 workspace_id.clone(),
25179 SessionGeneration(1),
25180 );
25181 }
25182 let error = shared.ensure_binding_capacity().unwrap_err();
25183 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25184 }
25185
25186 #[tokio::test]
25187 async fn provider_spawn_admission_rejects_before_session_mutation() {
25188 let catalog = active_registry().unwrap();
25189 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25190 let workspace = WorkspaceConfig::new(
25191 WorkspaceId::new("primary").unwrap(),
25192 std::env::current_dir().unwrap(),
25193 )
25194 .unwrap();
25195 let mut shared = NodeShared::new(
25196 handle,
25197 "fixture-token".to_owned(),
25198 NodeId::new("node-provider-admission").unwrap(),
25199 vec![workspace],
25200 vec![agent("claude")],
25201 );
25202 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25203 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25204 ])
25205 .unwrap();
25206 let terminal_size = gate4agent_types::TerminalSize {
25207 rows: 24,
25208 columns: 80,
25209 };
25210
25211 let unavailable = shared
25212 .spawn_session(
25213 WorkspaceId::new("primary").unwrap(),
25214 agent("claude"),
25215 SessionMode::Pty,
25216 terminal_size,
25217 None,
25218 )
25219 .await
25220 .unwrap_err();
25221 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25222 assert_eq!(unavailable.message, "backend-operation-failed");
25223
25224 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25225 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25226 agent("claude"),
25227 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25228 ),
25229 ])
25230 .unwrap();
25231 let semantic = shared
25232 .spawn_session(
25233 WorkspaceId::new("primary").unwrap(),
25234 agent("claude"),
25235 SessionMode::Inline,
25236 terminal_size,
25237 None,
25238 )
25239 .await
25240 .unwrap_err();
25241 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25242 assert_eq!(semantic.message, "unsupported-capability");
25243
25244 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25245 assert!(shared
25246 .session_bindings
25247 .lock()
25248 .unwrap_or_else(|poisoned| poisoned.into_inner())
25249 .is_empty());
25250 assert!(shared
25251 .session_records
25252 .lock()
25253 .unwrap_or_else(|poisoned| poisoned.into_inner())
25254 .records
25255 .is_empty());
25256 assert!(shared.handle.snapshot().sessions.is_empty());
25257 }
25258
25259 #[tokio::test]
25260 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25261 let catalog = active_registry().unwrap();
25262 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25263 let workspace_id = WorkspaceId::new("primary").unwrap();
25264 let workspace = WorkspaceConfig::new(
25265 workspace_id.clone(),
25266 std::env::current_dir().unwrap(),
25267 )
25268 .unwrap();
25269 let shared = NodeShared::new(
25270 handle,
25271 "fixture-token".to_owned(),
25272 NodeId::new("node-raw-spawn-record").unwrap(),
25273 vec![workspace],
25274 vec![agent("claude")],
25275 );
25276 let address = SessionAddress {
25277 workspace_id,
25278 session: SessionKey {
25279 instance_id: AgentInstanceId(71),
25280 generation: SessionGeneration::default(),
25281 },
25282 };
25283
25284 let record_id = shared
25285 .bind_spawn_session(
25286 &address,
25287 agent("claude"),
25288 SessionMode::Pty,
25289 ProviderRuntimePolicy::raw_pty(),
25290 SpawnRecordPolicy::Always,
25291 None,
25292 )
25293 .unwrap()
25294 .unwrap();
25295 shared
25296 .handle
25297 .dispatch(shared.prepare_command(ControlCommand::Register {
25298 instance_id: address.session.instance_id,
25299 agent_id: agent("claude"),
25300 transport: TransportKind::Pty,
25301 }))
25302 .unwrap();
25303 runtime.tick().await;
25304
25305 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25306 session.instance_id == address.session.instance_id
25307 && session.generation == address.session.generation
25308 }));
25309 let record = shared.record(&record_id).unwrap();
25310 assert_eq!(record.display_name, "claude #1");
25311 assert_eq!(record.state, ManagedSessionState::Live);
25312 assert_eq!(record.active_session.as_ref(), Some(&address));
25313 assert!(record.provider_session.is_none());
25314 let binding_record_id = shared
25315 .session_bindings
25316 .lock()
25317 .unwrap_or_else(|poisoned| poisoned.into_inner())
25318 .get(&address.session.instance_id)
25319 .and_then(|binding| binding.record_id.clone());
25320 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25321
25322 let renamed = shared
25323 .rename_session_record(&record_id, "raw review".to_owned())
25324 .unwrap();
25325 assert_eq!(renamed.record_id, record_id);
25326 assert_eq!(renamed.display_name, "raw review");
25327 shared.reconcile_managed_record(
25328 &record_id,
25329 &address,
25330 &ControlEvent {
25331 sequence: 1,
25332 command_id: None,
25333 instance_id: address.session.instance_id,
25334 generation: address.session.generation,
25335 event: ControlEventKind::Running { process_id: None },
25336 },
25337 false,
25338 false,
25339 );
25340 let running = shared.record(&record_id).unwrap();
25341 assert_eq!(running.state, ManagedSessionState::Live);
25342 assert_eq!(running.active_session.as_ref(), Some(&address));
25343 assert_eq!(running.display_name, "raw review");
25344
25345 shared.reconcile_managed_record(
25346 &record_id,
25347 &address,
25348 &ControlEvent {
25349 sequence: 2,
25350 command_id: None,
25351 instance_id: address.session.instance_id,
25352 generation: address.session.generation,
25353 event: ControlEventKind::Exited {
25354 exit_code: Some(0),
25355 forced: false,
25356 },
25357 },
25358 false,
25359 false,
25360 );
25361 let detached = shared.record(&record_id).unwrap();
25362 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25363 assert!(detached.active_session.is_none());
25364 assert_eq!(detached.display_name, "raw review");
25365 let renamed_detached = shared
25366 .rename_session_record(&record_id, "raw review detached".to_owned())
25367 .unwrap();
25368 assert_eq!(renamed_detached.record_id, record_id);
25369 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25370 assert!(renamed_detached.active_session.is_none());
25371 assert_eq!(renamed_detached.display_name, "raw review detached");
25372 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25373 }
25374
25375 #[test]
25376 fn legacy_raw_spawn_binding_remains_untracked() {
25377 let catalog = active_registry().unwrap();
25378 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25379 let workspace_id = WorkspaceId::new("primary").unwrap();
25380 let workspace = WorkspaceConfig::new(
25381 workspace_id.clone(),
25382 std::env::current_dir().unwrap(),
25383 )
25384 .unwrap();
25385 let shared = NodeShared::new(
25386 handle,
25387 "fixture-token".to_owned(),
25388 NodeId::new("node-legacy-raw-spawn").unwrap(),
25389 vec![workspace],
25390 vec![agent("claude")],
25391 );
25392 let address = SessionAddress {
25393 workspace_id,
25394 session: SessionKey {
25395 instance_id: AgentInstanceId(72),
25396 generation: SessionGeneration::default(),
25397 },
25398 };
25399
25400 let record_id = shared
25401 .bind_spawn_session(
25402 &address,
25403 agent("claude"),
25404 SessionMode::Pty,
25405 ProviderRuntimePolicy::raw_pty(),
25406 SpawnRecordPolicy::ProviderIdentityOnly,
25407 None,
25408 )
25409 .unwrap();
25410
25411 assert!(record_id.is_none());
25412 let binding_record_id = shared
25413 .session_bindings
25414 .lock()
25415 .unwrap_or_else(|poisoned| poisoned.into_inner())
25416 .get(&address.session.instance_id)
25417 .and_then(|binding| binding.record_id.clone());
25418 assert!(binding_record_id.is_none());
25419 assert!(shared.snapshot().session_records.is_empty());
25420 }
25421
25422 #[tokio::test]
25423 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25424 let catalog = active_registry().unwrap();
25425 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25426 let workspace_id = WorkspaceId::new("primary").unwrap();
25427 let workspace = WorkspaceConfig::new(
25428 workspace_id.clone(),
25429 std::env::current_dir().unwrap(),
25430 )
25431 .unwrap();
25432 let mut shared = NodeShared::new(
25433 handle,
25434 "fixture-token".to_owned(),
25435 NodeId::new("node-raw-semantic-gate").unwrap(),
25436 vec![workspace],
25437 vec![agent("claude")],
25438 );
25439 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25440 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25441 ])
25442 .unwrap();
25443 shared
25444 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25445 .unwrap();
25446 let address = SessionAddress {
25447 workspace_id,
25448 session: SessionKey {
25449 instance_id: AgentInstanceId(72),
25450 generation: SessionGeneration::default(),
25451 },
25452 };
25453 shared.bind_session_with_policy(
25454 &address,
25455 ProviderRuntimePolicy::raw_pty(),
25456 None,
25457 );
25458 shared
25459 .handle
25460 .dispatch(shared.prepare_command(ControlCommand::Register {
25461 instance_id: address.session.instance_id,
25462 agent_id: agent("claude"),
25463 transport: TransportKind::Pty,
25464 }))
25465 .unwrap();
25466 runtime.tick().await;
25467 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25468 let revision_before = shared.handle.snapshot().revision;
25469 let history_sequence_before = shared.current_sequence();
25470 let terminal_size = gate4agent_types::TerminalSize {
25471 rows: 24,
25472 columns: 80,
25473 };
25474
25475 for request in [
25476 NodeRequest::Prompt {
25477 session: address.clone(),
25478 text: "prompt".to_owned(),
25479 },
25480 NodeRequest::Paste {
25481 session: address.clone(),
25482 text: "paste".to_owned(),
25483 },
25484 NodeRequest::Resume {
25490 session: address.clone(),
25491 terminal_size,
25492 initial_prompt: Some("resume".to_owned()),
25493 },
25494 ] {
25495 let error = process_request_inner(
25496 &shared,
25497 77,
25498 ClientRole::Operator,
25499 request,
25500 )
25501 .await
25502 .unwrap_err();
25503 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25504 }
25505
25506 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25507 assert_eq!(shared.handle.snapshot().revision, revision_before);
25508 assert_eq!(shared.current_sequence(), history_sequence_before);
25509 let bindings = shared
25510 .session_bindings
25511 .lock()
25512 .unwrap_or_else(|poisoned| poisoned.into_inner());
25513 let binding = bindings.get(&address.session.instance_id).unwrap();
25514 assert_eq!(binding.generation, address.session.generation);
25515 assert!(binding.pending_resume.is_none());
25516 assert!(binding.record_id.is_none());
25517 }
25518
25519 #[tokio::test]
25520 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25521 let catalog = active_registry().unwrap();
25522 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25523 let primary = WorkspaceConfig::new(
25524 WorkspaceId::new("primary").unwrap(),
25525 std::env::current_dir().unwrap(),
25526 )
25527 .unwrap();
25528 let shared = NodeShared::new(
25529 handle,
25530 "fixture-token".to_owned(),
25531 NodeId::new("node-1").unwrap(),
25532 vec![primary],
25533 vec![agent("claude")],
25534 );
25535 shared
25536 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25537 .unwrap();
25538 let before = shared.snapshot();
25539 let error = process_request_inner(
25540 &shared,
25541 77,
25542 ClientRole::Operator,
25543 NodeRequest::RegisterWorkspace {
25544 workspace_id: WorkspaceId::new("foreign").unwrap(),
25545 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25546 },
25547 )
25548 .await
25549 .unwrap_err();
25550 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25551 assert_eq!(error.message, "invalid-request");
25552 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25553 }
25554
25555 #[tokio::test]
25556 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25557 let catalog = active_registry().unwrap();
25558 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25559 let primary_id = WorkspaceId::new("primary").unwrap();
25560 let primary = WorkspaceConfig::new(
25561 primary_id.clone(),
25562 std::env::current_dir().unwrap(),
25563 )
25564 .unwrap();
25565 let shared = NodeShared::new(
25566 handle,
25567 "fixture-token".to_owned(),
25568 NodeId::new("node-1").unwrap(),
25569 vec![primary.clone()],
25570 vec![agent("claude")],
25571 );
25572 let secondary_id = WorkspaceId::new("secondary").unwrap();
25573 let secondary = shared
25574 .register_workspace(
25575 secondary_id.clone(),
25576 std::env::temp_dir().to_string_lossy().into_owned(),
25577 )
25578 .await
25579 .unwrap();
25580 assert_eq!(secondary.workspace_id, secondary_id);
25581 assert!(secondary.sessions.is_empty());
25582 assert_eq!(shared.snapshot().workspaces.len(), 2);
25583
25584 let duplicate_id = shared
25585 .register_workspace(
25586 secondary_id.clone(),
25587 primary.canonical_root().to_owned(),
25588 )
25589 .await
25590 .unwrap_err();
25591 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25592 let duplicate_root = shared
25593 .register_workspace(
25594 WorkspaceId::new("duplicate-root").unwrap(),
25595 windows_path_text(&secondary.canonical_root).to_owned(),
25596 )
25597 .await
25598 .unwrap_err();
25599 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25600
25601 let address = SessionAddress {
25602 workspace_id: secondary_id.clone(),
25603 session: SessionKey {
25604 instance_id: AgentInstanceId(41),
25605 generation: SessionGeneration(1),
25606 },
25607 };
25608 shared.bind_session(
25609 address.session.instance_id,
25610 secondary_id.clone(),
25611 address.session.generation,
25612 );
25613 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25614 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25615 shared.remove_binding(&address);
25616 shared.unregister_workspace(&secondary_id).unwrap();
25617 assert_eq!(shared.snapshot().workspaces.len(), 1);
25618
25619 let last = shared.unregister_workspace(&primary_id).unwrap_err();
25620 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25621 let history = shared
25622 .history
25623 .lock()
25624 .unwrap_or_else(|poisoned| poisoned.into_inner());
25625 assert!(matches!(
25626 &history.events[0],
25627 NodeEventEnvelope {
25628 sequence: 1,
25629 event: NodeEvent::WorkspaceAdded { workspace },
25630 } if workspace.workspace_id == secondary_id
25631 ));
25632 assert!(matches!(
25633 &history.events[1],
25634 NodeEventEnvelope {
25635 sequence: 2,
25636 event: NodeEvent::WorkspaceRemoved { workspace_id },
25637 } if workspace_id == &secondary_id
25638 ));
25639 }
25640
25641 #[tokio::test]
25642 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25643 let secondary_root = temporary_workspace_root("provider-session-reference-index");
25644 std::fs::create_dir_all(&secondary_root).unwrap();
25645 let catalog = active_registry().unwrap();
25646 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25647 let primary_id = WorkspaceId::new("primary").unwrap();
25648 let secondary_id = WorkspaceId::new("secondary").unwrap();
25649 let shared = NodeShared::new(
25650 handle,
25651 "fixture-token".to_owned(),
25652 NodeId::new("node-session-reference-index").unwrap(),
25653 vec![
25654 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25655 .unwrap(),
25656 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25657 ],
25658 vec![agent("claude"), agent("codex")],
25659 );
25660 let identity = ProviderSessionIdentity {
25661 key: gate4agent_types::ProviderSessionKey::SessionId,
25662 id: "provider-session-42".to_owned(),
25663 transcript_path: None,
25664 };
25665 let index_request = |workspace_id: WorkspaceId,
25666 provider: AgentId,
25667 identity: ProviderSessionIdentity,
25668 display_name: &str| {
25669 NodeRequest::IndexProviderSession {
25670 workspace_id,
25671 provider,
25672 identity,
25673 display_name: display_name.to_owned(),
25674 }
25675 };
25676 let request = index_request(
25677 primary_id.clone(),
25678 agent("claude"),
25679 identity.clone(),
25680 "release shepherd",
25681 );
25682 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25683 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25684 }));
25685 let observer = process_request_inner(
25686 &shared,
25687 77,
25688 ClientRole::Observer,
25689 request.clone(),
25690 )
25691 .await
25692 .unwrap_err();
25693 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25694 let uncontrolled = process_request_inner(
25695 &shared,
25696 77,
25697 ClientRole::Operator,
25698 request.clone(),
25699 )
25700 .await
25701 .unwrap_err();
25702 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25703 shared
25704 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25705 .unwrap();
25706
25707 let indexed = process_request_inner(
25708 &shared,
25709 77,
25710 ClientRole::Operator,
25711 request.clone(),
25712 )
25713 .await
25714 .unwrap();
25715 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25716 panic!("provider session reference index returned another response");
25717 };
25718 assert_eq!(record.state, ManagedSessionState::Dormant);
25719 assert_eq!(record.mode, SessionMode::Pty);
25720 assert_eq!(record.workspace_id, primary_id);
25721 assert_eq!(record.provider_session.as_ref(), Some(&identity));
25722 assert!(record.active_session.is_none());
25723 assert!(record.environment_profile.is_none());
25724 assert!(record.bundle.is_none());
25725 assert!(record.context.is_none());
25726
25727 let repeated = process_request_inner(
25728 &shared,
25729 77,
25730 ClientRole::Operator,
25731 index_request(
25732 primary_id.clone(),
25733 agent("claude"),
25734 identity.clone(),
25735 "ignored replacement",
25736 ),
25737 )
25738 .await
25739 .unwrap();
25740 assert!(matches!(
25741 repeated,
25742 NodeResponse::ProviderSessionIndexed { record: ref existing }
25743 if existing.record_id == record.record_id
25744 && existing.display_name == "release shepherd"
25745 ));
25746 let conflicting_workspace = process_request_inner(
25747 &shared,
25748 77,
25749 ClientRole::Operator,
25750 index_request(
25751 secondary_id,
25752 agent("claude"),
25753 identity.clone(),
25754 "release shepherd",
25755 ),
25756 )
25757 .await
25758 .unwrap_err();
25759 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25760 let conflicting_provider = process_request_inner(
25761 &shared,
25762 77,
25763 ClientRole::Operator,
25764 index_request(
25765 primary_id.clone(),
25766 agent("codex"),
25767 identity.clone(),
25768 "release shepherd",
25769 ),
25770 )
25771 .await
25772 .unwrap_err();
25773 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25774 let transcript_rejected = process_request_inner(
25775 &shared,
25776 77,
25777 ClientRole::Operator,
25778 index_request(
25779 primary_id,
25780 agent("claude"),
25781 ProviderSessionIdentity {
25782 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25783 ..identity
25784 },
25785 "release shepherd",
25786 ),
25787 )
25788 .await
25789 .unwrap_err();
25790 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25791 let upserts = shared
25792 .history
25793 .lock()
25794 .unwrap_or_else(|poisoned| poisoned.into_inner())
25795 .events
25796 .iter()
25797 .filter(|event| matches!(
25798 &event.event,
25799 NodeEvent::SessionRecordUpserted { record: published }
25800 if published.record_id == record.record_id
25801 ))
25802 .count();
25803 assert_eq!(upserts, 1);
25804 std::fs::remove_dir_all(secondary_root).unwrap();
25805 }
25806
25807 #[tokio::test]
25808 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25809 let fixture_root = temporary_workspace_root("standalone-independent");
25810 let target = fixture_root.join("independent");
25811 std::fs::create_dir_all(&fixture_root).unwrap();
25812 let catalog = active_registry().unwrap();
25813 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25814 let selected = WorkspaceConfig::new(
25815 WorkspaceId::new("primary").unwrap(),
25816 std::env::current_dir().unwrap(),
25817 )
25818 .unwrap();
25819 let selected_root = selected.canonical_root().to_owned();
25820 let shared = NodeShared::new(
25821 handle,
25822 "fixture-token".to_owned(),
25823 NodeId::new("node-standalone").unwrap(),
25824 vec![selected],
25825 vec![agent("claude")],
25826 );
25827 let workspace_id = WorkspaceId::new("independent").unwrap();
25828 let request = NodeRequest::CreateStandaloneWorkspace {
25829 workspace_id: workspace_id.clone(),
25830 root: opaque_windows_path(target.to_string_lossy().into_owned()),
25831 initial_branch: Some("main".to_owned()),
25832 };
25833 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25834 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25835 }));
25836 assert!(!request_uses_unnegotiated_capability(
25837 &request,
25838 &baseline_capabilities().unwrap(),
25839 ));
25840 assert!(request_uses_unnegotiated_capability(&request, &[]));
25841
25842 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25843 .await
25844 .unwrap_err();
25845 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25846 assert!(!target.exists());
25847 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25848 .await
25849 .unwrap_err();
25850 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25851 assert!(!target.exists());
25852 shared
25853 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25854 .unwrap();
25855
25856 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25857 .await
25858 .unwrap();
25859 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25860 panic!("standalone workspace creation returned another response");
25861 };
25862 assert_eq!(workspace.workspace_id, workspace_id);
25863 assert!(target.join(".git").is_dir());
25864 let branch = run_git_bounded(
25865 target.to_str().unwrap(),
25866 &["branch", "--show-current"],
25867 GIT_OUTPUT_MAX_BYTES,
25868 )
25869 .await
25870 .unwrap();
25871 assert!(branch.success);
25872 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25873 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25874 assert_eq!(worktrees.len(), 1);
25875 let listed_root = WorkspaceConfig::new(
25876 WorkspaceId::new("listed-independent").unwrap(),
25877 &worktrees[0].path,
25878 )
25879 .unwrap();
25880 assert!(platform::roots_equal(
25881 listed_root.canonical_root(),
25882 windows_path_text(&workspace.canonical_root),
25883 ));
25884 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25885 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25886 snapshot.workspace_id == workspace_id
25887 && snapshot.canonical_root == workspace.canonical_root
25888 }));
25889 assert!(shared
25890 .history
25891 .lock()
25892 .unwrap_or_else(|poisoned| poisoned.into_inner())
25893 .events
25894 .iter()
25895 .any(|event| matches!(
25896 &event.event,
25897 NodeEvent::WorkspaceAdded { workspace }
25898 if workspace.workspace_id == workspace_id
25899 )));
25900 std::fs::remove_dir_all(fixture_root).unwrap();
25901 }
25902
25903 #[tokio::test]
25904 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25905 let fixture_root = temporary_workspace_root("standalone-compensation");
25906 let created_target = fixture_root.join("created-target");
25907 let existing_target = fixture_root.join("existing-target");
25908 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25909 let invalid_state_path = invalid_state_parent.join("state.json");
25910 std::fs::create_dir_all(&existing_target).unwrap();
25911 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25912 let catalog = active_registry().unwrap();
25913 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25914 let primary = WorkspaceConfig::new(
25915 WorkspaceId::new("primary").unwrap(),
25916 std::env::current_dir().unwrap(),
25917 )
25918 .unwrap();
25919 let mut shared = NodeShared::new(
25920 handle,
25921 "fixture-token".to_owned(),
25922 NodeId::new("node-standalone-compensation").unwrap(),
25923 vec![primary],
25924 vec![agent("claude")],
25925 );
25926 shared.state_path = Some(invalid_state_path);
25927
25928 let created_recovery = shared
25929 .create_standalone_workspace(
25930 WorkspaceId::new("compensated").unwrap(),
25931 created_target.to_string_lossy().into_owned(),
25932 Some("main".to_owned()),
25933 )
25934 .await
25935 .unwrap_err();
25936 assert_eq!(
25937 created_recovery.code,
25938 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25939 );
25940 assert!(created_target.join(".git").is_dir());
25941 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25942 workspace.workspace_id.as_str() != "compensated"
25943 }));
25944
25945 let existing_recovery = shared
25946 .create_standalone_workspace(
25947 WorkspaceId::new("restored").unwrap(),
25948 existing_target.to_string_lossy().into_owned(),
25949 None,
25950 )
25951 .await
25952 .unwrap_err();
25953 assert_eq!(
25954 existing_recovery.code,
25955 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25956 );
25957 assert!(existing_target.is_dir());
25958 assert!(existing_target.join(".git").is_dir());
25959 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25960 workspace.workspace_id.as_str() != "restored"
25961 }));
25962
25963 let registered_empty = fixture_root.join("registered-empty");
25964 std::fs::create_dir(®istered_empty).unwrap();
25965 let catalog = active_registry().unwrap();
25966 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25967 let registered = WorkspaceConfig::new(
25968 WorkspaceId::new("registered-empty").unwrap(),
25969 ®istered_empty,
25970 )
25971 .unwrap();
25972 let duplicate_shared = NodeShared::new(
25973 handle,
25974 "fixture-token".to_owned(),
25975 NodeId::new("node-standalone-duplicate-root").unwrap(),
25976 vec![registered],
25977 vec![agent("claude")],
25978 );
25979 let duplicate = duplicate_shared
25980 .create_standalone_workspace(
25981 WorkspaceId::new("duplicate-root").unwrap(),
25982 registered_empty.to_string_lossy().into_owned(),
25983 None,
25984 )
25985 .await
25986 .unwrap_err();
25987 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25988 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
25989 std::fs::remove_dir_all(fixture_root).unwrap();
25990 }
25991
25992 #[test]
25993 fn managed_record_id_collision_preserves_the_existing_record() {
25994 let catalog = active_registry().unwrap();
25995 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25996 let workspace = WorkspaceConfig::new(
25997 WorkspaceId::new("primary").unwrap(),
25998 std::env::current_dir().unwrap(),
25999 )
26000 .unwrap();
26001 let shared = NodeShared::new(
26002 handle,
26003 "fixture-token".to_owned(),
26004 NodeId::new("node-1").unwrap(),
26005 vec![workspace.clone()],
26006 vec![agent("claude")],
26007 );
26008 let record_id = SessionRecordId::new("sr-collision").unwrap();
26009 let original = ManagedSessionRecord {
26010 record_id: record_id.clone(),
26011 display_name: "original".to_owned(),
26012 provider: agent("claude"),
26013 mode: SessionMode::Pty,
26014 state: ManagedSessionState::Dormant,
26015 workspace_id: workspace.workspace_id,
26016 canonical_root: opaque_windows_path(workspace.canonical_root),
26017 provider_session: None,
26018 active_session: None,
26019 environment_profile: None,
26020 bundle: None,
26021 context_id: None,
26022 context: None,
26023 exported_context: None,
26024 task_binding: None,
26025 created_at_unix_ms: 1,
26026 updated_at_unix_ms: 1,
26027 last_error: None,
26028 };
26029 shared.insert_record(original.clone()).unwrap();
26030 let mut collision = original.clone();
26031 collision.display_name = "replacement".to_owned();
26032 let error = shared.insert_record(collision).unwrap_err();
26033 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26034 assert_eq!(shared.record(&record_id).unwrap(), original);
26035 }
26036
26037 #[test]
26038 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26039 let catalog = active_registry().unwrap();
26040 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26041 let workspace = WorkspaceConfig::new(
26042 WorkspaceId::new("primary").unwrap(),
26043 std::env::current_dir().unwrap(),
26044 )
26045 .unwrap();
26046 let shared = NodeShared::new(
26047 handle,
26048 "fixture-token".to_owned(),
26049 NodeId::new("node-1").unwrap(),
26050 vec![workspace.clone()],
26051 vec![agent("claude")],
26052 );
26053 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26054 shared
26055 .insert_record(ManagedSessionRecord {
26056 record_id: record_id.clone(),
26057 display_name: "runtime error".to_owned(),
26058 provider: agent("claude"),
26059 mode: SessionMode::Pty,
26060 state: ManagedSessionState::IdentityPending,
26061 workspace_id: workspace.workspace_id,
26062 canonical_root: opaque_windows_path(workspace.canonical_root),
26063 provider_session: None,
26064 active_session: None,
26065 environment_profile: None,
26066 bundle: None,
26067 context_id: None,
26068 context: None,
26069 exported_context: None,
26070 task_binding: None,
26071 created_at_unix_ms: 1,
26072 updated_at_unix_ms: 1,
26073 last_error: None,
26074 })
26075 .unwrap();
26076
26077 shared
26078 .mark_record_error(
26079 &record_id,
26080 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26081 )
26082 .unwrap();
26083 let record = shared.record(&record_id).unwrap();
26084 assert_eq!(record.state, ManagedSessionState::Unavailable);
26085 assert_eq!(
26086 record.last_error.as_deref(),
26087 Some("provider-runtime-failed"),
26088 );
26089 let snapshot = shared.snapshot();
26090 assert_eq!(snapshot.session_records, vec![record]);
26091 }
26092
26093 #[test]
26102 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26103 let catalog = active_registry().unwrap();
26104 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26105 let workspace = WorkspaceConfig::new(
26106 WorkspaceId::new("primary").unwrap(),
26107 std::env::current_dir().unwrap(),
26108 )
26109 .unwrap();
26110 let shared = NodeShared::new(
26111 handle,
26112 "fixture-token".to_owned(),
26113 NodeId::new("node-1").unwrap(),
26114 vec![workspace],
26115 vec![agent("claude")],
26116 );
26117
26118 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26119
26120 shared.record_rejected_command(
26121 CommandId(1),
26122 "agent 'claude' does not support transport Acp".to_owned(),
26123 true,
26124 );
26125 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26127
26128 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26129 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26130 assert!(reason.unsupported_transport);
26131
26132 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26134
26135 shared.record_rejected_command(
26140 CommandId(3),
26141 "backend busy".to_owned(),
26142 false,
26143 );
26144 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26145 assert!(!unclassified.unsupported_transport);
26146 }
26147
26148 #[test]
26149 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26150 let catalog = active_registry().unwrap();
26151 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26152 let workspace = WorkspaceConfig::new(
26153 WorkspaceId::new("primary").unwrap(),
26154 std::env::current_dir().unwrap(),
26155 )
26156 .unwrap();
26157 let shared = NodeShared::new(
26158 handle,
26159 "fixture-token".to_owned(),
26160 NodeId::new("node-identity-policy").unwrap(),
26161 vec![workspace.clone()],
26162 vec![agent("claude")],
26163 );
26164 let address = SessionAddress {
26165 workspace_id: workspace.workspace_id.clone(),
26166 session: SessionKey {
26167 instance_id: AgentInstanceId(90),
26168 generation: SessionGeneration(1),
26169 },
26170 };
26171 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26172 shared
26173 .insert_record(ManagedSessionRecord {
26174 record_id: record_id.clone(),
26175 display_name: "policy denied".to_owned(),
26176 provider: agent("claude"),
26177 mode: SessionMode::Pty,
26178 state: ManagedSessionState::IdentityPending,
26179 workspace_id: workspace.workspace_id,
26180 canonical_root: opaque_windows_path(workspace.canonical_root),
26181 provider_session: None,
26182 active_session: Some(address.clone()),
26183 environment_profile: None,
26184 bundle: None,
26185 context_id: None,
26186 context: None,
26187 exported_context: None,
26188 task_binding: None,
26189 created_at_unix_ms: 1,
26190 updated_at_unix_ms: 1,
26191 last_error: None,
26192 })
26193 .unwrap();
26194 let event = ControlEvent {
26195 sequence: 1,
26196 command_id: None,
26197 instance_id: address.session.instance_id,
26198 generation: address.session.generation,
26199 event: ControlEventKind::ProviderEvent {
26200 sequence: 1,
26201 source: gate4agent_types::ProviderSource {
26202 family: AdapterFamily::PtySemantic,
26203 binding: AdapterBinding::new(
26204 gate4agent_types::AdapterId::new("claude").unwrap(),
26205 "fixture/v1",
26206 gate4agent_types::AdapterVerification::SyntheticFixture,
26207 )
26208 .unwrap(),
26209 },
26210 source_sequence: 1,
26211 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26212 identity: gate4agent_types::ProviderSessionIdentity {
26213 key: gate4agent_types::ProviderSessionKey::SessionId,
26214 id: "provider-session".to_owned(),
26215 transcript_path: None,
26216 },
26217 },
26218 },
26219 };
26220
26221 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26222
26223 let record = shared.record(&record_id).unwrap();
26224 assert!(record.provider_session.is_none());
26225 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26226 assert_eq!(record.active_session.as_ref(), Some(&address));
26227 }
26228
26229 #[test]
26230 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26231 let secondary_root = temporary_workspace_root("identity-scope");
26232 std::fs::create_dir_all(&secondary_root).unwrap();
26233 let catalog = active_registry().unwrap();
26234 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26235 let primary = WorkspaceConfig::new(
26236 WorkspaceId::new("primary").unwrap(),
26237 std::env::current_dir().unwrap(),
26238 )
26239 .unwrap();
26240 let secondary = WorkspaceConfig::new(
26241 WorkspaceId::new("secondary").unwrap(),
26242 &secondary_root,
26243 )
26244 .unwrap();
26245 let shared = NodeShared::new(
26246 handle,
26247 "fixture-token".to_owned(),
26248 NodeId::new("node-1").unwrap(),
26249 vec![primary.clone(), secondary.clone()],
26250 vec![agent("claude")],
26251 );
26252 let identity = gate4agent_types::ProviderSessionIdentity {
26253 key: gate4agent_types::ProviderSessionKey::SessionId,
26254 id: "provider-session".to_owned(),
26255 transcript_path: None,
26256 };
26257 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26258 shared
26259 .insert_record(ManagedSessionRecord {
26260 record_id: dormant_id.clone(),
26261 display_name: "dormant".to_owned(),
26262 provider: agent("claude"),
26263 mode: SessionMode::Pty,
26264 state: ManagedSessionState::Dormant,
26265 workspace_id: primary.workspace_id,
26266 canonical_root: opaque_windows_path(primary.canonical_root),
26267 provider_session: Some(identity.clone()),
26268 active_session: None,
26269 environment_profile: None,
26270 bundle: None,
26271 context_id: None,
26272 context: None,
26273 exported_context: None,
26274 task_binding: None,
26275 created_at_unix_ms: 1,
26276 updated_at_unix_ms: 1,
26277 last_error: None,
26278 })
26279 .unwrap();
26280 let address = SessionAddress {
26281 workspace_id: secondary.workspace_id.clone(),
26282 session: SessionKey {
26283 instance_id: AgentInstanceId(91),
26284 generation: SessionGeneration(1),
26285 },
26286 };
26287 let current_id = SessionRecordId::new("sr-current").unwrap();
26288 shared
26289 .insert_record(ManagedSessionRecord {
26290 record_id: current_id.clone(),
26291 display_name: "current".to_owned(),
26292 provider: agent("claude"),
26293 mode: SessionMode::Pty,
26294 state: ManagedSessionState::IdentityPending,
26295 workspace_id: secondary.workspace_id,
26296 canonical_root: opaque_windows_path(secondary.canonical_root),
26297 provider_session: None,
26298 active_session: Some(address.clone()),
26299 environment_profile: None,
26300 bundle: None,
26301 context_id: None,
26302 context: None,
26303 exported_context: None,
26304 task_binding: None,
26305 created_at_unix_ms: 2,
26306 updated_at_unix_ms: 2,
26307 last_error: None,
26308 })
26309 .unwrap();
26310 shared.bind_managed_session(
26311 &address,
26312 current_id.clone(),
26313 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26314 None,
26315 );
26316 let event = ControlEvent {
26317 sequence: 1,
26318 command_id: None,
26319 instance_id: address.session.instance_id,
26320 generation: address.session.generation,
26321 event: ControlEventKind::ProviderEvent {
26322 sequence: 1,
26323 source: gate4agent_types::ProviderSource {
26324 family: gate4agent_types::AdapterFamily::PtySemantic,
26325 binding: gate4agent_types::AdapterBinding::new(
26326 gate4agent_types::AdapterId::new("claude").unwrap(),
26327 "fixture/v1",
26328 gate4agent_types::AdapterVerification::SyntheticFixture,
26329 )
26330 .unwrap(),
26331 },
26332 source_sequence: 1,
26333 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26334 identity,
26335 },
26336 },
26337 };
26338 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26339
26340 let binding = shared
26341 .session_bindings
26342 .lock()
26343 .unwrap_or_else(|poisoned| poisoned.into_inner())
26344 .get(&address.session.instance_id)
26345 .cloned()
26346 .unwrap();
26347 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26348 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26349 let current = shared.record(¤t_id).unwrap();
26350 assert_eq!(current.state, ManagedSessionState::Unavailable);
26351 assert_eq!(current.active_session.as_ref(), Some(&address));
26352 assert_eq!(
26353 current.last_error.as_deref(),
26354 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26355 );
26356 std::fs::remove_dir_all(secondary_root).unwrap();
26357 }
26358
26359 #[tokio::test]
26360 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26361 let root = temporary_workspace_root("semantic-provider-session");
26362 std::fs::create_dir_all(&root).unwrap();
26363 let state_path = root.join("state-v1.json");
26364 let catalog = active_registry().unwrap();
26365 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26366 let workspace = WorkspaceConfig::new(
26367 WorkspaceId::new("primary").unwrap(),
26368 &root,
26369 )
26370 .unwrap();
26371 let node_id = NodeId::new("node-semantic-identity").unwrap();
26372 let shared = NodeShared::new_with_incarnation(
26373 handle,
26374 "fixture-token".to_owned(),
26375 node_id.clone(),
26376 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26377 vec![workspace.clone()],
26378 vec![agent("claude")],
26379 ProviderRuntimeStatuses::default(),
26380 None,
26381 Vec::new(),
26382 Vec::new(),
26383 SpawnProfileRegistry::default(),
26384 None,
26385 None,
26386 None,
26387 Some(state_path.clone()),
26388 Vec::new(),
26389 Vec::new(),
26390 Vec::new(),
26391 Vec::new(),
26392 None,
26393 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26394 );
26395 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26396 key: gate4agent_types::ProviderSessionKey::SessionId,
26397 id: "provider-session".to_owned(),
26398 transcript_path: None,
26399 };
26400 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26401 shared
26402 .insert_record(ManagedSessionRecord {
26403 record_id: durable_id.clone(),
26404 display_name: "durable".to_owned(),
26405 provider: agent("claude"),
26406 mode: SessionMode::Pty,
26407 state: ManagedSessionState::Dormant,
26408 workspace_id: workspace.workspace_id.clone(),
26409 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26410 provider_session: Some(persisted_identity.clone()),
26411 active_session: None,
26412 environment_profile: None,
26413 bundle: None,
26414 context_id: None,
26415 context: None,
26416 exported_context: None,
26417 task_binding: None,
26418 created_at_unix_ms: 1,
26419 updated_at_unix_ms: 1,
26420 last_error: None,
26421 })
26422 .unwrap();
26423 let address = SessionAddress {
26424 workspace_id: workspace.workspace_id.clone(),
26425 session: SessionKey {
26426 instance_id: AgentInstanceId(92),
26427 generation: SessionGeneration(2),
26428 },
26429 };
26430 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26431 shared
26432 .insert_record(ManagedSessionRecord {
26433 record_id: pending_id.clone(),
26434 display_name: "pending".to_owned(),
26435 provider: agent("claude"),
26436 mode: SessionMode::Pty,
26437 state: ManagedSessionState::IdentityPending,
26438 workspace_id: workspace.workspace_id.clone(),
26439 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26440 provider_session: None,
26441 active_session: Some(address.clone()),
26442 environment_profile: None,
26443 bundle: None,
26444 context_id: None,
26445 context: None,
26446 exported_context: None,
26447 task_binding: None,
26448 created_at_unix_ms: 2,
26449 updated_at_unix_ms: 2,
26450 last_error: None,
26451 })
26452 .unwrap();
26453 shared.bind_managed_session(
26454 &address,
26455 pending_id.clone(),
26456 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26457 None,
26458 );
26459 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26460 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26461 ..persisted_identity
26462 };
26463 let event = ControlEvent {
26464 sequence: 1,
26465 command_id: None,
26466 instance_id: address.session.instance_id,
26467 generation: address.session.generation,
26468 event: ControlEventKind::ProviderEvent {
26469 sequence: 1,
26470 source: gate4agent_types::ProviderSource {
26471 family: gate4agent_types::AdapterFamily::PtySemantic,
26472 binding: gate4agent_types::AdapterBinding::new(
26473 gate4agent_types::AdapterId::new("claude").unwrap(),
26474 "fixture/v1",
26475 gate4agent_types::AdapterVerification::SyntheticFixture,
26476 )
26477 .unwrap(),
26478 },
26479 source_sequence: 1,
26480 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26481 identity: observed_identity.clone(),
26482 },
26483 },
26484 };
26485 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26486
26487 let snapshot = shared.snapshot();
26488 assert_eq!(snapshot.session_records.len(), 1);
26489 let live = &snapshot.session_records[0];
26490 assert_eq!(live.record_id, durable_id);
26491 assert_eq!(live.state, ManagedSessionState::Live);
26492 assert_eq!(live.active_session.as_ref(), Some(&address));
26493 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26494 let binding = shared
26495 .session_bindings
26496 .lock()
26497 .unwrap_or_else(|poisoned| poisoned.into_inner())
26498 .get(&address.session.instance_id)
26499 .cloned()
26500 .unwrap();
26501 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26502
26503 let mut duplicate = live.clone();
26504 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26505 duplicate.display_name = "duplicate".to_owned();
26506 duplicate.state = ManagedSessionState::Dormant;
26507 duplicate.active_session = None;
26508 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26509 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26510 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26511 assert_eq!(shared.snapshot().session_records.len(), 1);
26512
26513 let terminal_size = gate4agent_types::TerminalSize {
26514 rows: 24,
26515 columns: 80,
26516 };
26517 let (first_busy, second_busy) = tokio::join!(
26518 shared.resume_session_record(&durable_id, terminal_size, None),
26519 shared.resume_session_record(&durable_id, terminal_size, None),
26520 );
26521 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26522 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26523 assert_eq!(busy.message, "session-record-busy");
26524 }
26525
26526 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26527 assert_eq!(loaded.records.len(), 1);
26528 assert_eq!(loaded.records[0].record_id, durable_id);
26529 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26530 assert_eq!(
26531 loaded.records[0]
26532 .provider_session
26533 .as_ref()
26534 .unwrap()
26535 .transcript_path,
26536 None,
26537 );
26538 let catalog = active_registry().unwrap();
26539 let (reloaded_handle, _reloaded_runtime) =
26540 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26541 let reloaded = NodeShared::new_with_incarnation(
26542 reloaded_handle,
26543 "fixture-token".to_owned(),
26544 node_id,
26545 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26546 vec![workspace],
26547 vec![agent("claude")],
26548 ProviderRuntimeStatuses::default(),
26549 None,
26550 Vec::new(),
26551 Vec::new(),
26552 SpawnProfileRegistry::default(),
26553 None,
26554 None,
26555 None,
26556 None,
26557 loaded.records,
26558 loaded.managed_worktrees,
26559 loaded.managed_worktree_tombstones,
26560 loaded.materializations,
26561 loaded.warning,
26562 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26563 );
26564 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26565 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26566 std::fs::remove_dir_all(root).unwrap();
26567 }
26568
26569 #[tokio::test]
26570 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26571 let secondary_root = temporary_workspace_root("workspace-restore");
26572 let other_root = temporary_workspace_root("workspace-conflict");
26573 std::fs::create_dir_all(&secondary_root).unwrap();
26574 std::fs::create_dir_all(&other_root).unwrap();
26575 let catalog = active_registry().unwrap();
26576 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26577 let primary = WorkspaceConfig::new(
26578 WorkspaceId::new("primary").unwrap(),
26579 std::env::current_dir().unwrap(),
26580 )
26581 .unwrap();
26582 let secondary = WorkspaceConfig::new(
26583 WorkspaceId::new("secondary").unwrap(),
26584 &secondary_root,
26585 )
26586 .unwrap();
26587 let shared = NodeShared::new(
26588 handle,
26589 "fixture-token".to_owned(),
26590 NodeId::new("node-1").unwrap(),
26591 vec![primary, secondary.clone()],
26592 vec![agent("claude")],
26593 );
26594 let record_id = SessionRecordId::new("sr-workspace").unwrap();
26595 shared
26596 .insert_record(ManagedSessionRecord {
26597 record_id: record_id.clone(),
26598 display_name: "workspace record".to_owned(),
26599 provider: agent("claude"),
26600 mode: SessionMode::Pty,
26601 state: ManagedSessionState::Dormant,
26602 workspace_id: secondary.workspace_id.clone(),
26603 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26604 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26605 key: gate4agent_types::ProviderSessionKey::SessionId,
26606 id: "provider-session".to_owned(),
26607 transcript_path: None,
26608 }),
26609 active_session: None,
26610 environment_profile: None,
26611 bundle: None,
26612 context_id: None,
26613 context: None,
26614 exported_context: None,
26615 task_binding: None,
26616 created_at_unix_ms: 1,
26617 updated_at_unix_ms: 1,
26618 last_error: None,
26619 })
26620 .unwrap();
26621 shared.unregister_workspace(&secondary.workspace_id).unwrap();
26622 let unavailable = shared.record(&record_id).unwrap();
26623 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26624 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26625
26626 let conflict = shared
26627 .register_workspace(
26628 secondary.workspace_id.clone(),
26629 other_root.to_string_lossy().into_owned(),
26630 )
26631 .await
26632 .unwrap_err();
26633 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26634 shared
26635 .register_workspace(
26636 secondary.workspace_id,
26637 secondary_root.to_string_lossy().into_owned(),
26638 )
26639 .await
26640 .unwrap();
26641 let restored = shared.record(&record_id).unwrap();
26642 assert_eq!(restored.state, ManagedSessionState::Dormant);
26643 assert!(restored.last_error.is_none());
26644 std::fs::remove_dir_all(secondary_root).unwrap();
26645 std::fs::remove_dir_all(other_root).unwrap();
26646 }
26647
26648 #[test]
26649 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26650 let error = validate_workspace_request_root(
26651 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26652 )
26653 .unwrap_err();
26654 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26655 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26656 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26657 }
26658
26659 #[test]
26660 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26661 let catalog = active_registry().unwrap();
26662 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26663 let workspace = WorkspaceConfig::new(
26664 WorkspaceId::new("primary").unwrap(),
26665 std::env::current_dir().unwrap(),
26666 )
26667 .unwrap();
26668 let shared = NodeShared::new(
26669 handle,
26670 "fixture-token".to_owned(),
26671 NodeId::new("node-1").unwrap(),
26672 vec![workspace],
26673 vec![agent("claude")],
26674 );
26675 let publishers = 4_u64;
26676 let events_per_publisher = 256_u64;
26677 let start = std::sync::Barrier::new(publishers as usize + 1);
26678 let finished = AtomicU64::new(0);
26679
26680 std::thread::scope(|scope| {
26681 for _ in 0..publishers {
26682 scope.spawn(|| {
26683 start.wait();
26684 for _ in 0..events_per_publisher {
26685 shared.publish(NodeEvent::ControllerChanged { controller: None });
26686 }
26687 finished.fetch_add(1, Ordering::Release);
26688 });
26689 }
26690 start.wait();
26691 while finished.load(Ordering::Acquire) != publishers {
26692 assert_resync_history_is_linearized(shared.resync(0));
26693 std::thread::yield_now();
26694 }
26695 });
26696 let response = shared.resync(0);
26697 assert_resync_history_is_linearized(response.clone());
26698 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26699 unreachable!("resync helper returned another response");
26700 };
26701 assert_eq!(event_sequence, publishers * events_per_publisher);
26702 assert_eq!(events.len() as u64, event_sequence);
26703 }
26704
26705 fn assert_resync_history_is_linearized(response: NodeResponse) {
26706 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26707 unreachable!("resync helper returned another response");
26708 };
26709 assert!(events.windows(2).all(|pair| {
26710 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26711 }));
26712 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26713 }
26714
26715 #[test]
26716 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26717 assert_eq!(
26718 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26719 r"C:\repo\worktree",
26720 );
26721 assert_eq!(
26722 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26723 r"\\server\share\repo",
26724 );
26725 assert!(validate_workspace_root(
26726 &WorkspaceId::new("network").unwrap(),
26727 r"\\server\share\repo",
26728 )
26729 .is_err());
26730 let workspace = WorkspaceConfig::new(
26731 WorkspaceId::new("primary").unwrap(),
26732 std::env::current_dir().unwrap(),
26733 )
26734 .unwrap();
26735 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26736 }
26737
26738 #[test]
26739 fn resume_rebinds_only_on_the_expected_authorized_generation() {
26740 let catalog = active_registry().unwrap();
26741 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26742 let workspace_id = WorkspaceId::new("primary").unwrap();
26743 let workspace = WorkspaceConfig::new(
26744 workspace_id.clone(),
26745 std::env::current_dir().unwrap(),
26746 )
26747 .unwrap();
26748 let shared = NodeShared::new(
26749 handle,
26750 "fixture-token".to_owned(),
26751 NodeId::new("node-1").unwrap(),
26752 vec![workspace],
26753 vec![agent("claude")],
26754 );
26755 let instance_id = AgentInstanceId(17);
26756 let original = SessionAddress {
26757 workspace_id,
26758 session: SessionKey {
26759 instance_id,
26760 generation: SessionGeneration(3),
26761 },
26762 };
26763 shared.bind_session(
26764 instance_id,
26765 original.workspace_id.clone(),
26766 original.session.generation,
26767 );
26768 shared
26769 .arm_resume(
26770 &original,
26771 CommandId(41),
26772 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26773 )
26774 .unwrap();
26775
26776 shared.publish_control(ControlEvent {
26777 sequence: 1,
26778 command_id: None,
26779 instance_id,
26780 generation: SessionGeneration(4),
26781 event: ControlEventKind::Running { process_id: None },
26782 });
26783 assert_eq!(
26784 shared.address_for(instance_id, SessionGeneration(3)),
26785 Some(original.clone()),
26786 );
26787 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26788
26789 shared.publish_control(ControlEvent {
26790 sequence: 2,
26791 command_id: Some(CommandId(42)),
26792 instance_id,
26793 generation: SessionGeneration(3),
26794 event: ControlEventKind::CommandRejected {
26795 message: "unrelated command".to_owned(),
26796 },
26797 });
26798
26799 shared.publish_control(ControlEvent {
26800 sequence: 3,
26801 command_id: None,
26802 instance_id,
26803 generation: SessionGeneration(4),
26804 event: ControlEventKind::ResumeAuthorized {
26805 session: gate4agent_types::ResumeSessionSummary {
26806 key: gate4agent_types::ProviderSessionKey::SessionId,
26807 id: "fixture-session".to_owned(),
26808 },
26809 },
26810 });
26811 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26812 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26813 assert_eq!(rebound.workspace_id, original.workspace_id);
26814 let history = shared
26815 .history
26816 .lock()
26817 .unwrap_or_else(|poisoned| poisoned.into_inner());
26818 assert!(history.events.iter().any(|envelope| matches!(
26819 &envelope.event,
26820 NodeEvent::Control { address, event }
26821 if address == &rebound
26822 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26823 )));
26824 }
26825
26826 #[test]
26827 fn all_node_pty_prompts_use_bracketed_paste_framing() {
26828 for agent in ["claude", "codex", "kimi"] {
26829 assert_eq!(
26830 prompt_framing(&AgentId::new(agent).unwrap()),
26831 PromptFraming::BracketedPaste
26832 );
26833 }
26834 }
26835
26836 fn generous_walk_deadline() -> Instant {
26837 Instant::now() + Duration::from_secs(30)
26838 }
26839
26840 #[test]
26841 fn workspace_tree_is_relative_and_skips_heavy_directories() {
26842 let root = temporary_workspace_root("tree");
26843 std::fs::create_dir_all(root.join("src")).unwrap();
26844 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26845 std::fs::create_dir_all(root.join("target/debug")).unwrap();
26846 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26847 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26848 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26849 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26855 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26856 std::fs::create_dir_all(root.join("target-notes")).unwrap();
26860 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26861
26862 let (entries, truncated, _budget) =
26863 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26864 assert!(!truncated);
26865 assert!(entries.iter().any(|entry| {
26866 entry.relative_path.as_utf8() == Some("src")
26867 && entry.kind == WorkspaceEntryKind::Directory
26868 }));
26869 assert!(entries.iter().any(|entry| {
26870 entry.relative_path.as_utf8() == Some("src/lib.rs")
26871 && entry.kind == WorkspaceEntryKind::File
26872 }));
26873 assert!(entries.iter().any(|entry| {
26874 entry.relative_path.as_utf8() == Some("target-notes")
26875 && entry.kind == WorkspaceEntryKind::Directory
26876 }));
26877 assert!(entries.iter().any(|entry| {
26878 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26879 && entry.kind == WorkspaceEntryKind::File
26880 }));
26881 assert!(entries.iter().all(|entry| {
26882 let path = entry.relative_path.as_utf8().unwrap();
26883 !path.starts_with(".git")
26884 && !(path == "target" || path.starts_with("target/"))
26885 && !(path == "target-a2" || path.starts_with("target-a2/"))
26886 && !path.starts_with("node_modules")
26887 }));
26888
26889 std::fs::remove_dir_all(&root).unwrap();
26890 }
26891
26892 #[test]
26893 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26894 let root = temporary_workspace_root("tree-entry-cap");
26895 std::fs::create_dir_all(&root).unwrap();
26896 for index in 0..20 {
26897 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26898 }
26899
26900 let (entries, truncated, budget) =
26901 collect_workspace_entries(&root, generous_walk_deadline(), 5);
26902 assert!(truncated);
26903 assert!(budget.entry_cap_exceeded);
26904 assert!(!budget.time_budget_exceeded);
26905 assert!(entries.len() <= 5);
26906
26907 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26908 .expect("an entry-cap-truncated walk must report a truncation marker");
26909 assert!(inspection_truncation.walk_entry_cap_exceeded);
26910 assert!(!inspection_truncation.walk_time_budget_exceeded);
26911 assert!(!inspection_truncation.git_time_budget_exceeded);
26912 assert!(inspection_truncation.entries_visited >= 5);
26913
26914 std::fs::remove_dir_all(&root).unwrap();
26915 }
26916
26917 #[test]
26918 fn workspace_walk_within_budget_reports_no_truncation() {
26919 let root = temporary_workspace_root("tree-no-truncation");
26920 std::fs::create_dir_all(&root).unwrap();
26921 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26922
26923 let (_entries, truncated, budget) =
26924 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26925 assert!(!truncated);
26926 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26927
26928 std::fs::remove_dir_all(&root).unwrap();
26929 }
26930
26931 #[test]
26932 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26933 let root = temporary_workspace_root("tree-prefix-siblings");
26934 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26935 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26936 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26937 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26938
26939 let (entries, truncated, _budget) =
26940 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26941 assert!(!truncated);
26942 let paths = entries
26943 .iter()
26944 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26945 .collect::<Vec<_>>();
26946 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26947 assert_eq!(
26948 paths,
26949 vec![
26950 "gate4agent-c2".to_owned(),
26951 "gate4agent-c2-client".to_owned(),
26952 "gate4agent-c2-client/lib.rs".to_owned(),
26953 "gate4agent-c2/src".to_owned(),
26954 "gate4agent-c2/src/lib.rs".to_owned(),
26955 ],
26956 );
26957
26958 std::fs::remove_dir_all(&root).unwrap();
26959 }
26960
26961 #[test]
26962 fn git_parsers_cap_status_and_commit_results() {
26963 let mut status_output = Vec::new();
26964 for index in 0..=GIT_STATUS_MAX_ENTRIES {
26965 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26966 }
26967 let mut snapshot = git_snapshot_for_parser();
26968 parse_git_status(&status_output, &mut snapshot);
26969 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26970 assert!(snapshot.truncated);
26971 assert_eq!(snapshot.status[0].index_status, " ");
26972 assert_eq!(snapshot.status[0].worktree_status, "M");
26973
26974 snapshot.truncated = false;
26975 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26976 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26977 .collect::<String>();
26978 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26979 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26980 assert!(snapshot.truncated);
26981 }
26982
26983 #[test]
26984 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26985 let mut snapshot = git_snapshot_for_parser();
26986 parse_git_status(
26987 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26988 &mut snapshot,
26989 );
26990
26991 assert!(!snapshot.truncated);
26992 assert_eq!(snapshot.status.len(), 3);
26993 assert_eq!(snapshot.status[0].index_status, "R");
26994 assert_eq!(snapshot.status[0].worktree_status, " ");
26995 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26996 assert_eq!(
26997 snapshot.status[0]
26998 .previous_path
26999 .as_ref()
27000 .and_then(RepositoryPath::as_utf8),
27001 Some("src/previous name.rs"),
27002 );
27003 assert_eq!(snapshot.status[1].index_status, " ");
27004 assert_eq!(snapshot.status[1].worktree_status, "C");
27005 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27006 assert_eq!(
27007 snapshot.status[1]
27008 .previous_path
27009 .as_ref()
27010 .and_then(RepositoryPath::as_utf8),
27011 Some("src/original.rs"),
27012 );
27013 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27014 assert!(snapshot.status[2].previous_path.is_none());
27015 }
27016
27017 #[test]
27018 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27019 let mut snapshot = git_snapshot_for_parser();
27020 parse_git_status(
27021 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27022 &mut snapshot,
27023 );
27024
27025 assert!(snapshot.truncated);
27026 assert_eq!(snapshot.status.len(), 1);
27027 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27028 assert!(snapshot.status[0].previous_path.is_none());
27029 }
27030
27031 #[tokio::test]
27032 async fn observer_can_browse_host_directories_without_controller() {
27033 let root = temporary_workspace_root("host-directory-observer");
27034 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27035 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27036 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27037 let workspace_id = WorkspaceId::new("primary").unwrap();
27038 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27039 let catalog = active_registry().unwrap();
27040 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27041 let shared = NodeShared::new(
27042 handle,
27043 "fixture-token".to_owned(),
27044 NodeId::new("node-1").unwrap(),
27045 vec![workspace],
27046 vec![agent("claude")],
27047 );
27048 let request = NodeRequest::BrowseHostDirectories {
27049 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27050 after: None,
27051 };
27052 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27053 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27054 }));
27055 assert!(!request_uses_unnegotiated_capability(
27056 &request,
27057 &baseline_capabilities().unwrap(),
27058 ));
27059 assert!(request_uses_unnegotiated_capability(&request, &[]));
27060
27061 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27062 .await
27063 .unwrap();
27064 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27065 panic!("host directory browse returned another response");
27066 };
27067 assert_eq!(
27068 listing
27069 .entries
27070 .iter()
27071 .map(|entry| entry.display_name.as_str())
27072 .collect::<Vec<_>>(),
27073 vec!["directory-a", "directory-b"],
27074 );
27075 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27076 assert!(!listing.incomplete);
27077 std::fs::remove_dir_all(root).unwrap();
27078 }
27079
27080 #[tokio::test]
27081 async fn observer_can_inspect_only_a_registered_workspace() {
27082 let root = temporary_workspace_root("observer");
27083 std::fs::create_dir_all(root.join("src")).unwrap();
27084 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27085 let workspace_id = WorkspaceId::new("primary").unwrap();
27086 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27087 let catalog = active_registry().unwrap();
27088 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27089 let shared = NodeShared::new(
27090 handle,
27091 "fixture-token".to_owned(),
27092 NodeId::new("node-1").unwrap(),
27093 vec![workspace],
27094 vec![agent("claude")],
27095 );
27096
27097 let response = process_request_inner(
27098 &shared,
27099 77,
27100 ClientRole::Observer,
27101 NodeRequest::InspectWorkspace {
27102 workspace_id: workspace_id.clone(),
27103 },
27104 )
27105 .await
27106 .unwrap();
27107 let NodeResponse::WorkspaceInspected { inspection } = response else {
27108 panic!("workspace inspection returned another response");
27109 };
27110 assert_eq!(inspection.workspace_id, workspace_id);
27111 assert!(inspection.entries.iter().any(|entry| {
27112 entry.relative_path.as_utf8() == Some("src/main.rs")
27113 }));
27114
27115 let unknown = process_request_inner(
27116 &shared,
27117 77,
27118 ClientRole::Observer,
27119 NodeRequest::InspectWorkspace {
27120 workspace_id: WorkspaceId::new("unknown").unwrap(),
27121 },
27122 )
27123 .await
27124 .unwrap_err();
27125 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27126 std::fs::remove_dir_all(&root).unwrap();
27127 }
27128
27129 #[test]
27130 fn context_pack_source_accepts_only_running_or_successful_exit() {
27131 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27132 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27133 exit_code: Some(0),
27134 }));
27135 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27136 exit_code: Some(1),
27137 }));
27138 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27139 exit_code: None,
27140 }));
27141 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27142 message: "provider failed".to_owned(),
27143 }));
27144 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27145 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27146 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27147 }
27148
27149 #[test]
27150 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27151 let git = git_snapshot_for_parser();
27152 let first = ContextPackRepositoryObservation {
27153 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27154 git: git.clone(),
27155 files: Vec::new(),
27156 };
27157 let second = ContextPackRepositoryObservation {
27158 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27159 git,
27160 files: Vec::new(),
27161 };
27162
27163 let error = stable_context_pack_repository(first, second).unwrap_err();
27164
27165 assert_eq!(
27166 error.code,
27167 NodeFailureCode::ContextPackMaterializationFailed,
27168 );
27169 }
27170
27171 #[test]
27172 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27173 let git = git_snapshot_for_parser();
27174 let first = ContextPackRepositoryObservation {
27175 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27176 git: git.clone(),
27177 files: vec![ContextPackRepositoryFileSource::utf8(
27178 "README.md",
27179 "first\n".to_owned(),
27180 6,
27181 )],
27182 };
27183 let second = ContextPackRepositoryObservation {
27184 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27185 git,
27186 files: vec![ContextPackRepositoryFileSource::utf8(
27187 "README.md",
27188 "other\n".to_owned(),
27189 6,
27190 )],
27191 };
27192
27193 let error = stable_context_pack_repository(first, second).unwrap_err();
27194
27195 assert_eq!(
27196 error.code,
27197 NodeFailureCode::ContextPackMaterializationFailed,
27198 );
27199 }
27200
27201 #[tokio::test]
27202 async fn native_session_catalog_zero_active_session_fixture() {
27203 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27204 let history_root = temporary_workspace_root("native-catalog-history");
27205 let outside_root = temporary_workspace_root("native-catalog-outside");
27206 std::fs::create_dir_all(&workspace_root).unwrap();
27207 std::fs::create_dir_all(&history_root).unwrap();
27208 std::fs::create_dir_all(&outside_root).unwrap();
27209 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27210 [
27211 serde_json::json!({
27212 "type": "user",
27213 "sessionId": session_id,
27214 "cwd": cwd.to_str().unwrap(),
27215 "message": { "content": "raw user text must not cross the catalog wire" }
27216 }),
27217 serde_json::json!({
27218 "type": "assistant",
27219 "sessionId": session_id,
27220 "cwd": cwd.to_str().unwrap(),
27221 "message": { "model": "claude-sonnet", "content": "raw answer" }
27222 }),
27223 serde_json::json!({
27224 "type": "ai-title",
27225 "sessionId": session_id,
27226 "aiTitle": title
27227 }),
27228 ]
27229 .into_iter()
27230 .map(|record| record.to_string())
27231 .collect::<Vec<_>>()
27232 .join("\n")
27233 };
27234 std::fs::write(
27235 history_root.join("stable-session.jsonl"),
27236 transcript("stable-session", &workspace_root, "Workspace review"),
27237 )
27238 .unwrap();
27239 std::fs::write(
27240 history_root.join("outside-session.jsonl"),
27241 transcript("outside-session", &outside_root, "Outside review"),
27242 )
27243 .unwrap();
27244 let history = NativeHistoryConfig::new(vec![
27245 NativeHistoryRoot::new(
27246 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27247 HistorySourceLayout::SingleNdjson,
27248 history_root.clone(),
27249 )
27250 .unwrap(),
27251 ])
27252 .unwrap();
27253 let catalog = active_registry().unwrap();
27254 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27255 assert!(handle.snapshot().sessions.is_empty());
27256 let workspace_id = WorkspaceId::new("primary").unwrap();
27257 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27258 let mut shared = NodeShared::new(
27259 handle,
27260 "fixture-token".to_owned(),
27261 NodeId::new("node-native-catalog").unwrap(),
27262 vec![workspace],
27263 vec![agent("claude")],
27264 );
27265 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27266 NativeSessionCatalogAuthority::new(history),
27267 )));
27268 let route = NativeSessionCatalogRoute::workspace(
27269 workspace_id.clone(),
27270 agent("claude"),
27271 );
27272 let request = NodeRequest::CatalogNativeSessions {
27273 route: route.clone(),
27274 limit: 2,
27275 };
27276 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27277 .await
27278 .unwrap_err();
27279 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27280 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27281 .await
27282 .unwrap();
27283 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27284 panic!("native catalog returned a different response");
27285 };
27286 let summary = summary.expect("node must emit native session catalog summary metadata");
27287 assert_eq!(entries.len(), 1);
27288 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27289 assert_eq!(entries[0].external_group, None);
27290 assert_eq!(entries[0].record_id, None);
27291 assert_eq!(entries[0].title, None);
27292 assert_eq!(entries[0].model, None);
27293 assert_eq!(entries[0].message_count, 0);
27294 let encoded_entries = serde_json::to_string(&entries).unwrap();
27295 assert!(!encoded_entries.contains("stable-session"));
27296 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27297 let page_request = NodeRequest::PageNativeSessions {
27298 route: route.clone(),
27299 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27300 catalog_revision: summary.catalog_revision,
27301 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27302 after_selection_id: None,
27303 limit: 2,
27304 };
27305 let observer = process_request_inner(
27306 &shared,
27307 7,
27308 ClientRole::Observer,
27309 page_request.clone(),
27310 )
27311 .await
27312 .unwrap_err();
27313 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27314 let page_response = process_request_inner(
27315 &shared,
27316 7,
27317 ClientRole::Operator,
27318 page_request.clone(),
27319 )
27320 .await
27321 .unwrap();
27322 assert!(matches!(
27323 page_response,
27324 NodeResponse::NativeSessionsPaged { page, .. }
27325 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27326 ));
27327 let stale = process_request_inner(
27328 &shared,
27329 7,
27330 ClientRole::Operator,
27331 NodeRequest::PageNativeSessions {
27332 route: route.clone(),
27333 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27334 catalog_revision: summary.catalog_revision.wrapping_add(1),
27335 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27336 after_selection_id: None,
27337 limit: 2,
27338 },
27339 )
27340 .await
27341 .unwrap_err();
27342 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27343 let selection = NativeSessionSelection {
27344 route: route.clone(),
27345 catalog_revision: summary.catalog_revision,
27346 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27347 selection_id: entries[0].selection_id.clone(),
27348 };
27349 let preview_response = process_request_inner(
27350 &shared,
27351 7,
27352 ClientRole::Operator,
27353 NodeRequest::PreviewNativeSession {
27354 selection: selection.clone(),
27355 message_limit: 1,
27356 },
27357 )
27358 .await
27359 .unwrap();
27360 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27361 panic!("native preview returned a different response");
27362 };
27363 assert_eq!(preview.messages.len(), 1);
27364 assert!(preview.message_count_exact);
27365 assert_eq!(preview.messages[0].text, "raw answer");
27366 assert!(preview.truncated);
27367
27368 let index_request = NodeRequest::IndexNativeSession {
27369 selection: selection.clone(),
27370 display_name: "Saved Claude".to_owned(),
27371 };
27372 let observer = process_request_inner(
27373 &shared,
27374 7,
27375 ClientRole::Observer,
27376 index_request.clone(),
27377 )
27378 .await
27379 .unwrap_err();
27380 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27381 let without_controller = process_request_inner(
27382 &shared,
27383 7,
27384 ClientRole::Operator,
27385 index_request.clone(),
27386 )
27387 .await
27388 .unwrap_err();
27389 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27390 let acquired = process_request_inner(
27391 &shared,
27392 7,
27393 ClientRole::Operator,
27394 NodeRequest::AcquireController { lease_ms: 5_000 },
27395 )
27396 .await
27397 .unwrap();
27398 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27399 let indexed = process_request_inner(
27400 &shared,
27401 7,
27402 ClientRole::Operator,
27403 index_request,
27404 )
27405 .await
27406 .unwrap();
27407 assert!(indexed.native_session_index_contract_is_valid());
27408 let NodeResponse::NativeSessionIndexed {
27409 selection: echoed_selection,
27410 record,
27411 } = indexed else {
27412 panic!("native session index returned a different response");
27413 };
27414 assert_eq!(echoed_selection, selection);
27415 let recataloged = process_request_inner(
27416 &shared,
27417 7,
27418 ClientRole::Operator,
27419 NodeRequest::CatalogNativeSessions {
27420 route,
27421 limit: 2,
27422 },
27423 )
27424 .await
27425 .unwrap();
27426 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27427 panic!("native catalog returned a different response after indexing");
27428 };
27429 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27430
27431 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27432 let external_catalog = process_request_inner(
27433 &shared,
27434 7,
27435 ClientRole::Operator,
27436 NodeRequest::CatalogNativeSessions {
27437 route: unregistered_route.clone(),
27438 limit: 2,
27439 },
27440 )
27441 .await
27442 .unwrap();
27443 let external_json = serde_json::to_string(&external_catalog).unwrap();
27444 assert!(!external_json.contains("outside-session"));
27445 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27446 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27447 assert!(!external_json.contains("\"session_id\""));
27448 assert!(!external_json.contains("\"cwd\""));
27449 assert!(!external_json.contains("\"path\""));
27450 let NodeResponse::NativeSessionsCataloged {
27451 route: echoed_route,
27452 entries: external_entries,
27453 summary: Some(external_summary),
27454 } = external_catalog
27455 else {
27456 panic!("unregistered native catalog returned a different response");
27457 };
27458 assert_eq!(echoed_route, unregistered_route);
27459 assert_eq!(external_entries.len(), 1);
27460 assert_eq!(external_entries[0].record_id, None);
27461 let external_group = external_entries[0]
27462 .external_group
27463 .as_ref()
27464 .expect("unregistered session must have an external group");
27465 assert_eq!(
27466 external_group.kind,
27467 gate4agent_types::NativeSessionExternalGroupKind::Project,
27468 );
27469 assert!(external_group.group_id.starts_with("external-"));
27470 assert_eq!(
27471 external_group.display_name,
27472 outside_root.file_name().unwrap().to_string_lossy(),
27473 );
27474 let external_selection = NativeSessionSelection {
27475 route: unregistered_route,
27476 catalog_revision: external_summary.catalog_revision,
27477 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27478 selection_id: external_entries[0].selection_id.clone(),
27479 };
27480 let external_preview = process_request_inner(
27481 &shared,
27482 7,
27483 ClientRole::Operator,
27484 NodeRequest::PreviewNativeSession {
27485 selection: external_selection.clone(),
27486 message_limit: 1,
27487 },
27488 )
27489 .await
27490 .unwrap();
27491 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27492 assert!(!external_preview_json.contains("outside-session"));
27493 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27494 assert!(!external_preview_json.contains("\"session_id\""));
27495 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27496 panic!("unregistered native preview returned a different response");
27497 };
27498 assert_eq!(preview.messages.len(), 1);
27499 let external_index = process_request_inner(
27500 &shared,
27501 7,
27502 ClientRole::Operator,
27503 NodeRequest::IndexNativeSession {
27504 selection: external_selection,
27505 display_name: "External Claude".to_owned(),
27506 },
27507 )
27508 .await
27509 .unwrap_err();
27510 assert_eq!(
27511 external_index.code,
27512 NodeFailureCode::WorkspaceRegistrationRequired,
27513 );
27514 let record_response = process_request_inner(
27515 &shared,
27516 7,
27517 ClientRole::Operator,
27518 NodeRequest::PreviewSessionRecord {
27519 record_id: record.record_id.clone(),
27520 message_limit: 2,
27521 },
27522 )
27523 .await
27524 .unwrap();
27525 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27526 panic!("record preview returned a different response");
27527 };
27528 assert_eq!(record_id, record.record_id);
27529 assert_eq!(preview.messages.len(), 2);
27530 let encoded = serde_json::to_string(&preview).unwrap();
27531 assert!(!encoded.contains("stable-session"));
27532 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27533 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27534 panic!("record preview history summary returned a different response");
27535 };
27536 let summaries = events
27537 .iter()
27538 .filter_map(|envelope| match &envelope.event {
27539 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27540 if record_id == &record.record_id =>
27541 {
27542 Some(*summary)
27543 }
27544 _ => None,
27545 })
27546 .collect::<Vec<_>>();
27547 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27548 let summary_json = serde_json::to_string(&summaries).unwrap();
27549 for private in [
27550 "stable-session",
27551 "raw user text must not cross the catalog wire",
27552 "raw answer",
27553 "Workspace review",
27554 "claude-sonnet",
27555 ] {
27556 assert!(!summary_json.contains(private), "{private}");
27557 }
27558 let identity = record.provider_session.clone().unwrap();
27559 assert_eq!(
27560 shared.revalidate_session_record_preview(&record, &identity),
27561 Ok(())
27562 );
27563 shared.remove_record_memory(&record.record_id);
27564 assert_eq!(
27565 shared
27566 .revalidate_session_record_preview(&record, &identity)
27567 .unwrap_err()
27568 .code,
27569 NodeFailureCode::UnknownSessionRecord
27570 );
27571 let released = process_request_inner(
27572 &shared,
27573 7,
27574 ClientRole::Operator,
27575 NodeRequest::ReleaseController,
27576 )
27577 .await
27578 .unwrap();
27579 assert!(matches!(released, NodeResponse::Controller { controller: None }));
27580 assert!(shared.controller_state().is_none());
27581 std::fs::remove_dir_all(workspace_root).unwrap();
27582 std::fs::remove_dir_all(history_root).unwrap();
27583 std::fs::remove_dir_all(outside_root).unwrap();
27584 }
27585
27586 #[tokio::test]
27587 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27588 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27589 std::fs::create_dir_all(&history_root).unwrap();
27590 let history = NativeHistoryConfig::new(vec![
27591 NativeHistoryRoot::new(
27592 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27593 HistorySourceLayout::SingleNdjson,
27594 history_root.clone(),
27595 )
27596 .unwrap(),
27597 ])
27598 .unwrap();
27599 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27600 let held = catalog.lock().unwrap();
27601 let result = timeout(
27602 Duration::from_secs(1),
27603 run_native_session_catalog_operation(
27604 Arc::clone(&catalog),
27605 "native session catalog",
27606 |_| Ok::<(), std::convert::Infallible>(()),
27607 ),
27608 )
27609 .await
27610 .expect("concurrent native catalog request must not wait for the operation deadline")
27611 .unwrap_err();
27612 assert_eq!(result.code, NodeFailureCode::BackendBusy);
27613 drop(held);
27614 std::fs::remove_dir_all(history_root).unwrap();
27615 }
27616
27617 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27618 let unique = std::time::SystemTime::now()
27619 .duration_since(std::time::UNIX_EPOCH)
27620 .unwrap()
27621 .as_nanos();
27622 std::env::temp_dir().join(format!(
27623 "gate4agent-node-{label}-{}-{unique}",
27624 std::process::id(),
27625 ))
27626 }
27627
27628 fn git_snapshot_for_parser() -> GitSnapshot {
27629 GitSnapshot {
27630 is_repository: true,
27631 branch: Some("main".to_owned()),
27632 status: Vec::new(),
27633 recent_commits: Vec::new(),
27634 worktrees: Vec::new(),
27635 managed_worktree: None,
27636 truncated: false,
27637 diagnostic: None,
27638 }
27639 }
27640
27641 #[tokio::test]
27642 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27643 let root = temporary_workspace_root("managed-git-scope");
27644 std::fs::create_dir_all(&root).unwrap();
27645 run_test_git(&root, &["init"]);
27646 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27647 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27648 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27649 run_test_git(&root, &["add", "seed.txt"]);
27650 run_test_git(&root, &["commit", "-m", "seed"]);
27651 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27652
27653 let workspace_id = WorkspaceId::new("managed-a").unwrap();
27654 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27655 let catalog = active_registry().unwrap();
27656 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27657 let shared = NodeShared::new(
27658 handle,
27659 "fixture-token".to_owned(),
27660 NodeId::new("node-1").unwrap(),
27661 vec![workspace],
27662 vec![agent("claude")],
27663 );
27664 let mut lease = ManagedWorktreeLeaseRecord {
27665 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27666 source_workspace_id: WorkspaceId::new("primary").unwrap(),
27667 workspace_id: workspace_id.clone(),
27668 profile_id: WorktreeProfileId::new("default").unwrap(),
27669 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27670 target_root: root.to_string_lossy().into_owned(),
27671 branch: "gate4agent/a".to_owned(),
27672 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27673 expected_head: None,
27674 retention: ManagedWorktreeRetention::Retain,
27675 state: ManagedWorktreeLeaseState::InUse,
27676 session_holders: vec![ManagedWorktreeSessionHolder {
27677 incarnation_id: shared.incarnation_id,
27678 instance_id: AgentInstanceId(7),
27679 generation: SessionGeneration(1),
27680 }],
27681 record_holders: Vec::new(),
27682 cleanup_failure: None,
27683 created_at_unix_ms: 1,
27684 updated_at_unix_ms: 1,
27685 };
27686 *shared
27687 .managed_worktrees
27688 .lock()
27689 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27690 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27691
27692 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27693 let scope = inspection.git.managed_worktree.unwrap();
27694 assert_eq!(scope.lease_id, lease.lease_id);
27695 assert_eq!(scope.branch, "gate4agent/a");
27696 assert_eq!(scope.active_session_count, 1);
27697
27698 lease.branch = "gate4agent/b".to_owned();
27699 *shared
27700 .managed_worktrees
27701 .lock()
27702 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27703 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27704 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27705 assert!(mismatch.git.managed_worktree.is_none());
27706 std::fs::remove_dir_all(root).unwrap();
27707 }
27708
27709 #[tokio::test]
27710 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27711 let root = temporary_workspace_root("workspace-file-write-controller");
27712 std::fs::create_dir_all(root.join("src")).unwrap();
27713 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27714 let workspace_id = WorkspaceId::new("primary").unwrap();
27715 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27716 let catalog = active_registry().unwrap();
27717 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27718 let shared = NodeShared::new(
27719 handle,
27720 "fixture-token".to_owned(),
27721 NodeId::new("node-1").unwrap(),
27722 vec![workspace],
27723 vec![agent("claude")],
27724 );
27725 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27726 let expected_revision = workspace_file_revision(b"old\n");
27727 let request = NodeRequest::WriteWorkspaceFile {
27728 workspace_id: workspace_id.clone(),
27729 path: path.clone(),
27730 expected_revision: expected_revision.clone(),
27731 text: "new\n".to_owned(),
27732 };
27733 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27734 .await
27735 .unwrap_err();
27736 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27737 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27738 .await
27739 .unwrap_err();
27740 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27741 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27742 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27743 let NodeResponse::WorkspaceFileWritten { file } = response else {
27744 panic!("workspace file write returned another response");
27745 };
27746 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27747 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27748 let stale = process_request_inner(
27749 &shared,
27750 77,
27751 ClientRole::Operator,
27752 NodeRequest::WriteWorkspaceFile {
27753 workspace_id,
27754 path,
27755 expected_revision,
27756 text: "lost update\n".to_owned(),
27757 },
27758 )
27759 .await
27760 .unwrap_err();
27761 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27762 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27763 std::fs::remove_dir_all(root).unwrap();
27764 }
27765
27766 #[tokio::test]
27767 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27768 let root = temporary_workspace_root("workspace-entry-create-controller");
27769 std::fs::create_dir_all(root.join("src")).unwrap();
27770 let workspace_id = WorkspaceId::new("primary").unwrap();
27771 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27772 let catalog = active_registry().unwrap();
27773 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27774 let shared = NodeShared::new(
27775 handle,
27776 "fixture-token".to_owned(),
27777 NodeId::new("node-1").unwrap(),
27778 vec![workspace],
27779 vec![agent("claude")],
27780 );
27781 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27782 let request = NodeRequest::CreateWorkspaceFile {
27783 workspace_id: workspace_id.clone(),
27784 path: path.clone(),
27785 };
27786 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27787 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27788 }));
27789 assert!(!request_uses_unnegotiated_capability(
27790 &request,
27791 &baseline_capabilities().unwrap(),
27792 ));
27793 assert!(request_uses_unnegotiated_capability(&request, &[]));
27794 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27795 .await
27796 .unwrap_err();
27797 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27798 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27799 .await
27800 .unwrap_err();
27801 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27802 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27803
27804 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27805 .await
27806 .unwrap();
27807 let NodeResponse::WorkspaceFileCreated { file } = response else {
27808 panic!("workspace file create returned another response");
27809 };
27810 assert_eq!(file.workspace_id, workspace_id);
27811 assert_eq!(file.path, path);
27812 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27813 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27814
27815 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27816 .await
27817 .unwrap_err();
27818 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27819
27820 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27821 let response = process_request_inner(
27822 &shared,
27823 77,
27824 ClientRole::Operator,
27825 NodeRequest::CreateWorkspaceDirectory {
27826 workspace_id: workspace_id.clone(),
27827 path: directory_path.clone(),
27828 },
27829 )
27830 .await
27831 .unwrap();
27832 assert_eq!(
27833 response,
27834 NodeResponse::WorkspaceDirectoryCreated {
27835 workspace_id: workspace_id.clone(),
27836 entry: WorkspaceEntry {
27837 relative_path: directory_path,
27838 kind: WorkspaceEntryKind::Directory,
27839 },
27840 },
27841 );
27842 assert!(root.join("src/nested").is_dir());
27843
27844 let missing_parent = process_request_inner(
27845 &shared,
27846 77,
27847 ClientRole::Operator,
27848 NodeRequest::CreateWorkspaceFile {
27849 workspace_id: workspace_id.clone(),
27850 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27851 },
27852 )
27853 .await
27854 .unwrap_err();
27855 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27856 let non_directory_parent = process_request_inner(
27857 &shared,
27858 77,
27859 ClientRole::Operator,
27860 NodeRequest::CreateWorkspaceFile {
27861 workspace_id,
27862 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27863 },
27864 )
27865 .await
27866 .unwrap_err();
27867 assert_eq!(
27868 non_directory_parent.code,
27869 NodeFailureCode::RepositoryParentNotDirectory,
27870 );
27871 std::fs::remove_dir_all(root).unwrap();
27872 }
27873
27874 #[tokio::test]
27875 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27876 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27877 std::fs::create_dir_all(root.join("src")).unwrap();
27878 #[cfg(windows)]
27879 let root = std::fs::canonicalize(root).unwrap();
27880 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27881 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27882 let barrier = Arc::new(std::sync::Barrier::new(2));
27883 let done = Arc::new(AtomicBool::new(false));
27884 let task_commit_state = Arc::clone(&commit_state);
27885 let task_barrier = Arc::clone(&barrier);
27886 let task_done = Arc::clone(&done);
27887 let task_root = root.clone();
27888 let task = tokio::task::spawn_blocking(move || {
27889 task_barrier.wait();
27890 let result = create_workspace_file_on_disk(
27891 &task_root,
27892 &path,
27893 &task_commit_state,
27894 );
27895 task_done.store(true, Ordering::Release);
27896 result
27897 });
27898
27899 let error = settle_workspace_entry_create(
27900 task,
27901 Arc::clone(&commit_state),
27902 Duration::from_millis(10),
27903 "fixture deadline",
27904 "fixture task failed",
27905 )
27906 .await
27907 .unwrap_err();
27908 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27909 assert_eq!(
27910 commit_state.load(Ordering::Acquire),
27911 WORKSPACE_ENTRY_CREATE_CANCELED,
27912 );
27913
27914 barrier.wait();
27915 timeout(Duration::from_secs(1), async {
27916 while !done.load(Ordering::Acquire) {
27917 tokio::task::yield_now().await;
27918 }
27919 })
27920 .await
27921 .expect("canceled blocking create did not settle");
27922 assert!(!root.join("src/late.rs").exists());
27923 std::fs::remove_dir_all(root).unwrap();
27924 }
27925}
27926
27927#[cfg(test)]
27928mod provisional_spawn_tests {
27929 use super::*;
27930 use std::sync::Arc;
27931
27932 fn shared() -> NodeShared {
27933 let catalog = active_registry().unwrap();
27934 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27935 let workspace = WorkspaceConfig::new(
27936 WorkspaceId::new("primary").unwrap(),
27937 std::env::current_dir().unwrap(),
27938 )
27939 .unwrap()
27940 .with_worktree_service_mode(WorktreeServiceMode::Manual);
27941 NodeShared::new(
27942 handle,
27943 "fixture-token".to_owned(),
27944 NodeId::new("node-provisional-test").unwrap(),
27945 vec![workspace],
27946 vec![AgentId::new("claude").unwrap()],
27947 )
27948 }
27949
27950 #[test]
27953 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27954 let shared = shared();
27955 let ws = WorkspaceId::new("primary").unwrap();
27956 let mut ids = Vec::with_capacity(50);
27957 for i in 1u64..=50 {
27958 let id = AgentInstanceId(i);
27959 ids.push(id);
27960 let address = SessionAddress {
27961 workspace_id: ws.clone(),
27962 session: SessionKey {
27963 instance_id: id,
27964 generation: SessionGeneration(1),
27965 },
27966 };
27967 shared.mark_spawn_provisional(address);
27968 }
27969 assert_eq!(
27970 shared
27971 .provisional_spawns
27972 .lock()
27973 .unwrap_or_else(|p| p.into_inner())
27974 .len(),
27975 50
27976 );
27977 for id in ids.iter().step_by(2) {
27978 shared.sessionize_spawn(*id);
27979 }
27980 assert_eq!(
27981 shared
27982 .provisional_spawns
27983 .lock()
27984 .unwrap_or_else(|p| p.into_inner())
27985 .len(),
27986 25
27987 );
27988 assert!(shared.take_due_provisional_spawns().is_empty());
27989 {
27990 let mut map = shared
27991 .provisional_spawns
27992 .lock()
27993 .unwrap_or_else(|p| p.into_inner());
27994 let past = Instant::now() - Duration::from_secs(30);
27995 for (_, (_, at)) in map.iter_mut() {
27996 *at = past;
27997 }
27998 }
27999 let due = shared.take_due_provisional_spawns();
28000 assert_eq!(due.len(), 25);
28001 let due_ids: std::collections::BTreeSet<_> =
28002 due.iter().map(|a| a.session.instance_id.0).collect();
28003 let expected: std::collections::BTreeSet<_> =
28004 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28005 assert_eq!(due_ids, expected);
28006 assert!(shared
28007 .provisional_spawns
28008 .lock()
28009 .unwrap_or_else(|p| p.into_inner())
28010 .is_empty());
28011 }
28012
28013 #[test]
28016 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28017 let shared = Arc::new(shared());
28018 let ws = WorkspaceId::new("primary").unwrap();
28019 let mut handles = Vec::new();
28020 for i in 1u64..=20 {
28021 let shared = Arc::clone(&shared);
28022 let ws = ws.clone();
28023 handles.push(std::thread::spawn(move || {
28024 let id = AgentInstanceId(i);
28025 let address = SessionAddress {
28026 workspace_id: ws,
28027 session: SessionKey {
28028 instance_id: id,
28029 generation: SessionGeneration(1),
28030 },
28031 };
28032 shared.mark_spawn_provisional(address);
28033 id
28034 }));
28035 }
28036 let mut ids = Vec::new();
28037 for h in handles {
28038 ids.push(h.join().unwrap());
28039 }
28040 ids.sort_by_key(|id| id.0);
28041 assert_eq!(ids.len(), 20);
28042 ids.dedup();
28043 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28044 assert_eq!(
28045 shared
28046 .provisional_spawns
28047 .lock()
28048 .unwrap_or_else(|p| p.into_inner())
28049 .len(),
28050 20
28051 );
28052 shared.sessionize_spawn(AgentInstanceId(7));
28053 let left: Vec<_> = shared
28054 .provisional_spawns
28055 .lock()
28056 .unwrap_or_else(|p| p.into_inner())
28057 .keys()
28058 .map(|id| id.0)
28059 .collect();
28060 assert!(!left.contains(&7));
28061 assert_eq!(left.len(), 19);
28062 }
28063}
28064
28065
28066#[cfg(unix)]
28067extern "C" {
28068 #[link_name = "geteuid"]
28069 fn libc_geteuid() -> u32;
28070}
28071
28072#[cfg(test)]
28073mod call_home_link_tests {
28074 use super::*;
28075
28076 #[test]
28077 fn call_home_backoff_is_capped_and_monotonic() {
28078 let mut last = Duration::ZERO;
28079 for attempt in 1..20 {
28080 let next = call_home_backoff(attempt);
28081 assert!(next >= last);
28082 assert!(next <= Duration::from_secs(8));
28083 last = next;
28084 }
28085 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28086 }
28087
28088 #[tokio::test]
28089 async fn call_home_socket_gets_keepalive() {
28090 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28091 let addr = listener.local_addr().unwrap();
28092 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28093 let client = client.unwrap();
28094 enable_call_home_keepalive(&client).unwrap();
28095 let sock = socket2::SockRef::from(&client);
28096 assert!(sock.keepalive().unwrap());
28097 #[cfg(any(target_os = "linux", target_os = "macos"))]
28098 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28099 }
28100}