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 if let Err(error) = accounts.write_session_file(&account) {
3965 tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3966 }
3967 self.m4a_live
3968 .lock()
3969 .unwrap_or_else(|p| p.into_inner())
3970 .insert(account.nick.clone(), (instance_id, provider.clone()));
3971 Some(merged)
3972 }
3973 Err(error) => {
3974 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3975 environment_overlay
3976 }
3977 }
3978 }
3979 (SessionMode::Pty, Some(_), None, _) => {
3980 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3981 environment_overlay
3982 }
3983 _ => environment_overlay,
3984 };
3985 let mut instance_overlay = environment_overlay
3986 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3987 .transpose()?
3988 .flatten();
3989 Ok(PreparedSpawn {
3990 instance_overlay,
3991 environment_selection,
3992 materialization_guard,
3993 harness_mcp_overlay,
3994 #[cfg(feature = "dig2-station-probe")]
3995 browser_station_lease_guard,
3996 materialization_id,
3997 })
3998 }
3999}
4000
4001struct SessionRecords {
4002 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4003}
4004
4005struct SpawnIdempotencyCache {
4006 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4007}
4008
4009struct SpawnIdempotencyEntry {
4010 value: SpawnIdempotencyValue,
4011 expires_at: Instant,
4012}
4013
4014enum SpawnIdempotencyValue {
4015 Standard {
4016 spec: SpawnSpec,
4017 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4018 },
4019 Managed {
4020 request: ManagedWorktreeSpawnRequest,
4021 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4022 },
4023 HarnessMcp,
4024}
4025
4026enum DurableManagedSpawnReplayDecision {
4027 Reserved,
4028 PendingLinked,
4029 Committed(ManagedWorktreeSpawnReceipt),
4030}
4031
4032impl SpawnIdempotencyValue {
4033 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4034 match self {
4035 Self::Standard { result, .. } => result
4036 .as_ref()
4037 .ok()
4038 .and_then(|receipt| receipt.context.as_ref()),
4039 Self::Managed { result, .. } => result
4040 .as_ref()
4041 .ok()
4042 .and_then(|receipt| receipt.spawn.context.as_ref()),
4043 Self::HarnessMcp => None,
4044 }
4045 .is_some_and(|receipt| &receipt.id == context_id)
4046 }
4047}
4048
4049#[derive(Clone, Copy)]
4050struct ControllerLease {
4051 connection_id: u64,
4052 expires_at: Instant,
4053}
4054
4055struct NodeEventHistory {
4056 last_sequence: u64,
4057 replay_floor_sequence: u64,
4058 events: VecDeque<NodeEventEnvelope>,
4059 record_providers: BTreeMap<SessionRecordId, AgentId>,
4060 removed_record_providers: BTreeMap<u64, AgentId>,
4061}
4062
4063impl NodeEventHistory {
4064 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4065 Self {
4066 last_sequence: 0,
4067 replay_floor_sequence: 1,
4068 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4069 record_providers,
4070 removed_record_providers: BTreeMap::new(),
4071 }
4072 }
4073}
4074
4075impl NodeShared {
4076 fn session_grok_leader_socket(
4080 &self,
4081 provider: &AgentId,
4082 mode: SessionMode,
4083 instance_id: AgentInstanceId,
4084 ) -> Option<std::path::PathBuf> {
4085 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4086 return None;
4087 }
4088 let root = self
4089 .m4a_accounts
4090 .as_ref()
4091 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4092 .unwrap_or_else(std::env::temp_dir);
4093 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4094 if let Some(previous) = self
4095 .grok_leaders
4096 .lock()
4097 .unwrap_or_else(|p| p.into_inner())
4098 .insert(instance_id, socket.clone())
4099 {
4100 crate::grok_leader::retire(&previous);
4101 }
4102 Some(socket)
4103 }
4104
4105 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4107 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4108 if let Some(socket) = socket {
4109 crate::grok_leader::retire(&socket);
4110 }
4111 }
4112
4113 fn new_with_incarnation(
4114 handle: Gate4AgentHandle,
4115 access_token: String,
4116 node_id: NodeId,
4117 incarnation_id: NodeIncarnationId,
4118 workspaces: Vec<WorkspaceConfig>,
4119 enabled_providers: Vec<AgentId>,
4120 provider_runtime_statuses: ProviderRuntimeStatuses,
4121 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4122 provider_contracts: Vec<ProviderContractSupport>,
4123 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4124 spawn_profiles: SpawnProfileRegistry,
4125 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4126 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4127 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4128 state_path: Option<PathBuf>,
4129 records: Vec<ManagedSessionRecord>,
4130 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4131 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4132 materializations: Vec<MaterializationOwnershipRecord>,
4133 persistence_warning: Option<String>,
4134 input_settle_timeout_ms: u64,
4135 ) -> Self {
4136 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4137 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4138 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4139 let (harness_mcp_proxy_event_tx, _) =
4140 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4141 let record_providers = records
4142 .iter()
4143 .map(|record| (record.record_id.clone(), record.provider.clone()))
4144 .collect();
4145 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4146 .iter()
4147 .map(|workspace| {
4148 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4149 })
4150 .collect();
4151 for lease in &managed_worktrees {
4152 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4153 }
4154 let managed_worktree_profiles = workspaces.iter()
4155 .map(|workspace| (
4156 workspace.workspace_id.clone(),
4157 workspace.managed_worktree_profiles.clone(),
4158 ))
4159 .collect();
4160 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4161 managed_worktrees,
4162 managed_worktree_tombstones,
4163 ).expect("durable managed worktree registry was validated while loading");
4164 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4165 managed_worktrees.reattach_record_holders(
4166 &managed_worktree_record_holders(&records),
4167 unix_time_ms(),
4168 );
4169 Self {
4170 handle,
4171 access_token,
4172 node_id,
4173 incarnation_id,
4174 started_at_unix_ms: std::time::SystemTime::now()
4175 .duration_since(std::time::UNIX_EPOCH)
4176 .unwrap_or_default()
4177 .as_millis()
4178 .min(u64::MAX as u128) as u64,
4179 workspaces: RwLock::new(
4180 workspaces
4181 .into_iter()
4182 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4183 .collect(),
4184 ),
4185 worktree_service_modes,
4186 managed_worktree_profiles,
4187 managed_worktrees: Mutex::new(managed_worktrees),
4188 enabled_providers,
4189 provider_runtime_statuses,
4190 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4191 provider_runtime_monitor,
4192 provider_contracts,
4193 provider_adapter_contracts,
4194 spawn_profiles,
4195 environment_profiles: RwLock::new(BTreeMap::new()),
4196 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4197 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4198 #[cfg(feature = "dig2-station-probe")]
4199 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4200 bundle_catalog: RwLock::new(BundleCatalog::default()),
4201 delivery_store: Mutex::new(None),
4202 context_pack_store: Mutex::new(None),
4203 context_catalog: RwLock::new(ContextPackCatalog::default()),
4204 native_launch_profile_control,
4205 harness_mcp_registry: None,
4206 native_session_catalog,
4207 session_environment_materializer,
4208 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4209 m4a_live: Mutex::new(BTreeMap::new()),
4210 grok_leaders: Mutex::new(BTreeMap::new()),
4211 #[cfg(unix)]
4212 signer_proxy: std::sync::OnceLock::new(),
4213 materializations: Mutex::new(
4214 materializations
4215 .into_iter()
4216 .map(|record| (record.id().clone(), record))
4217 .collect(),
4218 ),
4219 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4220 entries: BTreeMap::new(),
4221 }),
4222 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4223 controller: Mutex::new(None),
4224 provisional_spawns: Mutex::new(BTreeMap::new()),
4225 pending_acp_options: Mutex::new(BTreeMap::new()),
4226 history: Mutex::new(NodeEventHistory::new(record_providers)),
4227 event_tx,
4228 terminal_event_tx,
4229 agent_stream_event_tx,
4230 harness_mcp_proxy_event_tx,
4231 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4232 pty_input_wake: tokio::sync::Notify::new(),
4233 pty_input_marks: Mutex::new(BTreeMap::new()),
4234 next_connection_id: AtomicU64::new(1),
4235 next_instance_id: AtomicU64::new(1),
4236 next_command_id: AtomicU64::new(1),
4237 shutdown: AtomicBool::new(false),
4238 shutdown_notify: Notify::new(),
4239 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4240 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4241 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4242 mutation_gate: AsyncMutex::new(()),
4243 session_bindings: Mutex::new(BTreeMap::new()),
4244 session_records: Mutex::new(SessionRecords {
4245 records: records
4246 .into_iter()
4247 .map(|record| (record.record_id.clone(), record))
4248 .collect(),
4249 }),
4250 state_path,
4251 persistence_error: RwLock::new(
4252 persistence_warning
4253 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4254 ),
4255 state_transaction: Mutex::new(()),
4256 input_settle_timeout_ms,
4257 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4258 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4259 shell_efficiency_profile: OnceLock::new(),
4260 native_session_gauge: AtomicUsize::new(0),
4261 connection_loop_iterations: AtomicU64::new(0),
4262 connection_events_sent: AtomicU64::new(0),
4263 rejected_commands_total: AtomicU64::new(0),
4264 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4265 drive_loop_iterations_total: AtomicU64::new(0),
4266 drive_loop_iterations_idle: AtomicU64::new(0),
4267 session_record_retention: SessionRecordRetentionConfig::default(),
4268 retired_records_total: AtomicU64::new(0),
4269 #[cfg(feature = "fixture")]
4270 fixture_semantic_hook_policy: false,
4271 #[cfg(feature = "fixture")]
4272 fixture_spawn_managed_worktree_v2_failure_probe:
4273 SpawnManagedWorktreeV2FailureProbe::default(),
4274 }
4275 }
4276
4277 #[cfg(test)]
4278 fn new(
4279 handle: Gate4AgentHandle,
4280 access_token: String,
4281 node_id: NodeId,
4282 workspaces: Vec<WorkspaceConfig>,
4283 enabled_providers: Vec<AgentId>,
4284 ) -> Self {
4285 Self::new_with_incarnation(
4286 handle,
4287 access_token,
4288 node_id,
4289 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4290 workspaces,
4291 enabled_providers,
4292 ProviderRuntimeStatuses::default(),
4293 None,
4294 Vec::new(),
4295 Vec::new(),
4296 SpawnProfileRegistry::default(),
4297 None,
4298 None,
4299 None,
4300 None,
4301 Vec::new(),
4302 Vec::new(),
4303 Vec::new(),
4304 Vec::new(),
4305 None,
4306 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4307 )
4308 }
4309
4310 fn persistence_error(&self) -> Option<String> {
4311 self.persistence_error
4312 .read()
4313 .unwrap_or_else(|poisoned| poisoned.into_inner())
4314 .clone()
4315 }
4316
4317 async fn admit_provider_runtime(
4318 &self,
4319 provider: &AgentId,
4320 requirement: ProviderRuntimeRequirement,
4321 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4322 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4323 let pipe_transport = monitor.supports_pipe_transport(provider);
4324 let acp_transport = monitor.supports_acp_transport(provider);
4325 let provider = provider.clone();
4326 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4327 let (status, admission) = timeout(
4328 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4329 refresh,
4330 )
4331 .await
4332 .map_err(|_| failure(
4333 NodeFailureCode::BackendBusy,
4334 "provider runtime probe exceeded its bounded deadline",
4335 ))?
4336 .map_err(|_| failure(
4337 NodeFailureCode::BackendOperationFailed,
4338 "provider runtime probe task failed",
4339 ))?;
4340 if let Some(status) = status {
4341 self
4342 .provider_runtime_status_updates
4343 .write()
4344 .unwrap_or_else(|poisoned| poisoned.into_inner())
4345 .insert(status.provider().clone(), status);
4346 }
4347 admission.and_then(|policy| {
4348 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4349 })
4350 } else {
4351 crate::provider_runtime::admit_status(
4357 &self.provider_runtime_statuses,
4358 provider,
4359 false,
4360 false,
4361 requirement,
4362 )
4363 };
4364 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4365 admission.map_err(|error| match error {
4366 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4367 NodeFailureCode::BackendOperationFailed,
4368 "provider launcher is unavailable",
4369 ),
4370 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4371 NodeFailureCode::UnsupportedCapability,
4372 "provider semantic capability is not verified",
4373 ),
4374 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4375 NodeFailureCode::BackendBusy,
4376 "provider runtime probe is already in progress",
4377 ),
4378 })
4379 }
4380
4381 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4387 match self.provider_runtime_monitor.as_deref() {
4388 Some(monitor) => monitor.supports_pipe_transport(provider),
4389 None => false,
4390 }
4391 }
4392
4393 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4399 match self.provider_runtime_monitor.as_deref() {
4400 Some(monitor) => monitor.supports_acp_transport(provider),
4401 None => false,
4402 }
4403 }
4404
4405 fn admit_declared_hook_semantics(
4421 &self,
4422 provider: &AgentId,
4423 policy: ProviderRuntimePolicy,
4424 ) -> ProviderRuntimePolicy {
4425 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4426 return policy;
4427 }
4428 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4429 contract.provider == *provider && contract.family == AdapterFamily::Hook
4430 });
4431 if !declares_hook {
4432 return policy;
4433 }
4434 ProviderRuntimePolicy::new(
4435 policy.raw_pty_lifecycle,
4436 policy.semantic_readiness,
4437 policy.structured_prompt,
4438 policy.provider_session_identity,
4439 policy.semantic_resume,
4440 true,
4441 )
4442 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4443 }
4444
4445 fn resolve_network_allowlist_overlay_args(
4460 &self,
4461 provider: &AgentId,
4462 mode: SessionMode,
4463 approval_level: ApprovalLevel,
4464 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4465 provider_home: Option<&Path>,
4466 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4467 let Some(allowlist_id) = network_allowlist else {
4468 return Ok(Vec::new());
4469 };
4470 let catalog = self
4471 .network_allowlist_catalog
4472 .read()
4473 .unwrap_or_else(|poisoned| poisoned.into_inner());
4474 let Some(entry) = catalog.get(allowlist_id) else {
4475 return Err(failure(
4476 NodeFailureCode::UnknownNetworkAllowlist,
4477 "spawn network allowlist is unavailable on this node",
4478 ));
4479 };
4480
4481 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4483 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4484 |message| {
4485 failure(
4486 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4487 &message,
4488 )
4489 },
4490 )?;
4491 if !matches!(mode, SessionMode::Pty) {
4492 return Err(failure(
4493 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4494 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4495 ));
4496 }
4497 let Some(home) = provider_home else {
4498 return Err(failure(
4499 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4500 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4501 ));
4502 };
4503 let domains =
4504 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4505 if domains.is_empty() {
4506 return Err(failure(
4507 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4508 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4509 ));
4510 }
4511 let settings_path = home.join(
4512 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4513 );
4514 let json =
4515 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4516 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4517 |_| {
4518 failure(
4519 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4520 "Claude station network settings sidecar could not be written under ProviderHome",
4521 )
4522 },
4523 )?;
4524 let overlay =
4525 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4526 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4527 }
4528
4529 let Some(native) = entry.provider_native.as_ref() else {
4530 return Ok(Vec::new());
4531 };
4532 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4533 provider.as_str(),
4534 approval_level,
4535 native,
4536 )
4537 .map_err(|message| {
4538 failure(
4539 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4540 &message,
4541 )
4542 })?;
4543 if overlay.is_empty() {
4544 return Ok(Vec::new());
4545 }
4546 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4547 return Err(failure(
4548 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4549 "Codex network_access -c overlay requires PTY or ACP transport",
4550 ));
4551 }
4552 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4553 }
4554
4555 fn resolve_codex_network_access_overlay_args(
4557 &self,
4558 provider: &AgentId,
4559 mode: SessionMode,
4560 approval_level: ApprovalLevel,
4561 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4562 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4563 self.resolve_network_allowlist_overlay_args(
4564 provider,
4565 mode,
4566 approval_level,
4567 network_allowlist,
4568 None,
4569 )
4570 }
4571
4572 fn resolve_environment_profile(
4573 &self,
4574 resolved: &ResolvedSpawnSpec,
4575 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4576 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4582 let catalog = self
4583 .network_allowlist_catalog
4584 .read()
4585 .unwrap_or_else(|poisoned| poisoned.into_inner());
4586 let Some(entry) = catalog.get(allowlist_id) else {
4587 return Err(failure(
4588 NodeFailureCode::UnknownNetworkAllowlist,
4589 "spawn network allowlist is unavailable on this node",
4590 ));
4591 };
4592 if let Some(native) = entry.provider_native.as_ref() {
4595 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4596 resolved.provider.as_str(),
4597 resolved.approval_level,
4598 native,
4599 ) {
4600 return Err(failure(
4601 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4602 &message,
4603 ));
4604 }
4605 }
4606 }
4607 #[cfg(feature = "dig2-station-probe")]
4616 if resolved.browser_profile_id.is_some() {
4617 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4618 match probe_station_reachable() {
4619 Ok(()) => {}
4620 Err(StationProbeError::UnsupportedPlatform)
4621 | Err(StationProbeError::InvalidPipeSuffix) => {
4622 return Err(failure(
4623 NodeFailureCode::BrowserStationProbeUnavailable,
4624 "dig2browser station probe unavailable on this platform",
4625 ));
4626 }
4627 Err(StationProbeError::Unreachable) => {
4628 return Err(failure(
4629 NodeFailureCode::BrowserStationUnreachable,
4630 "dig2browser station named-pipe is unreachable",
4631 ));
4632 }
4633 }
4634 }
4635 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4636 return Ok(None);
4637 };
4638 let profiles = self
4639 .environment_profiles
4640 .read()
4641 .unwrap_or_else(|poisoned| poisoned.into_inner());
4642 let binding = profiles.get(profile_id).ok_or_else(|| {
4643 failure(
4644 NodeFailureCode::UnknownEnvironmentProfile,
4645 "spawn environment profile is unavailable on this node",
4646 )
4647 })?;
4648 if binding.provider != resolved.provider
4649 || binding.native_profile_id(resolved.mode).is_none()
4650 {
4651 return Err(failure(
4652 NodeFailureCode::EnvironmentProfileBindingMismatch,
4653 "spawn environment profile does not match the provider and mode",
4654 ));
4655 }
4656 Ok(Some(ResolvedEnvironmentProfileReceipt {
4660 profile_id: binding.id.clone(),
4661 profile_revision: binding.revision.clone(),
4662 network_allowlist: resolved.network_allowlist.clone(),
4663 browser_profile_id: resolved.browser_profile_id.clone(),
4664 }))
4665 }
4666
4667 fn resolve_bundle(
4668 &self,
4669 resolved: &ResolvedSpawnSpec,
4670 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4671 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4672 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4673 return Ok(None);
4674 };
4675 let catalog = self.bundle_catalog
4676 .read()
4677 .unwrap_or_else(|poisoned| poisoned.into_inner());
4678 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4679 failure(
4680 NodeFailureCode::UnknownBundle,
4681 "spawn bundle is unavailable on this node",
4682 )
4683 })?;
4684 self.bundle_layout(
4685 &resolved.provider,
4686 resolved.mode,
4687 environment_profile,
4688 bundle,
4689 )?;
4690 Ok(Some(bundle.receipt()))
4691 }
4692
4693 fn begin_delivery_stage(
4694 &self,
4695 manifest: DeliveryBundleManifestV2,
4696 ) -> Result<NodeResponse, NodeFailure> {
4697 let mut store = self
4698 .delivery_store
4699 .lock()
4700 .unwrap_or_else(|poisoned| poisoned.into_inner());
4701 let store = store.as_mut().ok_or_else(|| {
4702 failure(
4703 NodeFailureCode::DeliveryStageStorageFailed,
4704 "node delivery store is unavailable",
4705 )
4706 })?;
4707 let begun = store.begin(manifest).map_err(delivery_failure)?;
4708 Ok(NodeResponse::DeliveryStageBegun {
4709 stage_id: begun.stage_id,
4710 manifest_digest: begun.manifest_digest,
4711 missing_blobs: begun.missing_blobs,
4712 })
4713 }
4714
4715 fn put_delivery_blob_chunk(
4716 &self,
4717 stage_id: DeliveryStageId,
4718 blob_digest: DeliveryBlobDigestV1,
4719 offset: u64,
4720 chunk_hex: DeliveryBlobChunkHexV1,
4721 ) -> Result<NodeResponse, NodeFailure> {
4722 let mut store = self
4723 .delivery_store
4724 .lock()
4725 .unwrap_or_else(|poisoned| poisoned.into_inner());
4726 let store = store.as_mut().ok_or_else(|| {
4727 failure(
4728 NodeFailureCode::DeliveryStageStorageFailed,
4729 "node delivery store is unavailable",
4730 )
4731 })?;
4732 let accepted = store
4733 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4734 .map_err(delivery_failure)?;
4735 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4736 stage_id: accepted.stage_id,
4737 blob_digest: accepted.blob_digest,
4738 next_offset: accepted.next_offset,
4739 })
4740 }
4741
4742 fn commit_delivery_stage(
4743 &self,
4744 stage_id: DeliveryStageId,
4745 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4746 let mut store = self
4747 .delivery_store
4748 .lock()
4749 .unwrap_or_else(|poisoned| poisoned.into_inner());
4750 let store = store.as_mut().ok_or_else(|| {
4751 failure(
4752 NodeFailureCode::DeliveryStageStorageFailed,
4753 "node delivery store is unavailable",
4754 )
4755 })?;
4756 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4757 let mut catalog = self
4758 .bundle_catalog
4759 .write()
4760 .unwrap_or_else(|poisoned| poisoned.into_inner());
4761 let mut next_catalog = catalog.clone();
4762 next_catalog
4763 .insert_idempotent(prepared.bundle().clone())
4764 .map_err(|_| {
4765 failure(
4766 NodeFailureCode::DeliveryStageConflict,
4767 "delivery bundle identity conflicts with installed content",
4768 )
4769 })?;
4770 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4771 *catalog = next_catalog;
4772 Ok(receipt)
4773 }
4774
4775 fn abort_delivery_stage(
4776 &self,
4777 stage_id: &DeliveryStageId,
4778 ) -> Result<(), NodeFailure> {
4779 let mut store = self
4780 .delivery_store
4781 .lock()
4782 .unwrap_or_else(|poisoned| poisoned.into_inner());
4783 let store = store.as_mut().ok_or_else(|| {
4784 failure(
4785 NodeFailureCode::DeliveryStageStorageFailed,
4786 "node delivery store is unavailable",
4787 )
4788 })?;
4789 store.abort(stage_id).map_err(delivery_failure)
4790 }
4791
4792 fn resolve_context(
4793 &self,
4794 resolved: &ResolvedSpawnSpec,
4795 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4796 let Some(context_id) = resolved.context_id.as_ref() else {
4797 return Ok(None);
4798 };
4799 self.context_catalog
4800 .read()
4801 .unwrap_or_else(|poisoned| poisoned.into_inner())
4802 .get(context_id)
4803 .map(|context| context.receipt().clone())
4804 .ok_or_else(|| {
4805 failure(
4806 NodeFailureCode::UnknownContextPack,
4807 "spawn context pack is unavailable on this node",
4808 )
4809 })
4810 .map(Some)
4811 }
4812
4813 fn bundle_layout(
4814 &self,
4815 provider: &AgentId,
4816 mode: SessionMode,
4817 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4818 bundle: &NodeBundle,
4819 ) -> Result<BundleProviderLayout, NodeFailure> {
4820 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4821 failure(
4822 NodeFailureCode::BundleBindingMismatch,
4823 "spawn bundle does not match the provider and mode",
4824 )
4825 })?;
4826 if layout != BundleProviderLayout::Codex {
4827 return Ok(layout);
4828 }
4829 let profile = environment_profile.and_then(|receipt| {
4830 let profiles = self
4831 .environment_profiles
4832 .read()
4833 .unwrap_or_else(|poisoned| poisoned.into_inner());
4834 let binding = profiles.get(&receipt.profile_id)?;
4835 if binding.revision != receipt.profile_revision
4836 || &binding.provider != provider
4837 || binding.native_profile_id(mode).is_none()
4838 {
4839 return None;
4840 }
4841 self.environment_materialization_profiles
4842 .read()
4843 .unwrap_or_else(|poisoned| poisoned.into_inner())
4844 .get(&receipt.profile_id)
4845 .cloned()
4846 });
4847 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4848 return Err(failure(
4849 NodeFailureCode::BundleBindingMismatch,
4850 "Codex bundle requires an exact isolated CODEX_HOME profile",
4851 ));
4852 }
4853 Ok(layout)
4854 }
4855
4856 fn select_environment_profile(
4857 &self,
4858 instance_id: AgentInstanceId,
4859 provider: &AgentId,
4860 mode: SessionMode,
4861 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4862 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4863 let Some(environment_profile) = environment_profile else {
4864 return Ok(None);
4865 };
4866 let native_profile_id = {
4867 let profiles = self
4868 .environment_profiles
4869 .read()
4870 .unwrap_or_else(|poisoned| poisoned.into_inner());
4871 let binding = profiles
4872 .get(&environment_profile.profile_id)
4873 .ok_or_else(|| {
4874 failure(
4875 NodeFailureCode::UnknownEnvironmentProfile,
4876 "session environment profile is unavailable on this node",
4877 )
4878 })?;
4879 if &binding.revision != &environment_profile.profile_revision
4880 || &binding.provider != provider
4881 {
4882 return Err(failure(
4883 NodeFailureCode::EnvironmentProfileBindingMismatch,
4884 "session environment profile revision or provider changed",
4885 ));
4886 }
4887 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4888 failure(
4889 NodeFailureCode::EnvironmentProfileBindingMismatch,
4890 "session environment profile does not support this mode",
4891 )
4892 })?
4893 };
4894 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4895 failure(
4896 NodeFailureCode::BackendOperationFailed,
4897 "native environment profile controller is unavailable",
4898 )
4899 })?;
4900 control
4901 .select_native_launch_profile(instance_id, native_profile_id)
4902 .map_err(|_| {
4903 failure(
4904 NodeFailureCode::BackendOperationFailed,
4905 "native environment profile selection failed",
4906 )
4907 })?;
4908 Ok(Some(NativeEnvironmentSelectionGuard {
4909 control: Some(control),
4910 instance_id,
4911 }))
4912 }
4913
4914 fn install_prepared_launch_overlay(
4915 &self,
4916 instance_id: AgentInstanceId,
4917 overlay: PreparedNativeLaunchOverlay,
4918 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4919 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4920 failure(
4921 NodeFailureCode::BackendOperationFailed,
4922 "native launch profile controller is unavailable",
4923 )
4924 })?;
4925 match overlay {
4926 PreparedNativeLaunchOverlay::Environment(overlay) => {
4927 control
4928 .install_native_launch_environment_overlay(instance_id, overlay)
4929 .map_err(|_| failure(
4930 NodeFailureCode::BackendOperationFailed,
4931 "session environment overlay installation failed",
4932 ))?;
4933 Ok(None)
4934 }
4935 PreparedNativeLaunchOverlay::Instance(overlay) => {
4936 control
4937 .install_native_instance_launch_overlay(instance_id, overlay)
4938 .map_err(|_| failure(
4939 NodeFailureCode::BundleBindingMismatch,
4940 "session bundle launch overlay installation failed",
4941 ))?;
4942 Ok(Some(NativeInstanceOverlayGuard {
4943 control: Some(control),
4944 instance_id,
4945 }))
4946 }
4947 }
4948 }
4949
4950 fn environment_materialization_profile(
4951 &self,
4952 environment_profile: &ResolvedEnvironmentProfileReceipt,
4953 ) -> Option<NodeSessionMaterializationProfile> {
4954 self.environment_materialization_profiles
4955 .read()
4956 .unwrap_or_else(|poisoned| poisoned.into_inner())
4957 .get(&environment_profile.profile_id)
4958 .cloned()
4959 }
4960
4961 fn materialization_profile(
4962 &self,
4963 provider: &AgentId,
4964 mode: SessionMode,
4965 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4966 bundle: Option<&ResolvedBundleReceipt>,
4967 context: Option<&ResolvedContextPackReceipt>,
4968 ) -> Result<Option<(
4969 NodeSessionMaterializationProfile,
4970 bool,
4971 Option<BundleProviderLayout>,
4972 )>, NodeFailure> {
4973 let environment = environment_profile
4974 .and_then(|receipt| self.environment_materialization_profile(receipt));
4975 let (bundle, bundle_layout) = match bundle {
4976 Some(receipt) => {
4977 let catalog = self.bundle_catalog.read()
4978 .unwrap_or_else(|poisoned| poisoned.into_inner());
4979 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4980 NodeFailureCode::UnknownBundle,
4981 "session bundle is unavailable on this node",
4982 ))?;
4983 if installed.receipt() != *receipt {
4984 return Err(failure(
4985 NodeFailureCode::BundleBindingMismatch,
4986 "session bundle revision or digest changed",
4987 ));
4988 }
4989 let layout = self.bundle_layout(
4990 provider,
4991 mode,
4992 environment_profile,
4993 installed,
4994 )?;
4995 (Some(installed.clone()), Some(layout))
4996 }
4997 None => (None, None),
4998 };
4999 let has_environment_overlay = environment.is_some() || context.is_some();
5000 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5001 (None, None, None) => None,
5002 (Some(profile), None, None) => Some(Ok(profile)),
5003 (None, Some(bundle), Some(layout)) => {
5004 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5005 }
5006 (Some(profile), Some(bundle), Some(layout)) => {
5007 Some(profile.with_bundle(bundle, layout))
5008 }
5009 _ => {
5010 return Err(failure(
5011 NodeFailureCode::BundleBindingMismatch,
5012 "session bundle provider layout is unavailable",
5013 ));
5014 }
5015 };
5016 let profile = profile
5017 .transpose()
5018 .map_err(|_| failure(
5019 NodeFailureCode::BundleMaterializationFailed,
5020 "session bundle materialization profile is invalid",
5021 ))?;
5022 let profile = match (profile, context) {
5023 (Some(profile), Some(receipt)) => {
5024 let catalog = self.context_catalog
5025 .read()
5026 .unwrap_or_else(|poisoned| poisoned.into_inner());
5027 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5028 NodeFailureCode::UnknownContextPack,
5029 "session context pack is unavailable on this node",
5030 ))?;
5031 if pack.receipt() != receipt {
5032 return Err(failure(
5033 NodeFailureCode::ContextPackMaterializationFailed,
5034 "session context pack identity changed",
5035 ));
5036 }
5037 profile.with_context(pack)
5038 }
5039 (None, Some(receipt)) => {
5040 let catalog = self.context_catalog
5041 .read()
5042 .unwrap_or_else(|poisoned| poisoned.into_inner());
5043 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5044 NodeFailureCode::UnknownContextPack,
5045 "session context pack is unavailable on this node",
5046 ))?;
5047 if pack.receipt() != receipt {
5048 return Err(failure(
5049 NodeFailureCode::ContextPackMaterializationFailed,
5050 "session context pack identity changed",
5051 ));
5052 }
5053 NodeSessionMaterializationProfile::from_context(pack)
5054 }
5055 (Some(profile), None) => Ok(profile),
5056 (None, None) => return Ok(None),
5057 }
5058 .map_err(|_| failure(
5059 NodeFailureCode::ContextPackMaterializationFailed,
5060 "session context pack materialization profile is invalid",
5061 ))?;
5062 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5063 }
5064
5065 fn prepare_session_materialization<'a>(
5066 &'a self,
5067 address: &SessionAddress,
5068 provider: &AgentId,
5069 mode: SessionMode,
5070 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5071 bundle: Option<&ResolvedBundleReceipt>,
5072 context: Option<&ResolvedContextPackReceipt>,
5073 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5074 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5075 let Some((profile, has_environment_overlay, bundle_layout)) =
5076 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5077 else {
5078 return Ok(None);
5079 };
5080 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5081 failure(
5082 NodeFailureCode::BackendOperationFailed,
5083 "session environment materializer is unavailable",
5084 )
5085 })?;
5086 let materialization_failure_code = if context.is_some() {
5087 NodeFailureCode::ContextPackMaterializationFailed
5088 } else if bundle.is_some() {
5089 NodeFailureCode::BundleMaterializationFailed
5090 } else {
5091 NodeFailureCode::BackendOperationFailed
5092 };
5093 let id = MaterializationId::new(format!(
5094 "mat-{}-{}-{}",
5095 self.incarnation_id,
5096 address.session.instance_id.0,
5097 address.session.generation.0,
5098 ))
5099 .map_err(|_| {
5100 failure(
5101 NodeFailureCode::BackendOperationFailed,
5102 "session environment materialization identity failed",
5103 )
5104 })?;
5105 let owner = MaterializationOwner::Session {
5106 incarnation_id: self.incarnation_id,
5107 instance_id: address.session.instance_id,
5108 generation: address.session.generation,
5109 };
5110 let mut ownership = materializer
5111 .begin(
5112 id.clone(),
5113 environment_profile.cloned(),
5114 bundle.cloned(),
5115 context.cloned(),
5116 owner,
5117 managed_lease_id,
5118 &profile,
5119 unix_time_ms(),
5120 )
5121 .map_err(|_| {
5122 failure(
5123 materialization_failure_code,
5124 "session materialization preparation failed",
5125 )
5126 })?;
5127 {
5128 let _transaction = self
5129 .state_transaction
5130 .lock()
5131 .unwrap_or_else(|poisoned| poisoned.into_inner());
5132 let duplicate = self
5133 .materializations
5134 .lock()
5135 .unwrap_or_else(|poisoned| poisoned.into_inner())
5136 .insert(id.clone(), ownership.clone());
5137 if duplicate.is_some() {
5138 return Err(failure(
5139 NodeFailureCode::BackendOperationFailed,
5140 "session environment materialization identity conflict",
5141 ));
5142 }
5143 if let Err(error) = self.persist_state_locked() {
5144 self.materializations
5145 .lock()
5146 .unwrap_or_else(|poisoned| poisoned.into_inner())
5147 .remove(&id);
5148 return Err(persistence_failure(error));
5149 }
5150 }
5151 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5152 Ok(environment) => environment,
5153 Err(_) => {
5154 let transaction = self
5155 .state_transaction
5156 .lock()
5157 .unwrap_or_else(|poisoned| poisoned.into_inner());
5158 self.materializations
5159 .lock()
5160 .unwrap_or_else(|poisoned| poisoned.into_inner())
5161 .insert(id.clone(), ownership.clone());
5162 if self.persist_state_locked().is_err() {
5163 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5164 }
5165 drop(transaction);
5166 if ownership.state() == MaterializationState::CleanupRequired {
5167 let _ = self.cleanup_materialization(&id);
5168 }
5169 return Err(failure(
5170 materialization_failure_code,
5171 "session materialization failed",
5172 ));
5173 }
5174 };
5175 {
5176 let _transaction = self
5177 .state_transaction
5178 .lock()
5179 .unwrap_or_else(|poisoned| poisoned.into_inner());
5180 self.materializations
5181 .lock()
5182 .unwrap_or_else(|poisoned| poisoned.into_inner())
5183 .insert(id.clone(), ownership.clone());
5184 if let Err(error) = self.persist_state_locked() {
5185 drop(_transaction);
5186 let _ = self.cleanup_materialization(&id);
5187 return Err(persistence_failure(error));
5188 }
5189 }
5190 let guard = SessionMaterializationGuard {
5191 shared: self,
5192 id: Some(id),
5193 provider_home: Some(ownership.provider_home().to_path_buf()),
5194 };
5195 let bundle_arguments = match bundle {
5196 Some(receipt) => {
5197 let catalog = self.bundle_catalog.read()
5198 .unwrap_or_else(|poisoned| poisoned.into_inner());
5199 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5200 NodeFailureCode::UnknownBundle,
5201 "session bundle is unavailable on this node",
5202 ))?;
5203 if installed.receipt() != *receipt {
5204 return Err(failure(
5205 NodeFailureCode::BundleBindingMismatch,
5206 "session bundle revision or digest changed",
5207 ));
5208 }
5209 let layout = bundle_layout.ok_or_else(|| failure(
5210 NodeFailureCode::BundleBindingMismatch,
5211 "materialized bundle provider layout is unavailable",
5212 ))?;
5213 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5214 failure(
5215 NodeFailureCode::BundleMaterializationFailed,
5216 "session bundle byte revalidation failed",
5217 )
5218 })?;
5219 Some(bundle_launch_arguments(
5220 layout,
5221 ownership.bundle_root(),
5222 ownership.provider_home(),
5223 ).map_err(|_| failure(
5224 NodeFailureCode::BundleBindingMismatch,
5225 "session bundle launch binding is invalid",
5226 ))?)
5227 }
5228 None => None,
5229 };
5230 if let Some(receipt) = context {
5231 materializer
5232 .revalidate_context(&ownership, receipt)
5233 .map_err(|_| failure(
5234 NodeFailureCode::ContextPackMaterializationFailed,
5235 "session context pack byte revalidation failed",
5236 ))?;
5237 }
5238 let transport = match mode {
5239 SessionMode::Pty => TransportKind::Pty,
5240 SessionMode::Inline => TransportKind::Pipe,
5241 SessionMode::Acp => TransportKind::Acp,
5242 };
5243 let overlay = if let Some(extra_args) = bundle_arguments {
5244 Some(PreparedNativeLaunchOverlay::Instance(
5245 NativeInstanceLaunchOverlay::new(
5246 provider.clone(),
5247 transport,
5248 if has_environment_overlay {
5249 environment
5250 } else {
5251 Vec::new()
5252 },
5253 extra_args,
5254 )
5255 .map_err(|_| {
5256 failure(
5257 NodeFailureCode::BundleBindingMismatch,
5258 "session bundle launch overlay is invalid",
5259 )
5260 })?,
5261 ))
5262 } else if has_environment_overlay {
5263 let context_only = environment_profile.is_none()
5264 && bundle.is_none()
5265 && context.is_some();
5266 let overlay = if context_only {
5267 NativeLaunchEnvironmentOverlay::new_context_root(
5268 provider.clone(),
5269 transport,
5270 environment,
5271 )
5272 .map_err(|_| {
5273 failure(
5274 NodeFailureCode::ContextPackMaterializationFailed,
5275 "context-only launch overlay is invalid",
5276 )
5277 })?
5278 } else {
5279 NativeLaunchEnvironmentOverlay::new(
5280 provider.clone(),
5281 transport,
5282 environment,
5283 )
5284 .map_err(|_| {
5285 failure(
5286 NodeFailureCode::BackendOperationFailed,
5287 "session environment overlay is invalid",
5288 )
5289 })?
5290 };
5291 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5292 } else {
5293 None
5294 };
5295 Ok(Some((overlay, guard)))
5296 }
5297
5298 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5299 let Some(mut ownership) = self
5300 .materializations
5301 .lock()
5302 .unwrap_or_else(|poisoned| poisoned.into_inner())
5303 .get(id)
5304 .cloned()
5305 else {
5306 return Ok(());
5307 };
5308 if ownership.state() != MaterializationState::CleanupRequired {
5309 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5310 failure(
5311 NodeFailureCode::BackendOperationFailed,
5312 "session environment cleanup state is invalid",
5313 )
5314 })?;
5315 }
5316 {
5317 let _transaction = self
5318 .state_transaction
5319 .lock()
5320 .unwrap_or_else(|poisoned| poisoned.into_inner());
5321 let previous = self
5322 .materializations
5323 .lock()
5324 .unwrap_or_else(|poisoned| poisoned.into_inner())
5325 .insert(id.clone(), ownership.clone());
5326 if let Err(error) = self.persist_state_locked() {
5327 let mut materializations = self
5328 .materializations
5329 .lock()
5330 .unwrap_or_else(|poisoned| poisoned.into_inner());
5331 if let Some(previous) = previous {
5332 materializations.insert(id.clone(), previous);
5333 }
5334 return Err(persistence_failure(error));
5335 }
5336 }
5337 let cleanup = self
5338 .session_environment_materializer
5339 .as_ref()
5340 .ok_or(())
5341 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5342 if cleanup.is_err() {
5343 let _ = ownership.mark_recovery_required(unix_time_ms());
5344 self.materializations
5345 .lock()
5346 .unwrap_or_else(|poisoned| poisoned.into_inner())
5347 .insert(id.clone(), ownership);
5348 if self.persist_state().is_err() {
5349 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5350 }
5351 return Err(failure(
5352 NodeFailureCode::BackendOperationFailed,
5353 "session environment cleanup requires recovery",
5354 ));
5355 }
5356 let _transaction = self
5357 .state_transaction
5358 .lock()
5359 .unwrap_or_else(|poisoned| poisoned.into_inner());
5360 self.materializations
5361 .lock()
5362 .unwrap_or_else(|poisoned| poisoned.into_inner())
5363 .remove(id);
5364 if let Err(error) = self.persist_state_locked() {
5365 self.materializations
5366 .lock()
5367 .unwrap_or_else(|poisoned| poisoned.into_inner())
5368 .insert(id.clone(), ownership);
5369 return Err(persistence_failure(error));
5370 }
5371 Ok(())
5372 }
5373
5374 fn resolve_record_materialization(
5375 &self,
5376 record_id: &SessionRecordId,
5377 provider: &AgentId,
5378 mode: SessionMode,
5379 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5380 bundle: Option<&ResolvedBundleReceipt>,
5381 context: Option<&ResolvedContextPackReceipt>,
5382 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5383 let ownership = {
5384 let materializations = self
5385 .materializations
5386 .lock()
5387 .unwrap_or_else(|poisoned| poisoned.into_inner());
5388 let mut matches = materializations.values().filter(|record| {
5389 record.owner()
5390 == &MaterializationOwner::Record {
5391 record_id: record_id.clone(),
5392 }
5393 });
5394 let ownership = matches.next().cloned();
5395 if matches.next().is_some() {
5396 return Err(failure(
5397 NodeFailureCode::BackendOperationFailed,
5398 "managed session has conflicting environment ownership",
5399 ));
5400 }
5401 ownership
5402 };
5403 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5404 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5405 failure(
5406 NodeFailureCode::ContextPackMaterializationFailed,
5407 "session context materializer is unavailable",
5408 )
5409 })?;
5410 let pack = materializer
5411 .revalidate_context(ownership, receipt)
5412 .map_err(|_| {
5413 self.mark_materialization_recovery_required(ownership.id());
5414 failure(
5415 NodeFailureCode::ContextPackMaterializationFailed,
5416 "managed session context pack revalidation failed",
5417 )
5418 })?;
5419 self.context_catalog
5420 .write()
5421 .unwrap_or_else(|poisoned| poisoned.into_inner())
5422 .insert(pack)
5423 .map_err(|_| failure(
5424 NodeFailureCode::ContextPackMaterializationFailed,
5425 "managed session context pack catalog restore failed",
5426 ))?;
5427 }
5428 let materialization = self.materialization_profile(
5429 provider,
5430 mode,
5431 environment_profile,
5432 bundle,
5433 context,
5434 )?;
5435 match (ownership, materialization) {
5436 (None, None) => Ok(None),
5437 (None, Some(_)) | (Some(_), None) => Err(failure(
5438 NodeFailureCode::BackendOperationFailed,
5439 "managed session materialization is unavailable",
5440 )),
5441 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5442 if ownership.environment_profile() != environment_profile
5443 || ownership.bundle() != bundle
5444 || ownership.context() != context
5445 || ownership.state() != MaterializationState::Ready
5446 {
5447 self.mark_materialization_recovery_required(ownership.id());
5448 return Err(failure(
5449 NodeFailureCode::BackendOperationFailed,
5450 "managed session environment ownership requires recovery",
5451 ));
5452 }
5453 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5454 failure(
5455 NodeFailureCode::BackendOperationFailed,
5456 "session environment materializer is unavailable",
5457 )
5458 })?;
5459 if materializer.revalidate(&ownership).is_err() {
5460 self.mark_materialization_recovery_required(ownership.id());
5461 return Err(failure(
5462 NodeFailureCode::BackendOperationFailed,
5463 "managed session environment revalidation failed",
5464 ));
5465 }
5466 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5467 let catalog = self.bundle_catalog.read()
5468 .unwrap_or_else(|poisoned| poisoned.into_inner());
5469 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5470 return Err(failure(
5471 NodeFailureCode::UnknownBundle,
5472 "managed session bundle is unavailable",
5473 ));
5474 };
5475 let layout = bundle_layout.ok_or_else(|| failure(
5476 NodeFailureCode::BundleBindingMismatch,
5477 "record bundle provider layout is unavailable",
5478 ))?;
5479 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5480 drop(catalog);
5481 self.mark_materialization_recovery_required(ownership.id());
5482 return Err(failure(
5483 NodeFailureCode::BundleMaterializationFailed,
5484 "managed session bundle revalidation failed",
5485 ));
5486 }
5487 Some(bundle_launch_arguments(
5488 layout,
5489 ownership.bundle_root(),
5490 ownership.provider_home(),
5491 ).map_err(|_| failure(
5492 NodeFailureCode::BundleBindingMismatch,
5493 "managed session bundle launch binding is invalid",
5494 ))?)
5495 } else {
5496 None
5497 };
5498 if !has_environment_materialization {
5499 let overlay = bundle_arguments.map(|extra_args| {
5500 NativeInstanceLaunchOverlay::new(
5501 provider.clone(),
5502 TransportKind::Pty,
5503 Vec::new(),
5504 extra_args,
5505 )
5506 .map(PreparedNativeLaunchOverlay::Instance)
5507 .map_err(|_| failure(
5508 NodeFailureCode::BundleBindingMismatch,
5509 "managed session bundle launch overlay is invalid",
5510 ))
5511 }).transpose()?;
5512 return Ok(Some((ownership.id().clone(), overlay)));
5513 }
5514 let environment = match materializer.resolve_environment(&ownership, &profile) {
5515 Ok(environment) => environment,
5516 Err(
5517 SessionEnvironmentMaterializeError::SecretUnavailable
5518 | SessionEnvironmentMaterializeError::SecretDenied
5519 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5520 ) => {
5521 return Err(failure(
5522 NodeFailureCode::BackendOperationFailed,
5523 "managed session environment resolution failed",
5524 ));
5525 }
5526 Err(
5527 SessionEnvironmentMaterializeError::InvalidRoot
5528 | SessionEnvironmentMaterializeError::OwnershipMismatch
5529 | SessionEnvironmentMaterializeError::Filesystem(_)
5530 | SessionEnvironmentMaterializeError::Record(_),
5531 ) => {
5532 self.mark_materialization_recovery_required(ownership.id());
5533 return Err(failure(
5534 NodeFailureCode::BackendOperationFailed,
5535 "managed session environment resolution failed",
5536 ));
5537 }
5538 };
5539 let transport = match mode {
5540 SessionMode::Pty => TransportKind::Pty,
5541 SessionMode::Inline => TransportKind::Pipe,
5542 SessionMode::Acp => TransportKind::Acp,
5543 };
5544 let overlay = match bundle_arguments {
5545 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5546 NativeInstanceLaunchOverlay::new(
5547 provider.clone(),
5548 transport,
5549 environment,
5550 extra_args,
5551 )
5552 .map_err(|_| failure(
5553 NodeFailureCode::BundleBindingMismatch,
5554 "managed session bundle launch overlay is invalid",
5555 ))?,
5556 ),
5557 None => PreparedNativeLaunchOverlay::Environment(
5558 NativeLaunchEnvironmentOverlay::new(
5559 provider.clone(),
5560 transport,
5561 environment,
5562 )
5563 .map_err(|_| {
5564 failure(
5565 NodeFailureCode::BackendOperationFailed,
5566 "managed session environment overlay is invalid",
5567 )
5568 })?,
5569 ),
5570 };
5571 Ok(Some((ownership.id().clone(), Some(overlay))))
5572 }
5573 }
5574 }
5575
5576 fn destroy_record_materialization(
5577 &self,
5578 record_id: &SessionRecordId,
5579 ) -> Result<bool, NodeFailure> {
5580 let ids = self
5581 .materializations
5582 .lock()
5583 .unwrap_or_else(|poisoned| poisoned.into_inner())
5584 .values()
5585 .filter_map(|record| {
5586 (record.owner()
5587 == &MaterializationOwner::Record {
5588 record_id: record_id.clone(),
5589 })
5590 .then(|| record.id().clone())
5591 })
5592 .collect::<Vec<_>>();
5593 if ids.len() > 1 {
5594 return Err(failure(
5595 NodeFailureCode::BackendOperationFailed,
5596 "managed session has conflicting environment ownership",
5597 ));
5598 }
5599 let Some(id) = ids.first().cloned() else {
5600 return Ok(false);
5601 };
5602 let (terminal_record, mut cleanup_ownership) = {
5603 let _transaction = self
5604 .state_transaction
5605 .lock()
5606 .unwrap_or_else(|poisoned| poisoned.into_inner());
5607 let original_record = self.record(record_id)?;
5608 let mut terminal_record = original_record.clone();
5609 terminal_record.provider_session = None;
5610 terminal_record.active_session = None;
5611 terminal_record.state = ManagedSessionState::Unavailable;
5612 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5613 terminal_record.updated_at_unix_ms = unix_time_ms();
5614 let original_ownership = self
5615 .materializations
5616 .lock()
5617 .unwrap_or_else(|poisoned| poisoned.into_inner())
5618 .get(&id)
5619 .cloned()
5620 .ok_or_else(|| {
5621 failure(
5622 NodeFailureCode::BackendOperationFailed,
5623 "managed session environment ownership disappeared during cleanup",
5624 )
5625 })?;
5626 let mut cleanup_ownership = original_ownership.clone();
5627 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5628 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5629 failure(
5630 NodeFailureCode::BackendOperationFailed,
5631 "managed session environment cleanup state is invalid",
5632 )
5633 })?;
5634 }
5635 self.session_records
5636 .lock()
5637 .unwrap_or_else(|poisoned| poisoned.into_inner())
5638 .records
5639 .insert(record_id.clone(), terminal_record.clone());
5640 self.materializations
5641 .lock()
5642 .unwrap_or_else(|poisoned| poisoned.into_inner())
5643 .insert(id.clone(), cleanup_ownership.clone());
5644 if let Err(error) = self.persist_state_locked() {
5645 self.session_records
5646 .lock()
5647 .unwrap_or_else(|poisoned| poisoned.into_inner())
5648 .records
5649 .insert(record_id.clone(), original_record);
5650 self.materializations
5651 .lock()
5652 .unwrap_or_else(|poisoned| poisoned.into_inner())
5653 .insert(id.clone(), original_ownership);
5654 return Err(persistence_failure(error));
5655 }
5656 (terminal_record, cleanup_ownership)
5657 };
5658 let cleanup = self
5659 .session_environment_materializer
5660 .as_ref()
5661 .ok_or(())
5662 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5663 if cleanup.is_err() {
5664 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5665 self.materializations
5666 .lock()
5667 .unwrap_or_else(|poisoned| poisoned.into_inner())
5668 .insert(id.clone(), cleanup_ownership);
5669 if self.persist_state().is_err() {
5670 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5671 }
5672 return Err(failure(
5673 NodeFailureCode::BackendOperationFailed,
5674 "managed session environment cleanup requires recovery",
5675 ));
5676 }
5677 let _transaction = self
5678 .state_transaction
5679 .lock()
5680 .unwrap_or_else(|poisoned| poisoned.into_inner());
5681 self.remove_record_memory(record_id);
5682 self.materializations
5683 .lock()
5684 .unwrap_or_else(|poisoned| poisoned.into_inner())
5685 .remove(&id);
5686 if let Err(error) = self.persist_state_locked() {
5687 self.session_records
5688 .lock()
5689 .unwrap_or_else(|poisoned| poisoned.into_inner())
5690 .records
5691 .insert(record_id.clone(), terminal_record);
5692 self.materializations
5693 .lock()
5694 .unwrap_or_else(|poisoned| poisoned.into_inner())
5695 .insert(id, cleanup_ownership);
5696 return Err(persistence_failure(error));
5697 }
5698 Ok(true)
5699 }
5700
5701 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5702 let changed = {
5703 let mut materializations = self
5704 .materializations
5705 .lock()
5706 .unwrap_or_else(|poisoned| poisoned.into_inner());
5707 let Some(ownership) = materializations.get_mut(id) else {
5708 return;
5709 };
5710 ownership.mark_recovery_required(unix_time_ms()).is_ok()
5711 };
5712 if changed && self.persist_state().is_err() {
5713 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5714 }
5715 }
5716
5717 fn reconcile_materializations(&self) {
5718 let records = self
5719 .materializations
5720 .lock()
5721 .unwrap_or_else(|poisoned| poisoned.into_inner())
5722 .values()
5723 .cloned()
5724 .collect::<Vec<_>>();
5725 for mut ownership in records {
5726 let result = match (ownership.state(), ownership.owner()) {
5727 (MaterializationState::Preparing, _)
5728 | (MaterializationState::CleanupRequired, _) => {
5729 self.cleanup_materialization(ownership.id())
5730 }
5731 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5732 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5733 self.materializations
5734 .lock()
5735 .unwrap_or_else(|poisoned| poisoned.into_inner())
5736 .insert(ownership.id().clone(), ownership.clone());
5737 self.persist_state().map_err(persistence_failure)
5738 } else {
5739 Err(failure(
5740 NodeFailureCode::BackendOperationFailed,
5741 "session environment reconciliation failed",
5742 ))
5743 }
5744 }
5745 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5746 .session_environment_materializer
5747 .as_ref()
5748 .ok_or_else(|| {
5749 failure(
5750 NodeFailureCode::BackendOperationFailed,
5751 "session environment materializer is unavailable",
5752 )
5753 })
5754 .and_then(|materializer| {
5755 materializer.revalidate(&ownership).map_err(|_| failure(
5756 NodeFailureCode::BackendOperationFailed,
5757 "session materialization revalidation failed",
5758 ))?;
5759 let record = self.record(record_id)?;
5760 if record.environment_profile.as_ref() != ownership.environment_profile()
5761 || record.bundle.as_ref() != ownership.bundle()
5762 || record.context.as_ref() != ownership.context()
5763 {
5764 return Err(failure(
5765 NodeFailureCode::BackendOperationFailed,
5766 "session materialization ownership changed after restart",
5767 ));
5768 }
5769 if let Some(receipt) = ownership.bundle() {
5770 let catalog = self.bundle_catalog.read()
5771 .unwrap_or_else(|poisoned| poisoned.into_inner());
5772 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5773 NodeFailureCode::UnknownBundle,
5774 "session bundle is unavailable after restart",
5775 ))?;
5776 if bundle.receipt() != *receipt {
5777 return Err(failure(
5778 NodeFailureCode::BundleBindingMismatch,
5779 "session bundle changed after restart",
5780 ));
5781 }
5782 let layout = self.bundle_layout(
5783 &record.provider,
5784 record.mode,
5785 record.environment_profile.as_ref(),
5786 bundle,
5787 )?;
5788 materializer
5789 .revalidate_bundle(&ownership, bundle, layout)
5790 .map_err(|_| failure(
5791 NodeFailureCode::BundleMaterializationFailed,
5792 "session bundle revalidation failed",
5793 ))?;
5794 }
5795 if let Some(receipt) = ownership.context() {
5796 let pack = materializer
5797 .revalidate_context(&ownership, receipt)
5798 .map_err(|_| {
5799 failure(
5800 NodeFailureCode::ContextPackMaterializationFailed,
5801 "session context revalidation failed",
5802 )
5803 })?;
5804 self.context_catalog
5805 .write()
5806 .unwrap_or_else(|poisoned| poisoned.into_inner())
5807 .insert(pack)
5808 .map_err(|_| {
5809 failure(
5810 NodeFailureCode::ContextPackMaterializationFailed,
5811 "session context catalog restore failed",
5812 )
5813 })?;
5814 }
5815 Ok(())
5816 }),
5817 (MaterializationState::RecoveryRequired, _) => Ok(()),
5818 };
5819 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5820 let _ = ownership.mark_recovery_required(unix_time_ms());
5821 self.materializations
5822 .lock()
5823 .unwrap_or_else(|poisoned| poisoned.into_inner())
5824 .insert(ownership.id().clone(), ownership);
5825 if self.persist_state().is_err() {
5826 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5827 }
5828 }
5829 }
5830 let materialized_profiles = self
5831 .environment_materialization_profiles
5832 .read()
5833 .unwrap_or_else(|poisoned| poisoned.into_inner())
5834 .keys()
5835 .cloned()
5836 .collect::<Vec<_>>();
5837 let ownership = self
5838 .materializations
5839 .lock()
5840 .unwrap_or_else(|poisoned| poisoned.into_inner())
5841 .values()
5842 .cloned()
5843 .collect::<Vec<_>>();
5844 let changed = {
5845 let mut session_records = self
5846 .session_records
5847 .lock()
5848 .unwrap_or_else(|poisoned| poisoned.into_inner());
5849 let mut changed = false;
5850 for record in session_records.records.values_mut() {
5851 let environment_profile = record.environment_profile.as_ref();
5852 if environment_profile.is_none()
5853 && record.bundle.is_none()
5854 && record.context.is_none()
5855 {
5856 continue;
5857 }
5858 if record.bundle.is_none()
5859 && environment_profile.is_some_and(|profile| {
5860 !materialized_profiles.contains(&profile.profile_id)
5861 })
5862 {
5863 continue;
5864 }
5865 let has_exact_ownership = ownership.iter().any(|candidate| {
5866 candidate.state() == MaterializationState::Ready
5867 && candidate.environment_profile() == environment_profile
5868 && candidate.bundle() == record.bundle.as_ref()
5869 && candidate.context() == record.context.as_ref()
5870 && candidate.owner()
5871 == &MaterializationOwner::Record {
5872 record_id: record.record_id.clone(),
5873 }
5874 });
5875 if !has_exact_ownership {
5876 record.provider_session = None;
5877 record.active_session = None;
5878 record.state = ManagedSessionState::Unavailable;
5879 record.last_error = Some(if record.bundle.is_some() {
5880 "bundle-unavailable".to_owned()
5881 } else if record.context.is_some() {
5882 "context-unavailable".to_owned()
5883 } else {
5884 "environment-profile-unavailable".to_owned()
5885 });
5886 record.updated_at_unix_ms = unix_time_ms();
5887 changed = true;
5888 }
5889 }
5890 changed
5891 };
5892 if changed && self.persist_state().is_err() {
5893 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5894 }
5895 }
5896
5897 fn replay_spawn_spec(
5898 &self,
5899 spec: &SpawnSpec,
5900 now: Instant,
5901 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5902 let mut cache = self
5903 .spawn_idempotency
5904 .lock()
5905 .unwrap_or_else(|poisoned| poisoned.into_inner());
5906 cache.entries.retain(|_, entry| entry.expires_at > now);
5907 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5908 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5909 return Err(failure(
5910 NodeFailureCode::SpawnIdempotencyConflict,
5911 "spawn idempotency key was reused across spawn kinds",
5912 ));
5913 };
5914 if existing != spec {
5915 return Err(failure(
5916 NodeFailureCode::SpawnIdempotencyConflict,
5917 "spawn idempotency key was reused with a different specification",
5918 ));
5919 }
5920 return Ok(Some(result.clone()));
5921 }
5922 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5923 return Err(failure(
5924 NodeFailureCode::SpawnIdempotencyCapacity,
5925 "spawn idempotency cache reached its bounded capacity",
5926 ));
5927 }
5928 Ok(None)
5929 }
5930
5931 fn remember_spawn_spec(
5932 &self,
5933 spec: SpawnSpec,
5934 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5935 accepted_at: Instant,
5936 ) {
5937 self
5938 .spawn_idempotency
5939 .lock()
5940 .unwrap_or_else(|poisoned| poisoned.into_inner())
5941 .entries
5942 .insert(
5943 spec.idempotency_key.clone(),
5944 SpawnIdempotencyEntry {
5945 value: SpawnIdempotencyValue::Standard { spec, result },
5946 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5947 },
5948 );
5949 }
5950
5951 fn claim_harness_mcp_spawn(
5952 &self,
5953 _reservation_id: &HarnessMcpReservationId,
5954 _activation_digest: &HarnessMcpActivationDigest,
5955 spec: &SpawnSpec,
5956 now: Instant,
5957 ) -> Result<(), NodeFailure> {
5958 let mut cache = self.spawn_idempotency.lock()
5959 .unwrap_or_else(|poisoned| poisoned.into_inner());
5960 cache.entries.retain(|_, entry| entry.expires_at > now);
5961 if cache.entries.contains_key(&spec.idempotency_key) {
5962 return Err(failure(
5963 NodeFailureCode::SpawnIdempotencyConflict,
5964 "spawn idempotency key was reused across spawn kinds",
5965 ));
5966 }
5967 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5968 return Err(failure(
5969 NodeFailureCode::SpawnIdempotencyCapacity,
5970 "spawn idempotency cache reached its bounded capacity",
5971 ));
5972 }
5973 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5974 value: SpawnIdempotencyValue::HarnessMcp,
5975 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5976 });
5977 Ok(())
5978 }
5979
5980 fn replay_managed_spawn(
5981 &self,
5982 request: &ManagedWorktreeSpawnRequest,
5983 now: Instant,
5984 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5985 let mut cache = self.spawn_idempotency.lock()
5986 .unwrap_or_else(|poisoned| poisoned.into_inner());
5987 cache.entries.retain(|_, entry| entry.expires_at > now);
5988 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5989 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5990 return Err(failure(
5991 NodeFailureCode::SpawnIdempotencyConflict,
5992 "spawn idempotency key was reused across spawn kinds",
5993 ));
5994 };
5995 if existing != request {
5996 return Err(failure(
5997 NodeFailureCode::SpawnIdempotencyConflict,
5998 "spawn idempotency key was reused with a different managed specification",
5999 ));
6000 }
6001 return Ok(Some(result.clone()));
6002 }
6003 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6004 return Err(failure(
6005 NodeFailureCode::SpawnIdempotencyCapacity,
6006 "spawn idempotency cache reached its bounded capacity",
6007 ));
6008 }
6009 Ok(None)
6010 }
6011
6012 fn remember_managed_spawn(
6013 &self,
6014 request: ManagedWorktreeSpawnRequest,
6015 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6016 accepted_at: Instant,
6017 ) {
6018 self.spawn_idempotency.lock()
6019 .unwrap_or_else(|poisoned| poisoned.into_inner())
6020 .entries
6021 .insert(
6022 request.spawn_spec.idempotency_key.clone(),
6023 SpawnIdempotencyEntry {
6024 value: SpawnIdempotencyValue::Managed { request, result },
6025 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6026 },
6027 );
6028 }
6029
6030 fn remember_managed_spawn_attempt(
6031 &self,
6032 durable_v2_key: Option<&SpawnIdempotencyKey>,
6033 request: ManagedWorktreeSpawnRequest,
6034 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6035 accepted_at: Instant,
6036 ) {
6037 if durable_v2_key.is_none() {
6038 self.remember_managed_spawn(request, result, accepted_at);
6039 }
6040 }
6041
6042 fn acquire_durable_managed_spawn_v2(
6043 &self,
6044 request: &ManagedWorktreeSpawnRequestV2,
6045 request_digest: String,
6046 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6047 if self.state_path.is_none() {
6048 return Err(failure(
6049 NodeFailureCode::BackendOperationFailed,
6050 "managed worktree V2 requires durable node state",
6051 ));
6052 }
6053 let _transaction = self.state_transaction
6054 .lock()
6055 .unwrap_or_else(|poisoned| poisoned.into_inner());
6056 let key = request.spawn_spec.idempotency_key.clone();
6057 let mut previous = None;
6058 {
6059 let mut replays = self.managed_spawn_replays
6060 .lock()
6061 .unwrap_or_else(|poisoned| poisoned.into_inner());
6062 if let Some(record) = replays.get_mut(&key) {
6063 if record.request_digest != request_digest {
6064 return Err(failure(
6065 NodeFailureCode::SpawnIdempotencyConflict,
6066 "managed worktree V2 idempotency key was reused with a different request",
6067 ));
6068 }
6069 match &record.state {
6070 ManagedWorktreeSpawnReplayStateV10::Pending {
6071 owner_incarnation_id,
6072 lease_id: None,
6073 } if owner_incarnation_id != &self.incarnation_id => {
6074 previous = Some(record.clone());
6075 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6076 owner_incarnation_id: self.incarnation_id,
6077 lease_id: None,
6078 };
6079 record.updated_at_unix_ms = unix_time_ms();
6080 }
6081 ManagedWorktreeSpawnReplayStateV10::Pending {
6082 lease_id: None,
6083 ..
6084 } => {
6085 return Err(failure(
6086 NodeFailureCode::BackendBusy,
6087 "managed worktree V2 reservation is already active",
6088 ));
6089 }
6090 ManagedWorktreeSpawnReplayStateV10::Pending {
6091 lease_id: Some(_),
6092 ..
6093 } => {
6094 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6095 }
6096 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6097 return Ok(DurableManagedSpawnReplayDecision::Committed(
6098 receipt.clone(),
6099 ));
6100 }
6101 }
6102 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6103 return Err(failure(
6104 NodeFailureCode::SpawnIdempotencyCapacity,
6105 "durable managed worktree replay ledger reached its bounded capacity",
6106 ));
6107 } else {
6108 let now = unix_time_ms();
6109 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6110 idempotency_key: key.clone(),
6111 request_digest,
6112 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6113 profile_id: request.worktree_profile_id.clone(),
6114 expected_profile_revision: request.expected_profile_revision.clone(),
6115 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6116 owner_incarnation_id: self.incarnation_id,
6117 lease_id: None,
6118 },
6119 created_at_unix_ms: now,
6120 updated_at_unix_ms: now,
6121 });
6122 }
6123 }
6124 if let Err(error) = self.persist_state_locked() {
6125 let mut replays = self.managed_spawn_replays
6126 .lock()
6127 .unwrap_or_else(|poisoned| poisoned.into_inner());
6128 match previous {
6129 Some(previous) => {
6130 replays.insert(key, previous);
6131 }
6132 None => {
6133 replays.remove(&key);
6134 }
6135 }
6136 return Err(persistence_failure(error));
6137 }
6138 Ok(DurableManagedSpawnReplayDecision::Reserved)
6139 }
6140
6141 fn link_durable_managed_spawn_v2_lease_locked(
6142 &self,
6143 key: &SpawnIdempotencyKey,
6144 lease_id: &ManagedWorktreeLeaseId,
6145 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6146 let mut replays = self.managed_spawn_replays
6147 .lock()
6148 .unwrap_or_else(|poisoned| poisoned.into_inner());
6149 let record = replays.get_mut(key).ok_or_else(|| {
6150 failure(
6151 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6152 "managed worktree V2 reservation disappeared before allocation",
6153 )
6154 })?;
6155 let previous = record.clone();
6156 match &mut record.state {
6157 ManagedWorktreeSpawnReplayStateV10::Pending {
6158 lease_id: current,
6159 ..
6160 }
6161 if current.is_none() =>
6162 {
6163 *current = Some(lease_id.clone());
6164 record.updated_at_unix_ms = unix_time_ms();
6165 Ok(previous)
6166 }
6167 _ => Err(failure(
6168 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6169 "managed worktree V2 reservation is not allocation-ready",
6170 )),
6171 }
6172 }
6173
6174 fn restore_durable_managed_spawn_v2_locked(
6175 &self,
6176 key: &SpawnIdempotencyKey,
6177 previous: ManagedWorktreeSpawnReplayRecordV10,
6178 ) {
6179 self.managed_spawn_replays
6180 .lock()
6181 .unwrap_or_else(|poisoned| poisoned.into_inner())
6182 .insert(key.clone(), previous);
6183 }
6184
6185 fn commit_durable_managed_spawn_v2(
6186 &self,
6187 request: &ManagedWorktreeSpawnRequestV2,
6188 receipt: ManagedWorktreeSpawnReceipt,
6189 ) -> Result<(), NodeFailure> {
6190 let _transaction = self.state_transaction
6191 .lock()
6192 .unwrap_or_else(|poisoned| poisoned.into_inner());
6193 let key = &request.spawn_spec.idempotency_key;
6194 let previous = {
6195 let mut replays = self.managed_spawn_replays
6196 .lock()
6197 .unwrap_or_else(|poisoned| poisoned.into_inner());
6198 let record = replays.get_mut(key).ok_or_else(|| {
6199 failure(
6200 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6201 "managed worktree V2 reservation disappeared before commit",
6202 )
6203 })?;
6204 let previous = record.clone();
6205 match &record.state {
6206 ManagedWorktreeSpawnReplayStateV10::Pending {
6207 lease_id: Some(lease_id),
6208 ..
6209 } if lease_id == &receipt.lease.lease_id => {}
6210 _ => {
6211 return Err(failure(
6212 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6213 "managed worktree V2 reservation is not linked to the spawned lease",
6214 ));
6215 }
6216 }
6217 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6218 record.updated_at_unix_ms = unix_time_ms();
6219 previous
6220 };
6221 if let Err(error) = self.persist_state_locked() {
6222 self.restore_durable_managed_spawn_v2_locked(key, previous);
6223 return Err(persistence_failure(error));
6224 }
6225 Ok(())
6226 }
6227
6228 fn remove_unallocated_durable_managed_spawn_v2(
6229 &self,
6230 request: &ManagedWorktreeSpawnRequestV2,
6231 ) -> Result<(), NodeFailure> {
6232 let _transaction = self.state_transaction
6233 .lock()
6234 .unwrap_or_else(|poisoned| poisoned.into_inner());
6235 let key = &request.spawn_spec.idempotency_key;
6236 let removed = {
6237 let mut replays = self.managed_spawn_replays
6238 .lock()
6239 .unwrap_or_else(|poisoned| poisoned.into_inner());
6240 match replays.get(key).map(|record| &record.state) {
6241 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6242 lease_id: None,
6243 ..
6244 }) => {
6245 replays.remove(key)
6246 }
6247 _ => None,
6248 }
6249 };
6250 let Some(removed) = removed else {
6251 return Ok(());
6252 };
6253 if let Err(error) = self.persist_state_locked() {
6254 self.managed_spawn_replays
6255 .lock()
6256 .unwrap_or_else(|poisoned| poisoned.into_inner())
6257 .insert(key.clone(), removed);
6258 return Err(persistence_failure(error));
6259 }
6260 Ok(())
6261 }
6262
6263 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6264 if spec.target.node_id != self.node_id {
6265 return Err(failure(
6266 NodeFailureCode::SpawnTargetMismatch,
6267 "spawn target does not match this node",
6268 ));
6269 }
6270 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6271 failure(
6272 NodeFailureCode::UnknownSpawnProfile,
6273 "spawn profile is unavailable on this node",
6274 )
6275 })?;
6276 let resolved = spec.resolve(defaults).map_err(|error| match error {
6277 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6278 NodeFailureCode::SpawnProfileRevisionMismatch,
6279 "spawn profile revision does not match the loaded profile",
6280 ),
6281 _ => failure(
6282 NodeFailureCode::InvalidRequest,
6283 "spawn specification could not be resolved",
6284 ),
6285 })?;
6286 if resolved.target.worktree_id.is_some() {
6287 self.require_worktree_service(&resolved.target.workspace_id)?;
6288 }
6289 let environment_profile = self.resolve_environment_profile(&resolved)?;
6290 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6291 self.resolve_context(&resolved)?;
6292 Ok(resolved)
6293 }
6294
6295 async fn resolve_spawn_workspace(
6296 &self,
6297 resolved: &ResolvedSpawnSpec,
6298 deadline: Instant,
6299 ) -> Result<WorkspaceId, NodeFailure> {
6300 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6301 return Ok(resolved.target.workspace_id.clone());
6302 };
6303 if self.managed_worktrees.lock()
6304 .unwrap_or_else(|poisoned| poisoned.into_inner())
6305 .lease_for_workspace(worktree_id)
6306 .is_some()
6307 {
6308 return Err(failure(
6309 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6310 "managed worktree targets may only be spawned through their lease request",
6311 ));
6312 }
6313 self.require_worktree_service(&resolved.target.workspace_id)?;
6314 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6315 let selected_root = self.workspace_root(worktree_id)?;
6316 let remaining = spawn_deadline_remaining(deadline)?;
6317 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6318 .await
6319 .map_err(|_| {
6320 failure(
6321 NodeFailureCode::SpawnDeadlineExceeded,
6322 "Git worktree selection exceeded the spawn deadline",
6323 )
6324 })?
6325 .map_err(git_worktree_failure)?;
6326 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6327 Ok(worktree_id.clone())
6328 }
6329
6330 async fn spawn_from_spec(
6331 &self,
6332 spec: SpawnSpec,
6333 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6334 self.spawn_from_spec_with_proxy(spec, None).await
6335 }
6336
6337 async fn spawn_from_spec_with_proxy(
6338 &self,
6339 spec: SpawnSpec,
6340 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6341 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6342 let accepted_at = Instant::now();
6343 if harness_mcp.is_none() {
6344 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6345 return replayed;
6346 }
6347 }
6348 let resolved = self.resolve_spawn_spec(&spec)?;
6349 let environment_profile = self.resolve_environment_profile(&resolved)?;
6350 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6351 let context = self.resolve_context(&resolved)?;
6352 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6353 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6354 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6355 let spawn_workspace_id = match spawn_workspace_id {
6356 Ok(workspace_id) => workspace_id,
6357 Err(error) => {
6358 let result = Err(error);
6359 if harness_mcp.is_none() {
6360 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6361 }
6362 return result;
6363 }
6364 };
6365 if self.managed_worktrees.lock()
6366 .unwrap_or_else(|poisoned| poisoned.into_inner())
6367 .lease_for_workspace(&spawn_workspace_id)
6368 .is_some()
6369 {
6370 let result = Err(failure(
6371 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6372 "managed worktree targets may only be spawned through their lease request",
6373 ));
6374 if harness_mcp.is_none() {
6375 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6376 }
6377 return result;
6378 }
6379 let result = self
6380 .spawn_session_with_deadline(
6381 spawn_workspace_id,
6382 resolved.provider.clone(),
6383 resolved.mode,
6384 resolved.terminal_size,
6385 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6386 environment_profile.clone(),
6387 bundle.clone(),
6388 context.clone(),
6389 None,
6390 None,
6391 SpawnRecordPolicy::Always,
6392 Some(deadline),
6393 &required_capabilities,
6394 harness_mcp,
6395 resolved.approval_level,
6396 resolved.network_allowlist.clone(),
6397 resolved.browser_profile_id.clone(),
6398 )
6399 .await
6400 .map(|(session, _runtime_policy)| {
6401 resolved.receipt_with_materialization(
6402 self.incarnation_id,
6403 session,
6404 environment_profile,
6405 bundle,
6406 context,
6407 )
6408 });
6409 if harness_mcp.is_none() {
6410 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6411 }
6412 result
6413 }
6414
6415 async fn spawn_from_spec_with_harness_mcp(
6416 &self,
6417 reservation_id: HarnessMcpReservationId,
6418 activation_digest: HarnessMcpActivationDigest,
6419 spec: SpawnSpec,
6420 deadline_unix_ms: u64,
6421 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6422 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6423 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6424 })?;
6425 if let Some(receipt) = registry
6426 .replay_spawn(&reservation_id, &activation_digest, &spec)
6427 .map_err(harness_mcp_failure)?
6428 {
6429 return Ok(receipt);
6430 }
6431 let resolved = self.resolve_spawn_spec(&spec)?;
6432 let prepared = registry
6433 .prepare_spawn(
6434 &reservation_id,
6435 &activation_digest,
6436 &spec,
6437 deadline_unix_ms,
6438 resolved.provider.clone(),
6439 )
6440 .map_err(harness_mcp_failure)?;
6441 self.claim_harness_mcp_spawn(
6442 &reservation_id,
6443 &activation_digest,
6444 &spec,
6445 Instant::now(),
6446 )?;
6447 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6448 let mut receipt = match spawn {
6449 Ok(receipt) => receipt,
6450 Err(error) => {
6451 let _ = registry.abort(&reservation_id, &activation_digest);
6452 return Err(error);
6453 }
6454 };
6455 let record_id = self
6456 .session_bindings
6457 .lock()
6458 .unwrap_or_else(|poisoned| poisoned.into_inner())
6459 .get(&receipt.session.session.instance_id)
6460 .and_then(|binding| binding.record_id.clone());
6461 let record_id = match record_id {
6462 Some(record_id) => record_id,
6463 None => {
6464 let rollback = self.remove_session(&receipt.session).await;
6465 let _ = registry.abort(&reservation_id, &activation_digest);
6466 if let Err(rollback_error) = rollback {
6467 return Err(rollback_error);
6468 }
6469 return Err(failure(
6470 NodeFailureCode::BindingMismatch,
6471 "harness MCP spawn did not retain its exact managed record",
6472 ));
6473 }
6474 };
6475 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6476 reservation_id: reservation_id.clone(),
6477 activation_digest: activation_digest.clone(),
6478 });
6479 if let Err(error) = registry.mark_spawned(
6480 &prepared,
6481 receipt.session.clone(),
6482 record_id,
6483 receipt.clone(),
6484 ) {
6485 let rollback = self.remove_session(&receipt.session).await;
6486 let _ = registry.abort(&reservation_id, &activation_digest);
6487 if let Err(rollback_error) = rollback {
6488 return Err(rollback_error);
6489 }
6490 return Err(harness_mcp_failure(error));
6491 }
6492 Ok(receipt)
6493 }
6494
6495 async fn spawn_managed_worktree(
6496 &self,
6497 request: ManagedWorktreeSpawnRequest,
6498 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6499 self.spawn_managed_worktree_inner(request, None).await
6500 }
6501
6502 async fn spawn_managed_worktree_inner(
6503 &self,
6504 request: ManagedWorktreeSpawnRequest,
6505 durable_v2_key: Option<&SpawnIdempotencyKey>,
6506 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6507 let accepted_at = Instant::now();
6508 if durable_v2_key.is_none() {
6509 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6510 return replayed;
6511 }
6512 }
6513 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6514 if request.spawn_spec.target.worktree_id.is_some() {
6515 let result = Err(failure(
6516 NodeFailureCode::InvalidRequest,
6517 "managed worktree spawn must not select a caller-provided worktree",
6518 ));
6519 self.remember_managed_spawn_attempt(
6520 durable_v2_key,
6521 request,
6522 result.clone(),
6523 accepted_at,
6524 );
6525 return result;
6526 }
6527 let environment_profile = self.resolve_environment_profile(&resolved)?;
6528 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6529 let context = self.resolve_context(&resolved)?;
6530 let source_workspace_id = resolved.target.workspace_id.clone();
6531 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6532 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6533 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6534 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6535 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6536 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6537 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6538 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6539 };
6540 let runtime_policy = timeout(
6541 spawn_deadline_remaining(deadline)?,
6542 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6543 ).await.map_err(|_| failure(
6544 NodeFailureCode::SpawnDeadlineExceeded,
6545 "provider admission exceeded the managed spawn deadline",
6546 ))??;
6547 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6548 return Err(failure(
6549 NodeFailureCode::UnsupportedSpawnCapability,
6550 "provider runtime does not admit a required spawn capability",
6551 ));
6552 }
6553 self.ensure_binding_capacity()?;
6554 let source_root = self.workspace_root(&source_workspace_id)?;
6555 profile.validate_for_workspace(&source_root)
6556 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6557 let base_timeout_ms = u64::try_from(
6558 spawn_deadline_remaining(deadline)?.as_millis(),
6559 ).unwrap_or(u64::MAX).max(1);
6560 let base_commit = resolve_base_commit_with_timeout(
6561 &source_root,
6562 profile.base(),
6563 base_timeout_ms,
6564 ).await.map_err(|error| {
6565 if Instant::now() >= deadline || error.message.contains("timed out") {
6566 failure(
6567 NodeFailureCode::SpawnDeadlineExceeded,
6568 "managed worktree base resolution exceeded the spawn deadline",
6569 )
6570 } else {
6571 managed_git_worktree_failure(error)
6572 }
6573 })?;
6574
6575 let lease = {
6576 let _transaction = self.state_transaction.lock()
6577 .unwrap_or_else(|poisoned| poisoned.into_inner());
6578 let previous = self.managed_worktrees.lock()
6579 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6580 let lease = self.managed_worktrees.lock()
6581 .unwrap_or_else(|poisoned| poisoned.into_inner())
6582 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6583 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6584 let previous_replay = match durable_v2_key
6585 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6586 .transpose()
6587 {
6588 Ok(previous) => previous,
6589 Err(error) => {
6590 *self.managed_worktrees.lock()
6591 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6592 return Err(error);
6593 }
6594 };
6595 if let Err(error) = self.persist_state_locked() {
6596 *self.managed_worktrees.lock()
6597 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6598 if let (Some(key), Some(previous_replay)) =
6599 (durable_v2_key, previous_replay)
6600 {
6601 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6602 }
6603 return Err(persistence_failure(error));
6604 }
6605 lease
6606 };
6607 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6608 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6609 self.managed_worktrees.lock()
6610 .unwrap_or_else(|poisoned| poisoned.into_inner())
6611 .remove_unmutated(&lease.lease_id);
6612 if self.persist_state().is_err() {
6613 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6614 }
6615 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6616 self.remember_managed_spawn_attempt(
6617 durable_v2_key,
6618 request,
6619 result.clone(),
6620 accepted_at,
6621 );
6622 return result;
6623 }
6624 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6625 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6626 Err(error) => {
6627 self.reconcile_failed_allocation(&lease, &source_root).await;
6628 let result = Err(error);
6629 self.remember_managed_spawn_attempt(
6630 durable_v2_key,
6631 request,
6632 result.clone(),
6633 accepted_at,
6634 );
6635 return result;
6636 }
6637 };
6638 let created = match create_git_worktree_with_timeout(
6639 &source_root,
6640 &lease.target_root,
6641 &lease.branch,
6642 Some(&base_commit),
6643 mutation_timeout_ms,
6644 ).await {
6645 Ok(created) => created,
6646 Err(error) => {
6647 self.reconcile_failed_allocation(&lease, &source_root).await;
6648 let result = Err(if Instant::now() >= deadline
6649 || error.message.contains("timed out")
6650 || error.message.contains("deadline")
6651 {
6652 failure(
6653 NodeFailureCode::SpawnDeadlineExceeded,
6654 "managed worktree creation exceeded the spawn deadline",
6655 )
6656 } else {
6657 managed_git_worktree_failure(error)
6658 });
6659 self.remember_managed_spawn_attempt(
6660 durable_v2_key,
6661 request,
6662 result.clone(),
6663 accepted_at,
6664 );
6665 return result;
6666 }
6667 };
6668 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6669 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6670 let result = Err(failure(
6671 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6672 "created worktree identity did not match its durable lease",
6673 ));
6674 self.remember_managed_spawn_attempt(
6675 durable_v2_key,
6676 request,
6677 result.clone(),
6678 accepted_at,
6679 );
6680 return result;
6681 }
6682 {
6683 let mut registry = self.managed_worktrees.lock()
6684 .unwrap_or_else(|poisoned| poisoned.into_inner());
6685 let current = registry.get_mut(&lease.lease_id)
6686 .expect("allocated managed worktree lease remains present");
6687 current.expected_head = Some(created.head.clone());
6688 current.state = ManagedWorktreeLeaseState::Ready;
6689 current.updated_at_unix_ms = unix_time_ms();
6690 }
6691 if let Err(error) = self.persist_state() {
6692 self.mark_managed_recovery_required(
6693 &lease.lease_id,
6694 ManagedWorktreeCleanupFailure::Backend,
6695 );
6696 let result = Err(persistence_failure(error));
6697 self.remember_managed_spawn_attempt(
6698 durable_v2_key,
6699 request,
6700 result.clone(),
6701 accepted_at,
6702 );
6703 return result;
6704 }
6705 let ready = self.managed_worktrees.lock()
6706 .unwrap_or_else(|poisoned| poisoned.into_inner())
6707 .get(&lease.lease_id)
6708 .expect("ready managed worktree lease remains present")
6709 .snapshot();
6710 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6711 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6712 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6713 let result = Err(error);
6714 self.remember_managed_spawn_attempt(
6715 durable_v2_key,
6716 request,
6717 result.clone(),
6718 accepted_at,
6719 );
6720 return result;
6721 }
6722 let spawn = self.spawn_session_with_deadline(
6723 lease.workspace_id.clone(),
6724 resolved.provider.clone(),
6725 resolved.mode,
6726 resolved.terminal_size,
6727 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6728 environment_profile.clone(),
6729 bundle.clone(),
6730 context.clone(),
6731 Some(lease.lease_id.clone()),
6732 Some(runtime_policy),
6733 SpawnRecordPolicy::Always,
6734 Some(deadline),
6735 &required_capabilities,
6736 None,
6737 resolved.approval_level,
6738 resolved.network_allowlist.clone(),
6739 resolved.browser_profile_id.clone(),
6740 ).await;
6741 let (session, effective_runtime_policy) = match spawn {
6742 Ok(spawned) => spawned,
6743 Err(error) => {
6744 if self
6745 .cleanup_managed_worktree(&lease.lease_id, true)
6746 .await
6747 .is_err()
6748 {
6749 self.mark_managed_recovery_required(
6750 &lease.lease_id,
6751 ManagedWorktreeCleanupFailure::Backend,
6752 );
6753 }
6754 let result = Err(error);
6755 self.remember_managed_spawn_attempt(
6756 durable_v2_key,
6757 request,
6758 result.clone(),
6759 accepted_at,
6760 );
6761 return result;
6762 }
6763 };
6764 let (record_id, raw_inventory_record_id) = {
6765 let mut bindings = self.session_bindings.lock()
6766 .unwrap_or_else(|poisoned| poisoned.into_inner());
6767 let binding = bindings.get_mut(&session.session.instance_id)
6768 .expect("successful spawn retains its session binding");
6769 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6770 managed_spawn_record_ownership(binding, effective_runtime_policy)
6771 };
6772 let bound = self.managed_worktrees.lock()
6773 .unwrap_or_else(|poisoned| poisoned.into_inner())
6774 .bind_session(
6775 &lease.lease_id,
6776 ManagedWorktreeSessionHolder {
6777 incarnation_id: self.incarnation_id,
6778 instance_id: session.session.instance_id,
6779 generation: session.session.generation,
6780 },
6781 record_id,
6782 unix_time_ms(),
6783 )
6784 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6785 let snapshot = match bound {
6786 Ok(snapshot) => snapshot,
6787 Err(error) => {
6788 if raw_inventory_record_id
6789 .as_ref()
6790 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6791 {
6792 self.mark_managed_recovery_required(
6793 &lease.lease_id,
6794 ManagedWorktreeCleanupFailure::Backend,
6795 );
6796 }
6797 let _ = self.remove_session(&session).await;
6798 self.mark_managed_recovery_required(
6799 &lease.lease_id,
6800 ManagedWorktreeCleanupFailure::Backend,
6801 );
6802 let result = Err(error);
6803 self.remember_managed_spawn_attempt(
6804 durable_v2_key,
6805 request,
6806 result.clone(),
6807 accepted_at,
6808 );
6809 return result;
6810 }
6811 };
6812 if let Err(error) = self.persist_state() {
6813 if raw_inventory_record_id
6814 .as_ref()
6815 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6816 {
6817 self.mark_managed_recovery_required(
6818 &lease.lease_id,
6819 ManagedWorktreeCleanupFailure::Backend,
6820 );
6821 }
6822 let _ = self.remove_session(&session).await;
6823 if self
6824 .cleanup_managed_worktree(&lease.lease_id, true)
6825 .await
6826 .is_err()
6827 {
6828 self.mark_managed_recovery_required(
6829 &lease.lease_id,
6830 ManagedWorktreeCleanupFailure::Backend,
6831 );
6832 }
6833 let result = Err(persistence_failure(error));
6834 self.remember_managed_spawn_attempt(
6835 durable_v2_key,
6836 request,
6837 result.clone(),
6838 accepted_at,
6839 );
6840 return result;
6841 }
6842 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6843 let receipt = managed_spawn_receipt(
6844 &resolved,
6845 self.incarnation_id,
6846 session,
6847 snapshot,
6848 environment_profile,
6849 bundle,
6850 context,
6851 );
6852 let result = Ok(receipt);
6853 self.remember_managed_spawn_attempt(
6854 durable_v2_key,
6855 request,
6856 result.clone(),
6857 accepted_at,
6858 );
6859 result
6860 }
6861
6862 async fn spawn_managed_worktree_v2(
6863 &self,
6864 request: ManagedWorktreeSpawnRequestV2,
6865 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6866 if request.spawn_spec.target.node_id != self.node_id {
6867 return Err(failure(
6868 NodeFailureCode::SpawnTargetMismatch,
6869 "spawn target does not match this node",
6870 ));
6871 }
6872 if request.spawn_spec.target.worktree_id.is_some() {
6873 return Err(failure(
6874 NodeFailureCode::InvalidRequest,
6875 "managed worktree spawn must not select a caller-provided worktree",
6876 ));
6877 }
6878 let request_digest = managed_spawn_request_digest_v2(&request)?;
6879 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6880 match replay {
6881 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6882 DurableManagedSpawnReplayDecision::PendingLinked => {
6883 return Err(failure(
6884 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6885 "managed worktree V2 reservation has an incomplete durable allocation",
6886 ));
6887 }
6888 DurableManagedSpawnReplayDecision::Reserved => {}
6889 }
6890 let validated = (|| {
6891 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6892 let profile = self.managed_profile(
6893 &resolved.target.workspace_id,
6894 &request.worktree_profile_id,
6895 )?;
6896 if profile.revision() != &request.expected_profile_revision {
6897 return Err(failure(
6898 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6899 "managed worktree profile revision does not match the loaded profile",
6900 ));
6901 }
6902 Ok(())
6903 })();
6904 match validated {
6905 Ok(()) => {}
6906 Err(error) => {
6907 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6908 return Err(error);
6909 }
6910 }
6911 let legacy = ManagedWorktreeSpawnRequest {
6912 spawn_spec: request.spawn_spec.clone(),
6913 worktree_profile_id: request.worktree_profile_id.clone(),
6914 };
6915 match self
6916 .spawn_managed_worktree_inner(
6917 legacy,
6918 Some(&request.spawn_spec.idempotency_key),
6919 )
6920 .await
6921 {
6922 Ok(receipt) => {
6923 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6924 Ok(receipt)
6925 }
6926 Err(error) => {
6927 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6928 Err(error)
6929 }
6930 }
6931 }
6932
6933 fn managed_profile(
6934 &self,
6935 workspace_id: &WorkspaceId,
6936 profile_id: &WorktreeProfileId,
6937 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6938 if self.worktree_service_modes.get(workspace_id).copied()
6939 != Some(WorktreeServiceMode::Managed)
6940 {
6941 return Err(failure(
6942 NodeFailureCode::UnsupportedSpawnCapability,
6943 "workspace does not enable managed worktrees",
6944 ));
6945 }
6946 self.managed_worktree_profiles.get(workspace_id)
6947 .and_then(|profiles| profiles.get(profile_id))
6948 .cloned()
6949 .ok_or_else(|| failure(
6950 NodeFailureCode::InvalidRequest,
6951 "managed worktree profile is unavailable for this workspace",
6952 ))
6953 }
6954
6955 async fn reconcile_failed_allocation(
6956 &self,
6957 lease: &ManagedWorktreeLeaseRecord,
6958 source_root: &str,
6959 ) {
6960 match list_git_worktrees(source_root).await {
6961 Ok(worktrees) => {
6962 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6963 None => {
6964 match std::fs::symlink_metadata(&lease.target_root) {
6965 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6966 self.managed_worktrees.lock()
6967 .unwrap_or_else(|poisoned| poisoned.into_inner())
6968 .remove_unmutated(&lease.lease_id);
6969 let _ = self.persist_state();
6970 }
6971 _ => self.mark_managed_recovery_required(
6972 &lease.lease_id,
6973 ManagedWorktreeCleanupFailure::OwnershipConflict,
6974 ),
6975 }
6976 }
6977 Some(item) if exact_owned_worktree(
6978 lease,
6979 &item.path,
6980 item.branch.as_deref(),
6981 &item.head,
6982 ) => {
6983 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6984 }
6985 Some(_) => self.mark_managed_recovery_required(
6986 &lease.lease_id,
6987 ManagedWorktreeCleanupFailure::OwnershipConflict,
6988 ),
6989 }
6990 }
6991 Err(_) => self.mark_managed_recovery_required(
6992 &lease.lease_id,
6993 ManagedWorktreeCleanupFailure::Backend,
6994 ),
6995 }
6996 }
6997
6998 async fn reconcile_managed_worktrees(&self) {
6999 let leases = self.managed_worktrees.lock()
7000 .unwrap_or_else(|poisoned| poisoned.into_inner())
7001 .active_records()
7002 .cloned()
7003 .collect::<Vec<_>>();
7004 for lease in leases {
7005 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7006 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7007 _ => {
7008 self.mark_managed_recovery_required(
7009 &lease.lease_id,
7010 ManagedWorktreeCleanupFailure::OwnershipConflict,
7011 );
7012 continue;
7013 }
7014 };
7015 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7016 self.mark_managed_recovery_required(
7017 &lease.lease_id,
7018 ManagedWorktreeCleanupFailure::OwnershipConflict,
7019 );
7020 continue;
7021 }
7022 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7023 Ok(root) => root,
7024 Err(_) => {
7025 self.mark_managed_recovery_required(
7026 &lease.lease_id,
7027 ManagedWorktreeCleanupFailure::OwnershipConflict,
7028 );
7029 continue;
7030 }
7031 };
7032 let worktrees = match list_git_worktrees(&source_root).await {
7033 Ok(worktrees) => worktrees,
7034 Err(_) => {
7035 self.mark_managed_recovery_required(
7036 &lease.lease_id,
7037 ManagedWorktreeCleanupFailure::Backend,
7038 );
7039 continue;
7040 }
7041 };
7042 let listed = worktrees.iter()
7043 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7044 let Some(listed) = listed else {
7045 match std::fs::symlink_metadata(&lease.target_root) {
7046 Err(error) if error.kind() == io::ErrorKind::NotFound
7047 && lease.state == ManagedWorktreeLeaseState::Allocating
7048 && !lease.has_holders() => {
7049 let removed = self.managed_worktrees.lock()
7050 .unwrap_or_else(|poisoned| poisoned.into_inner())
7051 .tombstone(&lease.lease_id, unix_time_ms());
7052 if self.persist_state().is_ok() && removed.is_some() {
7053 self.publish(NodeEvent::ManagedWorktreeRemoved {
7054 lease_id: lease.lease_id.clone(),
7055 });
7056 }
7057 }
7058 _ => self.mark_managed_recovery_required(
7059 &lease.lease_id,
7060 ManagedWorktreeCleanupFailure::OwnershipConflict,
7061 ),
7062 }
7063 continue;
7064 };
7065 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7066 &lease,
7067 &listed.path,
7068 listed.branch.as_deref(),
7069 &listed.head,
7070 ) {
7071 self.mark_managed_recovery_required(
7072 &lease.lease_id,
7073 ManagedWorktreeCleanupFailure::OwnershipConflict,
7074 );
7075 continue;
7076 }
7077 if !self.workspaces.read()
7078 .unwrap_or_else(|poisoned| poisoned.into_inner())
7079 .contains_key(&lease.workspace_id)
7080 {
7081 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7082 self.mark_managed_recovery_required(
7083 &lease.lease_id,
7084 ManagedWorktreeCleanupFailure::Backend,
7085 );
7086 continue;
7087 }
7088 }
7089 let snapshot = {
7090 let mut registry = self.managed_worktrees.lock()
7091 .unwrap_or_else(|poisoned| poisoned.into_inner());
7092 let current = registry.get_mut(&lease.lease_id)
7093 .expect("reconciled managed lease remains present");
7094 current.expected_head = Some(listed.head.clone());
7095 current.cleanup_failure = None;
7096 current.state = if current.has_holders() {
7097 ManagedWorktreeLeaseState::InUse
7098 } else if current.retention == ManagedWorktreeRetention::Retain {
7099 ManagedWorktreeLeaseState::Retained
7100 } else {
7101 ManagedWorktreeLeaseState::Ready
7102 };
7103 current.updated_at_unix_ms = unix_time_ms();
7104 current.snapshot()
7105 };
7106 if self.persist_state().is_err() {
7107 self.mark_managed_recovery_required(
7108 &lease.lease_id,
7109 ManagedWorktreeCleanupFailure::Backend,
7110 );
7111 continue;
7112 }
7113 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7114 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7115 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7116 }
7117 }
7118 }
7119
7120 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7140 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7141 .session_records
7142 .lock()
7143 .unwrap_or_else(|poisoned| poisoned.into_inner())
7144 .records
7145 .values()
7146 .filter(|record| {
7147 record.state == ManagedSessionState::Live
7148 && record.exported_context.is_none()
7149 && record.provider_session.is_some()
7150 })
7151 .filter_map(|record| {
7152 record
7153 .active_session
7154 .clone()
7155 .map(|session| (record.record_id.clone(), session))
7156 })
7157 .collect();
7158 for (record_id, session) in candidates {
7159 let shared = Arc::clone(self);
7160 tokio::spawn(async move {
7161 shared
7162 .reconcile_context_pack_export_for_record(&record_id, session)
7163 .await;
7164 });
7165 }
7166 }
7167
7168 async fn reconcile_context_pack_export_for_record(
7187 &self,
7188 record_id: &SessionRecordId,
7189 session: SessionAddress,
7190 ) {
7191 let Ok(record) = self.record(record_id) else {
7192 return;
7193 };
7194 if !matches!(
7195 record.state,
7196 ManagedSessionState::Live | ManagedSessionState::Dormant
7197 ) || record.exported_context.is_some()
7198 || record.provider_session.is_none()
7199 {
7200 return;
7201 }
7202 let Ok(receipt) = self
7209 .export_context_pack_for_session_record(&record.record_id, &session, true)
7210 .await
7211 else {
7212 return;
7213 };
7214 self.set_exported_context(&record.record_id, receipt);
7215 }
7216
7217 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7222 let transaction = self
7223 .state_transaction
7224 .lock()
7225 .unwrap_or_else(|poisoned| poisoned.into_inner());
7226 let (previous, updated) = {
7227 let mut records = self
7228 .session_records
7229 .lock()
7230 .unwrap_or_else(|poisoned| poisoned.into_inner());
7231 let Some(record) = records.records.get_mut(record_id) else {
7232 return;
7233 };
7234 if record.exported_context.is_some() {
7235 return;
7236 }
7237 let previous = record.clone();
7238 record.exported_context = Some(receipt);
7239 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7240 (previous, record.clone())
7241 };
7242 if self.persist_state_locked().is_err() {
7243 self.session_records
7244 .lock()
7245 .unwrap_or_else(|poisoned| poisoned.into_inner())
7246 .records
7247 .insert(record_id.clone(), previous);
7248 return;
7249 }
7250 drop(transaction);
7251 self.publish_record(updated);
7252 }
7253
7254 fn managed_record_export_target_for_instance(
7264 &self,
7265 instance_id: AgentInstanceId,
7266 generation: SessionGeneration,
7267 ) -> Option<(SessionRecordId, SessionAddress)> {
7268 let bindings = self
7269 .session_bindings
7270 .lock()
7271 .unwrap_or_else(|poisoned| poisoned.into_inner());
7272 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7273 let record_id = binding.record_id.clone()?;
7274 Some((
7275 record_id,
7276 SessionAddress {
7277 workspace_id: binding.workspace_id.clone(),
7278 session: SessionKey { instance_id, generation },
7279 },
7280 ))
7281 }
7282
7283 fn mark_managed_recovery_required(
7284 &self,
7285 lease_id: &ManagedWorktreeLeaseId,
7286 failure_kind: ManagedWorktreeCleanupFailure,
7287 ) {
7288 let snapshot = {
7289 let mut registry = self.managed_worktrees.lock()
7290 .unwrap_or_else(|poisoned| poisoned.into_inner());
7291 let Some(lease) = registry.get_mut(lease_id) else { return };
7292 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7293 lease.cleanup_failure = Some(failure_kind);
7294 lease.updated_at_unix_ms = unix_time_ms();
7295 lease.snapshot()
7296 };
7297 let _ = self.persist_state();
7298 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7299 }
7300
7301 fn set_persistence_error(&self, error: Option<String>) {
7302 *self
7303 .persistence_error
7304 .write()
7305 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7306 .map(|message| session_registry::sanitized_persistence_summary(&message));
7307 }
7308
7309 fn persist_state(&self) -> io::Result<()> {
7310 let _transaction = self
7311 .state_transaction
7312 .lock()
7313 .unwrap_or_else(|poisoned| poisoned.into_inner());
7314 self.persist_state_locked()
7315 }
7316
7317 fn persist_state_locked(&self) -> io::Result<()> {
7318 let workspaces = self
7319 .workspaces
7320 .read()
7321 .unwrap_or_else(|poisoned| poisoned.into_inner())
7322 .clone();
7323 let records = self
7324 .session_records
7325 .lock()
7326 .unwrap_or_else(|poisoned| poisoned.into_inner())
7327 .records
7328 .values()
7329 .cloned()
7330 .collect::<Vec<_>>();
7331 let mut managed = self.managed_worktrees
7332 .lock()
7333 .unwrap_or_else(|poisoned| poisoned.into_inner());
7334 managed.reattach_record_holders(
7335 &managed_worktree_record_holders(&records),
7336 unix_time_ms(),
7337 );
7338 let materializations = self
7339 .materializations
7340 .lock()
7341 .unwrap_or_else(|poisoned| poisoned.into_inner())
7342 .values()
7343 .cloned()
7344 .collect::<Vec<_>>();
7345 let managed_spawn_replays = self.managed_spawn_replays
7346 .lock()
7347 .unwrap_or_else(|poisoned| poisoned.into_inner())
7348 .values()
7349 .cloned()
7350 .collect::<Vec<_>>();
7351 let result = session_registry::save_v10(
7352 self.state_path.as_deref(),
7353 &self.node_id,
7354 &workspaces,
7355 &records,
7356 &managed.records(),
7357 &managed.tombstones(),
7358 &materializations,
7359 &managed_spawn_replays,
7360 );
7361 match &result {
7362 Ok(warning) => self.set_persistence_error(warning.clone()),
7363 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7364 }
7365 result.map(|_| ())
7366 }
7367
7368 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7369 let _mutation_guard = self.mutation_gate.lock().await;
7370 self.begin_shutdown_locked().await
7371 }
7372
7373 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7374 if self.shutdown.swap(true, Ordering::AcqRel) {
7375 return Ok(());
7376 }
7377 SessionLifecycleHook::on_shutdown(self);
7378 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7379 let mut first_error = None;
7380 for session in &self.handle.snapshot().sessions {
7381 let remaining = deadline.saturating_duration_since(Instant::now());
7382 if remaining.is_zero() {
7383 first_error.get_or_insert_with(|| {
7384 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7385 });
7386 break;
7387 }
7388 if let Err(error) = self
7389 .dispatch_bounded(
7390 ControlCommand::Stop {
7391 instance_id: session.instance_id,
7392 force: true,
7393 },
7394 remaining,
7395 )
7396 .await
7397 {
7398 first_error.get_or_insert(error);
7399 }
7400 }
7401 self.shutdown_notify.notify_waiters();
7402 match first_error {
7403 Some(error) => Err(error),
7404 None => Ok(()),
7405 }
7406 }
7407
7408 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7409 let bindings = self
7410 .session_bindings
7411 .lock()
7412 .unwrap_or_else(|poisoned| poisoned.into_inner());
7413 if bindings.len() >= CONTROL_SESSIONS_MAX {
7414 Err(failure(
7415 NodeFailureCode::BackendBusy,
7416 "node session-binding capacity is full; remove an existing session first",
7417 ))
7418 } else {
7419 Ok(())
7420 }
7421 }
7422
7423 fn ensure_workspace_environment_profile_compatible(
7430 &self,
7431 workspace_id: &WorkspaceId,
7432 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7433 ) -> Result<(), NodeFailure> {
7434 let bindings = self
7435 .session_bindings
7436 .lock()
7437 .unwrap_or_else(|poisoned| poisoned.into_inner());
7438 for binding in bindings.values() {
7439 if &binding.workspace_id != workspace_id {
7440 continue;
7441 }
7442 if binding.environment_profile.as_ref() != environment_profile {
7443 return Err(failure(
7444 NodeFailureCode::WorkspaceBusy,
7445 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7446 ));
7447 }
7448 }
7449 Ok(())
7450 }
7451
7452 #[cfg(test)]
7453 fn bind_session(
7454 &self,
7455 instance_id: AgentInstanceId,
7456 workspace_id: WorkspaceId,
7457 generation: SessionGeneration,
7458 ) {
7459 self.bind_session_with_policy(
7460 &SessionAddress {
7461 workspace_id,
7462 session: SessionKey { instance_id, generation },
7463 },
7464 ProviderRuntimePolicy::raw_pty(),
7465 None,
7466 );
7467 }
7468
7469 #[cfg(test)]
7470 fn bind_session_with_policy(
7471 &self,
7472 address: &SessionAddress,
7473 runtime_policy: ProviderRuntimePolicy,
7474 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7475 ) {
7476 self.bind_session_with_materialization(
7477 address,
7478 runtime_policy,
7479 environment_profile,
7480 None,
7481 None,
7482 None,
7483 );
7484 }
7485
7486 fn bind_session_with_materialization(
7487 &self,
7488 address: &SessionAddress,
7489 runtime_policy: ProviderRuntimePolicy,
7490 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7491 bundle: Option<ResolvedBundleReceipt>,
7492 context: Option<ResolvedContextPackReceipt>,
7493 materialization_id: Option<MaterializationId>,
7494 ) {
7495 let mut bindings = self
7496 .session_bindings
7497 .lock()
7498 .unwrap_or_else(|poisoned| poisoned.into_inner());
7499 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7500 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7501 bindings.insert(address.session.instance_id, SessionBinding {
7502 workspace_id: address.workspace_id.clone(),
7503 generation: address.session.generation,
7504 runtime_policy,
7505 pending_resume: None,
7506 record_id: None,
7507 managed_worktree_lease_id: None,
7508 environment_profile,
7509 bundle,
7510 context,
7511 materialization_id,
7512 });
7513 }
7514
7515 #[cfg(test)]
7516 fn bind_managed_session(
7517 &self,
7518 address: &SessionAddress,
7519 record_id: SessionRecordId,
7520 runtime_policy: ProviderRuntimePolicy,
7521 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7522 ) {
7523 self.bind_managed_session_with_materialization(
7524 address,
7525 record_id,
7526 runtime_policy,
7527 environment_profile,
7528 None,
7529 None,
7530 None,
7531 );
7532 }
7533
7534 fn bind_managed_session_with_materialization(
7535 &self,
7536 address: &SessionAddress,
7537 record_id: SessionRecordId,
7538 runtime_policy: ProviderRuntimePolicy,
7539 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7540 bundle: Option<ResolvedBundleReceipt>,
7541 context: Option<ResolvedContextPackReceipt>,
7542 materialization_id: Option<MaterializationId>,
7543 ) {
7544 let mut bindings = self
7545 .session_bindings
7546 .lock()
7547 .unwrap_or_else(|poisoned| poisoned.into_inner());
7548 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7549 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7550 bindings.insert(
7551 address.session.instance_id,
7552 SessionBinding {
7553 workspace_id: address.workspace_id.clone(),
7554 generation: address.session.generation,
7555 runtime_policy,
7556 pending_resume: None,
7557 record_id: Some(record_id),
7558 managed_worktree_lease_id: None,
7559 environment_profile,
7560 bundle,
7561 context,
7562 materialization_id,
7563 },
7564 );
7565 }
7566
7567 #[cfg(test)]
7568 fn bind_spawn_session(
7569 &self,
7570 address: &SessionAddress,
7571 provider: AgentId,
7572 mode: SessionMode,
7573 runtime_policy: ProviderRuntimePolicy,
7574 record_policy: SpawnRecordPolicy,
7575 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7576 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7577 self.bind_spawn_session_with_materialization(
7578 address,
7579 provider,
7580 mode,
7581 runtime_policy,
7582 record_policy,
7583 environment_profile,
7584 None,
7585 None,
7586 None,
7587 )
7588 }
7589
7590 fn bind_spawn_session_with_materialization(
7591 &self,
7592 address: &SessionAddress,
7593 provider: AgentId,
7594 mode: SessionMode,
7595 runtime_policy: ProviderRuntimePolicy,
7596 record_policy: SpawnRecordPolicy,
7597 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7598 bundle: Option<ResolvedBundleReceipt>,
7599 context: Option<ResolvedContextPackReceipt>,
7600 materialization_id: Option<MaterializationId>,
7601 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7602 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7603 && !runtime_policy.provider_session_identity
7604 {
7605 self.bind_session_with_materialization(
7606 address,
7607 runtime_policy,
7608 environment_profile,
7609 bundle,
7610 context,
7611 materialization_id,
7612 );
7613 return Ok(None);
7614 }
7615 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7616 let record = self.new_record(
7617 address,
7618 provider,
7619 mode,
7620 active_record_state(runtime_policy.provider_session_identity, false),
7621 None,
7622 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7623 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7624 record_owns_lifecycle.then(|| context.clone()).flatten(),
7625 )?;
7626 let record_id = record.record_id.clone();
7627 let transaction = self
7628 .state_transaction
7629 .lock()
7630 .unwrap_or_else(|poisoned| poisoned.into_inner());
7631 self.insert_record(record.clone())?;
7632 let previous_materialization = if record_owns_lifecycle {
7633 if let Some(materialization_id) = materialization_id.as_ref() {
7634 let mut materializations = self
7635 .materializations
7636 .lock()
7637 .unwrap_or_else(|poisoned| poisoned.into_inner());
7638 let Some(ownership) = materializations.get_mut(materialization_id) else {
7639 drop(materializations);
7640 self.remove_record_memory(&record_id);
7641 return Err(failure(
7642 NodeFailureCode::BackendOperationFailed,
7643 "session environment ownership disappeared before binding",
7644 ));
7645 };
7646 let previous = ownership.clone();
7647 if ownership
7648 .transfer_to_record(record_id.clone(), unix_time_ms())
7649 .is_err()
7650 {
7651 drop(materializations);
7652 self.remove_record_memory(&record_id);
7653 return Err(failure(
7654 NodeFailureCode::BackendOperationFailed,
7655 "session environment ownership transfer failed",
7656 ));
7657 }
7658 Some(previous)
7659 } else {
7660 None
7661 }
7662 } else {
7663 None
7664 };
7665 self.bind_managed_session_with_materialization(
7666 address,
7667 record_id.clone(),
7668 runtime_policy,
7669 environment_profile,
7670 bundle,
7671 context,
7672 materialization_id.clone(),
7673 );
7674 if let Err(error) = self.persist_state_locked() {
7675 self.remove_binding(address);
7676 self.remove_record_memory(&record_id);
7677 if let (Some(materialization_id), Some(previous)) =
7678 (materialization_id.as_ref(), previous_materialization)
7679 {
7680 self.materializations
7681 .lock()
7682 .unwrap_or_else(|poisoned| poisoned.into_inner())
7683 .insert(materialization_id.clone(), previous);
7684 }
7685 return Err(persistence_failure(error));
7686 }
7687 drop(transaction);
7688 self.publish_record(record);
7689 Ok(Some(record_id))
7690 }
7691
7692 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7693 for _ in 0..8 {
7694 let bytes = random_nonce().map_err(|error| {
7695 failure(
7696 NodeFailureCode::BackendOperationFailed,
7697 &format!("session record identity generation failed: {error}"),
7698 )
7699 })?;
7700 let mut value = String::with_capacity(27);
7701 value.push_str("sr-");
7702 for byte in &bytes[..12] {
7703 use std::fmt::Write as _;
7704 let _ = write!(&mut value, "{byte:02x}");
7705 }
7706 let record_id = SessionRecordId::new(value).map_err(|error| {
7707 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7708 })?;
7709 let records = self
7710 .session_records
7711 .lock()
7712 .unwrap_or_else(|poisoned| poisoned.into_inner());
7713 if !records.records.contains_key(&record_id) {
7714 return Ok(record_id);
7715 }
7716 }
7717 Err(failure(
7718 NodeFailureCode::BackendBusy,
7719 "could not allocate a unique session record ID",
7720 ))
7721 }
7722
7723 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7724 for _ in 0..8 {
7725 let bytes = random_nonce().map_err(|error| {
7726 failure(
7727 NodeFailureCode::BackendOperationFailed,
7728 &format!("task identity generation failed: {error}"),
7729 )
7730 })?;
7731 let mut nonce = [0_u8; 12];
7732 nonce.copy_from_slice(&bytes[..12]);
7733 let task_id = TaskId::from_nonce(nonce);
7734 let records = self
7735 .session_records
7736 .lock()
7737 .unwrap_or_else(|poisoned| poisoned.into_inner());
7738 if !records.records.values().any(|record| {
7739 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7740 == Some(&task_id)
7741 }) {
7742 return Ok(task_id);
7743 }
7744 }
7745 Err(failure(
7746 NodeFailureCode::BackendBusy,
7747 "could not allocate a unique task ID",
7748 ))
7749 }
7750
7751 fn default_record_name(&self, provider: &AgentId) -> String {
7752 let records = self
7753 .session_records
7754 .lock()
7755 .unwrap_or_else(|poisoned| poisoned.into_inner());
7756 let ordinal = records
7757 .records
7758 .values()
7759 .filter(|record| &record.provider == provider)
7760 .count()
7761 .saturating_add(1);
7762 format!("{} #{ordinal}", provider.as_str())
7763 }
7764
7765 fn new_record(
7766 &self,
7767 address: &SessionAddress,
7768 provider: AgentId,
7769 mode: SessionMode,
7770 state: ManagedSessionState,
7771 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7772 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7773 bundle: Option<ResolvedBundleReceipt>,
7774 context: Option<ResolvedContextPackReceipt>,
7775 ) -> Result<ManagedSessionRecord, NodeFailure> {
7776 let canonical_root = self.workspace_root(&address.workspace_id)?;
7777 let now = unix_time_ms();
7778 Ok(ManagedSessionRecord {
7779 record_id: self.allocate_record_id()?,
7780 display_name: self.default_record_name(&provider),
7781 provider,
7782 mode,
7783 state,
7784 workspace_id: address.workspace_id.clone(),
7785 canonical_root: opaque_windows_path(canonical_root),
7786 provider_session,
7787 active_session: Some(address.clone()),
7788 environment_profile,
7789 bundle,
7790 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7791 context,
7792 exported_context: None,
7793 task_binding: None,
7794 created_at_unix_ms: now,
7795 updated_at_unix_ms: now,
7796 last_error: None,
7797 })
7798 }
7799
7800 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7801 let mut records = self
7802 .session_records
7803 .lock()
7804 .unwrap_or_else(|poisoned| poisoned.into_inner());
7805 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7806 return Err(failure(
7807 NodeFailureCode::BackendBusy,
7808 "managed session record capacity is full; forget an inactive record first",
7809 ));
7810 }
7811 if let Some(identity) = record.provider_session.as_ref() {
7812 if records.records.values().any(|candidate| {
7813 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7814 session_registry::same_provider_session(
7815 &candidate.provider,
7816 candidate_identity,
7817 &record.provider,
7818 identity,
7819 )
7820 })
7821 }) {
7822 return Err(failure(
7823 NodeFailureCode::SessionRecordConflict,
7824 "provider session is already represented by another managed record",
7825 ));
7826 }
7827 }
7828 match records.records.entry(record.record_id.clone()) {
7829 std::collections::btree_map::Entry::Vacant(entry) => {
7830 entry.insert(record);
7831 }
7832 std::collections::btree_map::Entry::Occupied(_) => {
7833 return Err(failure(
7834 NodeFailureCode::SessionRecordConflict,
7835 "session record ID collision",
7836 ));
7837 }
7838 }
7839 Ok(())
7840 }
7841
7842 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7843 self.session_records
7844 .lock()
7845 .unwrap_or_else(|poisoned| poisoned.into_inner())
7846 .records
7847 .get(record_id)
7848 .cloned()
7849 .ok_or_else(|| {
7850 failure(
7851 NodeFailureCode::UnknownSessionRecord,
7852 "managed session record does not exist",
7853 )
7854 })
7855 }
7856
7857 fn revalidate_session_record_preview(
7858 &self,
7859 expected: &ManagedSessionRecord,
7860 identity: &ProviderSessionIdentity,
7861 ) -> Result<(), NodeFailure> {
7862 let current = self.record(&expected.record_id)?;
7863 let current_root = self.workspace_root(¤t.workspace_id)?;
7864 if current.provider != expected.provider
7865 || current.workspace_id != expected.workspace_id
7866 || current.canonical_root != expected.canonical_root
7867 || current.provider_session.as_ref() != Some(identity)
7868 || !platform::roots_equal(
7869 ¤t_root,
7870 windows_path_text(¤t.canonical_root),
7871 )
7872 {
7873 return Err(failure(
7874 NodeFailureCode::SessionRecordConflict,
7875 "managed session changed while its preview was loading",
7876 ));
7877 }
7878 Ok(())
7879 }
7880
7881 fn revalidate_session_record_context_export(
7882 &self,
7883 expected: &ManagedSessionRecord,
7884 identity: Option<&ProviderSessionIdentity>,
7885 session: &SessionAddress,
7886 allow_clean_detachment: bool,
7887 ) -> Result<(), NodeFailure> {
7888 let current = self.record(&expected.record_id)?;
7889 let current_root = self.workspace_root(¤t.workspace_id)?;
7890 let snapshot = self.internal_session_snapshot(session)?;
7891 let binding = self
7892 .session_bindings
7893 .lock()
7894 .unwrap_or_else(|poisoned| poisoned.into_inner())
7895 .get(&session.session.instance_id)
7896 .cloned()
7897 .ok_or_else(|| {
7898 failure(
7899 NodeFailureCode::SessionRecordConflict,
7900 "managed session runtime binding disappeared during context export",
7901 )
7902 })?;
7903 if !session_record_context_export_binding_is_exact(
7904 expected,
7905 ¤t,
7906 identity,
7907 session,
7908 &binding,
7909 &snapshot.agent_id,
7910 ¤t_root,
7911 allow_clean_detachment,
7912 ) || !session_record_context_export_source_is_usable(
7913 ¤t,
7914 &snapshot.status,
7915 allow_clean_detachment,
7916 ) {
7917 return Err(failure(
7918 NodeFailureCode::SessionRecordConflict,
7919 "managed session binding changed during context export",
7920 ));
7921 }
7922 Ok(())
7923 }
7924
7925 fn revalidate_loaded_history(
7926 &self,
7927 session: &SessionAddress,
7928 candidate_id: &str,
7929 loaded: &HistorySessionRecord,
7930 ) -> Result<(), NodeFailure> {
7931 let snapshot = self.internal_session_snapshot(session)?;
7932 if snapshot.history.pending.is_some()
7933 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7934 || snapshot.history.loaded.as_ref() != Some(loaded)
7935 {
7936 return Err(failure(
7937 NodeFailureCode::SessionRecordConflict,
7938 "managed session history changed during context export",
7939 ));
7940 }
7941 Ok(())
7942 }
7943
7944 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7945 self.session_records
7946 .lock()
7947 .unwrap_or_else(|poisoned| poisoned.into_inner())
7948 .records
7949 .remove(record_id)
7950 }
7951
7952 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7953 if self.destroy_record_materialization(record_id)? {
7954 self.publish(NodeEvent::SessionRecordRemoved {
7955 record_id: record_id.clone(),
7956 });
7957 return Ok(());
7958 }
7959 let transaction = self
7960 .state_transaction
7961 .lock()
7962 .unwrap_or_else(|poisoned| poisoned.into_inner());
7963 let Some(removed) = self.remove_record_memory(record_id) else {
7964 return Ok(());
7965 };
7966 if let Err(error) = self.persist_state_locked() {
7967 let _ = self.insert_record(removed);
7968 return Err(persistence_failure(error));
7969 }
7970 drop(transaction);
7971 self.publish(NodeEvent::SessionRecordRemoved {
7972 record_id: record_id.clone(),
7973 });
7974 Ok(())
7975 }
7976
7977 fn publish_record(&self, record: ManagedSessionRecord) {
7978 self.publish(NodeEvent::SessionRecordUpserted { record });
7979 }
7980
7981 fn mark_record_error(
7982 &self,
7983 record_id: &SessionRecordId,
7984 message: &str,
7985 ) -> Result<(), NodeFailure> {
7986 let transaction = self
7987 .state_transaction
7988 .lock()
7989 .unwrap_or_else(|poisoned| poisoned.into_inner());
7990 let previous = {
7991 let mut records = self
7992 .session_records
7993 .lock()
7994 .unwrap_or_else(|poisoned| poisoned.into_inner());
7995 let Some(record) = records.records.get_mut(record_id) else {
7996 return Ok(());
7997 };
7998 let previous = record.clone();
7999 record.state = ManagedSessionState::Unavailable;
8000 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8001 record.updated_at_unix_ms = unix_time_ms();
8002 previous
8003 };
8004 if let Err(error) = self.persist_state_locked() {
8005 self.session_records
8006 .lock()
8007 .unwrap_or_else(|poisoned| poisoned.into_inner())
8008 .records
8009 .insert(record_id.clone(), previous);
8010 return Err(persistence_failure(error));
8011 }
8012 let record = self.record(record_id)?;
8013 drop(transaction);
8014 self.publish_record(record);
8015 Ok(())
8016 }
8017
8018 fn rename_session_record(
8019 &self,
8020 record_id: &SessionRecordId,
8021 display_name: String,
8022 ) -> Result<ManagedSessionRecord, NodeFailure> {
8023 validate_display_name(&display_name)
8024 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8025 let _transaction = self
8026 .state_transaction
8027 .lock()
8028 .unwrap_or_else(|poisoned| poisoned.into_inner());
8029 let previous = {
8030 let mut records = self
8031 .session_records
8032 .lock()
8033 .unwrap_or_else(|poisoned| poisoned.into_inner());
8034 let record = records.records.get_mut(record_id).ok_or_else(|| {
8035 failure(
8036 NodeFailureCode::UnknownSessionRecord,
8037 "managed session record does not exist",
8038 )
8039 })?;
8040 let previous = record.clone();
8041 record.display_name = display_name;
8042 record.updated_at_unix_ms = unix_time_ms();
8043 previous
8044 };
8045 if let Err(error) = self.persist_state_locked() {
8046 self.session_records
8047 .lock()
8048 .unwrap_or_else(|poisoned| poisoned.into_inner())
8049 .records
8050 .insert(record_id.clone(), previous);
8051 return Err(persistence_failure(error));
8052 }
8053 let updated = self.record(record_id)?;
8054 self.publish_record(updated.clone());
8055 Ok(updated)
8056 }
8057
8058 fn set_session_task(
8059 &self,
8060 record_id: &SessionRecordId,
8061 expected_revision: u64,
8062 target: SessionTaskTargetV1,
8063 ) -> Result<ManagedSessionRecord, NodeFailure> {
8064 let transaction = self
8065 .state_transaction
8066 .lock()
8067 .unwrap_or_else(|poisoned| poisoned.into_inner());
8068 let current = self.record(record_id)?;
8069 let current_task_id = current
8070 .task_binding
8071 .as_ref()
8072 .and_then(|binding| binding.task_id.as_ref());
8073 let current_revision = current
8074 .task_binding
8075 .as_ref()
8076 .map_or(0, |binding| binding.revision);
8077 if expected_revision != current_revision {
8078 return Err(failure(
8079 NodeFailureCode::SessionRecordConflict,
8080 "managed session task binding revision changed",
8081 ));
8082 }
8083 let exact = match &target {
8084 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8085 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8086 SessionTaskTargetV1::New => false,
8087 };
8088 if exact {
8089 return Ok(current);
8090 }
8091 let revision = current_revision.checked_add(1).ok_or_else(|| {
8092 failure(
8093 NodeFailureCode::SessionRecordConflict,
8094 "managed session task binding revision is exhausted",
8095 )
8096 })?;
8097 let task_id = match target {
8098 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8099 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8100 SessionTaskTargetV1::Clear => None,
8101 };
8102 let changed_at_unix_ms = unix_time_ms()
8103 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8104 failure(
8105 NodeFailureCode::SessionRecordConflict,
8106 "managed session record timestamp is exhausted",
8107 )
8108 })?);
8109 let updated = {
8110 let mut records = self
8111 .session_records
8112 .lock()
8113 .unwrap_or_else(|poisoned| poisoned.into_inner());
8114 let record = records.records.get_mut(record_id).ok_or_else(|| {
8115 failure(
8116 NodeFailureCode::UnknownSessionRecord,
8117 "managed session record does not exist",
8118 )
8119 })?;
8120 record.task_binding = Some(SessionTaskBindingV1 {
8121 revision,
8122 task_id,
8123 changed_at_unix_ms,
8124 });
8125 record.updated_at_unix_ms = changed_at_unix_ms;
8126 record.clone()
8127 };
8128 if let Err(error) = self.persist_state_locked() {
8129 self.session_records
8130 .lock()
8131 .unwrap_or_else(|poisoned| poisoned.into_inner())
8132 .records
8133 .insert(record_id.clone(), current);
8134 return Err(persistence_failure(error));
8135 }
8136 drop(transaction);
8137 self.publish_record(updated.clone());
8138 Ok(updated)
8139 }
8140
8141 fn index_provider_session(
8142 &self,
8143 workspace_id: WorkspaceId,
8144 provider: AgentId,
8145 identity: ProviderSessionIdentity,
8146 display_name: String,
8147 ) -> Result<ManagedSessionRecord, NodeFailure> {
8148 self.index_provider_session_with_policy(
8149 workspace_id,
8150 provider,
8151 identity,
8152 display_name,
8153 false,
8154 )
8155 }
8156
8157 fn index_provider_session_with_policy(
8158 &self,
8159 workspace_id: WorkspaceId,
8160 provider: AgentId,
8161 identity: ProviderSessionIdentity,
8162 display_name: String,
8163 allow_validated_transcript_path: bool,
8164 ) -> Result<ManagedSessionRecord, NodeFailure> {
8165 validate_display_name(&display_name)
8166 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8167 identity
8168 .validate()
8169 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8170 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8171 return Err(failure(
8172 NodeFailureCode::InvalidRequest,
8173 "provider session reference index v1 does not accept transcript paths",
8174 ));
8175 }
8176 let canonical_root = self.workspace_root(&workspace_id)?;
8177 if !self.enabled_providers.contains(&provider) {
8178 return Err(failure(
8179 NodeFailureCode::InvalidRequest,
8180 "provider is not enabled on this node",
8181 ));
8182 }
8183
8184 let transaction = self
8185 .state_transaction
8186 .lock()
8187 .unwrap_or_else(|poisoned| poisoned.into_inner());
8188 {
8189 let records = self
8190 .session_records
8191 .lock()
8192 .unwrap_or_else(|poisoned| poisoned.into_inner());
8193 if let Some(existing) = records.records.values().find(|candidate| {
8194 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8195 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8196 })
8197 }) {
8198 if existing.provider == provider && existing.workspace_id == workspace_id {
8199 return Ok(existing.clone());
8200 }
8201 return Err(failure(
8202 NodeFailureCode::SessionRecordConflict,
8203 "provider session reference conflicts with another provider or workspace",
8204 ));
8205 }
8206 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8207 return Err(failure(
8208 NodeFailureCode::BackendBusy,
8209 "managed session record capacity is full; forget an inactive record first",
8210 ));
8211 }
8212 }
8213
8214 let now = unix_time_ms();
8215 let record = ManagedSessionRecord {
8216 record_id: self.allocate_record_id()?,
8217 display_name,
8218 provider,
8219 mode: SessionMode::Pty,
8220 state: ManagedSessionState::Dormant,
8221 workspace_id,
8222 canonical_root: opaque_windows_path(canonical_root),
8223 provider_session: Some(identity),
8224 active_session: None,
8225 environment_profile: None,
8226 bundle: None,
8227 context_id: None,
8228 context: None,
8229 exported_context: None,
8230 task_binding: None,
8231 created_at_unix_ms: now,
8232 updated_at_unix_ms: now,
8233 last_error: None,
8234 };
8235 self.insert_record(record.clone())?;
8236 if let Err(error) = self.persist_state_locked() {
8237 self.remove_record_memory(&record.record_id);
8238 return Err(persistence_failure(error));
8239 }
8240 drop(transaction);
8241 self.publish_record(record.clone());
8242 Ok(record)
8243 }
8244
8245 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8246 self.session_bindings
8247 .lock()
8248 .unwrap_or_else(|poisoned| poisoned.into_inner())
8249 .iter()
8250 .find_map(|(instance_id, binding)| {
8251 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8252 workspace_id: binding.workspace_id.clone(),
8253 session: SessionKey {
8254 instance_id: *instance_id,
8255 generation: binding.generation,
8256 },
8257 })
8258 })
8259 }
8260
8261 async fn forget_session_record(
8262 &self,
8263 record_id: &SessionRecordId,
8264 ) -> Result<(), NodeFailure> {
8265 let record = self.record(record_id)?;
8266 if record.active_session.is_some()
8267 || matches!(
8268 record.state,
8269 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8270 )
8271 {
8272 return Err(failure(
8273 NodeFailureCode::SessionRecordBusy,
8274 "stop the managed session before forgetting it",
8275 ));
8276 }
8277 if let Some(address) = self.bound_address_for_record(record_id) {
8278 self.remove_session(&address).await?;
8279 }
8280 self.remove_managed_session_record(record_id, &record.workspace_id).await
8281 }
8282
8283 async fn remove_managed_session_record(
8298 &self,
8299 record_id: &SessionRecordId,
8300 workspace_id: &WorkspaceId,
8301 ) -> Result<(), NodeFailure> {
8302 let managed_lease_id = self.managed_worktrees.lock()
8303 .unwrap_or_else(|poisoned| poisoned.into_inner())
8304 .lease_for_workspace(workspace_id);
8305 if !self.destroy_record_materialization(record_id)? {
8306 let transaction = self
8307 .state_transaction
8308 .lock()
8309 .unwrap_or_else(|poisoned| poisoned.into_inner());
8310 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8311 failure(
8312 NodeFailureCode::UnknownSessionRecord,
8313 "managed session record does not exist",
8314 )
8315 })?;
8316 if let Err(error) = self.persist_state_locked() {
8317 let _ = self.insert_record(removed);
8318 return Err(persistence_failure(error));
8319 }
8320 drop(transaction);
8321 }
8322 self.publish(NodeEvent::SessionRecordRemoved {
8323 record_id: record_id.clone(),
8324 });
8325 if let Some(lease_id) = managed_lease_id {
8326 if let Some(snapshot) = self.managed_worktrees.lock()
8327 .unwrap_or_else(|poisoned| poisoned.into_inner())
8328 .get(&lease_id)
8329 .map(ManagedWorktreeLeaseRecord::snapshot)
8330 {
8331 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8332 }
8333 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8334 }
8335 Ok(())
8336 }
8337
8338 async fn retire_unavailable_session_records(&self) {
8351 let policy = self.session_record_retention;
8352 let age_enabled = policy.age_ms > 0;
8353 let keep_enabled = policy.keep_per_workspace > 0;
8354 if !age_enabled && !keep_enabled {
8355 return;
8356 }
8357 let now = unix_time_ms();
8358 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8359 self.session_records
8360 .lock()
8361 .unwrap_or_else(|poisoned| poisoned.into_inner())
8362 .records
8363 .values()
8364 .filter(|record| record.state == ManagedSessionState::Unavailable)
8365 .map(|record| {
8366 (
8367 record.record_id.clone(),
8368 record.workspace_id.clone(),
8369 record.provider.clone(),
8370 record.updated_at_unix_ms,
8371 )
8372 })
8373 .collect()
8374 };
8375 if candidates.is_empty() {
8376 return;
8377 }
8378 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8379 BTreeMap::new();
8380 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8381 by_workspace
8382 .entry(workspace_id)
8383 .or_default()
8384 .push((record_id, provider, updated_at_unix_ms));
8385 }
8386 let reason = if age_enabled && keep_enabled {
8387 "age+keep-n"
8388 } else if age_enabled {
8389 "age"
8390 } else {
8391 "keep-n"
8392 };
8393 let mut retired: u64 = 0;
8394 for (workspace_id, mut records) in by_workspace {
8395 records.sort_by(|left, right| right.2.cmp(&left.2));
8398 for (index, (record_id, provider, updated_at_unix_ms)) in
8399 records.into_iter().enumerate()
8400 {
8401 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8402 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8403 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8404 let should_retire = if age_enabled && keep_enabled {
8405 age_hit && keep_hit
8406 } else {
8407 age_hit || keep_hit
8408 };
8409 if !should_retire {
8410 continue;
8411 }
8412 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8413 Ok(()) => {
8414 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8415 retired += 1;
8416 tracing::info!(
8417 node_id = %self.node_id,
8418 record_id = %record_id,
8419 workspace_id = %workspace_id,
8420 provider = %provider,
8421 age_ms = record_age_ms,
8422 reason,
8423 "managed session record retired"
8424 );
8425 }
8426 Err(error) => tracing::warn!(
8427 node_id = %self.node_id,
8428 record_id = %record_id,
8429 workspace_id = %workspace_id,
8430 code = ?error.code,
8431 cause = %error.message,
8432 "session record retention sweep failed to retire a record"
8433 ),
8434 }
8435 }
8436 }
8437 if retired > 0 {
8438 let remaining_unavailable = self
8439 .session_records
8440 .lock()
8441 .unwrap_or_else(|poisoned| poisoned.into_inner())
8442 .records
8443 .values()
8444 .filter(|record| record.state == ManagedSessionState::Unavailable)
8445 .count();
8446 tracing::info!(
8447 node_id = %self.node_id,
8448 retired,
8449 remaining_unavailable,
8450 "session record retention sweep completed"
8451 );
8452 }
8453 }
8454
8455 async fn resume_session_record(
8456 &self,
8457 record_id: &SessionRecordId,
8458 terminal_size: gate4agent_types::TerminalSize,
8459 initial_prompt: Option<String>,
8460 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8461 let record = self.record(record_id)?;
8462 if record.active_session.is_some()
8463 || matches!(
8464 record.state,
8465 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8466 )
8467 {
8468 return Err(failure(
8469 NodeFailureCode::SessionRecordBusy,
8470 "managed session already has a live runtime binding",
8471 ));
8472 }
8473 let identity = record.provider_session.clone().ok_or_else(|| {
8474 failure(
8475 NodeFailureCode::SessionRecordNotResumable,
8476 "managed session has no verified provider session identity",
8477 )
8478 })?;
8479 let working_directory = self.workspace_root(&record.workspace_id)?;
8480 if !platform::roots_equal(
8481 &working_directory,
8482 windows_path_text(&record.canonical_root),
8483 ) {
8484 return Err(failure(
8485 NodeFailureCode::SessionRecordConflict,
8486 "managed session workspace root changed; refusing to resume in another directory",
8487 ));
8488 }
8489 {
8490 let records = self
8491 .session_records
8492 .lock()
8493 .unwrap_or_else(|poisoned| poisoned.into_inner());
8494 if records.records.values().any(|candidate| {
8495 candidate.record_id != *record_id
8496 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8497 session_registry::same_provider_session(
8498 &candidate.provider,
8499 candidate_identity,
8500 &record.provider,
8501 &identity,
8502 )
8503 })
8504 && (candidate.active_session.is_some()
8505 || matches!(
8506 candidate.state,
8507 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8508 ))
8509 }) {
8510 return Err(failure(
8511 NodeFailureCode::SessionRecordConflict,
8512 "provider session is already active through another managed record",
8513 ));
8514 }
8515 }
8516 let runtime_requirement = if initial_prompt.is_some() {
8517 ProviderRuntimeRequirement::ResumeWithPrompt
8518 } else {
8519 ProviderRuntimeRequirement::Resume
8520 };
8521 let runtime_policy = self.admit_provider_runtime(
8522 &record.provider,
8523 runtime_requirement,
8524 )
8525 .await?;
8526 let request = ResumeLaunchRequest {
8527 working_directory,
8528 terminal_size,
8529 initial_prompt,
8530 };
8531 request
8532 .validate()
8533 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8534 self.ensure_binding_capacity()?;
8535 let resolved_materialization = match self.resolve_record_materialization(
8536 record_id,
8537 &record.provider,
8538 record.mode,
8539 record.environment_profile.as_ref(),
8540 record.bundle.as_ref(),
8541 record.context.as_ref(),
8542 ) {
8543 Ok(materialization) => materialization,
8544 Err(error) => {
8545 let label = match error.code {
8546 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8547 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8548 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8549 _ => "environment-profile-unavailable",
8550 };
8551 self.mark_record_error(record_id, label)?;
8552 return Err(error);
8553 }
8554 };
8555 let (materialization_id, environment_overlay) = match resolved_materialization {
8556 Some((id, overlay)) => (Some(id), overlay),
8557 None => (None, None),
8558 };
8559 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8560 let mut environment_selection = match SpawnPreparer::select_environment(self,
8561 instance_id,
8562 &record.provider,
8563 record.mode,
8564 record.environment_profile.as_ref(),
8565 ) {
8566 Ok(selection) => selection,
8567 Err(error) => {
8568 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8569 return Err(error);
8570 }
8571 };
8572 let mut instance_overlay = environment_overlay
8573 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8574 .transpose()?
8575 .flatten();
8576 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8577 self.remove_session(&stale_address).await?;
8578 }
8579 let address = SessionAddress {
8580 workspace_id: record.workspace_id.clone(),
8581 session: SessionKey {
8582 instance_id,
8583 generation: SessionGeneration(1),
8584 },
8585 };
8586 let transaction = self
8587 .state_transaction
8588 .lock()
8589 .unwrap_or_else(|poisoned| poisoned.into_inner());
8590 let current = self.record(record_id)?;
8591 if current.active_session.is_some()
8592 || matches!(
8593 current.state,
8594 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8595 )
8596 {
8597 return Err(failure(
8598 NodeFailureCode::SessionRecordBusy,
8599 "managed session became active while resume was being prepared",
8600 ));
8601 }
8602 self.bind_managed_session_with_materialization(
8603 &address,
8604 record_id.clone(),
8605 runtime_policy,
8606 record.environment_profile.clone(),
8607 record.bundle.clone(),
8608 record.context.clone(),
8609 materialization_id,
8610 );
8611 if let Some(selection) = environment_selection.take() {
8612 selection.retain();
8613 }
8614 if let Some(overlay) = instance_overlay.take() {
8615 overlay.retain();
8616 }
8617 drop(transaction);
8618
8619 let transport = match record.mode {
8620 SessionMode::Pty => TransportKind::Pty,
8621 SessionMode::Inline => TransportKind::Pipe,
8622 SessionMode::Acp => TransportKind::Acp,
8623 };
8624 let agent_id = record.provider.clone();
8625 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8626 if let Err(error) = self
8627 .dispatch_bounded(
8628 ControlCommand::Register {
8629 instance_id,
8630 agent_id,
8631 transport,
8632 },
8633 dispatch_timeout,
8634 )
8635 .await
8636 {
8637 if let Some(binding) = self.remove_binding(&address) {
8638 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8639 }
8640 return Err(error);
8641 }
8642 let command = self.prepare_command(ControlCommand::Resume {
8643 instance_id,
8644 target: ResumeTarget::ProviderSession { identity },
8645 runtime_policy,
8646 request,
8647 });
8648 let started_after = self.current_sequence();
8649 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8650 self.rollback_spawn(&address, dispatch_timeout).await?;
8651 return Err(error);
8652 }
8653 if let Err(error) = self.dispatch_envelope(command) {
8654 self.clear_armed_resume(&address);
8655 self.rollback_spawn(&address, dispatch_timeout).await?;
8656 return Err(error);
8657 }
8658 match self
8659 .wait_for_record_resume(&address, started_after)
8660 .await
8661 {
8662 Ok(settled_address) => {
8663 let managed_lease_id = {
8664 self.managed_worktrees.lock()
8665 .unwrap_or_else(|poisoned| poisoned.into_inner())
8666 .lease_for_workspace(&record.workspace_id)
8667 };
8668 if let Some(lease_id) = managed_lease_id {
8669 {
8670 let mut bindings = self.session_bindings.lock()
8671 .unwrap_or_else(|poisoned| poisoned.into_inner());
8672 let binding = bindings.get_mut(&settled_address.session.instance_id)
8673 .ok_or_else(|| failure(
8674 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8675 "resumed managed session lost its exact binding",
8676 ))?;
8677 if binding.generation != settled_address.session.generation {
8678 return Err(failure(
8679 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8680 "resumed managed session generation diverged",
8681 ));
8682 }
8683 binding.managed_worktree_lease_id = Some(lease_id.clone());
8684 }
8685 let snapshot = self.managed_worktrees.lock()
8686 .unwrap_or_else(|poisoned| poisoned.into_inner())
8687 .bind_session(
8688 &lease_id,
8689 ManagedWorktreeSessionHolder {
8690 incarnation_id: self.incarnation_id,
8691 instance_id: settled_address.session.instance_id,
8692 generation: settled_address.session.generation,
8693 },
8694 Some(record_id.clone()),
8695 unix_time_ms(),
8696 ).map_err(|message| failure(
8697 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8698 &message,
8699 ))?;
8700 if let Err(error) = self.persist_state() {
8701 let _ = self.remove_session(&settled_address).await;
8702 return Err(persistence_failure(error));
8703 }
8704 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8705 }
8706 Ok((self.record(record_id)?, settled_address))
8707 }
8708 Err(error) => {
8709 if matches!(
8710 error.code,
8711 NodeFailureCode::SessionRecordNotResumable
8712 | NodeFailureCode::BackendOperationFailed
8713 ) {
8714 if let Some(bound) = self.bound_address_for_record(record_id) {
8715 let _ = self.remove_session(&bound).await;
8716 }
8717 }
8718 Err(error)
8719 }
8720 }
8721 }
8722
8723 async fn wait_for_record_resume(
8724 &self,
8725 initial_address: &SessionAddress,
8726 after_sequence: u64,
8727 ) -> Result<SessionAddress, NodeFailure> {
8728 let deadline = Instant::now()
8729 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8730 let mut scan_after = after_sequence;
8731 loop {
8732 let events = self
8733 .history
8734 .lock()
8735 .unwrap_or_else(|poisoned| poisoned.into_inner())
8736 .events
8737 .iter()
8738 .filter(|event| event.sequence > scan_after)
8739 .cloned()
8740 .collect::<Vec<_>>();
8741 for envelope in events {
8742 scan_after = scan_after.max(envelope.sequence);
8743 let NodeEvent::Control { address, event } = envelope.event else {
8744 continue;
8745 };
8746 if address.session.instance_id != initial_address.session.instance_id {
8747 continue;
8748 }
8749 match event.event {
8750 ControlEventKind::Resumed { .. } => return Ok(address),
8751 ControlEventKind::ResumeDenied { reason } => {
8752 return Err(failure(
8753 NodeFailureCode::SessionRecordNotResumable,
8754 &reason,
8755 ));
8756 }
8757 ControlEventKind::ResumeFailed { message }
8758 | ControlEventKind::CommandRejected { message } => {
8759 return Err(failure(
8760 NodeFailureCode::BackendOperationFailed,
8761 &message,
8762 ));
8763 }
8764 _ => {}
8765 }
8766 }
8767 if Instant::now() >= deadline {
8768 return Err(failure(
8769 NodeFailureCode::BackendBusy,
8770 "managed resume did not settle before the bounded deadline",
8771 ));
8772 }
8773 sleep(Duration::from_millis(5)).await;
8774 }
8775 }
8776
8777 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8778 self.workspaces
8779 .read()
8780 .unwrap_or_else(|poisoned| poisoned.into_inner())
8781 .get(workspace_id)
8782 .cloned()
8783 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8784 }
8785
8786 fn workspace_roots(&self) -> Vec<PathBuf> {
8787 self.workspaces
8788 .read()
8789 .unwrap_or_else(|poisoned| poisoned.into_inner())
8790 .values()
8791 .map(PathBuf::from)
8792 .collect()
8793 }
8794
8795 async fn inspect_workspace(
8796 &self,
8797 workspace_id: WorkspaceId,
8798 ) -> Result<WorkspaceInspection, NodeFailure> {
8799 let _permit = self
8800 .inspection_slots
8801 .clone()
8802 .try_acquire_owned()
8803 .map_err(|_| {
8804 tracing::warn!(
8805 workspace_id = %workspace_id,
8806 "workspace inspection rejected: inspection capacity is busy",
8807 );
8808 failure(
8809 NodeFailureCode::BackendBusy,
8810 "workspace inspection capacity is busy",
8811 )
8812 })?;
8813 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8814 tracing::warn!(
8815 workspace_id = %workspace_id,
8816 code = ?error.code,
8817 "workspace inspection rejected: workspace is not registered",
8818 );
8819 error
8820 })?;
8821 let tree_root = canonical_root.clone();
8822 let started_at = Instant::now();
8823 let time_budget_ms = workspace_inspection_time_budget_ms();
8824 let entry_cap = workspace_inspection_entry_cap();
8825 let deadline = started_at + Duration::from_millis(time_budget_ms);
8826 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8827 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8828 })
8829 .await
8830 .map_err(|error| {
8831 tracing::warn!(
8832 workspace_id = %workspace_id,
8833 error = %error,
8834 "workspace inspection rejected: walk task failed",
8835 );
8836 failure(
8837 NodeFailureCode::BackendOperationFailed,
8838 &format!("workspace inspection task failed: {error}"),
8839 )
8840 })?;
8841 let (mut git, git_time_budget_exceeded) =
8842 inspect_git_workspace(&canonical_root, deadline).await;
8843 if !git.worktrees.is_empty() {
8844 let registered = self
8845 .workspaces
8846 .read()
8847 .unwrap_or_else(|poisoned| poisoned.into_inner())
8848 .iter()
8849 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8850 .collect::<Vec<_>>();
8851 for worktree in &mut git.worktrees {
8852 worktree.workspace_id = registered
8853 .iter()
8854 .find(|(_, root)| {
8855 worktree_paths_equal(root, windows_path_text(&worktree.path))
8856 })
8857 .map(|(workspace_id, _)| workspace_id.clone());
8858 }
8859 }
8860 if git.is_repository {
8861 git.managed_worktree = self
8862 .managed_worktrees
8863 .lock()
8864 .unwrap_or_else(|poisoned| poisoned.into_inner())
8865 .git_scope(&workspace_id, git.branch.as_deref());
8866 }
8867 let truncation = workspace_inspection_truncation(
8868 &walk_budget,
8869 git_time_budget_exceeded,
8870 started_at.elapsed(),
8871 );
8872 if let Some(truncation) = &truncation {
8873 tracing::info!(
8874 workspace_id = %workspace_id,
8875 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8876 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8877 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8878 entries_visited = truncation.entries_visited,
8879 elapsed_ms = truncation.elapsed_ms,
8880 "workspace inspection truncated by its inner budget",
8881 );
8882 }
8883 Ok(WorkspaceInspection {
8884 workspace_id,
8885 entries,
8886 tree_truncated,
8887 git,
8888 truncation,
8889 })
8890 }
8891
8892 async fn browse_host_directories(
8893 &self,
8894 directory: Option<OpaqueHostPath>,
8895 after: Option<OpaqueHostPath>,
8896 ) -> Result<HostDirectoryListing, NodeFailure> {
8897 let permit = self
8898 .inspection_slots
8899 .clone()
8900 .try_acquire_owned()
8901 .map_err(|_| {
8902 failure(
8903 NodeFailureCode::BackendBusy,
8904 "host directory browse capacity is busy",
8905 )
8906 })?;
8907 let task = tokio::task::spawn_blocking(move || {
8908 let _permit = permit;
8909 browse_host_directories(directory, after)
8910 });
8911 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8912 .await
8913 .map_err(|_| {
8914 failure(
8915 NodeFailureCode::HostDirectoryReadTimedOut,
8916 "host directory browse exceeded its bounded deadline",
8917 )
8918 })?
8919 .map_err(|_| {
8920 failure(
8921 NodeFailureCode::HostDirectoryReadFailed,
8922 "host directory browse task failed",
8923 )
8924 })?
8925 .map_err(host_directory_failure)
8926 }
8927
8928 async fn read_workspace_file(
8929 &self,
8930 workspace_id: WorkspaceId,
8931 path: RepositoryPath,
8932 ) -> Result<WorkspaceFileRead, NodeFailure> {
8933 let canonical_root = self.workspace_root(&workspace_id)?;
8934 let permit = self
8935 .inspection_slots
8936 .clone()
8937 .try_acquire_owned()
8938 .map_err(|_| {
8939 failure(
8940 NodeFailureCode::BackendBusy,
8941 "workspace file read capacity is busy",
8942 )
8943 })?;
8944 let path_to_read = path.clone();
8945 let task = tokio::task::spawn_blocking(move || {
8946 let _permit = permit;
8947 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8948 });
8949 let content = timeout(
8950 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8951 task,
8952 )
8953 .await
8954 .map_err(|_| {
8955 failure(
8956 NodeFailureCode::RepositoryFileReadTimedOut,
8957 "repository file read exceeded its bounded deadline",
8958 )
8959 })?
8960 .map_err(|_| {
8961 failure(
8962 NodeFailureCode::RepositoryFileReadFailed,
8963 "repository file read task failed",
8964 )
8965 })?
8966 .map_err(workspace_file_failure)?;
8967 let content = match content {
8968 WorkspaceFileBytes::Utf8(text) => {
8969 let revision = Some(workspace_file_revision(text.as_bytes()));
8970 return Ok(WorkspaceFileRead {
8971 workspace_id,
8972 path,
8973 content: WorkspaceFileContent::Utf8 {
8974 byte_len: u32::try_from(text.len())
8975 .expect("bounded workspace text length must fit u32"),
8976 text,
8977 },
8978 revision,
8979 });
8980 }
8981 WorkspaceFileBytes::NonUtf8 { byte_length } => {
8982 WorkspaceFileContent::NonUtf8 {
8983 byte_len: u32::try_from(byte_length)
8984 .expect("bounded workspace file length must fit u32"),
8985 }
8986 }
8987 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8988 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8989 },
8990 };
8991 Ok(WorkspaceFileRead {
8992 workspace_id,
8993 path,
8994 content,
8995 revision: None,
8996 })
8997 }
8998
8999 async fn write_workspace_file(
9000 &self,
9001 workspace_id: WorkspaceId,
9002 path: RepositoryPath,
9003 expected_revision: WorkspaceFileRevision,
9004 text: String,
9005 ) -> Result<WorkspaceFileRead, NodeFailure> {
9006 let canonical_root = self.workspace_root(&workspace_id)?;
9007 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9008 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9009 })?;
9010 let path_to_write = path.clone();
9011 let expected = expected_revision.as_str().to_owned();
9012 let text_to_write = text.clone();
9013 let task = tokio::task::spawn_blocking(move || {
9014 let _permit = permit;
9015 write_workspace_file_to_disk(
9016 Path::new(&canonical_root),
9017 &path_to_write,
9018 &expected,
9019 &text_to_write,
9020 )
9021 });
9022 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9023 .await
9024 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9025 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9026 .map_err(workspace_file_write_failure)?;
9027 Ok(WorkspaceFileRead {
9028 workspace_id,
9029 path,
9030 content: WorkspaceFileContent::Utf8 {
9031 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9032 text,
9033 },
9034 revision: Some(WorkspaceFileRevision::new(revision)
9035 .expect("workspace writer returns a valid SHA-256 revision")),
9036 })
9037 }
9038
9039 async fn create_workspace_file(
9040 &self,
9041 workspace_id: WorkspaceId,
9042 path: RepositoryPath,
9043 ) -> Result<WorkspaceFileRead, NodeFailure> {
9044 let canonical_root = self.workspace_root(&workspace_id)?;
9045 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9046 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9047 })?;
9048 let path_to_create = path.clone();
9049 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9050 let task_commit_state = Arc::clone(&commit_state);
9051 let task = tokio::task::spawn_blocking(move || {
9052 let _permit = permit;
9053 create_workspace_file_on_disk(
9054 Path::new(&canonical_root),
9055 &path_to_create,
9056 &task_commit_state,
9057 )
9058 });
9059 let revision = settle_workspace_entry_create(
9060 task,
9061 commit_state,
9062 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9063 "repository file create exceeded its bounded deadline",
9064 "repository file create task failed",
9065 )
9066 .await?;
9067 Ok(WorkspaceFileRead {
9068 workspace_id,
9069 path,
9070 content: WorkspaceFileContent::Utf8 {
9071 text: String::new(),
9072 byte_len: 0,
9073 },
9074 revision: Some(WorkspaceFileRevision::new(revision)
9075 .expect("workspace creator returns a valid SHA-256 revision")),
9076 })
9077 }
9078
9079 async fn create_workspace_directory(
9080 &self,
9081 workspace_id: WorkspaceId,
9082 path: RepositoryPath,
9083 ) -> Result<WorkspaceEntry, NodeFailure> {
9084 let canonical_root = self.workspace_root(&workspace_id)?;
9085 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9086 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9087 })?;
9088 let path_to_create = path.clone();
9089 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9090 let task_commit_state = Arc::clone(&commit_state);
9091 let task = tokio::task::spawn_blocking(move || {
9092 let _permit = permit;
9093 create_workspace_directory_on_disk(
9094 Path::new(&canonical_root),
9095 &path_to_create,
9096 &task_commit_state,
9097 )
9098 });
9099 settle_workspace_entry_create(
9100 task,
9101 commit_state,
9102 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9103 "repository directory create exceeded its bounded deadline",
9104 "repository directory create task failed",
9105 )
9106 .await?;
9107 Ok(WorkspaceEntry {
9108 relative_path: path,
9109 kind: WorkspaceEntryKind::Directory,
9110 })
9111 }
9112
9113 async fn read_git_history(
9114 &self,
9115 workspace_id: WorkspaceId,
9116 path: Option<RepositoryPath>,
9117 before: Option<GitObjectId>,
9118 limit: u16,
9119 ) -> Result<GitHistoryPage, NodeFailure> {
9120 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9121 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9122 })?;
9123 let root = self.workspace_root(&workspace_id)?;
9124 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9125 }
9126
9127 async fn read_git_diff(
9128 &self,
9129 workspace_id: WorkspaceId,
9130 request: GitDiffRequest,
9131 ) -> Result<GitDiff, NodeFailure> {
9132 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9133 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9134 })?;
9135 let root = self.workspace_root(&workspace_id)?;
9136 read_git_diff_bounded(&root, request).await
9137 }
9138
9139 fn native_session_route_root(
9140 &self,
9141 route: &NativeSessionCatalogRoute,
9142 ) -> Result<Option<String>, NodeFailure> {
9143 route
9144 .validate()
9145 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9146 match route.scope {
9147 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9148 .workspace_id
9149 .as_ref()
9150 .map(|workspace_id| self.workspace_root(workspace_id))
9151 .transpose(),
9152 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9153 }
9154 }
9155
9156 fn project_native_session_entries(
9157 &self,
9158 route: &NativeSessionCatalogRoute,
9159 entries: Vec<ScopedNativeSessionCatalogEntry>,
9160 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9161 let current_root = route
9162 .workspace_id
9163 .as_ref()
9164 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9165 let records = self
9166 .session_records
9167 .lock()
9168 .unwrap_or_else(|poisoned| poisoned.into_inner());
9169 let mut seen_records = std::collections::HashSet::new();
9170 entries
9171 .into_iter()
9172 .map(|entry| {
9173 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9174 let matches = records
9175 .records
9176 .values()
9177 .filter(|record| {
9178 record.provider == route.provider
9179 && record.workspace_id == *workspace_id
9180 && current_root.as_deref().is_some_and(|root| {
9181 platform::roots_equal(
9182 root,
9183 windows_path_text(&record.canonical_root),
9184 )
9185 })
9186 && record.provider_session.as_ref().is_some_and(|identity| {
9187 session_registry::same_provider_session(
9188 &record.provider,
9189 identity,
9190 &route.provider,
9191 &entry.provider_session,
9192 )
9193 })
9194 })
9195 .map(|record| record.record_id.clone())
9196 .collect::<Vec<_>>();
9197 (matches.len() == 1
9198 && seen_records.insert(matches[0].clone()))
9199 .then(|| matches[0].clone())
9200 });
9201 let metadata = entry.metadata;
9202 crate::protocol::NativeSessionCatalogEntry {
9203 selection_id: metadata.selection_id,
9204 title: metadata.title,
9205 modified_at_unix_ms: metadata.modified_at_unix_ms,
9206 model: metadata.model,
9207 message_count: metadata.message_count,
9208 completed_turn_count: metadata.completed_turn_count,
9209 external_group: entry.external_group,
9210 record_id,
9211 }
9212 })
9213 .collect()
9214 }
9215
9216 async fn catalog_native_sessions(
9217 &self,
9218 route: NativeSessionCatalogRoute,
9219 limit: u16,
9220 ) -> Result<(
9221 Vec<crate::protocol::NativeSessionCatalogEntry>,
9222 crate::protocol::NativeSessionCatalogSummary,
9223 ), NodeFailure> {
9224 let canonical_root = self.native_session_route_root(&route)?;
9225 let registered_workspace_roots = self.workspace_roots();
9226 let provider = route.provider.clone();
9227 let scope = route.scope;
9228 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9229 failure(
9230 NodeFailureCode::InvalidRequest,
9231 "native session catalog is not configured",
9232 )
9233 })?;
9234 let _permit = Arc::clone(&self.inspection_slots)
9235 .try_acquire_owned()
9236 .map_err(|_| {
9237 failure(
9238 NodeFailureCode::BackendBusy,
9239 "native session catalog capacity is busy",
9240 )
9241 })?;
9242 let page = run_native_session_catalog_operation(
9243 catalog,
9244 "native session catalog",
9245 move |catalog| {
9246 catalog.catalog_initial_for_scope(
9247 &provider,
9248 scope,
9249 canonical_root.as_deref().map(Path::new),
9250 ®istered_workspace_roots,
9251 limit,
9252 )
9253 },
9254 )
9255 .await?;
9256 let summary = crate::protocol::NativeSessionCatalogSummary {
9257 catalog_revision: page.revision,
9258 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9259 recent_total_count: page.recent_total_count,
9260 older_total_count: page.older_total_count,
9261 recent_next_after_selection_id: page.next_after_selection_id,
9262 recent_has_more: page.remaining_count > 0,
9263 };
9264 Ok((self.project_native_session_entries(&route, page.entries), summary))
9265 }
9266
9267 async fn page_native_sessions(
9268 &self,
9269 route: NativeSessionCatalogRoute,
9270 window: crate::protocol::NativeSessionCatalogWindow,
9271 catalog_revision: u64,
9272 recent_cutoff_unix_ms: u64,
9273 after_selection_id: Option<String>,
9274 limit: u16,
9275 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9276 let canonical_root = self.native_session_route_root(&route)?;
9277 let registered_workspace_roots = self.workspace_roots();
9278 let provider = route.provider.clone();
9279 let scope = route.scope;
9280 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9281 failure(
9282 NodeFailureCode::InvalidRequest,
9283 "native session catalog is not configured",
9284 )
9285 })?;
9286 let _permit = Arc::clone(&self.inspection_slots)
9287 .try_acquire_owned()
9288 .map_err(|_| {
9289 failure(
9290 NodeFailureCode::BackendBusy,
9291 "native session catalog capacity is busy",
9292 )
9293 })?;
9294 let page = run_native_session_catalog_operation(
9295 catalog,
9296 "native session catalog page",
9297 move |catalog| {
9298 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9299 &provider,
9300 scope,
9301 canonical_root.as_deref().map(Path::new),
9302 ®istered_workspace_roots,
9303 window,
9304 catalog_revision,
9305 recent_cutoff_unix_ms,
9306 after_selection_id.as_deref(),
9307 limit,
9308 ))
9309 },
9310 )
9311 .await?;
9312 let page = page.map_err(|error| match error {
9313 NativeSessionCatalogError::StaleCatalog => failure(
9314 NodeFailureCode::StaleNativeSessionCatalog,
9315 "native session catalog revision or cursor is stale",
9316 ),
9317 _ => failure(
9318 NodeFailureCode::BackendOperationFailed,
9319 "native session catalog page failed",
9320 ),
9321 })?;
9322 let entries = self.project_native_session_entries(&route, page.entries);
9323 Ok(crate::protocol::NativeSessionCatalogPage {
9324 window: page.window,
9325 revision: page.revision,
9326 entries,
9327 next_after_selection_id: page.next_after_selection_id,
9328 remaining_count: page.remaining_count,
9329 has_more: page.remaining_count > 0,
9330 })
9331 }
9332
9333 async fn preview_native_session(
9334 &self,
9335 selection: NativeSessionSelection,
9336 message_limit: u16,
9337 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9338 selection
9339 .validate()
9340 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9341 let canonical_root = self.native_session_route_root(&selection.route)?;
9342 let registered_workspace_roots = self.workspace_roots();
9343 let provider = selection.route.provider.clone();
9344 let scope = selection.route.scope;
9345 let catalog_revision = selection.catalog_revision;
9346 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9347 let selection_id = selection.selection_id.clone();
9348 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9349 failure(
9350 NodeFailureCode::InvalidRequest,
9351 "native session preview is not configured",
9352 )
9353 })?;
9354 let _permit = Arc::clone(&self.inspection_slots)
9355 .try_acquire_owned()
9356 .map_err(|_| {
9357 failure(
9358 NodeFailureCode::BackendBusy,
9359 "native session preview capacity is busy",
9360 )
9361 })?;
9362 let preview = run_native_session_catalog_operation(
9363 catalog,
9364 "native session preview",
9365 move |catalog| {
9366 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9367 &provider,
9368 scope,
9369 canonical_root.as_deref().map(Path::new),
9370 ®istered_workspace_roots,
9371 catalog_revision,
9372 recent_cutoff_unix_ms,
9373 &selection_id,
9374 message_limit,
9375 ))
9376 },
9377 )
9378 .await?;
9379 preview
9380 .map(Into::into)
9381 .map_err(native_session_preview_failure)
9382 }
9383
9384 async fn index_native_session(
9385 &self,
9386 selection: NativeSessionSelection,
9387 display_name: String,
9388 ) -> Result<ManagedSessionRecord, NodeFailure> {
9389 selection
9390 .validate()
9391 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9392 if selection.route.scope
9393 != gate4agent_types::NativeSessionCatalogScope::Workspace
9394 {
9395 return Err(failure(
9396 NodeFailureCode::WorkspaceRegistrationRequired,
9397 "register the external project as a workspace before indexing or resuming it",
9398 ));
9399 }
9400 let workspace_id = selection
9401 .route
9402 .workspace_id
9403 .clone()
9404 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9405 let canonical_root = self
9406 .native_session_route_root(&selection.route)?
9407 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9408 let registered_workspace_roots = self.workspace_roots();
9409 let provider = selection.route.provider.clone();
9410 let catalog_revision = selection.catalog_revision;
9411 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9412 let selection_id = selection.selection_id.clone();
9413 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9414 failure(
9415 NodeFailureCode::InvalidRequest,
9416 "native session index is not configured",
9417 )
9418 })?;
9419 let _permit = Arc::clone(&self.inspection_slots)
9420 .try_acquire_owned()
9421 .map_err(|_| {
9422 failure(
9423 NodeFailureCode::BackendBusy,
9424 "native session index capacity is busy",
9425 )
9426 })?;
9427 let resolved = run_native_session_catalog_operation(
9428 catalog,
9429 "native session index resolution",
9430 move |catalog| {
9431 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9432 &provider,
9433 gate4agent_types::NativeSessionCatalogScope::Workspace,
9434 Some(Path::new(&canonical_root)),
9435 ®istered_workspace_roots,
9436 catalog_revision,
9437 recent_cutoff_unix_ms,
9438 &selection_id,
9439 ))
9440 },
9441 )
9442 .await?
9443 .map_err(native_session_preview_failure)?;
9444 self.index_provider_session_with_policy(
9445 workspace_id,
9446 selection.route.provider,
9447 resolved.identity,
9448 display_name,
9449 true,
9450 )
9451 }
9452
9453 async fn preview_native_session_by_session_id(
9454 &self,
9455 workspace_id: WorkspaceId,
9456 provider: AgentId,
9457 session_id: String,
9458 message_limit: u16,
9459 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9460 let canonical_root = self.workspace_root(&workspace_id)?;
9461 let registered_workspace_roots = self.workspace_roots();
9462 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9463 failure(
9464 NodeFailureCode::InvalidRequest,
9465 "native session preview is not configured",
9466 )
9467 })?;
9468 let _permit = Arc::clone(&self.inspection_slots)
9469 .try_acquire_owned()
9470 .map_err(|_| {
9471 failure(
9472 NodeFailureCode::BackendBusy,
9473 "native session preview capacity is busy",
9474 )
9475 })?;
9476 run_native_session_catalog_operation(
9477 catalog,
9478 "native session preview",
9479 move |catalog| {
9480 catalog.preview_session_id_for_workspace(
9481 &provider,
9482 Path::new(&canonical_root),
9483 ®istered_workspace_roots,
9484 &session_id,
9485 message_limit,
9486 )
9487 },
9488 )
9489 .await
9490 }
9491
9492 async fn register_workspace(
9493 &self,
9494 workspace_id: WorkspaceId,
9495 root: String,
9496 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9497 validate_workspace_request_root(&root)?;
9498 let canonicalize_workspace_id = workspace_id.clone();
9499 let workspace = tokio::task::spawn_blocking(move || {
9500 WorkspaceConfig::new(canonicalize_workspace_id, root)
9501 })
9502 .await
9503 .map_err(|error| {
9504 failure(
9505 NodeFailureCode::BackendOperationFailed,
9506 &format!("workspace canonicalization task failed: {error}"),
9507 )
9508 })?
9509 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9510 let transaction = self
9511 .state_transaction
9512 .lock()
9513 .unwrap_or_else(|poisoned| poisoned.into_inner());
9514 let mut workspaces = self
9515 .workspaces
9516 .write()
9517 .unwrap_or_else(|poisoned| poisoned.into_inner());
9518 if workspaces.contains_key(&workspace_id) {
9519 return Err(failure(
9520 NodeFailureCode::DuplicateWorkspaceId,
9521 "workspace ID is already registered",
9522 ));
9523 }
9524 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9525 platform::roots_equal(existing_root, workspace.canonical_root())
9526 }) {
9527 return Err(failure(
9528 NodeFailureCode::DuplicateWorkspaceRoot,
9529 &format!("workspace root is already registered as '{existing_id}'"),
9530 ));
9531 }
9532 {
9533 let records = self
9534 .session_records
9535 .lock()
9536 .unwrap_or_else(|poisoned| poisoned.into_inner());
9537 if let Some(record) = records.records.values().find(|record| {
9538 record.workspace_id == workspace_id
9539 && !record
9540 .canonical_root
9541 .as_utf8()
9542 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9543 }) {
9544 return Err(failure(
9545 NodeFailureCode::SessionRecordConflict,
9546 &format!(
9547 "workspace ID is retained by session record '{}' at another root",
9548 record.record_id,
9549 ),
9550 ));
9551 }
9552 }
9553 let snapshot = WorkspaceSnapshot {
9554 workspace_id: workspace_id.clone(),
9555 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9556 sessions: Vec::new(),
9557 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9558 managed_worktree_profiles: Some(WorktreeProfileInventory {
9559 profiles: Vec::new(),
9560 }),
9561 };
9562 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9563 drop(workspaces);
9564 let previous_records = {
9565 let mut records = self
9566 .session_records
9567 .lock()
9568 .unwrap_or_else(|poisoned| poisoned.into_inner());
9569 let previous = records.records.clone();
9570 for record in records.records.values_mut().filter(|record| {
9571 record.workspace_id == workspace_id
9572 && record
9573 .canonical_root
9574 .as_utf8()
9575 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9576 }) {
9577 if record.active_session.is_none() {
9578 record.state = detached_record_state(record);
9579 }
9580 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9581 record.last_error = None;
9582 }
9583 record.updated_at_unix_ms = unix_time_ms();
9584 }
9585 previous
9586 };
9587 if let Err(error) = self.persist_state_locked() {
9588 self.workspaces
9589 .write()
9590 .unwrap_or_else(|poisoned| poisoned.into_inner())
9591 .remove(&workspace_id);
9592 self.session_records
9593 .lock()
9594 .unwrap_or_else(|poisoned| poisoned.into_inner())
9595 .records = previous_records;
9596 return Err(persistence_failure(error));
9597 }
9598 let restored = self
9599 .session_records
9600 .lock()
9601 .unwrap_or_else(|poisoned| poisoned.into_inner())
9602 .records
9603 .values()
9604 .filter(|record| record.workspace_id == workspace_id)
9605 .cloned()
9606 .collect::<Vec<_>>();
9607 drop(transaction);
9608 self.publish(NodeEvent::WorkspaceAdded {
9609 workspace: snapshot.clone(),
9610 });
9611 for record in restored {
9612 self.publish_record(record);
9613 }
9614 Ok(snapshot)
9615 }
9616
9617 async fn create_standalone_workspace(
9618 &self,
9619 workspace_id: WorkspaceId,
9620 root: String,
9621 initial_branch: Option<String>,
9622 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9623 validate_workspace_request_root(&root)?;
9624 validate_git_revision("initial branch", initial_branch.as_deref())?;
9625 let preflight_workspace_id = workspace_id.clone();
9626 let preflight_root = root.clone();
9627 let canonical_candidate = tokio::task::spawn_blocking(move || {
9628 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9629 })
9630 .await
9631 .map_err(|_| failure(
9632 NodeFailureCode::BackendOperationFailed,
9633 "standalone workspace root preflight task failed",
9634 ))?
9635 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9636 {
9637 let workspaces = self
9638 .workspaces
9639 .read()
9640 .unwrap_or_else(|poisoned| poisoned.into_inner());
9641 if workspaces.contains_key(&workspace_id) {
9642 return Err(failure(
9643 NodeFailureCode::DuplicateWorkspaceId,
9644 "workspace ID is already registered",
9645 ));
9646 }
9647 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9648 platform::roots_equal(existing_root, &canonical_candidate)
9649 }) {
9650 return Err(failure(
9651 NodeFailureCode::DuplicateWorkspaceRoot,
9652 &format!("workspace root is already registered as '{existing_id}'"),
9653 ));
9654 }
9655 }
9656 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9657 .await
9658 .map_err(standalone_workspace_failure)?;
9659 prepared
9660 .verify_for_registration()
9661 .await
9662 .map_err(standalone_workspace_failure)?;
9663 match self
9664 .register_workspace(workspace_id, prepared.root().to_owned())
9665 .await
9666 {
9667 Ok(workspace) => Ok(workspace),
9668 Err(registration_error) => {
9669 if prepared.compensate_registration_failure().await {
9670 Err(registration_error)
9671 } else {
9672 Err(failure(
9673 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9674 "standalone workspace initialization succeeded but registration failed; recovery is required",
9675 ))
9676 }
9677 }
9678 }
9679 }
9680
9681 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9682 let bindings = self
9683 .session_bindings
9684 .lock()
9685 .unwrap_or_else(|poisoned| poisoned.into_inner());
9686 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9687 return Err(failure(
9688 NodeFailureCode::WorkspaceBusy,
9689 "workspace retains a bound session; remove every session before unregistering it",
9690 ));
9691 }
9692 drop(bindings);
9693 let transaction = self
9694 .state_transaction
9695 .lock()
9696 .unwrap_or_else(|poisoned| poisoned.into_inner());
9697 let mut workspaces = self
9698 .workspaces
9699 .write()
9700 .unwrap_or_else(|poisoned| poisoned.into_inner());
9701 if !workspaces.contains_key(workspace_id) {
9702 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9703 }
9704 if workspaces.len() == 1 {
9705 return Err(failure(
9706 NodeFailureCode::LastWorkspace,
9707 "node must retain at least one workspace",
9708 ));
9709 }
9710 let removed_root = workspaces
9711 .remove(workspace_id)
9712 .expect("workspace presence was validated");
9713 drop(workspaces);
9714 let previous_records = {
9715 let mut records = self
9716 .session_records
9717 .lock()
9718 .unwrap_or_else(|poisoned| poisoned.into_inner());
9719 let previous = records.records.clone();
9720 for record in records
9721 .records
9722 .values_mut()
9723 .filter(|record| &record.workspace_id == workspace_id)
9724 {
9725 record.active_session = None;
9726 record.state = ManagedSessionState::Unavailable;
9727 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9728 record.updated_at_unix_ms = unix_time_ms();
9729 }
9730 previous
9731 };
9732 if let Err(error) = self.persist_state_locked() {
9733 self.workspaces
9734 .write()
9735 .unwrap_or_else(|poisoned| poisoned.into_inner())
9736 .insert(workspace_id.clone(), removed_root);
9737 self.session_records
9738 .lock()
9739 .unwrap_or_else(|poisoned| poisoned.into_inner())
9740 .records = previous_records;
9741 return Err(persistence_failure(error));
9742 }
9743 let unavailable = self
9744 .session_records
9745 .lock()
9746 .unwrap_or_else(|poisoned| poisoned.into_inner())
9747 .records
9748 .values()
9749 .filter(|record| &record.workspace_id == workspace_id)
9750 .cloned()
9751 .collect::<Vec<_>>();
9752 drop(transaction);
9753 self.publish(NodeEvent::WorkspaceRemoved {
9754 workspace_id: workspace_id.clone(),
9755 });
9756 for record in unavailable {
9757 self.publish_record(record);
9758 }
9759 Ok(())
9760 }
9761
9762 async fn create_worktree(
9763 &self,
9764 source_workspace_id: WorkspaceId,
9765 workspace_id: WorkspaceId,
9766 target_root: String,
9767 branch: String,
9768 base: Option<String>,
9769 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9770 self.require_worktree_service(&source_workspace_id)?;
9771 validate_workspace_request_root(&target_root)?;
9772 validate_git_revision("worktree base", base.as_deref())?;
9773 let source_root = self.workspace_root(&source_workspace_id)?;
9774 {
9775 let workspaces = self
9776 .workspaces
9777 .read()
9778 .unwrap_or_else(|poisoned| poisoned.into_inner());
9779 if workspaces.contains_key(&workspace_id) {
9780 return Err(failure(
9781 NodeFailureCode::DuplicateWorkspaceId,
9782 "workspace ID is already registered",
9783 ));
9784 }
9785 if let Some((existing_id, _)) = workspaces
9786 .iter()
9787 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9788 {
9789 return Err(failure(
9790 NodeFailureCode::DuplicateWorkspaceRoot,
9791 &format!("workspace root is already registered as '{existing_id}'"),
9792 ));
9793 }
9794 }
9795 let mut worktree = create_git_worktree(
9796 &source_root,
9797 &target_root,
9798 &branch,
9799 base.as_deref(),
9800 )
9801 .await
9802 .map_err(git_worktree_failure)?;
9803 let workspace = match self
9804 .register_workspace(workspace_id, worktree.path.clone())
9805 .await
9806 {
9807 Ok(workspace) => workspace,
9808 Err(registration_error) => {
9809 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9810 return Err(failure(
9811 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9812 "worktree registration failed and compensation requires recovery",
9813 ));
9814 }
9815 return Err(failure(
9816 registration_error.code,
9817 ®istration_error.message,
9818 ));
9819 }
9820 };
9821 worktree.workspace_id = Some(workspace.workspace_id.clone());
9822 Ok((protocol_worktree(worktree), workspace))
9823 }
9824
9825 async fn remove_worktree(
9826 &self,
9827 source_workspace_id: WorkspaceId,
9828 target_root: String,
9829 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9830 self.require_worktree_service(&source_workspace_id)?;
9831 validate_workspace_request_root(&target_root)?;
9832 let source_root = self.workspace_root(&source_workspace_id)?;
9833 let target_root = normalize_worktree_removal_target(&target_root)
9834 .map_err(git_worktree_failure)?;
9835 let target = list_git_worktrees(&source_root)
9836 .await
9837 .map_err(git_worktree_failure)?
9838 .into_iter()
9839 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9840 .ok_or_else(|| {
9841 failure(
9842 NodeFailureCode::WorktreeProtected,
9843 "refusing to remove a path that is not in Git's worktree listing",
9844 )
9845 })?;
9846 let registered_workspace_id = {
9847 let workspaces = self
9848 .workspaces
9849 .read()
9850 .unwrap_or_else(|poisoned| poisoned.into_inner());
9851 let matched = workspaces
9852 .iter()
9853 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9854 .map(|(workspace_id, _)| workspace_id.clone());
9855 if matched.is_some() && workspaces.len() == 1 {
9856 return Err(failure(
9857 NodeFailureCode::LastWorkspace,
9858 "node must retain at least one workspace",
9859 ));
9860 }
9861 matched
9862 };
9863 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9864 let bindings = self
9865 .session_bindings
9866 .lock()
9867 .unwrap_or_else(|poisoned| poisoned.into_inner());
9868 if bindings
9869 .values()
9870 .any(|binding| &binding.workspace_id == workspace_id)
9871 {
9872 return Err(failure(
9873 NodeFailureCode::WorkspaceBusy,
9874 "worktree workspace retains a bound session; remove every session first",
9875 ));
9876 }
9877 }
9878 remove_git_worktree(&source_root, &target.path)
9879 .await
9880 .map_err(git_worktree_failure)?;
9881 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9882 self.unregister_workspace(workspace_id)
9883 .map_err(|error| failure(error.code, &error.message))?;
9884 }
9885 Ok(registered_workspace_id)
9886 }
9887
9888 async fn cleanup_managed_worktree(
9889 &self,
9890 lease_id: &ManagedWorktreeLeaseId,
9891 explicit: bool,
9892 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9893 let lease = self.managed_worktrees.lock()
9894 .unwrap_or_else(|poisoned| poisoned.into_inner())
9895 .get(lease_id)
9896 .cloned()
9897 .ok_or_else(|| failure(
9898 NodeFailureCode::UnknownManagedWorktreeLease,
9899 "managed worktree lease does not exist",
9900 ))?;
9901 if lease.state == ManagedWorktreeLeaseState::Removed {
9902 return Ok(lease.snapshot());
9903 }
9904 if lease.has_holders() {
9905 return Err(failure(
9906 NodeFailureCode::ManagedWorktreeBusy,
9907 "managed worktree lease still has a session or durable-record holder",
9908 ));
9909 }
9910 if self
9911 .materializations
9912 .lock()
9913 .unwrap_or_else(|poisoned| poisoned.into_inner())
9914 .values()
9915 .any(|record| record.managed_lease_id() == Some(lease_id))
9916 {
9917 return Err(failure(
9918 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9919 "managed worktree cleanup is blocked by session environment ownership",
9920 ));
9921 }
9922 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9923 let snapshot = {
9924 let mut registry = self.managed_worktrees.lock()
9925 .unwrap_or_else(|poisoned| poisoned.into_inner());
9926 let current = registry.get_mut(lease_id)
9927 .expect("managed worktree lease remains present");
9928 current.state = ManagedWorktreeLeaseState::Retained;
9929 current.cleanup_failure = None;
9930 current.updated_at_unix_ms = unix_time_ms();
9931 current.snapshot()
9932 };
9933 if self.persist_state().is_err() {
9934 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9935 }
9936 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9937 return Ok(snapshot);
9938 }
9939 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9940 if profile.revision() != &lease.profile_revision {
9941 self.mark_managed_recovery_required(
9942 lease_id,
9943 ManagedWorktreeCleanupFailure::OwnershipConflict,
9944 );
9945 return Err(failure(
9946 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9947 "managed worktree profile revision changed",
9948 ));
9949 }
9950 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9951 self.mark_managed_recovery_required(
9952 lease_id,
9953 ManagedWorktreeCleanupFailure::OwnershipConflict,
9954 );
9955 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9956 })?;
9957 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9958 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9959 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9960 managed_git_worktree_failure(error)
9961 })?;
9962 let target = listed.iter()
9963 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9964 if let Some(target) = target {
9965 if target.is_main || target.is_bare || !exact_owned_worktree(
9966 &lease,
9967 &target.path,
9968 target.branch.as_deref(),
9969 &target.head,
9970 ) {
9971 self.mark_managed_recovery_required(
9972 lease_id,
9973 ManagedWorktreeCleanupFailure::OwnershipConflict,
9974 );
9975 return Err(failure(
9976 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9977 "Git worktree identity no longer matches the managed lease",
9978 ));
9979 }
9980 if target.prunable {
9981 self.mark_managed_cleanup_blocked(
9982 lease_id,
9983 ManagedWorktreeCleanupFailure::Prunable,
9984 );
9985 return Err(failure(
9986 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9987 "managed worktree is prunable and requires recovery",
9988 ));
9989 }
9990 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9991 let failure_kind = match error.kind {
9992 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9993 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9994 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9995 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9996 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9997 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9998 };
9999 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10000 self.mark_managed_recovery_required(lease_id, failure_kind);
10001 } else {
10002 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10003 }
10004 return Err(managed_git_worktree_failure(error));
10005 }
10006 } else {
10007 match std::fs::symlink_metadata(&lease.target_root) {
10008 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10009 Ok(_) => {
10010 self.mark_managed_recovery_required(
10011 lease_id,
10012 ManagedWorktreeCleanupFailure::OwnershipConflict,
10013 );
10014 return Err(failure(
10015 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10016 "managed target exists but is not owned by Git",
10017 ));
10018 }
10019 Err(_) => {
10020 self.mark_managed_cleanup_blocked(
10021 lease_id,
10022 ManagedWorktreeCleanupFailure::Backend,
10023 );
10024 return Err(failure(
10025 NodeFailureCode::BackendOperationFailed,
10026 "managed target identity could not be inspected",
10027 ));
10028 }
10029 }
10030 }
10031 let registered = self.workspaces.read()
10032 .unwrap_or_else(|poisoned| poisoned.into_inner())
10033 .contains_key(&lease.workspace_id);
10034 if registered {
10035 self.unregister_workspace(&lease.workspace_id)?;
10036 }
10037 let removed = {
10038 self.managed_worktrees.lock()
10039 .unwrap_or_else(|poisoned| poisoned.into_inner())
10040 .tombstone(lease_id, unix_time_ms())
10041 .expect("managed worktree lease remains present during cleanup")
10042 };
10043 self.persist_state().map_err(persistence_failure)?;
10044 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10045 Ok(removed.snapshot())
10046 }
10047
10048 fn mark_managed_cleanup_blocked(
10049 &self,
10050 lease_id: &ManagedWorktreeLeaseId,
10051 failure_kind: ManagedWorktreeCleanupFailure,
10052 ) {
10053 let snapshot = {
10054 let mut registry = self.managed_worktrees.lock()
10055 .unwrap_or_else(|poisoned| poisoned.into_inner());
10056 let Some(lease) = registry.get_mut(lease_id) else { return };
10057 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10058 lease.cleanup_failure = Some(failure_kind);
10059 lease.updated_at_unix_ms = unix_time_ms();
10060 lease.snapshot()
10061 };
10062 let _ = self.persist_state();
10063 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10064 }
10065
10066 fn require_worktree_service(
10067 &self,
10068 source_workspace_id: &WorkspaceId,
10069 ) -> Result<(), NodeFailure> {
10070 if self.managed_worktrees.lock()
10071 .unwrap_or_else(|poisoned| poisoned.into_inner())
10072 .lease_for_workspace(source_workspace_id)
10073 .is_some()
10074 {
10075 return Err(failure(
10076 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10077 "managed worktree targets are internal-only workspaces",
10078 ));
10079 }
10080 match self
10081 .worktree_service_modes
10082 .get(source_workspace_id)
10083 .copied()
10084 .unwrap_or(WorktreeServiceMode::Manual)
10085 {
10086 WorktreeServiceMode::Manual => Ok(()),
10087 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10088 NodeFailureCode::UnsupportedSpawnCapability,
10089 "Git worktree service mode is unavailable in this node build",
10090 )),
10091 }
10092 }
10093
10094 fn managed_reservation_conflict(
10095 &self,
10096 workspace_id: Option<&WorkspaceId>,
10097 root: Option<&str>,
10098 ) -> bool {
10099 self.managed_worktrees.lock()
10100 .unwrap_or_else(|poisoned| poisoned.into_inner())
10101 .active_records()
10102 .any(|lease| {
10103 workspace_id.is_some_and(|workspace_id| {
10104 workspace_id == &lease.source_workspace_id
10105 || workspace_id == &lease.workspace_id
10106 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10107 })
10108 }
10109
10110 fn reject_managed_reservation(
10111 &self,
10112 workspace_id: Option<&WorkspaceId>,
10113 root: Option<&str>,
10114 ) -> Result<(), NodeFailure> {
10115 if self.managed_reservation_conflict(workspace_id, root) {
10116 Err(failure(
10117 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10118 "managed worktree ownership may only be mutated through its lease cleanup",
10119 ))
10120 } else {
10121 Ok(())
10122 }
10123 }
10124
10125 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10126 self.validate_address(address)?;
10127 self.workspace_root(&address.workspace_id)
10128 }
10129
10130 fn require_session_runtime_policy(
10164 &self,
10165 address: &SessionAddress,
10166 requirement: ProviderRuntimeRequirement,
10167 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10168 let runtime_policy = {
10169 let bindings = self
10170 .session_bindings
10171 .lock()
10172 .unwrap_or_else(|poisoned| poisoned.into_inner());
10173 let binding = bindings
10174 .get(&address.session.instance_id)
10175 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10176 if binding.workspace_id != address.workspace_id {
10177 return Err(failure(
10178 NodeFailureCode::SessionWorkspaceMismatch,
10179 "session belongs to another workspace",
10180 ));
10181 }
10182 if binding.generation != address.session.generation {
10183 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10184 }
10185 binding.runtime_policy
10186 };
10187 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10188 let snapshot = self.internal_session_snapshot(address)?;
10189 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10190 && snapshot.provider.lead_activity != ProviderActivity::Idle
10191 {
10192 return Err(failure(
10193 NodeFailureCode::TurnInFlight,
10194 "session already has a turn in flight; wait for it to complete before sending another prompt",
10195 ));
10196 }
10197 }
10198 require_policy(runtime_policy, false, false, requirement)
10199 .map(|()| runtime_policy)
10200 .map_err(|_| failure(
10201 NodeFailureCode::UnsupportedCapability,
10202 "session runtime policy does not admit this semantic operation",
10203 ))
10204 }
10205
10206 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10207 let mut bindings = self
10208 .session_bindings
10209 .lock()
10210 .unwrap_or_else(|poisoned| poisoned.into_inner());
10211 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10212 binding.workspace_id == address.workspace_id
10213 && binding.generation == address.session.generation
10214 }) {
10215 let removed = bindings.remove(&address.session.instance_id);
10216 self.clear_terminal_frame_watermark(address);
10217 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10218 return removed;
10219 }
10220 None
10221 }
10222
10223 fn cleanup_session_owned_materialization(
10224 &self,
10225 address: &SessionAddress,
10226 binding: &SessionBinding,
10227 ) -> Result<(), NodeFailure> {
10228 let Some(id) = binding.materialization_id.as_ref() else {
10229 return Ok(());
10230 };
10231 let owner = self
10232 .materializations
10233 .lock()
10234 .unwrap_or_else(|poisoned| poisoned.into_inner())
10235 .get(id)
10236 .map(|record| record.owner().clone());
10237 match owner {
10238 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10239 Some(MaterializationOwner::Session {
10240 incarnation_id,
10241 instance_id,
10242 generation,
10243 }) if incarnation_id == self.incarnation_id
10244 && instance_id == address.session.instance_id
10245 && generation == address.session.generation =>
10246 {
10247 self.cleanup_materialization(id)
10248 }
10249 Some(MaterializationOwner::Session { .. }) => Err(failure(
10250 NodeFailureCode::BackendOperationFailed,
10251 "session environment ownership requires recovery",
10252 )),
10253 }
10254 }
10255
10256 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10257 let mut watermarks = self
10258 .terminal_frame_watermarks
10259 .lock()
10260 .unwrap_or_else(|poisoned| poisoned.into_inner());
10261 if watermarks
10262 .get(&address.session.instance_id)
10263 .is_some_and(|(current, _)| current == address)
10264 {
10265 watermarks.remove(&address.session.instance_id);
10266 }
10267 }
10268
10269 #[cfg(test)]
10270 fn address_for(
10271 &self,
10272 instance_id: AgentInstanceId,
10273 generation: SessionGeneration,
10274 ) -> Option<SessionAddress> {
10275 let binding = self.session_bindings
10276 .lock()
10277 .unwrap_or_else(|poisoned| poisoned.into_inner())
10278 .get(&instance_id)
10279 .cloned()?;
10280 (binding.generation == generation).then_some(SessionAddress {
10281 workspace_id: binding.workspace_id,
10282 session: SessionKey { instance_id, generation },
10283 })
10284 }
10285
10286 fn arm_resume(
10287 &self,
10288 address: &SessionAddress,
10289 command_id: CommandId,
10290 runtime_policy: ProviderRuntimePolicy,
10291 ) -> Result<(), NodeFailure> {
10292 let mut bindings = self
10293 .session_bindings
10294 .lock()
10295 .unwrap_or_else(|poisoned| poisoned.into_inner());
10296 let binding = bindings
10297 .get_mut(&address.session.instance_id)
10298 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10299 if binding.workspace_id != address.workspace_id {
10300 return Err(failure(
10301 NodeFailureCode::SessionWorkspaceMismatch,
10302 "session belongs to another workspace",
10303 ));
10304 }
10305 if binding.generation != address.session.generation {
10306 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10307 }
10308 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10309 Ok(())
10310 }
10311
10312 fn clear_armed_resume(&self, address: &SessionAddress) {
10313 let mut bindings = self
10314 .session_bindings
10315 .lock()
10316 .unwrap_or_else(|poisoned| poisoned.into_inner());
10317 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10318 if binding.workspace_id == address.workspace_id
10319 && binding.generation == address.session.generation
10320 {
10321 binding.pending_resume = None;
10322 }
10323 }
10324 }
10325
10326 fn publish_control(&self, event: ControlEvent) {
10327 let revokes_harness_mcp = matches!(
10328 &event.event,
10329 ControlEventKind::Exited { .. }
10330 | ControlEventKind::Failed { .. }
10331 | ControlEventKind::Removed
10332 );
10333 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10334 let mut bindings = self
10335 .session_bindings
10336 .lock()
10337 .unwrap_or_else(|poisoned| poisoned.into_inner());
10338 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10339 return;
10340 };
10341 let previous_address = SessionAddress {
10342 workspace_id: binding.workspace_id.clone(),
10343 session: SessionKey {
10344 instance_id: event.instance_id,
10345 generation: binding.generation,
10346 },
10347 };
10348 let mut resume_command_rejected = false;
10349 if binding.generation == event.generation {
10350 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10351 let rejected_resume = matches!(
10352 &event.event,
10353 ControlEventKind::CommandRejected { .. }
10354 ) && event.command_id == Some(pending_command_id);
10355 let resume_failed = matches!(
10356 &event.event,
10357 ControlEventKind::ResumeDenied { .. }
10358 | ControlEventKind::ResumeFailed { .. }
10359 );
10360 if pending_generation == binding.generation
10361 && (rejected_resume || resume_failed)
10362 {
10363 resume_command_rejected = rejected_resume;
10364 binding.pending_resume = None;
10365 }
10366 }
10367 } else {
10368 let expected = binding.generation.0.checked_add(1);
10369 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10370 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10371 generation == binding.generation
10372 })
10373 || expected != Some(event.generation.0)
10374 || !authorized
10375 {
10376 return;
10377 }
10378 let (_, _, runtime_policy) = binding
10379 .pending_resume
10380 .expect("authorized resume retains its pending runtime policy");
10381 binding.generation = event.generation;
10382 binding.runtime_policy = runtime_policy;
10383 binding.pending_resume = None;
10384 }
10385 (
10386 SessionAddress {
10387 workspace_id: binding.workspace_id.clone(),
10388 session: SessionKey {
10389 instance_id: event.instance_id,
10390 generation: event.generation,
10391 },
10392 },
10393 (previous_address.session.generation != event.generation)
10394 .then_some(previous_address),
10395 binding.record_id.clone(),
10396 binding.runtime_policy.provider_session_identity,
10397 resume_command_rejected,
10398 )
10399 };
10400 if let Some(previous_address) = previous_address.as_ref() {
10401 self.clear_terminal_frame_watermark(previous_address);
10402 }
10403 if let Some(record_id) = record_id {
10404 self.reconcile_managed_record(
10405 &record_id,
10406 &address,
10407 &event,
10408 identity_admitted,
10409 resume_command_rejected,
10410 );
10411 }
10412 match &event.event {
10420 ControlEventKind::Running { process_id } => {
10421 #[cfg(unix)]
10422 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10423 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10424 }
10425 tracing::info!(
10426 node_id = %self.node_id,
10427 workspace_id = %address.workspace_id,
10428 session = ?address.session,
10429 pid = ?process_id,
10430 "session process is running",
10431 );
10432 }
10433 ControlEventKind::Exited { exit_code, forced } => {
10434 #[cfg(unix)]
10435 if let Some(proxy) = self.signer_proxy.get().cloned() {
10436 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10437 if proxy.has_session(&key) {
10438 std::thread::spawn(move || proxy.release(&key));
10440 }
10441 }
10442 tracing::info!(
10443 node_id = %self.node_id,
10444 workspace_id = %address.workspace_id,
10445 session = ?address.session,
10446 exit_code = ?exit_code,
10447 forced = forced,
10448 "session process exited",
10449 );
10450 }
10451 ControlEventKind::Failed { message } => {
10452 tracing::warn!(
10453 node_id = %self.node_id,
10454 workspace_id = %address.workspace_id,
10455 session = ?address.session,
10456 cause = %message,
10457 "session failed",
10458 );
10459 }
10460 ControlEventKind::CommandRejected { message } => {
10461 tracing::warn!(
10462 node_id = %self.node_id,
10463 workspace_id = %address.workspace_id,
10464 session = ?address.session,
10465 cause = %message,
10466 "session command rejected",
10467 );
10468 }
10469 ControlEventKind::Removed => {
10470 tracing::info!(
10471 node_id = %self.node_id,
10472 workspace_id = %address.workspace_id,
10473 session = ?address.session,
10474 "session removed",
10475 );
10476 }
10477 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10478 tracing::info!(
10479 node_id = %self.node_id,
10480 workspace_id = %address.workspace_id,
10481 session = ?address.session,
10482 provider_session = ?provider_session,
10483 "session resume authorized",
10484 );
10485 }
10486 ControlEventKind::Resumed { session: provider_session, process_id } => {
10487 tracing::info!(
10488 node_id = %self.node_id,
10489 workspace_id = %address.workspace_id,
10490 session = ?address.session,
10491 provider_session = ?provider_session,
10492 pid = ?process_id,
10493 "session resumed",
10494 );
10495 }
10496 ControlEventKind::ResumeDenied { reason } => {
10497 tracing::warn!(
10498 node_id = %self.node_id,
10499 workspace_id = %address.workspace_id,
10500 session = ?address.session,
10501 cause = %reason,
10502 "session resume denied",
10503 );
10504 }
10505 ControlEventKind::ResumeFailed { message } => {
10506 tracing::warn!(
10507 node_id = %self.node_id,
10508 workspace_id = %address.workspace_id,
10509 session = ?address.session,
10510 cause = %message,
10511 "session resume failed",
10512 );
10513 }
10514 _ => {}
10515 }
10516 self.apply_pending_acp_options(&address, &event);
10517 let stream_chunk = agent_stream_chunk(&event);
10518 self.publish(NodeEvent::Control {
10519 address: address.clone(),
10520 event,
10521 });
10522 if let Some(chunk) = stream_chunk {
10523 self.publish_agent_stream_chunk(address.clone(), chunk);
10524 }
10525 if revokes_harness_mcp {
10526 SessionLifecycleHook::on_session_ended(self, &address);
10527 }
10528 }
10529
10530 fn reconcile_managed_record(
10531 &self,
10532 record_id: &SessionRecordId,
10533 address: &SessionAddress,
10534 event: &ControlEvent,
10535 identity_admitted: bool,
10536 resume_command_rejected: bool,
10537 ) {
10538 let _transaction = self
10539 .state_transaction
10540 .lock()
10541 .unwrap_or_else(|poisoned| poisoned.into_inner());
10542 let observed_identity = match &event.event {
10543 ControlEventKind::ProviderEvent {
10544 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10545 ..
10546 } if identity_admitted => Some(identity.clone()),
10547 _ => None,
10548 };
10549 let identity_was_observed = observed_identity.is_some();
10550 let replacement_id = observed_identity
10551 .as_ref()
10552 .and_then(|_| self.allocate_record_id().ok());
10553 let mut upserts = Vec::new();
10554 let mut removals = Vec::new();
10555 let mut rebind = None;
10556 let original_records;
10557 let original_materializations = self
10558 .materializations
10559 .lock()
10560 .unwrap_or_else(|poisoned| poisoned.into_inner())
10561 .clone();
10562 {
10563 let mut records = self
10564 .session_records
10565 .lock()
10566 .unwrap_or_else(|poisoned| poisoned.into_inner());
10567 original_records = records.records.clone();
10568 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10569 return;
10570 };
10571 if let Some(identity) = observed_identity {
10572 let existing_id = records
10573 .records
10574 .values()
10575 .find(|record| {
10576 record.record_id != *record_id
10577 && record.provider_session.as_ref().is_some_and(|record_identity| {
10578 session_registry::same_provider_session(
10579 &record.provider,
10580 record_identity,
10581 ¤t_snapshot.provider,
10582 &identity,
10583 )
10584 })
10585 })
10586 .map(|record| record.record_id.clone());
10587 if let Some(existing_id) = existing_id {
10588 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10589 record.mode == current_snapshot.mode
10590 && record.workspace_id == current_snapshot.workspace_id
10591 && record.environment_profile == current_snapshot.environment_profile
10592 && record.bundle == current_snapshot.bundle
10593 && record.context_id == current_snapshot.context_id
10594 && record.context == current_snapshot.context
10595 && record
10596 .canonical_root
10597 .as_utf8()
10598 .zip(current_snapshot.canonical_root.as_utf8())
10599 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10600 });
10601 if !scope_matches {
10602 if let Some(current) = records.records.get_mut(record_id) {
10603 current.state = ManagedSessionState::Unavailable;
10604 current.last_error =
10605 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10606 current.updated_at_unix_ms = unix_time_ms();
10607 upserts.push(current.clone());
10608 }
10609 } else {
10610 let existing_is_active = records
10611 .records
10612 .get(&existing_id)
10613 .is_some_and(|record| record.active_session.is_some());
10614 if existing_is_active {
10615 if let Some(current) = records.records.get_mut(record_id) {
10616 current.state = ManagedSessionState::Unavailable;
10617 current.last_error =
10618 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10619 current.updated_at_unix_ms = unix_time_ms();
10620 upserts.push(current.clone());
10621 }
10622 } else {
10623 if current_snapshot.provider_session.is_some() {
10624 if let Some(current) = records.records.get_mut(record_id) {
10625 current.active_session = None;
10626 current.state = ManagedSessionState::Dormant;
10627 current.updated_at_unix_ms = unix_time_ms();
10628 upserts.push(current.clone());
10629 }
10630 } else {
10631 records.records.remove(record_id);
10632 removals.push(record_id.clone());
10633 }
10634 if let Some(existing) = records.records.get_mut(&existing_id) {
10635 existing.provider_session = Some(identity.clone());
10636 existing.active_session = Some(address.clone());
10637 existing.state = ManagedSessionState::Live;
10638 existing.updated_at_unix_ms = unix_time_ms();
10639 existing.last_error = None;
10640 upserts.push(existing.clone());
10641 }
10642 rebind = Some(existing_id);
10643 }
10644 }
10645 } else if current_snapshot
10646 .provider_session
10647 .as_ref()
10648 .is_some_and(|current| {
10649 !session_registry::same_provider_session(
10650 ¤t_snapshot.provider,
10651 current,
10652 ¤t_snapshot.provider,
10653 &identity,
10654 )
10655 })
10656 {
10657 if let Some(current) = records.records.get_mut(record_id) {
10658 current.active_session = None;
10659 if current.bundle.is_some() || current.context.is_some() {
10660 let had_bundle = current.bundle.is_some();
10661 current.provider_session = None;
10662 current.bundle = None;
10663 current.context_id = None;
10664 current.context = None;
10665 current.state = ManagedSessionState::Unavailable;
10666 current.last_error = Some(if had_bundle {
10667 "bundle-unavailable".to_owned()
10668 } else {
10669 "context-unavailable".to_owned()
10670 });
10671 } else {
10672 current.state = ManagedSessionState::Dormant;
10673 }
10674 current.updated_at_unix_ms = unix_time_ms();
10675 upserts.push(current.clone());
10676 }
10677 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10678 if let Some(current) = records.records.get_mut(record_id) {
10679 current.state = ManagedSessionState::Unavailable;
10680 current.last_error =
10681 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10682 current.updated_at_unix_ms = unix_time_ms();
10683 upserts.push(current.clone());
10684 }
10685 } else if let Some(new_record_id) = replacement_id {
10686 let now = unix_time_ms();
10687 let replacement = ManagedSessionRecord {
10688 record_id: new_record_id.clone(),
10689 display_name: format!(
10690 "{} #{}",
10691 current_snapshot.provider.as_str(),
10692 records
10693 .records
10694 .values()
10695 .filter(|record| {
10696 record.provider == current_snapshot.provider
10697 })
10698 .count()
10699 .saturating_add(1),
10700 ),
10701 provider: current_snapshot.provider.clone(),
10702 mode: current_snapshot.mode,
10703 state: ManagedSessionState::Live,
10704 workspace_id: current_snapshot.workspace_id,
10705 canonical_root: current_snapshot.canonical_root,
10706 provider_session: Some(identity),
10707 active_session: Some(address.clone()),
10708 environment_profile: current_snapshot.environment_profile,
10709 bundle: current_snapshot.bundle,
10710 context_id: current_snapshot.context_id,
10711 context: current_snapshot.context,
10712 exported_context: None,
10718 task_binding: current_snapshot.task_binding,
10719 created_at_unix_ms: now,
10720 updated_at_unix_ms: now,
10721 last_error: None,
10722 };
10723 records
10724 .records
10725 .insert(new_record_id.clone(), replacement.clone());
10726 upserts.push(replacement);
10727 rebind = Some(new_record_id);
10728 } else if let Some(current) = records.records.get_mut(record_id) {
10729 current.state = ManagedSessionState::Unavailable;
10730 current.last_error =
10731 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10732 upserts.push(current.clone());
10733 }
10734 } else if let Some(current) = records.records.get_mut(record_id) {
10735 current.provider_session = Some(identity);
10736 current.active_session = Some(address.clone());
10737 current.state = ManagedSessionState::Live;
10738 current.updated_at_unix_ms = unix_time_ms();
10739 current.last_error = None;
10740 upserts.push(current.clone());
10741 }
10742 } else if let Some(current) = records.records.get_mut(record_id) {
10743 if resume_command_rejected {
10744 current.active_session = None;
10745 current.state = detached_record_state(current);
10746 current.updated_at_unix_ms = unix_time_ms();
10747 current.last_error =
10748 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10749 upserts.push(current.clone());
10750 } else {
10751 match &event.event {
10752 ControlEventKind::Running { .. }
10753 | ControlEventKind::ResumeAuthorized { .. }
10754 | ControlEventKind::Resumed { .. } => {
10755 current.active_session = Some(address.clone());
10756 current.state = active_record_state(
10757 identity_admitted,
10758 current.provider_session.is_some(),
10759 );
10760 current.updated_at_unix_ms = unix_time_ms();
10761 current.last_error = None;
10762 upserts.push(current.clone());
10763 }
10764 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10765 current.active_session = None;
10766 current.state = detached_record_state(current);
10767 current.updated_at_unix_ms = unix_time_ms();
10768 upserts.push(current.clone());
10769 }
10770 ControlEventKind::Failed { message }
10771 | ControlEventKind::ResumeFailed { message } => {
10772 current.active_session = None;
10773 current.state = detached_record_state(current);
10774 current.updated_at_unix_ms = unix_time_ms();
10775 current.last_error = Some(
10776 session_registry::sanitized_record_error_summary(message),
10777 );
10778 upserts.push(current.clone());
10779 }
10780 ControlEventKind::ResumeDenied { reason } => {
10781 current.active_session = None;
10782 current.state = detached_record_state(current);
10783 current.updated_at_unix_ms = unix_time_ms();
10784 current.last_error = Some(
10785 session_registry::sanitized_record_error_summary(reason),
10786 );
10787 upserts.push(current.clone());
10788 }
10789 _ => {}
10790 }
10791 }
10792 }
10793 }
10794 let materialization_owner_record = identity_was_observed.then(|| {
10795 rebind.clone().unwrap_or_else(|| record_id.clone())
10796 }).and_then(|candidate| {
10797 self.session_records
10798 .lock()
10799 .unwrap_or_else(|poisoned| poisoned.into_inner())
10800 .records
10801 .get(&candidate)
10802 .filter(|record| {
10803 record.provider_session.is_some()
10804 && record.active_session.as_ref() == Some(address)
10805 })
10806 .map(|_| candidate)
10807 });
10808 if let Some(owner_record_id) = materialization_owner_record {
10809 let materialization_id = self
10810 .session_bindings
10811 .lock()
10812 .unwrap_or_else(|poisoned| poisoned.into_inner())
10813 .get(&address.session.instance_id)
10814 .and_then(|binding| binding.materialization_id.clone());
10815 if let Some(materialization_id) = materialization_id {
10816 let mut transfer_failed = false;
10817 {
10818 let mut materializations = self
10819 .materializations
10820 .lock()
10821 .unwrap_or_else(|poisoned| poisoned.into_inner());
10822 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10823 match ownership.owner() {
10824 MaterializationOwner::Session {
10825 incarnation_id,
10826 instance_id,
10827 generation,
10828 } if *incarnation_id == self.incarnation_id
10829 && *instance_id == address.session.instance_id
10830 && *generation == address.session.generation =>
10831 {
10832 transfer_failed = ownership
10833 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10834 .is_err();
10835 }
10836 MaterializationOwner::Record { record_id }
10837 if record_id == &owner_record_id => {}
10838 MaterializationOwner::Record { record_id } => {
10839 let expected = record_id.clone();
10840 transfer_failed = ownership
10841 .transfer_record_owner(
10842 &expected,
10843 owner_record_id.clone(),
10844 unix_time_ms(),
10845 )
10846 .is_err();
10847 }
10848 _ => transfer_failed = true,
10849 }
10850 if transfer_failed {
10851 let _ = ownership.mark_recovery_required(unix_time_ms());
10852 }
10853 } else {
10854 transfer_failed = true;
10855 }
10856 }
10857 if transfer_failed {
10858 if let Some(record) = self
10859 .session_records
10860 .lock()
10861 .unwrap_or_else(|poisoned| poisoned.into_inner())
10862 .records
10863 .get_mut(&owner_record_id)
10864 {
10865 record.state = ManagedSessionState::Unavailable;
10866 record.last_error = Some("environment-profile-unavailable".to_owned());
10867 record.updated_at_unix_ms = unix_time_ms();
10868 upserts.push(record.clone());
10869 }
10870 }
10871 }
10872 }
10873 if let Some(new_record_id) = rebind.clone() {
10874 if let Some(binding) = self
10875 .session_bindings
10876 .lock()
10877 .unwrap_or_else(|poisoned| poisoned.into_inner())
10878 .get_mut(&address.session.instance_id)
10879 {
10880 binding.record_id = Some(new_record_id);
10881 }
10882 }
10883 if upserts.is_empty() && removals.is_empty() {
10884 return;
10885 }
10886 if self.persist_state_locked().is_err() {
10887 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10888 let mut records = self
10889 .session_records
10890 .lock()
10891 .unwrap_or_else(|poisoned| poisoned.into_inner());
10892 records.records = original_records;
10893 *self
10894 .materializations
10895 .lock()
10896 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10897 if let Some(current) = records.records.get_mut(record_id) {
10898 current.state = ManagedSessionState::Unavailable;
10899 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10900 current.updated_at_unix_ms = unix_time_ms();
10901 upserts = vec![current.clone()];
10902 } else {
10903 upserts.clear();
10904 }
10905 removals.clear();
10906 drop(records);
10907 if let Some(binding) = self
10908 .session_bindings
10909 .lock()
10910 .unwrap_or_else(|poisoned| poisoned.into_inner())
10911 .get_mut(&address.session.instance_id)
10912 {
10913 binding.record_id = Some(record_id.clone());
10914 }
10915 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10916 }
10917 for record in upserts {
10918 self.publish_record(record);
10919 }
10920 for record_id in removals {
10921 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10922 }
10923 }
10924
10925 fn snapshot(&self) -> NodeSnapshot {
10926 let control = self.handle.snapshot();
10927 let bindings = self
10928 .session_bindings
10929 .lock()
10930 .unwrap_or_else(|poisoned| poisoned.into_inner())
10931 .clone();
10932 let workspace_roots = self
10933 .workspaces
10934 .read()
10935 .unwrap_or_else(|poisoned| poisoned.into_inner())
10936 .clone();
10937 let environment_profiles = self
10938 .environment_profiles
10939 .read()
10940 .unwrap_or_else(|poisoned| poisoned.into_inner());
10941 let mut workspaces = workspace_roots
10942 .iter()
10943 .map(|(workspace_id, canonical_root)| {
10944 (
10945 workspace_id.clone(),
10946 WorkspaceSnapshot {
10947 workspace_id: workspace_id.clone(),
10948 canonical_root: opaque_windows_path(canonical_root.clone()),
10949 sessions: Vec::new(),
10950 worktree_service_mode: Some(
10951 self.worktree_service_modes
10952 .get(workspace_id)
10953 .copied()
10954 .unwrap_or(WorktreeServiceMode::Manual),
10955 ),
10956 managed_worktree_profiles: Some(WorktreeProfileInventory {
10957 profiles: self
10958 .managed_worktree_profiles
10959 .get(workspace_id)
10960 .into_iter()
10961 .flat_map(BTreeMap::values)
10962 .map(|profile| ManagedWorktreeProfileSummary {
10963 id: profile.profile_id().clone(),
10964 revision: profile.revision().clone(),
10965 retention: profile.retention(),
10966 })
10967 .collect(),
10968 }),
10969 },
10970 )
10971 })
10972 .collect::<BTreeMap<_, _>>();
10973 let mut agent_progress = Vec::new();
10974 for session in &control.sessions {
10975 let Some(binding) = bindings.get(&session.instance_id) else {
10976 continue;
10977 };
10978 if binding.generation != session.generation {
10979 continue;
10980 }
10981 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10982 let address = SessionAddress {
10983 workspace_id: binding.workspace_id.clone(),
10984 session: SessionKey {
10985 instance_id: session.instance_id,
10986 generation: session.generation,
10987 },
10988 };
10989 if let Some(progress) =
10990 agent_progress_from_provider_snapshot(address, &session.provider)
10991 {
10992 agent_progress.push(progress);
10993 }
10994 workspace.sessions.push(session.clone());
10995 }
10996 }
10997 NodeSnapshot {
10998 node_id: self.node_id.clone(),
10999 enabled_providers: self.enabled_providers.clone(),
11000 provider_runtime_statuses: {
11001 let updates = self
11002 .provider_runtime_status_updates
11003 .read()
11004 .unwrap_or_else(|poisoned| poisoned.into_inner());
11005 ProviderRuntimeStatuses::new(
11006 self.provider_runtime_statuses
11007 .iter()
11008 .filter(|status| !updates.contains_key(status.provider()))
11009 .cloned()
11010 .chain(updates.values().cloned()),
11011 )
11012 .expect("refreshed provider runtime inventory remains bounded and unique")
11013 },
11014 workspaces: workspaces.into_values().collect(),
11015 session_records: self
11016 .session_records
11017 .lock()
11018 .unwrap_or_else(|poisoned| poisoned.into_inner())
11019 .records
11020 .values()
11021 .cloned()
11022 .collect(),
11023 managed_worktrees: self.managed_worktrees
11024 .lock()
11025 .unwrap_or_else(|poisoned| poisoned.into_inner())
11026 .snapshots(),
11027 launch_inventory: Some(LaunchInventory {
11028 spawn_profiles: Some(
11029 self.spawn_profiles
11030 .iter()
11031 .filter_map(|profile| {
11032 resolved_spawn_profile_summary(profile, &environment_profiles)
11033 })
11034 .collect(),
11035 ),
11036 bundles: Some(
11037 self.bundle_catalog
11038 .read()
11039 .unwrap_or_else(|poisoned| poisoned.into_inner())
11040 .iter()
11041 .map(NodeBundle::receipt)
11042 .collect(),
11043 ),
11044 network_allowlists: {
11047 let catalog = self
11048 .network_allowlist_catalog
11049 .read()
11050 .unwrap_or_else(|poisoned| poisoned.into_inner());
11051 if catalog.is_empty() {
11052 None
11053 } else {
11054 Some(catalog.ids().cloned().collect())
11055 }
11056 },
11057 }),
11058 agent_progress,
11059 }
11060 }
11061
11062 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11063 assert!(
11064 !matches!(
11065 &event,
11066 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11067 ),
11068 "terminal frame and agent stream events must use their own dedicated live channel",
11069 );
11070 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11071 let sequence = history
11072 .last_sequence
11073 .checked_add(1)
11074 .expect("node event sequence exhausted");
11075 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11076 if let Some(evicted) = history.events.pop_front() {
11077 history.replay_floor_sequence = evicted
11078 .sequence
11079 .checked_add(1)
11080 .expect("node replay floor sequence exhausted");
11081 history.removed_record_providers.remove(&evicted.sequence);
11082 }
11083 }
11084 match &event {
11085 NodeEvent::HarnessMcpReadCall { .. } => {
11086 panic!("transient harness MCP calls must not enter durable event history")
11087 }
11088 NodeEvent::SessionRecordUpserted { record } => {
11089 history
11090 .record_providers
11091 .insert(record.record_id.clone(), record.provider.clone());
11092 }
11093 NodeEvent::SessionRecordRemoved { record_id } => {
11094 if let Some(provider) = history.record_providers.remove(record_id) {
11095 history.removed_record_providers.insert(sequence, provider);
11096 }
11097 }
11098 NodeEvent::ManagedWorktreeUpserted { .. }
11099 | NodeEvent::ManagedWorktreeRemoved { .. }
11100 | NodeEvent::Control { .. }
11101 | NodeEvent::ControllerChanged { .. }
11102 | NodeEvent::SessionRecordHistorySummarized { .. }
11103 | NodeEvent::WorkspaceAdded { .. }
11104 | NodeEvent::WorkspaceRemoved { .. }
11105 | NodeEvent::ResyncRequired { .. }
11106 | NodeEvent::AgentStream { .. }
11107 | NodeEvent::TerminalFrame { .. } => {}
11108 }
11109 let envelope = NodeEventEnvelope { sequence, event };
11110 history.events.push_back(envelope.clone());
11111 history.last_sequence = sequence;
11112 let _ = self.event_tx.send(envelope.clone());
11113 drop(history);
11114 envelope
11115 }
11116
11117 fn publish_transient(&self, event: NodeEvent) -> bool {
11128 assert!(event.requires_harness_mcp_proxy_capability());
11129 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11130 let envelope = NodeEventEnvelope { sequence: 0, event };
11131 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11132 Ok(_) => true,
11133 Err(broadcast::error::SendError(undelivered)) => {
11134 if let Some(NodeEventEnvelope {
11135 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11136 ..
11137 }) = undelivered.first() {
11138 tracing::warn!(
11144 call_id = call_id.as_str(),
11145 content_type = ?request.content_type,
11146 cause = "no-subscribers",
11147 "harness MCP proxy event could not be published",
11148 );
11149 }
11150 false
11151 }
11152 }
11153 }
11154
11155 fn publish_terminal_frames(&self) {
11156 let snapshot = self.handle.snapshot();
11157 let bindings = self
11158 .session_bindings
11159 .lock()
11160 .unwrap_or_else(|poisoned| poisoned.into_inner());
11161 let candidates = snapshot
11162 .sessions
11163 .iter()
11164 .filter_map(|session| {
11165 let binding = bindings.get(&session.instance_id)?;
11166 if binding.generation != session.generation {
11167 return None;
11168 }
11169 let frame = session.terminal_frame.clone()?;
11170 crate::session_signals::feed(
11171 binding.workspace_id.as_str(),
11172 session.instance_id.0,
11173 session.agent_id.as_str(),
11174 &frame.contents,
11175 );
11176 Some((
11177 SessionAddress {
11178 workspace_id: binding.workspace_id.clone(),
11179 session: SessionKey {
11180 instance_id: session.instance_id,
11181 generation: session.generation,
11182 },
11183 },
11184 frame,
11185 ))
11186 })
11187 .collect();
11188 drop(bindings);
11189 self.publish_terminal_frame_candidates(candidates);
11190 }
11191
11192 fn publish_terminal_frame_candidates(
11193 &self,
11194 candidates: Vec<(SessionAddress, TerminalFrame)>,
11195 ) {
11196 let mut watermarks = self
11197 .terminal_frame_watermarks
11198 .lock()
11199 .unwrap_or_else(|poisoned| poisoned.into_inner());
11200 let mut events = Vec::with_capacity(candidates.len());
11201 for (address, frame) in candidates {
11202 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11203 let advanced = watermarks
11204 .get(&address.session.instance_id)
11205 .map_or(true, |(current_address, sequence)| {
11206 current_address != &address || frame.sequence > *sequence
11207 });
11208 if !advanced {
11209 continue;
11210 }
11211 if is_first_frame {
11212 if self.controller_state().is_none() {
11226 tracing::warn!(
11227 node_id = %self.node_id,
11228 workspace_id = %address.workspace_id,
11229 session = ?address.session,
11230 terminal_size = ?frame.size,
11231 "session producing output with no controller attached",
11232 );
11233 } else {
11234 tracing::info!(
11235 node_id = %self.node_id,
11236 workspace_id = %address.workspace_id,
11237 session = ?address.session,
11238 terminal_size = ?frame.size,
11239 "session produced its first output",
11240 );
11241 }
11242 }
11243 watermarks.insert(
11244 address.session.instance_id,
11245 (address.clone(), frame.sequence),
11246 );
11247 if let Some((at, reported)) = self
11248 .pty_input_marks
11249 .lock()
11250 .unwrap_or_else(|poisoned| poisoned.into_inner())
11251 .get_mut(&address.session.instance_id)
11252 {
11253 if !*reported && at.elapsed() < Duration::from_secs(5) {
11255 *reported = true;
11256 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");
11257 }
11258 }
11259 events.push(NodeEvent::TerminalFrame { address, frame });
11260 }
11261 drop(watermarks);
11262 if events.is_empty() {
11263 return;
11264 }
11265 let mut history = self
11266 .history
11267 .lock()
11268 .unwrap_or_else(|poisoned| poisoned.into_inner());
11269 let mut envelopes = Vec::with_capacity(events.len());
11270 for event in events {
11271 let sequence = history
11272 .last_sequence
11273 .checked_add(1)
11274 .expect("node event sequence exhausted");
11275 history.last_sequence = sequence;
11276 envelopes.push(NodeEventEnvelope { sequence, event });
11277 }
11278 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11279 drop(history);
11280 }
11281
11282 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11291 let mut history = self
11292 .history
11293 .lock()
11294 .unwrap_or_else(|poisoned| poisoned.into_inner());
11295 let sequence = history
11296 .last_sequence
11297 .checked_add(1)
11298 .expect("node event sequence exhausted");
11299 history.last_sequence = sequence;
11300 drop(history);
11301 tracing::debug!(
11309 source_sequence = chunk.source_sequence,
11310 kind = agent_stream_chunk_kind_label(&chunk.kind),
11311 envelope_sequence = sequence,
11312 "publishing agent stream chunk",
11313 );
11314 let envelope = NodeEventEnvelope {
11315 sequence,
11316 event: NodeEvent::AgentStream { address, chunk },
11317 };
11318 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11319 }
11320
11321 fn current_sequence(&self) -> u64 {
11322 self.history
11323 .lock()
11324 .unwrap_or_else(|poisoned| poisoned.into_inner())
11325 .last_sequence
11326 }
11327
11328 fn controller_state(&self) -> Option<ControllerState> {
11329 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11330 let lease = controller.as_ref().copied()?;
11331 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11332 if remaining.is_zero() {
11333 *controller = None;
11334 return None;
11335 }
11336 Some(ControllerState {
11337 connection_id: lease.connection_id,
11338 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11339 })
11340 }
11341
11342 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11343 if role != ClientRole::Operator {
11344 tracing::warn!(
11345 node_id = %self.node_id,
11346 connection_id,
11347 "controller lease rejected: observer connections cannot acquire control",
11348 );
11349 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11350 }
11351 let lease_ms = if lease_ms == 0 {
11352 DEFAULT_CONTROLLER_LEASE_MS
11353 } else {
11354 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11355 };
11356 let now = Instant::now();
11357 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11358 if let Some(active) = controller.as_ref() {
11359 if active.connection_id != connection_id && active.expires_at > now {
11360 tracing::warn!(
11361 node_id = %self.node_id,
11362 connection_id,
11363 holder_connection_id = active.connection_id,
11364 "controller lease rejected: another operator holds it",
11365 );
11366 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11367 }
11368 }
11369 let is_new_attach = match controller.as_ref() {
11376 Some(active) => active.connection_id != connection_id,
11377 None => true,
11378 };
11379 *controller = Some(ControllerLease {
11380 connection_id,
11381 expires_at: now + Duration::from_millis(lease_ms),
11382 });
11383 drop(controller);
11384 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11385 if is_new_attach {
11386 tracing::info!(
11387 node_id = %self.node_id,
11388 connection_id,
11389 lease_ms,
11390 "controller lease acquired",
11391 );
11392 } else {
11393 tracing::debug!(
11394 node_id = %self.node_id,
11395 connection_id,
11396 lease_ms,
11397 "controller lease renewed",
11398 );
11399 }
11400 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11401 Ok(state)
11402 }
11403
11404 fn release_controller(&self, connection_id: u64) -> bool {
11405 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11406 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11407 if released {
11408 *controller = None;
11409 }
11410 drop(controller);
11411 if released {
11412 tracing::info!(
11413 node_id = %self.node_id,
11414 connection_id,
11415 "controller lease released",
11416 );
11417 self.publish(NodeEvent::ControllerChanged { controller: None });
11418 }
11419 released
11420 }
11421
11422 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11431 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11432 return;
11433 };
11434 let id = address.session.instance_id;
11435 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11436 return;
11437 };
11438 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11439 .iter()
11440 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11441 .collect();
11442 let (writes, set_model, effort_gap) =
11443 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11444 if set_model {
11445 if let Some(model_id) = model.clone() {
11446 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11447 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11448 }
11449 }
11450 }
11451 for (option_id, value_json) in writes {
11452 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11453 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11454 }
11455 }
11456 if effort_gap {
11457 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11458 }
11459 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11460 }
11461
11462 fn mark_spawn_provisional(&self, address: SessionAddress) {
11463 let id = address.session.instance_id;
11464 self.provisional_spawns
11465 .lock()
11466 .unwrap_or_else(|poisoned| poisoned.into_inner())
11467 .insert(id, (address, Instant::now()));
11468 tracing::info!(
11469 node_id = %self.node_id,
11470 instance_id = id.0,
11471 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11472 "draft spawn; waiting for HQ view",
11473 );
11474 }
11475
11476 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11477 let removed = self
11478 .provisional_spawns
11479 .lock()
11480 .unwrap_or_else(|poisoned| poisoned.into_inner())
11481 .remove(&instance_id);
11482 if removed.is_some() {
11483 tracing::info!(
11484 node_id = %self.node_id,
11485 instance_id = instance_id.0,
11486 "draft spawn claimed by HQ view",
11487 );
11488 }
11489 }
11490
11491 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11492 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11493 let mut map = self
11494 .provisional_spawns
11495 .lock()
11496 .unwrap_or_else(|poisoned| poisoned.into_inner());
11497 let due: Vec<AgentInstanceId> = map
11498 .iter()
11499 .filter(|(_, (_, at))| at.elapsed() >= grace)
11500 .map(|(id, _)| *id)
11501 .collect();
11502 due.into_iter()
11503 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11504 .collect()
11505 }
11506
11507 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11508 let instance_id = address.session.instance_id;
11509 tracing::warn!(
11510 node_id = %self.node_id,
11511 instance_id = instance_id.0,
11512 workspace = %address.workspace_id,
11513 reason = "attach_never_arrived",
11514 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11515 "draft spawn never viewed; stopping PTY",
11516 );
11517 let _ = self.dispatch(ControlCommand::Stop {
11518 instance_id,
11519 force: true,
11520 });
11521 let _ = self.remove_session(&address).await;
11522 }
11523
11524 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11525 if role != ClientRole::Operator {
11526 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11527 }
11528 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11529 let now = Instant::now();
11530 match controller.as_ref().copied() {
11531 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11532 Some(active) if active.expires_at <= now => {
11533 *controller = None;
11534 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11535 }
11536 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11537 }
11538 }
11539
11540 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11541 if !self
11542 .workspaces
11543 .read()
11544 .unwrap_or_else(|poisoned| poisoned.into_inner())
11545 .contains_key(&address.workspace_id)
11546 {
11547 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11548 }
11549 let binding = self
11550 .session_bindings
11551 .lock()
11552 .unwrap_or_else(|poisoned| poisoned.into_inner())
11553 .get(&address.session.instance_id)
11554 .cloned()
11555 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11556 if binding.workspace_id != address.workspace_id {
11557 return Err(failure(
11558 NodeFailureCode::SessionWorkspaceMismatch,
11559 "session belongs to another workspace",
11560 ));
11561 }
11562 if binding.generation != address.session.generation {
11563 return Err(failure(
11564 NodeFailureCode::StaleGeneration,
11565 &format!(
11566 "stale session generation: expected {}, received {}",
11567 binding.generation.0,
11568 address.session.generation.0,
11569 ),
11570 ));
11571 }
11572 let snapshot = self.handle.snapshot();
11573 let Some(current) = snapshot
11574 .sessions
11575 .iter()
11576 .find(|item| item.instance_id == address.session.instance_id)
11577 else {
11578 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11579 };
11580 if current.generation != address.session.generation {
11581 return Err(failure(
11582 NodeFailureCode::StaleGeneration,
11583 &format!(
11584 "stale session generation: expected {}, received {}",
11585 current.generation.0,
11586 address.session.generation.0,
11587 ),
11588 ));
11589 }
11590 Ok(current.agent_id.clone())
11591 }
11592
11593 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11594 self.dispatch_envelope(self.prepare_command(command))
11595 }
11596
11597 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11598 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11599 CommandEnvelope {
11600 id: CommandId(id),
11601 command,
11602 }
11603 }
11604
11605 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11606 let command_id = envelope.id;
11607 self.handle
11608 .dispatch(envelope)
11609 .map_err(|error| match error {
11610 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11611 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11612 })?;
11613 Ok(command_id)
11614 }
11615
11616 fn record_rejected_command(
11625 &self,
11626 command_id: CommandId,
11627 message: String,
11628 unsupported_transport: bool,
11629 ) {
11630 let now = Instant::now();
11631 let mut reasons = self.rejected_command_reasons.lock()
11632 .unwrap_or_else(|poisoned| poisoned.into_inner());
11633 reasons.retain(|_, reason| reason.expires_at > now);
11634 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11635 if let Some(&oldest) = reasons.keys().next() {
11636 reasons.remove(&oldest);
11637 }
11638 }
11639 reasons.insert(
11640 command_id,
11641 RejectedCommandReason {
11642 message,
11643 unsupported_transport,
11644 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11645 },
11646 );
11647 }
11648
11649 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11656 let now = Instant::now();
11657 let mut reasons = self.rejected_command_reasons.lock()
11658 .unwrap_or_else(|poisoned| poisoned.into_inner());
11659 match reasons.remove(&command_id) {
11660 Some(reason) if reason.expires_at > now => Some(reason),
11661 _ => None,
11662 }
11663 }
11664
11665 async fn dispatch_bounded(
11666 &self,
11667 command: ControlCommand,
11668 timeout_duration: Duration,
11669 ) -> Result<CommandId, NodeFailure> {
11670 let deadline = Instant::now() + timeout_duration;
11671 loop {
11672 match self.dispatch(command.clone()) {
11673 Ok(command_id) => return Ok(command_id),
11674 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11675 sleep(Duration::from_millis(2)).await;
11676 }
11677 Err(error) => return Err(error),
11678 }
11679 }
11680 }
11681
11682 fn internal_session_snapshot(
11683 &self,
11684 address: &SessionAddress,
11685 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11686 self.validate_address(address)?;
11687 self.handle
11688 .snapshot()
11689 .sessions
11690 .clone()
11691 .into_iter()
11692 .find(|session| {
11693 session.instance_id == address.session.instance_id
11694 && session.generation == address.session.generation
11695 })
11696 .ok_or_else(|| {
11697 failure(
11698 NodeFailureCode::UnknownSession,
11699 "session snapshot is unavailable",
11700 )
11701 })
11702 }
11703
11704 fn require_acp_session(
11712 &self,
11713 address: &SessionAddress,
11714 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11715 let snapshot = self.internal_session_snapshot(address)?;
11716 if snapshot.transport != TransportKind::Acp {
11717 return Err(failure(
11718 NodeFailureCode::UnsupportedTransport,
11719 "this verb requires a session spawned over the ACP transport",
11720 ));
11721 }
11722 Ok(snapshot)
11723 }
11724
11725 async fn dispatch_history_bounded(
11726 &self,
11727 address: &SessionAddress,
11728 command: ControlCommand,
11729 operation: HistoryOperation,
11730 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11731 self.validate_address(address)?;
11732 let started_after = self.current_sequence();
11733 let command_id = self
11734 .dispatch_bounded(
11735 command,
11736 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11737 )
11738 .await?;
11739 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11740 let mut scan_after = started_after;
11741 let mut accepted_after = None;
11742 loop {
11743 let events = self
11744 .history
11745 .lock()
11746 .unwrap_or_else(|poisoned| poisoned.into_inner())
11747 .events
11748 .iter()
11749 .filter(|envelope| envelope.sequence > scan_after)
11750 .cloned()
11751 .collect::<Vec<_>>();
11752 for envelope in events {
11753 scan_after = scan_after.max(envelope.sequence);
11754 let NodeEvent::Control {
11755 address: event_address,
11756 event,
11757 } = envelope.event
11758 else {
11759 continue;
11760 };
11761 if event_address != *address {
11762 continue;
11763 }
11764 if event.command_id == Some(command_id) {
11765 match &event.event {
11766 ControlEventKind::CommandRejected { .. } => {
11767 return Err(failure(
11768 NodeFailureCode::InvalidRequest,
11769 "history request was rejected",
11770 ));
11771 }
11772 ControlEventKind::HistoryRequested {
11773 operation: requested,
11774 ..
11775 } if requested == &operation => {
11776 accepted_after = Some(envelope.sequence);
11777 continue;
11778 }
11779 _ => {}
11780 }
11781 }
11782 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11783 match (&operation, &event.event) {
11784 (
11785 HistoryOperation::Discover { .. },
11786 ControlEventKind::HistoryDiscovered { .. },
11787 )
11788 | (
11789 HistoryOperation::Load { .. },
11790 ControlEventKind::HistoryLoaded { .. },
11791 ) => return self.internal_session_snapshot(address),
11792 (_, ControlEventKind::HistoryFailed { .. }) => {
11793 return Err(failure(
11794 NodeFailureCode::BackendOperationFailed,
11795 "history operation failed",
11796 ));
11797 }
11798 _ => {}
11799 }
11800 }
11801 }
11802 if Instant::now() >= deadline {
11803 return Err(failure(
11804 NodeFailureCode::BackendBusy,
11805 if accepted_after.is_some() {
11806 "history operation did not settle before the bounded deadline"
11807 } else {
11808 "history operation was not accepted before the bounded deadline"
11809 },
11810 ));
11811 }
11812 sleep(Duration::from_millis(2)).await;
11813 }
11814 }
11815
11816 async fn discover_history(
11817 &self,
11818 address: &SessionAddress,
11819 limit: u16,
11820 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11821 let query = HistoryQuery {
11822 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11823 limit,
11824 };
11825 query
11826 .validate()
11827 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11828 let snapshot = self
11829 .dispatch_history_bounded(
11830 address,
11831 ControlCommand::DiscoverHistory {
11832 instance_id: address.session.instance_id,
11833 query: query.clone(),
11834 },
11835 HistoryOperation::Discover { query },
11836 )
11837 .await?;
11838 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11839 return Err(failure(
11840 NodeFailureCode::BackendOperationFailed,
11841 "history discovery settled into an invalid state",
11842 ));
11843 }
11844 Ok(snapshot.history.candidates)
11845 }
11846
11847 async fn load_history(
11848 &self,
11849 address: &SessionAddress,
11850 candidate_id: String,
11851 ) -> Result<HistorySessionRecord, NodeFailure> {
11852 validate_candidate_id(&candidate_id)
11853 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11854 let snapshot = self
11855 .dispatch_history_bounded(
11856 address,
11857 ControlCommand::LoadHistory {
11858 instance_id: address.session.instance_id,
11859 candidate_id: candidate_id.clone(),
11860 },
11861 HistoryOperation::Load {
11862 candidate_id: candidate_id.clone(),
11863 },
11864 )
11865 .await?;
11866 if snapshot.history.pending.is_some()
11867 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11868 {
11869 return Err(failure(
11870 NodeFailureCode::BackendOperationFailed,
11871 "history load settled into an invalid state",
11872 ));
11873 }
11874 snapshot.history.loaded.ok_or_else(|| {
11875 failure(
11876 NodeFailureCode::BackendOperationFailed,
11877 "history load produced no bounded session",
11878 )
11879 })
11880 }
11881
11882 async fn export_context_pack_for_session_record(
11889 &self,
11890 record_id: &SessionRecordId,
11891 session: &SessionAddress,
11892 allow_clean_detachment: bool,
11893 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11894 let outcome = self
11900 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11901 .await;
11902 if let Err(failure) = &outcome {
11903 tracing::warn!(
11904 record = %record_id,
11905 code = ?failure.code,
11906 reason = %failure.message,
11907 "session-record context-pack export refused",
11908 );
11909 }
11910 outcome
11911 }
11912
11913 async fn export_context_pack_for_session_record_inner(
11914 &self,
11915 record_id: &SessionRecordId,
11916 session: &SessionAddress,
11917 allow_clean_detachment: bool,
11918 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11919 let step = |name: &'static str| move |failure: NodeFailure| {
11924 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11925 failure
11926 };
11927 let record = self.record(record_id).map_err(step("record-lookup"))?;
11928 let identity = match record.provider_session.clone() {
11944 Some(identity) => {
11945 if identity.key != ProviderSessionKey::SessionId {
11946 return Err(step("provider-identity-key")(failure(
11947 NodeFailureCode::BackendOperationFailed,
11948 "managed session provider identity cannot bind history exactly",
11949 )));
11950 }
11951 Some(identity)
11952 }
11953 None if record.state == ManagedSessionState::IdentityPending => {
11954 return Err(step("provider-session-identity")(failure(
11955 NodeFailureCode::BackendOperationFailed,
11956 "managed session has not yet observed its provider session identity",
11957 )));
11958 }
11959 None => None,
11960 };
11961 self.revalidate_session_record_context_export(
11962 &record,
11963 identity.as_ref(),
11964 session,
11965 allow_clean_detachment,
11966 ).map_err(step("revalidate-before-discovery"))?;
11967 let candidates = self
11968 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11969 .await
11970 .map_err(step("discover-history"))?;
11971 let candidate_id = select_history_candidate(
11976 &candidates,
11977 identity.as_ref().map(|identity| identity.id.as_str()),
11978 ).map_err(step("unique-candidate"))?;
11979 let loaded = self.load_history(session, candidate_id.clone()).await
11980 .map_err(step("load-history"))?;
11981 if let Some(identity) = identity.as_ref() {
11982 if loaded.session_id != identity.id {
11983 return Err(failure(
11984 NodeFailureCode::SessionRecordConflict,
11985 "loaded history identity does not match the managed session record",
11986 ));
11987 }
11988 }
11989 self.revalidate_session_record_context_export(
11990 &record,
11991 identity.as_ref(),
11992 session,
11993 allow_clean_detachment,
11994 )?;
11995 self.revalidate_loaded_history(session, &candidate_id, &loaded)
11996 .map_err(step("revalidate-loaded-history"))?;
11997 let pack = self.materialize_context_pack(session).await
11998 .map_err(step("materialize-context-pack"))?;
11999 self.commit_context_pack_for_session_record(
12000 &record,
12001 identity.as_ref(),
12002 session,
12003 &candidate_id,
12004 &loaded,
12005 pack,
12006 allow_clean_detachment,
12007 )
12008 }
12009
12010 async fn export_context_pack(
12011 &self,
12012 address: &SessionAddress,
12013 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12014 let pack = self.materialize_context_pack(address).await?;
12015 self.commit_context_pack(pack)
12016 }
12017
12018 async fn materialize_context_pack(
12019 &self,
12020 address: &SessionAddress,
12021 ) -> Result<NodeContextPack, NodeFailure> {
12022 let snapshot = self.internal_session_snapshot(address)?;
12023 if !context_pack_source_status_is_usable(&snapshot.status) {
12024 return Err(failure(
12025 NodeFailureCode::InvalidRequest,
12026 "context export requires a running or successfully exited source session",
12027 ));
12028 }
12029 if snapshot.history.pending.is_some()
12030 || snapshot.history.loaded_candidate_id.is_none()
12031 {
12032 return Err(failure(
12033 NodeFailureCode::InvalidRequest,
12034 "context export requires settled loaded history",
12035 ));
12036 }
12037 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12038 failure(
12039 NodeFailureCode::InvalidRequest,
12040 "context export requires settled loaded history",
12041 )
12042 })?;
12043 let source_provider = snapshot.agent_id.clone();
12044 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12045 let repository = self
12046 .context_pack_repository(&address.workspace_id)
12047 .await?;
12048 let current = self.internal_session_snapshot(address)?;
12049 if !context_pack_source_status_is_usable(¤t.status)
12050 || current.agent_id != source_provider
12051 || current.history.pending.is_some()
12052 || current.history.loaded_candidate_id != loaded_candidate_id
12053 || current.history.loaded.as_ref() != Some(&loaded)
12054 {
12055 return Err(failure(
12056 NodeFailureCode::ContextPackMaterializationFailed,
12057 "context source changed during bounded repository capture",
12058 ));
12059 }
12060 NodeContextPack::export_with_repository(
12061 ContextPackLineageReceipt {
12062 source_node_id: self.node_id.clone(),
12063 source_session: address.clone(),
12064 source_provider,
12065 },
12066 &loaded,
12067 Some(repository),
12068 )
12069 .map_err(|_| {
12070 failure(
12071 NodeFailureCode::ContextPackMaterializationFailed,
12072 "bounded context pack export failed",
12073 )
12074 })
12075 }
12076
12077 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12083 let mut store = self
12084 .context_pack_store
12085 .lock()
12086 .unwrap_or_else(|poisoned| poisoned.into_inner());
12087 let Some(store) = store.as_mut() else {
12088 return Ok(());
12089 };
12090 store.commit(pack).map_err(|_| {
12091 failure(
12092 NodeFailureCode::ContextPackMaterializationFailed,
12093 "durable context pack commit failed",
12094 )
12095 })
12096 }
12097
12098 fn commit_context_pack(
12099 &self,
12100 pack: NodeContextPack,
12101 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12102 self.commit_context_pack_durably(&pack)?;
12103 self.context_catalog
12104 .write()
12105 .unwrap_or_else(|poisoned| poisoned.into_inner())
12106 .insert(pack)
12107 .map_err(|_| {
12108 failure(
12109 NodeFailureCode::ContextPackMaterializationFailed,
12110 "context pack catalog is full",
12111 )
12112 })
12113 }
12114
12115 fn commit_context_pack_for_session_record(
12116 &self,
12117 expected: &ManagedSessionRecord,
12118 identity: Option<&ProviderSessionIdentity>,
12119 session: &SessionAddress,
12120 candidate_id: &str,
12121 loaded: &HistorySessionRecord,
12122 pack: NodeContextPack,
12123 allow_clean_detachment: bool,
12124 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12125 let current_root = self.workspace_root(&expected.workspace_id)?;
12126 let _transaction = self
12127 .state_transaction
12128 .lock()
12129 .unwrap_or_else(|poisoned| poisoned.into_inner());
12130 let records = self
12131 .session_records
12132 .lock()
12133 .unwrap_or_else(|poisoned| poisoned.into_inner());
12134 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12135 failure(
12136 NodeFailureCode::SessionRecordConflict,
12137 "managed session record disappeared during context export",
12138 )
12139 })?;
12140 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12141 return Err(failure(
12142 NodeFailureCode::SessionRecordConflict,
12143 "managed session record changed during context export",
12144 ));
12145 }
12146 let bindings = self
12147 .session_bindings
12148 .lock()
12149 .unwrap_or_else(|poisoned| poisoned.into_inner());
12150 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12151 failure(
12152 NodeFailureCode::SessionRecordConflict,
12153 "managed session runtime binding disappeared during context export",
12154 )
12155 })?;
12156 let runtime = self
12157 .handle
12158 .snapshot()
12159 .sessions
12160 .iter()
12161 .find(|snapshot| {
12162 snapshot.instance_id == session.session.instance_id
12163 && snapshot.generation == session.session.generation
12164 })
12165 .cloned()
12166 .ok_or_else(|| {
12167 failure(
12168 NodeFailureCode::SessionRecordConflict,
12169 "managed session runtime disappeared during context export",
12170 )
12171 })?;
12172 let receipt = pack.receipt();
12173 if !session_record_context_export_binding_is_exact(
12174 expected,
12175 current,
12176 identity,
12177 session,
12178 binding,
12179 &runtime.agent_id,
12180 ¤t_root,
12181 allow_clean_detachment,
12182 ) || !session_record_context_export_source_is_usable(
12183 current,
12184 &runtime.status,
12185 allow_clean_detachment,
12186 ) || runtime.history.pending.is_some()
12187 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12188 || runtime.history.loaded.as_ref() != Some(loaded)
12189 || receipt.lineage.source_node_id != self.node_id
12190 || receipt.lineage.source_session != *session
12191 || receipt.lineage.source_provider != current.provider
12192 || receipt.source_message_count != loaded.message_count
12193 {
12194 return Err(failure(
12195 NodeFailureCode::SessionRecordConflict,
12196 "managed session source changed before context catalog commit",
12197 ));
12198 }
12199 self.commit_context_pack_durably(&pack)?;
12200 self.context_catalog
12201 .write()
12202 .unwrap_or_else(|poisoned| poisoned.into_inner())
12203 .insert(pack)
12204 .map_err(|_| {
12205 failure(
12206 NodeFailureCode::ContextPackMaterializationFailed,
12207 "context pack catalog is full",
12208 )
12209 })
12210 }
12211
12212 async fn context_pack_repository(
12213 &self,
12214 workspace_id: &WorkspaceId,
12215 ) -> Result<ContextPackRepository, NodeFailure> {
12216 let first = self
12217 .context_pack_repository_observation(workspace_id)
12218 .await?;
12219 let second = self
12220 .context_pack_repository_observation(workspace_id)
12221 .await?;
12222 stable_context_pack_repository(first, second)
12223 }
12224
12225 async fn context_pack_repository_observation(
12226 &self,
12227 workspace_id: &WorkspaceId,
12228 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12229 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12230 let head = if git.is_repository {
12231 let root = self.workspace_root(workspace_id)?;
12232 match resolve_base_commit_with_timeout(
12233 &root,
12234 "HEAD",
12235 GIT_COMMAND_TIMEOUT_MS,
12236 )
12237 .await
12238 {
12239 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12240 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12241 ContextPackRepositoryHead::Unborn
12242 }
12243 Err(_) => {
12244 return Err(failure(
12245 NodeFailureCode::ContextPackMaterializationFailed,
12246 "context pack HEAD observation failed",
12247 ));
12248 }
12249 }
12250 } else {
12251 ContextPackRepositoryHead::NotRepository
12252 };
12253 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12254 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12255 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12256 failure(
12257 NodeFailureCode::InvalidRepositoryPath,
12258 "context pack selected file path is invalid",
12259 )
12260 })?;
12261 match self.read_workspace_file(workspace_id.clone(), path).await {
12262 Ok(WorkspaceFileRead {
12263 content: WorkspaceFileContent::Utf8 { text, byte_len },
12264 ..
12265 }) => files.push(ContextPackRepositoryFileSource::utf8(
12266 selected_path,
12267 text,
12268 byte_len,
12269 )),
12270 Ok(WorkspaceFileRead {
12271 content: WorkspaceFileContent::NonUtf8 { byte_len },
12272 ..
12273 }) => files.push(ContextPackRepositoryFileSource::skipped(
12274 selected_path,
12275 ContextPackSelectedFileSkipReason::NonUtf8,
12276 Some(byte_len),
12277 )),
12278 Ok(WorkspaceFileRead {
12279 content: WorkspaceFileContent::TooLarge { .. },
12280 ..
12281 }) => files.push(ContextPackRepositoryFileSource::skipped(
12282 selected_path,
12283 ContextPackSelectedFileSkipReason::TooLarge,
12284 None,
12285 )),
12286 Err(error) => {
12287 let reason = if matches!(
12288 error.code,
12289 NodeFailureCode::InvalidRepositoryPath
12290 | NodeFailureCode::RepositoryFileNotRegular
12291 | NodeFailureCode::RepositoryPathUnsafe
12292 ) {
12293 ContextPackSelectedFileSkipReason::Unsafe
12294 } else {
12295 ContextPackSelectedFileSkipReason::Unavailable
12296 };
12297 files.push(ContextPackRepositoryFileSource::skipped(
12298 selected_path,
12299 reason,
12300 None,
12301 ));
12302 }
12303 }
12304 }
12305 Ok(ContextPackRepositoryObservation { head, git, files })
12306 }
12307
12308 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12309 let referenced_by_binding = self
12310 .session_bindings
12311 .lock()
12312 .unwrap_or_else(|poisoned| poisoned.into_inner())
12313 .values()
12314 .any(|binding| {
12315 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12316 });
12317 let referenced_by_record = self
12318 .session_records
12319 .lock()
12320 .unwrap_or_else(|poisoned| poisoned.into_inner())
12321 .records
12322 .values()
12323 .any(|record| record.context_id.as_ref() == Some(context_id));
12324 let referenced_by_materialization = self
12325 .materializations
12326 .lock()
12327 .unwrap_or_else(|poisoned| poisoned.into_inner())
12328 .values()
12329 .any(|ownership| {
12330 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12331 });
12332 if referenced_by_binding
12333 || referenced_by_record
12334 || referenced_by_materialization
12335 {
12336 return Err(failure(
12337 NodeFailureCode::ContextPackBusy,
12338 "context pack is still referenced",
12339 ));
12340 }
12341 let mut catalog = self.context_catalog
12342 .write()
12343 .unwrap_or_else(|poisoned| poisoned.into_inner());
12344 if catalog.get(context_id).is_none() {
12345 return Err(failure(
12346 NodeFailureCode::UnknownContextPack,
12347 "context pack is unavailable",
12348 ));
12349 }
12350 self.spawn_idempotency
12351 .lock()
12352 .unwrap_or_else(|poisoned| poisoned.into_inner())
12353 .entries
12354 .retain(|_, entry| !entry.value.references_context(context_id));
12355 let removed = catalog.remove(context_id);
12356 debug_assert!(removed.is_some(), "validated context pack remains present");
12357 Ok(())
12358 }
12359
12360 async fn dispatch_input_bounded(
12361 &self,
12362 address: &SessionAddress,
12363 action: InputAction,
12364 ) -> Result<(), NodeFailure> {
12365 let started_after = self.current_sequence();
12366 self.pty_input_marks
12367 .lock()
12368 .unwrap_or_else(|poisoned| poisoned.into_inner())
12369 .insert(address.session.instance_id, (Instant::now(), false));
12370 self.pty_input_wake.notify_one();
12371 let command_id = self
12372 .dispatch_bounded(
12373 ControlCommand::SendInput {
12374 instance_id: address.session.instance_id,
12375 action,
12376 },
12377 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12378 )
12379 .await?;
12380 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12384 let mut scan_after = started_after;
12385 let mut accepted_after = None;
12386 loop {
12387 let events = self
12388 .history
12389 .lock()
12390 .unwrap_or_else(|poisoned| poisoned.into_inner())
12391 .events
12392 .iter()
12393 .filter(|envelope| envelope.sequence > scan_after)
12394 .cloned()
12395 .collect::<Vec<_>>();
12396 for envelope in events {
12397 scan_after = scan_after.max(envelope.sequence);
12398 let NodeEvent::Control {
12399 address: event_address,
12400 event,
12401 } = envelope.event
12402 else {
12403 continue;
12404 };
12405 if event_address != *address {
12406 continue;
12407 }
12408 if event.command_id == Some(command_id) {
12409 match &event.event {
12410 ControlEventKind::CommandRejected { message } => {
12411 return Err(failure(NodeFailureCode::InvalidRequest, message));
12412 }
12413 ControlEventKind::InputRequested { .. } => {
12414 accepted_after = Some(envelope.sequence);
12415 continue;
12416 }
12417 _ => {}
12418 }
12419 }
12420 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12421 match &event.event {
12422 ControlEventKind::InputCompleted { .. } => return Ok(()),
12423 ControlEventKind::InputFailed { message, .. } => {
12424 return Err(failure(
12425 NodeFailureCode::BackendOperationFailed,
12426 message,
12427 ));
12428 }
12429 _ => {}
12430 }
12431 }
12432 }
12433 if Instant::now() >= deadline {
12434 return Err(failure(
12435 NodeFailureCode::BackendBusy,
12436 if accepted_after.is_some() {
12437 "input was accepted but did not settle before the bounded deadline"
12438 } else {
12439 "input was not accepted before the bounded deadline"
12440 },
12441 ));
12442 }
12443 sleep(Duration::from_millis(2)).await;
12444 }
12445 }
12446
12447 async fn dispatch_resize_bounded(
12448 &self,
12449 address: &SessionAddress,
12450 size: gate4agent_types::TerminalSize,
12451 ) -> Result<(), NodeFailure> {
12452 let started_after = self.current_sequence();
12453 let command_id = self
12454 .dispatch_bounded(
12455 ControlCommand::Resize {
12456 instance_id: address.session.instance_id,
12457 size,
12458 },
12459 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12460 )
12461 .await?;
12462 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12463 let mut scan_after = started_after;
12464 let mut accepted_after = None;
12465 loop {
12466 let events = self
12467 .history
12468 .lock()
12469 .unwrap_or_else(|poisoned| poisoned.into_inner())
12470 .events
12471 .iter()
12472 .filter(|envelope| envelope.sequence > scan_after)
12473 .cloned()
12474 .collect::<Vec<_>>();
12475 for envelope in events {
12476 scan_after = scan_after.max(envelope.sequence);
12477 let NodeEvent::Control {
12478 address: event_address,
12479 event,
12480 } = envelope.event
12481 else {
12482 continue;
12483 };
12484 if event_address != *address {
12485 continue;
12486 }
12487 if event.command_id == Some(command_id) {
12488 match &event.event {
12489 ControlEventKind::CommandRejected { message } => {
12490 return Err(failure(NodeFailureCode::InvalidRequest, message));
12491 }
12492 ControlEventKind::ResizeRequested {
12493 size: requested_size,
12494 ..
12495 } if *requested_size == size => {
12496 accepted_after = Some(envelope.sequence);
12497 continue;
12498 }
12499 _ => {}
12500 }
12501 }
12502 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12503 match &event.event {
12504 ControlEventKind::Resized { size: settled_size }
12505 if *settled_size == size =>
12506 {
12507 return Ok(());
12508 }
12509 ControlEventKind::ResizeFailed { message } => {
12510 return Err(failure(
12511 NodeFailureCode::BackendOperationFailed,
12512 message,
12513 ));
12514 }
12515 _ => {}
12516 }
12517 }
12518 }
12519 if Instant::now() >= deadline {
12520 return Err(failure(
12521 NodeFailureCode::BackendBusy,
12522 if accepted_after.is_some() {
12523 "resize was accepted but did not settle before the bounded deadline"
12524 } else {
12525 "resize was not accepted before the bounded deadline"
12526 },
12527 ));
12528 }
12529 sleep(Duration::from_millis(2)).await;
12530 }
12531 }
12532
12533 async fn spawn_session(
12534 &self,
12535 workspace_id: WorkspaceId,
12536 provider: AgentId,
12537 mode: SessionMode,
12538 terminal_size: gate4agent_types::TerminalSize,
12539 initial_prompt: Option<String>,
12540 ) -> Result<SessionAddress, NodeFailure> {
12541 if self.managed_worktrees.lock()
12542 .unwrap_or_else(|poisoned| poisoned.into_inner())
12543 .lease_for_workspace(&workspace_id)
12544 .is_some()
12545 {
12546 return Err(failure(
12547 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12548 "managed worktree targets may only be spawned through their lease request",
12549 ));
12550 }
12551 self
12552 .spawn_session_with_deadline(
12553 workspace_id,
12554 provider,
12555 mode,
12556 terminal_size,
12557 initial_prompt,
12558 None,
12559 None,
12560 None,
12561 None,
12562 None,
12563 SpawnRecordPolicy::ProviderIdentityOnly,
12564 None,
12565 &[],
12566 None,
12567 ApprovalLevel::default(),
12573 None,
12574 None,
12575 )
12576 .await
12577 .map(|(session, _runtime_policy)| session)
12578 }
12579
12580 async fn spawn_session_with_deadline(
12581 &self,
12582 workspace_id: WorkspaceId,
12583 provider: AgentId,
12584 mode: SessionMode,
12585 terminal_size: gate4agent_types::TerminalSize,
12586 initial_prompt: Option<String>,
12587 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12588 bundle: Option<ResolvedBundleReceipt>,
12589 context: Option<ResolvedContextPackReceipt>,
12590 managed_authority: Option<ManagedWorktreeLeaseId>,
12591 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12592 record_policy: SpawnRecordPolicy,
12593 deadline: Option<Instant>,
12594 required_capabilities: &[ProviderRuntimeCapability],
12595 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12596 approval_level: ApprovalLevel,
12597 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12598 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12604 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12605 let reserved_lease = self.managed_worktrees.lock()
12606 .unwrap_or_else(|poisoned| poisoned.into_inner())
12607 .lease_for_workspace(&workspace_id);
12608 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12609 (Some(expected), Some(provided)) if expected == provided => {}
12610 (None, None) => {}
12611 _ => {
12612 return Err(failure(
12613 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12614 "spawn lacks exact managed worktree lease authority",
12615 ));
12616 }
12617 }
12618 self.ensure_workspace_environment_profile_compatible(
12622 &workspace_id,
12623 environment_profile.as_ref(),
12624 )?;
12625 let runtime_requirement = match (mode, initial_prompt.is_some()) {
12626 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12627 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12628 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12629 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12630 };
12631 let transport = match mode {
12637 SessionMode::Pty => TransportKind::Pty,
12638 SessionMode::Inline => TransportKind::Pipe,
12639 SessionMode::Acp => TransportKind::Acp,
12640 };
12641 let pipe_transport = self.provider_supports_pipe_transport(&provider);
12642 let acp_transport = self.provider_supports_acp_transport(&provider);
12643 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12644 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12645 NodeFailureCode::UnsupportedSpawnCapability,
12646 "pre-admitted provider runtime no longer satisfies spawn requirements",
12647 ))?;
12648 runtime_policy
12649 } else if let Some(deadline) = deadline {
12650 let remaining = spawn_deadline_remaining(deadline)?;
12651 timeout(
12652 remaining,
12653 self.admit_provider_runtime(&provider, runtime_requirement),
12654 )
12655 .await
12656 .map_err(|_| {
12657 failure(
12658 NodeFailureCode::SpawnDeadlineExceeded,
12659 "provider admission exceeded the spawn deadline",
12660 )
12661 })??
12662 } else {
12663 self.admit_provider_runtime(&provider, runtime_requirement).await?
12664 };
12665 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12666 let runtime_policy =
12678 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12679 if required_capabilities
12680 .iter()
12681 .any(|capability| !runtime_policy.admits(*capability))
12682 {
12683 return Err(failure(
12684 NodeFailureCode::UnsupportedSpawnCapability,
12685 "provider runtime does not admit a required spawn capability",
12686 ));
12687 }
12688 if let Some(deadline) = deadline {
12689 spawn_deadline_remaining(deadline)?;
12690 }
12691 self.ensure_binding_capacity()?;
12692 let working_directory = self.workspace_root(&workspace_id)?;
12693 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12694 let session = SessionKey {
12695 instance_id,
12696 generation: SessionGeneration(1),
12697 };
12698 let address = SessionAddress {
12699 workspace_id: workspace_id.clone(),
12700 session,
12701 };
12702 tracing::info!(
12710 node_id = %self.node_id,
12711 workspace_id = %workspace_id,
12712 provider = %provider,
12713 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12714 session = ?address.session,
12715 "session spawn request accepted",
12716 );
12717 let mut prepared_spawn = SpawnPreparer::prepare(
12718 self,
12719 SpawnPrepareRequest {
12720 instance_id,
12721 address: &address,
12722 provider: &provider,
12723 mode,
12724 environment_profile: environment_profile.as_ref(),
12725 bundle: bundle.as_ref(),
12726 context: context.as_ref(),
12727 managed_authority: managed_authority.as_ref(),
12728 harness_mcp,
12729 approval_level,
12730 network_allowlist: network_allowlist.as_ref(),
12731 browser_profile_id,
12732 },
12733 )?;
12734 let materialization_id = prepared_spawn.materialization_id.take();
12735 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12736 let record_id = self.bind_spawn_session_with_materialization(
12737 &address,
12738 provider.clone(),
12739 mode,
12740 runtime_policy,
12741 record_policy,
12742 environment_profile,
12743 bundle,
12744 context,
12745 materialization_id,
12746 )?;
12747 prepared_spawn.retain_session_guards();
12748 let agent_id = provider;
12752 let register_command_id = match self.dispatch_bounded(
12753 ControlCommand::Register {
12754 instance_id,
12755 agent_id,
12756 transport,
12757 },
12758 dispatch_timeout,
12759 )
12760 .await {
12761 Ok(command_id) => command_id,
12762 Err(error) => {
12763 if let Some(binding) = self.remove_binding(&address) {
12764 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12765 }
12766 if let Some(record_id) = record_id.as_ref() {
12767 self.discard_record(record_id)?;
12768 }
12769 return Err(spawn_dispatch_error(error, deadline));
12770 }
12771 };
12772 let start_timeout = match spawn_dispatch_timeout(deadline) {
12773 Ok(timeout) => timeout,
12774 Err(error) => {
12775 let recovery = self
12776 .rollback_spawn(
12777 &address,
12778 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12779 )
12780 .await;
12781 return match recovery {
12782 Ok(()) => {
12783 if let Some(record_id) = record_id.as_ref() {
12784 self.discard_record(record_id)?;
12785 }
12786 Err(error)
12787 }
12788 Err(recovery_error) => {
12789 if let Some(record_id) = record_id.as_ref() {
12790 self.mark_record_error(
12791 record_id,
12792 "spawn deadline elapsed and registration recovery failed",
12793 )?;
12794 }
12795 Err(recovery_error)
12796 }
12797 };
12798 }
12799 };
12800 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12801 if let Err(recovery_error) = self
12802 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12803 .await
12804 {
12805 return Err(recovery_error);
12806 }
12807 if let Some(record_id) = record_id.as_ref() {
12808 self.discard_record(record_id)?;
12809 }
12810 return Err(start_error);
12811 }
12812 let start_result = self
12813 .dispatch_bounded(
12814 ControlCommand::Start {
12815 instance_id,
12816 runtime_policy,
12817 request: StartRequest {
12818 working_directory,
12819 terminal_size,
12820 initial_prompt,
12821 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
12822 approval_level,
12823 },
12824 },
12825 start_timeout,
12826 )
12827 .await;
12828 let start_command_id = match start_result {
12829 Ok(command_id) => command_id,
12830 Err(start_error) => {
12831 let start_error = spawn_dispatch_error(start_error, deadline);
12832 let recovery = self
12833 .rollback_spawn(
12834 &address,
12835 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12836 )
12837 .await;
12838 return match recovery {
12839 Ok(()) => {
12840 if let Some(record_id) = record_id.as_ref() {
12841 self.discard_record(record_id)?;
12842 }
12843 Err(start_error)
12844 }
12845 Err(recovery_error) => {
12846 let diagnostic = format!(
12847 "start failed and registration recovery failed: {}",
12848 recovery_error.message,
12849 );
12850 if let Some(record_id) = record_id.as_ref() {
12851 self.mark_record_error(record_id, &diagnostic)?;
12852 }
12853 Err(failure(recovery_error.code, &diagnostic))
12854 }
12855 };
12856 }
12857 };
12858
12859 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12860 loop {
12861 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12870 .or_else(|| self.take_rejected_command_reason(start_command_id))
12871 {
12872 self.rollback_spawn(
12873 &address,
12874 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12875 ).await.map_err(|error| {
12876 failure(
12877 error.code,
12878 &format!(
12879 "spawn was rejected ({}) and registration recovery failed: {}",
12880 reason.message, error.message
12881 ),
12882 )
12883 })?;
12884 if let Some(record_id) = record_id.as_ref() {
12885 self.discard_record(record_id)?;
12886 }
12887 let code = if reason.unsupported_transport {
12888 NodeFailureCode::UnsupportedTransport
12889 } else {
12890 NodeFailureCode::BackendOperationFailed
12891 };
12892 return Err(failure(code, &reason.message));
12893 }
12894 if let Some(current) = self
12895 .handle
12896 .snapshot()
12897 .sessions
12898 .iter()
12899 .find(|current| current.instance_id == instance_id)
12900 {
12901 if current.generation != session.generation {
12902 let divergence = failure(
12903 NodeFailureCode::StaleGeneration,
12904 "spawn generation diverged",
12905 );
12906 return match self
12907 .rollback_spawn(
12908 &address,
12909 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12910 )
12911 .await
12912 {
12913 Ok(()) => {
12914 if let Some(record_id) = record_id.as_ref() {
12915 self.discard_record(record_id)?;
12916 }
12917 Err(divergence)
12918 }
12919 Err(recovery_error) => {
12920 if let Some(record_id) = record_id.as_ref() {
12921 self.mark_record_error(
12922 record_id,
12923 "spawn generation diverged and recovery failed",
12924 )?;
12925 }
12926 Err(recovery_error)
12927 }
12928 };
12929 }
12930 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12931 prepared_spawn.retain_launch_guards();
12932 return Ok((address, runtime_policy));
12933 }
12934 }
12935 if Instant::now() >= commit_deadline {
12936 self.rollback_spawn(
12937 &address,
12938 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12939 ).await.map_err(|error| {
12940 failure(
12941 error.code,
12942 &format!(
12943 "start did not commit and registration recovery failed: {}",
12944 error.message
12945 ),
12946 )
12947 })?;
12948 if let Some(record_id) = record_id.as_ref() {
12949 self.discard_record(record_id)?;
12950 }
12951 return Err(failure(
12952 if deadline.is_some() {
12953 NodeFailureCode::SpawnDeadlineExceeded
12954 } else {
12955 NodeFailureCode::BackendBusy
12956 },
12957 "start did not commit before the bounded deadline; registration was removed",
12958 ));
12959 }
12960 sleep(Duration::from_millis(2)).await;
12961 }
12962 }
12963
12964 fn effective_spawn_runtime_policy(
12965 &self,
12966 runtime_policy: ProviderRuntimePolicy,
12967 ) -> ProviderRuntimePolicy {
12968 #[cfg(feature = "fixture")]
12969 let runtime_policy = if self.fixture_semantic_hook_policy {
12970 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12971 .expect("monitoring Hook fixture policy is internally valid")
12972 } else {
12973 runtime_policy
12974 };
12975 runtime_policy
12976 }
12977
12978 fn verify_harness_mcp_before_start(
12979 &self,
12980 prepared: Option<&PreparedHarnessMcpSpawn>,
12981 ) -> Result<(), NodeFailure> {
12982 prepared
12983 .map(PreparedHarnessMcpSpawn::verify_helper_program)
12984 .transpose()
12985 .map(|_| ())
12986 .map_err(harness_mcp_failure)
12987 }
12988
12989 async fn rollback_spawn(
12990 &self,
12991 address: &SessionAddress,
12992 commit_timeout: Duration,
12993 ) -> Result<(), NodeFailure> {
12994 self.dispatch_bounded(
12995 ControlCommand::Remove {
12996 instance_id: address.session.instance_id,
12997 },
12998 commit_timeout,
12999 )
13000 .await?;
13001 self.wait_until_removed(address, commit_timeout).await?;
13002 if let Some(binding) = self.remove_binding(address) {
13003 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13004 }
13005 Ok(())
13006 }
13007
13008 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13009 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13010 self.dispatch_bounded(
13011 ControlCommand::Remove {
13012 instance_id: address.session.instance_id,
13013 },
13014 commit_timeout,
13015 )
13016 .await?;
13017 self.wait_until_removed(address, commit_timeout).await?;
13018 let removed = self.remove_binding(address);
13019 if let Some(binding) = removed.as_ref() {
13020 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13021 }
13022 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13023 let snapshot = self.managed_worktrees.lock()
13024 .unwrap_or_else(|poisoned| poisoned.into_inner())
13025 .release_session(
13026 &lease_id,
13027 self.incarnation_id,
13028 address.session.instance_id,
13029 address.session.generation,
13030 unix_time_ms(),
13031 );
13032 if self.persist_state().is_err() {
13033 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13034 }
13035 if let Some(snapshot) = snapshot {
13036 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13037 }
13038 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13039 }
13040 Ok(())
13041 }
13042
13043 async fn wait_until_removed(
13089 &self,
13090 address: &SessionAddress,
13091 commit_timeout: Duration,
13092 ) -> Result<(), NodeFailure> {
13093 let deadline = Instant::now() + commit_timeout;
13094 let mut next_dispatch = Instant::now();
13095 loop {
13096 let snapshot = self.handle.snapshot();
13097 let status = snapshot
13098 .sessions
13099 .iter()
13100 .find(|session| session.instance_id == address.session.instance_id)
13101 .map(|session| session.status.clone());
13102 let Some(status) = status else {
13103 return Ok(());
13104 };
13105 let now = Instant::now();
13106 if now >= deadline {
13107 return Err(failure(
13108 NodeFailureCode::BackendBusy,
13109 "remove did not commit before the bounded deadline; session binding was retained",
13110 ));
13111 }
13112 if status.allows_remove() && now >= next_dispatch {
13113 let _ = self.dispatch(ControlCommand::Remove {
13114 instance_id: address.session.instance_id,
13115 });
13116 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13117 }
13118 sleep(Duration::from_millis(2)).await;
13119 }
13120 }
13121
13122 fn resync(&self, after_sequence: u64) -> NodeResponse {
13123 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13124 let event_sequence = history.last_sequence;
13125 let oldest_available_sequence = history.replay_floor_sequence;
13126 let events = history
13127 .events
13128 .iter()
13129 .filter(|event| event.sequence > after_sequence)
13130 .cloned()
13131 .collect();
13132 drop(history);
13133 NodeResponse::Resync {
13134 event_sequence,
13135 oldest_available_sequence,
13136 snapshot: self.snapshot(),
13137 events,
13138 }
13139 }
13140}
13141
13142fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13147 match kind {
13148 AgentStreamChunkKindV1::Text { .. } => "text",
13149 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13150 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13151 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13152 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13153 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13154 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13155 }
13156}
13157
13158async fn accept_connections(
13172 endpoint: &str,
13173 call_home: Option<std::net::SocketAddr>,
13174 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13175 shared: Arc<NodeShared>,
13176) -> Result<(), NodeServerError> {
13177 let mut connections = JoinSet::new();
13178 let result = match call_home {
13179 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13180 Some(relay) => {
13181 let listener =
13182 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13183 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13184 tokio::pin!(listener);
13185 tokio::pin!(dialler);
13186 tokio::select! {
13187 result = &mut listener => result,
13188 result = &mut dialler => result,
13189 }
13190 }
13191 };
13192 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13193 while !connections.is_empty() {
13194 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13195 if remaining.is_zero()
13196 || timeout(remaining, connections.join_next()).await.is_err()
13197 {
13198 break;
13199 }
13200 }
13201 connections.abort_all();
13202 while connections.join_next().await.is_some() {}
13203 result
13204}
13205
13206const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13210
13211async fn call_home_loop(
13225 relay: std::net::SocketAddr,
13226 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13227 shared: Arc<NodeShared>,
13228) -> Result<(), NodeServerError> {
13229 let mut failures = 0_usize;
13230 loop {
13231 if shared.shutdown.load(Ordering::Acquire) {
13232 return Ok(());
13233 }
13234 let permit = tokio::select! {
13235 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13236 permit.map_err(|_| NodeServerError::PreauthClosed)?
13237 }
13238 _ = shared.shutdown_notify.notified() => return Ok(()),
13239 };
13240 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13241 Ok(()) => {
13242 failures = 0;
13246 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13247 }
13248 Err(error) => {
13249 failures = failures.saturating_add(1);
13250 tracing::warn!(
13251 node_id = %shared.node_id,
13252 %relay,
13253 cause = %error,
13254 attempt = failures,
13255 "call-home connection failed",
13256 );
13257 let backoff = call_home_backoff(failures);
13258 tokio::select! {
13259 _ = sleep(backoff) => {}
13260 _ = shared.shutdown_notify.notified() => return Ok(()),
13261 }
13262 }
13263 }
13264 }
13265}
13266
13267const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13269const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13271
13272fn call_home_keepalive() -> socket2::TcpKeepalive {
13273 let keepalive = socket2::TcpKeepalive::new()
13274 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13275 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13276 #[cfg(not(windows))]
13277 let keepalive = keepalive.with_retries(3);
13278 keepalive
13279}
13280
13281fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13282 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13283}
13284
13285pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13287 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13288}
13289
13290fn call_home_backoff(attempt: usize) -> Duration {
13292 Duration::from_millis(
13293 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13294 )
13295}
13296
13297async fn dial_relay_and_serve(
13299 relay: std::net::SocketAddr,
13300 identity: Option<&hatchery_enroll::NodeIdentity>,
13301 shared: Arc<NodeShared>,
13302 preauth_permit: OwnedSemaphorePermit,
13303) -> Result<(), NodeServerError> {
13304 let mut stream = tokio::net::TcpStream::connect(relay)
13305 .await
13306 .map_err(NodeServerError::CallHomeTransport)?;
13307 let _ = stream.set_nodelay(true);
13311 if let Err(error) = enable_call_home_keepalive(&stream) {
13317 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13318 }
13319 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13320 .await
13321 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13322 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13323 let mut session_token = None;
13324 if let Some(identity) = identity {
13325 let mut magic = [0_u8; 4];
13329 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13330 .await
13331 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13332 .map_err(NodeServerError::CallHomeTransport)?;
13333 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13334 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13335 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13336 .await
13337 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13338 .map_err(NodeServerError::CallHomeTransport)?;
13339 let (answer, token) = identity
13340 .answer_challenge(shared.node_id.as_str(), &frame)
13341 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13342 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13343 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13344 session_token = Some(token);
13345 }
13346 }
13347 serve_connection(stream, shared, session_token, preauth_permit).await
13348}
13349
13350async fn accept_connections_inner(
13351 endpoint: &str,
13352 shared: Arc<NodeShared>,
13353 connections: &mut JoinSet<Result<(), NodeServerError>>,
13354) -> Result<(), NodeServerError> {
13355 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13356 loop {
13357 while connections.try_join_next().is_some() {}
13358 if shared.shutdown.load(Ordering::Acquire) {
13359 return Ok(());
13360 }
13361 let preauth_permit = tokio::select! {
13362 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13363 permit.map_err(|_| NodeServerError::PreauthClosed)?
13364 }
13365 _ = shared.shutdown_notify.notified() => return Ok(()),
13366 };
13367 let server = tokio::select! {
13368 result = listener.accept() => result?,
13369 _ = shared.shutdown_notify.notified() => return Ok(()),
13370 };
13371 let connection_shared = Arc::clone(&shared);
13372 connections.spawn(async move {
13373 serve_connection(server, connection_shared, None, preauth_permit).await
13374 });
13375 if shared.shutdown.load(Ordering::Acquire) {
13376 return Ok(());
13377 }
13378 }
13379}
13380
13381async fn serve_connection<S>(
13389 mut pipe: S,
13390 shared: Arc<NodeShared>,
13391 session_token: Option<String>,
13392 preauth_permit: OwnedSemaphorePermit,
13393) -> Result<(), NodeServerError>
13394where
13395 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13396{
13397 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13399 let hello_frame = timeout(
13400 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13401 read_json_frame_limited_body_timeout(
13402 &mut pipe,
13403 MAX_NODE_HELLO_FRAME_BYTES,
13404 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13405 ),
13406 )
13407 .await
13408 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13409 let ClientFrame::Hello(hello) = hello_frame else {
13410 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13411 };
13412 if hello.build_stamp != BUILD_STAMP {
13413 return Err(NodeServerError::Handshake(format!(
13414 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13415 hello.build_stamp,
13416 )));
13417 }
13418 let compatibility = match hello.compatibility.as_ref() {
13419 Some(offer) => {
13420 let mut selected = node_compatibility_support(&shared)?
13421 .negotiate(offer)
13422 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13423 project_negotiated_provider_ids(&mut selected);
13424 Some(selected)
13425 }
13426 None => None,
13427 };
13428 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13429 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13430 (Some(offer), Some(selected)) => negotiated_auth_proof(
13431 auth_token,
13432 AuthDirection::Server,
13433 hello.role,
13434 &hello.client_nonce,
13435 &server_nonce,
13436 offer,
13437 selected,
13438 ),
13439 (None, None) => auth_proof(
13440 auth_token,
13441 AuthDirection::Server,
13442 hello.role,
13443 &hello.client_nonce,
13444 &server_nonce,
13445 ),
13446 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13447 }
13448 .map_err(NodeServerError::Authentication)?;
13449 write_json_frame_limited(
13450 &mut pipe,
13451 &ServerFrame::Challenge(ServerChallenge {
13452 build_stamp: BUILD_STAMP.to_owned(),
13453 server_nonce,
13454 server_proof,
13455 compatibility: compatibility.clone(),
13456 }),
13457 MAX_NODE_HELLO_FRAME_BYTES,
13458 )
13459 .await?;
13460 let authentication = timeout(
13461 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13462 read_json_frame_limited_body_timeout(
13463 &mut pipe,
13464 MAX_NODE_HELLO_FRAME_BYTES,
13465 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13466 ),
13467 )
13468 .await
13469 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13470 let ClientFrame::Authenticate(authentication) = authentication else {
13471 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13472 };
13473 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13474 (Some(offer), Some(selected)) => negotiated_auth_proof(
13475 auth_token,
13476 AuthDirection::Client,
13477 hello.role,
13478 &hello.client_nonce,
13479 &server_nonce,
13480 offer,
13481 selected,
13482 ),
13483 (None, None) => auth_proof(
13484 auth_token,
13485 AuthDirection::Client,
13486 hello.role,
13487 &hello.client_nonce,
13488 &server_nonce,
13489 ),
13490 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13491 }
13492 .map_err(NodeServerError::Authentication)?;
13493 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13494 return Err(NodeServerError::Handshake("access denied".to_owned()));
13495 }
13496 let selected_capabilities = compatibility
13497 .as_ref()
13498 .map(|selected| selected.capabilities.clone())
13499 .unwrap_or_default();
13500 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13501 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13502 });
13503 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13504 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13505 });
13506 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13507 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13508 });
13509 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13510 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13511 });
13512 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13513 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13514 });
13515 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13516 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13517 }) && selected_capabilities.iter().any(|capability| {
13518 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13519 });
13520 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13521 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13522 });
13523 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13524 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13525 });
13526 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13527 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13528 });
13529 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13530 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13531 });
13532 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13533 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13534 });
13535 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13536 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13537 });
13538 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13539 .try_acquire_owned()
13540 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13541 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13542 let _release_guard = ControllerReleaseGuard {
13543 shared: Arc::clone(&shared),
13544 connection_id,
13545 };
13546 drop(preauth_permit);
13547 let _authenticated_permit = authenticated_permit;
13548 let mut event_rx = shared.event_tx.subscribe();
13549 let mut terminal_event_rx = terminal_event_subscription(
13550 &shared,
13551 include_terminal_frame_events,
13552 );
13553 let mut agent_stream_event_rx = agent_stream_event_subscription(
13554 &shared,
13555 include_agent_stream_events,
13556 );
13557 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13558 &shared,
13559 include_harness_mcp_proxy,
13560 );
13561 write_json_frame(
13562 &mut pipe,
13563 &ServerFrame::Hello(NodeHello {
13564 build_stamp: BUILD_STAMP.to_owned(),
13565 incarnation_id: shared.incarnation_id,
13566 connection_id,
13567 role: hello.role,
13568 event_sequence: shared.current_sequence(),
13569 controller: shared.controller_state(),
13570 snapshot: snapshot_for_wire(
13571 &shared,
13572 include_provider_runtime_status,
13573 include_open_provider_ids,
13574 include_spawn_profiles,
13575 include_managed_worktrees,
13576 include_child_environment_profiles,
13577 include_session_bundles,
13578 include_history_context_packs,
13579 include_agent_progress,
13580 include_session_task_correlation,
13581 ),
13582 compatibility,
13583 }),
13584 )
13585 .await?;
13586 let (mut reader, mut writer) = tokio::io::split(pipe);
13587 let (frame_tx, mut frame_rx) = mpsc::channel(16);
13588 let reader_task = tokio::spawn(async move {
13589 loop {
13590 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13591 &mut reader,
13592 MAX_NODE_CLIENT_FRAME_BYTES,
13593 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13594 )
13595 .await;
13596 let terminal = frame.is_err();
13597 if frame_tx.send(frame).await.is_err() || terminal {
13598 break;
13599 }
13600 }
13601 });
13602 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13603 let mut pending_events = Vec::new();
13604 let mut resync_required = false;
13605 let mut discard_events_through = 0_u64;
13606 loop {
13607 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13612 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13613 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13614 for _ in 0..durable_drain_budget {
13615 match event_rx.try_recv() {
13616 Ok(event) => queue_connection_event(
13617 &mut pending_events,
13618 event,
13619 discard_events_through,
13620 ),
13621 Err(broadcast::error::TryRecvError::Empty) => break,
13622 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13623 resync_required = true;
13624 break;
13625 }
13626 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13627 }
13628 }
13629 if let Some(receiver) = terminal_event_rx.as_mut() {
13630 match receiver.try_recv() {
13631 Ok(events) => queue_connection_event_batch(
13632 &mut pending_events,
13633 &events,
13634 discard_events_through,
13635 ),
13636 Err(broadcast::error::TryRecvError::Empty) => {}
13637 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13638 resync_required = true;
13639 }
13640 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13641 }
13642 }
13643 if let Some(receiver) = agent_stream_event_rx.as_mut() {
13644 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13654 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13655 for _ in 0..agent_stream_drain_budget {
13656 match receiver.try_recv() {
13657 Ok(events) => queue_connection_event_batch(
13658 &mut pending_events,
13659 &events,
13660 discard_events_through,
13661 ),
13662 Err(broadcast::error::TryRecvError::Empty) => break,
13663 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13664 tracing::warn!(
13665 connection_id,
13666 dropped,
13667 "agent stream event subscription lagged; forcing a resync",
13668 );
13669 resync_required = true;
13670 break;
13671 }
13672 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13673 }
13674 }
13675 }
13676 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13677 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13683 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13684 for _ in 0..harness_mcp_proxy_drain_budget {
13685 match receiver.try_recv() {
13686 Ok(events) => queue_connection_event_batch(
13687 &mut pending_events,
13688 &events,
13689 discard_events_through,
13690 ),
13691 Err(broadcast::error::TryRecvError::Empty) => break,
13692 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13693 tracing::warn!(
13694 connection_id,
13695 dropped,
13696 "harness MCP proxy event subscription lagged; forcing a resync",
13697 );
13698 resync_required = true;
13699 break;
13700 }
13701 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13702 }
13703 }
13704 }
13705 if resync_required {
13706 let event = resync_required_event(&shared);
13707 apply_connection_resync_watermark(
13708 &mut pending_events,
13709 &mut discard_events_through,
13710 event.sequence,
13711 );
13712 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13713 resync_required = false;
13714 continue;
13715 }
13716 if !pending_events.is_empty() {
13717 pending_events.sort_unstable_by_key(|event| event.sequence);
13718 let burst_len = connection_event_burst_len(pending_events.len());
13719 shared
13720 .connection_events_sent
13721 .fetch_add(burst_len as u64, Ordering::Relaxed);
13722 for event in pending_events.drain(..burst_len) {
13723 let event = if include_managed_worktrees {
13724 Some(event)
13725 } else {
13726 project_event_without_managed_worktrees(event)
13727 };
13728 let event = event.and_then(|event| if include_open_provider_ids {
13729 Some(event)
13730 } else {
13731 project_event_legacy_provider_ids(&shared, event)
13732 });
13733 let event = event.map(|event| if include_child_environment_profiles {
13734 event
13735 } else {
13736 project_event_without_child_environment_profile(event)
13737 });
13738 let event = event.map(|event| if include_session_bundles {
13739 event
13740 } else {
13741 project_event_without_session_bundle(event)
13742 });
13743 let event = event.map(|event| {
13744 project_event_history_for_wire(event, include_history_context_packs)
13745 });
13746 let event = event.map(|event| if include_history_context_packs {
13747 event
13748 } else {
13749 project_event_without_context_pack(event)
13750 });
13751 let event = event.map(|event| if include_session_task_correlation {
13752 event
13753 } else {
13754 project_event_without_session_task_binding(event)
13755 });
13756 let exact_harness_controller = hello.role == ClientRole::Operator
13757 && shared.controller_state().is_some_and(|controller| {
13758 controller.connection_id == connection_id
13759 });
13760 let event = event.and_then(|event| {
13761 if include_harness_mcp_proxy && exact_harness_controller {
13762 Some(event)
13763 } else {
13764 project_event_without_harness_mcp_proxy(event)
13765 }
13766 });
13767 if let Some(event) = event {
13768 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13769 }
13770 }
13771 }
13772 tokio::select! {
13773 biased;
13774 frame = frame_rx.recv() => {
13775 let frame = match frame {
13776 Some(frame) => frame,
13777 None => break,
13778 };
13779 let frame = match frame {
13780 Ok(frame) => frame,
13781 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13782 Err(error) => return Err(error.into()),
13783 };
13784 let ClientFrame::Request(request) = frame else {
13785 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13786 };
13787 #[cfg(feature = "fixture")]
13788 let probe_spawn_managed_worktree_v2 = matches!(
13789 &request.request,
13790 NodeRequest::SpawnManagedWorktreeV2 { .. }
13791 );
13792 let requires_open_provider_ids =
13793 request_requires_open_provider_ids(&shared, &request.request);
13794 let requires_child_environment_profile =
13795 request_requires_child_environment_profile(&shared, &request.request);
13796 let requires_session_bundle =
13797 request_requires_session_bundle(&shared, &request.request);
13798 let requires_history_context_pack =
13799 request_requires_history_context_pack(&shared, &request.request);
13800 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13801 ResponseEnvelope {
13802 request_id: request.request_id,
13803 result: Err(failure(
13804 NodeFailureCode::UnsupportedCapability,
13805 "open provider IDs were not negotiated",
13806 )),
13807 }
13808 } else if requires_child_environment_profile
13809 && !include_child_environment_profiles
13810 {
13811 ResponseEnvelope {
13812 request_id: request.request_id,
13813 result: Err(failure(
13814 NodeFailureCode::UnsupportedCapability,
13815 "child environment profiles were not negotiated",
13816 )),
13817 }
13818 } else if requires_session_bundle && !include_session_bundles {
13819 ResponseEnvelope {
13820 request_id: request.request_id,
13821 result: Err(failure(
13822 NodeFailureCode::UnsupportedCapability,
13823 "session bundles were not negotiated",
13824 )),
13825 }
13826 } else if requires_history_context_pack && !include_history_context_packs {
13827 ResponseEnvelope {
13828 request_id: request.request_id,
13829 result: Err(failure(
13830 NodeFailureCode::UnsupportedCapability,
13831 "history context packs were not negotiated",
13832 )),
13833 }
13834 } else if request_uses_unnegotiated_capability(
13835 &request.request,
13836 &selected_capabilities,
13837 )
13838 {
13839 ResponseEnvelope {
13840 request_id: request.request_id,
13841 result: Err(failure(
13842 NodeFailureCode::UnsupportedCapability,
13843 "request capability was not negotiated",
13844 )),
13845 }
13846 } else {
13847 process_request(&shared, connection_id, hello.role, request).await
13848 };
13849 #[cfg(feature = "fixture")]
13850 if probe_spawn_managed_worktree_v2 {
13851 shared
13852 .fixture_spawn_managed_worktree_v2_failure_probe
13853 .record(reply.result.as_ref().err().map(|failure| failure.code));
13854 }
13855 if !include_provider_runtime_status {
13856 clear_response_provider_runtime_status(&mut reply);
13857 }
13858 if !include_managed_worktrees {
13859 project_response_without_managed_worktrees(&mut reply);
13860 }
13861 if !include_open_provider_ids {
13862 project_response_legacy_provider_ids(&shared, &mut reply);
13863 }
13864 if !include_child_environment_profiles {
13865 project_response_without_child_environment_profile(&mut reply);
13866 }
13867 if !include_session_bundles {
13868 project_response_without_session_bundle(&mut reply);
13869 }
13870 project_response_history_for_wire(
13871 &mut reply,
13872 include_history_context_packs,
13873 );
13874 if !include_history_context_packs {
13875 project_response_without_context_pack(&mut reply);
13876 }
13877 if !include_agent_progress {
13878 project_response_without_agent_progress(&mut reply);
13879 }
13880 if !include_session_task_correlation {
13881 project_response_without_session_task_binding(&mut reply);
13882 }
13883 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13884 }
13885 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13886 event = event_rx.recv() => {
13887 match event {
13888 Ok(event) => queue_connection_event(
13889 &mut pending_events,
13890 event,
13891 discard_events_through,
13892 ),
13893 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13894 Err(broadcast::error::RecvError::Closed) => break,
13895 }
13896 }
13897 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13898 match events {
13899 Ok(events) => queue_connection_event_batch(
13900 &mut pending_events,
13901 &events,
13902 discard_events_through,
13903 ),
13904 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13905 Err(broadcast::error::RecvError::Closed) => break,
13906 }
13907 }
13908 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13909 match events {
13910 Ok(events) => queue_connection_event_batch(
13911 &mut pending_events,
13912 &events,
13913 discard_events_through,
13914 ),
13915 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13916 Err(broadcast::error::RecvError::Closed) => break,
13917 }
13918 }
13919 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13920 match events {
13921 Ok(events) => queue_connection_event_batch(
13922 &mut pending_events,
13923 &events,
13924 discard_events_through,
13925 ),
13926 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13927 Err(broadcast::error::RecvError::Closed) => break,
13928 }
13929 }
13930 _ = shared.shutdown_notify.notified() => break,
13931 }
13932 }
13933 Ok(())
13934}
13935
13936async fn receive_terminal_event_batch(
13937 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13938) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13939 match receiver {
13940 Some(receiver) => receiver.recv().await,
13941 None => std::future::pending().await,
13942 }
13943}
13944
13945fn terminal_event_subscription(
13946 shared: &NodeShared,
13947 enabled: bool,
13948) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13949 enabled.then(|| shared.terminal_event_tx.subscribe())
13950}
13951
13952fn agent_stream_event_subscription(
13956 shared: &NodeShared,
13957 enabled: bool,
13958) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13959 enabled.then(|| shared.agent_stream_event_tx.subscribe())
13960}
13961
13962fn harness_mcp_proxy_event_subscription(
13967 shared: &NodeShared,
13968 enabled: bool,
13969) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13970 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13971}
13972
13973fn queue_connection_event(
13974 pending: &mut Vec<NodeEventEnvelope>,
13975 event: NodeEventEnvelope,
13976 discard_through: u64,
13977) {
13978 if event.sequence <= discard_through
13979 && !event.event.requires_harness_mcp_proxy_capability()
13980 {
13981 return;
13982 }
13983 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13984 if let Some(index) = pending.iter().position(|current| {
13985 matches!(
13986 ¤t.event,
13987 NodeEvent::TerminalFrame {
13988 address: current_address,
13989 ..
13990 } if current_address == address
13991 )
13992 }) {
13993 pending.remove(index);
13994 }
13995 }
13996 pending.push(event);
13997}
13998
13999fn project_event_without_harness_mcp_proxy(
14000 envelope: NodeEventEnvelope,
14001) -> Option<NodeEventEnvelope> {
14002 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14003}
14004
14005fn queue_connection_event_batch(
14006 pending: &mut Vec<NodeEventEnvelope>,
14007 events: &[NodeEventEnvelope],
14008 discard_through: u64,
14009) {
14010 for event in events {
14011 queue_connection_event(pending, event.clone(), discard_through);
14012 }
14013}
14014
14015fn apply_connection_resync_watermark(
14016 pending: &mut Vec<NodeEventEnvelope>,
14017 discard_through: &mut u64,
14018 marker_sequence: u64,
14019) {
14020 *discard_through = (*discard_through).max(marker_sequence);
14021 pending.retain(|event| event.sequence > *discard_through);
14022}
14023
14024fn connection_event_burst_len(pending_len: usize) -> usize {
14025 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14026}
14027
14028fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14029 let history = shared
14030 .history
14031 .lock()
14032 .unwrap_or_else(|poisoned| poisoned.into_inner());
14033 let sequence = history.last_sequence;
14034 let oldest_available_sequence = history.replay_floor_sequence;
14035 NodeEventEnvelope {
14036 sequence,
14037 event: NodeEvent::ResyncRequired {
14038 oldest_available_sequence,
14039 },
14040 }
14041}
14042
14043fn node_compatibility_support(
14044 shared: &NodeShared,
14045) -> Result<NodeCompatibilitySupport, NodeServerError> {
14046 let mut support = node_compatibility_support_for_manifest(
14047 &shared.provider_contracts,
14048 &shared.provider_adapter_contracts,
14049 )?;
14050 if shared
14051 .delivery_store
14052 .lock()
14053 .unwrap_or_else(|poisoned| poisoned.into_inner())
14054 .is_some()
14055 {
14056 support.capabilities.push(
14057 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14058 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14059 );
14060 }
14061 if shared.harness_mcp_registry.is_some() {
14062 support.capabilities.push(
14063 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14064 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14065 );
14066 }
14067 Ok(support)
14068}
14069
14070fn node_compatibility_support_for_manifest(
14071 provider_contracts: &[ProviderContractSupport],
14072 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14073) -> Result<NodeCompatibilitySupport, NodeServerError> {
14074 Ok(NodeCompatibilitySupport {
14075 build_stamp: BUILD_STAMP.to_owned(),
14076 capabilities: baseline_capabilities()?,
14077 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14078 path_semantics: platform::path_semantics(),
14079 local_transport: platform::local_transport(),
14080 state_schema: StateSchemaSupport {
14081 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14082 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14083 },
14084 provider_contracts: provider_contracts.to_vec(),
14085 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14086 })
14087}
14088
14089fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14090 let capabilities = [
14091 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14092 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14093 NODE_REPOSITORY_PATH_CAPABILITY,
14094 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14095 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14096 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14097 NODE_GIT_READ_CAPABILITY,
14098 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14099 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14100 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14101 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14102 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14103 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14104 NODE_ACP_CONTROL_CAPABILITY,
14105 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14106 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14107 NODE_WORKTREE_SELECTION_CAPABILITY,
14108 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14109 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14110 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14111 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14112 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14113 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14114 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14115 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14116 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14117 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14118 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14119 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14120 ]
14121 .into_iter()
14122 .map(|capability| {
14123 CapabilityId::new(capability)
14124 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14125 })
14126 .collect::<Result<Vec<_>, _>>()?;
14127 #[cfg(windows)]
14128 let capabilities = {
14129 let mut capabilities = capabilities;
14130 capabilities.push(
14131 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14132 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14133 );
14134 capabilities
14135 };
14136 Ok(capabilities)
14137}
14138
14139fn request_uses_unnegotiated_capability(
14140 request: &NodeRequest,
14141 selected_capabilities: &[CapabilityId],
14142) -> bool {
14143 let selected = |required: &str| {
14144 selected_capabilities
14145 .iter()
14146 .any(|capability| capability.as_str() == required)
14147 };
14148 request
14149 .required_capability()
14150 .is_some_and(|required| !selected(required))
14151 || (request.requires_worktree_selection_capability()
14152 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14153 || (request.requires_spawn_spec_defaults_overrides_capability()
14154 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14155 || (request.requires_spawn_profile_revision_capability()
14156 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14157 || (request.requires_child_environment_profile_capability()
14158 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14159 || (request.requires_session_bundle_materialization_capability()
14160 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14161 || (request.requires_history_context_pack_capability()
14162 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14163}
14164
14165fn snapshot_for_wire(
14166 shared: &NodeShared,
14167 include_provider_runtime_status: bool,
14168 include_open_provider_ids: bool,
14169 include_spawn_profiles: bool,
14170 include_managed_worktrees: bool,
14171 include_child_environment_profiles: bool,
14172 include_session_bundles: bool,
14173 include_history_context_packs: bool,
14174 include_agent_progress: bool,
14175 include_session_task_correlation: bool,
14176) -> NodeSnapshot {
14177 let mut snapshot = shared.snapshot();
14178 if !include_provider_runtime_status {
14179 snapshot.provider_runtime_statuses.clear();
14180 }
14181 if !include_open_provider_ids {
14182 project_snapshot_legacy_provider_ids(&mut snapshot);
14183 }
14184 if !include_managed_worktrees {
14185 snapshot.managed_worktrees.clear();
14186 for workspace in &mut snapshot.workspaces {
14187 workspace.managed_worktree_profiles = None;
14188 }
14189 }
14190 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14191 if !include_spawn_profiles {
14192 inventory.spawn_profiles = None;
14193 }
14194 if !include_session_bundles {
14195 inventory.bundles = None;
14196 }
14197 if inventory.spawn_profiles.is_none()
14198 && inventory.bundles.is_none()
14199 && inventory.network_allowlists.is_none()
14200 {
14201 snapshot.launch_inventory = None;
14202 }
14203 }
14204 if !include_child_environment_profiles {
14205 clear_snapshot_child_environment_profiles(&mut snapshot);
14206 }
14207 if !include_session_bundles {
14208 clear_snapshot_session_bundles(&mut snapshot);
14209 }
14210 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14211 if !include_history_context_packs {
14212 clear_snapshot_context_packs(&mut snapshot);
14213 }
14214 if !include_agent_progress {
14215 snapshot.agent_progress.clear();
14216 }
14217 if !include_session_task_correlation {
14218 clear_snapshot_session_task_bindings(&mut snapshot);
14219 }
14220 snapshot
14221}
14222
14223fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14224 for record in &mut snapshot.session_records {
14225 record.task_binding = None;
14226 }
14227}
14228
14229fn project_event_without_session_task_binding(
14230 mut envelope: NodeEventEnvelope,
14231) -> NodeEventEnvelope {
14232 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14233 record.task_binding = None;
14234 }
14235 envelope
14236}
14237
14238fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14239 let Ok(response) = reply.result.as_mut() else { return };
14240 match response {
14241 NodeResponse::Snapshot { snapshot, .. } => {
14242 clear_snapshot_session_task_bindings(snapshot);
14243 }
14244 NodeResponse::Resync { snapshot, events, .. } => {
14245 clear_snapshot_session_task_bindings(snapshot);
14246 for event in events {
14247 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14248 record.task_binding = None;
14249 }
14250 }
14251 }
14252 NodeResponse::SessionRecordUpdated { record }
14253 | NodeResponse::ProviderSessionIndexed { record }
14254 | NodeResponse::NativeSessionIndexed { record, .. }
14255 | NodeResponse::SessionRecordResumed { record, .. } => {
14256 record.task_binding = None;
14257 }
14258 _ => {}
14259 }
14260}
14261
14262fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14263 let Ok(response) = reply.result.as_mut() else { return };
14264 match response {
14265 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14266 snapshot.agent_progress.clear();
14267 }
14268 _ => {}
14269 }
14270}
14271
14272fn project_event_without_managed_worktrees(
14273 mut envelope: NodeEventEnvelope,
14274) -> Option<NodeEventEnvelope> {
14275 if matches!(
14276 envelope.event,
14277 NodeEvent::ManagedWorktreeUpserted { .. }
14278 | NodeEvent::ManagedWorktreeRemoved { .. }
14279 ) {
14280 return None;
14281 }
14282 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14283 workspace.managed_worktree_profiles = None;
14284 }
14285 Some(envelope)
14286}
14287
14288fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14289 if matches!(
14290 reply.result,
14291 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14292 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14293 ) {
14294 reply.result = Err(failure(
14295 NodeFailureCode::UnsupportedCapability,
14296 "managed worktree capability was not negotiated",
14297 ));
14298 return;
14299 }
14300 let Ok(response) = reply.result.as_mut() else { return };
14301 match response {
14302 NodeResponse::Snapshot { snapshot, .. } => {
14303 snapshot.managed_worktrees.clear();
14304 for workspace in &mut snapshot.workspaces {
14305 workspace.managed_worktree_profiles = None;
14306 }
14307 }
14308 NodeResponse::Resync { snapshot, events, .. } => {
14309 snapshot.managed_worktrees.clear();
14310 for workspace in &mut snapshot.workspaces {
14311 workspace.managed_worktree_profiles = None;
14312 }
14313 *events = std::mem::take(events)
14314 .into_iter()
14315 .filter_map(project_event_without_managed_worktrees)
14316 .collect();
14317 }
14318 NodeResponse::WorkspaceRegistered { workspace }
14319 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14320 | NodeResponse::WorktreeCreated { workspace, .. } => {
14321 workspace.managed_worktree_profiles = None;
14322 }
14323 NodeResponse::WorkspaceInspected { inspection } => {
14324 inspection.git.managed_worktree = None;
14325 }
14326 _ => {}
14327 }
14328}
14329
14330fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14331 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14332 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14333 });
14334 if includes_open_provider_ids {
14335 return;
14336 }
14337 compatibility
14338 .provider_contracts
14339 .retain(|contract| provider_id_is_legacy(&contract.provider));
14340 compatibility
14341 .provider_adapter_contracts
14342 .retain(|contract| provider_id_is_legacy(&contract.provider));
14343}
14344
14345fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14346 if let Some(spec) = match request {
14347 NodeRequest::SpawnSpec { spec }
14348 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14349 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14350 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14351 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14352 _ => None,
14353 } {
14354 return shared
14355 .resolve_spawn_spec(spec)
14356 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14357 }
14358 request_requires_open_provider_ids_with(
14359 request,
14360 |session| shared.validate_address(session).ok(),
14361 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14362 )
14363}
14364
14365fn request_requires_child_environment_profile(
14366 shared: &NodeShared,
14367 request: &NodeRequest,
14368) -> bool {
14369 if let Some(spec) = match request {
14370 NodeRequest::SpawnSpec { spec }
14371 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14372 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14373 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14374 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14375 _ => None,
14376 } {
14377 return shared
14378 .resolve_spawn_spec(spec)
14379 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14380 }
14381 match request {
14382 NodeRequest::Resume { session, .. }
14383 | NodeRequest::Prompt { session, .. }
14384 | NodeRequest::Paste { session, .. }
14385 | NodeRequest::Input { session, .. }
14386 | NodeRequest::TerminalBytes { session, .. }
14387 | NodeRequest::TerminalControl { session, .. }
14388 | NodeRequest::Resize { session, .. }
14389 | NodeRequest::Interrupt { session }
14390 | NodeRequest::Stop { session, .. }
14391 | NodeRequest::Remove { session }
14392 | NodeRequest::ResolveInteraction { session, .. }
14393 | NodeRequest::SetSessionMode { session, .. }
14394 | NodeRequest::SetSessionConfigOption { session, .. }
14395 | NodeRequest::SetSessionModel { session, .. }
14396 | NodeRequest::DiscoverHistory { session, .. }
14397 | NodeRequest::LoadHistory { session, .. }
14398 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14399 | NodeRequest::ExportContextPack { session } => shared
14400 .session_bindings
14401 .lock()
14402 .unwrap_or_else(|poisoned| poisoned.into_inner())
14403 .get(&session.session.instance_id)
14404 .filter(|binding| {
14405 binding.workspace_id == session.workspace_id
14406 && binding.generation == session.session.generation
14407 })
14408 .map_or(true, |binding| binding.environment_profile.is_some()),
14409 NodeRequest::RenameSessionRecord { record_id, .. }
14410 | NodeRequest::SetSessionTask { record_id, .. }
14411 | NodeRequest::ResumeSessionRecord { record_id, .. }
14412 | NodeRequest::ForgetSessionRecord { record_id } => shared
14413 .record(record_id)
14414 .map_or(true, |record| record.environment_profile.is_some()),
14415 NodeRequest::ArmHarnessMcpReservation { .. }
14416 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14417 | NodeRequest::ActivateHarnessMcpReservation { .. }
14418 | NodeRequest::AbortHarnessMcpReservation { .. }
14419 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14420 | NodeRequest::RejectHarnessMcpCall { .. }
14421 | NodeRequest::Snapshot
14422 | NodeRequest::Resync { .. }
14423 | NodeRequest::BeginDeliveryStage { .. }
14424 | NodeRequest::PutDeliveryBlobChunk { .. }
14425 | NodeRequest::CommitDeliveryStage { .. }
14426 | NodeRequest::AbortDeliveryStage { .. }
14427 | NodeRequest::BrowseHostDirectories { .. }
14428 | NodeRequest::InspectWorkspace { .. }
14429 | NodeRequest::ReadWorkspaceFile { .. }
14430 | NodeRequest::WriteWorkspaceFile { .. }
14431 | NodeRequest::CreateWorkspaceFile { .. }
14432 | NodeRequest::CreateWorkspaceDirectory { .. }
14433 | NodeRequest::ReadGitHistory { .. }
14434 | NodeRequest::ReadGitDiff { .. }
14435 | NodeRequest::AcquireController { .. }
14436 | NodeRequest::ReleaseController
14437 | NodeRequest::Spawn { .. }
14438 | NodeRequest::SpawnWithOptions { .. }
14439 | NodeRequest::SpawnSpec { .. }
14440 | NodeRequest::RegisterWorkspace { .. }
14441 | NodeRequest::CreateStandaloneWorkspace { .. }
14442 | NodeRequest::UnregisterWorkspace { .. }
14443 | NodeRequest::CreateWorktree { .. }
14444 | NodeRequest::RemoveWorktree { .. }
14445 | NodeRequest::SpawnManagedWorktree { .. }
14446 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14447 | NodeRequest::CleanupManagedWorktree { .. }
14448 | NodeRequest::IndexProviderSession { .. }
14449 | NodeRequest::IndexNativeSession { .. }
14450 | NodeRequest::CatalogNativeSessions { .. }
14451 | NodeRequest::PageNativeSessions { .. }
14452 | NodeRequest::PreviewNativeSession { .. }
14453 | NodeRequest::PreviewSessionRecord { .. }
14454 | NodeRequest::ForgetContextPack { .. }
14455 | NodeRequest::ResolveDurableContextPack { .. }
14456 | NodeRequest::ReadContextPack { .. }
14457 | NodeRequest::Shutdown => false,
14458 }
14459}
14460
14461fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14462 if let Some(spec) = match request {
14463 NodeRequest::SpawnSpec { spec }
14464 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14465 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14466 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14467 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14468 _ => None,
14469 } {
14470 return shared
14471 .resolve_spawn_spec(spec)
14472 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14473 }
14474 match request {
14475 NodeRequest::Resume { session, .. }
14476 | NodeRequest::Prompt { session, .. }
14477 | NodeRequest::Paste { session, .. }
14478 | NodeRequest::Input { session, .. }
14479 | NodeRequest::TerminalBytes { session, .. }
14480 | NodeRequest::TerminalControl { session, .. }
14481 | NodeRequest::Resize { session, .. }
14482 | NodeRequest::Interrupt { session }
14483 | NodeRequest::Stop { session, .. }
14484 | NodeRequest::Remove { session }
14485 | NodeRequest::ResolveInteraction { session, .. }
14486 | NodeRequest::SetSessionMode { session, .. }
14487 | NodeRequest::SetSessionConfigOption { session, .. }
14488 | NodeRequest::SetSessionModel { session, .. }
14489 | NodeRequest::DiscoverHistory { session, .. }
14490 | NodeRequest::LoadHistory { session, .. }
14491 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14492 | NodeRequest::ExportContextPack { session } => shared
14493 .session_bindings
14494 .lock()
14495 .unwrap_or_else(|poisoned| poisoned.into_inner())
14496 .get(&session.session.instance_id)
14497 .filter(|binding| {
14498 binding.workspace_id == session.workspace_id
14499 && binding.generation == session.session.generation
14500 })
14501 .map_or(true, |binding| binding.bundle.is_some()),
14502 NodeRequest::RenameSessionRecord { record_id, .. }
14503 | NodeRequest::SetSessionTask { record_id, .. }
14504 | NodeRequest::ResumeSessionRecord { record_id, .. }
14505 | NodeRequest::ForgetSessionRecord { record_id } => shared
14506 .record(record_id)
14507 .map_or(true, |record| record.bundle.is_some()),
14508 NodeRequest::ArmHarnessMcpReservation { .. }
14509 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14510 | NodeRequest::ActivateHarnessMcpReservation { .. }
14511 | NodeRequest::AbortHarnessMcpReservation { .. }
14512 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14513 | NodeRequest::RejectHarnessMcpCall { .. }
14514 | NodeRequest::Snapshot
14515 | NodeRequest::Resync { .. }
14516 | NodeRequest::BeginDeliveryStage { .. }
14517 | NodeRequest::PutDeliveryBlobChunk { .. }
14518 | NodeRequest::CommitDeliveryStage { .. }
14519 | NodeRequest::AbortDeliveryStage { .. }
14520 | NodeRequest::BrowseHostDirectories { .. }
14521 | NodeRequest::InspectWorkspace { .. }
14522 | NodeRequest::ReadWorkspaceFile { .. }
14523 | NodeRequest::WriteWorkspaceFile { .. }
14524 | NodeRequest::CreateWorkspaceFile { .. }
14525 | NodeRequest::CreateWorkspaceDirectory { .. }
14526 | NodeRequest::ReadGitHistory { .. }
14527 | NodeRequest::ReadGitDiff { .. }
14528 | NodeRequest::AcquireController { .. }
14529 | NodeRequest::ReleaseController
14530 | NodeRequest::Spawn { .. }
14531 | NodeRequest::SpawnWithOptions { .. }
14532 | NodeRequest::SpawnSpec { .. }
14533 | NodeRequest::RegisterWorkspace { .. }
14534 | NodeRequest::CreateStandaloneWorkspace { .. }
14535 | NodeRequest::UnregisterWorkspace { .. }
14536 | NodeRequest::CreateWorktree { .. }
14537 | NodeRequest::RemoveWorktree { .. }
14538 | NodeRequest::SpawnManagedWorktree { .. }
14539 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14540 | NodeRequest::CleanupManagedWorktree { .. }
14541 | NodeRequest::IndexProviderSession { .. }
14542 | NodeRequest::IndexNativeSession { .. }
14543 | NodeRequest::CatalogNativeSessions { .. }
14544 | NodeRequest::PageNativeSessions { .. }
14545 | NodeRequest::PreviewNativeSession { .. }
14546 | NodeRequest::PreviewSessionRecord { .. }
14547 | NodeRequest::ForgetContextPack { .. }
14548 | NodeRequest::ResolveDurableContextPack { .. }
14549 | NodeRequest::ReadContextPack { .. }
14550 | NodeRequest::Shutdown => false,
14551 }
14552}
14553
14554fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14555 if request.requires_history_context_pack_capability() {
14556 return true;
14557 }
14558 if let Some(spec) = match request {
14559 NodeRequest::SpawnSpec { spec }
14560 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14561 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14562 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14563 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14564 _ => None,
14565 } {
14566 return shared
14567 .resolve_spawn_spec(spec)
14568 .map_or(true, |resolved| resolved.context_id.is_some());
14569 }
14570 match request {
14571 NodeRequest::Resume { session, .. }
14572 | NodeRequest::Prompt { session, .. }
14573 | NodeRequest::Paste { session, .. }
14574 | NodeRequest::Input { session, .. }
14575 | NodeRequest::TerminalBytes { session, .. }
14576 | NodeRequest::TerminalControl { session, .. }
14577 | NodeRequest::Resize { session, .. }
14578 | NodeRequest::Interrupt { session }
14579 | NodeRequest::Stop { session, .. }
14580 | NodeRequest::Remove { session }
14581 | NodeRequest::ResolveInteraction { session, .. }
14582 | NodeRequest::SetSessionMode { session, .. }
14583 | NodeRequest::SetSessionConfigOption { session, .. }
14584 | NodeRequest::SetSessionModel { session, .. } => shared
14585 .session_bindings
14586 .lock()
14587 .unwrap_or_else(|poisoned| poisoned.into_inner())
14588 .get(&session.session.instance_id)
14589 .filter(|binding| {
14590 binding.workspace_id == session.workspace_id
14591 && binding.generation == session.session.generation
14592 })
14593 .map_or(true, |binding| binding.context.is_some()),
14594 NodeRequest::RenameSessionRecord { record_id, .. }
14595 | NodeRequest::SetSessionTask { record_id, .. }
14596 | NodeRequest::ResumeSessionRecord { record_id, .. }
14597 | NodeRequest::ForgetSessionRecord { record_id } => shared
14598 .record(record_id)
14599 .map_or(true, |record| record.context.is_some()),
14600 NodeRequest::ArmHarnessMcpReservation { .. }
14601 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14602 | NodeRequest::ActivateHarnessMcpReservation { .. }
14603 | NodeRequest::AbortHarnessMcpReservation { .. }
14604 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14605 | NodeRequest::RejectHarnessMcpCall { .. }
14606 | NodeRequest::Snapshot
14607 | NodeRequest::Resync { .. }
14608 | NodeRequest::BeginDeliveryStage { .. }
14609 | NodeRequest::PutDeliveryBlobChunk { .. }
14610 | NodeRequest::CommitDeliveryStage { .. }
14611 | NodeRequest::AbortDeliveryStage { .. }
14612 | NodeRequest::BrowseHostDirectories { .. }
14613 | NodeRequest::InspectWorkspace { .. }
14614 | NodeRequest::ReadWorkspaceFile { .. }
14615 | NodeRequest::WriteWorkspaceFile { .. }
14616 | NodeRequest::CreateWorkspaceFile { .. }
14617 | NodeRequest::CreateWorkspaceDirectory { .. }
14618 | NodeRequest::ReadGitHistory { .. }
14619 | NodeRequest::ReadGitDiff { .. }
14620 | NodeRequest::AcquireController { .. }
14621 | NodeRequest::ReleaseController
14622 | NodeRequest::Spawn { .. }
14623 | NodeRequest::SpawnWithOptions { .. }
14624 | NodeRequest::SpawnSpec { .. }
14625 | NodeRequest::RegisterWorkspace { .. }
14626 | NodeRequest::CreateStandaloneWorkspace { .. }
14627 | NodeRequest::UnregisterWorkspace { .. }
14628 | NodeRequest::CreateWorktree { .. }
14629 | NodeRequest::RemoveWorktree { .. }
14630 | NodeRequest::SpawnManagedWorktree { .. }
14631 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14632 | NodeRequest::CleanupManagedWorktree { .. }
14633 | NodeRequest::IndexProviderSession { .. }
14634 | NodeRequest::IndexNativeSession { .. }
14635 | NodeRequest::CatalogNativeSessions { .. }
14636 | NodeRequest::PageNativeSessions { .. }
14637 | NodeRequest::PreviewNativeSession { .. }
14638 | NodeRequest::PreviewSessionRecord { .. }
14639 | NodeRequest::DiscoverHistory { .. }
14640 | NodeRequest::LoadHistory { .. }
14641 | NodeRequest::ExportContextPackForSessionRecord { .. }
14642 | NodeRequest::ExportContextPack { .. }
14643 | NodeRequest::ForgetContextPack { .. }
14644 | NodeRequest::ResolveDurableContextPack { .. }
14645 | NodeRequest::ReadContextPack { .. }
14646 | NodeRequest::Shutdown => false,
14647 }
14648}
14649
14650fn request_requires_open_provider_ids_with(
14651 request: &NodeRequest,
14652 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14653 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14654) -> bool {
14655 match request {
14656 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
14657 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14658 NodeRequest::CatalogNativeSessions { route, .. }
14659 | NodeRequest::PageNativeSessions { route, .. } => {
14660 !provider_id_is_legacy(&route.provider)
14661 }
14662 NodeRequest::PreviewNativeSession { selection, .. }
14663 | NodeRequest::IndexNativeSession { selection, .. } => {
14664 !provider_id_is_legacy(&selection.route.provider)
14665 }
14666 NodeRequest::SpawnSpec { .. }
14667 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14668 NodeRequest::SpawnManagedWorktree { .. }
14669 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14670 NodeRequest::Resume { session, .. }
14671 | NodeRequest::Prompt { session, .. }
14672 | NodeRequest::Paste { session, .. }
14673 | NodeRequest::Input { session, .. }
14674 | NodeRequest::TerminalBytes { session, .. }
14675 | NodeRequest::TerminalControl { session, .. }
14676 | NodeRequest::Resize { session, .. }
14677 | NodeRequest::Interrupt { session }
14678 | NodeRequest::Stop { session, .. }
14679 | NodeRequest::Remove { session }
14680 | NodeRequest::ResolveInteraction { session, .. }
14681 | NodeRequest::SetSessionMode { session, .. }
14682 | NodeRequest::SetSessionConfigOption { session, .. }
14683 | NodeRequest::SetSessionModel { session, .. }
14684 | NodeRequest::DiscoverHistory { session, .. }
14685 | NodeRequest::LoadHistory { session, .. }
14686 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14687 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14688 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14689 NodeRequest::RenameSessionRecord { record_id, .. }
14690 | NodeRequest::SetSessionTask { record_id, .. }
14691 | NodeRequest::ResumeSessionRecord { record_id, .. }
14692 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14693 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14694 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14695 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14696 NodeRequest::ArmHarnessMcpReservation { .. }
14697 | NodeRequest::ActivateHarnessMcpReservation { .. }
14698 | NodeRequest::AbortHarnessMcpReservation { .. }
14699 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14700 | NodeRequest::RejectHarnessMcpCall { .. }
14701 | NodeRequest::Snapshot
14702 | NodeRequest::Resync { .. }
14703 | NodeRequest::BeginDeliveryStage { .. }
14704 | NodeRequest::PutDeliveryBlobChunk { .. }
14705 | NodeRequest::CommitDeliveryStage { .. }
14706 | NodeRequest::AbortDeliveryStage { .. }
14707 | NodeRequest::BrowseHostDirectories { .. }
14708 | NodeRequest::InspectWorkspace { .. }
14709 | NodeRequest::ReadWorkspaceFile { .. }
14710 | NodeRequest::WriteWorkspaceFile { .. }
14711 | NodeRequest::CreateWorkspaceFile { .. }
14712 | NodeRequest::CreateWorkspaceDirectory { .. }
14713 | NodeRequest::ReadGitHistory { .. }
14714 | NodeRequest::ReadGitDiff { .. }
14715 | NodeRequest::AcquireController { .. }
14716 | NodeRequest::ReleaseController
14717 | NodeRequest::RegisterWorkspace { .. }
14718 | NodeRequest::CreateStandaloneWorkspace { .. }
14719 | NodeRequest::UnregisterWorkspace { .. }
14720 | NodeRequest::CreateWorktree { .. }
14721 | NodeRequest::RemoveWorktree { .. }
14722 | NodeRequest::CleanupManagedWorktree { .. }
14723 | NodeRequest::Shutdown => false,
14724 NodeRequest::ForgetContextPack { .. } => true,
14725 NodeRequest::ResolveDurableContextPack { .. }
14731 | NodeRequest::ReadContextPack { .. } => true,
14732 }
14733}
14734
14735fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14736 snapshot.enabled_providers.retain(provider_id_is_legacy);
14737 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14738 snapshot
14739 .provider_runtime_statuses
14740 .as_slice()
14741 .iter()
14742 .filter(|status| provider_id_is_legacy(status.provider()))
14743 .cloned(),
14744 )
14745 .expect("a filtered provider runtime projection remains valid");
14746 snapshot
14747 .session_records
14748 .retain(|record| provider_id_is_legacy(&record.provider));
14749 for workspace in &mut snapshot.workspaces {
14750 workspace
14751 .sessions
14752 .retain(|session| provider_id_is_legacy(&session.agent_id));
14753 }
14754}
14755
14756fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14757 for record in &mut snapshot.session_records {
14758 record.environment_profile = None;
14759 }
14760 if let Some(profiles) = snapshot
14761 .launch_inventory
14762 .as_mut()
14763 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14764 {
14765 for profile in profiles {
14766 profile.environment_profile = None;
14767 }
14768 }
14769}
14770
14771fn project_event_without_child_environment_profile(
14772 mut envelope: NodeEventEnvelope,
14773) -> NodeEventEnvelope {
14774 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14775 record.environment_profile = None;
14776 }
14777 envelope
14778}
14779
14780fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14781 let contains_environment_profile = match reply.result.as_ref() {
14782 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14783 receipt.environment_profile.is_some()
14784 }
14785 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14786 receipt.spawn.environment_profile.is_some()
14787 }
14788 _ => false,
14789 };
14790 if contains_environment_profile {
14791 reply.result = Err(failure(
14792 NodeFailureCode::UnsupportedCapability,
14793 "child environment profiles were not negotiated",
14794 ));
14795 return;
14796 }
14797 let Ok(response) = reply.result.as_mut() else {
14798 return;
14799 };
14800 match response {
14801 NodeResponse::Snapshot { snapshot, .. } => {
14802 clear_snapshot_child_environment_profiles(snapshot);
14803 }
14804 NodeResponse::Resync { snapshot, events, .. } => {
14805 clear_snapshot_child_environment_profiles(snapshot);
14806 for event in events {
14807 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14808 record.environment_profile = None;
14809 }
14810 }
14811 }
14812 NodeResponse::SessionRecordUpdated { record }
14813 | NodeResponse::ProviderSessionIndexed { record }
14814 | NodeResponse::NativeSessionIndexed { record, .. }
14815 | NodeResponse::SessionRecordResumed { record, .. } => {
14816 record.environment_profile = None;
14817 }
14818 NodeResponse::Armed { .. }
14819 | NodeResponse::Spawned { .. }
14820 | NodeResponse::Activated { .. }
14821 | NodeResponse::Aborted { .. }
14822 | NodeResponse::ReplyChunkAccepted { .. }
14823 | NodeResponse::CallRejected { .. }
14824 | NodeResponse::DeliveryStageBegun { .. }
14825 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14826 | NodeResponse::DeliveryCommitted { .. }
14827 | NodeResponse::DeliveryStageAborted { .. }
14828 | NodeResponse::WorkspaceInspected { .. }
14829 | NodeResponse::HostDirectoriesBrowsed { .. }
14830 | NodeResponse::WorkspaceFileRead { .. }
14831 | NodeResponse::WorkspaceFileWritten { .. }
14832 | NodeResponse::WorkspaceFileCreated { .. }
14833 | NodeResponse::WorkspaceDirectoryCreated { .. }
14834 | NodeResponse::GitHistoryRead { .. }
14835 | NodeResponse::GitDiffRead { .. }
14836 | NodeResponse::Controller { .. }
14837 | NodeResponse::SpawnAccepted { .. }
14838 | NodeResponse::SpawnSpecAccepted { .. }
14839 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14840 | NodeResponse::ManagedWorktreeCleanup { .. }
14841 | NodeResponse::NativeSessionsCataloged { .. }
14842 | NodeResponse::NativeSessionsPaged { .. }
14843 | NodeResponse::NativeSessionPreviewed { .. }
14844 | NodeResponse::SessionRecordPreviewed { .. }
14845 | NodeResponse::HistoryDiscovered { .. }
14846 | NodeResponse::HistoryLoaded { .. }
14847 | NodeResponse::ContextPackForSessionRecordExported { .. }
14848 | NodeResponse::ContextPackExported { .. }
14849 | NodeResponse::ContextPackForgotten { .. }
14850 | NodeResponse::DurableContextPackResolved { .. }
14851 | NodeResponse::ContextPackBytesRead { .. }
14852 | NodeResponse::SessionRecordForgotten { .. }
14853 | NodeResponse::WorkspaceRegistered { .. }
14854 | NodeResponse::StandaloneWorkspaceCreated { .. }
14855 | NodeResponse::WorkspaceUnregistered { .. }
14856 | NodeResponse::WorktreeCreated { .. }
14857 | NodeResponse::WorktreeRemoved { .. }
14858 | NodeResponse::Accepted
14859 | NodeResponse::ShuttingDown => {}
14860 }
14861}
14862
14863fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14864 for record in &mut snapshot.session_records {
14865 record.bundle = None;
14866 }
14867}
14868
14869fn project_event_without_session_bundle(
14870 mut envelope: NodeEventEnvelope,
14871) -> NodeEventEnvelope {
14872 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14873 record.bundle = None;
14874 }
14875 envelope
14876}
14877
14878fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14879 let contains_bundle = match reply.result.as_ref() {
14880 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14881 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14882 receipt.spawn.bundle.is_some()
14883 }
14884 _ => false,
14885 };
14886 if contains_bundle {
14887 reply.result = Err(failure(
14888 NodeFailureCode::UnsupportedCapability,
14889 "session bundles were not negotiated",
14890 ));
14891 return;
14892 }
14893 let Ok(response) = reply.result.as_mut() else {
14894 return;
14895 };
14896 match response {
14897 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14898 NodeResponse::Resync { snapshot, events, .. } => {
14899 clear_snapshot_session_bundles(snapshot);
14900 for event in events {
14901 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14902 record.bundle = None;
14903 }
14904 }
14905 }
14906 NodeResponse::SessionRecordUpdated { record }
14907 | NodeResponse::ProviderSessionIndexed { record }
14908 | NodeResponse::NativeSessionIndexed { record, .. }
14909 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14910 NodeResponse::Armed { .. }
14911 | NodeResponse::Spawned { .. }
14912 | NodeResponse::Activated { .. }
14913 | NodeResponse::Aborted { .. }
14914 | NodeResponse::ReplyChunkAccepted { .. }
14915 | NodeResponse::CallRejected { .. }
14916 | NodeResponse::DeliveryStageBegun { .. }
14917 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14918 | NodeResponse::DeliveryCommitted { .. }
14919 | NodeResponse::DeliveryStageAborted { .. }
14920 | NodeResponse::WorkspaceInspected { .. }
14921 | NodeResponse::HostDirectoriesBrowsed { .. }
14922 | NodeResponse::WorkspaceFileRead { .. }
14923 | NodeResponse::WorkspaceFileWritten { .. }
14924 | NodeResponse::WorkspaceFileCreated { .. }
14925 | NodeResponse::WorkspaceDirectoryCreated { .. }
14926 | NodeResponse::GitHistoryRead { .. }
14927 | NodeResponse::GitDiffRead { .. }
14928 | NodeResponse::Controller { .. }
14929 | NodeResponse::SpawnAccepted { .. }
14930 | NodeResponse::SpawnSpecAccepted { .. }
14931 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14932 | NodeResponse::ManagedWorktreeCleanup { .. }
14933 | NodeResponse::NativeSessionsCataloged { .. }
14934 | NodeResponse::NativeSessionsPaged { .. }
14935 | NodeResponse::NativeSessionPreviewed { .. }
14936 | NodeResponse::SessionRecordPreviewed { .. }
14937 | NodeResponse::HistoryDiscovered { .. }
14938 | NodeResponse::HistoryLoaded { .. }
14939 | NodeResponse::ContextPackForSessionRecordExported { .. }
14940 | NodeResponse::ContextPackExported { .. }
14941 | NodeResponse::ContextPackForgotten { .. }
14942 | NodeResponse::DurableContextPackResolved { .. }
14943 | NodeResponse::ContextPackBytesRead { .. }
14944 | NodeResponse::SessionRecordForgotten { .. }
14945 | NodeResponse::WorkspaceRegistered { .. }
14946 | NodeResponse::StandaloneWorkspaceCreated { .. }
14947 | NodeResponse::WorkspaceUnregistered { .. }
14948 | NodeResponse::WorktreeCreated { .. }
14949 | NodeResponse::WorktreeRemoved { .. }
14950 | NodeResponse::Accepted
14951 | NodeResponse::ShuttingDown => {}
14952 }
14953}
14954
14955fn project_snapshot_history_for_wire(
14956 snapshot: &mut NodeSnapshot,
14957 include_history_metadata: bool,
14958) {
14959 for workspace in &mut snapshot.workspaces {
14960 for session in &mut workspace.sessions {
14961 if include_history_metadata {
14962 session.history.loaded = None;
14963 } else {
14964 session.history = Default::default();
14965 }
14966 }
14967 }
14968}
14969
14970fn project_event_history_for_wire(
14971 mut envelope: NodeEventEnvelope,
14972 include_history_metadata: bool,
14973) -> NodeEventEnvelope {
14974 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14975 for session in &mut workspace.sessions {
14976 if include_history_metadata {
14977 session.history.loaded = None;
14978 } else {
14979 session.history = Default::default();
14980 }
14981 }
14982 }
14983 envelope
14984}
14985
14986fn project_response_history_for_wire(
14987 reply: &mut ResponseEnvelope,
14988 include_history_metadata: bool,
14989) {
14990 let Ok(response) = reply.result.as_mut() else {
14991 return;
14992 };
14993 match response {
14994 NodeResponse::Snapshot { snapshot, .. } => {
14995 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14996 }
14997 NodeResponse::Resync {
14998 snapshot, events, ..
14999 } => {
15000 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15001 for event in events {
15002 *event = project_event_history_for_wire(
15003 event.clone(),
15004 include_history_metadata,
15005 );
15006 }
15007 }
15008 NodeResponse::WorkspaceRegistered { workspace }
15009 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15010 | NodeResponse::WorktreeCreated { workspace, .. } => {
15011 for session in &mut workspace.sessions {
15012 if include_history_metadata {
15013 session.history.loaded = None;
15014 } else {
15015 session.history = Default::default();
15016 }
15017 }
15018 }
15019 _ => {}
15020 }
15021}
15022
15023fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15024 for record in &mut snapshot.session_records {
15025 record.context_id = None;
15026 record.context = None;
15027 record.exported_context = None;
15028 }
15029}
15030
15031fn project_event_without_context_pack(
15032 mut envelope: NodeEventEnvelope,
15033) -> NodeEventEnvelope {
15034 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15035 record.context_id = None;
15036 record.context = None;
15037 record.exported_context = None;
15038 }
15039 envelope
15040}
15041
15042fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15043 let contains_context = match reply.result.as_ref() {
15044 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15045 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15046 receipt.spawn.context.is_some()
15047 }
15048 Ok(NodeResponse::HistoryDiscovered { .. })
15049 | Ok(NodeResponse::HistoryLoaded { .. })
15050 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15051 | Ok(NodeResponse::ContextPackExported { .. })
15052 | Ok(NodeResponse::ContextPackForgotten { .. })
15053 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15054 _ => false,
15055 };
15056 if contains_context {
15057 reply.result = Err(failure(
15058 NodeFailureCode::UnsupportedCapability,
15059 "history context packs were not negotiated",
15060 ));
15061 return;
15062 }
15063 let Ok(response) = reply.result.as_mut() else {
15064 return;
15065 };
15066 match response {
15067 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15068 NodeResponse::Resync {
15069 snapshot, events, ..
15070 } => {
15071 clear_snapshot_context_packs(snapshot);
15072 for event in events {
15073 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15074 record.context_id = None;
15075 record.context = None;
15076 record.exported_context = None;
15077 }
15078 }
15079 }
15080 NodeResponse::SessionRecordUpdated { record }
15081 | NodeResponse::ProviderSessionIndexed { record }
15082 | NodeResponse::NativeSessionIndexed { record, .. }
15083 | NodeResponse::SessionRecordResumed { record, .. } => {
15084 record.context_id = None;
15085 record.context = None;
15086 record.exported_context = None;
15087 }
15088 _ => {}
15089 }
15090}
15091
15092fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15093 let contains_open_record = match reply.result.as_ref() {
15094 Ok(NodeResponse::SessionRecordUpdated { record })
15095 | Ok(NodeResponse::ProviderSessionIndexed { record })
15096 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15097 !provider_id_is_legacy(&record.provider)
15098 }
15099 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15100 !provider_id_is_legacy(&selection.route.provider)
15101 || !provider_id_is_legacy(&record.provider)
15102 }
15103 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15104 !provider_id_is_legacy(&receipt.provider)
15105 }
15106 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15107 !provider_id_is_legacy(&receipt.spawn.provider)
15108 }
15109 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15110 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15111 !provider_id_is_legacy(&route.provider)
15112 }
15113 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15114 !provider_id_is_legacy(&selection.route.provider)
15115 }
15116 _ => false,
15117 };
15118 if contains_open_record {
15119 reply.result = Err(failure(
15120 NodeFailureCode::UnsupportedCapability,
15121 "open provider IDs were not negotiated",
15122 ));
15123 return;
15124 }
15125 let Ok(response) = reply.result.as_mut() else {
15126 return;
15127 };
15128 match response {
15129 NodeResponse::Snapshot { snapshot, .. } => {
15130 project_snapshot_legacy_provider_ids(snapshot);
15131 }
15132 NodeResponse::Resync { snapshot, events, .. } => {
15133 project_snapshot_legacy_provider_ids(snapshot);
15134 *events = std::mem::take(events)
15135 .into_iter()
15136 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15137 .collect();
15138 }
15139 NodeResponse::WorkspaceRegistered { workspace }
15140 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15141 | NodeResponse::WorktreeCreated { workspace, .. } => {
15142 workspace
15143 .sessions
15144 .retain(|session| provider_id_is_legacy(&session.agent_id));
15145 }
15146 NodeResponse::Armed { .. }
15147 | NodeResponse::Spawned { .. }
15148 | NodeResponse::Activated { .. }
15149 | NodeResponse::Aborted { .. }
15150 | NodeResponse::ReplyChunkAccepted { .. }
15151 | NodeResponse::CallRejected { .. }
15152 | NodeResponse::DeliveryStageBegun { .. }
15153 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15154 | NodeResponse::DeliveryCommitted { .. }
15155 | NodeResponse::DeliveryStageAborted { .. }
15156 | NodeResponse::WorkspaceInspected { .. }
15157 | NodeResponse::HostDirectoriesBrowsed { .. }
15158 | NodeResponse::WorkspaceFileRead { .. }
15159 | NodeResponse::WorkspaceFileWritten { .. }
15160 | NodeResponse::WorkspaceFileCreated { .. }
15161 | NodeResponse::WorkspaceDirectoryCreated { .. }
15162 | NodeResponse::GitHistoryRead { .. }
15163 | NodeResponse::GitDiffRead { .. }
15164 | NodeResponse::Controller { .. }
15165 | NodeResponse::SpawnAccepted { .. }
15166 | NodeResponse::SpawnSpecAccepted { .. }
15167 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15168 | NodeResponse::ManagedWorktreeCleanup { .. }
15169 | NodeResponse::NativeSessionsCataloged { .. }
15170 | NodeResponse::NativeSessionsPaged { .. }
15171 | NodeResponse::NativeSessionPreviewed { .. }
15172 | NodeResponse::SessionRecordPreviewed { .. }
15173 | NodeResponse::HistoryDiscovered { .. }
15174 | NodeResponse::HistoryLoaded { .. }
15175 | NodeResponse::ContextPackForSessionRecordExported { .. }
15176 | NodeResponse::ContextPackExported { .. }
15177 | NodeResponse::ContextPackForgotten { .. }
15178 | NodeResponse::DurableContextPackResolved { .. }
15179 | NodeResponse::ContextPackBytesRead { .. }
15180 | NodeResponse::SessionRecordUpdated { .. }
15181 | NodeResponse::ProviderSessionIndexed { .. }
15182 | NodeResponse::NativeSessionIndexed { .. }
15183 | NodeResponse::SessionRecordResumed { .. }
15184 | NodeResponse::SessionRecordForgotten { .. }
15185 | NodeResponse::WorkspaceUnregistered { .. }
15186 | NodeResponse::WorktreeRemoved { .. }
15187 | NodeResponse::Accepted
15188 | NodeResponse::ShuttingDown => {}
15189 }
15190}
15191
15192fn project_event_legacy_provider_ids(
15193 shared: &NodeShared,
15194 mut envelope: NodeEventEnvelope,
15195) -> Option<NodeEventEnvelope> {
15196 let include = match &mut envelope.event {
15197 NodeEvent::HarnessMcpReadCall { .. } => true,
15198 NodeEvent::Control { address, .. }
15199 | NodeEvent::AgentStream { address, .. }
15200 | NodeEvent::TerminalFrame { address, .. } => shared
15201 .validate_address(address)
15202 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15203 NodeEvent::WorkspaceAdded { workspace } => {
15204 workspace
15205 .sessions
15206 .retain(|session| provider_id_is_legacy(&session.agent_id));
15207 true
15208 }
15209 NodeEvent::SessionRecordUpserted { record } => {
15210 provider_id_is_legacy(&record.provider)
15211 }
15212 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15213 NodeEvent::SessionRecordRemoved { .. } => shared
15214 .history
15215 .lock()
15216 .unwrap_or_else(|poisoned| poisoned.into_inner())
15217 .removed_record_providers
15218 .get(&envelope.sequence)
15219 .is_some_and(provider_id_is_legacy),
15220 NodeEvent::ControllerChanged { .. }
15221 | NodeEvent::ManagedWorktreeUpserted { .. }
15222 | NodeEvent::ManagedWorktreeRemoved { .. }
15223 | NodeEvent::WorkspaceRemoved { .. }
15224 | NodeEvent::ResyncRequired { .. } => true,
15225 };
15226 include.then_some(envelope)
15227}
15228
15229fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15230 let Ok(response) = reply.result.as_mut() else {
15231 return;
15232 };
15233 match response {
15234 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15235 snapshot.provider_runtime_statuses.clear();
15236 }
15237 NodeResponse::Armed { .. }
15238 | NodeResponse::Spawned { .. }
15239 | NodeResponse::Activated { .. }
15240 | NodeResponse::Aborted { .. }
15241 | NodeResponse::ReplyChunkAccepted { .. }
15242 | NodeResponse::CallRejected { .. }
15243 | NodeResponse::DeliveryStageBegun { .. }
15244 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15245 | NodeResponse::DeliveryCommitted { .. }
15246 | NodeResponse::DeliveryStageAborted { .. }
15247 | NodeResponse::WorkspaceInspected { .. }
15248 | NodeResponse::HostDirectoriesBrowsed { .. }
15249 | NodeResponse::WorkspaceFileRead { .. }
15250 | NodeResponse::WorkspaceFileWritten { .. }
15251 | NodeResponse::WorkspaceFileCreated { .. }
15252 | NodeResponse::WorkspaceDirectoryCreated { .. }
15253 | NodeResponse::GitHistoryRead { .. }
15254 | NodeResponse::GitDiffRead { .. }
15255 | NodeResponse::Controller { .. }
15256 | NodeResponse::SpawnAccepted { .. }
15257 | NodeResponse::SpawnSpecAccepted { .. }
15258 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15259 | NodeResponse::ManagedWorktreeCleanup { .. }
15260 | NodeResponse::NativeSessionsCataloged { .. }
15261 | NodeResponse::NativeSessionsPaged { .. }
15262 | NodeResponse::NativeSessionPreviewed { .. }
15263 | NodeResponse::SessionRecordPreviewed { .. }
15264 | NodeResponse::HistoryDiscovered { .. }
15265 | NodeResponse::HistoryLoaded { .. }
15266 | NodeResponse::ContextPackForSessionRecordExported { .. }
15267 | NodeResponse::ContextPackExported { .. }
15268 | NodeResponse::ContextPackForgotten { .. }
15269 | NodeResponse::DurableContextPackResolved { .. }
15270 | NodeResponse::ContextPackBytesRead { .. }
15271 | NodeResponse::SessionRecordUpdated { .. }
15272 | NodeResponse::ProviderSessionIndexed { .. }
15273 | NodeResponse::NativeSessionIndexed { .. }
15274 | NodeResponse::SessionRecordResumed { .. }
15275 | NodeResponse::SessionRecordForgotten { .. }
15276 | NodeResponse::WorkspaceRegistered { .. }
15277 | NodeResponse::StandaloneWorkspaceCreated { .. }
15278 | NodeResponse::WorkspaceUnregistered { .. }
15279 | NodeResponse::WorktreeCreated { .. }
15280 | NodeResponse::WorktreeRemoved { .. }
15281 | NodeResponse::Accepted
15282 | NodeResponse::ShuttingDown => {}
15283 }
15284}
15285
15286struct ControllerReleaseGuard {
15287 shared: Arc<NodeShared>,
15288 connection_id: u64,
15289}
15290
15291impl Drop for ControllerReleaseGuard {
15292 fn drop(&mut self) {
15293 self.shared.release_controller(self.connection_id);
15294 }
15295}
15296
15297struct AbortTaskOnDrop(AbortHandle);
15298
15299impl Drop for AbortTaskOnDrop {
15300 fn drop(&mut self) {
15301 self.0.abort();
15302 }
15303}
15304
15305fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15310 std::env::var(name)
15311 .ok()
15312 .and_then(|value| value.parse::<u64>().ok())
15313 .filter(|value| *value > 0)
15314 .unwrap_or(default)
15315 .min(max)
15316}
15317
15318fn workspace_inspection_time_budget_ms() -> u64 {
15319 env_config_bounded(
15320 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15321 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15322 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15323 )
15324}
15325
15326fn workspace_inspection_entry_cap() -> usize {
15327 env_config_bounded(
15328 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15329 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15330 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15331 ) as usize
15332}
15333
15334struct WorkspaceWalkBudget {
15340 deadline: Instant,
15341 entry_cap: usize,
15342 entries_visited: u64,
15343 time_budget_exceeded: bool,
15344 entry_cap_exceeded: bool,
15345}
15346
15347impl WorkspaceWalkBudget {
15348 fn new(deadline: Instant, entry_cap: usize) -> Self {
15349 Self {
15350 deadline,
15351 entry_cap,
15352 entries_visited: 0,
15353 time_budget_exceeded: false,
15354 entry_cap_exceeded: false,
15355 }
15356 }
15357
15358 fn is_exhausted(&self) -> bool {
15359 self.time_budget_exceeded || self.entry_cap_exceeded
15360 }
15361
15362 fn record_visit(&mut self) -> bool {
15365 self.entries_visited += 1;
15366 if self.entries_visited as usize >= self.entry_cap {
15367 self.entry_cap_exceeded = true;
15368 }
15369 if Instant::now() >= self.deadline {
15370 self.time_budget_exceeded = true;
15371 }
15372 self.is_exhausted()
15373 }
15374}
15375
15376fn collect_workspace_entries(
15377 root: &Path,
15378 deadline: Instant,
15379 entry_cap: usize,
15380) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15381 let mut entries = Vec::new();
15382 let mut truncated = false;
15383 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15384 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15385 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15389 (entries, truncated, budget)
15390}
15391
15392fn workspace_inspection_truncation(
15396 walk_budget: &WorkspaceWalkBudget,
15397 git_time_budget_exceeded: bool,
15398 elapsed: Duration,
15399) -> Option<WorkspaceInspectionTruncationV1> {
15400 if !walk_budget.time_budget_exceeded
15401 && !walk_budget.entry_cap_exceeded
15402 && !git_time_budget_exceeded
15403 {
15404 return None;
15405 }
15406 Some(WorkspaceInspectionTruncationV1 {
15407 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15408 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15409 git_time_budget_exceeded,
15410 entries_visited: walk_budget.entries_visited,
15411 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15412 })
15413}
15414
15415fn walk_workspace_directory(
15416 directory: &Path,
15417 relative_directory: Option<&RepositoryPath>,
15418 depth: usize,
15419 entries: &mut Vec<WorkspaceEntry>,
15420 truncated: &mut bool,
15421 budget: &mut WorkspaceWalkBudget,
15422) {
15423 if budget.is_exhausted() {
15424 *truncated = true;
15425 return;
15426 }
15427 let read_dir = match std::fs::read_dir(directory) {
15428 Ok(read_dir) => read_dir,
15429 Err(_) => {
15430 *truncated = true;
15431 return;
15432 }
15433 };
15434 let mut children = Vec::new();
15435 for child in read_dir {
15436 match child {
15437 Ok(child) => children.push(child),
15438 Err(_) => *truncated = true,
15439 }
15440 }
15441 children.sort_by_key(|child| child.file_name());
15442 for child in children {
15443 if budget.record_visit() {
15444 *truncated = true;
15445 return;
15446 }
15447 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15448 *truncated = true;
15449 return;
15450 }
15451 let file_type = match child.file_type() {
15452 Ok(file_type) => file_type,
15453 Err(_) => {
15454 *truncated = true;
15455 continue;
15456 }
15457 };
15458 if file_type.is_symlink() {
15459 continue;
15460 }
15461 let name = match child.file_name().into_string() {
15462 Ok(name) => name,
15463 Err(_) => {
15464 *truncated = true;
15465 continue;
15466 }
15467 };
15468 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15469 continue;
15470 }
15471 let relative_path = match relative_directory {
15472 None => windows_repository_path(name),
15473 Some(parent) => parent
15474 .as_utf8()
15475 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15476 };
15477 let Some(relative_path) = relative_path else {
15478 *truncated = true;
15479 continue;
15480 };
15481 let kind = if file_type.is_dir() {
15482 WorkspaceEntryKind::Directory
15483 } else if file_type.is_file() {
15484 WorkspaceEntryKind::File
15485 } else {
15486 continue;
15487 };
15488 entries.push(WorkspaceEntry {
15489 relative_path: relative_path.clone(),
15490 kind,
15491 });
15492 if file_type.is_dir() {
15493 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15494 walk_workspace_directory(
15495 &child.path(),
15496 Some(&relative_path),
15497 depth + 1,
15498 entries,
15499 truncated,
15500 budget,
15501 );
15502 } else {
15503 *truncated = true;
15504 }
15505 }
15506 }
15507}
15508
15509fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15510 if value.contains('\\') {
15511 return None;
15512 }
15513 RepositoryPath::utf8(value).ok()
15514}
15515
15516fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15517 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15518}
15519
15520fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15529 if name.eq_ignore_ascii_case(".git")
15530 || name.eq_ignore_ascii_case("target")
15531 || name.eq_ignore_ascii_case("node_modules")
15532 {
15533 return true;
15534 }
15535 path.join("CACHEDIR.TAG").is_file()
15536}
15537
15538fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15539 matches!(
15540 status,
15541 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15542 )
15543}
15544
15545#[derive(Clone, Debug, Eq, PartialEq)]
15546enum ContextPackRepositoryHead {
15547 NotRepository,
15548 Unborn,
15549 Commit(String),
15550}
15551
15552#[derive(Clone, Debug, Eq, PartialEq)]
15553struct ContextPackRepositoryObservation {
15554 head: ContextPackRepositoryHead,
15555 git: GitSnapshot,
15556 files: Vec<ContextPackRepositoryFileSource>,
15557}
15558
15559fn stable_context_pack_repository(
15560 first: ContextPackRepositoryObservation,
15561 second: ContextPackRepositoryObservation,
15562) -> Result<ContextPackRepository, NodeFailure> {
15563 if first != second {
15564 return Err(failure(
15565 NodeFailureCode::ContextPackMaterializationFailed,
15566 "context repository changed during bounded capture",
15567 ));
15568 }
15569 Ok(ContextPackRepository::from_sources(
15570 &second.git,
15571 second.files,
15572 ))
15573}
15574
15575fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15582 if Instant::now() < deadline {
15583 return false;
15584 }
15585 append_git_diagnostic(
15586 snapshot,
15587 &format!("{stage} skipped: workspace inspection time budget exhausted"),
15588 );
15589 true
15590}
15591
15592async fn run_git_bounded_with_deadline(
15601 root: &str,
15602 arguments: &[&str],
15603 output_limit: usize,
15604 deadline: Instant,
15605) -> io::Result<GitCommandOutput> {
15606 let remaining = deadline.checked_duration_since(Instant::now());
15607 let timeout_ms = remaining
15608 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15609 .unwrap_or(0)
15610 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15611 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15612}
15613
15614async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15621 let mut snapshot = GitSnapshot {
15622 is_repository: false,
15623 branch: None,
15624 status: Vec::new(),
15625 recent_commits: Vec::new(),
15626 worktrees: Vec::new(),
15627 managed_worktree: None,
15628 truncated: false,
15629 diagnostic: None,
15630 };
15631 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15632 return (snapshot, true);
15633 }
15634 let repository = match run_git_bounded_with_deadline(
15635 root,
15636 &["rev-parse", "--is-inside-work-tree"],
15637 4 * 1_024,
15638 deadline,
15639 )
15640 .await
15641 {
15642 Ok(output) => output,
15643 Err(error) => {
15644 append_git_diagnostic(
15645 &mut snapshot,
15646 &format!("git inspection unavailable: {error}"),
15647 );
15648 return (snapshot, false);
15649 }
15650 };
15651 snapshot.truncated |= repository.truncated;
15652 if repository.timed_out {
15653 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15654 return (snapshot, false);
15655 }
15656 if !repository.success
15657 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15658 {
15659 let stderr = String::from_utf8_lossy(&repository.stderr);
15660 if !stderr.to_ascii_lowercase().contains("not a git repository") {
15661 append_git_diagnostic(&mut snapshot, stderr.trim());
15662 }
15663 return (snapshot, false);
15664 }
15665 snapshot.is_repository = true;
15666
15667 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15668 return (snapshot, true);
15669 }
15670 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15671 Ok(output) => {
15672 snapshot.truncated |= output.truncated;
15673 if output.timed_out {
15674 append_git_diagnostic(&mut snapshot, "git branch query timed out");
15675 } else if output.success {
15676 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15677 if !branch.is_empty() {
15678 snapshot.branch = Some(truncate_git_field(
15679 &branch,
15680 MAX_REPOSITORY_PATH_BYTES,
15681 &mut snapshot.truncated,
15682 ));
15683 }
15684 }
15685 }
15686 Err(error) => append_git_diagnostic(
15687 &mut snapshot,
15688 &format!("git branch query failed: {error}"),
15689 ),
15690 }
15691 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15692 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15693 snapshot.truncated |= output.truncated;
15694 if output.success && !output.timed_out {
15695 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15696 if !head.is_empty() {
15697 snapshot.branch = Some(format!("detached:{head}"));
15698 }
15699 }
15700 }
15701 }
15702
15703 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15704 return (snapshot, true);
15705 }
15706 match run_git_bounded_with_deadline(
15707 root,
15708 &[
15709 "status",
15710 "--porcelain=v1",
15711 "-z",
15712 "--renames",
15713 "--untracked-files=normal",
15714 "--",
15715 ".",
15716 ],
15717 GIT_OUTPUT_MAX_BYTES,
15718 deadline,
15719 )
15720 .await
15721 {
15722 Ok(output) => {
15723 snapshot.truncated |= output.truncated;
15724 if output.timed_out {
15725 append_git_diagnostic(&mut snapshot, "git status timed out");
15726 } else if output.success {
15727 parse_git_status(&output.stdout, &mut snapshot);
15728 } else {
15729 append_git_diagnostic(
15730 &mut snapshot,
15731 String::from_utf8_lossy(&output.stderr).trim(),
15732 );
15733 }
15734 }
15735 Err(error) => append_git_diagnostic(
15736 &mut snapshot,
15737 &format!("git status failed: {error}"),
15738 ),
15739 }
15740
15741 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15742 return (snapshot, true);
15743 }
15744 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15745 match run_git_bounded_with_deadline(
15746 root,
15747 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15748 16 * 1_024,
15749 deadline,
15750 )
15751 .await
15752 {
15753 Ok(output) => {
15754 snapshot.truncated |= output.truncated;
15755 if output.timed_out {
15756 append_git_diagnostic(&mut snapshot, "git log timed out");
15757 } else if output.success {
15758 parse_git_commits(&output.stdout, &mut snapshot);
15759 } else {
15760 let stderr = String::from_utf8_lossy(&output.stderr);
15761 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15762 append_git_diagnostic(&mut snapshot, stderr.trim());
15763 }
15764 }
15765 }
15766 Err(error) => append_git_diagnostic(
15767 &mut snapshot,
15768 &format!("git log failed: {error}"),
15769 ),
15770 }
15771
15772 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15773 return (snapshot, true);
15774 }
15775 match list_git_worktrees_with_deadline(root, deadline).await {
15776 Ok(worktrees) => {
15777 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15778 }
15779 Err(error) => append_git_diagnostic(
15780 &mut snapshot,
15781 &format!("git worktree list failed: {}", error.message),
15782 ),
15783 }
15784 (snapshot, false)
15785}
15786
15787fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15788 let mut remaining = output;
15789 while !remaining.is_empty() {
15790 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15791 snapshot.truncated = true;
15792 break;
15793 }
15794 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15795 snapshot.truncated = true;
15796 break;
15797 };
15798 let record = &remaining[..record_end];
15799 remaining = &remaining[record_end + 1..];
15800 let has_previous_path = record
15801 .get(..2)
15802 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15803 let previous_path_bytes = if has_previous_path {
15804 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15805 snapshot.truncated = true;
15806 break;
15807 };
15808 let previous = &remaining[..previous_end];
15809 remaining = &remaining[previous_end + 1..];
15810 Some(previous)
15811 } else {
15812 None
15813 };
15814 if record.len() < 4
15815 || record[2] != b' '
15816 || !is_git_status_code(record[0])
15817 || !is_git_status_code(record[1])
15818 {
15819 snapshot.truncated = true;
15820 continue;
15821 }
15822 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15823 snapshot.truncated = true;
15824 continue;
15825 };
15826 let previous_path = match previous_path_bytes {
15827 Some(previous) => {
15828 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15829 snapshot.truncated = true;
15830 continue;
15831 };
15832 Some(previous)
15833 }
15834 None => None,
15835 };
15836 snapshot.status.push(GitStatusEntry {
15837 index_status: (record[0] as char).to_string(),
15838 worktree_status: (record[1] as char).to_string(),
15839 path,
15840 previous_path,
15841 });
15842 }
15843}
15844
15845fn is_git_status_code(byte: u8) -> bool {
15846 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15847}
15848
15849fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15850 for line in String::from_utf8_lossy(output).lines() {
15851 let Some((id, summary)) = line.split_once('\u{1f}') else {
15852 continue;
15853 };
15854 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15855 snapshot.truncated = true;
15856 break;
15857 }
15858 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15859 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15860 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15861 }
15862}
15863
15864fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15865 if value.len() <= max_bytes {
15866 return value.to_owned();
15867 }
15868 *truncated = true;
15869 let mut boundary = max_bytes;
15870 while boundary > 0 && !value.is_char_boundary(boundary) {
15871 boundary -= 1;
15872 }
15873 value[..boundary].to_owned()
15874}
15875
15876fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15877 if message.is_empty() {
15878 return;
15879 }
15880 let normalized = message
15881 .chars()
15882 .map(|character| if character.is_control() { ' ' } else { character })
15883 .collect::<String>();
15884 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15885 if !diagnostic.is_empty() {
15886 diagnostic.push_str("; ");
15887 }
15888 diagnostic.push_str(normalized.trim());
15889 snapshot.diagnostic = Some(truncate_git_field(
15890 &diagnostic,
15891 GIT_DIAGNOSTIC_MAX_BYTES,
15892 &mut snapshot.truncated,
15893 ));
15894}
15895
15896async fn run_git_bounded(
15897 root: &str,
15898 arguments: &[&str],
15899 output_limit: usize,
15900) -> io::Result<GitCommandOutput> {
15901 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15902}
15903
15904async fn read_git_history_bounded(
15905 root: &str,
15906 path: Option<&RepositoryPath>,
15907 before: Option<&GitObjectId>,
15908 limit: u16,
15909) -> Result<GitHistoryPage, NodeFailure> {
15910 let path = path
15911 .map(|path| {
15912 path.as_utf8().ok_or_else(|| {
15913 failure(
15914 NodeFailureCode::InvalidRepositoryPath,
15915 "git history path must use UTF-8",
15916 )
15917 })
15918 })
15919 .transpose()?
15920 .unwrap_or(".");
15921 let count = (usize::from(limit) + 1).to_string();
15922 let start = before
15923 .map(|revision| format!("{}^", revision.as_str()))
15924 .unwrap_or_else(|| "HEAD".to_owned());
15925 let arguments = [
15926 "--no-pager",
15927 "log",
15928 "--no-decorate",
15929 "--date=iso-strict",
15930 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15931 "-n",
15932 count.as_str(),
15933 start.as_str(),
15934 "--",
15935 path,
15936 ];
15937 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15938 .await
15939 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15940 if output.timed_out {
15941 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15942 }
15943 if !output.success {
15944 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15945 }
15946 let mut commits = parse_git_history(&output.stdout);
15947 let has_more = commits.len() > usize::from(limit) || output.truncated;
15948 commits.truncate(usize::from(limit));
15949 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15950 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15951}
15952
15953fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15954 String::from_utf8_lossy(output)
15955 .split('\u{1e}')
15956 .filter_map(|record| {
15957 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15958 if fields.len() != 9 {
15959 return None;
15960 }
15961 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15962 let parents = if fields[1].is_empty() {
15963 Vec::new()
15964 } else {
15965 fields[1]
15966 .split(' ')
15967 .map(|value| GitObjectId::new(value.to_owned()))
15968 .collect::<Result<Vec<_>, _>>()
15969 .ok()?
15970 };
15971 Some(GitCommitDetails {
15972 id,
15973 parents,
15974 subject: bounded_git_text(fields[2], 512),
15975 author_name: bounded_git_text(fields[3], 256),
15976 author_email: bounded_git_text(fields[4], 320),
15977 authored_at: bounded_git_text(fields[5], 64),
15978 committer_name: bounded_git_text(fields[6], 256),
15979 committer_email: bounded_git_text(fields[7], 320),
15980 committed_at: bounded_git_text(fields[8], 64),
15981 signature_status: GitSignatureStatus::NoSignature,
15982 signer: None,
15983 })
15984 })
15985 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15986 .collect()
15987}
15988
15989fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15990 let normalized = value
15991 .chars()
15992 .map(|character| if character.is_control() { ' ' } else { character })
15993 .collect::<String>();
15994 if normalized.len() <= max_bytes {
15995 return normalized;
15996 }
15997 let mut boundary = max_bytes;
15998 while boundary > 0 && !normalized.is_char_boundary(boundary) {
15999 boundary -= 1;
16000 }
16001 normalized[..boundary].to_owned()
16002}
16003
16004async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16005 let path = request.path.as_ref().map(|path| {
16006 path.as_utf8()
16007 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16008 }).transpose()?;
16009 let mut arguments = match &request.mode {
16010 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16011 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16012 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16013 };
16014 if let GitDiffMode::Commit { revision } = &request.mode {
16015 arguments.push(revision.as_str());
16016 }
16017 arguments.push("--");
16018 if let Some(path) = path {
16019 arguments.push(path);
16020 }
16021 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16022 .await
16023 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16024 if output.timed_out {
16025 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16026 }
16027 if !output.success {
16028 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16029 }
16030 Ok(GitDiff {
16031 mode: request.mode,
16032 path: request.path,
16033 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16034 truncated: output.truncated,
16035 })
16036}
16037
16038async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16039 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16040 ResponseEnvelope { request_id: envelope.request_id, result }
16041}
16042
16043async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16044 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16045 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16046 }
16047 let read_only = matches!(
16048 &request,
16049 NodeRequest::Snapshot
16050 | NodeRequest::Resync { .. }
16051 | NodeRequest::BrowseHostDirectories { .. }
16052 | NodeRequest::InspectWorkspace { .. }
16053 | NodeRequest::ReadWorkspaceFile { .. }
16054 | NodeRequest::ReadGitHistory { .. }
16055 | NodeRequest::ReadGitDiff { .. }
16056 | NodeRequest::CatalogNativeSessions { .. }
16057 | NodeRequest::PageNativeSessions { .. }
16058 | NodeRequest::PreviewNativeSession { .. }
16059 | NodeRequest::PreviewSessionRecord { .. }
16060 );
16061 let _mutation_guard = if read_only {
16062 None
16063 } else {
16064 let guard = shared.mutation_gate.lock().await;
16065 if shared.shutdown.load(Ordering::Acquire) {
16066 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16067 }
16068 Some(guard)
16069 };
16070 match request {
16071 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16072 event_sequence: shared.current_sequence(),
16073 controller: shared.controller_state(),
16074 snapshot: shared.snapshot(),
16075 }),
16076 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16077 NodeRequest::ArmHarnessMcpReservation {
16078 reservation_id,
16079 activation_digest,
16080 spawn_spec,
16081 launch,
16082 expires_at_unix_ms,
16083 } => {
16084 shared.require_controller(connection_id, role)?;
16085 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16086 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16087 })?;
16088 let expires_at_unix_ms = registry
16089 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16090 .await
16091 .map_err(harness_mcp_failure)?;
16092 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16093 }
16094 NodeRequest::SpawnSpecWithHarnessMcp {
16095 reservation_id,
16096 activation_digest,
16097 spec,
16098 deadline_unix_ms,
16099 } => {
16100 shared.require_controller(connection_id, role)?;
16101 let receipt = shared
16102 .spawn_from_spec_with_harness_mcp(
16103 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16104 )
16105 .await?;
16106 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16107 }
16108 NodeRequest::ActivateHarnessMcpReservation {
16109 reservation_id,
16110 activation_digest,
16111 record_id,
16112 session,
16113 } => {
16114 shared.require_controller(connection_id, role)?;
16115 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16116 current.instance_id == session.session.instance_id
16117 && current.generation == session.session.generation
16118 }).and_then(|current| current.process_id).ok_or_else(|| {
16119 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16120 })?;
16121 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16122 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16123 })?.activate(
16124 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16125 ).map_err(harness_mcp_failure)?;
16126 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16127 }
16128 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16129 shared.require_controller(connection_id, role)?;
16130 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16131 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16132 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16133 if let Some(instance_id) = instance_id {
16134 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16135 }
16136 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16137 }
16138 NodeRequest::PutHarnessMcpReplyChunk {
16139 reservation_id, activation_digest, record_id, session, call_id,
16140 offset, final_chunk, chunk_hex,
16141 } => {
16142 shared.require_controller(connection_id, role)?;
16143 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16144 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16145 })?.put_reply_chunk(
16146 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16147 offset, final_chunk, &chunk_hex,
16148 ).map_err(harness_mcp_failure)?;
16149 Ok(NodeResponse::ReplyChunkAccepted {
16150 reservation_id, activation_digest, record_id, session, call_id,
16151 next_offset, completed,
16152 })
16153 }
16154 NodeRequest::RejectHarnessMcpCall {
16155 reservation_id, activation_digest, record_id, session, call_id, reason,
16156 } => {
16157 shared.require_controller(connection_id, role)?;
16158 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16159 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16160 })?.reject_call(
16161 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16162 ).map_err(harness_mcp_failure)?;
16163 Ok(NodeResponse::CallRejected {
16164 reservation_id, activation_digest, record_id, session, call_id,
16165 })
16166 }
16167 NodeRequest::BeginDeliveryStage { manifest } => {
16168 shared.require_controller(connection_id, role)?;
16169 shared.begin_delivery_stage(manifest)
16170 }
16171 NodeRequest::PutDeliveryBlobChunk {
16172 stage_id,
16173 blob_digest,
16174 offset,
16175 chunk_hex,
16176 } => {
16177 shared.require_controller(connection_id, role)?;
16178 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16179 }
16180 NodeRequest::CommitDeliveryStage { stage_id } => {
16181 shared.require_controller(connection_id, role)?;
16182 let receipt = shared.commit_delivery_stage(stage_id)?;
16183 Ok(NodeResponse::DeliveryCommitted { receipt })
16184 }
16185 NodeRequest::AbortDeliveryStage { stage_id } => {
16186 shared.require_controller(connection_id, role)?;
16187 shared.abort_delivery_stage(&stage_id)?;
16188 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16189 }
16190 NodeRequest::BrowseHostDirectories { directory, after } => {
16191 let listing = shared.browse_host_directories(directory, after).await?;
16192 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16193 }
16194 NodeRequest::InspectWorkspace { workspace_id } => {
16195 let inspection = shared.inspect_workspace(workspace_id).await?;
16196 Ok(NodeResponse::WorkspaceInspected { inspection })
16197 }
16198 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16199 let file = shared.read_workspace_file(workspace_id, path).await?;
16200 Ok(NodeResponse::WorkspaceFileRead { file })
16201 }
16202 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16203 shared.require_controller(connection_id, role)?;
16204 let file = shared
16205 .write_workspace_file(workspace_id, path, expected_revision, text)
16206 .await?;
16207 Ok(NodeResponse::WorkspaceFileWritten { file })
16208 }
16209 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16210 shared.require_controller(connection_id, role)?;
16211 let file = shared.create_workspace_file(workspace_id, path).await?;
16212 Ok(NodeResponse::WorkspaceFileCreated { file })
16213 }
16214 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16215 shared.require_controller(connection_id, role)?;
16216 let entry = shared
16217 .create_workspace_directory(workspace_id.clone(), path)
16218 .await?;
16219 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16220 }
16221 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16222 if role != ClientRole::Operator {
16223 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16224 }
16225 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16226 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16227 }
16228 let page = shared
16229 .read_git_history(workspace_id.clone(), path, before, limit)
16230 .await?;
16231 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16232 }
16233 NodeRequest::ReadGitDiff { workspace_id, request } => {
16234 if role != ClientRole::Operator {
16235 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16236 }
16237 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16238 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16239 }
16240 NodeRequest::CatalogNativeSessions { route, limit } => {
16241 if role != ClientRole::Operator {
16242 return Err(failure(
16243 NodeFailureCode::ObserverReadOnly,
16244 "native session catalog requires an operator connection",
16245 ));
16246 }
16247 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16248 return Err(failure(
16249 NodeFailureCode::InvalidRequest,
16250 "native session catalog limit is invalid",
16251 ));
16252 }
16253 let (entries, summary) = shared
16254 .catalog_native_sessions(route.clone(), limit)
16255 .await?;
16256 Ok(NodeResponse::NativeSessionsCataloged {
16257 route,
16258 entries,
16259 summary: Some(summary),
16260 })
16261 }
16262 NodeRequest::PageNativeSessions {
16263 route,
16264 window,
16265 catalog_revision,
16266 recent_cutoff_unix_ms,
16267 after_selection_id,
16268 limit,
16269 } => {
16270 if role != ClientRole::Operator {
16271 return Err(failure(
16272 NodeFailureCode::ObserverReadOnly,
16273 "native session catalog paging requires an operator connection",
16274 ));
16275 }
16276 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16277 || after_selection_id
16278 .as_deref()
16279 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16280 {
16281 return Err(failure(
16282 NodeFailureCode::InvalidRequest,
16283 "native session catalog page request is invalid",
16284 ));
16285 }
16286 let page = shared
16287 .page_native_sessions(
16288 route.clone(),
16289 window,
16290 catalog_revision,
16291 recent_cutoff_unix_ms,
16292 after_selection_id,
16293 limit,
16294 )
16295 .await?;
16296 Ok(NodeResponse::NativeSessionsPaged {
16297 route,
16298 page,
16299 })
16300 }
16301 NodeRequest::PreviewNativeSession {
16302 selection,
16303 message_limit,
16304 } => {
16305 if role != ClientRole::Operator {
16306 return Err(failure(
16307 NodeFailureCode::ObserverReadOnly,
16308 "native session preview requires an operator connection",
16309 ));
16310 }
16311 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16312 .contains(&message_limit)
16313 || selection.validate().is_err()
16314 {
16315 return Err(failure(
16316 NodeFailureCode::InvalidRequest,
16317 "native session preview request is invalid",
16318 ));
16319 }
16320 let preview = shared
16321 .preview_native_session(selection.clone(), message_limit)
16322 .await?;
16323 Ok(NodeResponse::NativeSessionPreviewed {
16324 selection,
16325 preview,
16326 })
16327 }
16328 NodeRequest::IndexNativeSession {
16329 selection,
16330 display_name,
16331 } => {
16332 shared.require_controller(connection_id, role)?;
16333 let record = shared
16334 .index_native_session(selection.clone(), display_name)
16335 .await?;
16336 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16337 }
16338 NodeRequest::PreviewSessionRecord {
16339 record_id,
16340 message_limit,
16341 } => {
16342 if role != ClientRole::Operator {
16343 return Err(failure(
16344 NodeFailureCode::ObserverReadOnly,
16345 "native session preview requires an operator connection",
16346 ));
16347 }
16348 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16349 .contains(&message_limit)
16350 {
16351 return Err(failure(
16352 NodeFailureCode::InvalidRequest,
16353 "native session preview request is invalid",
16354 ));
16355 }
16356 let record = shared.record(&record_id)?;
16357 let working_directory = shared.workspace_root(&record.workspace_id)?;
16358 if !platform::roots_equal(
16359 &working_directory,
16360 windows_path_text(&record.canonical_root),
16361 ) {
16362 return Err(failure(
16363 NodeFailureCode::SessionRecordConflict,
16364 "managed session workspace root changed; refusing to preview another directory",
16365 ));
16366 }
16367 let identity = record.provider_session.clone().ok_or_else(|| {
16368 failure(
16369 NodeFailureCode::BackendOperationFailed,
16370 "native session preview is unavailable for this session record",
16371 )
16372 })?;
16373 if identity.key != ProviderSessionKey::SessionId {
16374 return Err(failure(
16375 NodeFailureCode::BackendOperationFailed,
16376 "native session preview is unavailable for this session identity",
16377 ));
16378 }
16379 let preview = shared
16380 .preview_native_session_by_session_id(
16381 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16382 )
16383 .await?;
16384 if preview.session_id != identity.id {
16385 return Err(failure(
16386 NodeFailureCode::SessionRecordConflict,
16387 "native session preview identity changed while loading",
16388 ));
16389 }
16390 shared.revalidate_session_record_preview(&record, &identity)?;
16391 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16392 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16393 record_id: record_id.clone(),
16394 summary: SessionHistorySummaryV1::from(&preview),
16395 });
16396 Ok(NodeResponse::SessionRecordPreviewed {
16397 record_id,
16398 preview,
16399 })
16400 }
16401 NodeRequest::AcquireController { lease_ms } => {
16402 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16403 Ok(NodeResponse::Controller { controller: Some(controller) })
16404 }
16405 NodeRequest::ReleaseController => {
16406 shared.release_controller(connection_id);
16407 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16408 }
16409 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16410 shared.require_controller(connection_id, role)?;
16411 let root = require_windows_path(root)?;
16412 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16413 let workspace = shared.register_workspace(workspace_id, root).await?;
16414 Ok(NodeResponse::WorkspaceRegistered { workspace })
16415 }
16416 NodeRequest::CreateStandaloneWorkspace {
16417 workspace_id,
16418 root,
16419 initial_branch,
16420 } => {
16421 shared.require_controller(connection_id, role)?;
16422 let root = require_windows_path(root)?;
16423 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16424 let workspace = shared
16425 .create_standalone_workspace(workspace_id, root, initial_branch)
16426 .await?;
16427 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16428 }
16429 NodeRequest::UnregisterWorkspace { workspace_id } => {
16430 shared.require_controller(connection_id, role)?;
16431 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16432 shared.unregister_workspace(&workspace_id)?;
16433 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16434 }
16435 NodeRequest::CreateWorktree {
16436 source_workspace_id,
16437 workspace_id,
16438 target_root,
16439 branch,
16440 base,
16441 } => {
16442 shared.require_controller(connection_id, role)?;
16443 let target_root = require_windows_path(target_root)?;
16444 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16445 let (worktree, workspace) = shared
16446 .create_worktree(
16447 source_workspace_id,
16448 workspace_id,
16449 target_root,
16450 branch,
16451 base,
16452 )
16453 .await?;
16454 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16455 }
16456 NodeRequest::RemoveWorktree {
16457 source_workspace_id,
16458 target_root,
16459 } => {
16460 shared.require_controller(connection_id, role)?;
16461 let native_target_root = require_windows_path(target_root.clone())?;
16462 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16463 let workspace_id = shared
16464 .remove_worktree(source_workspace_id, native_target_root)
16465 .await?;
16466 Ok(NodeResponse::WorktreeRemoved {
16467 target_root,
16468 workspace_id,
16469 })
16470 }
16471 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16472 shared.require_controller(connection_id, role)?;
16473 if !terminal_size.is_valid() {
16474 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16475 }
16476 if let Some(prompt) = initial_prompt.as_deref() {
16477 validate_node_text("spawn initial prompt", prompt)?;
16478 }
16479 match shared
16480 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16481 .await
16482 {
16483 Ok(session) => {
16484 shared.mark_spawn_provisional(session.clone());
16485 Ok(NodeResponse::SpawnAccepted { session })
16486 }
16487 Err(err) => {
16488 tracing::warn!(
16489 %provider,
16490 %workspace_id,
16491 ?mode,
16492 code = ?err.code,
16493 message = %err.message,
16494 "session spawn refused"
16495 );
16496 Err(err)
16497 }
16498 }
16499 }
16500 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16501 shared.require_controller(connection_id, role)?;
16502 if !terminal_size.is_valid() {
16503 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16504 }
16505 if let Some(prompt) = initial_prompt.as_deref() {
16506 validate_node_text("spawn initial prompt", prompt)?;
16507 }
16508 let plan = backend_paths::plan_launch_options(
16509 provider.as_str(),
16510 mode,
16511 options.model.is_some(),
16512 options.effort.is_some(),
16513 )
16514 .map_err(|r| {
16515 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16516 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16517 })?;
16518 let selection = spawn_selection(&options)?;
16519 let pty_selection = match plan {
16520 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16521 _ => None,
16522 };
16523 let spawned = SPAWN_SESSION_OPTIONS
16524 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16525 .await;
16526 match spawned {
16527 Ok(session) => {
16528 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16529 shared
16530 .pending_acp_options
16531 .lock()
16532 .unwrap_or_else(|p| p.into_inner())
16533 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16534 }
16535 shared.mark_spawn_provisional(session.clone());
16536 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16537 Ok(NodeResponse::SpawnAccepted { session })
16538 }
16539 Err(err) => {
16540 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16541 Err(err)
16542 }
16543 }
16544 }
16545 NodeRequest::SpawnSpec { spec } => {
16546 shared.require_controller(connection_id, role)?;
16547 let receipt = shared.spawn_from_spec(spec).await?;
16548 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16549 }
16550 NodeRequest::SpawnManagedWorktree { request } => {
16551 shared.require_controller(connection_id, role)?;
16552 let receipt = shared.spawn_managed_worktree(request).await?;
16553 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16554 }
16555 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16556 shared.require_controller(connection_id, role)?;
16557 let receipt = shared.spawn_managed_worktree_v2(request).await?;
16558 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16559 }
16560 NodeRequest::CleanupManagedWorktree { lease_id } => {
16561 shared.require_controller(connection_id, role)?;
16562 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16563 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16564 }
16565 NodeRequest::DiscoverHistory { session, limit } => {
16566 controlled_session(shared, connection_id, role, &session)?;
16567 let candidates = shared.discover_history(&session, limit).await?;
16568 Ok(NodeResponse::HistoryDiscovered {
16569 session,
16570 candidates,
16571 })
16572 }
16573 NodeRequest::LoadHistory {
16574 session,
16575 candidate_id,
16576 } => {
16577 controlled_session(shared, connection_id, role, &session)?;
16578 let loaded = shared
16579 .load_history(&session, candidate_id)
16580 .await?;
16581 Ok(NodeResponse::HistoryLoaded {
16582 session,
16583 session_id: loaded.session_id,
16584 message_count: loaded.message_count,
16585 completed_turn_count: loaded.completed_turn_count,
16586 })
16587 }
16588 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16589 shared.require_controller(connection_id, role)?;
16590 let context = shared
16591 .export_context_pack_for_session_record(&record_id, &session, false)
16592 .await?;
16593 Ok(NodeResponse::ContextPackForSessionRecordExported {
16594 record_id,
16595 session,
16596 context,
16597 })
16598 }
16599 NodeRequest::ExportContextPack { session } => {
16600 controlled_session(shared, connection_id, role, &session)?;
16601 let context = shared.export_context_pack(&session).await?;
16602 Ok(NodeResponse::ContextPackExported { context })
16603 }
16604 NodeRequest::ForgetContextPack { context_id } => {
16605 shared.require_controller(connection_id, role)?;
16606 shared.forget_context_pack(&context_id)?;
16607 Ok(NodeResponse::ContextPackForgotten { context_id })
16608 }
16609 NodeRequest::ResolveDurableContextPack { context_id } => {
16610 let context = shared
16614 .context_catalog
16615 .read()
16616 .unwrap_or_else(|poisoned| poisoned.into_inner())
16617 .get(&context_id)
16618 .map(|pack| pack.receipt().clone())
16619 .ok_or_else(|| {
16620 failure(
16621 NodeFailureCode::UnknownContextPack,
16622 "context pack is not present",
16623 )
16624 })?;
16625 Ok(NodeResponse::DurableContextPackResolved { context })
16626 }
16627 NodeRequest::ReadContextPack { digest } => {
16628 let (id, byte_len, bytes) = shared
16633 .context_catalog
16634 .read()
16635 .unwrap_or_else(|poisoned| poisoned.into_inner())
16636 .get_by_digest(&digest)
16637 .map(|pack| {
16638 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16639 })
16640 .ok_or_else(|| {
16641 failure(
16642 NodeFailureCode::UnknownContextPack,
16643 &format!("context pack {digest} not on this node"),
16644 )
16645 })?;
16646 tracing::debug!(
16647 digest = digest.as_str(),
16648 byte_len,
16649 "read context pack bytes",
16650 );
16651 Ok(NodeResponse::ContextPackBytesRead {
16652 pack: ContextPackBytesRead {
16653 digest,
16654 id,
16655 byte_len,
16656 bytes,
16657 },
16658 })
16659 }
16660 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16661 let provider = controlled_session(shared, connection_id, role, &session)?;
16662 if let Some(prompt) = initial_prompt.as_deref() {
16663 validate_node_text("resume initial prompt", prompt)?;
16664 }
16665 let runtime_requirement = if initial_prompt.is_some() {
16666 ProviderRuntimeRequirement::ResumeWithPrompt
16667 } else {
16668 ProviderRuntimeRequirement::Resume
16669 };
16670 shared.require_session_runtime_policy(&session, runtime_requirement)?;
16671 let runtime_policy = shared
16672 .admit_provider_runtime(&provider, runtime_requirement)
16673 .await?;
16674 let working_directory = shared.bound_workspace_root(&session)?;
16675 let request = ResumeLaunchRequest {
16676 working_directory,
16677 terminal_size,
16678 initial_prompt,
16679 };
16680 request
16681 .validate()
16682 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16683 let command = shared.prepare_command(ControlCommand::Resume {
16684 instance_id: session.session.instance_id,
16685 target: ResumeTarget::CurrentProvider,
16686 runtime_policy,
16687 request,
16688 });
16689 shared.arm_resume(&session, command.id, runtime_policy)?;
16690 let dispatch = shared.dispatch_envelope(command);
16691 if let Err(error) = dispatch {
16692 shared.clear_armed_resume(&session);
16693 return Err(error);
16694 }
16695 Ok(NodeResponse::Accepted)
16696 }
16697 NodeRequest::RenameSessionRecord {
16698 record_id,
16699 display_name,
16700 } => {
16701 shared.require_controller(connection_id, role)?;
16702 let record = shared.rename_session_record(&record_id, display_name)?;
16703 Ok(NodeResponse::SessionRecordUpdated { record })
16704 }
16705 NodeRequest::SetSessionTask {
16706 record_id,
16707 expected_revision,
16708 target,
16709 } => {
16710 shared.require_controller(connection_id, role)?;
16711 let record = shared.set_session_task(&record_id, expected_revision, target)?;
16712 Ok(NodeResponse::SessionRecordUpdated { record })
16713 }
16714 NodeRequest::IndexProviderSession {
16715 workspace_id,
16716 provider,
16717 identity,
16718 display_name,
16719 } => {
16720 shared.require_controller(connection_id, role)?;
16721 let record = shared.index_provider_session(
16722 workspace_id,
16723 provider,
16724 identity,
16725 display_name,
16726 )?;
16727 Ok(NodeResponse::ProviderSessionIndexed { record })
16728 }
16729 NodeRequest::ResumeSessionRecord {
16730 record_id,
16731 terminal_size,
16732 initial_prompt,
16733 } => {
16734 shared.require_controller(connection_id, role)?;
16735 if let Some(prompt) = initial_prompt.as_deref() {
16736 validate_node_text("managed resume initial prompt", prompt)?;
16737 }
16738 let (record, session) = shared
16739 .resume_session_record(
16740 &record_id,
16741 terminal_size,
16742 initial_prompt,
16743 )
16744 .await?;
16745 Ok(NodeResponse::SessionRecordResumed { record, session })
16746 }
16747 NodeRequest::ForgetSessionRecord { record_id } => {
16748 shared.require_controller(connection_id, role)?;
16749 shared.forget_session_record(&record_id).await?;
16750 Ok(NodeResponse::SessionRecordForgotten { record_id })
16751 }
16752 NodeRequest::Prompt { session, text } => {
16753 let agent_id = controlled_session(shared, connection_id, role, &session)?;
16754 validate_node_text("prompt", &text)?;
16755 shared.require_session_runtime_policy(
16756 &session,
16757 ProviderRuntimeRequirement::SemanticPrompt,
16758 )?;
16759 let framing = prompt_framing(&agent_id);
16760 shared
16761 .dispatch_input_bounded(
16762 &session,
16763 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16764 )
16765 .await?;
16766 Ok(NodeResponse::Accepted)
16767 }
16768 NodeRequest::Paste { session, text } => {
16769 controlled_session(shared, connection_id, role, &session)?;
16770 validate_node_text("paste", &text)?;
16771 shared.require_session_runtime_policy(
16772 &session,
16773 ProviderRuntimeRequirement::SemanticPrompt,
16774 )?;
16775 shared
16776 .dispatch_input_bounded(
16777 &session,
16778 InputAction::InsertDraft(PromptPayload {
16779 text,
16780 framing: PromptFraming::BracketedPaste,
16781 }),
16782 )
16783 .await?;
16784 Ok(NodeResponse::Accepted)
16785 }
16786 NodeRequest::Input { session, text } => {
16787 controlled_session(shared, connection_id, role, &session)?;
16788 validate_node_text("terminal input", &text)?;
16789 shared
16790 .dispatch_input_bounded(
16791 &session,
16792 InputAction::TerminalText(TerminalText { text }),
16793 )
16794 .await?;
16795 Ok(NodeResponse::Accepted)
16796 }
16797 NodeRequest::TerminalBytes { session, bytes } => {
16798 controlled_session(shared, connection_id, role, &session)?;
16799 shared.sessionize_spawn(session.session.instance_id);
16800 let action = terminal_bytes_action(bytes)?;
16801 shared
16802 .dispatch_input_bounded(&session, action)
16803 .await?;
16804 Ok(NodeResponse::Accepted)
16805 }
16806 NodeRequest::TerminalControl { session, control } => {
16807 controlled_session(shared, connection_id, role, &session)?;
16808 shared
16809 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16810 .await?;
16811 Ok(NodeResponse::Accepted)
16812 }
16813 NodeRequest::Resize { session, size } => {
16814 controlled_session(shared, connection_id, role, &session)?;
16815 shared.sessionize_spawn(session.session.instance_id);
16816 if !size.is_valid() {
16817 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16818 }
16819 shared.dispatch_resize_bounded(&session, size).await?;
16820 Ok(NodeResponse::Accepted)
16821 }
16822 NodeRequest::Interrupt { session } => {
16823 controlled_session(shared, connection_id, role, &session)?;
16824 shared
16825 .dispatch_input_bounded(
16826 &session,
16827 InputAction::TerminalControl(TerminalControl::Interrupt),
16828 )
16829 .await?;
16830 Ok(NodeResponse::Accepted)
16831 }
16832 NodeRequest::Stop { session, force } => {
16833 controlled_session(shared, connection_id, role, &session)?;
16834 shared.sessionize_spawn(session.session.instance_id);
16835 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16836 shared.retire_session_grok_leader(session.session.instance_id);
16837 Ok(NodeResponse::Accepted)
16838 }
16839 NodeRequest::Remove { session } => {
16840 controlled_session(shared, connection_id, role, &session)?;
16841 shared.remove_session(&session).await?;
16842 Ok(NodeResponse::Accepted)
16843 }
16844 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16845 shared.require_controller(connection_id, role)?;
16846 let snapshot = shared.require_acp_session(&session)?;
16847 let interaction = snapshot
16848 .provider
16849 .interactions
16850 .iter()
16851 .find(|interaction| {
16852 interaction_correlation(
16853 session.session.instance_id,
16854 session.session.generation,
16855 interaction.id.0,
16856 ) == correlation_id
16857 })
16858 .ok_or_else(|| failure(
16859 NodeFailureCode::InvalidRequest,
16860 "unknown interaction correlation",
16861 ))?;
16862 if interaction.status != ProviderInteractionStatus::Pending {
16863 return Err(failure(
16864 NodeFailureCode::BackendOperationFailed,
16865 "interaction has already been resolved",
16866 ));
16867 }
16868 response
16869 .validate_for(interaction.interaction_kind)
16870 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16871 let interaction_id = interaction.id;
16872 let response_kind = response.kind();
16873 let response_outcome = response.outcome();
16874 shared.dispatch(ControlCommand::ResolveInteraction {
16875 instance_id: session.session.instance_id,
16876 generation: session.session.generation,
16877 interaction_id,
16878 response,
16879 })?;
16880 tracing::info!(
16886 node_id = %shared.node_id,
16887 session = ?session.session,
16888 correlation_id = %correlation_id,
16889 interaction_id = interaction_id.0,
16890 response_kind = ?response_kind,
16891 outcome = ?response_outcome,
16892 "interaction resolved",
16893 );
16894 Ok(NodeResponse::Accepted)
16895 }
16896 NodeRequest::SetSessionMode { session, mode_id } => {
16897 shared.require_controller(connection_id, role)?;
16898 shared.require_acp_session(&session)?;
16899 shared.dispatch(ControlCommand::SetSessionMode {
16900 instance_id: session.session.instance_id,
16901 mode_id,
16902 })?;
16903 Ok(NodeResponse::Accepted)
16904 }
16905 NodeRequest::SetSessionConfigOption {
16906 session,
16907 option_id,
16908 value_json,
16909 } => {
16910 shared.require_controller(connection_id, role)?;
16911 shared.require_acp_session(&session)?;
16912 shared.dispatch(ControlCommand::SetSessionConfigOption {
16913 instance_id: session.session.instance_id,
16914 option_id,
16915 value_json,
16916 })?;
16917 Ok(NodeResponse::Accepted)
16918 }
16919 NodeRequest::SetSessionModel { session, model_id } => {
16920 shared.require_controller(connection_id, role)?;
16921 shared.require_acp_session(&session)?;
16922 shared.dispatch(ControlCommand::SetSessionModel {
16923 instance_id: session.session.instance_id,
16924 model_id,
16925 })?;
16926 Ok(NodeResponse::Accepted)
16927 }
16928 NodeRequest::Shutdown => {
16929 shared.require_controller(connection_id, role)?;
16930 shared.begin_shutdown_locked().await?;
16931 Ok(NodeResponse::ShuttingDown)
16932 }
16933 }
16934}
16935
16936fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16937 shared.require_controller(connection_id, role)?;
16938 shared.validate_address(session)
16939}
16940
16941fn select_history_candidate(
16947 candidates: &[HistoryCandidateSummary],
16948 provider_session_id: Option<&str>,
16949) -> Result<String, NodeFailure> {
16950 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16951 Some(id) => candidate.session_id_hint == id,
16952 None => true,
16953 });
16954 let candidate = matches.next().ok_or_else(|| {
16955 failure(
16956 NodeFailureCode::SessionRecordConflict,
16957 if provider_session_id.is_some() {
16958 "no history candidate matches the managed provider session"
16959 } else {
16960 "no history candidate exists for the managed session"
16961 },
16962 )
16963 })?;
16964 if matches.next().is_some() {
16965 return Err(failure(
16966 NodeFailureCode::SessionRecordConflict,
16967 if provider_session_id.is_some() {
16968 "history identity is ambiguous for the managed provider session"
16969 } else {
16970 "history is ambiguous without a verified provider session identity"
16971 },
16972 ));
16973 }
16974 Ok(candidate.id.clone())
16975}
16976
16977fn resolved_spawn_profile_summary(
16978 profile: &SpawnProfileDefaults,
16979 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16980) -> Option<SpawnProfileSummary> {
16981 let environment_profile = match profile.environment_profile_id.as_ref() {
16982 None => None,
16983 Some(profile_id) => {
16984 let binding = environment_profiles.get(profile_id)?;
16985 if binding.provider != profile.provider
16986 || binding.native_profile_id(profile.mode).is_none()
16987 {
16988 return None;
16989 }
16990 Some(ResolvedEnvironmentProfileReceipt {
16991 profile_id: binding.id.clone(),
16992 profile_revision: binding.revision.clone(),
16993 network_allowlist: None,
16994 browser_profile_id: None,
16995 })
16996 }
16997 };
16998 Some(SpawnProfileSummary {
16999 id: profile.profile_id.clone(),
17000 revision: profile.revision.clone(),
17001 environment_profile,
17002 })
17003}
17004
17005fn session_record_export_target_matches(
17024 expected: &ManagedSessionRecord,
17025 current: &ManagedSessionRecord,
17026 session: &SessionAddress,
17027 allow_clean_detachment: bool,
17028) -> bool {
17029 if current == expected && current.active_session.as_ref() == Some(session) {
17030 return current.state == ManagedSessionState::Live;
17031 }
17032 allow_clean_detachment
17033 && current.state == ManagedSessionState::Dormant
17034 && current.active_session.is_none()
17035 && current.record_id == expected.record_id
17036 && current.display_name == expected.display_name
17037 && current.provider == expected.provider
17038 && current.mode == expected.mode
17039 && current.workspace_id == expected.workspace_id
17040 && current.canonical_root == expected.canonical_root
17041 && current.provider_session == expected.provider_session
17042 && current.environment_profile == expected.environment_profile
17043 && current.bundle == expected.bundle
17044 && current.context_id == expected.context_id
17045 && current.context == expected.context
17046 && current.exported_context == expected.exported_context
17047 && current.task_binding == expected.task_binding
17048 && current.created_at_unix_ms == expected.created_at_unix_ms
17049 && current.last_error == expected.last_error
17050}
17051
17052fn session_record_context_export_binding_is_exact(
17053 expected: &ManagedSessionRecord,
17054 current: &ManagedSessionRecord,
17055 identity: Option<&ProviderSessionIdentity>,
17056 session: &SessionAddress,
17057 binding: &SessionBinding,
17058 provider: &AgentId,
17059 current_root: &str,
17060 allow_clean_detachment: bool,
17061) -> bool {
17062 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17063 && current.provider_session.as_ref() == identity
17064 && current.workspace_id == session.workspace_id
17065 && provider == ¤t.provider
17066 && binding.workspace_id == session.workspace_id
17067 && binding.generation == session.session.generation
17068 && binding.record_id.as_ref() == Some(¤t.record_id)
17069 && platform::roots_equal(
17070 current_root,
17071 windows_path_text(¤t.canonical_root),
17072 )
17073}
17074
17075fn session_record_context_export_source_is_usable(
17076 record: &ManagedSessionRecord,
17077 status: &SessionStatus,
17078 allow_clean_detachment: bool,
17079) -> bool {
17080 (record.state == ManagedSessionState::Live
17081 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17082 && context_pack_source_status_is_usable(status)
17083}
17084
17085fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17086 PromptFraming::BracketedPaste
17087}
17088
17089fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17090 if text.len() > MAX_NODE_TEXT_BYTES {
17091 return Err(failure(
17092 NodeFailureCode::InvalidRequest,
17093 &format!(
17094 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17095 text.len(),
17096 ),
17097 ));
17098 }
17099 Ok(())
17100}
17101
17102fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17103 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17104 return Err(failure(
17105 NodeFailureCode::InvalidRequest,
17106 &format!(
17107 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17108 bytes.len(),
17109 ),
17110 ));
17111 }
17112 Ok(())
17113}
17114
17115fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17116 validate_terminal_bytes(&bytes)?;
17117 Ok(InputAction::TerminalBytes(bytes))
17118}
17119
17120fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17121 if root.is_empty()
17122 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17123 || root.chars().any(char::is_control)
17124 {
17125 return Err(failure(
17126 NodeFailureCode::InvalidWorkspaceRoot,
17127 &format!(
17128 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17129 ),
17130 ));
17131 }
17132 Ok(())
17133}
17134
17135fn canonical_standalone_workspace_candidate(
17136 workspace_id: &WorkspaceId,
17137 root: &str,
17138) -> Result<String, NodeServerError> {
17139 let path = Path::new(root);
17140 match std::fs::symlink_metadata(path) {
17141 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17142 .map(|workspace| workspace.canonical_root),
17143 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17144 if !path.is_absolute() {
17145 return Err(NodeServerError::InvalidWorkspaceRoot {
17146 workspace_id: workspace_id.clone(),
17147 path: root.to_owned(),
17148 message: "path is not absolute".to_owned(),
17149 });
17150 }
17151 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17152 workspace_id: workspace_id.clone(),
17153 path: root.to_owned(),
17154 message: "path has no standalone directory name".to_owned(),
17155 })?;
17156 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17157 workspace_id: workspace_id.clone(),
17158 path: root.to_owned(),
17159 message: "path has no parent directory".to_owned(),
17160 })?;
17161 let canonical = std::fs::canonicalize(parent)
17162 .map(|parent| parent.join(name))
17163 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17164 workspace_id: workspace_id.clone(),
17165 path: root.to_owned(),
17166 message: error.to_string(),
17167 })?;
17168 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17169 NodeServerError::InvalidWorkspaceRoot {
17170 workspace_id: workspace_id.clone(),
17171 path: path.to_string_lossy().into_owned(),
17172 message: "canonical path is not valid Unicode".to_owned(),
17173 }
17174 })?;
17175 let canonical_root = platform::normalize_canonical_root(canonical_root);
17176 validate_workspace_root(workspace_id, &canonical_root)?;
17177 Ok(canonical_root)
17178 }
17179 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17180 workspace_id: workspace_id.clone(),
17181 path: root.to_owned(),
17182 message: error.to_string(),
17183 }),
17184 }
17185}
17186
17187fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17188 let Some(revision) = revision else {
17189 return Ok(());
17190 };
17191 if revision.is_empty()
17192 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17193 || revision.chars().any(char::is_control)
17194 {
17195 return Err(failure(
17196 NodeFailureCode::InvalidRequest,
17197 &format!(
17198 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17199 ),
17200 ));
17201 }
17202 Ok(())
17203}
17204
17205fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17206 let code = match error.kind {
17207 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17208 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17209 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17210 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17211 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17212 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17213 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17214 };
17215 failure(code, &error.message)
17216}
17217
17218fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17219 let code = match error.kind {
17220 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17221 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17222 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17223 NodeFailureCode::BackendOperationFailed
17224 }
17225 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17226 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17227 }
17228 };
17229 failure(code, error.message)
17230}
17231
17232fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17233 let (code, message) = match error.kind {
17234 GitWorktreeErrorKind::Invalid => (
17235 NodeFailureCode::InvalidRequest,
17236 "managed Git input is invalid",
17237 ),
17238 GitWorktreeErrorKind::NotRepository => (
17239 NodeFailureCode::NotGitRepository,
17240 "managed source is not a Git repository",
17241 ),
17242 GitWorktreeErrorKind::Conflict => (
17243 NodeFailureCode::WorktreeConflict,
17244 "managed Git identity conflicts with existing repository state",
17245 ),
17246 GitWorktreeErrorKind::Protected => (
17247 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17248 "managed worktree ownership validation failed",
17249 ),
17250 GitWorktreeErrorKind::Dirty => (
17251 NodeFailureCode::WorktreeDirty,
17252 "managed worktree contains uncommitted changes",
17253 ),
17254 GitWorktreeErrorKind::Locked => (
17255 NodeFailureCode::WorktreeLocked,
17256 "managed worktree is locked",
17257 ),
17258 GitWorktreeErrorKind::Failed => (
17259 NodeFailureCode::BackendOperationFailed,
17260 "managed Git operation failed",
17261 ),
17262 };
17263 NodeFailure { code, message: message.to_owned() }
17264}
17265
17266fn managed_spawn_receipt(
17267 resolved: &ResolvedSpawnSpec,
17268 incarnation_id: NodeIncarnationId,
17269 session: SessionAddress,
17270 lease: ManagedWorktreeLeaseSnapshot,
17271 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17272 bundle: Option<ResolvedBundleReceipt>,
17273 context: Option<ResolvedContextPackReceipt>,
17274) -> ManagedWorktreeSpawnReceipt {
17275 let mut managed = resolved.clone();
17276 managed.target.worktree_id = Some(lease.workspace_id.clone());
17277 ManagedWorktreeSpawnReceipt {
17278 spawn: managed.receipt_with_materialization(
17279 incarnation_id,
17280 session,
17281 environment_profile,
17282 bundle,
17283 context,
17284 ),
17285 lease,
17286 }
17287}
17288
17289fn validate_selected_worktree(
17290 source_root: &str,
17291 selected_root: &str,
17292 worktrees: &[NativeGitWorktreeSnapshot],
17293) -> Result<(), NodeFailure> {
17294 if !worktrees
17295 .iter()
17296 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17297 {
17298 return Err(failure(
17299 NodeFailureCode::WorktreeProtected,
17300 "source workspace is not an authoritative non-bare Git worktree root",
17301 ));
17302 }
17303 if !worktrees.iter().any(|worktree| {
17304 worktree_paths_equal(&worktree.path, selected_root)
17305 && !worktree.is_main
17306 && !worktree.is_bare
17307 }) {
17308 return Err(failure(
17309 NodeFailureCode::WorktreeProtected,
17310 "selected workspace is not an eligible Git worktree in the source repository",
17311 ));
17312 }
17313 Ok(())
17314}
17315
17316fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17317 let code = match error.kind() {
17318 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17319 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17320 NodeFailureCode::RepositoryPathUnsafe
17321 }
17322 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17323 WorkspaceFileReadErrorKind::NotRegularFile => {
17324 NodeFailureCode::RepositoryFileNotRegular
17325 }
17326 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17327 NodeFailureCode::RepositoryFileReadFailed
17328 }
17329 WorkspaceFileReadErrorKind::RevisionConflict => {
17330 NodeFailureCode::RepositoryFileRevisionConflict
17331 }
17332 WorkspaceFileReadErrorKind::AlreadyExists
17333 | WorkspaceFileReadErrorKind::ParentNotFound
17334 | WorkspaceFileReadErrorKind::ParentNotDirectory
17335 | WorkspaceFileReadErrorKind::Canceled => {
17336 NodeFailureCode::RepositoryFileReadFailed
17337 }
17338 };
17339 failure(code, "repository file read failed")
17340}
17341
17342fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17343 let code = match error.kind() {
17344 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17345 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17346 NodeFailureCode::RepositoryPathUnsafe
17347 }
17348 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17349 WorkspaceFileReadErrorKind::NotRegularFile => {
17350 NodeFailureCode::RepositoryFileNotRegular
17351 }
17352 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17353 NodeFailureCode::RepositoryFileWriteFailed
17354 }
17355 WorkspaceFileReadErrorKind::RevisionConflict => {
17356 NodeFailureCode::RepositoryFileRevisionConflict
17357 }
17358 WorkspaceFileReadErrorKind::AlreadyExists
17359 | WorkspaceFileReadErrorKind::ParentNotFound
17360 | WorkspaceFileReadErrorKind::ParentNotDirectory
17361 | WorkspaceFileReadErrorKind::Canceled => {
17362 NodeFailureCode::RepositoryFileWriteFailed
17363 }
17364 };
17365 failure(code, "repository file write failed")
17366}
17367
17368async fn settle_workspace_entry_create<T>(
17369 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17370 commit_state: Arc<AtomicU8>,
17371 deadline: Duration,
17372 timeout_message: &'static str,
17373 task_failure_message: &'static str,
17374) -> Result<T, NodeFailure> {
17375 let joined = match timeout(deadline, &mut task).await {
17376 Ok(joined) => joined,
17377 Err(_) => {
17378 if commit_state
17379 .compare_exchange(
17380 WORKSPACE_ENTRY_CREATE_PENDING,
17381 WORKSPACE_ENTRY_CREATE_CANCELED,
17382 Ordering::AcqRel,
17383 Ordering::Acquire,
17384 )
17385 .is_ok()
17386 {
17387 return Err(failure(
17388 NodeFailureCode::RepositoryEntryCreateTimedOut,
17389 timeout_message,
17390 ));
17391 }
17392 debug_assert_eq!(
17393 commit_state.load(Ordering::Acquire),
17394 WORKSPACE_ENTRY_CREATE_COMMITTING,
17395 );
17396 task.await
17397 }
17398 };
17399 joined
17400 .map_err(|_| {
17401 failure(
17402 NodeFailureCode::RepositoryEntryCreateFailed,
17403 task_failure_message,
17404 )
17405 })?
17406 .map_err(workspace_entry_create_failure)
17407}
17408
17409fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17410 let code = match error.kind() {
17411 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17412 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17413 NodeFailureCode::RepositoryPathUnsafe
17414 }
17415 WorkspaceFileReadErrorKind::AlreadyExists => {
17416 NodeFailureCode::RepositoryEntryAlreadyExists
17417 }
17418 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17419 NodeFailureCode::RepositoryParentNotFound
17420 }
17421 WorkspaceFileReadErrorKind::ParentNotDirectory
17422 | WorkspaceFileReadErrorKind::NotRegularFile => {
17423 NodeFailureCode::RepositoryParentNotDirectory
17424 }
17425 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17426 NodeFailureCode::RepositoryEntryCreateFailed
17427 }
17428 WorkspaceFileReadErrorKind::RevisionConflict => {
17429 NodeFailureCode::RepositoryEntryCreateFailed
17430 }
17431 WorkspaceFileReadErrorKind::Canceled => {
17432 NodeFailureCode::RepositoryEntryCreateTimedOut
17433 }
17434 };
17435 failure(code, "repository entry create failed")
17436}
17437
17438fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17439 let value = digest(&SHA256, bytes)
17440 .as_ref()
17441 .iter()
17442 .map(|byte| format!("{byte:02x}"))
17443 .collect();
17444 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17445}
17446
17447fn managed_spawn_request_digest_v2(
17448 request: &ManagedWorktreeSpawnRequestV2,
17449) -> Result<String, NodeFailure> {
17450 let encoded = serde_json::to_vec(request).map_err(|_| {
17451 failure(
17452 NodeFailureCode::InvalidRequest,
17453 "managed worktree V2 request could not be canonicalized",
17454 )
17455 })?;
17456 let mut value = String::with_capacity(71);
17457 value.push_str("sha256:");
17458 for byte in digest(&SHA256, &encoded).as_ref() {
17459 use std::fmt::Write as _;
17460 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17461 }
17462 Ok(value)
17463}
17464
17465fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17466 let code = match error {
17467 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17468 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17469 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17470 NativeSessionPreviewError::UnsupportedProvider
17471 | NativeSessionPreviewError::PreviewUnavailable
17472 | NativeSessionPreviewError::SessionNotFound
17473 | NativeSessionPreviewError::AmbiguousSession => {
17474 NodeFailureCode::BackendOperationFailed
17475 }
17476 };
17477 failure(code, "native session preview failed")
17478}
17479
17480fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17481 let code = match error.kind() {
17482 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17483 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17484 };
17485 failure(code, "host directory browse failed")
17486}
17487
17488fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17489 let code = match error {
17490 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17491 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17492 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17493 NodeFailureCode::DeliveryStageConflict
17494 }
17495 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17496 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17497 NodeFailureCode::DeliveryChunkOutOfOrder
17498 }
17499 DeliveryStoreError::BlobDigestMismatch => {
17500 NodeFailureCode::DeliveryBlobDigestMismatch
17501 }
17502 DeliveryStoreError::BundleDigestMismatch => {
17503 NodeFailureCode::DeliveryBundleDigestMismatch
17504 }
17505 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17506 DeliveryStoreError::Unavailable
17507 | DeliveryStoreError::Corrupt
17508 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17509 };
17510 failure(code, "delivery operation failed")
17511}
17512
17513fn validate_workspace_root(
17514 workspace_id: &WorkspaceId,
17515 canonical_root: &str,
17516) -> Result<(), NodeServerError> {
17517 #[cfg(windows)]
17518 if canonical_root.starts_with(r"\\") {
17519 return Err(NodeServerError::InvalidWorkspaceRoot {
17520 workspace_id: workspace_id.clone(),
17521 path: canonical_root.to_owned(),
17522 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17523 });
17524 }
17525 if !platform::workspace_root_supported(canonical_root)
17526 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17527 {
17528 return Err(NodeServerError::InvalidWorkspaceRoot {
17529 workspace_id: workspace_id.clone(),
17530 path: canonical_root.to_owned(),
17531 message: "canonical path must be absolute, bounded, and free of control characters"
17532 .to_owned(),
17533 });
17534 }
17535 Ok(())
17536}
17537
17538fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17539 NodeFailure {
17540 code,
17541 message: node_failure_category(code).to_owned(),
17542 }
17543}
17544
17545fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17546 let code = match error {
17547 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17548 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17549 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17550 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17551 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17552 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17553 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17554 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17555 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17556 };
17557 failure(code, "harness MCP proxy operation failed")
17558}
17559
17560static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17564
17565fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17572 let trace = launch.trace.as_ref()?;
17573 let trace_dir = std::env::var(&trace.dir_env).ok()?;
17574 if trace_dir.is_empty() {
17575 return None;
17576 }
17577 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17578 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17579 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17580}
17581
17582fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17583 let remaining = deadline.saturating_duration_since(Instant::now());
17584 if remaining.is_zero() {
17585 Err(failure(
17586 NodeFailureCode::SpawnDeadlineExceeded,
17587 "spawn deadline elapsed",
17588 ))
17589 } else {
17590 Ok(remaining)
17591 }
17592}
17593
17594fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17595 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17596 match deadline {
17597 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17598 None => Ok(bounded),
17599 }
17600}
17601
17602fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17603 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17604 failure(
17605 NodeFailureCode::SpawnDeadlineExceeded,
17606 "spawn dispatch exceeded the spawn deadline",
17607 )
17608 } else {
17609 error
17610 }
17611}
17612
17613fn spawn_runtime_capabilities(
17614 capabilities: &SpawnRequiredCapabilities,
17615) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17616 capabilities
17617 .iter()
17618 .map(|capability| match capability.as_str() {
17619 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17620 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17621 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17622 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17623 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17624 }
17625 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17626 _ => Err(failure(
17627 NodeFailureCode::UnsupportedSpawnCapability,
17628 "spawn required capability is unknown",
17629 )),
17630 })
17631 .collect()
17632}
17633
17634fn persistence_failure(_error: io::Error) -> NodeFailure {
17635 NodeFailure {
17636 code: NodeFailureCode::BackendOperationFailed,
17637 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17638 }
17639}
17640
17641fn node_failure_category(code: NodeFailureCode) -> &'static str {
17642 match code {
17643 NodeFailureCode::InvalidRequest => "invalid-request",
17644 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17645 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17646 NodeFailureCode::ReservationNotFound => "reservation-not-found",
17647 NodeFailureCode::ReservationConflict => "reservation-conflict",
17648 NodeFailureCode::ReservationExpired => "reservation-expired",
17649 NodeFailureCode::BindingMismatch => "binding-mismatch",
17650 NodeFailureCode::NotActivated => "not-activated",
17651 NodeFailureCode::CallNotFound => "call-not-found",
17652 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17653 NodeFailureCode::ResponseTooLarge => "response-too-large",
17654 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17655 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17656 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17657 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17658 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17659 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17660 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17661 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17662 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17663 NodeFailureCode::Unauthorized => "unauthorized",
17664 NodeFailureCode::ObserverReadOnly => "observer-read-only",
17665 NodeFailureCode::ControllerBusy => "controller-busy",
17666 NodeFailureCode::ControllerRequired => "controller-required",
17667 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17668 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17669 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17670 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17671 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17672 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17673 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17674 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17675 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17676 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17677 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17678 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17679 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17680 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17681 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17682 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17683 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17684 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17685 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17686 NodeFailureCode::GitReadFailed => "git-read-failed",
17687 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17688 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17689 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17690 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17691 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17692 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17693 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17694 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17695 NodeFailureCode::TurnInFlight => "turn-in-flight",
17696 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17697 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17698 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17699 "unsupported-network-allowlist-mapping"
17700 }
17701 NodeFailureCode::BrowserStationProbeUnavailable => {
17702 "browser-station-probe-unavailable"
17703 }
17704 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17705 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17706 NodeFailureCode::EnvironmentProfileBindingMismatch => {
17707 "environment-profile-binding-mismatch"
17708 }
17709 NodeFailureCode::UnknownBundle => "unknown-bundle",
17710 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17711 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17712 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17713 NodeFailureCode::ContextPackBusy => "context-pack-busy",
17714 NodeFailureCode::ContextPackMaterializationFailed => {
17715 "context-pack-materialization-failed"
17716 }
17717 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17718 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17719 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17720 NodeFailureCode::WorkspaceBusy => "workspace-busy",
17721 NodeFailureCode::LastWorkspace => "last-workspace",
17722 NodeFailureCode::NotGitRepository => "not-git-repository",
17723 NodeFailureCode::WorktreeConflict => "worktree-conflict",
17724 NodeFailureCode::WorktreeProtected => "worktree-protected",
17725 NodeFailureCode::WorktreeDirty => "worktree-dirty",
17726 NodeFailureCode::WorktreeLocked => "worktree-locked",
17727 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17728 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17729 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17730 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17731 "managed-worktree-profile-revision-mismatch"
17732 }
17733 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17734 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17735 "standalone-workspace-recovery-required"
17736 }
17737 NodeFailureCode::UnknownSession => "unknown-session",
17738 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17739 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17740 NodeFailureCode::SessionRecordBusy => "session-record-busy",
17741 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17742 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17743 NodeFailureCode::WorkspaceRegistrationRequired => {
17744 "workspace-registration-required"
17745 }
17746 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17747 NodeFailureCode::StaleGeneration => "stale-generation",
17748 NodeFailureCode::BackendBusy => "backend-busy",
17749 NodeFailureCode::BackendDisconnected => "backend-disconnected",
17750 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17751 NodeFailureCode::ShuttingDown => "shutting-down",
17752 }
17753}
17754
17755fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17756 NodeServerError::DurableState(io::Error::new(error.kind(), category))
17757}
17758
17759fn durable_state_load_error(error: io::Error) -> NodeServerError {
17760 let category = match session_registry::state_load_refusal(&error) {
17761 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17762 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17763 }
17764 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17765 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17766 }
17767 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17768 DURABLE_STATE_CONFLICT_ERROR
17769 }
17770 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17771 };
17772 durable_state_server_error(error, category)
17773}
17774
17775fn unix_time_ms() -> u64 {
17776 std::time::SystemTime::now()
17777 .duration_since(std::time::UNIX_EPOCH)
17778 .unwrap_or_default()
17779 .as_millis()
17780 .min(u64::MAX as u128) as u64
17781}
17782
17783fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17784 if record.provider_session.is_some() {
17785 ManagedSessionState::Dormant
17786 } else {
17787 ManagedSessionState::Unavailable
17788 }
17789}
17790
17791fn active_record_state(
17792 identity_admitted: bool,
17793 provider_session_is_known: bool,
17794) -> ManagedSessionState {
17795 if identity_admitted && !provider_session_is_known {
17796 ManagedSessionState::IdentityPending
17797 } else {
17798 ManagedSessionState::Live
17799 }
17800}
17801
17802fn managed_worktree_record_holders(
17803 records: &[ManagedSessionRecord],
17804) -> Vec<(SessionRecordId, WorkspaceId)> {
17805 records
17806 .iter()
17807 .filter(|record| record.provider_session.is_some())
17808 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17809 .collect()
17810}
17811
17812fn managed_spawn_record_ownership(
17813 binding: &SessionBinding,
17814 effective_runtime_policy: ProviderRuntimePolicy,
17815) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17816 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17817 if effective_runtime_policy.provider_session_identity {
17818 (binding.record_id.clone(), None)
17819 } else {
17820 (None, binding.record_id.clone())
17821 }
17822}
17823
17824#[derive(Debug, Error)]
17825pub enum NodeServerError {
17826 #[error("node endpoint must be a bounded absolute local transport path")]
17827 InvalidEndpoint,
17828 #[error("node access token must contain 1..=4096 bytes")]
17829 InvalidAccessToken,
17830 #[error("node HTTP observer must listen on a loopback address: {0}")]
17831 InvalidApiListen(std::net::SocketAddr),
17832 #[error("node browser bridge must listen on a loopback address: {0}")]
17833 InvalidBridgeListen(std::net::SocketAddr),
17834 #[error("node browser bridge token must contain 1..=4096 bytes")]
17835 InvalidBridgeToken,
17836 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17837 BridgeUnderlayRequiresBridgeListen,
17838 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17839 BridgeUnderlayRequiresBridgeToken,
17840 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17841 InvalidBridgeUnderlayToken,
17842 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17843 InvalidBridgeUnderlayKey,
17844 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17845 InvalidCallHome(std::net::SocketAddr),
17846 #[error("node could not reach its call-home relay: {0}")]
17847 CallHomeTransport(#[source] std::io::Error),
17848 #[error("node durable state path must be an absolute file path: {0}")]
17849 InvalidStatePath(String),
17850 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17851 InvalidHarnessMcpHelper,
17852 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17853 InvalidNetworkAllowlistCatalogPath(String),
17854 #[error("network allowlist catalog could not be read: {0}")]
17855 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17856 #[error("network allowlist catalog line {line}: {message}")]
17857 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17858 #[error("network allowlist catalog: {0}")]
17859 InvalidNetworkAllowlistCatalog(String),
17860 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17861 NetworkAllowlistCatalogCapacity { max: usize },
17862 #[error("the local state directory is unavailable; supply an explicit state path")]
17863 LocalStateDirectoryUnavailable,
17864 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17865 LocalRuntimeDirectoryUnavailable,
17866 #[error("node durable state failed: {0}")]
17867 DurableState(io::Error),
17868 #[error("node requires at least one configured workspace")]
17869 NoWorkspaces,
17870 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17871 InvalidWorkspaceRoot {
17872 workspace_id: WorkspaceId,
17873 path: String,
17874 message: String,
17875 },
17876 #[error("duplicate workspace ID: {0}")]
17877 DuplicateWorkspaceId(WorkspaceId),
17878 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17879 DuplicateWorkspaceRoot {
17880 first: WorkspaceId,
17881 second: WorkspaceId,
17882 root: String,
17883 },
17884 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17885 ManagedWorktreeProfileRequired(WorkspaceId),
17886 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17887 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17888 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17889 DuplicateManagedWorktreeProfile {
17890 workspace_id: WorkspaceId,
17891 profile_id: WorktreeProfileId,
17892 },
17893 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17894 ManagedWorktreeProfileCapacity {
17895 workspace_id: WorkspaceId,
17896 max: usize,
17897 },
17898 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17899 InvalidManagedWorktreeProfile {
17900 workspace_id: WorkspaceId,
17901 profile_id: WorktreeProfileId,
17902 message: String,
17903 },
17904 #[error("duplicate node environment profile: {0}")]
17905 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17906 #[error("node environment profile provider is unavailable: {0}")]
17907 EnvironmentProfileProviderUnavailable(AgentId),
17908 #[error("node environment profile capacity is {max}")]
17909 EnvironmentProfileCapacity { max: usize },
17910 #[error("node environment profiles reuse native profile ID '{0}'")]
17911 DuplicateNativeEnvironmentProfile(String),
17912 #[error("node native environment profile is invalid: {0}")]
17913 NativeEnvironmentProfile(String),
17914 #[error("node session-environment materialization root must be absolute")]
17915 InvalidSessionEnvironmentRoot,
17916 #[error("node session-environment materializer is unavailable")]
17917 SessionEnvironmentMaterializerRequired,
17918 #[error("node session-environment materializer failed to initialize")]
17919 SessionEnvironmentMaterializer,
17920 #[error("node bundle catalog is invalid: {0}")]
17921 BundleCatalog(String),
17922 #[error("node delivery store failed to initialize: {0}")]
17923 DeliveryStore(String),
17924 #[error("node context pack store failed to initialize")]
17925 ContextPackStore,
17926 #[error("active agent registry failed: {0}")]
17927 Registry(String),
17928 #[error("node provider contract manifest is invalid: {0}")]
17929 ProviderContractManifest(String),
17930 #[error("named pipe I/O failed: {0}")]
17931 Io(#[from] io::Error),
17932 #[error("node HTTP observer failed: {0}")]
17933 HttpApi(io::Error),
17934 #[error("node browser bridge failed: {0}")]
17935 BridgeApi(io::Error),
17936 #[error(transparent)]
17937 Frame(#[from] FrameError),
17938 #[error("node handshake failed: {0}")]
17939 Handshake(String),
17940 #[error("node authentication frame was not received before the bounded deadline")]
17941 AuthenticationTimedOut,
17942 #[error("node authentication primitive failed: {0}")]
17943 Authentication(String),
17944 #[error("node incarnation identity generation failed: {0}")]
17945 IncarnationIdentity(String),
17946 #[error("node pre-authentication limiter is closed")]
17947 PreauthClosed,
17948 #[error("node authenticated connection limit was reached")]
17949 AuthenticatedConnectionLimit,
17950 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17951 SignalStreamClosed,
17952 #[error("node stop-all dispatch failed: {0:?}")]
17953 ShutdownDispatch(NodeFailure),
17954 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17955 ShutdownTimedOut { active_native_sessions: usize },
17956}
17957
17958impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17959 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17960 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17961 match error {
17962 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17963 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17964 E::InvalidEntry { line, message } => {
17965 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17966 }
17967 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17968 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17969 }
17970 }
17971}
17972
17973#[cfg(test)]
17974mod agent_stream_blocked_tests {
17975 use super::*;
17976 use gate4agent_types::{
17977 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17978 ProviderSource,
17979 };
17980
17981 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17982 ProviderSource {
17983 family,
17984 binding: AdapterBinding::new(
17985 AdapterId::new(adapter).unwrap(),
17986 "fixture/v1",
17987 AdapterVerification::SyntheticFixture,
17988 )
17989 .unwrap(),
17990 }
17991 }
17992
17993 fn provider_control_event_at(
17994 family: AdapterFamily,
17995 adapter: &str,
17996 source_sequence: u64,
17997 provider_event: ProviderEvent,
17998 ) -> ControlEvent {
17999 ControlEvent {
18000 sequence: 41,
18001 command_id: None,
18002 instance_id: AgentInstanceId(7),
18003 generation: SessionGeneration(3),
18004 event: ControlEventKind::ProviderEvent {
18005 sequence: 8,
18006 source: provider_source(family, adapter),
18007 source_sequence,
18008 event: provider_event,
18009 },
18010 }
18011 }
18012
18013 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18014 provider_control_event_at(family, "fixture", 9, provider_event)
18015 }
18016
18017 fn denied_host_request(
18018 by: ProviderHostDecisionAuthority,
18019 reason: Option<String>,
18020 ) -> ControlEvent {
18021 provider_control_event_at(
18022 AdapterFamily::Acp,
18023 "claude-code",
18024 2,
18025 ProviderEvent::HostRequestObserved {
18026 method: "terminal/create".to_owned(),
18027 params_json: String::new(),
18028 decision: ProviderHostRequestDecision::Denied { by },
18029 outcome: ProviderHostRequestOutcome::Executed,
18030 reason,
18031 },
18032 )
18033 }
18034
18035 fn blocked(
18036 event: &ControlEvent,
18037 ) -> (
18038 Option<String>,
18039 String,
18040 BlockAuthorityV1,
18041 Option<String>,
18042 String,
18043 Option<String>,
18044 ) {
18045 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18046 chunk.validate().expect("minted chunk must validate");
18047 let AgentStreamChunkKindV1::Blocked {
18048 correlation_id,
18049 tool_class,
18050 authority,
18051 reason_kind,
18052 reason,
18053 help,
18054 } = chunk.kind
18055 else {
18056 panic!("expected AgentStreamChunkKindV1::Blocked");
18057 };
18058 (correlation_id, tool_class, authority, reason_kind, reason, help)
18059 }
18060
18061 #[test]
18065 fn the_enabled_provider_matrix_is_exact() {
18066 let registry = active_registry().unwrap();
18067 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18068 assert_eq!(
18069 providers
18070 .iter()
18071 .map(|contract| contract.provider.as_str())
18072 .collect::<Vec<_>>(),
18073 vec!["shell", "claude", "codex", "grok", "kimi"],
18075 );
18076 assert!(adapters.iter().all(|contract| {
18077 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18078 }));
18079 }
18080
18081 #[test]
18084 fn gate_denied_host_request_mints_a_blocked_chunk() {
18085 let gate_text =
18086 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18087 let event = denied_host_request(
18088 ProviderHostDecisionAuthority::Gate,
18089 Some(gate_text.to_owned()),
18090 );
18091 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18092 assert_eq!(chunk.source_sequence, 2);
18093 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18094 assert_eq!(authority, BlockAuthorityV1::HostGate);
18095 assert_eq!(reason, gate_text);
18096 assert_eq!(correlation_id, None);
18097 assert_eq!(reason_kind, None);
18098 assert_eq!(help, None);
18099 assert_eq!(tool_class, "Write");
18100 }
18101
18102 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.";
18103
18104 #[test]
18110 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18111 let event = provider_control_event(
18112 AdapterFamily::Acp,
18113 ProviderEvent::ToolCompleted {
18114 id: "toolu_9".to_owned(),
18115 output: CLASSIFIER_SAMPLE.to_owned(),
18116 is_error: true,
18117 duration_ms: None,
18118 agent_id: None,
18119 non_execution_kind: Some("permission-rule".to_owned()),
18120 },
18121 );
18122 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18123 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18124 assert_eq!(reason, "Blocked by classifier");
18125 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18126 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18127 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18128 }
18129
18130 #[test]
18135 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18136 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.";
18137 let event = provider_control_event(
18138 AdapterFamily::Acp,
18139 ProviderEvent::SessionEnded {
18140 result: "JSON-RPC error -32603: Internal error".to_owned(),
18141 cost_usd: None,
18142 is_error: true,
18143 stop_reason: Some(ProviderStopReason::ProviderError {
18144 code: -32603,
18145 message: message.to_owned(),
18146 vendor_code: Some("usageLimitExceeded".to_owned()),
18147 }),
18148 },
18149 );
18150 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18151 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18152 assert_eq!(reason, message);
18153 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18154 assert_eq!(tool_class, "Session");
18155 }
18156
18157 #[test]
18160 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18161 let event = provider_control_event(
18162 AdapterFamily::Acp,
18163 ProviderEvent::SessionEnded {
18164 result: "refusal".to_owned(),
18165 cost_usd: None,
18166 is_error: true,
18167 stop_reason: Some(ProviderStopReason::Refusal),
18168 },
18169 );
18170 let (_, _, authority, _, _, _) = blocked(&event);
18171 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18172 }
18173
18174 #[test]
18178 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18179 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18180 let event = provider_control_event(
18181 AdapterFamily::Acp,
18182 ProviderEvent::ToolCompleted {
18183 id: "toolu_1".to_owned(),
18184 output: "command exited with status 1".to_owned(),
18185 is_error: true,
18186 duration_ms: Some(12),
18187 agent_id: None,
18188 non_execution_kind,
18189 },
18190 );
18191 assert!(agent_stream_chunk(&event).is_none());
18192 }
18193 let narrative = provider_control_event(
18194 AdapterFamily::Acp,
18195 ProviderEvent::ToolCompleted {
18196 id: "toolu_2".to_owned(),
18197 output: "the harness blocked the write, approval needed".to_owned(),
18198 is_error: false,
18199 duration_ms: Some(3),
18200 agent_id: None,
18201 non_execution_kind: None,
18202 },
18203 );
18204 assert!(agent_stream_chunk(&narrative).is_none());
18205 }
18206
18207 #[test]
18211 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18212 for (decision, outcome) in [
18213 (
18214 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18215 ProviderHostRequestOutcome::Executed,
18216 ),
18217 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18218 (
18219 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18220 ProviderHostRequestOutcome::Failed {
18221 error: "terminal/create spawn failed: os error 3".to_owned(),
18222 },
18223 ),
18224 ] {
18225 let event = provider_control_event_at(
18226 AdapterFamily::Acp,
18227 "claude-code",
18228 3,
18229 ProviderEvent::HostRequestObserved {
18230 method: "fs/read_text_file".to_owned(),
18231 params_json: String::new(),
18232 decision: decision.clone(),
18233 outcome,
18234 reason: None,
18235 },
18236 );
18237 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18238 }
18239 }
18240
18241 #[test]
18245 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18246 for (by, expected_authority, expected_reason) in [
18247 (
18248 ProviderHostDecisionAuthority::Operator,
18249 BlockAuthorityV1::Operator,
18250 "declined by the operator",
18251 ),
18252 (
18253 ProviderHostDecisionAuthority::DeadlinePolicy,
18254 BlockAuthorityV1::HostDeadline,
18255 "denied by host policy after the operator deadline expired",
18256 ),
18257 ] {
18258 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18259 assert_eq!(authority, expected_authority);
18260 assert_eq!(reason, expected_reason);
18261 }
18262 }
18263
18264 #[test]
18268 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18269 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18270 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18271 ProviderHostDecisionAuthority::Policy,
18272 Some(oversized),
18273 ));
18274 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18275 }
18276}
18277
18278#[cfg(test)]
18279mod standalone_capability_platform_tests {
18280 use super::*;
18281
18282 #[test]
18283 fn standalone_workspace_capability_requires_windows_identity_guards() {
18284 let capabilities = baseline_capabilities().unwrap();
18285 let advertised = capabilities.iter().any(|capability| {
18286 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18287 });
18288 assert_eq!(advertised, cfg!(windows));
18289
18290 let request = NodeRequest::CreateStandaloneWorkspace {
18291 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18292 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18293 initial_branch: None,
18294 };
18295 assert_eq!(
18296 request_uses_unnegotiated_capability(&request, &capabilities),
18297 !cfg!(windows),
18298 );
18299 }
18300}
18301
18302
18303#[cfg(test)]
18304mod station_knobs_resolve_tests {
18305 use super::*;
18306 use gate4agent_catalog::EnvMutation;
18307 use gate4agent_runtime_native::{
18308 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18309 NativeLaunchProfileId,
18310 };
18311 use std::ffi::OsString;
18312 use std::sync::Arc;
18313
18314 struct EmptyEnvironmentResolver;
18315
18316 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18317 fn resolve_child_environment(
18318 &self,
18319 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18320 Ok(vec![EnvMutation {
18321 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18322 value: None,
18323 }])
18324 }
18325 }
18326
18327 fn agent(value: &str) -> AgentId {
18328 AgentId::new(value).unwrap()
18329 }
18330
18331 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18332 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18333 let profile_revision =
18334 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18335 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18336 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18337 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18338 provider: agent("claude"),
18339 mode: SessionMode::Pty,
18340 terminal_size: gate4agent_types::TerminalSize {
18341 rows: 24,
18342 columns: 80,
18343 },
18344 prompt: None,
18345 bundle_id: None,
18346 context_id: None,
18347 environment_profile_id: Some(profile_id.clone()),
18348 };
18349 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18350 let catalog = active_registry().unwrap();
18351 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18352 let control = runtime.native_launch_profile_control();
18353 let node_profile = NodeEnvironmentProfile::new(
18354 profile_id.clone(),
18355 profile_revision,
18356 agent("claude"),
18357 [NativeLaunchProfile::new(
18358 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18359 agent("claude"),
18360 TransportKind::Pty,
18361 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18362 Arc::new(EmptyEnvironmentResolver),
18363 )
18364 .unwrap()],
18365 )
18366 .unwrap();
18367 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18368 for native_profile in native_profiles {
18369 runtime.upsert_native_launch_profile(native_profile).unwrap();
18370 }
18371 let workspace = WorkspaceConfig::new(
18372 WorkspaceId::new("primary").unwrap(),
18373 std::env::current_dir().unwrap(),
18374 )
18375 .unwrap();
18376 let shared = NodeShared::new_with_incarnation(
18377 handle,
18378 "fixture-token".to_owned(),
18379 NodeId::new("node-station-knobs-test").unwrap(),
18380 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18381 vec![workspace],
18382 vec![agent("claude")],
18383 ProviderRuntimeStatuses::default(),
18384 None,
18385 Vec::new(),
18386 Vec::new(),
18387 spawn_profiles,
18388 Some(control),
18389 None,
18390 None,
18391 None,
18392 Vec::new(),
18393 Vec::new(),
18394 Vec::new(),
18395 Vec::new(),
18396 None,
18397 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18398 );
18399 shared
18400 .environment_profiles
18401 .write()
18402 .unwrap_or_else(|poisoned| poisoned.into_inner())
18403 .insert(binding.id.clone(), binding);
18404 (shared, spawn_defaults)
18405 }
18406
18407 #[test]
18410 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18411 let (shared, defaults) = station_knobs_fixture();
18412 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18413 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18414 let spec = crate::protocol::SpawnSpec {
18415 target: crate::protocol::SpawnTarget {
18416 node_id: shared.node_id.clone(),
18417 workspace_id: WorkspaceId::new("primary").unwrap(),
18418 worktree_id: None,
18419 },
18420 profile_id: defaults.profile_id.clone(),
18421 expected_profile_revision: defaults.revision.clone(),
18422 overrides: crate::protocol::SpawnOverrides {
18423 network_allowlist: Some(unknown),
18424 browser_profile_id: Some(browser),
18425 ..crate::protocol::SpawnOverrides::default()
18426 },
18427 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18428 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18429 "station-knobs-unknown-allowlist",
18430 )
18431 .unwrap(),
18432 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18433 };
18434 let resolved = spec.resolve(&defaults).unwrap();
18435 let failure = shared
18436 .resolve_environment_profile(&resolved)
18437 .expect_err("unknown allowlist must refuse");
18438 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18439 }
18440
18441 #[test]
18447 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18448 let (shared, defaults) = station_knobs_fixture();
18449 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18450 #[cfg(not(feature = "dig2-station-probe"))]
18451 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18452 shared
18453 .network_allowlist_catalog
18454 .write()
18455 .unwrap_or_else(|poisoned| poisoned.into_inner())
18456 .insert_id(allowlist.clone())
18457 .unwrap();
18458 let spec = crate::protocol::SpawnSpec {
18459 target: crate::protocol::SpawnTarget {
18460 node_id: shared.node_id.clone(),
18461 workspace_id: WorkspaceId::new("primary").unwrap(),
18462 worktree_id: None,
18463 },
18464 profile_id: defaults.profile_id.clone(),
18465 expected_profile_revision: defaults.revision.clone(),
18466 overrides: crate::protocol::SpawnOverrides {
18467 network_allowlist: Some(allowlist.clone()),
18468 #[cfg(not(feature = "dig2-station-probe"))]
18469 browser_profile_id: Some(browser.clone()),
18470 ..crate::protocol::SpawnOverrides::default()
18471 },
18472 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18473 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18474 .unwrap(),
18475 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18476 };
18477 let resolved = spec.resolve(&defaults).unwrap();
18478 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18479 #[cfg(not(feature = "dig2-station-probe"))]
18480 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18481 let receipt = shared
18482 .resolve_environment_profile(&resolved)
18483 .expect("resolve ok")
18484 .expect("env profile present");
18485 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18486 #[cfg(not(feature = "dig2-station-probe"))]
18487 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18488 #[cfg(feature = "dig2-station-probe")]
18489 assert_eq!(receipt.browser_profile_id, None);
18490 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18491
18492 let omitted = crate::protocol::SpawnSpec {
18493 overrides: crate::protocol::SpawnOverrides::default(),
18494 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18495 .unwrap(),
18496 ..spec
18497 };
18498 let resolved_omit = omitted.resolve(&defaults).unwrap();
18499 let bare = shared
18500 .resolve_environment_profile(&resolved_omit)
18501 .expect("resolve ok")
18502 .expect("env profile present");
18503 assert_eq!(bare.network_allowlist, None);
18504 assert_eq!(bare.browser_profile_id, None);
18505 }
18506
18507 #[cfg(feature = "dig2-station-probe")]
18512 #[test]
18513 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18514 let (shared, defaults) = station_knobs_fixture();
18515 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18516 let spec = crate::protocol::SpawnSpec {
18517 target: crate::protocol::SpawnTarget {
18518 node_id: shared.node_id.clone(),
18519 workspace_id: WorkspaceId::new("primary").unwrap(),
18520 worktree_id: None,
18521 },
18522 profile_id: defaults.profile_id.clone(),
18523 expected_profile_revision: defaults.revision.clone(),
18524 overrides: crate::protocol::SpawnOverrides {
18525 browser_profile_id: Some(browser),
18526 ..crate::protocol::SpawnOverrides::default()
18527 },
18528 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18529 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18530 "station-knobs-probe-refuse",
18531 )
18532 .unwrap(),
18533 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18534 };
18535 let resolved = spec.resolve(&defaults).unwrap();
18536 let failure = shared
18537 .resolve_environment_profile(&resolved)
18538 .expect_err("browser_profile with failing probe must refuse");
18539 #[cfg(windows)]
18540 {
18541 assert!(
18542 matches!(
18543 failure.code,
18544 NodeFailureCode::BrowserStationUnreachable
18545 | NodeFailureCode::BrowserStationProbeUnavailable
18546 ),
18547 "unexpected code {:?}",
18548 failure.code
18549 );
18550 }
18551 #[cfg(not(windows))]
18552 {
18553 assert_eq!(
18554 failure.code,
18555 NodeFailureCode::BrowserStationProbeUnavailable
18556 );
18557 }
18558 }
18559
18560 #[cfg(feature = "dig2-station-probe")]
18562 #[test]
18563 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18564 let (shared, defaults) = station_knobs_fixture();
18565 let spec = crate::protocol::SpawnSpec {
18566 target: crate::protocol::SpawnTarget {
18567 node_id: shared.node_id.clone(),
18568 workspace_id: WorkspaceId::new("primary").unwrap(),
18569 worktree_id: None,
18570 },
18571 profile_id: defaults.profile_id.clone(),
18572 expected_profile_revision: defaults.revision.clone(),
18573 overrides: crate::protocol::SpawnOverrides::default(),
18574 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18575 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18576 "station-knobs-probe-omit",
18577 )
18578 .unwrap(),
18579 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18580 };
18581 let resolved = spec.resolve(&defaults).unwrap();
18582 let receipt = shared
18583 .resolve_environment_profile(&resolved)
18584 .expect("omit browser_profile must not probe")
18585 .expect("env profile present");
18586 assert_eq!(receipt.browser_profile_id, None);
18587 }
18588
18589 #[cfg(feature = "dig2-station-probe")]
18593 #[test]
18594 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18595 let (shared, _defaults) = station_knobs_fixture();
18596 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18597 let workspace_id = WorkspaceId::new("primary").unwrap();
18598 let address_a = SessionAddress {
18599 workspace_id: workspace_id.clone(),
18600 session: SessionKey {
18601 instance_id: AgentInstanceId(101),
18602 generation: SessionGeneration(1),
18603 },
18604 };
18605 let address_b = SessionAddress {
18606 workspace_id: workspace_id.clone(),
18607 session: SessionKey {
18608 instance_id: AgentInstanceId(102),
18609 generation: SessionGeneration(1),
18610 },
18611 };
18612 let guard_a = crate::dig2_station_lease::acquire_lease(
18613 &shared.browser_station_leases,
18614 profile.clone(),
18615 address_a.session.instance_id,
18616 1,
18617 )
18618 .expect("first lease");
18619 let busy = crate::dig2_station_lease::acquire_lease(
18620 &shared.browser_station_leases,
18621 profile.clone(),
18622 address_b.session.instance_id,
18623 2,
18624 )
18625 .expect_err("second holder must refuse");
18626 assert!(matches!(
18627 busy,
18628 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18629 ));
18630 shared.bind_session_with_policy(
18632 &address_a,
18633 ProviderRuntimePolicy::raw_pty(),
18634 None,
18635 );
18636 guard_a.retain();
18637 assert!(shared.remove_binding(&address_a).is_some());
18638 crate::dig2_station_lease::acquire_lease(
18639 &shared.browser_station_leases,
18640 profile,
18641 address_b.session.instance_id,
18642 3,
18643 )
18644 .expect("reacquire after clear")
18645 .retain();
18646 assert!(
18647 crate::dig2_station_lease::release_lease_for_holder(
18648 &shared.browser_station_leases,
18649 address_b.session.instance_id,
18650 )
18651 );
18652 }
18653
18654 #[cfg(not(feature = "dig2-station-probe"))]
18657 #[test]
18658 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18659 let (shared, defaults) = station_knobs_fixture();
18660 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18661 let spec = crate::protocol::SpawnSpec {
18662 target: crate::protocol::SpawnTarget {
18663 node_id: shared.node_id.clone(),
18664 workspace_id: WorkspaceId::new("primary").unwrap(),
18665 worktree_id: None,
18666 },
18667 profile_id: defaults.profile_id.clone(),
18668 expected_profile_revision: defaults.revision.clone(),
18669 overrides: crate::protocol::SpawnOverrides {
18670 browser_profile_id: Some(browser.clone()),
18671 ..crate::protocol::SpawnOverrides::default()
18672 },
18673 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18674 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18675 .unwrap(),
18676 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18677 };
18678 let resolved = spec.resolve(&defaults).unwrap();
18679 let receipt = shared
18680 .resolve_environment_profile(&resolved)
18681 .expect("stub resolve")
18682 .expect("env profile");
18683 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18684 }
18685
18686
18687 #[test]
18690 fn network_allowlist_catalog_loads_from_temp_file() {
18691 let unique = std::time::SystemTime::now()
18692 .duration_since(std::time::UNIX_EPOCH)
18693 .unwrap()
18694 .as_nanos();
18695 let path = std::env::temp_dir().join(format!(
18696 "gate4agent-node-allowlist-config-{}-{unique}.txt",
18697 std::process::id(),
18698 ));
18699 std::fs::write(
18700 &path,
18701 "# opaque station allowlist ids\negress-default\nlab-egress\n",
18702 )
18703 .unwrap();
18704 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18705 .expect("temp catalog file must load");
18706 let _ = std::fs::remove_file(&path);
18707 assert_eq!(loaded.len(), 2);
18708 let (shared, defaults) = station_knobs_fixture();
18709 {
18710 let mut catalog = shared
18711 .network_allowlist_catalog
18712 .write()
18713 .unwrap_or_else(|poisoned| poisoned.into_inner());
18714 *catalog = loaded;
18715 }
18716 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18717 let spec = crate::protocol::SpawnSpec {
18718 target: crate::protocol::SpawnTarget {
18719 node_id: shared.node_id.clone(),
18720 workspace_id: WorkspaceId::new("primary").unwrap(),
18721 worktree_id: None,
18722 },
18723 profile_id: defaults.profile_id.clone(),
18724 expected_profile_revision: defaults.revision.clone(),
18725 overrides: crate::protocol::SpawnOverrides {
18726 network_allowlist: Some(allowlist.clone()),
18727 ..crate::protocol::SpawnOverrides::default()
18728 },
18729 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18730 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18731 "station-knobs-catalog-file",
18732 )
18733 .unwrap(),
18734 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18735 };
18736 let resolved = spec.resolve(&defaults).unwrap();
18737 let receipt = shared
18738 .resolve_environment_profile(&resolved)
18739 .expect("registered via file load")
18740 .expect("env profile present");
18741 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18742 }
18743
18744 #[test]
18747 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18748 let (shared, _defaults) = station_knobs_fixture();
18749 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18750 shared
18751 .network_allowlist_catalog
18752 .write()
18753 .unwrap_or_else(|poisoned| poisoned.into_inner())
18754 .insert_id(allowlist.clone())
18755 .unwrap();
18756 let snapshot = shared.snapshot();
18757 let inventory = snapshot
18758 .launch_inventory
18759 .expect("launch inventory present");
18760 let ids = inventory
18761 .network_allowlists
18762 .expect("non-empty catalog surfaces ids");
18763 assert_eq!(ids, vec![allowlist]);
18764 }
18765
18766 #[test]
18770 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18771 let (shared, defaults) = station_knobs_fixture();
18772 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18773 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18774 id: allowlist.clone(),
18775 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18776 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18777 peer: "127.0.0.1:443".to_owned(),
18778 }],
18779 provider_native: Some(
18780 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18781 codex_network_access: Some(true),
18782 },
18783 ),
18784 };
18785 shared
18786 .network_allowlist_catalog
18787 .write()
18788 .unwrap_or_else(|poisoned| poisoned.into_inner())
18789 .insert(entry)
18790 .unwrap();
18791 let spec = crate::protocol::SpawnSpec {
18792 target: crate::protocol::SpawnTarget {
18793 node_id: shared.node_id.clone(),
18794 workspace_id: WorkspaceId::new("primary").unwrap(),
18795 worktree_id: None,
18796 },
18797 profile_id: defaults.profile_id.clone(),
18798 expected_profile_revision: defaults.revision.clone(),
18799 overrides: crate::protocol::SpawnOverrides {
18800 network_allowlist: Some(allowlist),
18801 ..crate::protocol::SpawnOverrides::default()
18802 },
18803 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18804 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18805 "station-knobs-unsupported-mapping",
18806 )
18807 .unwrap(),
18808 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18809 };
18810 let resolved = spec.resolve(&defaults).unwrap();
18811 assert_eq!(resolved.provider.as_str(), "claude");
18812 let failure = shared
18813 .resolve_environment_profile(&resolved)
18814 .expect_err("claude + codex_network_access must refuse");
18815 assert_eq!(
18816 failure.code,
18817 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18818 );
18819 }
18820
18821 #[test]
18825 fn resolve_codex_network_access_overlay_moderate_only() {
18826 let (shared, defaults) = station_knobs_fixture();
18827 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18828 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18829 id: allowlist.clone(),
18830 permits: Vec::new(),
18831 provider_native: Some(
18832 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18833 codex_network_access: Some(true),
18834 },
18835 ),
18836 };
18837 shared
18838 .network_allowlist_catalog
18839 .write()
18840 .unwrap_or_else(|poisoned| poisoned.into_inner())
18841 .insert(entry)
18842 .unwrap();
18843
18844 let overlay = shared
18845 .resolve_codex_network_access_overlay_args(
18846 &agent("codex"),
18847 SessionMode::Pty,
18848 ApprovalLevel::Moderate,
18849 Some(&allowlist),
18850 )
18851 .expect("Moderate Codex maps network_access");
18852 assert_eq!(
18853 overlay,
18854 vec![
18855 std::ffi::OsString::from("-c"),
18856 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18857 ]
18858 );
18859
18860 let acp = shared
18861 .resolve_codex_network_access_overlay_args(
18862 &agent("codex"),
18863 SessionMode::Acp,
18864 ApprovalLevel::Moderate,
18865 Some(&allowlist),
18866 )
18867 .unwrap();
18868 assert_eq!(acp, overlay);
18869
18870 let inline = shared
18871 .resolve_codex_network_access_overlay_args(
18872 &agent("codex"),
18873 SessionMode::Inline,
18874 ApprovalLevel::Moderate,
18875 Some(&allowlist),
18876 )
18877 .unwrap_err();
18878 assert_eq!(
18879 inline.code,
18880 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18881 );
18882
18883 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18884 let spec = crate::protocol::SpawnSpec {
18885 target: crate::protocol::SpawnTarget {
18886 node_id: shared.node_id.clone(),
18887 workspace_id: WorkspaceId::new("primary").unwrap(),
18888 worktree_id: None,
18889 },
18890 profile_id: defaults.profile_id.clone(),
18891 expected_profile_revision: defaults.revision.clone(),
18892 overrides: crate::protocol::SpawnOverrides {
18893 provider: crate::protocol::SpawnOverride::Set {
18894 value: agent("codex"),
18895 },
18896 approval_level: Some(level),
18897 network_allowlist: Some(allowlist.clone()),
18898 ..crate::protocol::SpawnOverrides::default()
18899 },
18900 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18901 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18902 "codex-net-refuse-{}",
18903 match level {
18904 ApprovalLevel::ReadOnly => "ro",
18905 ApprovalLevel::FullAuto => "fa",
18906 _ => "other",
18907 }
18908 ))
18909 .unwrap(),
18910 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18911 };
18912 let resolved = spec.resolve(&defaults).unwrap();
18913 let failure = shared
18914 .resolve_environment_profile(&resolved)
18915 .expect_err("RO/FullAuto must refuse");
18916 assert_eq!(
18917 failure.code,
18918 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18919 );
18920 }
18921 }
18922
18923 #[test]
18927 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18928 let (shared, _defaults) = station_knobs_fixture();
18929 let allowlist =
18930 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18931 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18932 id: allowlist.clone(),
18933 permits: vec![
18934 crate::network_allowlist_catalog::NetworkPermitSketch {
18935 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18936 peer: "github.com:443".to_owned(),
18937 },
18938 crate::network_allowlist_catalog::NetworkPermitSketch {
18939 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18940 peer: "*.npmjs.org".to_owned(),
18941 },
18942 ],
18943 provider_native: None,
18944 };
18945 shared
18946 .network_allowlist_catalog
18947 .write()
18948 .unwrap_or_else(|poisoned| poisoned.into_inner())
18949 .insert(entry)
18950 .unwrap();
18951
18952 let unique = std::time::SystemTime::now()
18953 .duration_since(std::time::UNIX_EPOCH)
18954 .unwrap()
18955 .as_nanos();
18956 let provider_home = std::env::temp_dir().join(format!(
18957 "gate4agent-claude-provider-home-{}-{unique}",
18958 std::process::id(),
18959 ));
18960 std::fs::create_dir_all(&provider_home).unwrap();
18961
18962 let overlay = shared
18963 .resolve_network_allowlist_overlay_args(
18964 &agent("claude"),
18965 SessionMode::Pty,
18966 ApprovalLevel::Moderate,
18967 Some(&allowlist),
18968 Some(provider_home.as_path()),
18969 )
18970 .expect("Claude PTY settings overlay on unix");
18971 let settings_path = provider_home.join(
18972 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18973 );
18974 assert_eq!(
18975 overlay,
18976 vec![
18977 std::ffi::OsString::from("--settings"),
18978 std::ffi::OsString::from(settings_path.as_os_str()),
18979 ]
18980 );
18981 let body = std::fs::read_to_string(&settings_path).unwrap();
18982 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18983 assert_eq!(value["sandbox"]["enabled"], true);
18984 assert_eq!(
18985 value["sandbox"]["network"]["allowedDomains"],
18986 serde_json::json!(["github.com", "*.npmjs.org"])
18987 );
18988 assert!(settings_path.starts_with(&provider_home));
18989
18990 let acp = shared
18991 .resolve_network_allowlist_overlay_args(
18992 &agent("claude"),
18993 SessionMode::Acp,
18994 ApprovalLevel::Moderate,
18995 Some(&allowlist),
18996 Some(provider_home.as_path()),
18997 )
18998 .expect_err("Claude ACP must refuse settings overlay");
18999 assert_eq!(
19000 acp.code,
19001 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19002 );
19003
19004 let no_home = shared
19005 .resolve_network_allowlist_overlay_args(
19006 &agent("claude"),
19007 SessionMode::Pty,
19008 ApprovalLevel::Moderate,
19009 Some(&allowlist),
19010 None,
19011 )
19012 .expect_err("Claude overlay requires ProviderHome");
19013 assert_eq!(
19014 no_home.code,
19015 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19016 );
19017
19018 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19019 shared
19020 .network_allowlist_catalog
19021 .write()
19022 .unwrap_or_else(|poisoned| poisoned.into_inner())
19023 .insert_id(bare.clone())
19024 .unwrap();
19025 let empty = shared
19026 .resolve_network_allowlist_overlay_args(
19027 &agent("claude"),
19028 SessionMode::Pty,
19029 ApprovalLevel::Moderate,
19030 Some(&bare),
19031 Some(provider_home.as_path()),
19032 )
19033 .unwrap();
19034 assert!(empty.is_empty());
19035
19036 let _ = std::fs::remove_dir_all(&provider_home);
19037 }
19038
19039 #[test]
19041 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19042 let (shared, _defaults) = station_knobs_fixture();
19043 let allowlist =
19044 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19045 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19046 id: allowlist.clone(),
19047 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19048 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19049 peer: "example.com:443".to_owned(),
19050 }],
19051 provider_native: None,
19052 };
19053 shared
19054 .network_allowlist_catalog
19055 .write()
19056 .unwrap_or_else(|poisoned| poisoned.into_inner())
19057 .insert(entry)
19058 .unwrap();
19059 for provider in ["kimi", "grok"] {
19060 let overlay = shared
19061 .resolve_network_allowlist_overlay_args(
19062 &agent(provider),
19063 SessionMode::Pty,
19064 ApprovalLevel::Moderate,
19065 Some(&allowlist),
19066 None,
19067 )
19068 .unwrap();
19069 assert!(
19070 overlay.is_empty(),
19071 "{provider} must not invent network argv from permits"
19072 );
19073 }
19074 }
19075
19076 #[test]
19078 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19079 let unique = std::time::SystemTime::now()
19080 .duration_since(std::time::UNIX_EPOCH)
19081 .unwrap()
19082 .as_nanos();
19083 let path = std::env::temp_dir().join(format!(
19084 "gate4agent-node-allowlist-json-{}-{unique}.json",
19085 std::process::id(),
19086 ));
19087 std::fs::write(
19088 &path,
19089 r#"{
19090 "schema_version": 2,
19091 "entries": [
19092 {
19093 "id": "egress-default",
19094 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19095 },
19096 { "id": "lab-egress" }
19097 ]
19098 }"#,
19099 )
19100 .unwrap();
19101 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19102 .expect("json v2 catalog must load");
19103 let _ = std::fs::remove_file(&path);
19104 assert_eq!(loaded.len(), 2);
19105 let entry = loaded
19106 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19107 .unwrap();
19108 assert_eq!(entry.permits.len(), 1);
19109 let (shared, defaults) = station_knobs_fixture();
19110 {
19111 let mut catalog = shared
19112 .network_allowlist_catalog
19113 .write()
19114 .unwrap_or_else(|poisoned| poisoned.into_inner());
19115 *catalog = loaded;
19116 }
19117 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19118 let spec = crate::protocol::SpawnSpec {
19119 target: crate::protocol::SpawnTarget {
19120 node_id: shared.node_id.clone(),
19121 workspace_id: WorkspaceId::new("primary").unwrap(),
19122 worktree_id: None,
19123 },
19124 profile_id: defaults.profile_id.clone(),
19125 expected_profile_revision: defaults.revision.clone(),
19126 overrides: crate::protocol::SpawnOverrides {
19127 network_allowlist: Some(allowlist.clone()),
19128 ..crate::protocol::SpawnOverrides::default()
19129 },
19130 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19131 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19132 "station-knobs-catalog-json",
19133 )
19134 .unwrap(),
19135 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19136 };
19137 let resolved = spec.resolve(&defaults).unwrap();
19138 let receipt = shared
19139 .resolve_environment_profile(&resolved)
19140 .expect("permits-only entry ok for claude")
19141 .expect("env profile present");
19142 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19143 let inventory = shared
19144 .snapshot()
19145 .launch_inventory
19146 .expect("launch inventory present");
19147 let ids = inventory.network_allowlists.expect("ids surface");
19148 assert_eq!(
19149 ids,
19150 vec![
19151 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19152 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19153 ]
19154 );
19155 }
19156
19157
19158}
19159
19160
19161#[cfg(test)]
19162mod workspace_environment_collision_tests {
19163 use super::*;
19164 use crate::session_environment::{
19165 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19166 };
19167 use gate4agent_catalog::EnvMutation;
19168 use gate4agent_runtime_native::{
19169 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19170 NativeLaunchProfileId,
19171 };
19172 use std::ffi::OsString;
19173 use std::sync::Arc;
19174
19175 struct EmptyEnvironmentResolver;
19176
19177 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19178 fn resolve_child_environment(
19179 &self,
19180 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19181 Ok(vec![EnvMutation {
19182 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19183 value: None,
19184 }])
19185 }
19186 }
19187
19188 fn agent(value: &str) -> AgentId {
19189 AgentId::new(value).unwrap()
19190 }
19191
19192 fn claude_config_dir_collision_fixture() -> (
19194 NodeShared,
19195 ResolvedEnvironmentProfileReceipt,
19196 ResolvedEnvironmentProfileReceipt,
19197 ) {
19198 let catalog = active_registry().unwrap();
19199 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19200 let control = runtime.native_launch_profile_control();
19201 let workspace = WorkspaceConfig::new(
19202 WorkspaceId::new("primary").unwrap(),
19203 std::env::current_dir().unwrap(),
19204 )
19205 .unwrap();
19206 let shared = NodeShared::new_with_incarnation(
19207 handle,
19208 "fixture-token".to_owned(),
19209 NodeId::new("node-env-collision-test").unwrap(),
19210 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19211 vec![workspace],
19212 vec![agent("claude")],
19213 ProviderRuntimeStatuses::default(),
19214 None,
19215 Vec::new(),
19216 Vec::new(),
19217 SpawnProfileRegistry::default(),
19218 Some(control),
19219 None,
19220 None,
19221 None,
19222 Vec::new(),
19223 Vec::new(),
19224 Vec::new(),
19225 Vec::new(),
19226 None,
19227 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19228 );
19229
19230 let mut receipts = Vec::new();
19231 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19232 let profile_id =
19233 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19234 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19235 "claude-config-dir-{suffix}-r1"
19236 ))
19237 .unwrap();
19238 let materialization = NodeSessionMaterializationProfile::new(
19239 Vec::new(),
19240 vec![NodeSessionPathBinding::new(
19241 "CLAUDE_CONFIG_DIR",
19242 NodeSessionPathClass::ProviderHome,
19243 )
19244 .unwrap()],
19245 Vec::new(),
19246 )
19247 .unwrap();
19248 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19249 profile_id.clone(),
19250 profile_revision.clone(),
19251 agent("claude"),
19252 [NativeLaunchProfile::new(
19253 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19254 .unwrap(),
19255 agent("claude"),
19256 TransportKind::Pty,
19257 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19258 Arc::new(EmptyEnvironmentResolver),
19259 )
19260 .unwrap()],
19261 Some(materialization),
19262 )
19263 .unwrap();
19264 let (binding, native_profiles, materialization) = node_profile.into_parts();
19265 for native_profile in native_profiles {
19266 runtime.upsert_native_launch_profile(native_profile).unwrap();
19267 }
19268 shared
19269 .environment_profiles
19270 .write()
19271 .unwrap_or_else(|poisoned| poisoned.into_inner())
19272 .insert(binding.id.clone(), binding);
19273 shared
19274 .environment_materialization_profiles
19275 .write()
19276 .unwrap_or_else(|poisoned| poisoned.into_inner())
19277 .insert(profile_id.clone(), materialization.unwrap());
19278 receipts.push(ResolvedEnvironmentProfileReceipt {
19279 profile_id,
19280 profile_revision,
19281 network_allowlist: None,
19282 browser_profile_id: None,
19283 });
19284 }
19285 let profile_b = receipts.pop().unwrap();
19286 let profile_a = receipts.pop().unwrap();
19287 (shared, profile_a, profile_b)
19288 }
19289
19290 #[test]
19291 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19292 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19293 let primary = WorkspaceId::new("primary").unwrap();
19294 let worktree = WorkspaceId::new("worktree-b").unwrap();
19295 let address = SessionAddress {
19296 workspace_id: primary.clone(),
19297 session: SessionKey {
19298 instance_id: AgentInstanceId(41),
19299 generation: SessionGeneration(1),
19300 },
19301 };
19302 shared.bind_session_with_policy(
19303 &address,
19304 ProviderRuntimePolicy::raw_pty(),
19305 Some(profile_a.clone()),
19306 );
19307
19308 let refused = shared
19309 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19310 .unwrap_err();
19311 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19312
19313 let refused_none = shared
19315 .ensure_workspace_environment_profile_compatible(&primary, None)
19316 .unwrap_err();
19317 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19318
19319 shared
19321 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19322 .unwrap();
19323
19324 shared
19326 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19327 .unwrap();
19328 }
19329
19330 #[tokio::test]
19331 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19332 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19333 let primary = WorkspaceId::new("primary").unwrap();
19334 let address = SessionAddress {
19335 workspace_id: primary.clone(),
19336 session: SessionKey {
19337 instance_id: AgentInstanceId(41),
19338 generation: SessionGeneration(1),
19339 },
19340 };
19341 shared.bind_session_with_policy(
19342 &address,
19343 ProviderRuntimePolicy::raw_pty(),
19344 Some(profile_a),
19345 );
19346
19347 let failure = shared
19348 .spawn_session_with_deadline(
19349 primary,
19350 agent("claude"),
19351 SessionMode::Pty,
19352 gate4agent_types::TerminalSize {
19353 rows: 24,
19354 columns: 80,
19355 },
19356 None,
19357 Some(profile_b),
19358 None,
19359 None,
19360 None,
19361 None,
19362 SpawnRecordPolicy::ProviderIdentityOnly,
19363 Some(Instant::now() + Duration::from_secs(30)),
19364 &[],
19365 None,
19366 ApprovalLevel::default(),
19367 None,
19368 None,
19369 )
19370 .await
19371 .unwrap_err();
19372 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19373 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19375 assert!(shared.handle.snapshot().sessions.is_empty());
19376 }
19377}
19378
19379#[cfg(test)]
19380mod spawn_characterization_tests {
19381 use super::*;
19382 use crate::session_environment::{
19383 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19384 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19385 NodeSessionPathClass,
19386 };
19387 use gate4agent_catalog::EnvMutation;
19388 use gate4agent_runtime_native::{
19389 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19390 NativeLaunchProfile, NativeLaunchProfileId,
19391 };
19392 use std::ffi::OsString;
19393 use std::sync::Arc;
19394
19395 struct EmptyEnvironmentResolver;
19396
19397 struct FixtureSecretResolver {
19398 deny: Arc<AtomicBool>,
19399 }
19400
19401 impl NodeSecretResolver for FixtureSecretResolver {
19402 fn resolve(
19403 &self,
19404 _reference: &NodeSecretReference,
19405 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19406 if self.deny.load(Ordering::Acquire) {
19407 Err(NodeSecretResolveError::Denied)
19408 } else {
19409 NodeSecretValue::text("fixture-secret")
19410 .map_err(|_| NodeSecretResolveError::Unavailable)
19411 }
19412 }
19413 }
19414
19415 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19416 fn resolve_child_environment(
19417 &self,
19418 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19419 Ok(vec![EnvMutation {
19420 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19421 value: None,
19422 }])
19423 }
19424 }
19425
19426 fn agent(value: &str) -> AgentId {
19427 AgentId::new(value).unwrap()
19428 }
19429
19430 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19431 let unique = std::time::SystemTime::now()
19432 .duration_since(std::time::UNIX_EPOCH)
19433 .unwrap()
19434 .as_nanos();
19435 std::env::temp_dir().join(format!(
19436 "gate4agent-node-{label}-{}-{unique}",
19437 std::process::id(),
19438 ))
19439 }
19440
19441 fn materialization_test_shared(
19442 deny_secret: bool,
19443 ) -> (
19444 NodeShared,
19445 NativeRuntime,
19446 ResolvedEnvironmentProfileReceipt,
19447 PathBuf,
19448 Arc<AtomicBool>,
19449 ) {
19450 let workspace = WorkspaceConfig::new(
19451 WorkspaceId::new("primary").unwrap(),
19452 std::env::current_dir().unwrap(),
19453 )
19454 .unwrap();
19455 materialization_test_shared_with_workspace(deny_secret, workspace)
19456 }
19457
19458 fn materialization_test_shared_with_workspace(
19459 deny_secret: bool,
19460 workspace: WorkspaceConfig,
19461 ) -> (
19462 NodeShared,
19463 NativeRuntime,
19464 ResolvedEnvironmentProfileReceipt,
19465 PathBuf,
19466 Arc<AtomicBool>,
19467 ) {
19468 let root = temporary_workspace_root(if deny_secret {
19469 "materialization-denied"
19470 } else {
19471 "materialization-ready"
19472 });
19473 std::fs::create_dir_all(&root).unwrap();
19474 let materialization_root = root.join("session-environments");
19475 let deny = Arc::new(AtomicBool::new(deny_secret));
19476 let resolver = Arc::new(FixtureSecretResolver {
19477 deny: Arc::clone(&deny),
19478 });
19479 let materializer = SessionEnvironmentMaterializer::new(
19480 materialization_root,
19481 resolver,
19482 )
19483 .unwrap();
19484 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19485 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19486 "materialized-claude-r1",
19487 )
19488 .unwrap();
19489 let materialization = NodeSessionMaterializationProfile::new(
19490 vec![NodeSessionEnvironmentMutation::SetSecret {
19491 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19492 reference: NodeSecretReference::new("fixture-token").unwrap(),
19493 }],
19494 vec![NodeSessionPathBinding::new(
19495 "GATE4AGENT_PROVIDER_HOME",
19496 NodeSessionPathClass::ProviderHome,
19497 )
19498 .unwrap()],
19499 vec![NodeSessionFile::secret(
19500 NodeSessionPathClass::Config,
19501 "auth/token",
19502 NodeSecretReference::new("fixture-token").unwrap(),
19503 )
19504 .unwrap()],
19505 )
19506 .unwrap();
19507 let catalog = active_registry().unwrap();
19508 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19509 let control = runtime.native_launch_profile_control();
19510 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19511 profile_id.clone(),
19512 profile_revision.clone(),
19513 agent("claude"),
19514 [NativeLaunchProfile::new(
19515 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19516 agent("claude"),
19517 TransportKind::Pty,
19518 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19519 Arc::new(EmptyEnvironmentResolver),
19520 )
19521 .unwrap()],
19522 Some(materialization),
19523 )
19524 .unwrap();
19525 let (binding, native_profiles, materialization) = node_profile.into_parts();
19526 for native_profile in native_profiles {
19527 runtime.upsert_native_launch_profile(native_profile).unwrap();
19528 }
19529 let shared = NodeShared::new_with_incarnation(
19530 handle,
19531 "fixture-token".to_owned(),
19532 NodeId::new("node-materialization-test").unwrap(),
19533 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19534 vec![workspace],
19535 vec![agent("claude")],
19536 ProviderRuntimeStatuses::default(),
19537 None,
19538 Vec::new(),
19539 Vec::new(),
19540 SpawnProfileRegistry::default(),
19541 Some(control),
19542 None,
19543 Some(materializer),
19544 None,
19545 Vec::new(),
19546 Vec::new(),
19547 Vec::new(),
19548 Vec::new(),
19549 None,
19550 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19551 );
19552 shared
19553 .environment_profiles
19554 .write()
19555 .unwrap_or_else(|poisoned| poisoned.into_inner())
19556 .insert(binding.id.clone(), binding);
19557 shared
19558 .environment_materialization_profiles
19559 .write()
19560 .unwrap_or_else(|poisoned| poisoned.into_inner())
19561 .insert(profile_id.clone(), materialization.unwrap());
19562 (
19563 shared,
19564 runtime,
19565 ResolvedEnvironmentProfileReceipt {
19566 profile_id,
19567 profile_revision,
19568 network_allowlist: None,
19569 browser_profile_id: None,
19570 },
19571 root,
19572 deny,
19573 )
19574 }
19575
19576
19577 async fn characterization_spawn(
19580 shared: &NodeShared,
19581 mode: SessionMode,
19582 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19583 required: &[ProviderRuntimeCapability],
19584 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19585 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19586 shared
19587 .spawn_session_with_deadline(
19588 WorkspaceId::new("primary").unwrap(),
19589 agent("claude"),
19590 mode,
19591 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19592 None,
19593 environment_profile,
19594 None,
19595 None,
19596 None,
19597 Some(ProviderRuntimePolicy::raw_pty()),
19598 SpawnRecordPolicy::ProviderIdentityOnly,
19599 Some(Instant::now() + Duration::from_secs(30)),
19600 required,
19601 harness_mcp,
19602 ApprovalLevel::default(),
19603 None,
19604 None,
19605 )
19606 .await
19607 }
19608
19609 fn assert_no_session_state(shared: &NodeShared) {
19610 assert!(shared.handle.snapshot().sessions.is_empty());
19611 assert!(shared
19612 .session_bindings
19613 .lock()
19614 .unwrap_or_else(|poisoned| poisoned.into_inner())
19615 .is_empty());
19616 assert!(shared
19617 .materializations
19618 .lock()
19619 .unwrap_or_else(|poisoned| poisoned.into_inner())
19620 .is_empty());
19621 }
19622
19623 #[tokio::test]
19624 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19625 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19626 let error = characterization_spawn(
19627 &shared,
19628 SessionMode::Pty,
19629 None,
19630 &[ProviderRuntimeCapability::SemanticReadiness],
19631 None,
19632 )
19633 .await
19634 .unwrap_err();
19635 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19636 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19637 assert_no_session_state(&shared);
19638 drop(shared);
19639 drop(runtime);
19640 std::fs::remove_dir_all(root).unwrap();
19641 }
19642
19643 #[tokio::test]
19644 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19645 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19646 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19647 .await
19648 .unwrap_err();
19649 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19650 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19651 assert_no_session_state(&shared);
19652 drop(shared);
19653 drop(runtime);
19654 std::fs::remove_dir_all(root).unwrap();
19655 }
19656
19657 #[test]
19658 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19659 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19660 let address = SessionAddress {
19661 workspace_id: WorkspaceId::new("primary").unwrap(),
19662 session: SessionKey {
19663 instance_id: AgentInstanceId(77),
19664 generation: SessionGeneration(1),
19665 },
19666 };
19667 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19668 let removed = shared.remove_binding(&address).expect("binding removed");
19669 assert!(removed.environment_profile.is_some());
19670 assert!(shared.remove_binding(&address).is_none());
19671 assert_no_session_state(&shared);
19672 drop(shared);
19673 drop(runtime);
19674 std::fs::remove_dir_all(root).unwrap();
19675 }
19676
19677 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19678 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19679 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19680 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19681 .expect("review current test executable as fixture helper");
19682 let registry = HarnessMcpProxyRegistry::new(helper);
19683 let spec = crate::protocol::SpawnSpec {
19684 target: crate::protocol::SpawnTarget {
19685 node_id: NodeId::new("node-characterization").unwrap(),
19686 workspace_id: WorkspaceId::new("primary").unwrap(),
19687 worktree_id: None,
19688 },
19689 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19690 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19691 overrides: crate::protocol::SpawnOverrides::default(),
19692 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19693 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19694 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19695 };
19696 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19697 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19698 let launch = HarnessMcpLaunchV1 {
19699 server_name: "fixture-harness".to_owned(),
19700 args: vec!["--session-proxy".to_owned()],
19701 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19702 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19703 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19704 trace: None,
19705 scrub_env: Vec::new(),
19706 };
19707 let expires = unix_time_ms().saturating_add(30_000);
19708 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19709 registry
19710 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19711 .unwrap()
19712 }
19713
19714 #[tokio::test]
19715 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19716 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19717 let prepared = characterization_prepared_harness("claude").await;
19718 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19719 .await
19720 .unwrap_err();
19721 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19723 let mismatched = characterization_prepared_harness("codex").await;
19724 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19725 .await
19726 .unwrap_err();
19727 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19728 assert_no_session_state(&shared);
19729 drop(shared);
19730 drop(runtime);
19731 std::fs::remove_dir_all(root).unwrap();
19732 }
19733
19734 #[tokio::test]
19735 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19736 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19737 shared.native_launch_profile_control = None;
19738 let prepared = characterization_prepared_harness("claude").await;
19739 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19740 .await
19741 .unwrap_err();
19742 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19743 assert_no_session_state(&shared);
19744 drop(shared);
19745 drop(runtime);
19746 std::fs::remove_dir_all(root).unwrap();
19747 }
19748}
19749
19750#[cfg(all(test, windows))]
19751mod tests {
19752 use super::*;
19753 use gate4agent_types::ProviderActivity;
19754 use crate::session_environment::{
19755 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19756 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19757 NodeSessionPathClass,
19758 };
19759 use gate4agent_catalog::EnvMutation;
19760 use gate4agent_runtime_native::{
19761 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19762 NativeLaunchProfile, NativeLaunchProfileId,
19763 };
19764 use std::ffi::OsString;
19765 use std::sync::Arc;
19766
19767 struct EmptyEnvironmentResolver;
19768
19769 struct FixtureSecretResolver {
19770 deny: Arc<AtomicBool>,
19771 }
19772
19773 impl NodeSecretResolver for FixtureSecretResolver {
19774 fn resolve(
19775 &self,
19776 _reference: &NodeSecretReference,
19777 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19778 if self.deny.load(Ordering::Acquire) {
19779 Err(NodeSecretResolveError::Denied)
19780 } else {
19781 NodeSecretValue::text("fixture-secret")
19782 .map_err(|_| NodeSecretResolveError::Unavailable)
19783 }
19784 }
19785 }
19786
19787 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19788 fn resolve_child_environment(
19789 &self,
19790 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19791 Ok(vec![EnvMutation {
19792 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19793 value: None,
19794 }])
19795 }
19796 }
19797
19798 fn agent(value: &str) -> AgentId {
19799 AgentId::new(value).unwrap()
19800 }
19801
19802 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19803 let catalog = active_registry().unwrap();
19804 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19805 let workspace = WorkspaceConfig::new(
19806 WorkspaceId::new("primary").unwrap(),
19807 std::env::current_dir().unwrap(),
19808 )
19809 .unwrap()
19810 .with_worktree_service_mode(mode);
19811 NodeShared::new(
19812 handle,
19813 "fixture-token".to_owned(),
19814 NodeId::new("node-terminal-test").unwrap(),
19815 vec![workspace],
19816 vec![agent("claude")],
19817 )
19818 }
19819
19820 fn terminal_test_shared() -> NodeShared {
19821 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19822 }
19823
19824 #[cfg(feature = "fixture")]
19825 #[test]
19826 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19827 let probe = SpawnManagedWorktreeV2FailureProbe::default();
19828 assert_eq!(probe.latest_code(), None);
19829
19830 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19831 assert_eq!(
19832 probe.latest_code(),
19833 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19834 );
19835
19836 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19837 assert_eq!(
19838 probe.latest_code(),
19839 Some(NodeFailureCode::SpawnDeadlineExceeded),
19840 );
19841
19842 probe.record(None);
19843 assert_eq!(probe.latest_code(), None);
19844 }
19845
19846 #[test]
19847 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19848 let source = temporary_workspace_root("launch-inventory-source");
19849 let allocation = temporary_workspace_root("launch-inventory-allocation");
19850 std::fs::create_dir_all(&source).unwrap();
19851 std::fs::create_dir_all(&allocation).unwrap();
19852 let workspace_id = WorkspaceId::new("primary").unwrap();
19853 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19854 .unwrap()
19855 .with_worktree_service_mode(WorktreeServiceMode::Managed);
19856 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19857 workspace = workspace
19858 .with_managed_worktree_profile(
19859 ManagedWorktreeProfile::new(
19860 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19861 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19862 &allocation,
19863 "gate4agent",
19864 "HEAD",
19865 ManagedWorktreeRetention::RemoveWhenReleased,
19866 )
19867 .unwrap(),
19868 )
19869 .unwrap();
19870 }
19871 let overflow = workspace.clone().with_managed_worktree_profile(
19872 ManagedWorktreeProfile::new(
19873 WorktreeProfileId::new("overflow").unwrap(),
19874 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19875 &allocation,
19876 "gate4agent",
19877 "HEAD",
19878 ManagedWorktreeRetention::Retain,
19879 )
19880 .unwrap(),
19881 );
19882 assert!(matches!(
19883 overflow,
19884 Err(NodeServerError::ManagedWorktreeProfileCapacity {
19885 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19886 ..
19887 }),
19888 ));
19889
19890 let catalog = active_registry().unwrap();
19891 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19892 let shared = NodeShared::new(
19893 handle,
19894 "fixture-token".to_owned(),
19895 NodeId::new("node-launch-inventory").unwrap(),
19896 vec![workspace],
19897 vec![agent("claude")],
19898 );
19899 let snapshot = shared.snapshot();
19900 let launch = snapshot.launch_inventory.as_ref().unwrap();
19901 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19902 assert!(launch.bundles.as_ref().unwrap().is_empty());
19903 let profiles = snapshot.workspaces[0]
19904 .managed_worktree_profiles
19905 .as_ref()
19906 .unwrap();
19907 assert_eq!(
19908 snapshot.workspaces[0].worktree_service_mode,
19909 Some(WorktreeServiceMode::Managed),
19910 );
19911 assert_eq!(
19912 profiles.profiles.len(),
19913 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19914 );
19915 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19916 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19917 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19918 }
19919 let legacy = snapshot_for_wire(
19920 &shared,
19921 true,
19922 true,
19923 false,
19924 false,
19925 true,
19926 false,
19927 true,
19928 false,
19929 true,
19930 );
19931 assert!(legacy.launch_inventory.is_none());
19932 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19933
19934 drop(shared);
19935 std::fs::remove_dir_all(source).unwrap();
19936 std::fs::remove_dir_all(allocation).unwrap();
19937 }
19938
19939 #[test]
19940 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19941 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19942 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19943 }));
19944 let source = gate4agent_types::ProviderSource {
19945 family: AdapterFamily::PtySemantic,
19946 binding: AdapterBinding::new(
19947 gate4agent_types::AdapterId::new("claude").unwrap(),
19948 "fixture/v1",
19949 gate4agent_types::AdapterVerification::SyntheticFixture,
19950 )
19951 .unwrap(),
19952 };
19953 let provider = ProviderSnapshot {
19954 sequence: 17,
19955 session: Some(ProviderSessionIdentity {
19956 key: ProviderSessionKey::SessionId,
19957 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19958 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19959 }),
19960 model: Some("PRIVATE_MODEL".to_owned()),
19961 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19962 completed_turns: 3,
19963 usage: gate4agent_types::TokenUsage {
19964 input_tokens: 10,
19965 output_tokens: 20,
19966 cache_read_tokens: 30,
19967 cache_write_tokens: 40,
19968 reasoning_tokens: 50,
19969 context_window: Some(200_000),
19970 },
19971 lead_activity: ProviderActivity::WaitingForInput,
19972 activity: ProviderActivity::WaitingForInput,
19973 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19974 active_tools: (0..9)
19975 .map(|index| gate4agent_types::ActiveProviderTool {
19976 id: format!("private-tool-id-{index}"),
19977 name: if index == 0 {
19978 format!(" Read\u{0000}{}", "x".repeat(80))
19979 } else {
19980 format!("Tool-{index}")
19981 },
19982 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19983 })
19984 .collect(),
19985 interactions: vec![gate4agent_types::ProviderInteraction {
19986 id: gate4agent_types::ProviderInteractionId(17),
19987 source: source.clone(),
19988 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19989 interaction_kind: ProviderInteractionKind::Approval,
19990 tool_name: " Approve\u{0007}Tool ".to_owned(),
19991 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19992 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19993 resume_lead_activity: Some(ProviderActivity::Working),
19994 status: ProviderInteractionStatus::Pending,
19995 }],
19996 subagents: vec![gate4agent_types::ProviderSubagent {
19997 source: source.clone(),
19998 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19999 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20000 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20001 }],
20002 sources: vec![gate4agent_types::ProviderSourceCursor {
20003 source,
20004 sequence: 17,
20005 gap_count: 2,
20006 stale: true,
20007 }],
20008 last_event: Some(ProviderEvent::Error {
20009 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20010 }),
20011 gap_count: 2,
20012 stale: true,
20013 };
20014 let address = terminal_address(3);
20015 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20016 assert_eq!(entry.address, address);
20017 assert_eq!(entry.progress.provider_sequence, 17);
20018 assert_eq!(entry.progress.active_tool_count, 9);
20019 assert_eq!(
20020 entry.progress.active_tool_labels.len(),
20021 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20022 );
20023 assert_eq!(entry.progress.subagent_count, 1);
20024 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20025 assert!(entry.progress.truncated);
20026 let encoded = serde_json::to_string(&entry).unwrap();
20027 for forbidden in [
20028 "PRIVATE_PROVIDER_SESSION",
20029 "PRIVATE_PROVIDER_PATH",
20030 "PRIVATE_MODEL",
20031 "PRIVATE_TOOL_INVENTORY",
20032 "PRIVATE_CURRENT_PROMPT",
20033 "PRIVATE_TOOL_INPUT_JSON",
20034 "PRIVATE_PROVIDER_REQUEST",
20035 "PRIVATE_INTERACTION_PROMPT",
20036 "PRIVATE_INTERACTION_AGENT",
20037 "PRIVATE_SUBAGENT_ID",
20038 "PRIVATE_SUBAGENT_TYPE",
20039 "PRIVATE_SUBAGENT_DESCRIPTION",
20040 "PRIVATE_PROVIDER_ERROR",
20041 "input_json",
20042 "current_prompt",
20043 "session_id",
20044 ] {
20045 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20046 }
20047
20048 let snapshot = NodeSnapshot {
20049 node_id: NodeId::new("node-progress-test").unwrap(),
20050 enabled_providers: vec![agent("claude")],
20051 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20052 workspaces: Vec::new(),
20053 session_records: Vec::new(),
20054 managed_worktrees: Vec::new(),
20055 launch_inventory: None,
20056 agent_progress: vec![entry],
20057 };
20058 let mut legacy_reply = ResponseEnvelope {
20059 request_id: 1,
20060 result: Ok(NodeResponse::Snapshot {
20061 event_sequence: 0,
20062 controller: None,
20063 snapshot: snapshot.clone(),
20064 }),
20065 };
20066 project_response_without_agent_progress(&mut legacy_reply);
20067 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20068 panic!("agent progress projection changed response kind");
20069 };
20070 assert!(legacy.agent_progress.is_empty());
20071 assert_eq!(snapshot.agent_progress.len(), 1);
20072 }
20073
20074 fn terminal_address(generation: u64) -> SessionAddress {
20075 SessionAddress {
20076 workspace_id: WorkspaceId::new("primary").unwrap(),
20077 session: SessionKey {
20078 instance_id: AgentInstanceId(41),
20079 generation: SessionGeneration(generation),
20080 },
20081 }
20082 }
20083
20084 fn spawn_spec_fixture() -> SpawnSpec {
20085 SpawnSpec {
20086 target: crate::protocol::SpawnTarget {
20087 node_id: NodeId::new("node-terminal-test").unwrap(),
20088 workspace_id: WorkspaceId::new("primary").unwrap(),
20089 worktree_id: None,
20090 },
20091 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20092 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20093 .unwrap(),
20094 overrides: crate::protocol::SpawnOverrides::default(),
20095 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20096 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20097 required_capabilities: SpawnRequiredCapabilities::default(),
20098 }
20099 }
20100
20101 fn environment_profile_test_shared(
20102 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20103 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20104 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20105 "local-claude-r1",
20106 )
20107 .unwrap();
20108 let spawn_profiles = SpawnProfileRegistry::new([
20109 crate::protocol::SpawnProfileDefaults {
20110 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20111 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20112 provider: agent("claude"),
20113 mode: SessionMode::Pty,
20114 terminal_size: gate4agent_types::TerminalSize {
20115 rows: 24,
20116 columns: 80,
20117 },
20118 prompt: None,
20119 bundle_id: None,
20120 context_id: None,
20121 environment_profile_id: Some(profile_id.clone()),
20122 },
20123 ])
20124 .unwrap();
20125 let catalog = active_registry().unwrap();
20126 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20127 let control = runtime.native_launch_profile_control();
20128 let node_profile = NodeEnvironmentProfile::new(
20129 profile_id.clone(),
20130 profile_revision.clone(),
20131 agent("claude"),
20132 [NativeLaunchProfile::new(
20133 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20134 agent("claude"),
20135 TransportKind::Pty,
20136 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20137 Arc::new(EmptyEnvironmentResolver),
20138 )
20139 .unwrap()],
20140 )
20141 .unwrap();
20142 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20143 for native_profile in native_profiles {
20144 runtime.upsert_native_launch_profile(native_profile).unwrap();
20145 }
20146 let workspace = WorkspaceConfig::new(
20147 WorkspaceId::new("primary").unwrap(),
20148 std::env::current_dir().unwrap(),
20149 )
20150 .unwrap();
20151 let shared = NodeShared::new_with_incarnation(
20152 handle,
20153 "fixture-token".to_owned(),
20154 NodeId::new("node-terminal-test").unwrap(),
20155 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20156 vec![workspace],
20157 vec![agent("claude")],
20158 ProviderRuntimeStatuses::default(),
20159 None,
20160 Vec::new(),
20161 Vec::new(),
20162 spawn_profiles,
20163 Some(control),
20164 None,
20165 None,
20166 None,
20167 Vec::new(),
20168 Vec::new(),
20169 Vec::new(),
20170 Vec::new(),
20171 None,
20172 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20173 );
20174 shared
20175 .environment_profiles
20176 .write()
20177 .unwrap_or_else(|poisoned| poisoned.into_inner())
20178 .insert(binding.id.clone(), binding);
20179 (
20180 shared,
20181 runtime,
20182 ResolvedEnvironmentProfileReceipt {
20183 profile_id,
20184 profile_revision,
20185 network_allowlist: None,
20186 browser_profile_id: None,
20187 },
20188 )
20189 }
20190
20191 fn materialization_test_shared(
20192 deny_secret: bool,
20193 ) -> (
20194 NodeShared,
20195 NativeRuntime,
20196 ResolvedEnvironmentProfileReceipt,
20197 PathBuf,
20198 Arc<AtomicBool>,
20199 ) {
20200 let workspace = WorkspaceConfig::new(
20201 WorkspaceId::new("primary").unwrap(),
20202 std::env::current_dir().unwrap(),
20203 )
20204 .unwrap();
20205 materialization_test_shared_with_workspace(deny_secret, workspace)
20206 }
20207
20208 fn materialization_test_shared_with_workspace(
20209 deny_secret: bool,
20210 workspace: WorkspaceConfig,
20211 ) -> (
20212 NodeShared,
20213 NativeRuntime,
20214 ResolvedEnvironmentProfileReceipt,
20215 PathBuf,
20216 Arc<AtomicBool>,
20217 ) {
20218 let root = temporary_workspace_root(if deny_secret {
20219 "materialization-denied"
20220 } else {
20221 "materialization-ready"
20222 });
20223 std::fs::create_dir_all(&root).unwrap();
20224 let materialization_root = root.join("session-environments");
20225 let deny = Arc::new(AtomicBool::new(deny_secret));
20226 let resolver = Arc::new(FixtureSecretResolver {
20227 deny: Arc::clone(&deny),
20228 });
20229 let materializer = SessionEnvironmentMaterializer::new(
20230 materialization_root,
20231 resolver,
20232 )
20233 .unwrap();
20234 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20235 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20236 "materialized-claude-r1",
20237 )
20238 .unwrap();
20239 let materialization = NodeSessionMaterializationProfile::new(
20240 vec![NodeSessionEnvironmentMutation::SetSecret {
20241 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20242 reference: NodeSecretReference::new("fixture-token").unwrap(),
20243 }],
20244 vec![NodeSessionPathBinding::new(
20245 "GATE4AGENT_PROVIDER_HOME",
20246 NodeSessionPathClass::ProviderHome,
20247 )
20248 .unwrap()],
20249 vec![NodeSessionFile::secret(
20250 NodeSessionPathClass::Config,
20251 "auth/token",
20252 NodeSecretReference::new("fixture-token").unwrap(),
20253 )
20254 .unwrap()],
20255 )
20256 .unwrap();
20257 let catalog = active_registry().unwrap();
20258 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20259 let control = runtime.native_launch_profile_control();
20260 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20261 profile_id.clone(),
20262 profile_revision.clone(),
20263 agent("claude"),
20264 [NativeLaunchProfile::new(
20265 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20266 agent("claude"),
20267 TransportKind::Pty,
20268 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20269 Arc::new(EmptyEnvironmentResolver),
20270 )
20271 .unwrap()],
20272 Some(materialization),
20273 )
20274 .unwrap();
20275 let (binding, native_profiles, materialization) = node_profile.into_parts();
20276 for native_profile in native_profiles {
20277 runtime.upsert_native_launch_profile(native_profile).unwrap();
20278 }
20279 let shared = NodeShared::new_with_incarnation(
20280 handle,
20281 "fixture-token".to_owned(),
20282 NodeId::new("node-materialization-test").unwrap(),
20283 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20284 vec![workspace],
20285 vec![agent("claude")],
20286 ProviderRuntimeStatuses::default(),
20287 None,
20288 Vec::new(),
20289 Vec::new(),
20290 SpawnProfileRegistry::default(),
20291 Some(control),
20292 None,
20293 Some(materializer),
20294 None,
20295 Vec::new(),
20296 Vec::new(),
20297 Vec::new(),
20298 Vec::new(),
20299 None,
20300 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20301 );
20302 shared
20303 .environment_profiles
20304 .write()
20305 .unwrap_or_else(|poisoned| poisoned.into_inner())
20306 .insert(binding.id.clone(), binding);
20307 shared
20308 .environment_materialization_profiles
20309 .write()
20310 .unwrap_or_else(|poisoned| poisoned.into_inner())
20311 .insert(profile_id.clone(), materialization.unwrap());
20312 (
20313 shared,
20314 runtime,
20315 ResolvedEnvironmentProfileReceipt {
20316 profile_id,
20317 profile_revision,
20318 network_allowlist: None,
20319 browser_profile_id: None,
20320 },
20321 root,
20322 deny,
20323 )
20324 }
20325
20326 #[cfg(feature = "fixture")]
20327 #[derive(Clone, Copy)]
20328 enum CodexHomeProfile {
20329 Missing,
20330 WrongClass,
20331 Exact,
20332 }
20333
20334 #[cfg(feature = "fixture")]
20335 fn codex_bundle_test_shared(
20336 home_profile: CodexHomeProfile,
20337 ) -> (
20338 NodeShared,
20339 NativeRuntime,
20340 ResolvedEnvironmentProfileReceipt,
20341 NodeBundle,
20342 PathBuf,
20343 ) {
20344 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20345 const CLAUDE_MANIFEST: &[u8] =
20346 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20347 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20348 const DIGEST: &str =
20349 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20350
20351 let (shared, mut runtime, _claude_receipt, root, _deny) =
20352 materialization_test_shared(false);
20353 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20354 CodexHomeProfile::Missing => "codex-home-missing",
20355 CodexHomeProfile::WrongClass => "codex-home-wrong",
20356 CodexHomeProfile::Exact => "codex-home-exact",
20357 })
20358 .unwrap();
20359 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20360 "codex-home-r1",
20361 )
20362 .unwrap();
20363 let materialization = match home_profile {
20364 CodexHomeProfile::Missing => None,
20365 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20366 Some(NodeSessionMaterializationProfile::new(
20367 Vec::new(),
20368 vec![NodeSessionPathBinding::new(
20369 "CODEX_HOME",
20370 if matches!(home_profile, CodexHomeProfile::Exact) {
20371 NodeSessionPathClass::ProviderHome
20372 } else {
20373 NodeSessionPathClass::Config
20374 },
20375 )
20376 .unwrap()],
20377 Vec::new(),
20378 )
20379 .unwrap())
20380 }
20381 };
20382 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20383 profile_id.clone(),
20384 profile_revision.clone(),
20385 agent("codex"),
20386 [NativeLaunchProfile::new(
20387 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20388 agent("codex"),
20389 TransportKind::Pty,
20390 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20391 Arc::new(EmptyEnvironmentResolver),
20392 )
20393 .unwrap()],
20394 materialization,
20395 )
20396 .unwrap();
20397 let (binding, native_profiles, materialization) = node_profile.into_parts();
20398 for native_profile in native_profiles {
20399 runtime.upsert_native_launch_profile(native_profile).unwrap();
20400 }
20401 shared
20402 .environment_profiles
20403 .write()
20404 .unwrap_or_else(|poisoned| poisoned.into_inner())
20405 .insert(binding.id.clone(), binding);
20406 if let Some(materialization) = materialization {
20407 shared
20408 .environment_materialization_profiles
20409 .write()
20410 .unwrap_or_else(|poisoned| poisoned.into_inner())
20411 .insert(profile_id.clone(), materialization);
20412 }
20413
20414 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20415 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20416 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20417 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20418 std::fs::write(
20419 bundle_source.join(".claude-plugin/plugin.json"),
20420 CLAUDE_MANIFEST,
20421 )
20422 .unwrap();
20423 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20424 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20425 let bundle = NodeBundle::new(
20426 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20427 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20428 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20429 &bundle_source,
20430 )
20431 .unwrap();
20432 *shared
20433 .bundle_catalog
20434 .write()
20435 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20436 BundleCatalog::new([bundle.clone()]).unwrap();
20437 (
20438 shared,
20439 runtime,
20440 ResolvedEnvironmentProfileReceipt {
20441 profile_id,
20442 profile_revision,
20443 network_allowlist: None,
20444 browser_profile_id: None,
20445 },
20446 bundle,
20447 root,
20448 )
20449 }
20450
20451 #[cfg(feature = "fixture")]
20452 #[test]
20453 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20454 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20455 let (shared, runtime, receipt, bundle, root) =
20456 codex_bundle_test_shared(home_profile);
20457 let error = shared
20458 .bundle_layout(
20459 &agent("codex"),
20460 SessionMode::Pty,
20461 Some(&receipt),
20462 &bundle,
20463 )
20464 .unwrap_err();
20465 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20466 assert!(shared
20467 .materializations
20468 .lock()
20469 .unwrap_or_else(|poisoned| poisoned.into_inner())
20470 .is_empty());
20471 let materialization_root = root.join("session-environments");
20472 assert!(!std::fs::read_dir(&materialization_root)
20473 .unwrap()
20474 .filter_map(Result::ok)
20475 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20476 drop(shared);
20477 drop(runtime);
20478 let _ = std::fs::remove_dir_all(root);
20479 }
20480 }
20481
20482 #[cfg(feature = "fixture")]
20483 #[test]
20484 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20485 let (shared, runtime, receipt, bundle, root) =
20486 codex_bundle_test_shared(CodexHomeProfile::Exact);
20487 let address = SessionAddress {
20488 workspace_id: WorkspaceId::new("primary").unwrap(),
20489 session: SessionKey {
20490 instance_id: AgentInstanceId(310),
20491 generation: SessionGeneration(1),
20492 },
20493 };
20494 let (overlay, guard) = shared
20495 .prepare_session_materialization(
20496 &address,
20497 &agent("codex"),
20498 SessionMode::Pty,
20499 Some(&receipt),
20500 Some(&bundle.receipt()),
20501 None,
20502 None,
20503 )
20504 .unwrap()
20505 .unwrap();
20506 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20507 let ownership = shared
20508 .materializations
20509 .lock()
20510 .unwrap_or_else(|poisoned| poisoned.into_inner())
20511 .get(guard.id().unwrap())
20512 .unwrap()
20513 .clone();
20514 let (profile, _, _) = shared
20515 .materialization_profile(
20516 &agent("codex"),
20517 SessionMode::Pty,
20518 Some(&receipt),
20519 Some(&bundle.receipt()),
20520 None,
20521 )
20522 .unwrap()
20523 .unwrap();
20524 let environment = shared
20525 .session_environment_materializer
20526 .as_ref()
20527 .unwrap()
20528 .resolve_environment(&ownership, &profile)
20529 .unwrap();
20530 assert!(environment.iter().any(|mutation| {
20531 mutation.key == OsString::from("CODEX_HOME")
20532 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20533 }));
20534 assert_eq!(
20535 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20536 .unwrap(),
20537 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20538 );
20539 assert!(std::fs::read_dir(ownership.bundle_root())
20540 .unwrap()
20541 .next()
20542 .is_none());
20543 drop(guard);
20544 drop(shared);
20545 drop(runtime);
20546 let _ = std::fs::remove_dir_all(root);
20547 }
20548
20549 #[cfg(feature = "fixture")]
20550 #[test]
20551 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20552 let (shared, runtime, receipt, bundle, root) =
20553 codex_bundle_test_shared(CodexHomeProfile::Exact);
20554 let bundle_receipt = bundle.receipt();
20555 let address = SessionAddress {
20556 workspace_id: WorkspaceId::new("primary").unwrap(),
20557 session: SessionKey {
20558 instance_id: AgentInstanceId(311),
20559 generation: SessionGeneration(1),
20560 },
20561 };
20562 let (_overlay, guard) = shared
20563 .prepare_session_materialization(
20564 &address,
20565 &agent("codex"),
20566 SessionMode::Pty,
20567 Some(&receipt),
20568 Some(&bundle_receipt),
20569 None,
20570 None,
20571 )
20572 .unwrap()
20573 .unwrap();
20574 let materialization_id = guard.id().unwrap().clone();
20575 let skill_path = shared
20576 .materializations
20577 .lock()
20578 .unwrap_or_else(|poisoned| poisoned.into_inner())
20579 .get(&materialization_id)
20580 .unwrap()
20581 .provider_home()
20582 .join("skills/review-code/SKILL.md");
20583 let record_id = shared
20584 .bind_spawn_session_with_materialization(
20585 &address,
20586 agent("codex"),
20587 SessionMode::Pty,
20588 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20589 SpawnRecordPolicy::ProviderIdentityOnly,
20590 Some(receipt.clone()),
20591 Some(bundle_receipt.clone()),
20592 None,
20593 Some(materialization_id.clone()),
20594 )
20595 .unwrap()
20596 .unwrap();
20597 guard.retain();
20598 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20599
20600 let error = match shared.resolve_record_materialization(
20601 &record_id,
20602 &agent("codex"),
20603 SessionMode::Pty,
20604 Some(&receipt),
20605 Some(&bundle_receipt),
20606 None,
20607 ) {
20608 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20609 Err(error) => error,
20610 };
20611 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20612 assert_eq!(
20613 shared
20614 .materializations
20615 .lock()
20616 .unwrap_or_else(|poisoned| poisoned.into_inner())
20617 .get(&materialization_id)
20618 .unwrap()
20619 .state(),
20620 MaterializationState::RecoveryRequired,
20621 );
20622 drop(shared);
20623 drop(runtime);
20624 let _ = std::fs::remove_dir_all(root);
20625 }
20626
20627 fn bind_record_owned_materialization_fixture(
20628 shared: &NodeShared,
20629 receipt: &ResolvedEnvironmentProfileReceipt,
20630 instance_id: u64,
20631 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20632 let address = SessionAddress {
20633 workspace_id: WorkspaceId::new("primary").unwrap(),
20634 session: SessionKey {
20635 instance_id: AgentInstanceId(instance_id),
20636 generation: SessionGeneration(1),
20637 },
20638 };
20639 let (overlay, materialization_guard) = shared
20640 .prepare_session_materialization(
20641 &address,
20642 &agent("claude"),
20643 SessionMode::Pty,
20644 Some(receipt),
20645 None,
20646 None,
20647 None,
20648 )
20649 .unwrap()
20650 .unwrap();
20651 let materialization_id = materialization_guard.id().unwrap().clone();
20652 let materialization_root = shared
20653 .materializations
20654 .lock()
20655 .unwrap_or_else(|poisoned| poisoned.into_inner())
20656 .get(&materialization_id)
20657 .unwrap()
20658 .root()
20659 .to_path_buf();
20660 let selection = shared
20661 .select_environment_profile(
20662 address.session.instance_id,
20663 &agent("claude"),
20664 SessionMode::Pty,
20665 Some(receipt),
20666 )
20667 .unwrap()
20668 .unwrap();
20669 assert!(shared
20670 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20671 .unwrap()
20672 .is_none());
20673 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20674 let record_id = shared
20675 .bind_spawn_session_with_materialization(
20676 &address,
20677 agent("claude"),
20678 SessionMode::Pty,
20679 policy,
20680 SpawnRecordPolicy::ProviderIdentityOnly,
20681 Some(receipt.clone()),
20682 None,
20683 None,
20684 Some(materialization_id.clone()),
20685 )
20686 .unwrap()
20687 .unwrap();
20688 selection.retain();
20689 materialization_guard.retain();
20690 (address, record_id, materialization_id, materialization_root)
20691 }
20692
20693 #[test]
20694 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20695 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20696 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20697 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20698 }));
20699 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20700 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20701 let mut spec = spawn_spec_fixture();
20702 spec.expected_profile_revision =
20703 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20704 assert!(matches!(
20705 &spec.overrides.environment_profile_id,
20706 crate::protocol::SpawnOverride::Inherit,
20707 ));
20708 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20709 .requires_child_environment_profile_capability());
20710 assert!(request_requires_child_environment_profile(
20711 &shared,
20712 &NodeRequest::SpawnSpec { spec: spec.clone() },
20713 ));
20714
20715 let address = terminal_address(1);
20716 shared.bind_session_with_policy(
20717 &address,
20718 ProviderRuntimePolicy::raw_pty(),
20719 Some(environment_profile.clone()),
20720 );
20721 assert!(request_requires_child_environment_profile(
20722 &shared,
20723 &NodeRequest::Input {
20724 session: address.clone(),
20725 text: "x".to_owned(),
20726 },
20727 ));
20728
20729 let mut record = record("claude", "profile-record");
20730 record.active_session = Some(address.clone());
20731 record.environment_profile = Some(environment_profile.clone());
20732 let record_id = record.record_id.clone();
20733 shared.insert_record(record.clone()).unwrap();
20734 assert!(request_requires_child_environment_profile(
20735 &shared,
20736 &NodeRequest::ResumeSessionRecord {
20737 record_id,
20738 terminal_size: gate4agent_types::TerminalSize {
20739 rows: 24,
20740 columns: 80,
20741 },
20742 initial_prompt: None,
20743 },
20744 ));
20745
20746 let snapshot = snapshot_for_wire(
20747 &shared, false, true, true, true, false, false, false, false, true,
20748 );
20749 assert_eq!(snapshot.session_records.len(), 1);
20750 assert!(snapshot.session_records[0].environment_profile.is_none());
20751 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20752 sequence: 1,
20753 event: NodeEvent::SessionRecordUpserted { record },
20754 });
20755 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20756 panic!("record projection changed the event kind");
20757 };
20758 assert!(record.environment_profile.is_none());
20759
20760 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20761 let mut reply = ResponseEnvelope {
20762 request_id: 1,
20763 result: Ok(NodeResponse::SpawnSpecAccepted {
20764 receipt: resolved.receipt_with_environment(
20765 shared.incarnation_id,
20766 address,
20767 Some(environment_profile),
20768 ),
20769 }),
20770 };
20771 project_response_without_child_environment_profile(&mut reply);
20772 assert_eq!(
20773 reply.result.unwrap_err().code,
20774 NodeFailureCode::UnsupportedCapability,
20775 );
20776 }
20777
20778 #[test]
20779 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20780 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20781 let snapshot = shared.snapshot();
20782 let profiles = snapshot
20783 .launch_inventory
20784 .as_ref()
20785 .and_then(|inventory| inventory.spawn_profiles.as_ref())
20786 .unwrap();
20787 assert_eq!(profiles.len(), 1);
20788 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20789 assert!(snapshot.requires_child_environment_profile_capability());
20790
20791 let mut legacy = snapshot.clone();
20792 clear_snapshot_child_environment_profiles(&mut legacy);
20793 assert!(legacy
20794 .launch_inventory
20795 .as_ref()
20796 .unwrap()
20797 .spawn_profiles
20798 .as_ref()
20799 .unwrap()[0]
20800 .environment_profile
20801 .is_none());
20802 assert!(!legacy.requires_child_environment_profile_capability());
20803
20804 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20805 let bindings = shared
20806 .environment_profiles
20807 .read()
20808 .unwrap_or_else(|poisoned| poisoned.into_inner())
20809 .clone();
20810 let mut provider_mismatch = bindings.clone();
20811 provider_mismatch
20812 .values_mut()
20813 .next()
20814 .unwrap()
20815 .provider = agent("codex");
20816 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20817 let mut mode_mismatch = profile;
20818 mode_mismatch.mode = SessionMode::Inline;
20819 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20820
20821 shared
20822 .environment_profiles
20823 .write()
20824 .unwrap_or_else(|poisoned| poisoned.into_inner())
20825 .clear();
20826 assert!(shared
20827 .snapshot()
20828 .launch_inventory
20829 .unwrap()
20830 .spawn_profiles
20831 .unwrap()
20832 .is_empty());
20833 }
20834
20835 #[test]
20836 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20837 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20838 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20839 }));
20840 let shared = terminal_test_shared();
20841 let bundle = ResolvedBundleReceipt {
20842 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20843 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20844 digest: crate::protocol::SpawnBundleDigest::new(format!(
20845 "sha256:{}",
20846 "0".repeat(64),
20847 ))
20848 .unwrap(),
20849 };
20850 let mut bundled = record("claude", "bundle-record");
20851 bundled.bundle = Some(bundle.clone());
20852 let record_id = bundled.record_id.clone();
20853 shared.insert_record(bundled.clone()).unwrap();
20854 assert!(request_requires_session_bundle(
20855 &shared,
20856 &NodeRequest::ForgetSessionRecord {
20857 record_id: record_id.clone(),
20858 },
20859 ));
20860
20861 let snapshot = snapshot_for_wire(
20862 &shared, false, true, true, true, true, false, false, false, true,
20863 );
20864 assert!(snapshot.session_records[0].bundle.is_none());
20865 let launch = snapshot.launch_inventory.as_ref().unwrap();
20866 assert!(launch.spawn_profiles.is_some());
20867 assert!(launch.bundles.is_none());
20868 let event = project_event_without_session_bundle(NodeEventEnvelope {
20869 sequence: 1,
20870 event: NodeEvent::SessionRecordUpserted { record: bundled },
20871 });
20872 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20873 panic!("record projection changed the event kind");
20874 };
20875 assert!(record.bundle.is_none());
20876
20877 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20878 let mut reply = ResponseEnvelope {
20879 request_id: 1,
20880 result: Ok(NodeResponse::SpawnSpecAccepted {
20881 receipt: resolved.receipt_with_materialization(
20882 shared.incarnation_id,
20883 terminal_address(17),
20884 None,
20885 Some(bundle),
20886 None,
20887 ),
20888 }),
20889 };
20890 project_response_without_session_bundle(&mut reply);
20891 assert_eq!(
20892 reply.result.unwrap_err().code,
20893 NodeFailureCode::UnsupportedCapability,
20894 );
20895 }
20896
20897 #[test]
20898 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20899 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20900 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20901 }));
20902 let context_id = SpawnContextId::new("context-review").unwrap();
20903 let context = ResolvedContextPackReceipt {
20904 id: context_id.clone(),
20905 digest: crate::protocol::SpawnContextDigest::new(format!(
20906 "sha256:{}",
20907 "c".repeat(64),
20908 ))
20909 .unwrap(),
20910 lineage: ContextPackLineageReceipt {
20911 source_node_id: NodeId::new("node-source").unwrap(),
20912 source_session: terminal_address(1),
20913 source_provider: agent("kimi"),
20914 },
20915 source_message_count: 2,
20916 retained_message_count: 2,
20917 byte_len: 256,
20918 truncated: false,
20919 };
20920 assert!(context.is_valid());
20921 let mut bound_record = record("codex", "context-record");
20922 bound_record.context_id = Some(context_id);
20923 bound_record.context = Some(context.clone());
20924 let session = gate4agent_types::SessionSnapshot {
20925 instance_id: AgentInstanceId(91),
20926 agent_id: agent("kimi"),
20927 transport: TransportKind::Pty,
20928 generation: SessionGeneration(3),
20929 status: SessionStatus::Running,
20930 pending_operation: None,
20931 pending_input: None,
20932 process_id: Some(9001),
20933 terminal_size: None,
20934 terminal_frame: None,
20935 terminal_stale: None,
20936 session_options: None,
20937 capabilities: gate4agent_types::CapabilitySnapshot::default(),
20938 history: gate4agent_types::HistorySnapshot {
20939 pending: None,
20940 candidates: vec![HistoryCandidateSummary {
20941 id: "candidate-opaque".to_owned(),
20942 session_id_hint: "session-hint".to_owned(),
20943 modified_at_unix_ms: Some(1),
20944 }],
20945 loaded_candidate_id: Some("candidate-opaque".to_owned()),
20946 loaded: Some(HistorySessionRecord {
20947 session_id: "private-vendor-session".to_owned(),
20948 title: Some("private title".to_owned()),
20949 cwd: Some(r"C:\private\history-root".to_owned()),
20950 model: Some("private-model".to_owned()),
20951 message_count: 2,
20952 completed_turn_count: None,
20953 total_tokens: 19,
20954 messages: vec![
20955 gate4agent_types::HistoryMessageRecord {
20956 role: gate4agent_types::HistoryMessageRole::User,
20957 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20958 },
20959 gate4agent_types::HistoryMessageRecord {
20960 role: gate4agent_types::HistoryMessageRole::Assistant,
20961 text: "private answer".to_owned(),
20962 },
20963 ],
20964 }),
20965 last_error: None,
20966 },
20967 resume: gate4agent_types::ResumeSnapshot::default(),
20968 foreground: gate4agent_types::ForegroundSnapshot::default(),
20969 provider: gate4agent_types::ProviderSnapshot::default(),
20970 screen_state: gate4agent_types::PtyScreenState::default(),
20971 };
20972 let original = NodeSnapshot {
20973 node_id: NodeId::new("node-terminal-test").unwrap(),
20974 enabled_providers: vec![agent("kimi"), agent("codex")],
20975 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20976 workspaces: vec![WorkspaceSnapshot {
20977 workspace_id: WorkspaceId::new("primary").unwrap(),
20978 canonical_root: opaque_windows_path(
20979 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20980 ),
20981 sessions: vec![session],
20982 worktree_service_mode: None,
20983 managed_worktree_profiles: None,
20984 }],
20985 session_records: vec![bound_record],
20986 managed_worktrees: Vec::new(),
20987 launch_inventory: None,
20988 agent_progress: Vec::new(),
20989 };
20990
20991 let mut negotiated = original.clone();
20992 project_snapshot_history_for_wire(&mut negotiated, true);
20993 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20994 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20995 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20996 assert!(!serde_json::to_string(&negotiated)
20997 .unwrap()
20998 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20999
21000 let mut legacy = original;
21001 project_snapshot_history_for_wire(&mut legacy, false);
21002 clear_snapshot_context_packs(&mut legacy);
21003 assert_eq!(
21004 legacy.workspaces[0].sessions[0].history,
21005 gate4agent_types::HistorySnapshot::default(),
21006 );
21007 assert!(legacy.session_records[0].context_id.is_none());
21008 assert!(legacy.session_records[0].context.is_none());
21009 let encoded = serde_json::to_string(&legacy).unwrap();
21010 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21011 assert!(!encoded.contains("context-review"));
21012
21013 let mut reply = ResponseEnvelope {
21014 request_id: 7,
21015 result: Ok(NodeResponse::ContextPackExported { context }),
21016 };
21017 project_response_without_context_pack(&mut reply);
21018 assert_eq!(
21019 reply.result.unwrap_err().code,
21020 NodeFailureCode::UnsupportedCapability,
21021 );
21022 }
21023
21024 #[test]
21025 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21026 let shared = terminal_test_shared();
21027 let history = HistorySessionRecord {
21028 session_id: "fixture-history".to_owned(),
21029 title: None,
21030 cwd: None,
21031 model: None,
21032 message_count: 1,
21033 completed_turn_count: None,
21034 total_tokens: 0,
21035 messages: vec![gate4agent_types::HistoryMessageRecord {
21036 role: gate4agent_types::HistoryMessageRole::User,
21037 text: "bounded handoff".to_owned(),
21038 }],
21039 };
21040 let pack = NodeContextPack::export(
21041 ContextPackLineageReceipt {
21042 source_node_id: shared.node_id.clone(),
21043 source_session: terminal_address(1),
21044 source_provider: agent("claude"),
21045 },
21046 &history,
21047 )
21048 .unwrap();
21049 let context = shared
21050 .context_catalog
21051 .write()
21052 .unwrap_or_else(|poisoned| poisoned.into_inner())
21053 .insert(pack)
21054 .unwrap();
21055 let mut retained = record("codex", "context-retained-record");
21056 retained.context_id = Some(context.id.clone());
21057 retained.context = Some(context.clone());
21058 let record_id = retained.record_id.clone();
21059 shared.insert_record(retained).unwrap();
21060 assert_eq!(
21061 shared.forget_context_pack(&context.id).unwrap_err().code,
21062 NodeFailureCode::ContextPackBusy,
21063 );
21064 assert!(shared
21065 .context_catalog
21066 .read()
21067 .unwrap_or_else(|poisoned| poisoned.into_inner())
21068 .get(&context.id)
21069 .is_some());
21070 shared
21071 .session_records
21072 .lock()
21073 .unwrap_or_else(|poisoned| poisoned.into_inner())
21074 .records
21075 .remove(&record_id);
21076
21077 let mut spec = spawn_spec_fixture();
21078 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21079 value: context.id.clone(),
21080 };
21081 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21082 let receipt = resolved.receipt_with_materialization(
21083 shared.incarnation_id,
21084 terminal_address(2),
21085 None,
21086 None,
21087 Some(context.clone()),
21088 );
21089 assert!(receipt.context_binding_is_valid());
21090 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21091
21092 shared.forget_context_pack(&context.id).unwrap();
21093 assert!(shared
21094 .context_catalog
21095 .read()
21096 .unwrap_or_else(|poisoned| poisoned.into_inner())
21097 .get(&context.id)
21098 .is_none());
21099 assert!(shared
21100 .replay_spawn_spec(&spec, Instant::now())
21101 .unwrap()
21102 .is_none());
21103 }
21104
21105 #[test]
21106 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21107 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21108 let address = terminal_address(1);
21109 let selection = shared
21110 .select_environment_profile(
21111 address.session.instance_id,
21112 &agent("claude"),
21113 SessionMode::Pty,
21114 Some(&environment_profile),
21115 )
21116 .unwrap()
21117 .unwrap();
21118 shared.bind_session_with_policy(
21119 &address,
21120 ProviderRuntimePolicy::raw_pty(),
21121 Some(environment_profile.clone()),
21122 );
21123 selection.retain();
21124 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21125 assert!(matches!(
21126 runtime.remove_native_launch_profile(&native_id),
21127 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21128 ));
21129 assert!(shared.remove_binding(&address).is_some());
21130 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21131
21132 let mismatched = ResolvedEnvironmentProfileReceipt {
21133 profile_id: environment_profile.profile_id,
21134 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21135 "local-claude-r2",
21136 )
21137 .unwrap(),
21138 network_allowlist: None,
21139 browser_profile_id: None,
21140 };
21141 let error = match shared.select_environment_profile(
21142 AgentInstanceId(42),
21143 &agent("claude"),
21144 SessionMode::Pty,
21145 Some(&mismatched),
21146 ) {
21147 Ok(_) => panic!("mismatched environment profile revision was selected"),
21148 Err(error) => error,
21149 };
21150 assert_eq!(
21151 error.code,
21152 NodeFailureCode::EnvironmentProfileBindingMismatch,
21153 );
21154 assert!(!shared
21155 .session_bindings
21156 .lock()
21157 .unwrap_or_else(|poisoned| poisoned.into_inner())
21158 .contains_key(&AgentInstanceId(42)));
21159 }
21160
21161 #[test]
21162 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21163 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21164 let address = terminal_address(1);
21165 let selection = shared
21166 .select_environment_profile(
21167 address.session.instance_id,
21168 &agent("claude"),
21169 SessionMode::Pty,
21170 Some(&environment_profile),
21171 )
21172 .unwrap()
21173 .unwrap();
21174 let overlay = NativeInstanceLaunchOverlay::new(
21175 agent("claude"),
21176 TransportKind::Pty,
21177 vec![EnvMutation {
21178 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21179 value: Some(OsString::from("fixture-secret")),
21180 }],
21181 vec![
21182 OsString::from("--plugin-dir"),
21183 std::env::current_dir().unwrap().into_os_string(),
21184 ],
21185 )
21186 .unwrap();
21187 let instance_overlay = shared
21188 .install_prepared_launch_overlay(
21189 address.session.instance_id,
21190 PreparedNativeLaunchOverlay::Instance(overlay),
21191 )
21192 .unwrap()
21193 .unwrap();
21194 let bundle = ResolvedBundleReceipt {
21195 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21196 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21197 digest: crate::protocol::SpawnBundleDigest::new(format!(
21198 "sha256:{}",
21199 "0".repeat(64),
21200 ))
21201 .unwrap(),
21202 };
21203 shared.bind_session_with_materialization(
21204 &address,
21205 ProviderRuntimePolicy::raw_pty(),
21206 Some(environment_profile),
21207 Some(bundle),
21208 None,
21209 None,
21210 );
21211 selection.retain();
21212 instance_overlay.retain();
21213
21214 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21215 assert!(matches!(
21216 runtime.remove_native_launch_profile(&native_id),
21217 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21218 ));
21219 assert!(shared.remove_binding(&address).is_some());
21220 assert!(!shared
21221 .native_launch_profile_control
21222 .as_ref()
21223 .unwrap()
21224 .clear_native_instance_launch_overlay(address.session.instance_id));
21225 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21226 }
21227
21228 #[test]
21229 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21230 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21231 let address = SessionAddress {
21232 workspace_id: WorkspaceId::new("primary").unwrap(),
21233 session: SessionKey {
21234 instance_id: AgentInstanceId(201),
21235 generation: SessionGeneration(1),
21236 },
21237 };
21238 let error = match shared.prepare_session_materialization(
21239 &address,
21240 &agent("claude"),
21241 SessionMode::Pty,
21242 Some(&receipt),
21243 None,
21244 None,
21245 None,
21246 ) {
21247 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21248 Err(error) => error,
21249 };
21250 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21251 assert!(shared.handle.snapshot().sessions.is_empty());
21252 assert!(shared
21253 .session_bindings
21254 .lock()
21255 .unwrap_or_else(|poisoned| poisoned.into_inner())
21256 .is_empty());
21257 assert!(shared
21258 .materializations
21259 .lock()
21260 .unwrap_or_else(|poisoned| poisoned.into_inner())
21261 .is_empty());
21262 drop(shared);
21263 drop(runtime);
21264 std::fs::remove_dir_all(root).unwrap();
21265 }
21266
21267 #[test]
21268 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21269 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21270 let address = SessionAddress {
21271 workspace_id: WorkspaceId::new("primary").unwrap(),
21272 session: SessionKey {
21273 instance_id: AgentInstanceId(202),
21274 generation: SessionGeneration(1),
21275 },
21276 };
21277 let (overlay, materialization_guard) = shared
21278 .prepare_session_materialization(
21279 &address,
21280 &agent("claude"),
21281 SessionMode::Pty,
21282 Some(&receipt),
21283 None,
21284 None,
21285 None,
21286 )
21287 .unwrap()
21288 .unwrap();
21289 let materialization_id = materialization_guard.id().unwrap().clone();
21290 let materialization_root = shared
21291 .materializations
21292 .lock()
21293 .unwrap_or_else(|poisoned| poisoned.into_inner())
21294 .get(&materialization_id)
21295 .unwrap()
21296 .root()
21297 .to_path_buf();
21298 let selection = shared
21299 .select_environment_profile(
21300 address.session.instance_id,
21301 &agent("claude"),
21302 SessionMode::Pty,
21303 Some(&receipt),
21304 )
21305 .unwrap()
21306 .unwrap();
21307 assert!(shared
21308 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21309 .unwrap()
21310 .is_none());
21311 shared.bind_session_with_materialization(
21312 &address,
21313 ProviderRuntimePolicy::raw_pty(),
21314 Some(receipt),
21315 None,
21316 None,
21317 Some(materialization_id),
21318 );
21319 selection.retain();
21320 materialization_guard.retain();
21321
21322 assert!(materialization_root.is_dir());
21323 assert!(shared
21324 .session_bindings
21325 .lock()
21326 .unwrap_or_else(|poisoned| poisoned.into_inner())
21327 .contains_key(&address.session.instance_id));
21328
21329 let removed = shared.remove_binding(&address).unwrap();
21330 shared
21331 .cleanup_session_owned_materialization(&address, &removed)
21332 .unwrap();
21333 assert!(!materialization_root.exists());
21334 assert!(shared
21335 .materializations
21336 .lock()
21337 .unwrap_or_else(|poisoned| poisoned.into_inner())
21338 .is_empty());
21339 drop(shared);
21340 drop(runtime);
21341 std::fs::remove_dir_all(root).unwrap();
21342 }
21343
21344 #[tokio::test]
21345 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21346 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21347 let address = SessionAddress {
21348 workspace_id: WorkspaceId::new("primary").unwrap(),
21349 session: SessionKey {
21350 instance_id: AgentInstanceId(203),
21351 generation: SessionGeneration(1),
21352 },
21353 };
21354 let (overlay, materialization_guard) = shared
21355 .prepare_session_materialization(
21356 &address,
21357 &agent("claude"),
21358 SessionMode::Pty,
21359 Some(&receipt),
21360 None,
21361 None,
21362 None,
21363 )
21364 .unwrap()
21365 .unwrap();
21366 let materialization_id = materialization_guard.id().unwrap().clone();
21367 let materialization_root = shared
21368 .materializations
21369 .lock()
21370 .unwrap_or_else(|poisoned| poisoned.into_inner())
21371 .get(&materialization_id)
21372 .unwrap()
21373 .root()
21374 .to_path_buf();
21375 let selection = shared
21376 .select_environment_profile(
21377 address.session.instance_id,
21378 &agent("claude"),
21379 SessionMode::Pty,
21380 Some(&receipt),
21381 )
21382 .unwrap()
21383 .unwrap();
21384 assert!(shared
21385 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21386 .unwrap()
21387 .is_none());
21388 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21389 let record_id = shared
21390 .bind_spawn_session_with_materialization(
21391 &address,
21392 agent("claude"),
21393 SessionMode::Pty,
21394 policy,
21395 SpawnRecordPolicy::ProviderIdentityOnly,
21396 Some(receipt.clone()),
21397 None,
21398 None,
21399 Some(materialization_id.clone()),
21400 )
21401 .unwrap()
21402 .unwrap();
21403 selection.retain();
21404 materialization_guard.retain();
21405 assert_eq!(
21406 shared
21407 .materializations
21408 .lock()
21409 .unwrap_or_else(|poisoned| poisoned.into_inner())
21410 .get(&materialization_id)
21411 .unwrap()
21412 .owner(),
21413 &MaterializationOwner::Record {
21414 record_id: record_id.clone(),
21415 },
21416 );
21417
21418 let removed = shared.remove_binding(&address).unwrap();
21419 shared
21420 .cleanup_session_owned_materialization(&address, &removed)
21421 .unwrap();
21422 assert!(materialization_root.is_dir());
21423 assert!(shared
21424 .resolve_record_materialization(
21425 &record_id,
21426 &agent("claude"),
21427 SessionMode::Pty,
21428 Some(&receipt),
21429 None,
21430 None,
21431 )
21432 .unwrap()
21433 .is_some());
21434 {
21435 let mut records = shared
21436 .session_records
21437 .lock()
21438 .unwrap_or_else(|poisoned| poisoned.into_inner());
21439 let record = records.records.get_mut(&record_id).unwrap();
21440 record.active_session = None;
21441 record.state = ManagedSessionState::Unavailable;
21442 }
21443 shared.forget_session_record(&record_id).await.unwrap();
21444 assert!(!materialization_root.exists());
21445 assert!(shared
21446 .materializations
21447 .lock()
21448 .unwrap_or_else(|poisoned| poisoned.into_inner())
21449 .is_empty());
21450 drop(shared);
21451 drop(runtime);
21452 std::fs::remove_dir_all(root).unwrap();
21453 }
21454
21455 #[test]
21456 fn record_materialization_revalidation_failure_persists_recovery_required() {
21457 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21458 let state_path = root.join("node-state-v6.json");
21459 shared.state_path = Some(state_path.clone());
21460 let (_address, _record_id, materialization_id, materialization_root) =
21461 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21462 std::fs::write(
21463 materialization_root.join(".gate4agent-materialization-owner"),
21464 b"tampered",
21465 )
21466 .unwrap();
21467
21468 let error = match shared.resolve_record_materialization(
21469 &_record_id,
21470 &agent("claude"),
21471 SessionMode::Pty,
21472 Some(&receipt),
21473 None,
21474 None,
21475 ) {
21476 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21477 Err(error) => error,
21478 };
21479 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21480 assert_eq!(
21481 shared
21482 .materializations
21483 .lock()
21484 .unwrap_or_else(|poisoned| poisoned.into_inner())
21485 .get(&materialization_id)
21486 .unwrap()
21487 .state(),
21488 MaterializationState::RecoveryRequired,
21489 );
21490 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21491 assert_eq!(loaded.materializations.len(), 1);
21492 assert_eq!(
21493 loaded.materializations[0].state(),
21494 MaterializationState::RecoveryRequired,
21495 );
21496 drop(shared);
21497 drop(runtime);
21498 std::fs::remove_dir_all(root).unwrap();
21499 }
21500
21501 #[test]
21502 fn transient_record_materialization_resolution_failure_remains_ready() {
21503 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21504 let (_address, record_id, materialization_id, _materialization_root) =
21505 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21506 deny.store(true, Ordering::Release);
21507
21508 let error = match shared.resolve_record_materialization(
21509 &record_id,
21510 &agent("claude"),
21511 SessionMode::Pty,
21512 Some(&receipt),
21513 None,
21514 None,
21515 ) {
21516 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21517 Err(error) => error,
21518 };
21519 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21520 assert_eq!(
21521 shared
21522 .materializations
21523 .lock()
21524 .unwrap_or_else(|poisoned| poisoned.into_inner())
21525 .get(&materialization_id)
21526 .unwrap()
21527 .state(),
21528 MaterializationState::Ready,
21529 );
21530 drop(shared);
21531 drop(runtime);
21532 std::fs::remove_dir_all(root).unwrap();
21533 }
21534
21535 #[test]
21536 fn startup_missing_record_materialization_is_not_resumable() {
21537 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21538 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21539 shared
21540 .insert_record(ManagedSessionRecord {
21541 record_id: record_id.clone(),
21542 display_name: "missing materialization".to_owned(),
21543 provider: agent("claude"),
21544 mode: SessionMode::Pty,
21545 state: ManagedSessionState::Dormant,
21546 workspace_id: WorkspaceId::new("primary").unwrap(),
21547 canonical_root: opaque_windows_path(
21548 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21549 ),
21550 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21551 key: gate4agent_types::ProviderSessionKey::SessionId,
21552 id: "provider-session-missing-materialization".to_owned(),
21553 transcript_path: None,
21554 }),
21555 active_session: None,
21556 environment_profile: Some(receipt),
21557 bundle: None,
21558 context_id: None,
21559 context: None,
21560 exported_context: None,
21561 task_binding: None,
21562 created_at_unix_ms: 1,
21563 updated_at_unix_ms: 1,
21564 last_error: None,
21565 })
21566 .unwrap();
21567
21568 shared.reconcile_materializations();
21569 let record = shared.record(&record_id).unwrap();
21570 assert_eq!(record.state, ManagedSessionState::Unavailable);
21571 assert!(record.provider_session.is_none());
21572 assert_eq!(
21573 record.last_error.as_deref(),
21574 Some("environment-profile-unavailable"),
21575 );
21576 drop(shared);
21577 drop(runtime);
21578 std::fs::remove_dir_all(root).unwrap();
21579 }
21580
21581 #[tokio::test]
21582 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21583 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21584 let lease = install_managed_lease(&shared);
21585 let address = SessionAddress {
21586 workspace_id: WorkspaceId::new("primary").unwrap(),
21587 session: SessionKey {
21588 instance_id: AgentInstanceId(204),
21589 generation: SessionGeneration(1),
21590 },
21591 };
21592 let (overlay, materialization_guard) = shared
21593 .prepare_session_materialization(
21594 &address,
21595 &agent("claude"),
21596 SessionMode::Pty,
21597 Some(&receipt),
21598 None,
21599 None,
21600 Some(lease.lease_id.clone()),
21601 )
21602 .unwrap()
21603 .unwrap();
21604 let materialization_id = materialization_guard.id().unwrap().clone();
21605 let materialization_root = shared
21606 .materializations
21607 .lock()
21608 .unwrap_or_else(|poisoned| poisoned.into_inner())
21609 .get(&materialization_id)
21610 .unwrap()
21611 .root()
21612 .to_path_buf();
21613 let selection = shared
21614 .select_environment_profile(
21615 address.session.instance_id,
21616 &agent("claude"),
21617 SessionMode::Pty,
21618 Some(&receipt),
21619 )
21620 .unwrap()
21621 .unwrap();
21622 assert!(shared
21623 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21624 .unwrap()
21625 .is_none());
21626 shared.bind_session_with_materialization(
21627 &address,
21628 ProviderRuntimePolicy::raw_pty(),
21629 Some(receipt),
21630 None,
21631 None,
21632 Some(materialization_id),
21633 );
21634 selection.retain();
21635 materialization_guard.retain();
21636
21637 let blocked = shared
21638 .cleanup_managed_worktree(&lease.lease_id, false)
21639 .await
21640 .unwrap_err();
21641 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21642 assert!(materialization_root.is_dir());
21643 let removed = shared.remove_binding(&address).unwrap();
21644 shared
21645 .cleanup_session_owned_materialization(&address, &removed)
21646 .unwrap();
21647 assert!(!materialization_root.exists());
21648 drop(shared);
21649 drop(runtime);
21650 std::fs::remove_dir_all(root).unwrap();
21651 }
21652
21653 #[tokio::test]
21654 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21655 let git_root = temporary_workspace_root("raw-managed-inventory");
21656 let source = git_root.join("source");
21657 let allocation = git_root.join("allocation");
21658 std::fs::create_dir_all(&source).unwrap();
21659 std::fs::create_dir_all(&allocation).unwrap();
21660 run_test_git(&source, &["init"]);
21661 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21662 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21663 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21664 run_test_git(&source, &["add", "seed.txt"]);
21665 run_test_git(&source, &["commit", "-m", "seed"]);
21666 let profile = ManagedWorktreeProfile::new(
21667 WorktreeProfileId::new("default").unwrap(),
21668 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21669 &allocation,
21670 "gate4agent",
21671 "HEAD",
21672 ManagedWorktreeRetention::RemoveWhenReleased,
21673 )
21674 .unwrap();
21675 let workspace = WorkspaceConfig::new(
21676 WorkspaceId::new("primary").unwrap(),
21677 &source,
21678 )
21679 .unwrap()
21680 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21681 .with_managed_worktree_profile(profile.clone())
21682 .unwrap();
21683 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21684 materialization_test_shared_with_workspace(false, workspace);
21685 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21686 let source_text = source.to_string_lossy().into_owned();
21687 let target_text = target.to_string_lossy().into_owned();
21688 let created = create_git_worktree(
21689 &source_text,
21690 &target_text,
21691 "gate4agent/mw-raw-inventory",
21692 Some("HEAD"),
21693 )
21694 .await
21695 .unwrap();
21696 let lease = ManagedWorktreeLeaseRecord {
21697 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21698 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21699 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21700 profile_id: profile.profile_id().clone(),
21701 profile_revision: profile.revision().clone(),
21702 target_root: created.path.clone(),
21703 branch: "gate4agent/mw-raw-inventory".to_owned(),
21704 base_commit: created.head.clone(),
21705 expected_head: Some(created.head.clone()),
21706 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21707 state: ManagedWorktreeLeaseState::Ready,
21708 session_holders: Vec::new(),
21709 record_holders: Vec::new(),
21710 cleanup_failure: None,
21711 created_at_unix_ms: 1,
21712 updated_at_unix_ms: 1,
21713 };
21714 shared
21715 .workspaces
21716 .write()
21717 .unwrap_or_else(|poisoned| poisoned.into_inner())
21718 .insert(lease.workspace_id.clone(), created.path.clone());
21719 *shared
21720 .managed_worktrees
21721 .lock()
21722 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21723 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21724 let address = SessionAddress {
21725 workspace_id: lease.workspace_id.clone(),
21726 session: SessionKey {
21727 instance_id: AgentInstanceId(205),
21728 generation: SessionGeneration(1),
21729 },
21730 };
21731 let (overlay, materialization_guard) = shared
21732 .prepare_session_materialization(
21733 &address,
21734 &agent("claude"),
21735 SessionMode::Pty,
21736 Some(&receipt),
21737 None,
21738 None,
21739 Some(lease.lease_id.clone()),
21740 )
21741 .unwrap()
21742 .unwrap();
21743 let materialization_id = materialization_guard.id().unwrap().clone();
21744 let materialization_root = shared
21745 .materializations
21746 .lock()
21747 .unwrap_or_else(|poisoned| poisoned.into_inner())
21748 .get(&materialization_id)
21749 .unwrap()
21750 .root()
21751 .to_path_buf();
21752 let selection = shared
21753 .select_environment_profile(
21754 address.session.instance_id,
21755 &agent("claude"),
21756 SessionMode::Pty,
21757 Some(&receipt),
21758 )
21759 .unwrap()
21760 .unwrap();
21761 assert!(shared
21762 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21763 .unwrap()
21764 .is_none());
21765 let record_id = shared
21766 .bind_spawn_session_with_materialization(
21767 &address,
21768 agent("claude"),
21769 SessionMode::Pty,
21770 ProviderRuntimePolicy::raw_pty(),
21771 SpawnRecordPolicy::Always,
21772 Some(receipt.clone()),
21773 None,
21774 None,
21775 Some(materialization_id.clone()),
21776 )
21777 .unwrap()
21778 .unwrap();
21779 selection.retain();
21780 materialization_guard.retain();
21781 let record = shared.record(&record_id).unwrap();
21782 assert_eq!(record.state, ManagedSessionState::Live);
21783 assert!(record.provider_session.is_none());
21784 assert!(record.environment_profile.is_none());
21785 assert!(record.bundle.is_none());
21786 assert!(record.context_id.is_none());
21787 assert!(record.context.is_none());
21788 assert_eq!(record.active_session.as_ref(), Some(&address));
21789 assert_eq!(
21790 shared
21791 .materializations
21792 .lock()
21793 .unwrap_or_else(|poisoned| poisoned.into_inner())
21794 .get(&materialization_id)
21795 .unwrap()
21796 .owner(),
21797 &MaterializationOwner::Session {
21798 incarnation_id: shared.incarnation_id,
21799 instance_id: address.session.instance_id,
21800 generation: address.session.generation,
21801 },
21802 );
21803 {
21804 let mut bindings = shared
21805 .session_bindings
21806 .lock()
21807 .unwrap_or_else(|poisoned| poisoned.into_inner());
21808 bindings
21809 .get_mut(&address.session.instance_id)
21810 .unwrap()
21811 .managed_worktree_lease_id = Some(lease.lease_id.clone());
21812 }
21813 let lease_snapshot = shared
21814 .managed_worktrees
21815 .lock()
21816 .unwrap_or_else(|poisoned| poisoned.into_inner())
21817 .bind_session(
21818 &lease.lease_id,
21819 ManagedWorktreeSessionHolder {
21820 incarnation_id: shared.incarnation_id,
21821 instance_id: address.session.instance_id,
21822 generation: address.session.generation,
21823 },
21824 None,
21825 unix_time_ms(),
21826 )
21827 .unwrap();
21828 assert_eq!(lease_snapshot.active_session_count, 1);
21829 assert_eq!(lease_snapshot.managed_record_count, 0);
21830 shared.persist_state().unwrap();
21831 assert!(shared
21832 .managed_worktrees
21833 .lock()
21834 .unwrap_or_else(|poisoned| poisoned.into_inner())
21835 .get(&lease.lease_id)
21836 .unwrap()
21837 .record_holders
21838 .is_empty());
21839 shared
21840 .handle
21841 .dispatch(shared.prepare_command(ControlCommand::Register {
21842 instance_id: address.session.instance_id,
21843 agent_id: agent("claude"),
21844 transport: TransportKind::Pty,
21845 }))
21846 .unwrap();
21847 runtime.tick().await;
21848 shared.reconcile_managed_record(
21849 &record_id,
21850 &address,
21851 &ControlEvent {
21852 sequence: 1,
21853 command_id: None,
21854 instance_id: address.session.instance_id,
21855 generation: address.session.generation,
21856 event: ControlEventKind::Removed,
21857 },
21858 false,
21859 false,
21860 );
21861 let remove = shared.remove_session(&address);
21862 let drive_runtime = async {
21863 runtime.tick().await;
21864 };
21865 let (removed, ()) = tokio::join!(remove, drive_runtime);
21866 removed.unwrap();
21867
21868 assert!(!materialization_root.exists());
21869 assert!(!Path::new(&created.path).exists());
21870 assert_eq!(
21871 shared
21872 .managed_worktrees
21873 .lock()
21874 .unwrap_or_else(|poisoned| poisoned.into_inner())
21875 .get(&lease.lease_id)
21876 .unwrap()
21877 .state,
21878 ManagedWorktreeLeaseState::Removed,
21879 );
21880 let renamed = shared
21881 .rename_session_record(&record_id, "raw managed inventory".to_owned())
21882 .unwrap();
21883 assert_eq!(renamed.record_id, record_id);
21884 assert_eq!(renamed.display_name, "raw managed inventory");
21885 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21886 assert!(renamed.active_session.is_none());
21887
21888 drop(shared);
21889 drop(runtime);
21890 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21891 std::fs::remove_dir_all(git_root).unwrap();
21892 }
21893
21894 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21895 let root = std::env::current_dir().unwrap()
21896 .join("managed-test-target")
21897 .to_string_lossy()
21898 .into_owned();
21899 let lease = ManagedWorktreeLeaseRecord {
21900 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21901 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21902 workspace_id: WorkspaceId::new("managed-test").unwrap(),
21903 profile_id: WorktreeProfileId::new("default").unwrap(),
21904 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21905 target_root: root.clone(),
21906 branch: "gate4agent/mw-test".to_owned(),
21907 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21908 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21909 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21910 state: ManagedWorktreeLeaseState::Ready,
21911 session_holders: Vec::new(),
21912 record_holders: Vec::new(),
21913 cleanup_failure: None,
21914 created_at_unix_ms: 1,
21915 updated_at_unix_ms: 1,
21916 };
21917 shared.workspaces.write()
21918 .unwrap_or_else(|poisoned| poisoned.into_inner())
21919 .insert(lease.workspace_id.clone(), root);
21920 *shared.managed_worktrees.lock()
21921 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21922 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21923 lease
21924 }
21925
21926 #[cfg(feature = "fixture")]
21927 #[test]
21928 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21929 let mut shared = terminal_test_shared();
21930 shared.fixture_semantic_hook_policy = true;
21931 let lease = install_managed_lease(&shared);
21932 let address = SessionAddress {
21933 workspace_id: lease.workspace_id.clone(),
21934 session: SessionKey {
21935 instance_id: AgentInstanceId(206),
21936 generation: SessionGeneration(1),
21937 },
21938 };
21939 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21940 let effective_runtime_policy =
21941 shared.effective_spawn_runtime_policy(pre_admission_policy);
21942 assert_ne!(effective_runtime_policy, pre_admission_policy);
21943 assert!(effective_runtime_policy.provider_session_identity);
21944
21945 let record_id = shared
21946 .bind_spawn_session(
21947 &address,
21948 agent("claude"),
21949 SessionMode::Pty,
21950 effective_runtime_policy,
21951 SpawnRecordPolicy::Always,
21952 None,
21953 )
21954 .unwrap()
21955 .unwrap();
21956 assert_eq!(
21957 shared.record(&record_id).unwrap().state,
21958 ManagedSessionState::IdentityPending,
21959 );
21960 let (record_holder, raw_inventory_record_id) = {
21961 let mut bindings = shared
21962 .session_bindings
21963 .lock()
21964 .unwrap_or_else(|poisoned| poisoned.into_inner());
21965 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21966 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21967 managed_spawn_record_ownership(binding, effective_runtime_policy)
21968 };
21969 assert_eq!(record_holder.as_ref(), Some(&record_id));
21970 assert!(raw_inventory_record_id.is_none());
21971 let snapshot = shared
21972 .managed_worktrees
21973 .lock()
21974 .unwrap_or_else(|poisoned| poisoned.into_inner())
21975 .bind_session(
21976 &lease.lease_id,
21977 ManagedWorktreeSessionHolder {
21978 incarnation_id: shared.incarnation_id,
21979 instance_id: address.session.instance_id,
21980 generation: address.session.generation,
21981 },
21982 record_holder,
21983 unix_time_ms(),
21984 )
21985 .unwrap();
21986 assert_eq!(snapshot.managed_record_count, 1);
21987 assert_eq!(
21988 shared
21989 .managed_worktrees
21990 .lock()
21991 .unwrap_or_else(|poisoned| poisoned.into_inner())
21992 .get(&lease.lease_id)
21993 .unwrap()
21994 .record_holders,
21995 vec![record_id.clone()],
21996 );
21997
21998 let identity = ProviderSessionIdentity {
21999 key: ProviderSessionKey::SessionId,
22000 id: "monitoring-fixture-managed-session".to_owned(),
22001 transcript_path: None,
22002 };
22003 shared.publish_control(ControlEvent {
22004 sequence: 1,
22005 command_id: None,
22006 instance_id: address.session.instance_id,
22007 generation: address.session.generation,
22008 event: ControlEventKind::ProviderEvent {
22009 sequence: 1,
22010 source: gate4agent_types::ProviderSource {
22011 family: AdapterFamily::Hook,
22012 binding: AdapterBinding::new(
22013 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22014 "fixture/v1",
22015 gate4agent_types::AdapterVerification::SyntheticFixture,
22016 )
22017 .unwrap(),
22018 },
22019 source_sequence: 1,
22020 event: ProviderEvent::SessionIdentityObserved {
22021 identity: identity.clone(),
22022 },
22023 },
22024 });
22025
22026 let live = shared.record(&record_id).unwrap();
22027 assert_eq!(live.state, ManagedSessionState::Live);
22028 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22029 assert_eq!(live.active_session.as_ref(), Some(&address));
22030 assert_eq!(
22031 shared
22032 .managed_worktrees
22033 .lock()
22034 .unwrap_or_else(|poisoned| poisoned.into_inner())
22035 .get(&lease.lease_id)
22036 .unwrap()
22037 .record_holders,
22038 vec![record_id],
22039 );
22040 }
22041
22042 fn managed_v2_test_shared(
22043 allocation_root: &Path,
22044 state_path: Option<PathBuf>,
22045 profile_revision: &str,
22046 ) -> NodeShared {
22047 let profile = ManagedWorktreeProfile::new(
22048 WorktreeProfileId::new("default").unwrap(),
22049 WorktreeProfileRevision::new(profile_revision).unwrap(),
22050 allocation_root,
22051 "gate4agent",
22052 "HEAD",
22053 ManagedWorktreeRetention::RemoveWhenReleased,
22054 )
22055 .unwrap();
22056 let workspace = WorkspaceConfig::new(
22057 WorkspaceId::new("primary").unwrap(),
22058 std::env::current_dir().unwrap(),
22059 )
22060 .unwrap()
22061 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22062 .with_managed_worktree_profile(profile)
22063 .unwrap();
22064 let catalog = active_registry().unwrap();
22065 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22066 let mut shared = NodeShared::new(
22067 handle,
22068 "fixture-token".to_owned(),
22069 NodeId::new("node-terminal-test").unwrap(),
22070 vec![workspace],
22071 vec![agent("claude")],
22072 );
22073 shared.state_path = state_path;
22074 shared
22075 }
22076
22077 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22078 let mut spawn_spec = spawn_spec_fixture();
22079 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22080 ManagedWorktreeSpawnRequestV2 {
22081 spawn_spec,
22082 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22083 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22084 }
22085 }
22086
22087 #[test]
22088 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22089 let shared = terminal_test_shared();
22090 let mut lease = install_managed_lease(&shared);
22091 let session = SessionAddress {
22092 workspace_id: lease.workspace_id.clone(),
22093 session: SessionKey {
22094 instance_id: AgentInstanceId(73),
22095 generation: SessionGeneration(4),
22096 },
22097 };
22098 lease.state = ManagedWorktreeLeaseState::InUse;
22099 lease.session_holders.push(ManagedWorktreeSessionHolder {
22100 incarnation_id: shared.incarnation_id,
22101 instance_id: session.session.instance_id,
22102 generation: session.session.generation,
22103 });
22104 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22105 assert!(resolved.target.worktree_id.is_none());
22106
22107 let receipt = managed_spawn_receipt(
22108 &resolved,
22109 shared.incarnation_id,
22110 session.clone(),
22111 lease.snapshot(),
22112 None,
22113 None,
22114 None,
22115 );
22116 assert!(resolved.target.worktree_id.is_none());
22117 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22118 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22119 assert_eq!(receipt.spawn.session, session);
22120 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22121 let encoded = serde_json::to_vec(&receipt).unwrap();
22122 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22123 assert_eq!(decoded, receipt);
22124 }
22125
22126 #[tokio::test]
22127 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22128 let root = temporary_workspace_root("managed-v2-revision");
22129 let allocation = root.join("allocation");
22130 std::fs::create_dir_all(&allocation).unwrap();
22131 let shared = managed_v2_test_shared(
22132 &allocation,
22133 Some(root.join("node-state.json")),
22134 "v1",
22135 );
22136 let mut request = managed_v2_request("managed-v2-revision");
22137 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22138
22139 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22140
22141 assert_eq!(
22142 failure.code,
22143 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22144 );
22145 assert!(shared
22146 .managed_worktrees
22147 .lock()
22148 .unwrap_or_else(|poisoned| poisoned.into_inner())
22149 .records()
22150 .is_empty());
22151 assert!(shared
22152 .managed_spawn_replays
22153 .lock()
22154 .unwrap_or_else(|poisoned| poisoned.into_inner())
22155 .is_empty());
22156 std::fs::remove_dir_all(root).unwrap();
22157 }
22158
22159 #[test]
22160 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22161 let root = temporary_workspace_root("managed-v2-concurrent");
22162 let allocation = root.join("allocation");
22163 std::fs::create_dir_all(&allocation).unwrap();
22164 let shared = Arc::new(managed_v2_test_shared(
22165 &allocation,
22166 Some(root.join("node-state.json")),
22167 "v1",
22168 ));
22169 let request = managed_v2_request("managed-v2-concurrent");
22170 let barrier = Arc::new(std::sync::Barrier::new(3));
22171 let mut joins = Vec::new();
22172 for _ in 0..2 {
22173 let shared = Arc::clone(&shared);
22174 let request = request.clone();
22175 let barrier = Arc::clone(&barrier);
22176 joins.push(std::thread::spawn(move || {
22177 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22178 barrier.wait();
22179 shared.acquire_durable_managed_spawn_v2(&request, digest)
22180 }));
22181 }
22182 barrier.wait();
22183 let results = joins
22184 .into_iter()
22185 .map(|join| join.join().unwrap())
22186 .collect::<Vec<_>>();
22187 assert_eq!(
22188 results
22189 .iter()
22190 .filter(|result| {
22191 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22192 })
22193 .count(),
22194 1,
22195 );
22196 assert_eq!(
22197 results
22198 .iter()
22199 .filter(|result| {
22200 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22201 })
22202 .count(),
22203 1,
22204 );
22205 assert_eq!(
22206 shared
22207 .managed_spawn_replays
22208 .lock()
22209 .unwrap_or_else(|poisoned| poisoned.into_inner())
22210 .len(),
22211 1,
22212 );
22213 assert!(shared
22214 .managed_worktrees
22215 .lock()
22216 .unwrap_or_else(|poisoned| poisoned.into_inner())
22217 .records()
22218 .is_empty());
22219 drop(shared);
22220 std::fs::remove_dir_all(root).unwrap();
22221 }
22222
22223 #[tokio::test]
22224 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22225 let root = temporary_workspace_root("managed-v2-linked-pending");
22226 let allocation = root.join("allocation");
22227 std::fs::create_dir_all(&allocation).unwrap();
22228 let state_path = root.join("node-state.json");
22229 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22230 let lease = install_managed_lease(&shared);
22231 let request = managed_v2_request("managed-v2-linked-pending");
22232 assert!(matches!(
22233 shared
22234 .acquire_durable_managed_spawn_v2(
22235 &request,
22236 managed_spawn_request_digest_v2(&request).unwrap(),
22237 )
22238 .unwrap(),
22239 DurableManagedSpawnReplayDecision::Reserved,
22240 ));
22241 {
22242 let _transaction = shared
22243 .state_transaction
22244 .lock()
22245 .unwrap_or_else(|poisoned| poisoned.into_inner());
22246 shared
22247 .link_durable_managed_spawn_v2_lease_locked(
22248 &request.spawn_spec.idempotency_key,
22249 &lease.lease_id,
22250 )
22251 .unwrap();
22252 shared.persist_state_locked().unwrap();
22253 }
22254 drop(shared);
22255
22256 let mut loaded = session_registry::load(
22257 Some(&state_path),
22258 &NodeId::new("node-terminal-test").unwrap(),
22259 )
22260 .unwrap();
22261 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22262 *reopened
22263 .managed_worktrees
22264 .lock()
22265 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22266 ManagedWorktreeRegistry::from_records(
22267 std::mem::take(&mut loaded.managed_worktrees),
22268 std::mem::take(&mut loaded.managed_worktree_tombstones),
22269 )
22270 .unwrap();
22271 *reopened
22272 .managed_spawn_replays
22273 .lock()
22274 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22275 .managed_spawn_replays
22276 .into_iter()
22277 .map(|record| (record.idempotency_key.clone(), record))
22278 .collect();
22279 let lease_count = reopened
22280 .managed_worktrees
22281 .lock()
22282 .unwrap_or_else(|poisoned| poisoned.into_inner())
22283 .records()
22284 .len();
22285
22286 let failure = reopened
22287 .spawn_managed_worktree_v2(request)
22288 .await
22289 .unwrap_err();
22290
22291 assert_eq!(
22292 failure.code,
22293 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22294 );
22295 assert_eq!(
22296 reopened
22297 .managed_worktrees
22298 .lock()
22299 .unwrap_or_else(|poisoned| poisoned.into_inner())
22300 .records()
22301 .len(),
22302 lease_count,
22303 );
22304 std::fs::remove_dir_all(root).unwrap();
22305 }
22306
22307 #[tokio::test]
22308 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22309 let root = temporary_workspace_root("managed-v2-reopen");
22310 let allocation = root.join("allocation");
22311 std::fs::create_dir_all(&allocation).unwrap();
22312 let state_path = root.join("node-state.json");
22313 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22314 let lease = install_managed_lease(&shared);
22315 let request = managed_v2_request("managed-v2-reopen");
22316 let session = SessionAddress {
22317 workspace_id: lease.workspace_id.clone(),
22318 session: SessionKey {
22319 instance_id: AgentInstanceId(91),
22320 generation: SessionGeneration(1),
22321 },
22322 };
22323 let snapshot = {
22324 let mut registry = shared
22325 .managed_worktrees
22326 .lock()
22327 .unwrap_or_else(|poisoned| poisoned.into_inner());
22328 let current = registry.get_mut(&lease.lease_id).unwrap();
22329 current.state = ManagedWorktreeLeaseState::InUse;
22330 current.session_holders.push(ManagedWorktreeSessionHolder {
22331 incarnation_id: shared.incarnation_id,
22332 instance_id: session.session.instance_id,
22333 generation: session.session.generation,
22334 });
22335 current.snapshot()
22336 };
22337 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22338 let receipt = managed_spawn_receipt(
22339 &resolved,
22340 shared.incarnation_id,
22341 session,
22342 snapshot,
22343 None,
22344 None,
22345 None,
22346 );
22347 assert!(matches!(
22348 shared
22349 .acquire_durable_managed_spawn_v2(
22350 &request,
22351 managed_spawn_request_digest_v2(&request).unwrap(),
22352 )
22353 .unwrap(),
22354 DurableManagedSpawnReplayDecision::Reserved,
22355 ));
22356 {
22357 let _transaction = shared
22358 .state_transaction
22359 .lock()
22360 .unwrap_or_else(|poisoned| poisoned.into_inner());
22361 shared
22362 .link_durable_managed_spawn_v2_lease_locked(
22363 &request.spawn_spec.idempotency_key,
22364 &lease.lease_id,
22365 )
22366 .unwrap();
22367 shared.persist_state_locked().unwrap();
22368 }
22369 shared
22370 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22371 .unwrap();
22372 let legacy_same_key = ManagedWorktreeSpawnRequest {
22373 spawn_spec: request.spawn_spec.clone(),
22374 worktree_profile_id: request.worktree_profile_id.clone(),
22375 };
22376 shared.remember_managed_spawn_attempt(
22377 Some(&request.spawn_spec.idempotency_key),
22378 legacy_same_key.clone(),
22379 Ok(receipt.clone()),
22380 Instant::now(),
22381 );
22382 assert!(shared
22383 .replay_managed_spawn(&legacy_same_key, Instant::now())
22384 .unwrap()
22385 .is_none());
22386 drop(shared);
22387
22388 let mut loaded = session_registry::load(
22389 Some(&state_path),
22390 &NodeId::new("node-terminal-test").unwrap(),
22391 )
22392 .unwrap();
22393 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22394 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22395 *reopened
22396 .managed_worktrees
22397 .lock()
22398 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22399 ManagedWorktreeRegistry::from_records(
22400 std::mem::take(&mut loaded.managed_worktrees),
22401 std::mem::take(&mut loaded.managed_worktree_tombstones),
22402 )
22403 .unwrap();
22404 *reopened
22405 .managed_spawn_replays
22406 .lock()
22407 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22408 .managed_spawn_replays
22409 .into_iter()
22410 .map(|record| (record.idempotency_key.clone(), record))
22411 .collect();
22412 let lease_count_before = reopened
22413 .managed_worktrees
22414 .lock()
22415 .unwrap_or_else(|poisoned| poisoned.into_inner())
22416 .records()
22417 .len();
22418
22419 assert_eq!(
22420 reopened
22421 .spawn_managed_worktree_v2(request.clone())
22422 .await
22423 .unwrap(),
22424 receipt,
22425 );
22426 assert_eq!(
22427 reopened
22428 .managed_worktrees
22429 .lock()
22430 .unwrap_or_else(|poisoned| poisoned.into_inner())
22431 .records()
22432 .len(),
22433 lease_count_before,
22434 );
22435 let mut changed = request;
22436 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22437 let failure = reopened
22438 .spawn_managed_worktree_v2(changed)
22439 .await
22440 .unwrap_err();
22441 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22442 assert_eq!(
22443 reopened
22444 .managed_worktrees
22445 .lock()
22446 .unwrap_or_else(|poisoned| poisoned.into_inner())
22447 .records()
22448 .len(),
22449 lease_count_before,
22450 );
22451 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22452 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22453 let failure = reopened
22454 .spawn_managed_worktree_v2(new_key)
22455 .await
22456 .unwrap_err();
22457 assert_eq!(
22458 failure.code,
22459 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22460 );
22461 assert_eq!(
22462 reopened
22463 .managed_worktrees
22464 .lock()
22465 .unwrap_or_else(|poisoned| poisoned.into_inner())
22466 .records()
22467 .len(),
22468 lease_count_before,
22469 );
22470 assert_eq!(
22471 reopened
22472 .managed_spawn_replays
22473 .lock()
22474 .unwrap_or_else(|poisoned| poisoned.into_inner())
22475 .len(),
22476 1,
22477 );
22478 std::fs::remove_dir_all(root).unwrap();
22479 }
22480
22481 #[test]
22482 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22483 let shared = terminal_test_shared();
22484 let request = ManagedWorktreeSpawnRequest {
22485 spawn_spec: spawn_spec_fixture(),
22486 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22487 };
22488 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22489 shared.remember_managed_spawn(
22490 request.clone(),
22491 Err(expected.clone()),
22492 Instant::now(),
22493 );
22494
22495 assert_eq!(
22496 shared
22497 .replay_managed_spawn(&request, Instant::now())
22498 .unwrap()
22499 .unwrap(),
22500 Err(expected),
22501 );
22502 assert!(shared
22503 .managed_spawn_replays
22504 .lock()
22505 .unwrap_or_else(|poisoned| poisoned.into_inner())
22506 .is_empty());
22507 }
22508
22509 #[tokio::test]
22510 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22511 let shared = terminal_test_shared();
22512 let lease = install_managed_lease(&shared);
22513 let raw = shared.spawn_session(
22514 lease.workspace_id.clone(),
22515 agent("claude"),
22516 SessionMode::Pty,
22517 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22518 None,
22519 ).await.unwrap_err();
22520 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22521 let mut spec = spawn_spec_fixture();
22522 spec.target.workspace_id = lease.workspace_id.clone();
22523 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22524 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22525 let mut selected = spawn_spec_fixture();
22526 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22527 selected.target.worktree_id = Some(lease.workspace_id);
22528 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22529 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22530 assert!(shared.handle.snapshot().sessions.is_empty());
22531 }
22532
22533 #[test]
22534 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22535 let shared = terminal_test_shared();
22536 let lease = install_managed_lease(&shared);
22537 let snapshot = snapshot_for_wire(
22538 &shared, true, true, true, false, true, true, false, false, true,
22539 );
22540 assert!(snapshot.managed_worktrees.is_empty());
22541 assert!(snapshot.workspaces.iter().all(|workspace| {
22542 workspace.managed_worktree_profiles.is_none()
22543 }));
22544 let mut reply = ResponseEnvelope {
22545 request_id: 1,
22546 result: Ok(NodeResponse::Resync {
22547 event_sequence: 1,
22548 oldest_available_sequence: 1,
22549 snapshot: shared.snapshot(),
22550 events: vec![NodeEventEnvelope {
22551 sequence: 1,
22552 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22553 }],
22554 }),
22555 };
22556 project_response_without_managed_worktrees(&mut reply);
22557 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22558 panic!("projection changed the response kind");
22559 };
22560 assert!(snapshot.managed_worktrees.is_empty());
22561 assert!(events.is_empty());
22562 let mut git = git_snapshot_for_parser();
22563 git.branch = Some(lease.branch.clone());
22564 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22565 lease_id: lease.lease_id.clone(),
22566 source_workspace_id: lease.source_workspace_id.clone(),
22567 branch: lease.branch.clone(),
22568 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22569 active_session_count: 1,
22570 managed_record_count: 0,
22571 });
22572 let mut inspection_reply = ResponseEnvelope {
22573 request_id: 2,
22574 result: Ok(NodeResponse::WorkspaceInspected {
22575 inspection: WorkspaceInspection {
22576 workspace_id: lease.workspace_id.clone(),
22577 entries: Vec::new(),
22578 tree_truncated: false,
22579 git,
22580 truncation: None,
22581 },
22582 }),
22583 };
22584 project_response_without_managed_worktrees(&mut inspection_reply);
22585 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22586 panic!("projection changed the inspection response kind");
22587 };
22588 assert!(inspection.git.managed_worktree.is_none());
22589 let failure = managed_git_worktree_failure(GitWorktreeError {
22590 kind: GitWorktreeErrorKind::Failed,
22591 message: r"git failed at C:\private\managed-target".to_owned(),
22592 });
22593 assert!(!failure.message.contains("private"));
22594 assert!(!failure.message.contains("managed-target"));
22595 }
22596
22597 #[tokio::test]
22598 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22599 let root = temporary_workspace_root("managed-restart");
22600 let source = root.join("source");
22601 let allocation = root.join("allocation");
22602 std::fs::create_dir_all(&source).unwrap();
22603 std::fs::create_dir_all(&allocation).unwrap();
22604 run_test_git(&source, &["init"]);
22605 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22606 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22607 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22608 run_test_git(&source, &["add", "seed.txt"]);
22609 run_test_git(&source, &["commit", "-m", "seed"]);
22610 let profile = ManagedWorktreeProfile::new(
22611 WorktreeProfileId::new("default").unwrap(),
22612 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22613 &allocation,
22614 "gate4agent",
22615 "HEAD",
22616 ManagedWorktreeRetention::Retain,
22617 ).unwrap();
22618 let workspace = WorkspaceConfig::new(
22619 WorkspaceId::new("primary").unwrap(),
22620 &source,
22621 ).unwrap()
22622 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22623 .with_managed_worktree_profile(profile.clone()).unwrap();
22624 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22625 let source_text = workspace.canonical_root().to_owned();
22626 let target_text = target.to_string_lossy().into_owned();
22627 let created = create_git_worktree(
22628 &source_text,
22629 &target_text,
22630 "gate4agent/mw-restart",
22631 Some("HEAD"),
22632 ).await.unwrap();
22633 let lease = ManagedWorktreeLeaseRecord {
22634 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22635 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22636 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22637 profile_id: profile.profile_id().clone(),
22638 profile_revision: profile.revision().clone(),
22639 target_root: created.path.clone(),
22640 branch: "gate4agent/mw-restart".to_owned(),
22641 base_commit: created.head.clone(),
22642 expected_head: None,
22643 retention: ManagedWorktreeRetention::Retain,
22644 state: ManagedWorktreeLeaseState::Allocating,
22645 session_holders: Vec::new(),
22646 record_holders: Vec::new(),
22647 cleanup_failure: None,
22648 created_at_unix_ms: 1,
22649 updated_at_unix_ms: 1,
22650 };
22651 let catalog = active_registry().unwrap();
22652 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22653 let shared = NodeShared::new_with_incarnation(
22654 handle,
22655 "fixture-token".to_owned(),
22656 NodeId::new("node-managed-restart").unwrap(),
22657 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22658 vec![workspace],
22659 vec![agent("claude")],
22660 ProviderRuntimeStatuses::default(),
22661 None,
22662 Vec::new(),
22663 Vec::new(),
22664 SpawnProfileRegistry::default(),
22665 None,
22666 None,
22667 None,
22668 None,
22669 Vec::new(),
22670 vec![lease.clone()],
22671 Vec::new(),
22672 Vec::new(),
22673 None,
22674 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22675 );
22676 shared.reconcile_managed_worktrees().await;
22677 let retained = shared.managed_worktrees.lock()
22678 .unwrap_or_else(|poisoned| poisoned.into_inner())
22679 .get(&lease.lease_id).unwrap().clone();
22680 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22681 assert!(shared.workspaces.read()
22682 .unwrap_or_else(|poisoned| poisoned.into_inner())
22683 .contains_key(&lease.workspace_id));
22684 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22685 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22686 assert!(!Path::new(&created.path).exists());
22687 std::fs::remove_dir_all(root).unwrap();
22688 }
22689
22690 fn run_test_git(root: &Path, arguments: &[&str]) {
22691 let output = std::process::Command::new("git")
22692 .arg("-C").arg(root).args(arguments).output().unwrap();
22693 assert!(
22694 output.status.success(),
22695 "git {:?} failed: {}",
22696 arguments,
22697 String::from_utf8_lossy(&output.stderr),
22698 );
22699 }
22700
22701 #[tokio::test]
22702 async fn spawn_spec_validation_fails_before_session_mutation() {
22703 let shared = terminal_test_shared();
22704 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22705 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22706 }));
22707 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22708 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22709 }));
22710 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22711 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22712 }));
22713
22714 let mut target_mismatch = spawn_spec_fixture();
22715 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22716 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22717 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22718
22719 let mut unknown_profile = spawn_spec_fixture();
22720 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22721 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22722 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22723 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22724
22725 let mut revision_mismatch = spawn_spec_fixture();
22726 revision_mismatch.idempotency_key =
22727 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22728 revision_mismatch.expected_profile_revision =
22729 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22730 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22731 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22732
22733 let mut unsupported_materializer = spawn_spec_fixture();
22734 unsupported_materializer.idempotency_key =
22735 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22736 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22737 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22738 };
22739 let failure = shared
22740 .spawn_from_spec(unsupported_materializer)
22741 .await
22742 .unwrap_err();
22743 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22744
22745 let failure = shared
22746 .spawn_session_with_deadline(
22747 WorkspaceId::new("primary").unwrap(),
22748 agent("claude"),
22749 SessionMode::Pty,
22750 gate4agent_types::TerminalSize {
22751 rows: 24,
22752 columns: 80,
22753 },
22754 None,
22755 None,
22756 None,
22757 None,
22758 None,
22759 None,
22760 SpawnRecordPolicy::ProviderIdentityOnly,
22761 Some(Instant::now()),
22762 &[],
22763 None,
22764 ApprovalLevel::default(),
22765 None,
22766 None,
22767 )
22768 .await
22769 .unwrap_err();
22770 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22771 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22772 assert!(shared.handle.snapshot().sessions.is_empty());
22773 }
22774
22775 #[test]
22776 fn worktree_selection_requires_its_separate_negotiated_capability() {
22777 let mut spec = spawn_spec_fixture();
22778 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22779 let request = NodeRequest::SpawnSpec { spec };
22780 let spawn_spec_capability =
22781 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22782 assert!(request_uses_unnegotiated_capability(
22783 &request,
22784 &[spawn_spec_capability.clone()],
22785 ));
22786 let worktree_capability =
22787 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22788 assert!(request_uses_unnegotiated_capability(
22789 &request,
22790 &[spawn_spec_capability.clone(), worktree_capability.clone()],
22791 ));
22792 let profile_revision_capability =
22793 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22794 assert!(request_uses_unnegotiated_capability(
22795 &request,
22796 &[
22797 spawn_spec_capability.clone(),
22798 worktree_capability.clone(),
22799 profile_revision_capability.clone(),
22800 ],
22801 ));
22802 let bundle_materialization_capability =
22809 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22810 assert!(request_uses_unnegotiated_capability(
22811 &request,
22812 &[
22813 spawn_spec_capability.clone(),
22814 worktree_capability.clone(),
22815 profile_revision_capability.clone(),
22816 bundle_materialization_capability.clone(),
22817 ],
22818 ));
22819 let history_context_pack_capability =
22820 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22821 assert!(!request_uses_unnegotiated_capability(
22822 &request,
22823 &[
22824 spawn_spec_capability,
22825 worktree_capability,
22826 profile_revision_capability,
22827 bundle_materialization_capability,
22828 history_context_pack_capability,
22829 ],
22830 ));
22831 }
22832
22833 #[tokio::test]
22834 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22835 let mut shared = terminal_test_shared();
22836 let spec = spawn_spec_fixture();
22837 let receipt = shared
22838 .resolve_spawn_spec(&spec)
22839 .unwrap()
22840 .receipt(shared.incarnation_id, terminal_address(1));
22841 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22842
22843 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22844 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22845 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22846
22847 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22848
22849 let mut revision_conflicting = spec.clone();
22850 revision_conflicting.expected_profile_revision =
22851 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22852 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22853 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22854
22855 let mut stale_cache_miss = spec.clone();
22856 stale_cache_miss.idempotency_key =
22857 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22858 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22859 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22860
22861 let mut conflicting = spec;
22862 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22863 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22864 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22865 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22866 assert!(shared.handle.snapshot().sessions.is_empty());
22867 }
22868
22869 #[tokio::test]
22870 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22871 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22872 let mut spec = spawn_spec_fixture();
22873 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22874 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22875 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22876
22877 let failure = shared
22878 .create_worktree(
22879 WorkspaceId::new("primary").unwrap(),
22880 WorkspaceId::new("new-worktree").unwrap(),
22881 "not-an-absolute-path".to_owned(),
22882 "invalid branch".to_owned(),
22883 None,
22884 )
22885 .await
22886 .unwrap_err();
22887 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22888
22889 let failure = shared
22890 .remove_worktree(
22891 WorkspaceId::new("primary").unwrap(),
22892 "not-an-absolute-path".to_owned(),
22893 )
22894 .await
22895 .unwrap_err();
22896 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22897 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22898 assert!(shared.handle.snapshot().sessions.is_empty());
22899 }
22900
22901 #[tokio::test]
22902 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22903 let root = temporary_workspace_root("manual-worktree-compensation");
22904 let source = root.join("source");
22905 let target = root.join("target");
22906 let invalid_state_parent = root.join("state-parent-is-a-file");
22907 let invalid_state_path = invalid_state_parent.join("state.json");
22908 std::fs::create_dir_all(&source).unwrap();
22909 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22910 run_test_git(&source, &["init"]);
22911 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22912 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22913 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22914 run_test_git(&source, &["add", "seed.txt"]);
22915 run_test_git(&source, &["commit", "-m", "seed"]);
22916
22917 let catalog = active_registry().unwrap();
22918 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22919 let workspace = WorkspaceConfig::new(
22920 WorkspaceId::new("primary").unwrap(),
22921 &source,
22922 ).unwrap();
22923 let mut shared = NodeShared::new(
22924 handle,
22925 "fixture-token".to_owned(),
22926 NodeId::new("node-worktree-compensation").unwrap(),
22927 vec![workspace],
22928 vec![agent("claude")],
22929 );
22930 shared.state_path = Some(invalid_state_path);
22931
22932 let error = shared.create_worktree(
22933 WorkspaceId::new("primary").unwrap(),
22934 WorkspaceId::new("compensated").unwrap(),
22935 target.to_string_lossy().into_owned(),
22936 "gate4agent/compensated".to_owned(),
22937 Some("HEAD".to_owned()),
22938 ).await.unwrap_err();
22939
22940 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22941 assert!(!target.exists());
22942 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22943 .iter()
22944 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22945 std::fs::remove_dir_all(root).unwrap();
22946 }
22947
22948 #[test]
22949 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22950 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22951 NativeGitWorktreeSnapshot {
22952 path: path.to_owned(),
22953 head: "0123456789abcdef".to_owned(),
22954 branch: Some("refs/heads/test".to_owned()),
22955 is_bare,
22956 is_main,
22957 locked: false,
22958 lock_reason: None,
22959 prunable: false,
22960 prunable_reason: None,
22961 workspace_id: None,
22962 }
22963 }
22964
22965 let source = r"C:\repo";
22966 let selected = r"C:\repo-worktree";
22967 let listed = vec![
22968 worktree(source, true, false),
22969 worktree(selected, false, false),
22970 ];
22971 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22972
22973 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22974 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22975
22976 let bare = vec![
22977 worktree(source, true, false),
22978 worktree(selected, false, true),
22979 ];
22980 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22981 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22982
22983 let foreign_failure =
22984 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22985 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22986 }
22987
22988 fn terminal_frame(sequence: u64) -> TerminalFrame {
22989 TerminalFrame {
22990 sequence,
22991 size: gate4agent_types::TerminalSize {
22992 rows: 24,
22993 columns: 80,
22994 },
22995 cursor_row: 2,
22996 cursor_column: 3,
22997 contents: format!("frame-{sequence}"),
22998 formatted: vec![1, 2, 3],
22999 scrollback_formatted: Vec::new(),
23000 alternate_screen: false,
23001 mouse_protocol_enabled: false,
23002 mouse_protocol_encoding: Default::default(),
23003 produced_at_unix_ms: 0,
23004 screen_state: gate4agent_types::PtyScreenState::default(),
23005 bracketed_paste: None,
23006 }
23007 }
23008
23009 #[test]
23010 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23011 let shared = terminal_test_shared();
23012 let mut receiver = shared.terminal_event_tx.subscribe();
23013 let original = terminal_address(1);
23014
23015 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23016 let first = receiver.try_recv().unwrap();
23017 assert_eq!(first.len(), 1);
23018 assert!(matches!(
23019 &first[0].event,
23020 NodeEvent::TerminalFrame { address, frame }
23021 if address == &original && frame.sequence == 7
23022 ));
23023
23024 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23025 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23026 assert!(matches!(
23027 receiver.try_recv(),
23028 Err(broadcast::error::TryRecvError::Empty)
23029 ));
23030
23031 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23032 let advanced = receiver.try_recv().unwrap();
23033 assert_eq!(advanced[0].sequence, 2);
23034
23035 let resumed = terminal_address(2);
23036 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23037 let reset = receiver.try_recv().unwrap();
23038 assert_eq!(reset[0].sequence, 3);
23039 assert!(matches!(
23040 &reset[0].event,
23041 NodeEvent::TerminalFrame { address, frame }
23042 if address == &resumed && frame.sequence == 1
23043 ));
23044 shared.clear_terminal_frame_watermark(&resumed);
23045 assert!(!shared
23046 .terminal_frame_watermarks
23047 .lock()
23048 .unwrap_or_else(|poisoned| poisoned.into_inner())
23049 .contains_key(&resumed.session.instance_id));
23050 }
23051
23052 #[test]
23053 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23054 let shared = terminal_test_shared();
23055 let mut receiver = shared.terminal_event_tx.subscribe();
23056 let address = terminal_address(1);
23057 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23058
23059 for sequence in 1..=count {
23060 shared.publish_terminal_frame_candidates(vec![(
23061 address.clone(),
23062 terminal_frame(sequence),
23063 )]);
23064 }
23065
23066 let history = shared
23067 .history
23068 .lock()
23069 .unwrap_or_else(|poisoned| poisoned.into_inner());
23070 assert!(history.events.is_empty());
23071 assert_eq!(history.last_sequence, count);
23072 assert_eq!(history.replay_floor_sequence, 1);
23073 drop(history);
23074 let NodeResponse::Resync {
23075 event_sequence,
23076 oldest_available_sequence,
23077 events,
23078 ..
23079 } = shared.resync(0) else {
23080 unreachable!("resync helper returned another response")
23081 };
23082 assert_eq!(event_sequence, count);
23083 assert_eq!(oldest_available_sequence, 1);
23084 assert!(events.is_empty());
23085 assert!(matches!(
23086 resync_required_event(&shared).event,
23087 NodeEvent::ResyncRequired {
23088 oldest_available_sequence: 1,
23089 }
23090 ));
23091 assert!(matches!(
23092 receiver.try_recv(),
23093 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23094 ));
23095 let latest = receiver.try_recv().unwrap();
23096 assert_eq!(latest.len(), 1);
23097 assert_eq!(latest[0].sequence, count);
23098 }
23099
23100 #[test]
23101 fn real_durable_eviction_advances_authoritative_replay_floor() {
23102 let shared = terminal_test_shared();
23103 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23104 shared.publish(NodeEvent::ControllerChanged { controller: None });
23105 }
23106
23107 let NodeResponse::Resync {
23108 event_sequence,
23109 oldest_available_sequence,
23110 events,
23111 ..
23112 } = shared.resync(0) else {
23113 unreachable!("resync helper returned another response")
23114 };
23115 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23116 assert_eq!(oldest_available_sequence, 2);
23117 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23118 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23119 assert!(matches!(
23120 resync_required_event(&shared).event,
23121 NodeEvent::ResyncRequired {
23122 oldest_available_sequence: 2,
23123 }
23124 ));
23125 }
23126
23127 #[test]
23128 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23129 let shared = terminal_test_shared();
23130 let without_capability = terminal_event_subscription(&shared, false);
23131 let with_capability = terminal_event_subscription(&shared, true);
23132 assert!(without_capability.is_none());
23133 assert!(with_capability.is_some());
23134 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23135 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23136 }));
23137 }
23138
23139 #[test]
23140 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23141 let mut pending = Vec::new();
23142 let mut event_sequence = 0_u64;
23143 for frame_sequence in 1..=4 {
23144 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23145 event_sequence += 1;
23146 let mut address = terminal_address(1);
23147 address.session.instance_id = AgentInstanceId(instance);
23148 queue_connection_event(
23149 &mut pending,
23150 NodeEventEnvelope {
23151 sequence: event_sequence,
23152 event: NodeEvent::TerminalFrame {
23153 address,
23154 frame: terminal_frame(frame_sequence),
23155 },
23156 },
23157 0,
23158 );
23159 }
23160 }
23161 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23162 pending.sort_unstable_by_key(|event| event.sequence);
23163
23164 let mut uninterrupted = pending.clone();
23165 let first_burst = connection_event_burst_len(uninterrupted.len());
23166 uninterrupted.drain(..first_burst);
23167 let second_burst = connection_event_burst_len(uninterrupted.len());
23168 uninterrupted.drain(..second_burst);
23169 assert!(uninterrupted.is_empty());
23170
23171 let (frame_tx, mut frame_rx) =
23172 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23173 frame_tx
23174 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23175 request_id: 77,
23176 request: NodeRequest::Snapshot,
23177 })))
23178 .unwrap();
23179 let burst_len = connection_event_burst_len(pending.len());
23180 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23181 pending.drain(..burst_len);
23182 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23183 assert!(matches!(
23184 frame_rx.try_recv(),
23185 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23186 request_id: 77,
23187 request: NodeRequest::Snapshot,
23188 })))
23189 ));
23190 }
23191
23192 #[test]
23193 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23194 let mut pending = Vec::new();
23195 for sequence in [7_u64, 8] {
23196 let mut address = terminal_address(1);
23197 address.session.instance_id = AgentInstanceId(sequence);
23198 queue_connection_event(
23199 &mut pending,
23200 NodeEventEnvelope {
23201 sequence,
23202 event: NodeEvent::TerminalFrame {
23203 address,
23204 frame: terminal_frame(sequence),
23205 },
23206 },
23207 0,
23208 );
23209 }
23210 let mut discard_through = 0;
23211 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23212 assert!(pending.is_empty());
23213
23214 let replayed = NodeEventEnvelope {
23215 sequence: 8,
23216 event: NodeEvent::TerminalFrame {
23217 address: terminal_address(1),
23218 frame: terminal_frame(8),
23219 },
23220 };
23221 let advanced = NodeEventEnvelope {
23222 sequence: 9,
23223 event: NodeEvent::TerminalFrame {
23224 address: terminal_address(2),
23225 frame: terminal_frame(1),
23226 },
23227 };
23228 queue_connection_event_batch(
23229 &mut pending,
23230 &[replayed, advanced.clone()],
23231 discard_through,
23232 );
23233 assert_eq!(pending, vec![advanced]);
23234 }
23235
23236 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23237 ManagedSessionRecord {
23238 record_id: SessionRecordId::new(record_id).unwrap(),
23239 display_name: format!("{provider} record"),
23240 provider: agent(provider),
23241 mode: SessionMode::Pty,
23242 state: ManagedSessionState::Unavailable,
23243 workspace_id: WorkspaceId::new("primary").unwrap(),
23244 canonical_root: opaque_windows_path(
23245 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23246 ),
23247 provider_session: None,
23248 active_session: None,
23249 environment_profile: None,
23250 bundle: None,
23251 context_id: None,
23252 context: None,
23253 exported_context: None,
23254 task_binding: None,
23255 created_at_unix_ms: 1,
23256 updated_at_unix_ms: 1,
23257 last_error: None,
23258 }
23259 }
23260
23261 fn retention_test_record(
23262 workspace_id: &str,
23263 record_id: &str,
23264 state: ManagedSessionState,
23265 updated_at_unix_ms: u64,
23266 ) -> ManagedSessionRecord {
23267 ManagedSessionRecord {
23268 record_id: SessionRecordId::new(record_id).unwrap(),
23269 display_name: format!("{record_id} display"),
23270 provider: agent("claude"),
23271 mode: SessionMode::Pty,
23272 state,
23273 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23274 canonical_root: opaque_windows_path(
23275 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23276 ),
23277 provider_session: None,
23278 active_session: None,
23279 environment_profile: None,
23280 bundle: None,
23281 context_id: None,
23282 context: None,
23283 exported_context: None,
23284 task_binding: None,
23285 created_at_unix_ms: updated_at_unix_ms,
23286 updated_at_unix_ms,
23287 last_error: None,
23288 }
23289 }
23290
23291 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23299 vec![
23300 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23301 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23302 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23303 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23304 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23305 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23306 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23307 retention_test_record(
23308 "workspace-a",
23309 "decoy-pending",
23310 ManagedSessionState::IdentityPending,
23311 now - 500_000,
23312 ),
23313 retention_test_record(
23314 "workspace-a",
23315 "decoy-dormant",
23316 ManagedSessionState::Dormant,
23317 now - 500_000,
23318 ),
23319 ]
23320 }
23321
23322 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23323 let root = temporary_workspace_root("session-record-retention");
23324 std::fs::create_dir_all(&root).unwrap();
23325 let catalog = active_registry().unwrap();
23326 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23327 let workspace_a =
23328 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23329 let workspace_b =
23330 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23331 let mut shared = NodeShared::new(
23332 handle,
23333 "fixture-token".to_owned(),
23334 NodeId::new("node-retention-test").unwrap(),
23335 vec![workspace_a, workspace_b],
23336 vec![agent("claude")],
23337 );
23338 shared.state_path = Some(root.join("node-state-v10.json"));
23339 let now = unix_time_ms();
23340 for record in retention_fixture_records(now) {
23341 shared.insert_record(record).unwrap();
23342 }
23343 shared
23344 .persist_state_locked()
23345 .expect("fixture-only, empty-history persist against a fresh temp state path");
23346 (shared, runtime, root, now)
23347 }
23348
23349 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23350 shared
23351 .history
23352 .lock()
23353 .unwrap_or_else(|poisoned| poisoned.into_inner())
23354 .events
23355 .iter()
23356 .filter_map(|envelope| match &envelope.event {
23357 NodeEvent::SessionRecordRemoved { record_id } => {
23358 Some(record_id.as_str().to_owned())
23359 }
23360 _ => None,
23361 })
23362 .collect()
23363 }
23364
23365 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23366 shared
23367 .session_records
23368 .lock()
23369 .unwrap_or_else(|poisoned| poisoned.into_inner())
23370 .records
23371 .keys()
23372 .map(|record_id| record_id.as_str().to_owned())
23373 .collect()
23374 }
23375
23376 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23377 values.iter().map(|value| (*value).to_owned()).collect()
23378 }
23379
23380 #[tokio::test]
23381 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23382 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23383 shared.session_record_retention = SessionRecordRetentionConfig {
23384 age_ms: 0,
23385 keep_per_workspace: 1,
23386 };
23387 shared.retire_unavailable_session_records().await;
23388
23389 assert_eq!(
23390 retention_remaining_record_ids(&shared),
23391 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23392 );
23393 assert_eq!(
23394 retention_removed_record_ids(&shared),
23395 owned(&["a1", "a2", "b1", "b2"]),
23396 );
23397 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23398
23399 drop(shared);
23400 drop(runtime);
23401 std::fs::remove_dir_all(root).unwrap();
23402 }
23403
23404 #[tokio::test]
23410 async fn session_record_retention_both_set_retires_only_the_intersection() {
23411 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23412 shared.session_record_retention = SessionRecordRetentionConfig {
23413 age_ms: 250_000,
23414 keep_per_workspace: 1,
23415 };
23416 shared.retire_unavailable_session_records().await;
23417
23418 assert_eq!(
23419 retention_remaining_record_ids(&shared),
23420 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23421 );
23422 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23423 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23424
23425 drop(shared);
23426 drop(runtime);
23427 std::fs::remove_dir_all(root).unwrap();
23428 }
23429
23430 #[tokio::test]
23431 async fn session_record_retention_both_disabled_is_a_no_op() {
23432 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23433 shared.session_record_retention = SessionRecordRetentionConfig::default();
23434 shared.retire_unavailable_session_records().await;
23435
23436 assert_eq!(
23437 retention_remaining_record_ids(&shared),
23438 owned(&[
23439 "a1", "a2", "a3", "b1", "b2", "b3",
23440 "decoy-live", "decoy-pending", "decoy-dormant",
23441 ]),
23442 );
23443 assert!(retention_removed_record_ids(&shared).is_empty());
23444 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23445
23446 drop(shared);
23447 drop(runtime);
23448 std::fs::remove_dir_all(root).unwrap();
23449 }
23450
23451 #[test]
23452 fn session_record_context_export_selects_one_exact_history_identity() {
23453 let candidates = vec![
23454 HistoryCandidateSummary {
23455 id: "candidate-other".to_owned(),
23456 session_id_hint: "provider-other".to_owned(),
23457 modified_at_unix_ms: Some(1),
23458 },
23459 HistoryCandidateSummary {
23460 id: "candidate-exact".to_owned(),
23461 session_id_hint: "provider-exact".to_owned(),
23462 modified_at_unix_ms: Some(2),
23463 },
23464 ];
23465 assert_eq!(
23466 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23467 "candidate-exact",
23468 );
23469 }
23470
23471 #[test]
23472 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23473 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23474 id: id.to_owned(),
23475 session_id_hint: hint.to_owned(),
23476 modified_at_unix_ms: None,
23477 };
23478 let missing = select_history_candidate(
23479 &[candidate("candidate-other", "provider-other")],
23480 Some("provider-exact"),
23481 )
23482 .unwrap_err();
23483 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23484 let ambiguous = select_history_candidate(
23485 &[
23486 candidate("candidate-a", "provider-exact"),
23487 candidate("candidate-b", "provider-exact"),
23488 ],
23489 Some("provider-exact"),
23490 )
23491 .unwrap_err();
23492 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23493 }
23494
23495 #[test]
23503 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23504 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23505 id: id.to_owned(),
23506 session_id_hint: hint.to_owned(),
23507 modified_at_unix_ms: None,
23508 };
23509 assert_eq!(
23510 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23511 .unwrap(),
23512 "candidate-sole",
23513 );
23514 let missing = select_history_candidate(&[], None).unwrap_err();
23515 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23516 let ambiguous = select_history_candidate(
23517 &[
23518 candidate("candidate-a", "sidecar-session-a"),
23519 candidate("candidate-b", "sidecar-session-b"),
23520 ],
23521 None,
23522 )
23523 .unwrap_err();
23524 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23525 }
23526
23527 #[test]
23528 fn session_record_context_export_revalidation_rejects_record_change() {
23529 let session = terminal_address(1);
23530 let identity = ProviderSessionIdentity {
23531 key: ProviderSessionKey::SessionId,
23532 id: "provider-exact".to_owned(),
23533 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23534 };
23535 let mut expected = record("claude", "record-context-export");
23536 expected.state = ManagedSessionState::Live;
23537 expected.provider_session = Some(identity.clone());
23538 expected.active_session = Some(session.clone());
23539 let binding = SessionBinding {
23540 workspace_id: session.workspace_id.clone(),
23541 generation: session.session.generation,
23542 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23543 pending_resume: None,
23544 record_id: Some(expected.record_id.clone()),
23545 managed_worktree_lease_id: None,
23546 environment_profile: None,
23547 bundle: None,
23548 context: None,
23549 materialization_id: None,
23550 };
23551 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23552 assert!(session_record_context_export_binding_is_exact(
23553 &expected,
23554 &expected,
23555 Some(&identity),
23556 &session,
23557 &binding,
23558 &expected.provider,
23559 ¤t_root,
23560 false,
23561 ));
23562 let mut changed = expected.clone();
23563 changed.updated_at_unix_ms += 1;
23564 assert!(!session_record_context_export_binding_is_exact(
23565 &expected,
23566 &changed,
23567 Some(&identity),
23568 &session,
23569 &binding,
23570 &expected.provider,
23571 ¤t_root,
23572 false,
23573 ));
23574 assert!(!session_record_context_export_binding_is_exact(
23579 &expected,
23580 &changed,
23581 Some(&identity),
23582 &session,
23583 &binding,
23584 &expected.provider,
23585 ¤t_root,
23586 true,
23587 ));
23588 let mut detached = expected.clone();
23589 detached.state = ManagedSessionState::Dormant;
23590 detached.active_session = None;
23591 detached.updated_at_unix_ms += 1;
23592 assert!(!session_record_context_export_binding_is_exact(
23593 &expected,
23594 &detached,
23595 Some(&identity),
23596 &session,
23597 &binding,
23598 &expected.provider,
23599 ¤t_root,
23600 false,
23601 ));
23602 assert!(session_record_context_export_binding_is_exact(
23603 &expected,
23604 &detached,
23605 Some(&identity),
23606 &session,
23607 &binding,
23608 &expected.provider,
23609 ¤t_root,
23610 true,
23611 ));
23612 }
23613
23614 #[test]
23621 fn session_record_context_export_binding_is_exact_without_identity() {
23622 let session = terminal_address(1);
23623 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23624 expected.state = ManagedSessionState::Live;
23625 expected.active_session = Some(session.clone());
23626 let binding = SessionBinding {
23627 workspace_id: session.workspace_id.clone(),
23628 generation: session.session.generation,
23629 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23630 pending_resume: None,
23631 record_id: Some(expected.record_id.clone()),
23632 managed_worktree_lease_id: None,
23633 environment_profile: None,
23634 bundle: None,
23635 context: None,
23636 materialization_id: None,
23637 };
23638 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23639 assert!(session_record_context_export_binding_is_exact(
23640 &expected,
23641 &expected,
23642 None,
23643 &session,
23644 &binding,
23645 &expected.provider,
23646 ¤t_root,
23647 false,
23648 ));
23649 let mut drifted = expected.clone();
23653 drifted.provider_session = Some(ProviderSessionIdentity {
23654 key: ProviderSessionKey::SessionId,
23655 id: "unexpected-identity".to_owned(),
23656 transcript_path: None,
23657 });
23658 assert!(!session_record_context_export_binding_is_exact(
23659 &expected,
23660 &drifted,
23661 None,
23662 &session,
23663 &binding,
23664 &expected.provider,
23665 ¤t_root,
23666 false,
23667 ));
23668 }
23669
23670 #[test]
23671 fn session_record_context_export_preflight_requires_live_usable_source() {
23672 let mut source = record("claude", "record-context-export-preflight");
23673 source.state = ManagedSessionState::Live;
23674 assert!(session_record_context_export_source_is_usable(
23675 &source,
23676 &SessionStatus::Running,
23677 false,
23678 ));
23679 assert!(session_record_context_export_source_is_usable(
23680 &source,
23681 &SessionStatus::Exited { exit_code: Some(0) },
23682 false,
23683 ));
23684
23685 source.state = ManagedSessionState::Dormant;
23686 assert!(!session_record_context_export_source_is_usable(
23687 &source,
23688 &SessionStatus::Running,
23689 false,
23690 ));
23691 assert!(session_record_context_export_source_is_usable(
23695 &source,
23696 &SessionStatus::Exited { exit_code: Some(0) },
23697 true,
23698 ));
23699 assert!(!session_record_context_export_source_is_usable(
23700 &source,
23701 &SessionStatus::Exited { exit_code: Some(0) },
23702 false,
23703 ));
23704 source.state = ManagedSessionState::Live;
23705 assert!(!session_record_context_export_source_is_usable(
23706 &source,
23707 &SessionStatus::Starting,
23708 false,
23709 ));
23710 }
23711
23712 #[test]
23713 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23714 let catalog = active_registry().unwrap();
23715 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23716 let workspace = WorkspaceConfig::new(
23717 WorkspaceId::new("primary").unwrap(),
23718 std::env::current_dir().unwrap(),
23719 )
23720 .unwrap();
23721 let shared = NodeShared::new(
23722 handle,
23723 "fixture-token".to_owned(),
23724 NodeId::new("node-context-export-conflict").unwrap(),
23725 vec![workspace],
23726 vec![agent("claude")],
23727 );
23728 let history = HistorySessionRecord {
23729 session_id: "provider-exact".to_owned(),
23730 title: None,
23731 cwd: None,
23732 model: None,
23733 message_count: 1,
23734 completed_turn_count: None,
23735 total_tokens: 0,
23736 messages: vec![gate4agent_types::HistoryMessageRecord {
23737 role: gate4agent_types::HistoryMessageRole::User,
23738 text: "bounded context".to_owned(),
23739 }],
23740 };
23741 let pack = NodeContextPack::export(
23742 ContextPackLineageReceipt {
23743 source_node_id: shared.node_id.clone(),
23744 source_session: terminal_address(1),
23745 source_provider: agent("claude"),
23746 },
23747 &history,
23748 )
23749 .unwrap();
23750 let context_id = pack.receipt().id.clone();
23751 let session = terminal_address(1);
23752 let identity = ProviderSessionIdentity {
23753 key: ProviderSessionKey::SessionId,
23754 id: history.session_id.clone(),
23755 transcript_path: None,
23756 };
23757 let mut expected = record("claude", "record-context-export-final-conflict");
23758 expected.state = ManagedSessionState::Live;
23759 expected.provider_session = Some(identity.clone());
23760 expected.active_session = Some(session.clone());
23761 let mut changed = expected.clone();
23762 changed.updated_at_unix_ms += 1;
23763 shared.insert_record(changed).unwrap();
23764
23765 let error = shared
23766 .commit_context_pack_for_session_record(
23767 &expected,
23768 Some(&identity),
23769 &session,
23770 "candidate-exact",
23771 &history,
23772 pack,
23773 false,
23774 )
23775 .unwrap_err();
23776
23777 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23778 assert!(shared
23779 .context_catalog
23780 .read()
23781 .unwrap_or_else(|poisoned| poisoned.into_inner())
23782 .get(&context_id)
23783 .is_none());
23784 }
23785
23786 #[tokio::test]
23787 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23788 let root = temporary_workspace_root("session-task-binding");
23789 std::fs::create_dir_all(&root).unwrap();
23790 let state_path = root.join("node-state.json");
23791 let catalog = active_registry().unwrap();
23792 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23793 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23794 let node_id = NodeId::new("node-1").unwrap();
23795 let mut shared = NodeShared::new(
23796 handle,
23797 "fixture-token".to_owned(),
23798 node_id.clone(),
23799 vec![workspace],
23800 vec![agent("claude")],
23801 );
23802 shared.state_path = Some(state_path.clone());
23803 let record = record("claude", "sr-task");
23804 let record_id = record.record_id.clone();
23805 shared.insert_record(record).unwrap();
23806 shared.persist_state().unwrap();
23807 shared
23808 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23809 .unwrap();
23810
23811 let minted = process_request_inner(
23812 &shared,
23813 77,
23814 ClientRole::Operator,
23815 NodeRequest::SetSessionTask {
23816 record_id: record_id.clone(),
23817 expected_revision: 0,
23818 target: SessionTaskTargetV1::New,
23819 },
23820 )
23821 .await
23822 .unwrap();
23823 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23824 panic!("session task mutation returned another response");
23825 };
23826 let minted_binding = minted.task_binding.clone().unwrap();
23827 let minted_task_id = minted_binding.task_id.clone().unwrap();
23828 assert_eq!(minted_binding.revision, 1);
23829 assert!(minted_task_id.as_str().starts_with("task-"));
23830 assert_eq!(minted_task_id.as_str().len(), 29);
23831
23832 let event_count = shared.current_sequence();
23833 let repeated = process_request_inner(
23834 &shared,
23835 77,
23836 ClientRole::Operator,
23837 NodeRequest::SetSessionTask {
23838 record_id: record_id.clone(),
23839 expected_revision: 1,
23840 target: SessionTaskTargetV1::Existing {
23841 task_id: minted_task_id.clone(),
23842 },
23843 },
23844 )
23845 .await
23846 .unwrap();
23847 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23848 assert_eq!(shared.current_sequence(), event_count);
23849
23850 let stale = process_request_inner(
23851 &shared,
23852 77,
23853 ClientRole::Operator,
23854 NodeRequest::SetSessionTask {
23855 record_id: record_id.clone(),
23856 expected_revision: 0,
23857 target: SessionTaskTargetV1::Existing {
23858 task_id: minted_task_id,
23859 },
23860 },
23861 )
23862 .await
23863 .unwrap_err();
23864 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23865
23866 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23867 let assigned = process_request_inner(
23868 &shared,
23869 77,
23870 ClientRole::Operator,
23871 NodeRequest::SetSessionTask {
23872 record_id: record_id.clone(),
23873 expected_revision: 1,
23874 target: SessionTaskTargetV1::Existing {
23875 task_id: assigned_id.clone(),
23876 },
23877 },
23878 )
23879 .await
23880 .unwrap();
23881 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23882 panic!("session task assignment returned another response");
23883 };
23884 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23885 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23886
23887 let cleared = process_request_inner(
23888 &shared,
23889 77,
23890 ClientRole::Operator,
23891 NodeRequest::SetSessionTask {
23892 record_id: record_id.clone(),
23893 expected_revision: 2,
23894 target: SessionTaskTargetV1::Clear,
23895 },
23896 )
23897 .await
23898 .unwrap();
23899 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23900 panic!("session task clear returned another response");
23901 };
23902 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23903 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23904 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23905 assert_eq!(
23906 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23907 cleared.updated_at_unix_ms,
23908 );
23909
23910 let persisted = std::fs::read(&state_path).unwrap();
23911 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23912 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23913 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23914 std::fs::remove_dir_all(root).unwrap();
23915 }
23916
23917 #[test]
23918 fn session_task_capability_strips_snapshots_events_and_replies() {
23919 let mut bound = record("claude", "sr-task-projection");
23920 bound.updated_at_unix_ms = 2;
23921 bound.task_binding = Some(SessionTaskBindingV1 {
23922 revision: 1,
23923 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23924 changed_at_unix_ms: 2,
23925 });
23926 let request = NodeRequest::SetSessionTask {
23927 record_id: bound.record_id.clone(),
23928 expected_revision: 1,
23929 target: SessionTaskTargetV1::Clear,
23930 };
23931 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23932 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23933 }));
23934 assert!(!request_uses_unnegotiated_capability(
23935 &request,
23936 &baseline_capabilities().unwrap(),
23937 ));
23938 assert!(request_uses_unnegotiated_capability(&request, &[]));
23939
23940 let mut snapshot = terminal_test_shared().snapshot();
23941 snapshot.session_records.push(bound.clone());
23942 let mut reply = ResponseEnvelope {
23943 request_id: 1,
23944 result: Ok(NodeResponse::Resync {
23945 event_sequence: 1,
23946 oldest_available_sequence: 1,
23947 snapshot,
23948 events: vec![NodeEventEnvelope {
23949 sequence: 1,
23950 event: NodeEvent::SessionRecordUpserted {
23951 record: bound.clone(),
23952 },
23953 }],
23954 }),
23955 };
23956 project_response_without_session_task_binding(&mut reply);
23957 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23958 panic!("task projection changed the response kind");
23959 };
23960 assert!(snapshot.session_records[0].task_binding.is_none());
23961 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23962 panic!("task projection changed the event kind");
23963 };
23964 assert!(record.task_binding.is_none());
23965
23966 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23967 sequence: 2,
23968 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23969 });
23970 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23971 panic!("task event projection changed the event kind");
23972 };
23973 assert!(record.task_binding.is_none());
23974
23975 let mut direct = ResponseEnvelope {
23976 request_id: 2,
23977 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23978 };
23979 project_response_without_session_task_binding(&mut direct);
23980 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23981 panic!("task reply projection changed the response kind");
23982 };
23983 assert!(record.task_binding.is_none());
23984 }
23985
23986 #[test]
23987 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23988 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23989 let workspace = WorkspaceConfig::new(
23990 WorkspaceId::new("primary").unwrap(),
23991 std::env::current_dir().unwrap(),
23992 )
23993 .unwrap();
23994 let config = NodeServerConfig::new(
23995 DEFAULT_NODE_ENDPOINT,
23996 "fixture-token",
23997 NodeId::new("node-1").unwrap(),
23998 [workspace],
23999 )
24000 .unwrap();
24001 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24002 }
24003
24004 #[cfg(all(feature = "fixture", windows))]
24005 #[test]
24006 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24007 let root = temporary_workspace_root("provider-bundle-argv-hold");
24008 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24009 std::fs::create_dir_all(&root).unwrap();
24010 std::fs::create_dir_all(&outside).unwrap();
24011 let proof_path = root.join("provider.proof");
24012 let release_signal = root.join("release.signal");
24013 let config = NodeServerConfig::new(
24014 format!(
24015 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24016 std::process::id(),
24017 ),
24018 "fixture-token",
24019 NodeId::new("provider-bundle-hold-node").unwrap(),
24020 [WorkspaceConfig::new(
24021 WorkspaceId::new("primary").unwrap(),
24022 std::env::current_dir().unwrap(),
24023 )
24024 .unwrap()],
24025 )
24026 .unwrap()
24027 .with_spawn_profiles(SpawnProfileRegistry::new([
24028 crate::protocol::SpawnProfileDefaults {
24029 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24030 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24031 .unwrap(),
24032 provider: agent("kimi"),
24033 mode: SessionMode::Pty,
24034 terminal_size: gate4agent_types::TerminalSize {
24035 rows: 24,
24036 columns: 80,
24037 },
24038 prompt: None,
24039 bundle_id: None,
24040 context_id: None,
24041 environment_profile_id: None,
24042 },
24043 ])
24044 .unwrap());
24045 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24046 config,
24047 agent("kimi"),
24048 proof_path.clone(),
24049 release_signal.clone(),
24050 )
24051 .unwrap();
24052 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24053
24054 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24055 agent("kimi"),
24056 proof_path.clone(),
24057 Some(release_signal.clone()),
24058 )
24059 .unwrap();
24060 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24061 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24062 let release_wait = wrapper
24063 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24064 .unwrap();
24065 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24066 assert!(proof_write < release_wait);
24067 assert!(release_wait < fixture_ready);
24068 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24069 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24070 assert_eq!(
24071 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24072 &[
24073 proof_path.to_string_lossy().into_owned(),
24074 release_signal.to_string_lossy().into_owned(),
24075 ],
24076 );
24077
24078 let error = NodeServer::provider_bundle_argv_fixture_spec(
24079 agent("kimi"),
24080 proof_path,
24081 Some(outside.join("release.signal")),
24082 )
24083 .unwrap_err();
24084 assert!(matches!(
24085 error,
24086 NodeServerError::Registry(message)
24087 if message == "provider bundle argv release path must share the proof parent"
24088 ));
24089 drop(server);
24090 std::fs::remove_dir_all(root).unwrap();
24091 std::fs::remove_dir_all(outside).unwrap();
24092 }
24093
24094 #[cfg(feature = "fixture")]
24095 #[test]
24096 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24097 let root = temporary_workspace_root("context-fixture-registry");
24098 std::fs::create_dir_all(&root).unwrap();
24099 let proof_path = root.join("context.proof");
24100 let config = NodeServerConfig::new(
24101 format!(
24102 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24103 std::process::id(),
24104 ),
24105 "fixture-token",
24106 NodeId::new("context-fixture-node").unwrap(),
24107 [WorkspaceConfig::new(
24108 WorkspaceId::new("primary").unwrap(),
24109 std::env::current_dir().unwrap(),
24110 )
24111 .unwrap()],
24112 )
24113 .unwrap();
24114 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24115 let mut enabled = server
24116 .shared
24117 .enabled_providers
24118 .iter()
24119 .map(AgentId::as_str)
24120 .collect::<Vec<_>>();
24121 enabled.sort_unstable();
24122 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24123 for provider in &server.shared.enabled_providers {
24124 assert!(server
24125 .shared
24126 .provider_adapter_contracts
24127 .iter()
24128 .any(|contract| {
24129 &contract.provider == provider
24130 && contract.family == AdapterFamily::History
24131 }));
24132 }
24133 drop(server);
24134 std::fs::remove_dir_all(root).unwrap();
24135 }
24136
24137 #[cfg(feature = "fixture")]
24138 #[test]
24139 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24140 let root = temporary_workspace_root("monitoring-context-fixture");
24141 std::fs::create_dir_all(&root).unwrap();
24142 let proof_path = root.join("context.proof");
24143 let standard = NodeServer::context_pack_fixture_catalog(
24144 proof_path.clone(),
24145 None,
24146 false,
24147 ).unwrap();
24148 let monitoring = NodeServer::context_pack_fixture_catalog(
24149 proof_path.clone(),
24150 None,
24151 true,
24152 ).unwrap();
24153 let claude = AgentId::new("claude").unwrap();
24154 let standard_claude = standard.get(&claude).unwrap();
24155 let monitoring_claude = monitoring.get(&claude).unwrap();
24156 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24160 assert_eq!(
24161 monitoring_claude.capabilities.adapters.history,
24162 standard_claude.capabilities.adapters.history,
24163 );
24164 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24165 let codex = AgentId::new("codex").unwrap();
24166 let standard_codex = standard.get(&codex).unwrap();
24167 let monitoring_codex = monitoring.get(&codex).unwrap();
24168 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24169 assert_eq!(
24170 monitoring_codex.capabilities.adapters.history,
24171 standard_codex.capabilities.adapters.history,
24172 );
24173 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24174 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24175 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24176 .unwrap();
24177 for required in [
24178 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24179 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24180 "@('role', 'text')",
24181 "g4a-private-provider-session-canary",
24182 "private-provider-login-identity",
24183 "$rawProof",
24184 "$env:GATE4AGENT_HOOK_URL",
24185 "g4a-private-provider-session-canary",
24186 ] {
24187 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24188 }
24189 for provider in ["grok", "kimi"] {
24190 let provider = AgentId::new(provider).unwrap();
24191 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24192 }
24193
24194 let config = NodeServerConfig::new(
24195 format!(
24196 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24197 std::process::id(),
24198 ),
24199 "fixture-token",
24200 NodeId::new("monitoring-context-fixture-node").unwrap(),
24201 [WorkspaceConfig::new(
24202 WorkspaceId::new("primary").unwrap(),
24203 std::env::current_dir().unwrap(),
24204 )
24205 .unwrap()],
24206 )
24207 .unwrap();
24208 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24209 assert!(server.shared.fixture_semantic_hook_policy);
24210 drop(server);
24211 std::fs::remove_dir_all(root).unwrap();
24212 }
24213
24214 #[cfg(feature = "fixture")]
24215 #[test]
24216 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24217 let root = temporary_workspace_root("kimi-context-only-fixture");
24218 std::fs::create_dir_all(&root).unwrap();
24219 let proof_path = root.join("context.proof");
24220 let standard = NodeServer::context_pack_fixture_catalog(
24221 proof_path.clone(),
24222 None,
24223 false,
24224 ).unwrap();
24225 let kimi = AgentId::new("kimi").unwrap();
24226 let context_only = NodeServer::context_pack_fixture_catalog(
24227 proof_path,
24228 Some(&kimi),
24229 false,
24230 )
24231 .unwrap();
24232
24233 let codex = AgentId::new("codex").unwrap();
24234 assert_eq!(
24235 standard.get(&codex).unwrap().launch.fixed_args,
24236 context_only.get(&codex).unwrap().launch.fixed_args,
24237 );
24238 let script = context_only
24239 .get(&kimi)
24240 .unwrap()
24241 .launch
24242 .fixed_args
24243 .iter()
24244 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24245 .expect("Kimi context-only validation script is missing");
24246 for required in [
24247 "$bundleArgs.Count -ne 0",
24248 "GATE4AGENT_CONTEXT_ROOT",
24249 "context-pack.json",
24250 "$entries.Count -ne 1",
24251 "g4a-context-pack-v1",
24252 "$document.PSObject.Properties['cwd']",
24253 "'claude', 'codex', 'grok', 'kimi'",
24254 "$hasUser",
24255 "$hasAssistant",
24256 "SHA256",
24257 "context-only",
24258 ] {
24259 assert!(script.contains(required), "missing validation: {required}");
24260 }
24261 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24262 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24263 }
24264 assert!(!standard
24265 .get(&kimi)
24266 .unwrap()
24267 .launch
24268 .fixed_args
24269 .iter()
24270 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24271
24272 std::fs::remove_dir_all(root).unwrap();
24273 }
24274
24275 #[cfg(all(feature = "fixture", windows))]
24276 #[tokio::test]
24277 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24278 let root = temporary_workspace_root("kimi-context-only-spawn");
24279 let workspace_root = root.join("workspace");
24280 std::fs::create_dir_all(&workspace_root).unwrap();
24281 let materialization_root = root.join("private-materializations");
24282 let proof_path = root.join("context.proof");
24283 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24284 let workspace_id = WorkspaceId::new("primary").unwrap();
24285 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24286 let endpoint = format!(
24287 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24288 std::process::id(),
24289 unix_time_ms(),
24290 );
24291 let spawn_profiles = SpawnProfileRegistry::new([
24292 crate::protocol::SpawnProfileDefaults {
24293 profile_id: profile_id.clone(),
24294 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24295 provider: agent("kimi"),
24296 mode: SessionMode::Pty,
24297 terminal_size: gate4agent_types::TerminalSize {
24298 rows: 30,
24299 columns: 120,
24300 },
24301 prompt: None,
24302 bundle_id: None,
24303 context_id: None,
24304 environment_profile_id: None,
24305 },
24306 ])
24307 .unwrap();
24308 let mut config = NodeServerConfig::new(
24309 &endpoint,
24310 "fixture-token",
24311 node_id.clone(),
24312 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24313 )
24314 .unwrap()
24315 .with_spawn_profiles(spawn_profiles)
24316 .with_session_environment_materialization(
24317 materialization_root,
24318 Arc::new(FixtureSecretResolver {
24319 deny: Arc::new(AtomicBool::new(false)),
24320 }),
24321 )
24322 .unwrap();
24323 config.fixture_raw_pty_runtime = true;
24324 let server = NodeServer::new_context_only_proof_fixture(
24325 config,
24326 agent("kimi"),
24327 proof_path.clone(),
24328 )
24329 .unwrap();
24330 let shared = Arc::clone(&server.shared);
24331 let shutdown = server.shutdown_handle();
24332
24333 let history = HistorySessionRecord {
24334 session_id: "context-only-source-history".to_owned(),
24335 title: None,
24336 cwd: None,
24337 model: None,
24338 message_count: 2,
24339 completed_turn_count: None,
24340 total_tokens: 0,
24341 messages: vec![
24342 gate4agent_types::HistoryMessageRecord {
24343 role: gate4agent_types::HistoryMessageRole::User,
24344 text: "continue the bounded parent run through native C2".to_owned(),
24345 },
24346 gate4agent_types::HistoryMessageRecord {
24347 role: gate4agent_types::HistoryMessageRole::Assistant,
24348 text: "the exact parent context is ready for the child run".to_owned(),
24349 },
24350 ],
24351 };
24352 let pack = NodeContextPack::export(
24353 ContextPackLineageReceipt {
24354 source_node_id: node_id.clone(),
24355 source_session: SessionAddress {
24356 workspace_id: workspace_id.clone(),
24357 session: SessionKey {
24358 instance_id: AgentInstanceId(71),
24359 generation: SessionGeneration(1),
24360 },
24361 },
24362 source_provider: agent("codex"),
24363 },
24364 &history,
24365 )
24366 .unwrap();
24367 let expected_context_hash = digest(&SHA256, pack.bytes())
24368 .as_ref()
24369 .iter()
24370 .map(|byte| format!("{byte:02x}"))
24371 .collect::<String>();
24372 let context = shared
24373 .context_catalog
24374 .write()
24375 .unwrap_or_else(|poisoned| poisoned.into_inner())
24376 .insert(pack)
24377 .unwrap();
24378 let spec = SpawnSpec {
24379 target: crate::protocol::SpawnTarget {
24380 node_id,
24381 workspace_id: workspace_id.clone(),
24382 worktree_id: None,
24383 },
24384 profile_id,
24385 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24386 "target-kimi-r1",
24387 ).unwrap(),
24388 overrides: crate::protocol::SpawnOverrides {
24389 context_id: crate::protocol::SpawnOverride::Set {
24390 value: context.id.clone(),
24391 },
24392 ..crate::protocol::SpawnOverrides::default()
24393 },
24394 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24395 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24396 required_capabilities: SpawnRequiredCapabilities::default(),
24397 };
24398 let resolved = match shared.resolve_spawn_spec(&spec) {
24399 Ok(resolved) => resolved,
24400 Err(error) => panic!(
24401 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24402 error.code,
24403 error.message,
24404 ),
24405 };
24406 let resolved_context = match shared.resolve_context(&resolved) {
24407 Ok(Some(resolved_context)) => resolved_context,
24408 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24409 Err(error) => panic!(
24410 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24411 error.code,
24412 error.message,
24413 ),
24414 };
24415 assert_eq!(resolved_context, context);
24416 let runtime_policy = match shared
24417 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24418 .await
24419 {
24420 Ok(runtime_policy) => runtime_policy,
24421 Err(error) => panic!(
24422 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24423 error.code,
24424 error.message,
24425 ),
24426 };
24427 assert!(runtime_policy.raw_pty_lifecycle);
24428
24429 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24430 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24431 agent("kimi"),
24432 TransportKind::Pty,
24433 vec![EnvMutation {
24434 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24435 value: Some(OsString::from("must-remain-rejected")),
24436 }],
24437 )
24438 .unwrap();
24439 assert_eq!(
24440 launch_control
24441 .install_native_launch_environment_overlay(
24442 AgentInstanceId(72),
24443 generic_overlay,
24444 )
24445 .unwrap_err(),
24446 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24447 );
24448
24449 let diagnostic_address = SessionAddress {
24450 workspace_id: workspace_id.clone(),
24451 session: SessionKey {
24452 instance_id: AgentInstanceId(73),
24453 generation: SessionGeneration(1),
24454 },
24455 };
24456 let (diagnostic_overlay, diagnostic_materialization) = match shared
24457 .prepare_session_materialization(
24458 &diagnostic_address,
24459 &agent("kimi"),
24460 SessionMode::Pty,
24461 None,
24462 None,
24463 Some(&resolved_context),
24464 None,
24465 )
24466 {
24467 Ok(Some(prepared)) => prepared,
24468 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24469 Err(error) => panic!(
24470 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24471 error.code,
24472 error.message,
24473 ),
24474 };
24475 let diagnostic_overlay = diagnostic_overlay
24476 .expect("Kimi context-only materialization omitted its environment overlay");
24477 assert!(matches!(
24478 &diagnostic_overlay,
24479 PreparedNativeLaunchOverlay::Environment(_),
24480 ));
24481 match shared.install_prepared_launch_overlay(
24482 diagnostic_address.session.instance_id,
24483 diagnostic_overlay,
24484 ) {
24485 Ok(None) => {}
24486 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24487 Err(error) => panic!(
24488 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24489 error.code,
24490 error.message,
24491 ),
24492 }
24493 assert!(launch_control.clear_native_instance_launch_overlay(
24494 diagnostic_address.session.instance_id,
24495 ));
24496 drop(diagnostic_materialization);
24497 assert!(shared
24498 .materializations
24499 .lock()
24500 .unwrap_or_else(|poisoned| poisoned.into_inner())
24501 .is_empty());
24502
24503 let node_task = tokio::spawn(server.run());
24504 let ready_client = timeout(Duration::from_secs(5), async {
24505 loop {
24506 match gate4agent_node_wire::LocalNodeClient::connect(
24507 &endpoint,
24508 &shared.node_id,
24509 ClientRole::Observer,
24510 "fixture-token",
24511 )
24512 .await
24513 {
24514 Ok(client) => break client,
24515 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24516 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24517 }
24518 }
24519 })
24520 .await
24521 .expect("Kimi context-only Node did not become ready");
24522 assert_eq!(
24523 ready_client
24524 .hello()
24525 .snapshot
24526 .provider_runtime_statuses
24527 .iter()
24528 .find(|status| status.provider().as_str() == "kimi")
24529 .expect("Kimi runtime status is missing")
24530 .mode(),
24531 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24532 );
24533 drop(ready_client);
24534
24535 let receipt = match shared.spawn_from_spec(spec).await {
24536 Ok(receipt) => receipt,
24537 Err(error) => panic!(
24538 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24539 error.code,
24540 error.message,
24541 ),
24542 };
24543 assert_eq!(receipt.provider, agent("kimi"));
24544 assert_eq!(receipt.context.as_ref(), Some(&context));
24545 assert!(receipt.context_binding_is_valid());
24546 timeout(Duration::from_secs(5), async {
24547 while !proof_path.is_file() {
24548 sleep(Duration::from_millis(10)).await;
24549 }
24550 })
24551 .await
24552 .expect("Kimi context-only fixture did not write its proof");
24553
24554 let proof = std::fs::read_to_string(&proof_path).unwrap();
24555 let lines = proof.lines().collect::<Vec<_>>();
24556 assert_eq!(lines.len(), 7);
24557 assert_eq!(lines[0], "context-only");
24558 assert!(Path::new(lines[1]).is_absolute());
24559 assert_ne!(
24560 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24561 std::fs::canonicalize(&workspace_root).unwrap(),
24562 );
24563 assert_eq!(
24564 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24565 std::fs::canonicalize(&workspace_root).unwrap(),
24566 );
24567 assert_eq!(lines[3], expected_context_hash);
24568 assert_eq!(lines[4], "g4a-context-pack-v1");
24569 assert_eq!(lines[5], "codex");
24570 assert_eq!(lines[6], "2");
24571 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24572 session.instance_id == receipt.session.session.instance_id
24573 && session.agent_id.as_str() == "kimi"
24574 }));
24575
24576 shutdown.request_shutdown().await.unwrap();
24577 timeout(Duration::from_secs(5), node_task)
24578 .await
24579 .unwrap()
24580 .unwrap()
24581 .unwrap();
24582 drop(shutdown);
24583 drop(shared);
24584 std::fs::remove_dir_all(root).unwrap();
24585 }
24586
24587 #[cfg(feature = "fixture")]
24588 #[test]
24589 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24590 let root = temporary_workspace_root("clean-exit-constructor");
24591 std::fs::create_dir_all(&root).unwrap();
24592 let config = construction_test_config("clean-exit-constructor")
24593 .with_state_path(root.join("node-state.json"))
24594 .unwrap();
24595 let server = NodeServer::new_clean_exit_fixture(
24596 config,
24597 root.clone(),
24598 root.join("started.marker"),
24599 root.join("release.signal"),
24600 )
24601 .unwrap();
24602 assert_eq!(
24603 server
24604 .shared
24605 .enabled_providers
24606 .iter()
24607 .map(AgentId::as_str)
24608 .collect::<Vec<_>>(),
24609 ["claude"],
24610 );
24611 drop(server);
24612 std::fs::remove_dir_all(root).unwrap();
24613 }
24614
24615 #[test]
24616 fn production_registry_provider_contract_manifest_is_exact() {
24617 let registry = active_registry().unwrap();
24618 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24619 assert_eq!(
24620 providers
24621 .iter()
24622 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24623 .collect::<Vec<_>>(),
24624 vec![
24625 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24626 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24627 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24628 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24629 ]
24630 );
24631 assert_eq!(
24632 adapters
24633 .iter()
24634 .map(|contract| (
24635 contract.provider.as_str(),
24636 contract.family,
24637 contract.adapter_id.as_str(),
24638 contract.revision.as_str(),
24639 ))
24640 .collect::<Vec<_>>(),
24641 vec![
24642 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24643 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24644 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24645 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24646 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24647 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24648 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24649 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24650 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24651 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24652 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24653 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24654 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24655 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24656 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24657 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24658 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24659 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24660 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24661 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24662 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24663 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24664 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24665 ]
24666 );
24667 }
24668
24669 fn construction_test_config(name: &str) -> NodeServerConfig {
24670 let workspace = WorkspaceConfig::new(
24671 WorkspaceId::new("primary").unwrap(),
24672 std::env::current_dir().unwrap(),
24673 )
24674 .unwrap();
24675 NodeServerConfig::new(
24676 format!(r"\\.\pipe\gate4agent-{name}"),
24677 "fixture-token",
24678 NodeId::new(name).unwrap(),
24679 [workspace],
24680 )
24681 .unwrap()
24682 }
24683
24684 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24685 let adapter = AdapterBinding::new(
24686 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24687 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24688 gate4agent_types::AdapterVerification::SyntheticFixture,
24689 )
24690 .unwrap();
24691 let agent_ids = ["claude", "codex", "kimi"]
24692 .into_iter()
24693 .map(|agent_id| AgentId::new(agent_id).unwrap())
24694 .collect::<Vec<_>>();
24695 let adapters = gate4agent_catalog::AdapterRegistry::new(
24696 [
24697 AdapterFamily::PtySemantic,
24698 AdapterFamily::Pipe,
24699 AdapterFamily::OneShot,
24700 AdapterFamily::Acp,
24701 AdapterFamily::Hook,
24702 AdapterFamily::ManagedHook,
24703 AdapterFamily::History,
24704 AdapterFamily::Resume,
24705 AdapterFamily::SessionOptions,
24706 AdapterFamily::CapabilityProbe,
24707 ]
24708 .into_iter()
24709 .map(|family| gate4agent_catalog::AdapterDescriptor {
24710 family,
24711 binding: adapter.clone(),
24712 agents: agent_ids.clone(),
24713 }),
24714 )
24715 .unwrap();
24716 let specs = agent_ids
24717 .iter()
24718 .map(|agent_id| {
24719 let agent_id = agent_id.as_str();
24720 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24721 spec.revision =
24722 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24723 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24724 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24725 adapter: adapter.clone(),
24726 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24727 launch_override: None,
24728 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24729 });
24730 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24731 adapter: adapter.clone(),
24732 launch_override: None,
24733 });
24734 spec.capabilities.adapters.hook = Some(adapter.clone());
24735 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24736 spec.capabilities.adapters.one_shot = Some(adapter.clone());
24737 spec.capabilities.adapters.history = Some(adapter.clone());
24738 spec.capabilities.adapters.resume = Some(adapter.clone());
24739 spec.capabilities.adapters.session_options = Some(adapter.clone());
24740 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24741 spec
24742 })
24743 .collect::<Vec<_>>();
24744 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24745 }
24746
24747 #[test]
24755 fn node_server_construction_accepts_the_production_manifest() {
24756 let registry = active_registry().unwrap();
24757 let (providers, _) = provider_contract_manifest(®istry).unwrap();
24758 assert_eq!(providers.len(), registry.iter().count());
24759 assert!(!providers.is_empty());
24760 let server = NodeServer::new_with_registry(
24761 construction_test_config("manifest-production-bound"),
24762 registry,
24763 )
24764 .unwrap();
24765 drop(server);
24766 }
24767
24768 #[test]
24769 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24770 let registry = maximum_size_provider_manifest_registry();
24771 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24772 assert_eq!(providers.len(), 3);
24773 assert_eq!(adapters.len(), 30);
24774 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24775 let error = match NodeServer::new_with_registry(
24776 construction_test_config("manifest-handshake-overflow"),
24777 registry,
24778 ) {
24779 Ok(_) => panic!("oversized negotiated manifest was accepted"),
24780 Err(error) => error,
24781 };
24782 assert!(matches!(
24783 error,
24784 NodeServerError::ProviderContractManifest(message)
24785 if message.contains("authentication binding length")
24786 && message.contains("8192-byte limit")
24787 ));
24788 }
24789
24790 #[test]
24791 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24792 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24793 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24794 let registry = AgentRegistry::new([spec]).unwrap();
24795 assert!(matches!(
24796 provider_contract_manifest(®istry),
24797 Err(NodeServerError::ProviderContractManifest(message))
24798 if message.contains("exceeds the 128-byte limit")
24799 ));
24800 }
24801
24802 #[test]
24803 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24804 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24805 spec.revision = "invalid revision".to_owned();
24806 let registry = AgentRegistry::new([spec]).unwrap();
24807 assert!(matches!(
24808 provider_contract_manifest(®istry),
24809 Err(NodeServerError::ProviderContractManifest(message))
24810 if message.contains("bounded lowercase ASCII")
24811 ));
24812 }
24813
24814 #[test]
24815 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24816 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24817 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24818 let conflicting_binding = builtin_registry()
24819 .get_by_id("codex")
24820 .unwrap()
24821 .capabilities
24822 .adapters
24823 .one_shot
24824 .clone()
24825 .unwrap();
24826 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24827 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24828 pipe.adapter = transport_binding;
24829 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24830 let registry = AgentRegistry::new([spec]).unwrap();
24831 assert!(matches!(
24832 provider_contract_manifest(®istry),
24833 Err(NodeServerError::ProviderContractManifest(message))
24834 if message.contains("conflicting OneShot adapter bindings")
24835 ));
24836 }
24837
24838 #[test]
24839 fn public_node_failures_expose_only_fixed_categories() {
24840 let provider = failure(
24841 NodeFailureCode::BackendOperationFailed,
24842 r"provider bearer-secret failed at C:\private\session.jsonl",
24843 );
24844 assert_eq!(provider.message, "backend-operation-failed");
24845 assert!(!provider.message.contains("bearer-secret"));
24846 assert!(!provider.message.contains(r"C:\private"));
24847 let persistence = persistence_failure(io::Error::new(
24848 io::ErrorKind::Other,
24849 r"state write failed at C:\private\state-v1.json",
24850 ));
24851 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24852 assert!(!persistence.message.contains(r"C:\private"));
24853
24854 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24855 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24856 assert_eq!(
24857 generic.to_string(),
24858 "node durable state failed: durable-state-load-failed",
24859 );
24860 }
24861 }
24862
24863 #[test]
24864 fn terminal_byte_sequences_are_bounded_before_dispatch() {
24865 let sequence = b"\x1b[1;5D".to_vec();
24866 assert_eq!(
24867 terminal_bytes_action(sequence.clone()).unwrap(),
24868 InputAction::TerminalBytes(sequence),
24869 );
24870 assert_eq!(
24871 terminal_bytes_action(Vec::new()).unwrap_err().code,
24872 NodeFailureCode::InvalidRequest,
24873 );
24874 assert_eq!(
24875 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24876 .unwrap_err()
24877 .code,
24878 NodeFailureCode::InvalidRequest,
24879 );
24880 }
24881
24882 #[test]
24883 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24884 let root = temporary_workspace_root("exclusive-state-owner");
24885 std::fs::create_dir_all(&root).unwrap();
24886 let workspace = WorkspaceConfig::new(
24887 WorkspaceId::new("primary").unwrap(),
24888 &root,
24889 )
24890 .unwrap();
24891 let state_path = root.join("state-v1.json");
24892 let endpoint = format!(
24893 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24894 std::process::id(),
24895 std::time::SystemTime::now()
24896 .duration_since(std::time::UNIX_EPOCH)
24897 .unwrap()
24898 .as_nanos(),
24899 );
24900 let config = NodeServerConfig::new(
24901 endpoint,
24902 "fixture-token",
24903 NodeId::new("node-1").unwrap(),
24904 [workspace],
24905 )
24906 .unwrap()
24907 .with_state_path(&state_path)
24908 .unwrap();
24909
24910 let first = NodeServer::new(config.clone()).unwrap();
24911 let error = match NodeServer::new(config.clone()) {
24912 Ok(_) => panic!("a second node server acquired the same durable state path"),
24913 Err(error) => error,
24914 };
24915 assert!(matches!(
24916 &error,
24917 NodeServerError::DurableState(error)
24918 if error.kind() == io::ErrorKind::WouldBlock
24919 ));
24920 assert_eq!(
24921 error.to_string(),
24922 "node durable state failed: durable-state-lock-failed",
24923 );
24924 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24925 drop(first);
24926 let second = NodeServer::new(config).unwrap();
24927 drop(second);
24928 std::fs::remove_dir_all(root).unwrap();
24929 }
24930
24931 #[test]
24932 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24933 let catalog = active_registry().unwrap();
24934 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24935 let workspace_id = WorkspaceId::new("primary").unwrap();
24936 let workspace = WorkspaceConfig::new(
24937 workspace_id.clone(),
24938 std::env::current_dir().unwrap(),
24939 )
24940 .unwrap();
24941 let shared = NodeShared::new(
24942 handle,
24943 "fixture-token".to_owned(),
24944 NodeId::new("node-1").unwrap(),
24945 vec![workspace.clone()],
24946 vec![agent("claude"), agent("codex"), agent("kimi")],
24947 );
24948 let instance_id = AgentInstanceId(17);
24949 let generation = SessionGeneration(3);
24950 assert!(shared.address_for(instance_id, generation).is_none());
24951 shared.bind_session(instance_id, workspace_id.clone(), generation);
24952 let address = SessionAddress {
24953 workspace_id,
24954 session: SessionKey { instance_id, generation },
24955 };
24956 assert_eq!(
24957 shared.workspace_root(&address.workspace_id).unwrap(),
24958 workspace.canonical_root,
24959 );
24960 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24961 shared.remove_binding(&address);
24962 assert!(shared.address_for(instance_id, generation).is_none());
24963 }
24964
24965 #[test]
24966 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24967 let address = SessionAddress {
24968 workspace_id: WorkspaceId::new("primary").unwrap(),
24969 session: SessionKey {
24970 instance_id: AgentInstanceId(7),
24971 generation: SessionGeneration(2),
24972 },
24973 };
24974 let record_id = SessionRecordId::new("record-a").unwrap();
24975 let size = gate4agent_types::TerminalSize {
24976 rows: 24,
24977 columns: 80,
24978 };
24979 let address_requests = [
24980 NodeRequest::Resume {
24981 session: address.clone(),
24982 terminal_size: size,
24983 initial_prompt: None,
24984 },
24985 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24986 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24987 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24988 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24989 NodeRequest::TerminalControl {
24990 session: address.clone(),
24991 control: TerminalControl::Enter,
24992 },
24993 NodeRequest::Resize { session: address.clone(), size },
24994 NodeRequest::Interrupt { session: address.clone() },
24995 NodeRequest::Stop { session: address.clone(), force: false },
24996 NodeRequest::Remove { session: address },
24997 ];
24998 for request in &address_requests {
24999 assert!(request_requires_open_provider_ids_with(
25000 request,
25001 |_| Some(agent("third-party-agent")),
25002 |_| Some(agent("claude")),
25003 ));
25004 assert!(!request_requires_open_provider_ids_with(
25005 request,
25006 |_| Some(agent("claude")),
25007 |_| Some(agent("claude")),
25008 ));
25009 assert!(request_requires_open_provider_ids_with(
25010 request,
25011 |_| None,
25012 |_| Some(agent("claude")),
25013 ));
25014 }
25015
25016 let record_requests = [
25017 NodeRequest::RenameSessionRecord {
25018 record_id: record_id.clone(),
25019 display_name: "renamed".to_owned(),
25020 },
25021 NodeRequest::ResumeSessionRecord {
25022 record_id: record_id.clone(),
25023 terminal_size: size,
25024 initial_prompt: None,
25025 },
25026 NodeRequest::ForgetSessionRecord { record_id },
25027 ];
25028 for request in &record_requests {
25029 assert!(request_requires_open_provider_ids_with(
25030 request,
25031 |_| Some(agent("claude")),
25032 |_| Some(agent("grok")),
25033 ));
25034 assert!(!request_requires_open_provider_ids_with(
25035 request,
25036 |_| Some(agent("claude")),
25037 |_| Some(agent("kimi")),
25038 ));
25039 assert!(request_requires_open_provider_ids_with(
25040 request,
25041 |_| Some(agent("claude")),
25042 |_| None,
25043 ));
25044 }
25045
25046 assert!(request_requires_open_provider_ids_with(
25047 &NodeRequest::Spawn {
25048 workspace_id: WorkspaceId::new("primary").unwrap(),
25049 provider: agent("third-party-agent"),
25050 mode: SessionMode::Pty,
25051 terminal_size: size,
25052 initial_prompt: None,
25053 },
25054 |_| None,
25055 |_| None,
25056 ));
25057 assert!(!request_requires_open_provider_ids_with(
25058 &NodeRequest::Snapshot,
25059 |_| None,
25060 |_| None,
25061 ));
25062 assert!(request_requires_open_provider_ids_with(
25063 &NodeRequest::ForgetContextPack {
25064 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25065 },
25066 |_| None,
25067 |_| None,
25068 ));
25069 }
25070
25071 #[test]
25072 fn legacy_projection_filters_open_record_replies_and_event_history() {
25073 let catalog = active_registry().unwrap();
25074 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25075 let workspace = WorkspaceConfig::new(
25076 WorkspaceId::new("primary").unwrap(),
25077 std::env::current_dir().unwrap(),
25078 )
25079 .unwrap();
25080 let shared = NodeShared::new(
25081 handle,
25082 "fixture-token".to_owned(),
25083 NodeId::new("node-1").unwrap(),
25084 vec![workspace],
25085 vec![agent("claude"), agent("grok")],
25086 );
25087 let legacy = record("claude", "legacy-record");
25088 let open = record("grok", "open-record");
25089 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25090 record: legacy.clone(),
25091 });
25092 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25093 record: open.clone(),
25094 });
25095 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25096 record_id: legacy.record_id.clone(),
25097 });
25098 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25099 record_id: open.record_id.clone(),
25100 });
25101 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25102 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25103 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25104 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25105
25106 let mut record_reply = ResponseEnvelope {
25107 request_id: 1,
25108 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25109 };
25110 project_response_legacy_provider_ids(&shared, &mut record_reply);
25111 assert!(matches!(
25112 record_reply.result,
25113 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25114 ));
25115
25116 let mut resync = ResponseEnvelope {
25117 request_id: 2,
25118 result: Ok(NodeResponse::Resync {
25119 event_sequence: open_removed.sequence,
25120 oldest_available_sequence: 1,
25121 snapshot: shared.snapshot(),
25122 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25123 }),
25124 };
25125 project_response_legacy_provider_ids(&shared, &mut resync);
25126 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25127 panic!("legacy projection changed the resync response kind");
25128 };
25129 assert!(snapshot.session_records.iter().all(|item| {
25130 provider_id_is_legacy(&item.provider)
25131 }));
25132 assert_eq!(events.len(), 2);
25133 assert!(events.iter().all(|event| matches!(
25134 &event.event,
25135 NodeEvent::SessionRecordUpserted { record }
25136 if provider_id_is_legacy(&record.provider)
25137 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25138 }
25139
25140 #[test]
25141 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25142 let catalog = active_registry().unwrap();
25143 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25144 let workspace_id = WorkspaceId::new("primary").unwrap();
25145 let workspace = WorkspaceConfig::new(
25146 workspace_id.clone(),
25147 std::env::current_dir().unwrap(),
25148 )
25149 .unwrap();
25150 let shared = NodeShared::new(
25151 handle,
25152 "fixture-token".to_owned(),
25153 NodeId::new("node-1").unwrap(),
25154 vec![workspace],
25155 vec![agent("claude")],
25156 );
25157 for index in 0..CONTROL_SESSIONS_MAX {
25158 shared.bind_session(
25159 AgentInstanceId((index + 1) as u64),
25160 workspace_id.clone(),
25161 SessionGeneration(1),
25162 );
25163 }
25164 let error = shared.ensure_binding_capacity().unwrap_err();
25165 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25166 }
25167
25168 #[tokio::test]
25169 async fn provider_spawn_admission_rejects_before_session_mutation() {
25170 let catalog = active_registry().unwrap();
25171 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25172 let workspace = WorkspaceConfig::new(
25173 WorkspaceId::new("primary").unwrap(),
25174 std::env::current_dir().unwrap(),
25175 )
25176 .unwrap();
25177 let mut shared = NodeShared::new(
25178 handle,
25179 "fixture-token".to_owned(),
25180 NodeId::new("node-provider-admission").unwrap(),
25181 vec![workspace],
25182 vec![agent("claude")],
25183 );
25184 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25185 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25186 ])
25187 .unwrap();
25188 let terminal_size = gate4agent_types::TerminalSize {
25189 rows: 24,
25190 columns: 80,
25191 };
25192
25193 let unavailable = shared
25194 .spawn_session(
25195 WorkspaceId::new("primary").unwrap(),
25196 agent("claude"),
25197 SessionMode::Pty,
25198 terminal_size,
25199 None,
25200 )
25201 .await
25202 .unwrap_err();
25203 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25204 assert_eq!(unavailable.message, "backend-operation-failed");
25205
25206 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25207 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25208 agent("claude"),
25209 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25210 ),
25211 ])
25212 .unwrap();
25213 let semantic = shared
25214 .spawn_session(
25215 WorkspaceId::new("primary").unwrap(),
25216 agent("claude"),
25217 SessionMode::Inline,
25218 terminal_size,
25219 None,
25220 )
25221 .await
25222 .unwrap_err();
25223 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25224 assert_eq!(semantic.message, "unsupported-capability");
25225
25226 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25227 assert!(shared
25228 .session_bindings
25229 .lock()
25230 .unwrap_or_else(|poisoned| poisoned.into_inner())
25231 .is_empty());
25232 assert!(shared
25233 .session_records
25234 .lock()
25235 .unwrap_or_else(|poisoned| poisoned.into_inner())
25236 .records
25237 .is_empty());
25238 assert!(shared.handle.snapshot().sessions.is_empty());
25239 }
25240
25241 #[tokio::test]
25242 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25243 let catalog = active_registry().unwrap();
25244 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25245 let workspace_id = WorkspaceId::new("primary").unwrap();
25246 let workspace = WorkspaceConfig::new(
25247 workspace_id.clone(),
25248 std::env::current_dir().unwrap(),
25249 )
25250 .unwrap();
25251 let shared = NodeShared::new(
25252 handle,
25253 "fixture-token".to_owned(),
25254 NodeId::new("node-raw-spawn-record").unwrap(),
25255 vec![workspace],
25256 vec![agent("claude")],
25257 );
25258 let address = SessionAddress {
25259 workspace_id,
25260 session: SessionKey {
25261 instance_id: AgentInstanceId(71),
25262 generation: SessionGeneration::default(),
25263 },
25264 };
25265
25266 let record_id = shared
25267 .bind_spawn_session(
25268 &address,
25269 agent("claude"),
25270 SessionMode::Pty,
25271 ProviderRuntimePolicy::raw_pty(),
25272 SpawnRecordPolicy::Always,
25273 None,
25274 )
25275 .unwrap()
25276 .unwrap();
25277 shared
25278 .handle
25279 .dispatch(shared.prepare_command(ControlCommand::Register {
25280 instance_id: address.session.instance_id,
25281 agent_id: agent("claude"),
25282 transport: TransportKind::Pty,
25283 }))
25284 .unwrap();
25285 runtime.tick().await;
25286
25287 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25288 session.instance_id == address.session.instance_id
25289 && session.generation == address.session.generation
25290 }));
25291 let record = shared.record(&record_id).unwrap();
25292 assert_eq!(record.display_name, "claude #1");
25293 assert_eq!(record.state, ManagedSessionState::Live);
25294 assert_eq!(record.active_session.as_ref(), Some(&address));
25295 assert!(record.provider_session.is_none());
25296 let binding_record_id = shared
25297 .session_bindings
25298 .lock()
25299 .unwrap_or_else(|poisoned| poisoned.into_inner())
25300 .get(&address.session.instance_id)
25301 .and_then(|binding| binding.record_id.clone());
25302 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25303
25304 let renamed = shared
25305 .rename_session_record(&record_id, "raw review".to_owned())
25306 .unwrap();
25307 assert_eq!(renamed.record_id, record_id);
25308 assert_eq!(renamed.display_name, "raw review");
25309 shared.reconcile_managed_record(
25310 &record_id,
25311 &address,
25312 &ControlEvent {
25313 sequence: 1,
25314 command_id: None,
25315 instance_id: address.session.instance_id,
25316 generation: address.session.generation,
25317 event: ControlEventKind::Running { process_id: None },
25318 },
25319 false,
25320 false,
25321 );
25322 let running = shared.record(&record_id).unwrap();
25323 assert_eq!(running.state, ManagedSessionState::Live);
25324 assert_eq!(running.active_session.as_ref(), Some(&address));
25325 assert_eq!(running.display_name, "raw review");
25326
25327 shared.reconcile_managed_record(
25328 &record_id,
25329 &address,
25330 &ControlEvent {
25331 sequence: 2,
25332 command_id: None,
25333 instance_id: address.session.instance_id,
25334 generation: address.session.generation,
25335 event: ControlEventKind::Exited {
25336 exit_code: Some(0),
25337 forced: false,
25338 },
25339 },
25340 false,
25341 false,
25342 );
25343 let detached = shared.record(&record_id).unwrap();
25344 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25345 assert!(detached.active_session.is_none());
25346 assert_eq!(detached.display_name, "raw review");
25347 let renamed_detached = shared
25348 .rename_session_record(&record_id, "raw review detached".to_owned())
25349 .unwrap();
25350 assert_eq!(renamed_detached.record_id, record_id);
25351 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25352 assert!(renamed_detached.active_session.is_none());
25353 assert_eq!(renamed_detached.display_name, "raw review detached");
25354 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25355 }
25356
25357 #[test]
25358 fn legacy_raw_spawn_binding_remains_untracked() {
25359 let catalog = active_registry().unwrap();
25360 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25361 let workspace_id = WorkspaceId::new("primary").unwrap();
25362 let workspace = WorkspaceConfig::new(
25363 workspace_id.clone(),
25364 std::env::current_dir().unwrap(),
25365 )
25366 .unwrap();
25367 let shared = NodeShared::new(
25368 handle,
25369 "fixture-token".to_owned(),
25370 NodeId::new("node-legacy-raw-spawn").unwrap(),
25371 vec![workspace],
25372 vec![agent("claude")],
25373 );
25374 let address = SessionAddress {
25375 workspace_id,
25376 session: SessionKey {
25377 instance_id: AgentInstanceId(72),
25378 generation: SessionGeneration::default(),
25379 },
25380 };
25381
25382 let record_id = shared
25383 .bind_spawn_session(
25384 &address,
25385 agent("claude"),
25386 SessionMode::Pty,
25387 ProviderRuntimePolicy::raw_pty(),
25388 SpawnRecordPolicy::ProviderIdentityOnly,
25389 None,
25390 )
25391 .unwrap();
25392
25393 assert!(record_id.is_none());
25394 let binding_record_id = shared
25395 .session_bindings
25396 .lock()
25397 .unwrap_or_else(|poisoned| poisoned.into_inner())
25398 .get(&address.session.instance_id)
25399 .and_then(|binding| binding.record_id.clone());
25400 assert!(binding_record_id.is_none());
25401 assert!(shared.snapshot().session_records.is_empty());
25402 }
25403
25404 #[tokio::test]
25405 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25406 let catalog = active_registry().unwrap();
25407 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25408 let workspace_id = WorkspaceId::new("primary").unwrap();
25409 let workspace = WorkspaceConfig::new(
25410 workspace_id.clone(),
25411 std::env::current_dir().unwrap(),
25412 )
25413 .unwrap();
25414 let mut shared = NodeShared::new(
25415 handle,
25416 "fixture-token".to_owned(),
25417 NodeId::new("node-raw-semantic-gate").unwrap(),
25418 vec![workspace],
25419 vec![agent("claude")],
25420 );
25421 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25422 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25423 ])
25424 .unwrap();
25425 shared
25426 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25427 .unwrap();
25428 let address = SessionAddress {
25429 workspace_id,
25430 session: SessionKey {
25431 instance_id: AgentInstanceId(72),
25432 generation: SessionGeneration::default(),
25433 },
25434 };
25435 shared.bind_session_with_policy(
25436 &address,
25437 ProviderRuntimePolicy::raw_pty(),
25438 None,
25439 );
25440 shared
25441 .handle
25442 .dispatch(shared.prepare_command(ControlCommand::Register {
25443 instance_id: address.session.instance_id,
25444 agent_id: agent("claude"),
25445 transport: TransportKind::Pty,
25446 }))
25447 .unwrap();
25448 runtime.tick().await;
25449 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25450 let revision_before = shared.handle.snapshot().revision;
25451 let history_sequence_before = shared.current_sequence();
25452 let terminal_size = gate4agent_types::TerminalSize {
25453 rows: 24,
25454 columns: 80,
25455 };
25456
25457 for request in [
25458 NodeRequest::Prompt {
25459 session: address.clone(),
25460 text: "prompt".to_owned(),
25461 },
25462 NodeRequest::Paste {
25463 session: address.clone(),
25464 text: "paste".to_owned(),
25465 },
25466 NodeRequest::Resume {
25472 session: address.clone(),
25473 terminal_size,
25474 initial_prompt: Some("resume".to_owned()),
25475 },
25476 ] {
25477 let error = process_request_inner(
25478 &shared,
25479 77,
25480 ClientRole::Operator,
25481 request,
25482 )
25483 .await
25484 .unwrap_err();
25485 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25486 }
25487
25488 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25489 assert_eq!(shared.handle.snapshot().revision, revision_before);
25490 assert_eq!(shared.current_sequence(), history_sequence_before);
25491 let bindings = shared
25492 .session_bindings
25493 .lock()
25494 .unwrap_or_else(|poisoned| poisoned.into_inner());
25495 let binding = bindings.get(&address.session.instance_id).unwrap();
25496 assert_eq!(binding.generation, address.session.generation);
25497 assert!(binding.pending_resume.is_none());
25498 assert!(binding.record_id.is_none());
25499 }
25500
25501 #[tokio::test]
25502 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25503 let catalog = active_registry().unwrap();
25504 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25505 let primary = WorkspaceConfig::new(
25506 WorkspaceId::new("primary").unwrap(),
25507 std::env::current_dir().unwrap(),
25508 )
25509 .unwrap();
25510 let shared = NodeShared::new(
25511 handle,
25512 "fixture-token".to_owned(),
25513 NodeId::new("node-1").unwrap(),
25514 vec![primary],
25515 vec![agent("claude")],
25516 );
25517 shared
25518 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25519 .unwrap();
25520 let before = shared.snapshot();
25521 let error = process_request_inner(
25522 &shared,
25523 77,
25524 ClientRole::Operator,
25525 NodeRequest::RegisterWorkspace {
25526 workspace_id: WorkspaceId::new("foreign").unwrap(),
25527 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25528 },
25529 )
25530 .await
25531 .unwrap_err();
25532 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25533 assert_eq!(error.message, "invalid-request");
25534 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25535 }
25536
25537 #[tokio::test]
25538 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25539 let catalog = active_registry().unwrap();
25540 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25541 let primary_id = WorkspaceId::new("primary").unwrap();
25542 let primary = WorkspaceConfig::new(
25543 primary_id.clone(),
25544 std::env::current_dir().unwrap(),
25545 )
25546 .unwrap();
25547 let shared = NodeShared::new(
25548 handle,
25549 "fixture-token".to_owned(),
25550 NodeId::new("node-1").unwrap(),
25551 vec![primary.clone()],
25552 vec![agent("claude")],
25553 );
25554 let secondary_id = WorkspaceId::new("secondary").unwrap();
25555 let secondary = shared
25556 .register_workspace(
25557 secondary_id.clone(),
25558 std::env::temp_dir().to_string_lossy().into_owned(),
25559 )
25560 .await
25561 .unwrap();
25562 assert_eq!(secondary.workspace_id, secondary_id);
25563 assert!(secondary.sessions.is_empty());
25564 assert_eq!(shared.snapshot().workspaces.len(), 2);
25565
25566 let duplicate_id = shared
25567 .register_workspace(
25568 secondary_id.clone(),
25569 primary.canonical_root().to_owned(),
25570 )
25571 .await
25572 .unwrap_err();
25573 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25574 let duplicate_root = shared
25575 .register_workspace(
25576 WorkspaceId::new("duplicate-root").unwrap(),
25577 windows_path_text(&secondary.canonical_root).to_owned(),
25578 )
25579 .await
25580 .unwrap_err();
25581 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25582
25583 let address = SessionAddress {
25584 workspace_id: secondary_id.clone(),
25585 session: SessionKey {
25586 instance_id: AgentInstanceId(41),
25587 generation: SessionGeneration(1),
25588 },
25589 };
25590 shared.bind_session(
25591 address.session.instance_id,
25592 secondary_id.clone(),
25593 address.session.generation,
25594 );
25595 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25596 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25597 shared.remove_binding(&address);
25598 shared.unregister_workspace(&secondary_id).unwrap();
25599 assert_eq!(shared.snapshot().workspaces.len(), 1);
25600
25601 let last = shared.unregister_workspace(&primary_id).unwrap_err();
25602 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25603 let history = shared
25604 .history
25605 .lock()
25606 .unwrap_or_else(|poisoned| poisoned.into_inner());
25607 assert!(matches!(
25608 &history.events[0],
25609 NodeEventEnvelope {
25610 sequence: 1,
25611 event: NodeEvent::WorkspaceAdded { workspace },
25612 } if workspace.workspace_id == secondary_id
25613 ));
25614 assert!(matches!(
25615 &history.events[1],
25616 NodeEventEnvelope {
25617 sequence: 2,
25618 event: NodeEvent::WorkspaceRemoved { workspace_id },
25619 } if workspace_id == &secondary_id
25620 ));
25621 }
25622
25623 #[tokio::test]
25624 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25625 let secondary_root = temporary_workspace_root("provider-session-reference-index");
25626 std::fs::create_dir_all(&secondary_root).unwrap();
25627 let catalog = active_registry().unwrap();
25628 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25629 let primary_id = WorkspaceId::new("primary").unwrap();
25630 let secondary_id = WorkspaceId::new("secondary").unwrap();
25631 let shared = NodeShared::new(
25632 handle,
25633 "fixture-token".to_owned(),
25634 NodeId::new("node-session-reference-index").unwrap(),
25635 vec![
25636 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25637 .unwrap(),
25638 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25639 ],
25640 vec![agent("claude"), agent("codex")],
25641 );
25642 let identity = ProviderSessionIdentity {
25643 key: gate4agent_types::ProviderSessionKey::SessionId,
25644 id: "provider-session-42".to_owned(),
25645 transcript_path: None,
25646 };
25647 let index_request = |workspace_id: WorkspaceId,
25648 provider: AgentId,
25649 identity: ProviderSessionIdentity,
25650 display_name: &str| {
25651 NodeRequest::IndexProviderSession {
25652 workspace_id,
25653 provider,
25654 identity,
25655 display_name: display_name.to_owned(),
25656 }
25657 };
25658 let request = index_request(
25659 primary_id.clone(),
25660 agent("claude"),
25661 identity.clone(),
25662 "release shepherd",
25663 );
25664 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25665 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25666 }));
25667 let observer = process_request_inner(
25668 &shared,
25669 77,
25670 ClientRole::Observer,
25671 request.clone(),
25672 )
25673 .await
25674 .unwrap_err();
25675 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25676 let uncontrolled = process_request_inner(
25677 &shared,
25678 77,
25679 ClientRole::Operator,
25680 request.clone(),
25681 )
25682 .await
25683 .unwrap_err();
25684 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25685 shared
25686 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25687 .unwrap();
25688
25689 let indexed = process_request_inner(
25690 &shared,
25691 77,
25692 ClientRole::Operator,
25693 request.clone(),
25694 )
25695 .await
25696 .unwrap();
25697 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25698 panic!("provider session reference index returned another response");
25699 };
25700 assert_eq!(record.state, ManagedSessionState::Dormant);
25701 assert_eq!(record.mode, SessionMode::Pty);
25702 assert_eq!(record.workspace_id, primary_id);
25703 assert_eq!(record.provider_session.as_ref(), Some(&identity));
25704 assert!(record.active_session.is_none());
25705 assert!(record.environment_profile.is_none());
25706 assert!(record.bundle.is_none());
25707 assert!(record.context.is_none());
25708
25709 let repeated = process_request_inner(
25710 &shared,
25711 77,
25712 ClientRole::Operator,
25713 index_request(
25714 primary_id.clone(),
25715 agent("claude"),
25716 identity.clone(),
25717 "ignored replacement",
25718 ),
25719 )
25720 .await
25721 .unwrap();
25722 assert!(matches!(
25723 repeated,
25724 NodeResponse::ProviderSessionIndexed { record: ref existing }
25725 if existing.record_id == record.record_id
25726 && existing.display_name == "release shepherd"
25727 ));
25728 let conflicting_workspace = process_request_inner(
25729 &shared,
25730 77,
25731 ClientRole::Operator,
25732 index_request(
25733 secondary_id,
25734 agent("claude"),
25735 identity.clone(),
25736 "release shepherd",
25737 ),
25738 )
25739 .await
25740 .unwrap_err();
25741 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25742 let conflicting_provider = process_request_inner(
25743 &shared,
25744 77,
25745 ClientRole::Operator,
25746 index_request(
25747 primary_id.clone(),
25748 agent("codex"),
25749 identity.clone(),
25750 "release shepherd",
25751 ),
25752 )
25753 .await
25754 .unwrap_err();
25755 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25756 let transcript_rejected = process_request_inner(
25757 &shared,
25758 77,
25759 ClientRole::Operator,
25760 index_request(
25761 primary_id,
25762 agent("claude"),
25763 ProviderSessionIdentity {
25764 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25765 ..identity
25766 },
25767 "release shepherd",
25768 ),
25769 )
25770 .await
25771 .unwrap_err();
25772 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25773 let upserts = shared
25774 .history
25775 .lock()
25776 .unwrap_or_else(|poisoned| poisoned.into_inner())
25777 .events
25778 .iter()
25779 .filter(|event| matches!(
25780 &event.event,
25781 NodeEvent::SessionRecordUpserted { record: published }
25782 if published.record_id == record.record_id
25783 ))
25784 .count();
25785 assert_eq!(upserts, 1);
25786 std::fs::remove_dir_all(secondary_root).unwrap();
25787 }
25788
25789 #[tokio::test]
25790 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25791 let fixture_root = temporary_workspace_root("standalone-independent");
25792 let target = fixture_root.join("independent");
25793 std::fs::create_dir_all(&fixture_root).unwrap();
25794 let catalog = active_registry().unwrap();
25795 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25796 let selected = WorkspaceConfig::new(
25797 WorkspaceId::new("primary").unwrap(),
25798 std::env::current_dir().unwrap(),
25799 )
25800 .unwrap();
25801 let selected_root = selected.canonical_root().to_owned();
25802 let shared = NodeShared::new(
25803 handle,
25804 "fixture-token".to_owned(),
25805 NodeId::new("node-standalone").unwrap(),
25806 vec![selected],
25807 vec![agent("claude")],
25808 );
25809 let workspace_id = WorkspaceId::new("independent").unwrap();
25810 let request = NodeRequest::CreateStandaloneWorkspace {
25811 workspace_id: workspace_id.clone(),
25812 root: opaque_windows_path(target.to_string_lossy().into_owned()),
25813 initial_branch: Some("main".to_owned()),
25814 };
25815 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25816 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25817 }));
25818 assert!(!request_uses_unnegotiated_capability(
25819 &request,
25820 &baseline_capabilities().unwrap(),
25821 ));
25822 assert!(request_uses_unnegotiated_capability(&request, &[]));
25823
25824 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25825 .await
25826 .unwrap_err();
25827 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25828 assert!(!target.exists());
25829 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25830 .await
25831 .unwrap_err();
25832 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25833 assert!(!target.exists());
25834 shared
25835 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25836 .unwrap();
25837
25838 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25839 .await
25840 .unwrap();
25841 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25842 panic!("standalone workspace creation returned another response");
25843 };
25844 assert_eq!(workspace.workspace_id, workspace_id);
25845 assert!(target.join(".git").is_dir());
25846 let branch = run_git_bounded(
25847 target.to_str().unwrap(),
25848 &["branch", "--show-current"],
25849 GIT_OUTPUT_MAX_BYTES,
25850 )
25851 .await
25852 .unwrap();
25853 assert!(branch.success);
25854 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25855 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25856 assert_eq!(worktrees.len(), 1);
25857 let listed_root = WorkspaceConfig::new(
25858 WorkspaceId::new("listed-independent").unwrap(),
25859 &worktrees[0].path,
25860 )
25861 .unwrap();
25862 assert!(platform::roots_equal(
25863 listed_root.canonical_root(),
25864 windows_path_text(&workspace.canonical_root),
25865 ));
25866 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25867 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25868 snapshot.workspace_id == workspace_id
25869 && snapshot.canonical_root == workspace.canonical_root
25870 }));
25871 assert!(shared
25872 .history
25873 .lock()
25874 .unwrap_or_else(|poisoned| poisoned.into_inner())
25875 .events
25876 .iter()
25877 .any(|event| matches!(
25878 &event.event,
25879 NodeEvent::WorkspaceAdded { workspace }
25880 if workspace.workspace_id == workspace_id
25881 )));
25882 std::fs::remove_dir_all(fixture_root).unwrap();
25883 }
25884
25885 #[tokio::test]
25886 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25887 let fixture_root = temporary_workspace_root("standalone-compensation");
25888 let created_target = fixture_root.join("created-target");
25889 let existing_target = fixture_root.join("existing-target");
25890 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25891 let invalid_state_path = invalid_state_parent.join("state.json");
25892 std::fs::create_dir_all(&existing_target).unwrap();
25893 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25894 let catalog = active_registry().unwrap();
25895 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25896 let primary = WorkspaceConfig::new(
25897 WorkspaceId::new("primary").unwrap(),
25898 std::env::current_dir().unwrap(),
25899 )
25900 .unwrap();
25901 let mut shared = NodeShared::new(
25902 handle,
25903 "fixture-token".to_owned(),
25904 NodeId::new("node-standalone-compensation").unwrap(),
25905 vec![primary],
25906 vec![agent("claude")],
25907 );
25908 shared.state_path = Some(invalid_state_path);
25909
25910 let created_recovery = shared
25911 .create_standalone_workspace(
25912 WorkspaceId::new("compensated").unwrap(),
25913 created_target.to_string_lossy().into_owned(),
25914 Some("main".to_owned()),
25915 )
25916 .await
25917 .unwrap_err();
25918 assert_eq!(
25919 created_recovery.code,
25920 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25921 );
25922 assert!(created_target.join(".git").is_dir());
25923 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25924 workspace.workspace_id.as_str() != "compensated"
25925 }));
25926
25927 let existing_recovery = shared
25928 .create_standalone_workspace(
25929 WorkspaceId::new("restored").unwrap(),
25930 existing_target.to_string_lossy().into_owned(),
25931 None,
25932 )
25933 .await
25934 .unwrap_err();
25935 assert_eq!(
25936 existing_recovery.code,
25937 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25938 );
25939 assert!(existing_target.is_dir());
25940 assert!(existing_target.join(".git").is_dir());
25941 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25942 workspace.workspace_id.as_str() != "restored"
25943 }));
25944
25945 let registered_empty = fixture_root.join("registered-empty");
25946 std::fs::create_dir(®istered_empty).unwrap();
25947 let catalog = active_registry().unwrap();
25948 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25949 let registered = WorkspaceConfig::new(
25950 WorkspaceId::new("registered-empty").unwrap(),
25951 ®istered_empty,
25952 )
25953 .unwrap();
25954 let duplicate_shared = NodeShared::new(
25955 handle,
25956 "fixture-token".to_owned(),
25957 NodeId::new("node-standalone-duplicate-root").unwrap(),
25958 vec![registered],
25959 vec![agent("claude")],
25960 );
25961 let duplicate = duplicate_shared
25962 .create_standalone_workspace(
25963 WorkspaceId::new("duplicate-root").unwrap(),
25964 registered_empty.to_string_lossy().into_owned(),
25965 None,
25966 )
25967 .await
25968 .unwrap_err();
25969 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25970 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
25971 std::fs::remove_dir_all(fixture_root).unwrap();
25972 }
25973
25974 #[test]
25975 fn managed_record_id_collision_preserves_the_existing_record() {
25976 let catalog = active_registry().unwrap();
25977 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25978 let workspace = WorkspaceConfig::new(
25979 WorkspaceId::new("primary").unwrap(),
25980 std::env::current_dir().unwrap(),
25981 )
25982 .unwrap();
25983 let shared = NodeShared::new(
25984 handle,
25985 "fixture-token".to_owned(),
25986 NodeId::new("node-1").unwrap(),
25987 vec![workspace.clone()],
25988 vec![agent("claude")],
25989 );
25990 let record_id = SessionRecordId::new("sr-collision").unwrap();
25991 let original = ManagedSessionRecord {
25992 record_id: record_id.clone(),
25993 display_name: "original".to_owned(),
25994 provider: agent("claude"),
25995 mode: SessionMode::Pty,
25996 state: ManagedSessionState::Dormant,
25997 workspace_id: workspace.workspace_id,
25998 canonical_root: opaque_windows_path(workspace.canonical_root),
25999 provider_session: None,
26000 active_session: None,
26001 environment_profile: None,
26002 bundle: None,
26003 context_id: None,
26004 context: None,
26005 exported_context: None,
26006 task_binding: None,
26007 created_at_unix_ms: 1,
26008 updated_at_unix_ms: 1,
26009 last_error: None,
26010 };
26011 shared.insert_record(original.clone()).unwrap();
26012 let mut collision = original.clone();
26013 collision.display_name = "replacement".to_owned();
26014 let error = shared.insert_record(collision).unwrap_err();
26015 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26016 assert_eq!(shared.record(&record_id).unwrap(), original);
26017 }
26018
26019 #[test]
26020 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26021 let catalog = active_registry().unwrap();
26022 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26023 let workspace = WorkspaceConfig::new(
26024 WorkspaceId::new("primary").unwrap(),
26025 std::env::current_dir().unwrap(),
26026 )
26027 .unwrap();
26028 let shared = NodeShared::new(
26029 handle,
26030 "fixture-token".to_owned(),
26031 NodeId::new("node-1").unwrap(),
26032 vec![workspace.clone()],
26033 vec![agent("claude")],
26034 );
26035 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26036 shared
26037 .insert_record(ManagedSessionRecord {
26038 record_id: record_id.clone(),
26039 display_name: "runtime error".to_owned(),
26040 provider: agent("claude"),
26041 mode: SessionMode::Pty,
26042 state: ManagedSessionState::IdentityPending,
26043 workspace_id: workspace.workspace_id,
26044 canonical_root: opaque_windows_path(workspace.canonical_root),
26045 provider_session: None,
26046 active_session: None,
26047 environment_profile: None,
26048 bundle: None,
26049 context_id: None,
26050 context: None,
26051 exported_context: None,
26052 task_binding: None,
26053 created_at_unix_ms: 1,
26054 updated_at_unix_ms: 1,
26055 last_error: None,
26056 })
26057 .unwrap();
26058
26059 shared
26060 .mark_record_error(
26061 &record_id,
26062 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26063 )
26064 .unwrap();
26065 let record = shared.record(&record_id).unwrap();
26066 assert_eq!(record.state, ManagedSessionState::Unavailable);
26067 assert_eq!(
26068 record.last_error.as_deref(),
26069 Some("provider-runtime-failed"),
26070 );
26071 let snapshot = shared.snapshot();
26072 assert_eq!(snapshot.session_records, vec![record]);
26073 }
26074
26075 #[test]
26084 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26085 let catalog = active_registry().unwrap();
26086 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26087 let workspace = WorkspaceConfig::new(
26088 WorkspaceId::new("primary").unwrap(),
26089 std::env::current_dir().unwrap(),
26090 )
26091 .unwrap();
26092 let shared = NodeShared::new(
26093 handle,
26094 "fixture-token".to_owned(),
26095 NodeId::new("node-1").unwrap(),
26096 vec![workspace],
26097 vec![agent("claude")],
26098 );
26099
26100 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26101
26102 shared.record_rejected_command(
26103 CommandId(1),
26104 "agent 'claude' does not support transport Acp".to_owned(),
26105 true,
26106 );
26107 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26109
26110 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26111 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26112 assert!(reason.unsupported_transport);
26113
26114 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26116
26117 shared.record_rejected_command(
26122 CommandId(3),
26123 "backend busy".to_owned(),
26124 false,
26125 );
26126 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26127 assert!(!unclassified.unsupported_transport);
26128 }
26129
26130 #[test]
26131 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26132 let catalog = active_registry().unwrap();
26133 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26134 let workspace = WorkspaceConfig::new(
26135 WorkspaceId::new("primary").unwrap(),
26136 std::env::current_dir().unwrap(),
26137 )
26138 .unwrap();
26139 let shared = NodeShared::new(
26140 handle,
26141 "fixture-token".to_owned(),
26142 NodeId::new("node-identity-policy").unwrap(),
26143 vec![workspace.clone()],
26144 vec![agent("claude")],
26145 );
26146 let address = SessionAddress {
26147 workspace_id: workspace.workspace_id.clone(),
26148 session: SessionKey {
26149 instance_id: AgentInstanceId(90),
26150 generation: SessionGeneration(1),
26151 },
26152 };
26153 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26154 shared
26155 .insert_record(ManagedSessionRecord {
26156 record_id: record_id.clone(),
26157 display_name: "policy denied".to_owned(),
26158 provider: agent("claude"),
26159 mode: SessionMode::Pty,
26160 state: ManagedSessionState::IdentityPending,
26161 workspace_id: workspace.workspace_id,
26162 canonical_root: opaque_windows_path(workspace.canonical_root),
26163 provider_session: None,
26164 active_session: Some(address.clone()),
26165 environment_profile: None,
26166 bundle: None,
26167 context_id: None,
26168 context: None,
26169 exported_context: None,
26170 task_binding: None,
26171 created_at_unix_ms: 1,
26172 updated_at_unix_ms: 1,
26173 last_error: None,
26174 })
26175 .unwrap();
26176 let event = ControlEvent {
26177 sequence: 1,
26178 command_id: None,
26179 instance_id: address.session.instance_id,
26180 generation: address.session.generation,
26181 event: ControlEventKind::ProviderEvent {
26182 sequence: 1,
26183 source: gate4agent_types::ProviderSource {
26184 family: AdapterFamily::PtySemantic,
26185 binding: AdapterBinding::new(
26186 gate4agent_types::AdapterId::new("claude").unwrap(),
26187 "fixture/v1",
26188 gate4agent_types::AdapterVerification::SyntheticFixture,
26189 )
26190 .unwrap(),
26191 },
26192 source_sequence: 1,
26193 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26194 identity: gate4agent_types::ProviderSessionIdentity {
26195 key: gate4agent_types::ProviderSessionKey::SessionId,
26196 id: "provider-session".to_owned(),
26197 transcript_path: None,
26198 },
26199 },
26200 },
26201 };
26202
26203 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26204
26205 let record = shared.record(&record_id).unwrap();
26206 assert!(record.provider_session.is_none());
26207 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26208 assert_eq!(record.active_session.as_ref(), Some(&address));
26209 }
26210
26211 #[test]
26212 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26213 let secondary_root = temporary_workspace_root("identity-scope");
26214 std::fs::create_dir_all(&secondary_root).unwrap();
26215 let catalog = active_registry().unwrap();
26216 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26217 let primary = WorkspaceConfig::new(
26218 WorkspaceId::new("primary").unwrap(),
26219 std::env::current_dir().unwrap(),
26220 )
26221 .unwrap();
26222 let secondary = WorkspaceConfig::new(
26223 WorkspaceId::new("secondary").unwrap(),
26224 &secondary_root,
26225 )
26226 .unwrap();
26227 let shared = NodeShared::new(
26228 handle,
26229 "fixture-token".to_owned(),
26230 NodeId::new("node-1").unwrap(),
26231 vec![primary.clone(), secondary.clone()],
26232 vec![agent("claude")],
26233 );
26234 let identity = gate4agent_types::ProviderSessionIdentity {
26235 key: gate4agent_types::ProviderSessionKey::SessionId,
26236 id: "provider-session".to_owned(),
26237 transcript_path: None,
26238 };
26239 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26240 shared
26241 .insert_record(ManagedSessionRecord {
26242 record_id: dormant_id.clone(),
26243 display_name: "dormant".to_owned(),
26244 provider: agent("claude"),
26245 mode: SessionMode::Pty,
26246 state: ManagedSessionState::Dormant,
26247 workspace_id: primary.workspace_id,
26248 canonical_root: opaque_windows_path(primary.canonical_root),
26249 provider_session: Some(identity.clone()),
26250 active_session: None,
26251 environment_profile: None,
26252 bundle: None,
26253 context_id: None,
26254 context: None,
26255 exported_context: None,
26256 task_binding: None,
26257 created_at_unix_ms: 1,
26258 updated_at_unix_ms: 1,
26259 last_error: None,
26260 })
26261 .unwrap();
26262 let address = SessionAddress {
26263 workspace_id: secondary.workspace_id.clone(),
26264 session: SessionKey {
26265 instance_id: AgentInstanceId(91),
26266 generation: SessionGeneration(1),
26267 },
26268 };
26269 let current_id = SessionRecordId::new("sr-current").unwrap();
26270 shared
26271 .insert_record(ManagedSessionRecord {
26272 record_id: current_id.clone(),
26273 display_name: "current".to_owned(),
26274 provider: agent("claude"),
26275 mode: SessionMode::Pty,
26276 state: ManagedSessionState::IdentityPending,
26277 workspace_id: secondary.workspace_id,
26278 canonical_root: opaque_windows_path(secondary.canonical_root),
26279 provider_session: None,
26280 active_session: Some(address.clone()),
26281 environment_profile: None,
26282 bundle: None,
26283 context_id: None,
26284 context: None,
26285 exported_context: None,
26286 task_binding: None,
26287 created_at_unix_ms: 2,
26288 updated_at_unix_ms: 2,
26289 last_error: None,
26290 })
26291 .unwrap();
26292 shared.bind_managed_session(
26293 &address,
26294 current_id.clone(),
26295 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26296 None,
26297 );
26298 let event = ControlEvent {
26299 sequence: 1,
26300 command_id: None,
26301 instance_id: address.session.instance_id,
26302 generation: address.session.generation,
26303 event: ControlEventKind::ProviderEvent {
26304 sequence: 1,
26305 source: gate4agent_types::ProviderSource {
26306 family: gate4agent_types::AdapterFamily::PtySemantic,
26307 binding: gate4agent_types::AdapterBinding::new(
26308 gate4agent_types::AdapterId::new("claude").unwrap(),
26309 "fixture/v1",
26310 gate4agent_types::AdapterVerification::SyntheticFixture,
26311 )
26312 .unwrap(),
26313 },
26314 source_sequence: 1,
26315 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26316 identity,
26317 },
26318 },
26319 };
26320 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26321
26322 let binding = shared
26323 .session_bindings
26324 .lock()
26325 .unwrap_or_else(|poisoned| poisoned.into_inner())
26326 .get(&address.session.instance_id)
26327 .cloned()
26328 .unwrap();
26329 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26330 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26331 let current = shared.record(¤t_id).unwrap();
26332 assert_eq!(current.state, ManagedSessionState::Unavailable);
26333 assert_eq!(current.active_session.as_ref(), Some(&address));
26334 assert_eq!(
26335 current.last_error.as_deref(),
26336 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26337 );
26338 std::fs::remove_dir_all(secondary_root).unwrap();
26339 }
26340
26341 #[tokio::test]
26342 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26343 let root = temporary_workspace_root("semantic-provider-session");
26344 std::fs::create_dir_all(&root).unwrap();
26345 let state_path = root.join("state-v1.json");
26346 let catalog = active_registry().unwrap();
26347 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26348 let workspace = WorkspaceConfig::new(
26349 WorkspaceId::new("primary").unwrap(),
26350 &root,
26351 )
26352 .unwrap();
26353 let node_id = NodeId::new("node-semantic-identity").unwrap();
26354 let shared = NodeShared::new_with_incarnation(
26355 handle,
26356 "fixture-token".to_owned(),
26357 node_id.clone(),
26358 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26359 vec![workspace.clone()],
26360 vec![agent("claude")],
26361 ProviderRuntimeStatuses::default(),
26362 None,
26363 Vec::new(),
26364 Vec::new(),
26365 SpawnProfileRegistry::default(),
26366 None,
26367 None,
26368 None,
26369 Some(state_path.clone()),
26370 Vec::new(),
26371 Vec::new(),
26372 Vec::new(),
26373 Vec::new(),
26374 None,
26375 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26376 );
26377 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26378 key: gate4agent_types::ProviderSessionKey::SessionId,
26379 id: "provider-session".to_owned(),
26380 transcript_path: None,
26381 };
26382 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26383 shared
26384 .insert_record(ManagedSessionRecord {
26385 record_id: durable_id.clone(),
26386 display_name: "durable".to_owned(),
26387 provider: agent("claude"),
26388 mode: SessionMode::Pty,
26389 state: ManagedSessionState::Dormant,
26390 workspace_id: workspace.workspace_id.clone(),
26391 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26392 provider_session: Some(persisted_identity.clone()),
26393 active_session: None,
26394 environment_profile: None,
26395 bundle: None,
26396 context_id: None,
26397 context: None,
26398 exported_context: None,
26399 task_binding: None,
26400 created_at_unix_ms: 1,
26401 updated_at_unix_ms: 1,
26402 last_error: None,
26403 })
26404 .unwrap();
26405 let address = SessionAddress {
26406 workspace_id: workspace.workspace_id.clone(),
26407 session: SessionKey {
26408 instance_id: AgentInstanceId(92),
26409 generation: SessionGeneration(2),
26410 },
26411 };
26412 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26413 shared
26414 .insert_record(ManagedSessionRecord {
26415 record_id: pending_id.clone(),
26416 display_name: "pending".to_owned(),
26417 provider: agent("claude"),
26418 mode: SessionMode::Pty,
26419 state: ManagedSessionState::IdentityPending,
26420 workspace_id: workspace.workspace_id.clone(),
26421 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26422 provider_session: None,
26423 active_session: Some(address.clone()),
26424 environment_profile: None,
26425 bundle: None,
26426 context_id: None,
26427 context: None,
26428 exported_context: None,
26429 task_binding: None,
26430 created_at_unix_ms: 2,
26431 updated_at_unix_ms: 2,
26432 last_error: None,
26433 })
26434 .unwrap();
26435 shared.bind_managed_session(
26436 &address,
26437 pending_id.clone(),
26438 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26439 None,
26440 );
26441 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26442 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26443 ..persisted_identity
26444 };
26445 let event = ControlEvent {
26446 sequence: 1,
26447 command_id: None,
26448 instance_id: address.session.instance_id,
26449 generation: address.session.generation,
26450 event: ControlEventKind::ProviderEvent {
26451 sequence: 1,
26452 source: gate4agent_types::ProviderSource {
26453 family: gate4agent_types::AdapterFamily::PtySemantic,
26454 binding: gate4agent_types::AdapterBinding::new(
26455 gate4agent_types::AdapterId::new("claude").unwrap(),
26456 "fixture/v1",
26457 gate4agent_types::AdapterVerification::SyntheticFixture,
26458 )
26459 .unwrap(),
26460 },
26461 source_sequence: 1,
26462 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26463 identity: observed_identity.clone(),
26464 },
26465 },
26466 };
26467 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26468
26469 let snapshot = shared.snapshot();
26470 assert_eq!(snapshot.session_records.len(), 1);
26471 let live = &snapshot.session_records[0];
26472 assert_eq!(live.record_id, durable_id);
26473 assert_eq!(live.state, ManagedSessionState::Live);
26474 assert_eq!(live.active_session.as_ref(), Some(&address));
26475 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26476 let binding = shared
26477 .session_bindings
26478 .lock()
26479 .unwrap_or_else(|poisoned| poisoned.into_inner())
26480 .get(&address.session.instance_id)
26481 .cloned()
26482 .unwrap();
26483 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26484
26485 let mut duplicate = live.clone();
26486 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26487 duplicate.display_name = "duplicate".to_owned();
26488 duplicate.state = ManagedSessionState::Dormant;
26489 duplicate.active_session = None;
26490 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26491 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26492 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26493 assert_eq!(shared.snapshot().session_records.len(), 1);
26494
26495 let terminal_size = gate4agent_types::TerminalSize {
26496 rows: 24,
26497 columns: 80,
26498 };
26499 let (first_busy, second_busy) = tokio::join!(
26500 shared.resume_session_record(&durable_id, terminal_size, None),
26501 shared.resume_session_record(&durable_id, terminal_size, None),
26502 );
26503 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26504 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26505 assert_eq!(busy.message, "session-record-busy");
26506 }
26507
26508 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26509 assert_eq!(loaded.records.len(), 1);
26510 assert_eq!(loaded.records[0].record_id, durable_id);
26511 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26512 assert_eq!(
26513 loaded.records[0]
26514 .provider_session
26515 .as_ref()
26516 .unwrap()
26517 .transcript_path,
26518 None,
26519 );
26520 let catalog = active_registry().unwrap();
26521 let (reloaded_handle, _reloaded_runtime) =
26522 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26523 let reloaded = NodeShared::new_with_incarnation(
26524 reloaded_handle,
26525 "fixture-token".to_owned(),
26526 node_id,
26527 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26528 vec![workspace],
26529 vec![agent("claude")],
26530 ProviderRuntimeStatuses::default(),
26531 None,
26532 Vec::new(),
26533 Vec::new(),
26534 SpawnProfileRegistry::default(),
26535 None,
26536 None,
26537 None,
26538 None,
26539 loaded.records,
26540 loaded.managed_worktrees,
26541 loaded.managed_worktree_tombstones,
26542 loaded.materializations,
26543 loaded.warning,
26544 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26545 );
26546 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26547 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26548 std::fs::remove_dir_all(root).unwrap();
26549 }
26550
26551 #[tokio::test]
26552 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26553 let secondary_root = temporary_workspace_root("workspace-restore");
26554 let other_root = temporary_workspace_root("workspace-conflict");
26555 std::fs::create_dir_all(&secondary_root).unwrap();
26556 std::fs::create_dir_all(&other_root).unwrap();
26557 let catalog = active_registry().unwrap();
26558 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26559 let primary = WorkspaceConfig::new(
26560 WorkspaceId::new("primary").unwrap(),
26561 std::env::current_dir().unwrap(),
26562 )
26563 .unwrap();
26564 let secondary = WorkspaceConfig::new(
26565 WorkspaceId::new("secondary").unwrap(),
26566 &secondary_root,
26567 )
26568 .unwrap();
26569 let shared = NodeShared::new(
26570 handle,
26571 "fixture-token".to_owned(),
26572 NodeId::new("node-1").unwrap(),
26573 vec![primary, secondary.clone()],
26574 vec![agent("claude")],
26575 );
26576 let record_id = SessionRecordId::new("sr-workspace").unwrap();
26577 shared
26578 .insert_record(ManagedSessionRecord {
26579 record_id: record_id.clone(),
26580 display_name: "workspace record".to_owned(),
26581 provider: agent("claude"),
26582 mode: SessionMode::Pty,
26583 state: ManagedSessionState::Dormant,
26584 workspace_id: secondary.workspace_id.clone(),
26585 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26586 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26587 key: gate4agent_types::ProviderSessionKey::SessionId,
26588 id: "provider-session".to_owned(),
26589 transcript_path: None,
26590 }),
26591 active_session: None,
26592 environment_profile: None,
26593 bundle: None,
26594 context_id: None,
26595 context: None,
26596 exported_context: None,
26597 task_binding: None,
26598 created_at_unix_ms: 1,
26599 updated_at_unix_ms: 1,
26600 last_error: None,
26601 })
26602 .unwrap();
26603 shared.unregister_workspace(&secondary.workspace_id).unwrap();
26604 let unavailable = shared.record(&record_id).unwrap();
26605 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26606 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26607
26608 let conflict = shared
26609 .register_workspace(
26610 secondary.workspace_id.clone(),
26611 other_root.to_string_lossy().into_owned(),
26612 )
26613 .await
26614 .unwrap_err();
26615 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26616 shared
26617 .register_workspace(
26618 secondary.workspace_id,
26619 secondary_root.to_string_lossy().into_owned(),
26620 )
26621 .await
26622 .unwrap();
26623 let restored = shared.record(&record_id).unwrap();
26624 assert_eq!(restored.state, ManagedSessionState::Dormant);
26625 assert!(restored.last_error.is_none());
26626 std::fs::remove_dir_all(secondary_root).unwrap();
26627 std::fs::remove_dir_all(other_root).unwrap();
26628 }
26629
26630 #[test]
26631 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26632 let error = validate_workspace_request_root(
26633 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26634 )
26635 .unwrap_err();
26636 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26637 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26638 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26639 }
26640
26641 #[test]
26642 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26643 let catalog = active_registry().unwrap();
26644 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26645 let workspace = WorkspaceConfig::new(
26646 WorkspaceId::new("primary").unwrap(),
26647 std::env::current_dir().unwrap(),
26648 )
26649 .unwrap();
26650 let shared = NodeShared::new(
26651 handle,
26652 "fixture-token".to_owned(),
26653 NodeId::new("node-1").unwrap(),
26654 vec![workspace],
26655 vec![agent("claude")],
26656 );
26657 let publishers = 4_u64;
26658 let events_per_publisher = 256_u64;
26659 let start = std::sync::Barrier::new(publishers as usize + 1);
26660 let finished = AtomicU64::new(0);
26661
26662 std::thread::scope(|scope| {
26663 for _ in 0..publishers {
26664 scope.spawn(|| {
26665 start.wait();
26666 for _ in 0..events_per_publisher {
26667 shared.publish(NodeEvent::ControllerChanged { controller: None });
26668 }
26669 finished.fetch_add(1, Ordering::Release);
26670 });
26671 }
26672 start.wait();
26673 while finished.load(Ordering::Acquire) != publishers {
26674 assert_resync_history_is_linearized(shared.resync(0));
26675 std::thread::yield_now();
26676 }
26677 });
26678 let response = shared.resync(0);
26679 assert_resync_history_is_linearized(response.clone());
26680 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26681 unreachable!("resync helper returned another response");
26682 };
26683 assert_eq!(event_sequence, publishers * events_per_publisher);
26684 assert_eq!(events.len() as u64, event_sequence);
26685 }
26686
26687 fn assert_resync_history_is_linearized(response: NodeResponse) {
26688 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26689 unreachable!("resync helper returned another response");
26690 };
26691 assert!(events.windows(2).all(|pair| {
26692 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26693 }));
26694 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26695 }
26696
26697 #[test]
26698 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26699 assert_eq!(
26700 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26701 r"C:\repo\worktree",
26702 );
26703 assert_eq!(
26704 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26705 r"\\server\share\repo",
26706 );
26707 assert!(validate_workspace_root(
26708 &WorkspaceId::new("network").unwrap(),
26709 r"\\server\share\repo",
26710 )
26711 .is_err());
26712 let workspace = WorkspaceConfig::new(
26713 WorkspaceId::new("primary").unwrap(),
26714 std::env::current_dir().unwrap(),
26715 )
26716 .unwrap();
26717 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26718 }
26719
26720 #[test]
26721 fn resume_rebinds_only_on_the_expected_authorized_generation() {
26722 let catalog = active_registry().unwrap();
26723 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26724 let workspace_id = WorkspaceId::new("primary").unwrap();
26725 let workspace = WorkspaceConfig::new(
26726 workspace_id.clone(),
26727 std::env::current_dir().unwrap(),
26728 )
26729 .unwrap();
26730 let shared = NodeShared::new(
26731 handle,
26732 "fixture-token".to_owned(),
26733 NodeId::new("node-1").unwrap(),
26734 vec![workspace],
26735 vec![agent("claude")],
26736 );
26737 let instance_id = AgentInstanceId(17);
26738 let original = SessionAddress {
26739 workspace_id,
26740 session: SessionKey {
26741 instance_id,
26742 generation: SessionGeneration(3),
26743 },
26744 };
26745 shared.bind_session(
26746 instance_id,
26747 original.workspace_id.clone(),
26748 original.session.generation,
26749 );
26750 shared
26751 .arm_resume(
26752 &original,
26753 CommandId(41),
26754 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26755 )
26756 .unwrap();
26757
26758 shared.publish_control(ControlEvent {
26759 sequence: 1,
26760 command_id: None,
26761 instance_id,
26762 generation: SessionGeneration(4),
26763 event: ControlEventKind::Running { process_id: None },
26764 });
26765 assert_eq!(
26766 shared.address_for(instance_id, SessionGeneration(3)),
26767 Some(original.clone()),
26768 );
26769 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26770
26771 shared.publish_control(ControlEvent {
26772 sequence: 2,
26773 command_id: Some(CommandId(42)),
26774 instance_id,
26775 generation: SessionGeneration(3),
26776 event: ControlEventKind::CommandRejected {
26777 message: "unrelated command".to_owned(),
26778 },
26779 });
26780
26781 shared.publish_control(ControlEvent {
26782 sequence: 3,
26783 command_id: None,
26784 instance_id,
26785 generation: SessionGeneration(4),
26786 event: ControlEventKind::ResumeAuthorized {
26787 session: gate4agent_types::ResumeSessionSummary {
26788 key: gate4agent_types::ProviderSessionKey::SessionId,
26789 id: "fixture-session".to_owned(),
26790 },
26791 },
26792 });
26793 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26794 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26795 assert_eq!(rebound.workspace_id, original.workspace_id);
26796 let history = shared
26797 .history
26798 .lock()
26799 .unwrap_or_else(|poisoned| poisoned.into_inner());
26800 assert!(history.events.iter().any(|envelope| matches!(
26801 &envelope.event,
26802 NodeEvent::Control { address, event }
26803 if address == &rebound
26804 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26805 )));
26806 }
26807
26808 #[test]
26809 fn all_node_pty_prompts_use_bracketed_paste_framing() {
26810 for agent in ["claude", "codex", "kimi"] {
26811 assert_eq!(
26812 prompt_framing(&AgentId::new(agent).unwrap()),
26813 PromptFraming::BracketedPaste
26814 );
26815 }
26816 }
26817
26818 fn generous_walk_deadline() -> Instant {
26819 Instant::now() + Duration::from_secs(30)
26820 }
26821
26822 #[test]
26823 fn workspace_tree_is_relative_and_skips_heavy_directories() {
26824 let root = temporary_workspace_root("tree");
26825 std::fs::create_dir_all(root.join("src")).unwrap();
26826 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26827 std::fs::create_dir_all(root.join("target/debug")).unwrap();
26828 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26829 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26830 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26831 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26837 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26838 std::fs::create_dir_all(root.join("target-notes")).unwrap();
26842 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26843
26844 let (entries, truncated, _budget) =
26845 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26846 assert!(!truncated);
26847 assert!(entries.iter().any(|entry| {
26848 entry.relative_path.as_utf8() == Some("src")
26849 && entry.kind == WorkspaceEntryKind::Directory
26850 }));
26851 assert!(entries.iter().any(|entry| {
26852 entry.relative_path.as_utf8() == Some("src/lib.rs")
26853 && entry.kind == WorkspaceEntryKind::File
26854 }));
26855 assert!(entries.iter().any(|entry| {
26856 entry.relative_path.as_utf8() == Some("target-notes")
26857 && entry.kind == WorkspaceEntryKind::Directory
26858 }));
26859 assert!(entries.iter().any(|entry| {
26860 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26861 && entry.kind == WorkspaceEntryKind::File
26862 }));
26863 assert!(entries.iter().all(|entry| {
26864 let path = entry.relative_path.as_utf8().unwrap();
26865 !path.starts_with(".git")
26866 && !(path == "target" || path.starts_with("target/"))
26867 && !(path == "target-a2" || path.starts_with("target-a2/"))
26868 && !path.starts_with("node_modules")
26869 }));
26870
26871 std::fs::remove_dir_all(&root).unwrap();
26872 }
26873
26874 #[test]
26875 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26876 let root = temporary_workspace_root("tree-entry-cap");
26877 std::fs::create_dir_all(&root).unwrap();
26878 for index in 0..20 {
26879 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26880 }
26881
26882 let (entries, truncated, budget) =
26883 collect_workspace_entries(&root, generous_walk_deadline(), 5);
26884 assert!(truncated);
26885 assert!(budget.entry_cap_exceeded);
26886 assert!(!budget.time_budget_exceeded);
26887 assert!(entries.len() <= 5);
26888
26889 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26890 .expect("an entry-cap-truncated walk must report a truncation marker");
26891 assert!(inspection_truncation.walk_entry_cap_exceeded);
26892 assert!(!inspection_truncation.walk_time_budget_exceeded);
26893 assert!(!inspection_truncation.git_time_budget_exceeded);
26894 assert!(inspection_truncation.entries_visited >= 5);
26895
26896 std::fs::remove_dir_all(&root).unwrap();
26897 }
26898
26899 #[test]
26900 fn workspace_walk_within_budget_reports_no_truncation() {
26901 let root = temporary_workspace_root("tree-no-truncation");
26902 std::fs::create_dir_all(&root).unwrap();
26903 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26904
26905 let (_entries, truncated, budget) =
26906 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26907 assert!(!truncated);
26908 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26909
26910 std::fs::remove_dir_all(&root).unwrap();
26911 }
26912
26913 #[test]
26914 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26915 let root = temporary_workspace_root("tree-prefix-siblings");
26916 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26917 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26918 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26919 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26920
26921 let (entries, truncated, _budget) =
26922 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26923 assert!(!truncated);
26924 let paths = entries
26925 .iter()
26926 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26927 .collect::<Vec<_>>();
26928 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26929 assert_eq!(
26930 paths,
26931 vec![
26932 "gate4agent-c2".to_owned(),
26933 "gate4agent-c2-client".to_owned(),
26934 "gate4agent-c2-client/lib.rs".to_owned(),
26935 "gate4agent-c2/src".to_owned(),
26936 "gate4agent-c2/src/lib.rs".to_owned(),
26937 ],
26938 );
26939
26940 std::fs::remove_dir_all(&root).unwrap();
26941 }
26942
26943 #[test]
26944 fn git_parsers_cap_status_and_commit_results() {
26945 let mut status_output = Vec::new();
26946 for index in 0..=GIT_STATUS_MAX_ENTRIES {
26947 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26948 }
26949 let mut snapshot = git_snapshot_for_parser();
26950 parse_git_status(&status_output, &mut snapshot);
26951 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26952 assert!(snapshot.truncated);
26953 assert_eq!(snapshot.status[0].index_status, " ");
26954 assert_eq!(snapshot.status[0].worktree_status, "M");
26955
26956 snapshot.truncated = false;
26957 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26958 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26959 .collect::<String>();
26960 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26961 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26962 assert!(snapshot.truncated);
26963 }
26964
26965 #[test]
26966 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26967 let mut snapshot = git_snapshot_for_parser();
26968 parse_git_status(
26969 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26970 &mut snapshot,
26971 );
26972
26973 assert!(!snapshot.truncated);
26974 assert_eq!(snapshot.status.len(), 3);
26975 assert_eq!(snapshot.status[0].index_status, "R");
26976 assert_eq!(snapshot.status[0].worktree_status, " ");
26977 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26978 assert_eq!(
26979 snapshot.status[0]
26980 .previous_path
26981 .as_ref()
26982 .and_then(RepositoryPath::as_utf8),
26983 Some("src/previous name.rs"),
26984 );
26985 assert_eq!(snapshot.status[1].index_status, " ");
26986 assert_eq!(snapshot.status[1].worktree_status, "C");
26987 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26988 assert_eq!(
26989 snapshot.status[1]
26990 .previous_path
26991 .as_ref()
26992 .and_then(RepositoryPath::as_utf8),
26993 Some("src/original.rs"),
26994 );
26995 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26996 assert!(snapshot.status[2].previous_path.is_none());
26997 }
26998
26999 #[test]
27000 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27001 let mut snapshot = git_snapshot_for_parser();
27002 parse_git_status(
27003 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27004 &mut snapshot,
27005 );
27006
27007 assert!(snapshot.truncated);
27008 assert_eq!(snapshot.status.len(), 1);
27009 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27010 assert!(snapshot.status[0].previous_path.is_none());
27011 }
27012
27013 #[tokio::test]
27014 async fn observer_can_browse_host_directories_without_controller() {
27015 let root = temporary_workspace_root("host-directory-observer");
27016 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27017 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27018 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27019 let workspace_id = WorkspaceId::new("primary").unwrap();
27020 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27021 let catalog = active_registry().unwrap();
27022 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27023 let shared = NodeShared::new(
27024 handle,
27025 "fixture-token".to_owned(),
27026 NodeId::new("node-1").unwrap(),
27027 vec![workspace],
27028 vec![agent("claude")],
27029 );
27030 let request = NodeRequest::BrowseHostDirectories {
27031 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27032 after: None,
27033 };
27034 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27035 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27036 }));
27037 assert!(!request_uses_unnegotiated_capability(
27038 &request,
27039 &baseline_capabilities().unwrap(),
27040 ));
27041 assert!(request_uses_unnegotiated_capability(&request, &[]));
27042
27043 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27044 .await
27045 .unwrap();
27046 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27047 panic!("host directory browse returned another response");
27048 };
27049 assert_eq!(
27050 listing
27051 .entries
27052 .iter()
27053 .map(|entry| entry.display_name.as_str())
27054 .collect::<Vec<_>>(),
27055 vec!["directory-a", "directory-b"],
27056 );
27057 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27058 assert!(!listing.incomplete);
27059 std::fs::remove_dir_all(root).unwrap();
27060 }
27061
27062 #[tokio::test]
27063 async fn observer_can_inspect_only_a_registered_workspace() {
27064 let root = temporary_workspace_root("observer");
27065 std::fs::create_dir_all(root.join("src")).unwrap();
27066 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27067 let workspace_id = WorkspaceId::new("primary").unwrap();
27068 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27069 let catalog = active_registry().unwrap();
27070 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27071 let shared = NodeShared::new(
27072 handle,
27073 "fixture-token".to_owned(),
27074 NodeId::new("node-1").unwrap(),
27075 vec![workspace],
27076 vec![agent("claude")],
27077 );
27078
27079 let response = process_request_inner(
27080 &shared,
27081 77,
27082 ClientRole::Observer,
27083 NodeRequest::InspectWorkspace {
27084 workspace_id: workspace_id.clone(),
27085 },
27086 )
27087 .await
27088 .unwrap();
27089 let NodeResponse::WorkspaceInspected { inspection } = response else {
27090 panic!("workspace inspection returned another response");
27091 };
27092 assert_eq!(inspection.workspace_id, workspace_id);
27093 assert!(inspection.entries.iter().any(|entry| {
27094 entry.relative_path.as_utf8() == Some("src/main.rs")
27095 }));
27096
27097 let unknown = process_request_inner(
27098 &shared,
27099 77,
27100 ClientRole::Observer,
27101 NodeRequest::InspectWorkspace {
27102 workspace_id: WorkspaceId::new("unknown").unwrap(),
27103 },
27104 )
27105 .await
27106 .unwrap_err();
27107 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27108 std::fs::remove_dir_all(&root).unwrap();
27109 }
27110
27111 #[test]
27112 fn context_pack_source_accepts_only_running_or_successful_exit() {
27113 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27114 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27115 exit_code: Some(0),
27116 }));
27117 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27118 exit_code: Some(1),
27119 }));
27120 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27121 exit_code: None,
27122 }));
27123 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27124 message: "provider failed".to_owned(),
27125 }));
27126 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27127 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27128 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27129 }
27130
27131 #[test]
27132 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27133 let git = git_snapshot_for_parser();
27134 let first = ContextPackRepositoryObservation {
27135 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27136 git: git.clone(),
27137 files: Vec::new(),
27138 };
27139 let second = ContextPackRepositoryObservation {
27140 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27141 git,
27142 files: Vec::new(),
27143 };
27144
27145 let error = stable_context_pack_repository(first, second).unwrap_err();
27146
27147 assert_eq!(
27148 error.code,
27149 NodeFailureCode::ContextPackMaterializationFailed,
27150 );
27151 }
27152
27153 #[test]
27154 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27155 let git = git_snapshot_for_parser();
27156 let first = ContextPackRepositoryObservation {
27157 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27158 git: git.clone(),
27159 files: vec![ContextPackRepositoryFileSource::utf8(
27160 "README.md",
27161 "first\n".to_owned(),
27162 6,
27163 )],
27164 };
27165 let second = ContextPackRepositoryObservation {
27166 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27167 git,
27168 files: vec![ContextPackRepositoryFileSource::utf8(
27169 "README.md",
27170 "other\n".to_owned(),
27171 6,
27172 )],
27173 };
27174
27175 let error = stable_context_pack_repository(first, second).unwrap_err();
27176
27177 assert_eq!(
27178 error.code,
27179 NodeFailureCode::ContextPackMaterializationFailed,
27180 );
27181 }
27182
27183 #[tokio::test]
27184 async fn native_session_catalog_zero_active_session_fixture() {
27185 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27186 let history_root = temporary_workspace_root("native-catalog-history");
27187 let outside_root = temporary_workspace_root("native-catalog-outside");
27188 std::fs::create_dir_all(&workspace_root).unwrap();
27189 std::fs::create_dir_all(&history_root).unwrap();
27190 std::fs::create_dir_all(&outside_root).unwrap();
27191 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27192 [
27193 serde_json::json!({
27194 "type": "user",
27195 "sessionId": session_id,
27196 "cwd": cwd.to_str().unwrap(),
27197 "message": { "content": "raw user text must not cross the catalog wire" }
27198 }),
27199 serde_json::json!({
27200 "type": "assistant",
27201 "sessionId": session_id,
27202 "cwd": cwd.to_str().unwrap(),
27203 "message": { "model": "claude-sonnet", "content": "raw answer" }
27204 }),
27205 serde_json::json!({
27206 "type": "ai-title",
27207 "sessionId": session_id,
27208 "aiTitle": title
27209 }),
27210 ]
27211 .into_iter()
27212 .map(|record| record.to_string())
27213 .collect::<Vec<_>>()
27214 .join("\n")
27215 };
27216 std::fs::write(
27217 history_root.join("stable-session.jsonl"),
27218 transcript("stable-session", &workspace_root, "Workspace review"),
27219 )
27220 .unwrap();
27221 std::fs::write(
27222 history_root.join("outside-session.jsonl"),
27223 transcript("outside-session", &outside_root, "Outside review"),
27224 )
27225 .unwrap();
27226 let history = NativeHistoryConfig::new(vec![
27227 NativeHistoryRoot::new(
27228 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27229 HistorySourceLayout::SingleNdjson,
27230 history_root.clone(),
27231 )
27232 .unwrap(),
27233 ])
27234 .unwrap();
27235 let catalog = active_registry().unwrap();
27236 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27237 assert!(handle.snapshot().sessions.is_empty());
27238 let workspace_id = WorkspaceId::new("primary").unwrap();
27239 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27240 let mut shared = NodeShared::new(
27241 handle,
27242 "fixture-token".to_owned(),
27243 NodeId::new("node-native-catalog").unwrap(),
27244 vec![workspace],
27245 vec![agent("claude")],
27246 );
27247 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27248 NativeSessionCatalogAuthority::new(history),
27249 )));
27250 let route = NativeSessionCatalogRoute::workspace(
27251 workspace_id.clone(),
27252 agent("claude"),
27253 );
27254 let request = NodeRequest::CatalogNativeSessions {
27255 route: route.clone(),
27256 limit: 2,
27257 };
27258 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27259 .await
27260 .unwrap_err();
27261 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27262 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27263 .await
27264 .unwrap();
27265 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27266 panic!("native catalog returned a different response");
27267 };
27268 let summary = summary.expect("node must emit native session catalog summary metadata");
27269 assert_eq!(entries.len(), 1);
27270 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27271 assert_eq!(entries[0].external_group, None);
27272 assert_eq!(entries[0].record_id, None);
27273 assert_eq!(entries[0].title, None);
27274 assert_eq!(entries[0].model, None);
27275 assert_eq!(entries[0].message_count, 0);
27276 let encoded_entries = serde_json::to_string(&entries).unwrap();
27277 assert!(!encoded_entries.contains("stable-session"));
27278 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27279 let page_request = NodeRequest::PageNativeSessions {
27280 route: route.clone(),
27281 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27282 catalog_revision: summary.catalog_revision,
27283 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27284 after_selection_id: None,
27285 limit: 2,
27286 };
27287 let observer = process_request_inner(
27288 &shared,
27289 7,
27290 ClientRole::Observer,
27291 page_request.clone(),
27292 )
27293 .await
27294 .unwrap_err();
27295 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27296 let page_response = process_request_inner(
27297 &shared,
27298 7,
27299 ClientRole::Operator,
27300 page_request.clone(),
27301 )
27302 .await
27303 .unwrap();
27304 assert!(matches!(
27305 page_response,
27306 NodeResponse::NativeSessionsPaged { page, .. }
27307 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27308 ));
27309 let stale = process_request_inner(
27310 &shared,
27311 7,
27312 ClientRole::Operator,
27313 NodeRequest::PageNativeSessions {
27314 route: route.clone(),
27315 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27316 catalog_revision: summary.catalog_revision.wrapping_add(1),
27317 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27318 after_selection_id: None,
27319 limit: 2,
27320 },
27321 )
27322 .await
27323 .unwrap_err();
27324 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27325 let selection = NativeSessionSelection {
27326 route: route.clone(),
27327 catalog_revision: summary.catalog_revision,
27328 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27329 selection_id: entries[0].selection_id.clone(),
27330 };
27331 let preview_response = process_request_inner(
27332 &shared,
27333 7,
27334 ClientRole::Operator,
27335 NodeRequest::PreviewNativeSession {
27336 selection: selection.clone(),
27337 message_limit: 1,
27338 },
27339 )
27340 .await
27341 .unwrap();
27342 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27343 panic!("native preview returned a different response");
27344 };
27345 assert_eq!(preview.messages.len(), 1);
27346 assert!(preview.message_count_exact);
27347 assert_eq!(preview.messages[0].text, "raw answer");
27348 assert!(preview.truncated);
27349
27350 let index_request = NodeRequest::IndexNativeSession {
27351 selection: selection.clone(),
27352 display_name: "Saved Claude".to_owned(),
27353 };
27354 let observer = process_request_inner(
27355 &shared,
27356 7,
27357 ClientRole::Observer,
27358 index_request.clone(),
27359 )
27360 .await
27361 .unwrap_err();
27362 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27363 let without_controller = process_request_inner(
27364 &shared,
27365 7,
27366 ClientRole::Operator,
27367 index_request.clone(),
27368 )
27369 .await
27370 .unwrap_err();
27371 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27372 let acquired = process_request_inner(
27373 &shared,
27374 7,
27375 ClientRole::Operator,
27376 NodeRequest::AcquireController { lease_ms: 5_000 },
27377 )
27378 .await
27379 .unwrap();
27380 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27381 let indexed = process_request_inner(
27382 &shared,
27383 7,
27384 ClientRole::Operator,
27385 index_request,
27386 )
27387 .await
27388 .unwrap();
27389 assert!(indexed.native_session_index_contract_is_valid());
27390 let NodeResponse::NativeSessionIndexed {
27391 selection: echoed_selection,
27392 record,
27393 } = indexed else {
27394 panic!("native session index returned a different response");
27395 };
27396 assert_eq!(echoed_selection, selection);
27397 let recataloged = process_request_inner(
27398 &shared,
27399 7,
27400 ClientRole::Operator,
27401 NodeRequest::CatalogNativeSessions {
27402 route,
27403 limit: 2,
27404 },
27405 )
27406 .await
27407 .unwrap();
27408 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27409 panic!("native catalog returned a different response after indexing");
27410 };
27411 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27412
27413 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27414 let external_catalog = process_request_inner(
27415 &shared,
27416 7,
27417 ClientRole::Operator,
27418 NodeRequest::CatalogNativeSessions {
27419 route: unregistered_route.clone(),
27420 limit: 2,
27421 },
27422 )
27423 .await
27424 .unwrap();
27425 let external_json = serde_json::to_string(&external_catalog).unwrap();
27426 assert!(!external_json.contains("outside-session"));
27427 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27428 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27429 assert!(!external_json.contains("\"session_id\""));
27430 assert!(!external_json.contains("\"cwd\""));
27431 assert!(!external_json.contains("\"path\""));
27432 let NodeResponse::NativeSessionsCataloged {
27433 route: echoed_route,
27434 entries: external_entries,
27435 summary: Some(external_summary),
27436 } = external_catalog
27437 else {
27438 panic!("unregistered native catalog returned a different response");
27439 };
27440 assert_eq!(echoed_route, unregistered_route);
27441 assert_eq!(external_entries.len(), 1);
27442 assert_eq!(external_entries[0].record_id, None);
27443 let external_group = external_entries[0]
27444 .external_group
27445 .as_ref()
27446 .expect("unregistered session must have an external group");
27447 assert_eq!(
27448 external_group.kind,
27449 gate4agent_types::NativeSessionExternalGroupKind::Project,
27450 );
27451 assert!(external_group.group_id.starts_with("external-"));
27452 assert_eq!(
27453 external_group.display_name,
27454 outside_root.file_name().unwrap().to_string_lossy(),
27455 );
27456 let external_selection = NativeSessionSelection {
27457 route: unregistered_route,
27458 catalog_revision: external_summary.catalog_revision,
27459 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27460 selection_id: external_entries[0].selection_id.clone(),
27461 };
27462 let external_preview = process_request_inner(
27463 &shared,
27464 7,
27465 ClientRole::Operator,
27466 NodeRequest::PreviewNativeSession {
27467 selection: external_selection.clone(),
27468 message_limit: 1,
27469 },
27470 )
27471 .await
27472 .unwrap();
27473 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27474 assert!(!external_preview_json.contains("outside-session"));
27475 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27476 assert!(!external_preview_json.contains("\"session_id\""));
27477 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27478 panic!("unregistered native preview returned a different response");
27479 };
27480 assert_eq!(preview.messages.len(), 1);
27481 let external_index = process_request_inner(
27482 &shared,
27483 7,
27484 ClientRole::Operator,
27485 NodeRequest::IndexNativeSession {
27486 selection: external_selection,
27487 display_name: "External Claude".to_owned(),
27488 },
27489 )
27490 .await
27491 .unwrap_err();
27492 assert_eq!(
27493 external_index.code,
27494 NodeFailureCode::WorkspaceRegistrationRequired,
27495 );
27496 let record_response = process_request_inner(
27497 &shared,
27498 7,
27499 ClientRole::Operator,
27500 NodeRequest::PreviewSessionRecord {
27501 record_id: record.record_id.clone(),
27502 message_limit: 2,
27503 },
27504 )
27505 .await
27506 .unwrap();
27507 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27508 panic!("record preview returned a different response");
27509 };
27510 assert_eq!(record_id, record.record_id);
27511 assert_eq!(preview.messages.len(), 2);
27512 let encoded = serde_json::to_string(&preview).unwrap();
27513 assert!(!encoded.contains("stable-session"));
27514 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27515 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27516 panic!("record preview history summary returned a different response");
27517 };
27518 let summaries = events
27519 .iter()
27520 .filter_map(|envelope| match &envelope.event {
27521 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27522 if record_id == &record.record_id =>
27523 {
27524 Some(*summary)
27525 }
27526 _ => None,
27527 })
27528 .collect::<Vec<_>>();
27529 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27530 let summary_json = serde_json::to_string(&summaries).unwrap();
27531 for private in [
27532 "stable-session",
27533 "raw user text must not cross the catalog wire",
27534 "raw answer",
27535 "Workspace review",
27536 "claude-sonnet",
27537 ] {
27538 assert!(!summary_json.contains(private), "{private}");
27539 }
27540 let identity = record.provider_session.clone().unwrap();
27541 assert_eq!(
27542 shared.revalidate_session_record_preview(&record, &identity),
27543 Ok(())
27544 );
27545 shared.remove_record_memory(&record.record_id);
27546 assert_eq!(
27547 shared
27548 .revalidate_session_record_preview(&record, &identity)
27549 .unwrap_err()
27550 .code,
27551 NodeFailureCode::UnknownSessionRecord
27552 );
27553 let released = process_request_inner(
27554 &shared,
27555 7,
27556 ClientRole::Operator,
27557 NodeRequest::ReleaseController,
27558 )
27559 .await
27560 .unwrap();
27561 assert!(matches!(released, NodeResponse::Controller { controller: None }));
27562 assert!(shared.controller_state().is_none());
27563 std::fs::remove_dir_all(workspace_root).unwrap();
27564 std::fs::remove_dir_all(history_root).unwrap();
27565 std::fs::remove_dir_all(outside_root).unwrap();
27566 }
27567
27568 #[tokio::test]
27569 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27570 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27571 std::fs::create_dir_all(&history_root).unwrap();
27572 let history = NativeHistoryConfig::new(vec![
27573 NativeHistoryRoot::new(
27574 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27575 HistorySourceLayout::SingleNdjson,
27576 history_root.clone(),
27577 )
27578 .unwrap(),
27579 ])
27580 .unwrap();
27581 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27582 let held = catalog.lock().unwrap();
27583 let result = timeout(
27584 Duration::from_secs(1),
27585 run_native_session_catalog_operation(
27586 Arc::clone(&catalog),
27587 "native session catalog",
27588 |_| Ok::<(), std::convert::Infallible>(()),
27589 ),
27590 )
27591 .await
27592 .expect("concurrent native catalog request must not wait for the operation deadline")
27593 .unwrap_err();
27594 assert_eq!(result.code, NodeFailureCode::BackendBusy);
27595 drop(held);
27596 std::fs::remove_dir_all(history_root).unwrap();
27597 }
27598
27599 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27600 let unique = std::time::SystemTime::now()
27601 .duration_since(std::time::UNIX_EPOCH)
27602 .unwrap()
27603 .as_nanos();
27604 std::env::temp_dir().join(format!(
27605 "gate4agent-node-{label}-{}-{unique}",
27606 std::process::id(),
27607 ))
27608 }
27609
27610 fn git_snapshot_for_parser() -> GitSnapshot {
27611 GitSnapshot {
27612 is_repository: true,
27613 branch: Some("main".to_owned()),
27614 status: Vec::new(),
27615 recent_commits: Vec::new(),
27616 worktrees: Vec::new(),
27617 managed_worktree: None,
27618 truncated: false,
27619 diagnostic: None,
27620 }
27621 }
27622
27623 #[tokio::test]
27624 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27625 let root = temporary_workspace_root("managed-git-scope");
27626 std::fs::create_dir_all(&root).unwrap();
27627 run_test_git(&root, &["init"]);
27628 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27629 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27630 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27631 run_test_git(&root, &["add", "seed.txt"]);
27632 run_test_git(&root, &["commit", "-m", "seed"]);
27633 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27634
27635 let workspace_id = WorkspaceId::new("managed-a").unwrap();
27636 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27637 let catalog = active_registry().unwrap();
27638 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27639 let shared = NodeShared::new(
27640 handle,
27641 "fixture-token".to_owned(),
27642 NodeId::new("node-1").unwrap(),
27643 vec![workspace],
27644 vec![agent("claude")],
27645 );
27646 let mut lease = ManagedWorktreeLeaseRecord {
27647 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27648 source_workspace_id: WorkspaceId::new("primary").unwrap(),
27649 workspace_id: workspace_id.clone(),
27650 profile_id: WorktreeProfileId::new("default").unwrap(),
27651 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27652 target_root: root.to_string_lossy().into_owned(),
27653 branch: "gate4agent/a".to_owned(),
27654 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27655 expected_head: None,
27656 retention: ManagedWorktreeRetention::Retain,
27657 state: ManagedWorktreeLeaseState::InUse,
27658 session_holders: vec![ManagedWorktreeSessionHolder {
27659 incarnation_id: shared.incarnation_id,
27660 instance_id: AgentInstanceId(7),
27661 generation: SessionGeneration(1),
27662 }],
27663 record_holders: Vec::new(),
27664 cleanup_failure: None,
27665 created_at_unix_ms: 1,
27666 updated_at_unix_ms: 1,
27667 };
27668 *shared
27669 .managed_worktrees
27670 .lock()
27671 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27672 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27673
27674 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27675 let scope = inspection.git.managed_worktree.unwrap();
27676 assert_eq!(scope.lease_id, lease.lease_id);
27677 assert_eq!(scope.branch, "gate4agent/a");
27678 assert_eq!(scope.active_session_count, 1);
27679
27680 lease.branch = "gate4agent/b".to_owned();
27681 *shared
27682 .managed_worktrees
27683 .lock()
27684 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27685 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27686 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27687 assert!(mismatch.git.managed_worktree.is_none());
27688 std::fs::remove_dir_all(root).unwrap();
27689 }
27690
27691 #[tokio::test]
27692 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27693 let root = temporary_workspace_root("workspace-file-write-controller");
27694 std::fs::create_dir_all(root.join("src")).unwrap();
27695 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27696 let workspace_id = WorkspaceId::new("primary").unwrap();
27697 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27698 let catalog = active_registry().unwrap();
27699 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27700 let shared = NodeShared::new(
27701 handle,
27702 "fixture-token".to_owned(),
27703 NodeId::new("node-1").unwrap(),
27704 vec![workspace],
27705 vec![agent("claude")],
27706 );
27707 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27708 let expected_revision = workspace_file_revision(b"old\n");
27709 let request = NodeRequest::WriteWorkspaceFile {
27710 workspace_id: workspace_id.clone(),
27711 path: path.clone(),
27712 expected_revision: expected_revision.clone(),
27713 text: "new\n".to_owned(),
27714 };
27715 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27716 .await
27717 .unwrap_err();
27718 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27719 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27720 .await
27721 .unwrap_err();
27722 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27723 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27724 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27725 let NodeResponse::WorkspaceFileWritten { file } = response else {
27726 panic!("workspace file write returned another response");
27727 };
27728 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27729 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27730 let stale = process_request_inner(
27731 &shared,
27732 77,
27733 ClientRole::Operator,
27734 NodeRequest::WriteWorkspaceFile {
27735 workspace_id,
27736 path,
27737 expected_revision,
27738 text: "lost update\n".to_owned(),
27739 },
27740 )
27741 .await
27742 .unwrap_err();
27743 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27744 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27745 std::fs::remove_dir_all(root).unwrap();
27746 }
27747
27748 #[tokio::test]
27749 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27750 let root = temporary_workspace_root("workspace-entry-create-controller");
27751 std::fs::create_dir_all(root.join("src")).unwrap();
27752 let workspace_id = WorkspaceId::new("primary").unwrap();
27753 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27754 let catalog = active_registry().unwrap();
27755 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27756 let shared = NodeShared::new(
27757 handle,
27758 "fixture-token".to_owned(),
27759 NodeId::new("node-1").unwrap(),
27760 vec![workspace],
27761 vec![agent("claude")],
27762 );
27763 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27764 let request = NodeRequest::CreateWorkspaceFile {
27765 workspace_id: workspace_id.clone(),
27766 path: path.clone(),
27767 };
27768 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27769 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27770 }));
27771 assert!(!request_uses_unnegotiated_capability(
27772 &request,
27773 &baseline_capabilities().unwrap(),
27774 ));
27775 assert!(request_uses_unnegotiated_capability(&request, &[]));
27776 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27777 .await
27778 .unwrap_err();
27779 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27780 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27781 .await
27782 .unwrap_err();
27783 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27784 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27785
27786 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27787 .await
27788 .unwrap();
27789 let NodeResponse::WorkspaceFileCreated { file } = response else {
27790 panic!("workspace file create returned another response");
27791 };
27792 assert_eq!(file.workspace_id, workspace_id);
27793 assert_eq!(file.path, path);
27794 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27795 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27796
27797 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27798 .await
27799 .unwrap_err();
27800 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27801
27802 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27803 let response = process_request_inner(
27804 &shared,
27805 77,
27806 ClientRole::Operator,
27807 NodeRequest::CreateWorkspaceDirectory {
27808 workspace_id: workspace_id.clone(),
27809 path: directory_path.clone(),
27810 },
27811 )
27812 .await
27813 .unwrap();
27814 assert_eq!(
27815 response,
27816 NodeResponse::WorkspaceDirectoryCreated {
27817 workspace_id: workspace_id.clone(),
27818 entry: WorkspaceEntry {
27819 relative_path: directory_path,
27820 kind: WorkspaceEntryKind::Directory,
27821 },
27822 },
27823 );
27824 assert!(root.join("src/nested").is_dir());
27825
27826 let missing_parent = process_request_inner(
27827 &shared,
27828 77,
27829 ClientRole::Operator,
27830 NodeRequest::CreateWorkspaceFile {
27831 workspace_id: workspace_id.clone(),
27832 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27833 },
27834 )
27835 .await
27836 .unwrap_err();
27837 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27838 let non_directory_parent = process_request_inner(
27839 &shared,
27840 77,
27841 ClientRole::Operator,
27842 NodeRequest::CreateWorkspaceFile {
27843 workspace_id,
27844 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27845 },
27846 )
27847 .await
27848 .unwrap_err();
27849 assert_eq!(
27850 non_directory_parent.code,
27851 NodeFailureCode::RepositoryParentNotDirectory,
27852 );
27853 std::fs::remove_dir_all(root).unwrap();
27854 }
27855
27856 #[tokio::test]
27857 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27858 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27859 std::fs::create_dir_all(root.join("src")).unwrap();
27860 #[cfg(windows)]
27861 let root = std::fs::canonicalize(root).unwrap();
27862 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27863 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27864 let barrier = Arc::new(std::sync::Barrier::new(2));
27865 let done = Arc::new(AtomicBool::new(false));
27866 let task_commit_state = Arc::clone(&commit_state);
27867 let task_barrier = Arc::clone(&barrier);
27868 let task_done = Arc::clone(&done);
27869 let task_root = root.clone();
27870 let task = tokio::task::spawn_blocking(move || {
27871 task_barrier.wait();
27872 let result = create_workspace_file_on_disk(
27873 &task_root,
27874 &path,
27875 &task_commit_state,
27876 );
27877 task_done.store(true, Ordering::Release);
27878 result
27879 });
27880
27881 let error = settle_workspace_entry_create(
27882 task,
27883 Arc::clone(&commit_state),
27884 Duration::from_millis(10),
27885 "fixture deadline",
27886 "fixture task failed",
27887 )
27888 .await
27889 .unwrap_err();
27890 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27891 assert_eq!(
27892 commit_state.load(Ordering::Acquire),
27893 WORKSPACE_ENTRY_CREATE_CANCELED,
27894 );
27895
27896 barrier.wait();
27897 timeout(Duration::from_secs(1), async {
27898 while !done.load(Ordering::Acquire) {
27899 tokio::task::yield_now().await;
27900 }
27901 })
27902 .await
27903 .expect("canceled blocking create did not settle");
27904 assert!(!root.join("src/late.rs").exists());
27905 std::fs::remove_dir_all(root).unwrap();
27906 }
27907}
27908
27909#[cfg(test)]
27910mod provisional_spawn_tests {
27911 use super::*;
27912 use std::sync::Arc;
27913
27914 fn shared() -> NodeShared {
27915 let catalog = active_registry().unwrap();
27916 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27917 let workspace = WorkspaceConfig::new(
27918 WorkspaceId::new("primary").unwrap(),
27919 std::env::current_dir().unwrap(),
27920 )
27921 .unwrap()
27922 .with_worktree_service_mode(WorktreeServiceMode::Manual);
27923 NodeShared::new(
27924 handle,
27925 "fixture-token".to_owned(),
27926 NodeId::new("node-provisional-test").unwrap(),
27927 vec![workspace],
27928 vec![AgentId::new("claude").unwrap()],
27929 )
27930 }
27931
27932 #[test]
27935 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27936 let shared = shared();
27937 let ws = WorkspaceId::new("primary").unwrap();
27938 let mut ids = Vec::with_capacity(50);
27939 for i in 1u64..=50 {
27940 let id = AgentInstanceId(i);
27941 ids.push(id);
27942 let address = SessionAddress {
27943 workspace_id: ws.clone(),
27944 session: SessionKey {
27945 instance_id: id,
27946 generation: SessionGeneration(1),
27947 },
27948 };
27949 shared.mark_spawn_provisional(address);
27950 }
27951 assert_eq!(
27952 shared
27953 .provisional_spawns
27954 .lock()
27955 .unwrap_or_else(|p| p.into_inner())
27956 .len(),
27957 50
27958 );
27959 for id in ids.iter().step_by(2) {
27960 shared.sessionize_spawn(*id);
27961 }
27962 assert_eq!(
27963 shared
27964 .provisional_spawns
27965 .lock()
27966 .unwrap_or_else(|p| p.into_inner())
27967 .len(),
27968 25
27969 );
27970 assert!(shared.take_due_provisional_spawns().is_empty());
27971 {
27972 let mut map = shared
27973 .provisional_spawns
27974 .lock()
27975 .unwrap_or_else(|p| p.into_inner());
27976 let past = Instant::now() - Duration::from_secs(30);
27977 for (_, (_, at)) in map.iter_mut() {
27978 *at = past;
27979 }
27980 }
27981 let due = shared.take_due_provisional_spawns();
27982 assert_eq!(due.len(), 25);
27983 let due_ids: std::collections::BTreeSet<_> =
27984 due.iter().map(|a| a.session.instance_id.0).collect();
27985 let expected: std::collections::BTreeSet<_> =
27986 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
27987 assert_eq!(due_ids, expected);
27988 assert!(shared
27989 .provisional_spawns
27990 .lock()
27991 .unwrap_or_else(|p| p.into_inner())
27992 .is_empty());
27993 }
27994
27995 #[test]
27998 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
27999 let shared = Arc::new(shared());
28000 let ws = WorkspaceId::new("primary").unwrap();
28001 let mut handles = Vec::new();
28002 for i in 1u64..=20 {
28003 let shared = Arc::clone(&shared);
28004 let ws = ws.clone();
28005 handles.push(std::thread::spawn(move || {
28006 let id = AgentInstanceId(i);
28007 let address = SessionAddress {
28008 workspace_id: ws,
28009 session: SessionKey {
28010 instance_id: id,
28011 generation: SessionGeneration(1),
28012 },
28013 };
28014 shared.mark_spawn_provisional(address);
28015 id
28016 }));
28017 }
28018 let mut ids = Vec::new();
28019 for h in handles {
28020 ids.push(h.join().unwrap());
28021 }
28022 ids.sort_by_key(|id| id.0);
28023 assert_eq!(ids.len(), 20);
28024 ids.dedup();
28025 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28026 assert_eq!(
28027 shared
28028 .provisional_spawns
28029 .lock()
28030 .unwrap_or_else(|p| p.into_inner())
28031 .len(),
28032 20
28033 );
28034 shared.sessionize_spawn(AgentInstanceId(7));
28035 let left: Vec<_> = shared
28036 .provisional_spawns
28037 .lock()
28038 .unwrap_or_else(|p| p.into_inner())
28039 .keys()
28040 .map(|id| id.0)
28041 .collect();
28042 assert!(!left.contains(&7));
28043 assert_eq!(left.len(), 19);
28044 }
28045}
28046
28047
28048#[cfg(unix)]
28049extern "C" {
28050 #[link_name = "geteuid"]
28051 fn libc_geteuid() -> u32;
28052}
28053
28054#[cfg(test)]
28055mod call_home_link_tests {
28056 use super::*;
28057
28058 #[test]
28059 fn call_home_backoff_is_capped_and_monotonic() {
28060 let mut last = Duration::ZERO;
28061 for attempt in 1..20 {
28062 let next = call_home_backoff(attempt);
28063 assert!(next >= last);
28064 assert!(next <= Duration::from_secs(8));
28065 last = next;
28066 }
28067 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28068 }
28069
28070 #[tokio::test]
28071 async fn call_home_socket_gets_keepalive() {
28072 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28073 let addr = listener.local_addr().unwrap();
28074 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28075 let client = client.unwrap();
28076 enable_call_home_keepalive(&client).unwrap();
28077 let sock = socket2::SockRef::from(&client);
28078 assert!(sock.keepalive().unwrap());
28079 #[cfg(any(target_os = "linux", target_os = "macos"))]
28080 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28081 }
28082}