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
215pub fn spawn_model() -> Option<String> {
217 SPAWN_SESSION_OPTIONS.try_with(|o| o.as_ref().map(|o| o.model.clone())).ok().flatten().filter(|m| !m.is_empty())
218 .or_else(|| SPAWN_NICK_MODEL.try_with(|m| m.clone()).ok().flatten().filter(|m| !m.is_empty()))
219}
220
221tokio::task_local! {
222 static SPAWN_NICK_MODEL: Option<String>;
225 static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
228 static RESUME_PROVIDER_SESSION: Option<String>;
230 static RESTORE_INSTANCE_ID: AgentInstanceId;
232 static SPAWN_ORIGIN: Option<String>;
234 static SPAWN_PARENT: Option<String>;
235}
236
237fn spawn_selection(
239 options: &gate4agent_node_protocol::SpawnSessionOptions,
240) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
241 options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
242 match (&options.model, &options.effort) {
243 (None, None) => Ok(None),
244 (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
245 (Some(model), effort) => {
246 let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
247 if let Some(e) = effort {
248 sel = sel.with_value("effort", e.clone());
249 }
250 Ok(Some(sel))
251 }
252 }
253}
254
255mod browser_bridge;
256
257use gate4agent_node_core::drive_loop_profile;
258
259#[path = "bundle_delivery.rs"]
260mod bundle_delivery;
261
262#[path = "context_pack_store.rs"]
263mod context_pack_store;
264
265#[cfg(windows)]
266pub use crate::platform::DEFAULT_NODE_ENDPOINT;
267
268const NODE_EVENT_HISTORY_MAX: usize = 4_096;
269const NODE_BROADCAST_CAPACITY: usize = 1_024;
270const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
271const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
277const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
284const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
285const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
286const MAX_PREAUTH_CONNECTIONS: usize = 32;
287const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
288const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
289const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
290const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
291const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
292const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
298const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
306const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
314const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
321const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
326const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
327const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
328const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
329const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
330const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
331const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
332const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
333const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
334const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
335const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
336const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
337const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
338const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
341const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
348const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
351const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
352const GIT_STATUS_MAX_ENTRIES: usize = 128;
353const GIT_COMMIT_MAX_ENTRIES: usize = 12;
354const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
355const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
356const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
357const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
358const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
359const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
360const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
361const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
362const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
363const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
364 "provider-session-identity-allocation-failed";
365const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
366const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
367const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
368const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
369const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
370const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
371const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
372 "durable-state-path-semantics-unsupported";
373
374fn opaque_windows_path(value: String) -> OpaqueHostPath {
375 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
376}
377
378fn agent_progress_from_provider_snapshot(
379 address: SessionAddress,
380 provider: &ProviderSnapshot,
381) -> Option<SessionAgentProgress> {
382 let mut truncated = false;
383 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
384 let mut active_tool_labels = Vec::with_capacity(
385 provider
386 .active_tools
387 .len()
388 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
389 );
390 for tool in &provider.active_tools {
391 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
392 truncated = true;
393 break;
394 }
395 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
396 active_tool_labels.push(label);
397 }
398 }
399 let attention = provider
400 .interactions
401 .iter()
402 .find(|interaction| {
403 matches!(
404 interaction.status,
405 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
406 )
407 })
408 .map(|interaction| AgentProgressAttentionV1 {
409 kind: match interaction.interaction_kind {
410 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
411 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
412 },
413 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
414 });
415 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
416 let usage = (provider.completed_turns > 0
417 || provider.usage.input_tokens > 0
418 || provider.usage.output_tokens > 0
419 || provider.usage.cache_read_tokens > 0
420 || provider.usage.cache_write_tokens > 0
421 || provider.usage.reasoning_tokens > 0)
422 .then_some(AgentProgressUsageV1 {
423 input_tokens: provider.usage.input_tokens,
424 output_tokens: provider.usage.output_tokens,
425 cache_read_tokens: provider.usage.cache_read_tokens,
426 cache_write_tokens: provider.usage.cache_write_tokens,
427 reasoning_tokens: provider.usage.reasoning_tokens,
428 });
429 let progress = AgentProgressV1 {
430 provider_sequence: provider.sequence,
431 activity: provider.activity,
432 completed_turns: provider.completed_turns,
433 usage,
434 current: AgentProgressCurrentV1::from(provider.activity),
435 active_tool_labels,
436 active_tool_count,
437 attention,
438 subagent_count,
439 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
440 gap_count: provider.gap_count,
441 stale: provider.stale,
442 truncated,
443 };
444 let entry = SessionAgentProgress { address, progress };
445 serde_json::to_vec(&entry)
446 .ok()
447 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
448 .map(|_| entry)
449}
450
451fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
452 match u32::try_from(count) {
453 Ok(count) => count,
454 Err(_) => {
455 *truncated = true;
456 u32::MAX
457 }
458 }
459}
460
461fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
462 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
463 return None;
464 }
465 Some(match event {
466 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
467 ProviderEvent::SessionIdentityObserved { .. } => {
468 AgentProgressEventKindV1::SessionIdentityObserved
469 }
470 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
471 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
472 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
473 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
474 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
475 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
476 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
477 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
478 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
479 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
480 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
481 ProviderEvent::InteractionRequested { .. } => {
482 AgentProgressEventKindV1::InteractionRequested
483 }
484 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
485 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
486 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
487 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
488 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
489 ProviderEvent::UnrecognizedNotification { .. } => {
490 AgentProgressEventKindV1::UnrecognizedNotification
491 }
492 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
493 ProviderEvent::UserMessage { .. }
504 | ProviderEvent::Plan { .. }
505 | ProviderEvent::AvailableCommandsUpdated { .. }
506 | ProviderEvent::ModeChanged { .. }
507 | ProviderEvent::SessionInfoUpdated { .. }
508 | ProviderEvent::UsageUpdated { .. }
509 | ProviderEvent::ConfigOptionsUpdated { .. } => {
510 AgentProgressEventKindV1::UnrecognizedNotification
511 }
512 })
513}
514
515fn provider_family_is_live(family: AdapterFamily) -> bool {
528 match family {
529 AdapterFamily::PtySemantic
530 | AdapterFamily::Hook
531 | AdapterFamily::ManagedHook
532 | AdapterFamily::Pipe
533 | AdapterFamily::OneShot
534 | AdapterFamily::Acp => true,
535 AdapterFamily::History
536 | AdapterFamily::Resume
537 | AdapterFamily::SessionOptions
538 | AdapterFamily::CapabilityProbe => false,
539 }
540}
541
542fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
543 match by {
544 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
545 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
546 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
547 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
548 }
549}
550
551fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
563 match authority {
564 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
565 BlockAuthorityV1::HostPolicy => "denied by host policy",
566 BlockAuthorityV1::HostDeadline => {
567 "denied by host policy after the operator deadline expired"
568 }
569 BlockAuthorityV1::Operator => "declined by the operator",
570 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
571 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
572 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
573 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
574 BlockAuthorityV1::UserRejected => "rejected by the user",
575 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
576 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
577 }
578}
579
580fn host_request_denied_block(
583 method: &str,
584 by: ProviderHostDecisionAuthority,
585 reason: Option<&str>,
586) -> (String, BlockAuthorityV1, String) {
587 let authority = block_authority(by);
588 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
589 let (reason_text, _) =
593 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
594 (tool_class_label(method), authority, reason_text)
595}
596
597fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
616 match kind? {
617 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
618 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
619 _ => None,
620 }
621}
622
623fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
635 let trimmed = text.trim();
636 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
637 return (trimmed.to_owned(), None);
638 };
639 let tail = after_reason.trim_start();
640 let Some((reason, help)) = tail.split_once(". ") else {
641 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
645 return (reason.to_owned(), None);
646 };
647 let help = help.trim();
648 if help.is_empty() {
649 (reason.trim().to_owned(), None)
650 } else {
651 (reason.trim().to_owned(), Some(help.to_owned()))
652 }
653}
654
655fn tool_class_from_denial_help(help: Option<&str>) -> String {
665 help.and_then(|text| text.split("add a ").nth(1))
666 .and_then(|tail| tail.split(' ').next())
667 .filter(|word| !word.is_empty())
668 .map(str::to_owned)
669 .unwrap_or_else(|| "Tool".to_owned())
670}
671
672fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
680 fn names_quota(s: &str) -> bool {
681 let lower = s.to_ascii_lowercase();
682 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
683 }
684 vendor_code.is_some_and(names_quota) || names_quota(message)
685}
686
687fn provider_session_end_block(
703 stop_reason: &ProviderStopReason,
704) -> Option<(BlockAuthorityV1, String, String)> {
705 match stop_reason {
706 ProviderStopReason::Refusal => Some((
707 BlockAuthorityV1::ProviderRefusal,
708 "refusal".to_owned(),
709 "the provider ended the turn with stopReason: refusal".to_owned(),
710 )),
711 ProviderStopReason::ProviderError { message, vendor_code, .. }
712 if provider_error_is_quota(vendor_code.as_deref(), message) =>
713 {
714 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
715 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
716 }
717 _ => None,
718 }
719}
720
721fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
762 let ControlEventKind::ProviderEvent {
763 sequence: provider_sequence,
764 source,
765 source_sequence,
766 event: provider_event,
767 } = &event.event
768 else {
769 return None;
770 };
771 if !provider_family_is_live(source.family) {
772 return None;
773 }
774 let kind = match provider_event {
775 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
776 text: text.clone(),
777 is_delta: *is_delta,
778 },
779 ProviderEvent::Thinking { text } => {
780 AgentStreamChunkKindV1::Thinking { text: text.clone() }
781 }
782 ProviderEvent::InteractionRequested {
783 interaction_kind,
784 tool_name,
785 title,
786 prompt,
787 options,
788 ..
789 } => AgentStreamChunkKindV1::InteractionPrompt {
790 correlation_id: interaction_correlation(
791 event.instance_id,
792 event.generation,
793 *provider_sequence,
794 ),
795 interaction_kind: *interaction_kind,
796 tool_name: tool_name.clone(),
797 title: title.clone(),
806 prompt: prompt.clone(),
807 options: options
808 .iter()
809 .map(|option| AgentStreamInteractionOptionV1 {
810 option_id: option.option_id.clone(),
811 name: option.name.clone(),
812 kind: option.kind.clone(),
813 })
814 .collect(),
815 },
816 ProviderEvent::ConfigOptionsUpdated { options } => {
817 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
818 }
819 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
830 current: Some(mode_id.clone()),
831 available: available
832 .iter()
833 .map(|mode| AgentStreamNamedIdV1 {
834 id: mode.id.clone(),
835 name: mode.name.clone(),
836 description: mode.description.clone(),
837 })
838 .collect(),
839 },
840 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
844 let ProviderHostRequestDecision::Denied { by } = decision else {
845 return None;
846 };
847 let (tool_class, authority, reason_text) =
848 host_request_denied_block(method, *by, reason.as_deref());
849 AgentStreamChunkKindV1::Blocked {
850 correlation_id: None,
851 tool_class,
852 authority,
853 reason_kind: None,
854 reason: reason_text,
855 help: None,
856 }
857 }
858 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
862 let (authority, reason_kind) =
863 provider_tool_block_authority(non_execution_kind.as_deref())?;
864 let (reason, help) = split_provider_denial_text(output);
865 let (reason, _) =
866 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
867 let help = help.map(|help| {
868 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
869 });
870 AgentStreamChunkKindV1::Blocked {
871 correlation_id: Some(tool_correlation(event, source, id)),
872 tool_class: tool_class_from_denial_help(help.as_deref()),
873 authority,
874 reason_kind: Some(reason_kind.to_owned()),
875 reason,
876 help,
877 }
878 }
879 ProviderEvent::SessionEnded { stop_reason, .. } => {
883 let (authority, reason_kind, reason) =
884 stop_reason.as_ref().and_then(provider_session_end_block)?;
885 let (reason, _) =
886 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
887 AgentStreamChunkKindV1::Blocked {
888 correlation_id: None,
889 tool_class: "Session".to_owned(),
890 authority,
891 reason_kind: Some(reason_kind),
892 reason,
893 help: None,
894 }
895 }
896 ProviderEvent::SessionStarted { .. }
897 | ProviderEvent::SessionIdentityObserved { .. }
898 | ProviderEvent::TurnStarted { .. }
899 | ProviderEvent::WorkingObserved
900 | ProviderEvent::ToolStarted { .. }
901 | ProviderEvent::TurnCompleted { .. }
902 | ProviderEvent::ContextWindowUsage { .. }
903 | ProviderEvent::TurnInterrupted
904 | ProviderEvent::Error { .. }
905 | ProviderEvent::Ready
906 | ProviderEvent::InteractionResolved { .. }
907 | ProviderEvent::SubagentStarted { .. }
908 | ProviderEvent::SubagentStopped { .. }
909 | ProviderEvent::RateLimited { .. }
910 | ProviderEvent::UnrecognizedNotification { .. }
911 | ProviderEvent::UserMessage { .. }
912 | ProviderEvent::Plan { .. }
913 | ProviderEvent::AvailableCommandsUpdated { .. }
914 | ProviderEvent::SessionInfoUpdated { .. }
915 | ProviderEvent::UsageUpdated { .. } => return None,
916 };
917 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
918}
919
920async fn run_native_session_catalog_operation<T, E, F>(
921 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
922 operation: &'static str,
923 execute: F,
924) -> Result<T, NodeFailure>
925where
926 T: Send + 'static,
927 E: std::fmt::Display + Send + 'static,
928 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
929{
930 let task = tokio::task::spawn_blocking(move || {
931 let mut catalog = match catalog.try_lock() {
932 Ok(catalog) => catalog,
933 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
934 Err(std::sync::TryLockError::WouldBlock) => {
935 return Err(failure(
936 NodeFailureCode::BackendBusy,
937 "native session catalog operation is already running",
938 ));
939 }
940 };
941 execute(&mut catalog).map_err(|error| {
942 failure(
943 NodeFailureCode::BackendOperationFailed,
944 &format!("{operation} failed: {error}"),
945 )
946 })
947 });
948 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
949 .await
950 .map_err(|_| {
951 failure(
952 NodeFailureCode::BackendBusy,
953 &format!("{operation} exceeded its bounded deadline"),
954 )
955 })?
956 .map_err(|_| {
957 failure(
958 NodeFailureCode::BackendOperationFailed,
959 &format!("{operation} task failed"),
960 )
961 })?
962}
963
964fn windows_path_text(path: &OpaqueHostPath) -> &str {
965 path.as_utf8()
966 .expect("Windows node only creates UTF-8 opaque host paths")
967}
968
969fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
970 path.as_utf8()
971 .map(str::to_owned)
972 .ok_or_else(|| failure(
973 NodeFailureCode::InvalidRequest,
974 "this node requires UTF-8 host paths",
975 ))
976}
977
978fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
979 GitWorktreeSnapshot {
980 path: opaque_windows_path(worktree.path),
981 head: worktree.head,
982 branch: worktree.branch,
983 is_bare: worktree.is_bare,
984 is_main: worktree.is_main,
985 locked: worktree.locked,
986 lock_reason: worktree.lock_reason,
987 prunable: worktree.prunable,
988 prunable_reason: worktree.prunable_reason,
989 workspace_id: worktree.workspace_id.take(),
990 }
991}
992
993#[derive(Clone, Debug, Eq, PartialEq)]
994pub struct WorkspaceConfig {
995 workspace_id: WorkspaceId,
996 canonical_root: String,
997 worktree_service_mode: WorktreeServiceMode,
998 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
999}
1000
1001impl WorkspaceConfig {
1002 pub fn new(
1003 workspace_id: WorkspaceId,
1004 root: impl AsRef<Path>,
1005 ) -> Result<Self, NodeServerError> {
1006 let root = root.as_ref();
1007 if !root.is_absolute() {
1008 return Err(NodeServerError::InvalidWorkspaceRoot {
1009 workspace_id,
1010 path: root.to_string_lossy().into_owned(),
1011 message: "path is not absolute".to_owned(),
1012 });
1013 }
1014 let canonical = std::fs::canonicalize(root).map_err(|error| {
1015 NodeServerError::InvalidWorkspaceRoot {
1016 workspace_id: workspace_id.clone(),
1017 path: root.to_string_lossy().into_owned(),
1018 message: error.to_string(),
1019 }
1020 })?;
1021 if !canonical.is_dir() {
1022 return Err(NodeServerError::InvalidWorkspaceRoot {
1023 workspace_id,
1024 path: root.to_string_lossy().into_owned(),
1025 message: "path is not a directory".to_owned(),
1026 });
1027 }
1028 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1029 NodeServerError::InvalidWorkspaceRoot {
1030 workspace_id: workspace_id.clone(),
1031 path: path.to_string_lossy().into_owned(),
1032 message: "canonical path is not valid Unicode".to_owned(),
1033 }
1034 })?;
1035 let canonical_root = platform::normalize_canonical_root(canonical_root);
1036 validate_workspace_root(&workspace_id, &canonical_root)?;
1037 Ok(Self {
1038 workspace_id,
1039 canonical_root,
1040 worktree_service_mode: WorktreeServiceMode::Manual,
1041 managed_worktree_profiles: BTreeMap::new(),
1042 })
1043 }
1044
1045 pub fn workspace_id(&self) -> &WorkspaceId {
1046 &self.workspace_id
1047 }
1048
1049 pub fn canonical_root(&self) -> &str {
1050 &self.canonical_root
1051 }
1052
1053 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1054 self.worktree_service_mode = mode;
1055 self
1056 }
1057
1058 pub fn with_managed_worktree_profile(
1059 mut self,
1060 profile: ManagedWorktreeProfile,
1061 ) -> Result<Self, NodeServerError> {
1062 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1063 NodeServerError::InvalidManagedWorktreeProfile {
1064 workspace_id: self.workspace_id.clone(),
1065 profile_id: profile.profile_id().clone(),
1066 message,
1067 }
1068 })?;
1069 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1070 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1071 workspace_id: self.workspace_id,
1072 profile_id: profile.profile_id().clone(),
1073 });
1074 }
1075 if self.managed_worktree_profiles.len()
1076 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1077 {
1078 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1079 workspace_id: self.workspace_id,
1080 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1081 });
1082 }
1083 self.managed_worktree_profiles
1084 .insert(profile.profile_id().clone(), profile);
1085 Ok(self)
1086 }
1087}
1088
1089#[derive(Clone)]
1090pub struct NodeServerConfig {
1091 pub endpoint: String,
1092 api_listen: Option<std::net::SocketAddr>,
1093 bridge_listen: Option<std::net::SocketAddr>,
1096 bridge_token: Option<String>,
1099 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1102 call_home: Option<std::net::SocketAddr>,
1105 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1109 pub node_id: NodeId,
1110 pub workspaces: Vec<WorkspaceConfig>,
1111 access_token: String,
1112 pub runtime: NativeRuntimeConfig,
1113 session_record_retention: SessionRecordRetentionConfig,
1114 state_path: Option<PathBuf>,
1115 spawn_profiles: SpawnProfileRegistry,
1116 session_environment: Option<NodeSessionEnvironmentConfig>,
1117 history: Option<NativeHistoryConfig>,
1118 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1119 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1123 #[cfg(feature = "fixture")]
1124 fixture_raw_pty_runtime: bool,
1125}
1126
1127#[derive(Clone)]
1128struct NodeSessionEnvironmentConfig {
1129 root: PathBuf,
1130 resolver: Arc<dyn NodeSecretResolver>,
1131}
1132
1133impl NodeServerConfig {
1134 pub fn new(
1135 endpoint: impl Into<String>,
1136 access_token: impl Into<String>,
1137 node_id: NodeId,
1138 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1139 ) -> Result<Self, NodeServerError> {
1140 let endpoint = endpoint.into();
1141 let access_token = access_token.into();
1142 if !platform::validate_endpoint(&endpoint) {
1143 return Err(NodeServerError::InvalidEndpoint);
1144 }
1145 if access_token.is_empty() || access_token.len() > 4_096 {
1146 return Err(NodeServerError::InvalidAccessToken);
1147 }
1148 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1149 if workspaces.is_empty() {
1150 return Err(NodeServerError::NoWorkspaces);
1151 }
1152 let mut workspace_ids = BTreeMap::new();
1153 let mut workspace_roots = BTreeMap::new();
1154 for workspace in &workspaces {
1155 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1156 && workspace.managed_worktree_profiles.is_empty()
1157 {
1158 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1159 workspace.workspace_id.clone(),
1160 ));
1161 }
1162 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1163 && !workspace.managed_worktree_profiles.is_empty()
1164 {
1165 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1166 workspace.workspace_id.clone(),
1167 ));
1168 }
1169 if workspace_ids
1170 .insert(workspace.workspace_id.clone(), ())
1171 .is_some()
1172 {
1173 return Err(NodeServerError::DuplicateWorkspaceId(
1174 workspace.workspace_id.clone(),
1175 ));
1176 }
1177 let root_key = platform::root_identity(&workspace.canonical_root);
1178 if let Some(existing) = workspace_roots.insert(
1179 root_key,
1180 workspace.workspace_id.clone(),
1181 ) {
1182 return Err(NodeServerError::DuplicateWorkspaceRoot {
1183 first: existing,
1184 second: workspace.workspace_id.clone(),
1185 root: workspace.canonical_root.clone(),
1186 });
1187 }
1188 }
1189 Ok(Self {
1190 endpoint,
1191 api_listen: None,
1192 bridge_listen: None,
1193 bridge_token: None,
1194 bridge_underlay: None,
1195 call_home: None,
1196 call_home_identity: None,
1197 node_id,
1198 workspaces,
1199 access_token,
1200 runtime: NativeRuntimeConfig {
1203 worker_poll_interval_ms: 4,
1204 ..NativeRuntimeConfig::default()
1205 },
1206 session_record_retention: SessionRecordRetentionConfig::default(),
1207 state_path: None,
1208 spawn_profiles: SpawnProfileRegistry::default(),
1209 session_environment: None,
1210 history: None,
1211 harness_mcp_helper: None,
1212 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1213 #[cfg(feature = "fixture")]
1214 fixture_raw_pty_runtime: false,
1215 })
1216 }
1217
1218 pub fn with_api_listen(
1219 mut self,
1220 api_listen: std::net::SocketAddr,
1221 ) -> Result<Self, NodeServerError> {
1222 if !api_listen.ip().is_loopback() {
1223 return Err(NodeServerError::InvalidApiListen(api_listen));
1224 }
1225 self.api_listen = Some(api_listen);
1226 Ok(self)
1227 }
1228
1229 pub fn with_bridge_listen(
1233 mut self,
1234 bridge_listen: std::net::SocketAddr,
1235 ) -> Result<Self, NodeServerError> {
1236 if !bridge_listen.ip().is_loopback() {
1237 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1238 }
1239 self.bridge_listen = Some(bridge_listen);
1240 Ok(self)
1241 }
1242
1243 pub fn with_bridge_token(
1246 mut self,
1247 bridge_token: impl Into<String>,
1248 ) -> Result<Self, NodeServerError> {
1249 let bridge_token = bridge_token.into();
1250 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1251 return Err(NodeServerError::InvalidBridgeToken);
1252 }
1253 self.bridge_token = Some(bridge_token);
1254 Ok(self)
1255 }
1256
1257 pub fn with_bridge_underlay(
1262 mut self,
1263 bind: std::net::SocketAddr,
1264 transport_key: [u8; 32],
1265 underlay_token: impl Into<String>,
1266 ) -> Result<Self, NodeServerError> {
1267 if self.bridge_listen.is_none() {
1268 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1269 }
1270 if self.bridge_token.is_none() {
1271 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1272 }
1273 let underlay_token = underlay_token.into();
1274 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1275 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1276 }
1277 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1279 bind,
1280 transport_key,
1281 underlay_token,
1282 });
1283 Ok(self)
1284 }
1285
1286 pub fn with_call_home(
1307 mut self,
1308 relay: std::net::SocketAddr,
1309 ) -> Result<Self, NodeServerError> {
1310 if relay.port() == 0 || relay.ip().is_unspecified() {
1313 return Err(NodeServerError::InvalidCallHome(relay));
1314 }
1315 self.call_home = Some(relay);
1316 Ok(self)
1317 }
1318
1319 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1321 self.call_home_identity = Some(identity);
1322 self
1323 }
1324
1325 pub fn with_state_path(
1326 mut self,
1327 state_path: impl Into<PathBuf>,
1328 ) -> Result<Self, NodeServerError> {
1329 let state_path = state_path.into();
1330 if !state_path.is_absolute() || state_path.file_name().is_none() {
1331 return Err(NodeServerError::InvalidStatePath(
1332 state_path.to_string_lossy().into_owned(),
1333 ));
1334 }
1335 self.state_path = Some(state_path);
1336 Ok(self)
1337 }
1338
1339 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1340 self.spawn_profiles = spawn_profiles;
1341 self
1342 }
1343
1344 pub fn with_session_record_retention(
1350 mut self,
1351 retention: SessionRecordRetentionConfig,
1352 ) -> Self {
1353 self.session_record_retention = retention;
1354 self
1355 }
1356
1357 pub fn with_session_environment_materialization(
1358 mut self,
1359 root: impl Into<PathBuf>,
1360 resolver: Arc<dyn NodeSecretResolver>,
1361 ) -> Result<Self, NodeServerError> {
1362 let root = root.into();
1363 if !root.is_absolute() {
1364 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1365 }
1366 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1367 Ok(self)
1368 }
1369
1370 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1373 self.history = Some(history);
1374 self
1375 }
1376
1377 pub fn with_harness_mcp_helper(
1383 mut self,
1384 helper_program: impl Into<PathBuf>,
1385 ) -> Result<Self, NodeServerError> {
1386 self.harness_mcp_helper = Some(
1387 ReviewedHarnessMcpProgram::review(helper_program.into())
1388 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1389 );
1390 Ok(self)
1391 }
1392
1393 pub fn with_network_allowlist_catalog(
1398 mut self,
1399 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1400 ) -> Result<Self, NodeServerError> {
1401 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1402 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1403 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1404 });
1405 }
1406 self.network_allowlist_catalog = catalog;
1407 Ok(self)
1408 }
1409
1410 pub fn with_network_allowlist_catalog_file(
1414 self,
1415 path: impl AsRef<std::path::Path>,
1416 ) -> Result<Self, NodeServerError> {
1417 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1418 .map_err(NodeServerError::from)?;
1419 self.with_network_allowlist_catalog(catalog)
1420 }
1421}
1422
1423pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1424 platform::default_state_path(node_id.as_str())
1425 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1426}
1427
1428pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1429 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1430}
1431
1432fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1433 let parent = state_path.parent()?;
1434 let mut name = state_path.file_name()?.to_os_string();
1435 name.push(".delivery-store");
1436 Some(parent.join(name))
1437}
1438
1439fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1440 let parent = state_path.parent()?;
1441 let mut name = state_path.file_name()?.to_os_string();
1442 name.push(".context-pack-store");
1443 Some(parent.join(name))
1444}
1445
1446pub struct NodeServer {
1447 config: NodeServerConfig,
1448 runtime: NativeRuntime,
1449 shared: Arc<NodeShared>,
1450 events: EventSubscription,
1451 state_path_lock: Option<session_registry::StatePathLock>,
1452}
1453
1454#[cfg(feature = "fixture")]
1455#[derive(Clone, Default)]
1456pub struct SpawnManagedWorktreeV2FailureProbe {
1457 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1458}
1459
1460#[cfg(feature = "fixture")]
1461impl SpawnManagedWorktreeV2FailureProbe {
1462 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1463 *self
1464 .latest_code
1465 .lock()
1466 .unwrap_or_else(|poisoned| poisoned.into_inner())
1467 }
1468
1469 fn record(&self, code: Option<NodeFailureCode>) {
1470 *self
1471 .latest_code
1472 .lock()
1473 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1474 }
1475}
1476
1477#[cfg(all(feature = "fixture", windows))]
1478struct IsolatedCodexHomeFixtureEnvironment;
1479
1480#[cfg(all(feature = "fixture", windows))]
1481impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1482 for IsolatedCodexHomeFixtureEnvironment
1483{
1484 fn resolve_child_environment(
1485 &self,
1486 ) -> Result<
1487 Vec<gate4agent_catalog::EnvMutation>,
1488 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1489 > {
1490 Ok(vec![gate4agent_catalog::EnvMutation {
1491 key: OsString::from("USERPROFILE"),
1492 value: None,
1493 }])
1494 }
1495}
1496
1497impl NodeServer {
1498 #[cfg(unix)]
1502 pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1503 self.shared.signer_proxy.set(proxy).is_ok()
1504 }
1505
1506 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1507 self.shared.m4a_accounts.as_ref()?;
1508 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1509 }
1510
1511 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1512 let catalog = active_registry()?;
1513 Self::new_with_registry(config, catalog)
1514 }
1515
1516 #[cfg(feature = "fixture")]
1517 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1518 let mut spec = gate4agent_testkit::interactive_agent_spec();
1519 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1520 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1521 Self::new_fixture_with_spec(config, spec)
1522 }
1523
1524 #[cfg(feature = "fixture")]
1525 pub fn new_clean_exit_fixture(
1526 config: NodeServerConfig,
1527 fixture_root: PathBuf,
1528 started_marker: PathBuf,
1529 release_signal: PathBuf,
1530 ) -> Result<Self, NodeServerError> {
1531 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1532 &fixture_root,
1533 &started_marker,
1534 &release_signal,
1535 )
1536 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1537 spec.id = AgentId::new("claude")
1538 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1539 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1540 Self::new_fixture_with_spec(config, spec)
1541 }
1542
1543 #[cfg(all(feature = "fixture", windows))]
1553 pub fn new_durable_context_pack_clean_exit_fixture(
1554 config: NodeServerConfig,
1555 fixture_root: PathBuf,
1556 started_marker: PathBuf,
1557 release_signal: PathBuf,
1558 session_id: &str,
1559 ) -> Result<Self, NodeServerError> {
1560 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1561 &fixture_root,
1562 &started_marker,
1563 &release_signal,
1564 session_id,
1565 )
1566 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1567 let source_id = AgentId::new("claude")
1568 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1569 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1570 NodeServerError::Registry(
1571 "durable context pack clean-exit fixture source adapter is unavailable"
1572 .to_owned(),
1573 )
1574 })?;
1575 source_spec.capabilities.adapters.history =
1576 source_provider.capabilities.adapters.history.clone();
1577 source_spec.id = source_id;
1578 source_spec.display_name =
1579 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1580 source_spec.detection.command =
1586 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1587 source_spec.detection.aliases.clear();
1588
1589 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1590 target_spec.id = AgentId::new("codex")
1591 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1592 target_spec.display_name =
1593 "Controlled Codex durable context-pack target fixture".to_owned();
1594 target_spec.detection.command =
1595 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1596 target_spec.detection.aliases.clear();
1597
1598 let catalog = AgentRegistry::new([source_spec, target_spec])
1599 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1600 let mut server = Self::new_with_registry(config, catalog)?;
1601 Arc::get_mut(&mut server.shared)
1602 .expect("fixture Node shared state must remain uniquely owned before run")
1603 .fixture_semantic_hook_policy = true;
1604 Ok(server)
1605 }
1606
1607 #[cfg(feature = "fixture")]
1608 pub fn new_monitoring_hook_fixture(
1609 config: NodeServerConfig,
1610 ) -> Result<Self, NodeServerError> {
1611 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1612 spec.id = AgentId::new("claude")
1613 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1614 let mut server = Self::new_fixture_with_spec(config, spec)?;
1615 Arc::get_mut(&mut server.shared)
1616 .expect("fixture Node shared state must remain uniquely owned before run")
1617 .fixture_semantic_hook_policy = true;
1618 Ok(server)
1619 }
1620
1621 #[cfg(all(feature = "fixture", windows))]
1622 pub fn new_provider_bundle_argv_fixture(
1623 config: NodeServerConfig,
1624 agent_id: AgentId,
1625 proof_path: PathBuf,
1626 ) -> Result<Self, NodeServerError> {
1627 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1628 Self::new_fixture_with_spec(config, spec)
1629 }
1630
1631 #[cfg(all(feature = "fixture", windows))]
1632 pub fn new_provider_bundle_argv_hold_fixture(
1633 config: NodeServerConfig,
1634 agent_id: AgentId,
1635 proof_path: PathBuf,
1636 release_signal: PathBuf,
1637 ) -> Result<Self, NodeServerError> {
1638 let spec = Self::provider_bundle_argv_fixture_spec(
1639 agent_id,
1640 proof_path,
1641 Some(release_signal),
1642 )?;
1643 Self::new_fixture_with_spec(config, spec)
1644 }
1645
1646 #[cfg(all(feature = "fixture", windows))]
1647 fn provider_bundle_argv_fixture_spec(
1648 agent_id: AgentId,
1649 proof_path: PathBuf,
1650 release_signal: Option<PathBuf>,
1651 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1652 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1653 return Err(NodeServerError::Registry(
1654 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1655 ));
1656 }
1657 let mut spec = gate4agent_testkit::interactive_agent_spec();
1658 spec.id = agent_id.clone();
1659 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1660 let provider = builtin_registry()
1661 .get(&agent_id)
1662 .ok_or_else(|| {
1663 NodeServerError::Registry(
1664 "provider bundle argv fixture adapter is unavailable".to_owned(),
1665 )
1666 })?;
1667 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1668 if !proof_path.is_absolute()
1669 || !proof_path
1670 .parent()
1671 .is_some_and(|parent| parent.is_dir())
1672 {
1673 return Err(NodeServerError::Registry(
1674 "provider bundle argv proof path requires an existing absolute parent"
1675 .to_owned(),
1676 ));
1677 }
1678 if let Some(release_signal) = release_signal.as_ref() {
1679 if !release_signal.is_absolute()
1680 || !release_signal
1681 .parent()
1682 .is_some_and(|parent| parent.is_dir())
1683 {
1684 return Err(NodeServerError::Registry(
1685 "provider bundle argv release path requires an existing absolute parent"
1686 .to_owned(),
1687 ));
1688 }
1689 if release_signal.exists() {
1690 return Err(NodeServerError::Registry(
1691 "provider bundle argv release path must not exist".to_owned(),
1692 ));
1693 }
1694 let proof_parent = std::fs::canonicalize(
1695 proof_path
1696 .parent()
1697 .expect("validated provider bundle proof parent"),
1698 )
1699 .map_err(|_| {
1700 NodeServerError::Registry(
1701 "provider bundle argv proof parent is unavailable".to_owned(),
1702 )
1703 })?;
1704 let release_parent = std::fs::canonicalize(
1705 release_signal
1706 .parent()
1707 .expect("validated provider bundle release parent"),
1708 )
1709 .map_err(|_| {
1710 NodeServerError::Registry(
1711 "provider bundle argv release parent is unavailable".to_owned(),
1712 )
1713 })?;
1714 if proof_parent != release_parent {
1715 return Err(NodeServerError::Registry(
1716 "provider bundle argv release path must share the proof parent".to_owned(),
1717 ));
1718 }
1719 }
1720 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1721 NodeServerError::Registry(
1722 "provider bundle argv proof path is not valid Unicode".to_owned(),
1723 )
1724 })?;
1725 let release_signal = release_signal.map(|release_signal| {
1726 release_signal.into_os_string().into_string().map_err(|_| {
1727 NodeServerError::Registry(
1728 "provider bundle argv release path is not valid Unicode".to_owned(),
1729 )
1730 })
1731 }).transpose()?;
1732 let script = spec
1733 .launch
1734 .fixed_args
1735 .pop()
1736 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1737 let bundle_validation = if agent_id.as_str() == "codex" {
1738 "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') }"
1739 } else if agent_id.as_str() == "claude" {
1740 "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 }"
1741 } else {
1742 "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 }"
1743 };
1744 if let Some(release_signal) = release_signal {
1745 spec.launch.fixed_args.push(format!(
1746 "& {{ 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} }}",
1747 ));
1748 spec.launch.fixed_args.push(proof_path);
1749 spec.launch.fixed_args.push(release_signal);
1750 } else {
1751 spec.launch.fixed_args.push(format!(
1752 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1753 ));
1754 spec.launch.fixed_args.push(proof_path);
1755 }
1756 Ok(spec)
1757 }
1758
1759 #[cfg(all(feature = "fixture", windows))]
1760 pub fn new_context_pack_fixture(
1761 config: NodeServerConfig,
1762 proof_path: PathBuf,
1763 ) -> Result<Self, NodeServerError> {
1764 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1765 Self::new_with_registry(config, catalog)
1766 }
1767
1768 #[cfg(all(feature = "fixture", windows))]
1769 pub fn new_monitoring_context_pack_fixture(
1770 config: NodeServerConfig,
1771 proof_path: PathBuf,
1772 ) -> Result<Self, NodeServerError> {
1773 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1774 let mut server = Self::new_with_registry(config, catalog)?;
1775 Arc::get_mut(&mut server.shared)
1776 .expect("fixture Node shared state must remain uniquely owned before run")
1777 .fixture_semantic_hook_policy = true;
1778 Ok(server)
1779 }
1780
1781 #[cfg(all(feature = "fixture", windows))]
1782 pub fn install_isolated_codex_home_environment_fixture(
1783 &mut self,
1784 profile_id: SpawnEnvironmentProfileId,
1785 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1786 ) -> Result<(), NodeServerError> {
1787 let provider = AgentId::new("codex")
1788 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1789 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1790 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1791 "{}-pty",
1792 profile_id.as_str(),
1793 ))
1794 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1795 provider.clone(),
1796 TransportKind::Pty,
1797 vec![OsString::from("USERPROFILE")],
1798 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1799 )
1800 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1801 let materialization = NodeSessionMaterializationProfile::new(
1802 Vec::new(),
1803 vec![crate::session_environment::NodeSessionPathBinding::new(
1804 "CODEX_HOME",
1805 crate::session_environment::NodeSessionPathClass::ProviderHome,
1806 )
1807 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1808 Vec::new(),
1809 )
1810 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1811 self.install_environment_profile(
1812 NodeEnvironmentProfile::new_with_materialization(
1813 profile_id,
1814 profile_revision,
1815 provider,
1816 [native_profile],
1817 Some(materialization),
1818 )
1819 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1820 )
1821 }
1822
1823 #[cfg(all(feature = "fixture", windows))]
1824 pub fn new_context_only_proof_fixture(
1825 config: NodeServerConfig,
1826 target_provider: AgentId,
1827 proof_path: PathBuf,
1828 ) -> Result<Self, NodeServerError> {
1829 if target_provider.as_str() != "kimi" {
1830 return Err(NodeServerError::Registry(
1831 "context-only proof fixture requires Kimi".to_owned(),
1832 ));
1833 }
1834 let catalog = Self::context_pack_fixture_catalog(
1835 proof_path,
1836 Some(&target_provider),
1837 false,
1838 )?;
1839 Self::new_with_registry(config, catalog)
1840 }
1841
1842 #[cfg(all(feature = "fixture", windows))]
1843 fn context_pack_fixture_catalog(
1844 proof_path: PathBuf,
1845 context_only_target: Option<&AgentId>,
1846 monitoring_claude_source: bool,
1847 ) -> Result<AgentRegistry, NodeServerError> {
1848 if !proof_path.is_absolute()
1849 || !proof_path
1850 .parent()
1851 .is_some_and(|parent| parent.is_dir())
1852 {
1853 return Err(NodeServerError::Registry(
1854 "context pack proof path requires an existing absolute parent".to_owned(),
1855 ));
1856 }
1857 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1858 NodeServerError::Registry(
1859 "context pack proof path is not valid Unicode".to_owned(),
1860 )
1861 })?;
1862 let providers = ["claude", "codex", "grok", "kimi"];
1863 let builtins = builtin_registry();
1864 let mut specs = Vec::with_capacity(providers.len());
1865 for provider_id in providers {
1866 let agent_id = AgentId::new(provider_id)
1867 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1868 let provider = builtins.get(&agent_id).ok_or_else(|| {
1869 NodeServerError::Registry(
1870 "context pack fixture provider adapter is unavailable".to_owned(),
1871 )
1872 })?;
1873 let monitored_provider =
1874 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1875 let mut spec = if monitored_provider {
1876 gate4agent_testkit::monitoring_hook_agent_spec()
1877 } else {
1878 gate4agent_testkit::interactive_agent_spec()
1879 };
1880 spec.id = agent_id.clone();
1881 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1882 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1883 spec.detection.aliases.clear();
1884 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1885 if provider_id == "codex" {
1886 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1887 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1888 })?;
1889 let validation = r#"
1890$bundleArgs = @($bundleArgs)
1891if ($bundleArgs.Count -ne 0) { exit 91 }
1892$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1893$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1894$cwd = (Get-Location).ProviderPath
1895if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1896$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1897if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1898$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1899if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1900$sha = [Security.Cryptography.SHA256]::Create()
1901try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1902if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1903if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1904$contextPath = Join-Path $contextRoot 'context-pack.json'
1905if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1906$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1907if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1908$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1909$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1910$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1911$separator = [IO.Path]::DirectorySeparatorChar
1912if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1913if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1914$rawContext = [IO.File]::ReadAllText($contextPath)
1915if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1916try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1917$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1918$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1919foreach ($property in @($document.PSObject.Properties.Name)) {
1920 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1921}
1922foreach ($property in $requiredTopLevel) {
1923 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1924}
1925if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1926if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1927$messages = @($document.retained_messages)
1928if ($messages.Count -eq 0) { exit 107 }
1929foreach ($message in $messages) {
1930 $messageProperties = @($message.PSObject.Properties.Name)
1931 foreach ($property in $messageProperties) {
1932 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1933 }
1934 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1935}
1936$sha = [Security.Cryptography.SHA256]::Create()
1937try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1938[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))
1939$rawProof = [IO.File]::ReadAllText($proofPath)
1940if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1941[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1942"#;
1943 spec.launch.fixed_args.push(format!(
1944 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1945 ));
1946 spec.launch.fixed_args.push(proof_path.clone());
1947 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1948 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1949 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1950 })?;
1951 let validation = r#"
1952$bundleArgs = @($bundleArgs)
1953if ($bundleArgs.Count -ne 0) { exit 91 }
1954$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1955$cwd = (Get-Location).ProviderPath
1956if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1957$contextPath = Join-Path $contextRoot 'context-pack.json'
1958if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1959$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1960if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1961$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1962$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1963$separator = [IO.Path]::DirectorySeparatorChar
1964if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1965try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1966if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1967if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1968$messages = @($document.retained_messages)
1969if ($messages.Count -eq 0) { exit 99 }
1970$hasUser = $false
1971$hasAssistant = $false
1972foreach ($message in $messages) {
1973 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1974 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1975 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1976 else { exit 101 }
1977}
1978if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1979$sha = [Security.Cryptography.SHA256]::Create()
1980try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1981[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1982[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1983"#;
1984 spec.launch.fixed_args.push(format!(
1985 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1986 ));
1987 spec.launch.fixed_args.push(proof_path.clone());
1988 }
1989 specs.push(spec);
1990 }
1991 let catalog = AgentRegistry::new(specs)
1992 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1993 Ok(catalog)
1994 }
1995
1996 #[cfg(feature = "fixture")]
1997 pub fn new_exact_launcher_fixture(
1998 config: NodeServerConfig,
1999 agent_id: AgentId,
2000 launcher: String,
2001 ) -> Result<Self, NodeServerError> {
2002 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
2003 return Err(NodeServerError::Registry(
2004 "exact launcher fixture requires a supported provider".to_owned(),
2005 ));
2006 }
2007 if launcher.contains('\0') {
2008 return Err(NodeServerError::Registry(
2009 "exact launcher fixture path contains NUL".to_owned(),
2010 ));
2011 }
2012 let launcher_path = Path::new(&launcher);
2013 if !launcher_path.is_absolute() {
2014 return Err(NodeServerError::Registry(
2015 "exact launcher fixture path must be absolute".to_owned(),
2016 ));
2017 }
2018 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2019 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2020 })?;
2021 if !metadata.is_file() {
2022 return Err(NodeServerError::Registry(
2023 "exact launcher fixture path must be a regular file".to_owned(),
2024 ));
2025 }
2026 let mut spec = builtin_registry()
2027 .get(&agent_id)
2028 .ok_or_else(|| {
2029 NodeServerError::Registry(
2030 "exact launcher fixture provider is unavailable".to_owned(),
2031 )
2032 })?
2033 .clone();
2034 spec.launch.program = launcher;
2035 Self::new_fixture_with_spec(config, spec)
2036 }
2037
2038 #[cfg(feature = "fixture")]
2047 fn h3b_provider_spec_with_program_and_args(
2048 agent_id: AgentId,
2049 provider_program: PathBuf,
2050 fixed_args: Vec<OsString>,
2051 ) -> Result<AgentSpec, NodeServerError> {
2052 if !provider_program.is_absolute() || fixed_args.len() > 32 {
2053 return Err(NodeServerError::Registry(
2054 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2055 ));
2056 }
2057 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2058 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2059 })?;
2060 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2061 return Err(NodeServerError::Registry(
2062 "H3B fixture provider must be an exact regular file".to_owned(),
2063 ));
2064 }
2065 let program = provider_program.into_os_string().into_string().map_err(|_| {
2066 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2067 })?;
2068 let fixed_args = fixed_args.into_iter().map(|argument| {
2069 argument.into_string().map_err(|_| NodeServerError::Registry(
2070 "H3B fixture argument is not Unicode".to_owned(),
2071 ))
2072 }).collect::<Result<Vec<_>, _>>()?;
2073 let argument_bytes = fixed_args.iter()
2074 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2075 if program.contains('\0')
2076 || fixed_args.iter().any(|argument| argument.contains('\0'))
2077 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2078 {
2079 return Err(NodeServerError::Registry(
2080 "H3B fixture provider or arguments are invalid".to_owned(),
2081 ));
2082 }
2083 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2084 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2085 })?.clone();
2086 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2087 .ok_or_else(|| NodeServerError::Registry(
2088 "H3B fixture provider basename is unavailable".to_owned(),
2089 ))?.to_owned();
2090 spec.detection.command = process_name.clone();
2091 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2092 name: process_name,
2093 }];
2094 spec.launch.program = program;
2095 spec.launch.fixed_args = fixed_args;
2096 Ok(spec)
2097 }
2098
2099 #[cfg(feature = "fixture")]
2100 pub fn new_harness_mcp_proxy_fixture(
2101 config: NodeServerConfig,
2102 provider_program: PathBuf,
2103 fixed_args: Vec<OsString>,
2104 harness_mcp_program: PathBuf,
2105 ) -> Result<Self, NodeServerError> {
2106 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2107 let agent_id = AgentId::new("codex")
2108 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2109 let mut spec =
2110 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2111 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2112 config.fixture_raw_pty_runtime = true;
2113 Self::new_fixture_with_spec(config, spec)
2114 }
2115
2116 #[cfg(feature = "fixture")]
2140 pub fn new_harness_mcp_acp_launcher_fixture(
2141 config: NodeServerConfig,
2142 helper_program: PathBuf,
2143 provider: AgentId,
2144 program: PathBuf,
2145 args: Vec<OsString>,
2146 ) -> Result<Self, NodeServerError> {
2147 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2148 return Err(NodeServerError::Registry(
2149 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2150 ));
2151 }
2152 let config = config.with_harness_mcp_helper(helper_program)?;
2153 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2154 let launch = spec.launch.clone();
2155 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2156 NodeServerError::Registry(
2157 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2158 .to_owned(),
2159 )
2160 })?;
2161 acp.launch_override = Some(launch);
2169 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2170 Self::new_fixture_with_spec(config, spec)
2171 }
2172
2173 #[cfg(feature = "fixture")]
2174 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2175 let mut spec = gate4agent_testkit::interactive_agent_spec();
2176 let claude_id = AgentId::new("claude")
2177 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2178 let claude = builtin_registry()
2179 .get(&claude_id)
2180 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2181 spec.id = claude_id;
2182 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2183 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2184 name: "claude".to_owned(),
2185 }];
2186 spec.capabilities.transports.pty_adapter = claude
2187 .capabilities
2188 .transports
2189 .pty_adapter
2190 .clone();
2191 spec.capabilities.adapters.resume = claude
2192 .capabilities
2193 .adapters
2194 .resume
2195 .clone();
2196 let script = spec
2197 .launch
2198 .fixed_args
2199 .pop()
2200 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2201 spec.launch.fixed_args.push(format!(
2202 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2203 ));
2204 Self::new_fixture_with_spec(config, spec)
2205 }
2206
2207 #[cfg(feature = "fixture")]
2208 pub fn new_cmd_cwd_fixture(
2209 config: NodeServerConfig,
2210 command: String,
2211 ) -> Result<Self, NodeServerError> {
2212 let mut spec = gate4agent_testkit::interactive_agent_spec();
2213 spec.id = AgentId::new("claude")
2214 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2215 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2216 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2217 spec.launch.program = command;
2218 spec.launch.fixed_args.clear();
2219 Self::new_fixture_with_spec(config, spec)
2220 }
2221
2222 #[cfg(feature = "fixture")]
2223 fn new_fixture_with_spec(
2224 config: NodeServerConfig,
2225 spec: gate4agent_types::AgentSpec,
2226 ) -> Result<Self, NodeServerError> {
2227 let catalog = AgentRegistry::new([spec])
2228 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2229 Self::new_with_registry(config, catalog)
2230 }
2231
2232 fn new_with_registry(
2233 config: NodeServerConfig,
2234 catalog: AgentRegistry,
2235 ) -> Result<Self, NodeServerError> {
2236 for profile in config.spawn_profiles.iter() {
2237 if catalog.get(&profile.provider).is_none() {
2238 return Err(NodeServerError::Registry(
2239 "spawn profile references an unavailable provider".to_owned(),
2240 ));
2241 }
2242 }
2243 let input_settle_timeout_ms = catalog
2244 .iter()
2245 .map(|spec| spec.readiness.timeout_ms)
2246 .max()
2247 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2248 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2249 .max(MUTATION_SETTLE_TIMEOUT_MS);
2250 let (provider_contracts, provider_adapter_contracts) =
2251 provider_contract_manifest(&catalog)?;
2252 let compatibility_support = node_compatibility_support_for_manifest(
2253 &provider_contracts,
2254 &provider_adapter_contracts,
2255 )?;
2256 validate_node_negotiated_handshake_capacity(&compatibility_support)
2257 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2258 let enabled_providers = provider_contracts
2259 .iter()
2260 .map(|contract| contract.provider.clone())
2261 .collect();
2262 #[cfg(feature = "fixture")]
2263 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2264 #[cfg(not(feature = "fixture"))]
2265 let fixture_raw_pty_runtime = false;
2266 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2267 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2268 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2269 })).expect("fixture provider catalog remains bounded");
2270 (None, statuses)
2271 } else {
2272 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2273 let statuses = monitor.collect();
2274 (Some(monitor), statuses)
2275 };
2276 let state_path_lock =
2277 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2278 .map_err(|error| {
2279 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2280 })?;
2281 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2282 Some(state_path) => {
2283 let delivery_root = delivery_store_root_for_state_path(state_path)
2284 .ok_or_else(|| {
2285 NodeServerError::DeliveryStore(
2286 "delivery store root could not be derived from state path".to_owned(),
2287 )
2288 })?;
2289 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2290 let detail = match &error {
2291 DeliveryStoreError::Storage(io_error) => {
2292 format!("{error}: {io_error}")
2293 }
2294 _ => error.to_string(),
2295 };
2296 NodeServerError::DeliveryStore(detail)
2297 })?;
2298 (Some(store), bundles)
2299 }
2300 None => (None, Vec::new()),
2301 };
2302 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2303 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2304 })?;
2305 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2306 Some(state_path) => {
2307 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2308 .ok_or(NodeServerError::ContextPackStore)?;
2309 let (store, packs) = ContextPackStore::open(context_pack_root)
2310 .map_err(|_| NodeServerError::ContextPackStore)?;
2311 (Some(store), packs)
2312 }
2313 None => (None, Vec::new()),
2314 };
2315 let mut durable_context_catalog = ContextPackCatalog::default();
2316 for pack in durable_context_packs {
2317 durable_context_catalog
2318 .insert(pack)
2319 .map_err(|_| NodeServerError::ContextPackStore)?;
2320 }
2321 let session_environment_materializer = config
2322 .session_environment
2323 .as_ref()
2324 .map(|environment| {
2325 SessionEnvironmentMaterializer::new(
2326 environment.root.clone(),
2327 Arc::clone(&environment.resolver),
2328 )
2329 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2330 })
2331 .transpose()?;
2332 #[cfg(unix)]
2335 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2336 let native_session_catalog = config.history.clone().map(|history| {
2337 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2338 });
2339 let (handle, runtime) = match config.history.clone() {
2340 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2341 None => NativeRuntime::new(catalog, config.runtime),
2342 };
2343 let native_launch_profile_control = runtime.native_launch_profile_control();
2344 let harness_mcp_registry = config
2345 .harness_mcp_helper
2346 .clone()
2347 .map(HarnessMcpProxyRegistry::new);
2348 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2349 let incarnation_id = random_incarnation_id()
2350 .map_err(NodeServerError::IncarnationIdentity)?;
2351 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2352 .map_err(durable_state_load_error)?;
2353 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2354 let managed_worktree_tombstones =
2355 std::mem::take(&mut loaded.managed_worktree_tombstones);
2356 let materializations = std::mem::take(&mut loaded.materializations);
2357 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2358 let (workspaces, records, persistence_warning) =
2359 merge_durable_state(&config.workspaces, loaded)?;
2360 let mut shared = NodeShared::new_with_incarnation(
2361 handle,
2362 config.access_token.clone(),
2363 config.node_id.clone(),
2364 incarnation_id,
2365 workspaces,
2366 enabled_providers,
2367 provider_runtime_statuses,
2368 provider_runtime_monitor,
2369 provider_contracts,
2370 provider_adapter_contracts,
2371 config.spawn_profiles.clone(),
2372 Some(native_launch_profile_control),
2373 native_session_catalog,
2374 session_environment_materializer,
2375 config.state_path.clone(),
2376 records,
2377 managed_worktrees,
2378 managed_worktree_tombstones,
2379 materializations,
2380 persistence_warning,
2381 input_settle_timeout_ms,
2382 );
2383 shared.harness_mcp_registry = harness_mcp_registry.clone();
2384 shared.session_record_retention = config.session_record_retention;
2385 shared.network_allowlist_catalog =
2386 RwLock::new(config.network_allowlist_catalog.clone());
2387 shared.bundle_catalog = RwLock::new(delivered_catalog);
2388 shared.delivery_store = Mutex::new(delivery_store);
2389 shared.context_catalog = RwLock::new(durable_context_catalog);
2390 shared.context_pack_store = Mutex::new(context_pack_store);
2391 shared.managed_spawn_replays = Mutex::new(
2392 managed_spawn_replays
2393 .into_iter()
2394 .map(|record| (record.idempotency_key.clone(), record))
2395 .collect(),
2396 );
2397 let shared = Arc::new(shared);
2398 if let Some(registry) = harness_mcp_registry {
2399 let weak = Arc::downgrade(&shared);
2400 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2401 Some(shared) => shared.publish_transient(event),
2402 None => false,
2403 }));
2404 }
2405 if shared.persistence_error().is_none() {
2406 shared.persist_state().map_err(|error| {
2407 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2408 })?;
2409 }
2410 Ok(Self {
2411 config,
2412 runtime,
2413 shared,
2414 events,
2415 state_path_lock,
2416 })
2417 }
2418
2419 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2420 NodeShutdownHandle {
2421 shared: Arc::clone(&self.shared),
2422 }
2423 }
2424
2425 #[cfg(feature = "fixture")]
2426 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2427 &self,
2428 ) -> SpawnManagedWorktreeV2FailureProbe {
2429 self.shared
2430 .fixture_spawn_managed_worktree_v2_failure_probe
2431 .clone()
2432 }
2433
2434 pub fn install_environment_profile(
2439 &mut self,
2440 profile: NodeEnvironmentProfile,
2441 ) -> Result<(), NodeServerError> {
2442 if !self.shared.enabled_providers.contains(profile.provider()) {
2443 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2444 profile.provider().clone(),
2445 ));
2446 }
2447 let (binding, native_profiles, materialization) = profile.into_parts();
2448 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2449 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2450 }
2451 let mut profiles = self
2452 .shared
2453 .environment_profiles
2454 .write()
2455 .unwrap_or_else(|poisoned| poisoned.into_inner());
2456 if profiles.contains_key(&binding.id) {
2457 return Err(NodeServerError::DuplicateEnvironmentProfile(
2458 binding.id.clone(),
2459 ));
2460 }
2461 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2462 return Err(NodeServerError::EnvironmentProfileCapacity {
2463 max: MAX_NODE_ENVIRONMENT_PROFILES,
2464 });
2465 }
2466 for native_id in binding.native_profile_ids() {
2467 if profiles.values().any(|current| {
2468 current.native_profile_ids().any(|current_id| current_id == native_id)
2469 }) {
2470 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2471 native_id.to_string(),
2472 ));
2473 }
2474 }
2475
2476 let mut installed = Vec::with_capacity(native_profiles.len());
2477 for native_profile in native_profiles {
2478 let native_id = native_profile.id().clone();
2479 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2480 for installed_id in installed.into_iter().rev() {
2481 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2482 }
2483 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2484 }
2485 installed.push(native_id);
2486 }
2487 if let Some(materialization) = materialization {
2488 self.shared
2489 .environment_materialization_profiles
2490 .write()
2491 .unwrap_or_else(|poisoned| poisoned.into_inner())
2492 .insert(binding.id.clone(), materialization);
2493 }
2494 profiles.insert(binding.id.clone(), binding);
2495 Ok(())
2496 }
2497
2498 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2500 if self.shared.session_environment_materializer.is_none() {
2501 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2502 }
2503 let mut catalog = self
2504 .shared
2505 .bundle_catalog
2506 .write()
2507 .unwrap_or_else(|poisoned| poisoned.into_inner());
2508 catalog
2509 .insert_idempotent(bundle)
2510 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2511 Ok(())
2512 }
2513
2514 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2515 let shutdown = self.shutdown_handle();
2516 let run = self.run();
2517 tokio::pin!(run);
2518 tokio::select! {
2519 result = &mut run => result,
2520 signal = wait_for_ctrl_signal() => {
2521 signal?;
2522 let shutdown_result = shutdown.request_shutdown().await;
2523 let run_result = run.await;
2524 shutdown_result.and(run_result)
2525 }
2526 }
2527 }
2528
2529 pub async fn run(self) -> Result<(), NodeServerError> {
2530 let NodeServer {
2531 config,
2532 mut runtime,
2533 shared,
2534 events,
2535 state_path_lock: _state_path_lock,
2536 } = self;
2537 shared.reconcile_materializations();
2538 shared.reconcile_managed_worktrees().await;
2539 shared.retire_unavailable_session_records().await;
2540 shared.reconcile_context_pack_exports().await;
2541 shared.reserve_journalled_instance_ids();
2542 {
2543 let restore = Arc::clone(&shared);
2544 tokio::spawn(async move {
2545 sleep(Duration::from_secs(3)).await;
2546 restore.restore_journalled_sessions().await;
2547 loop {
2548 restore.refresh_session_journal();
2549 if restore.shutdown.load(Ordering::Acquire) { break; }
2550 sleep(Duration::from_secs(10)).await;
2551 }
2552 });
2553 }
2554 let endpoint = config.endpoint.clone();
2558 let call_home = config.call_home;
2559 let call_home_identity = config.call_home_identity.clone();
2560 let api_listen = config.api_listen;
2561 let bridge_listen = config.bridge_listen;
2562 let bridge_underlay = config.bridge_underlay;
2563 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2564 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2565 let accept_shared = Arc::clone(&shared);
2566 let api_shared = Arc::clone(&shared);
2567 let bridge_shared = Arc::clone(&shared);
2568 let shutdown_shared = Arc::clone(&shared);
2569 let shutdown_timeout = Duration::from_millis(
2570 config.runtime.provider_shutdown_timeout_ms.max(1),
2571 );
2572 let result = {
2573 let runtime_loop = drive_runtime_until_shutdown(
2574 &mut runtime,
2575 events,
2576 Arc::clone(&shared),
2577 shutdown_timeout,
2578 );
2579 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2580 let api_loop = http_api::run(api_listen, api_shared);
2581 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2582 tokio::pin!(runtime_loop);
2583 tokio::pin!(accept_loop);
2584 tokio::pin!(api_loop);
2585 tokio::pin!(bridge_loop);
2586 tokio::select! {
2587 runtime_result = &mut runtime_loop => {
2588 let shutdown_result = shutdown_shared
2589 .begin_shutdown()
2590 .await
2591 .map_err(NodeServerError::ShutdownDispatch);
2592 let (accept_result, api_result, bridge_result) =
2593 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2594 match runtime_result {
2595 Err(error) => Err(error),
2596 Ok(()) => shutdown_result
2597 .and(accept_result)
2598 .and(api_result.map_err(NodeServerError::HttpApi))
2599 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2600 }
2601 }
2602 accept_result = &mut accept_loop => {
2603 let shutdown_result = shutdown_shared
2604 .begin_shutdown()
2605 .await
2606 .map_err(NodeServerError::ShutdownDispatch);
2607 let (runtime_result, api_result, bridge_result) =
2608 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2609 match accept_result {
2610 Err(error) => Err(error),
2611 Ok(()) => shutdown_result
2612 .and(runtime_result)
2613 .and(api_result.map_err(NodeServerError::HttpApi))
2614 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2615 }
2616 }
2617 api_result = &mut api_loop => {
2618 let shutdown_result = shutdown_shared
2619 .begin_shutdown()
2620 .await
2621 .map_err(NodeServerError::ShutdownDispatch);
2622 let (runtime_result, accept_result, bridge_result) =
2623 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2624 match api_result {
2625 Err(error) => Err(NodeServerError::HttpApi(error)),
2626 Ok(()) => shutdown_result
2627 .and(runtime_result)
2628 .and(accept_result)
2629 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2630 }
2631 }
2632 bridge_result = &mut bridge_loop => {
2633 let shutdown_result = shutdown_shared
2634 .begin_shutdown()
2635 .await
2636 .map_err(NodeServerError::ShutdownDispatch);
2637 let (runtime_result, accept_result, api_result) =
2638 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2639 match bridge_result {
2640 Err(error) => Err(NodeServerError::BridgeApi(error)),
2641 Ok(()) => shutdown_result
2642 .and(runtime_result)
2643 .and(accept_result)
2644 .and(api_result.map_err(NodeServerError::HttpApi)),
2645 }
2646 }
2647 }
2648 };
2649 if let Ok(mut guard) = shared.delivery_store.lock() {
2656 *guard = None;
2657 }
2658 if let Ok(mut guard) = shared.context_pack_store.lock() {
2659 *guard = None;
2660 }
2661 result
2662 }
2663}
2664
2665async fn drive_runtime_until_shutdown(
2666 runtime: &mut NativeRuntime,
2667 events: EventSubscription,
2668 shared: Arc<NodeShared>,
2669 shutdown_timeout: Duration,
2670) -> Result<(), NodeServerError> {
2671 let _ = shared
2675 .shell_efficiency_profile
2676 .set(runtime.shell_efficiency_profile());
2677 let mut shutdown_started = None;
2678 let mut last_retention_sweep = Instant::now();
2679 let mut last_provisional_reap = Instant::now();
2680 loop {
2681 let runtime_tick_start = Instant::now();
2688 let tick_result = runtime.tick().await;
2689 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2690
2691 let event_drain_start = Instant::now();
2692 for outcome in &tick_result.command_outcomes {
2700 if let Err(error) = &outcome.result {
2701 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2702 tracing::warn!(
2703 command_id = outcome.command_id.0,
2704 cause = %error,
2705 "control command rejected"
2706 );
2707 shared.record_rejected_command(
2708 outcome.command_id,
2709 error.to_string(),
2710 error.is_unsupported_transport(),
2711 );
2712 }
2713 }
2714 let mut events_drained: u64 = 0;
2715 while let Ok(event) = events.try_recv() {
2716 events_drained += 1;
2717 let clean_exit = matches!(
2718 event.event,
2719 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2720 );
2721 let instance_id = event.instance_id;
2722 let generation = event.generation;
2723 if clean_exit {
2735 if let Some((record_id, session)) =
2736 shared.managed_record_export_target_for_instance(instance_id, generation)
2737 {
2738 let export_shared = Arc::clone(&shared);
2739 tokio::spawn(async move {
2740 export_shared
2741 .reconcile_context_pack_export_for_record(&record_id, session)
2742 .await;
2743 });
2744 }
2745 }
2746 shared.publish_control(event);
2747 }
2748 let event_drain_elapsed = event_drain_start.elapsed();
2749
2750 let publish_terminal_frames_start = Instant::now();
2751 shared.publish_terminal_frames();
2752 shared.run_due_provider_retries();
2753 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2754
2755 shared
2761 .native_session_gauge
2762 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2763 *shared
2764 .runtime_tick_profile
2765 .lock()
2766 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2767
2768 let remainder_start = Instant::now();
2769 if shared.shutdown.load(Ordering::Acquire) {
2770 let started = *shutdown_started.get_or_insert_with(Instant::now);
2771 let snapshot = shared.handle.snapshot();
2772 let sessions_settled = snapshot.sessions.iter().all(|session| {
2773 !matches!(
2774 session.status,
2775 gate4agent_types::SessionStatus::Starting
2776 | gate4agent_types::SessionStatus::Running
2777 | gate4agent_types::SessionStatus::Stopping
2778 )
2779 });
2780 if runtime.active_native_sessions() == 0 && sessions_settled {
2781 return Ok(());
2782 }
2783 if started.elapsed() >= shutdown_timeout {
2784 return Err(NodeServerError::ShutdownTimedOut {
2785 active_native_sessions: runtime.active_native_sessions(),
2786 });
2787 }
2788 }
2789 let remainder_elapsed = remainder_start.elapsed();
2790
2791 {
2792 let mut profile = shared
2793 .drive_loop_profile
2794 .lock()
2795 .unwrap_or_else(|poisoned| poisoned.into_inner());
2796 profile.record_runtime_tick(runtime_tick_elapsed);
2797 profile.record_event_drain(event_drain_elapsed);
2798 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2799 profile.record_remainder(remainder_elapsed);
2800 profile.note_iteration(Instant::now());
2801 }
2802
2803 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2815 if tick_result.observations_applied == 0 && events_drained == 0 {
2816 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2817 }
2818
2819 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2824 shared.retire_unavailable_session_records().await;
2825 last_retention_sweep = Instant::now();
2826 }
2827 if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2829 for address in shared.take_due_provisional_spawns() {
2830 shared.reap_provisional_spawn(address).await;
2831 }
2832 last_provisional_reap = Instant::now();
2833 }
2834
2835 let hot = shared
2838 .pty_input_marks
2839 .lock()
2840 .unwrap_or_else(|poisoned| poisoned.into_inner())
2841 .values()
2842 .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2843 if hot {
2844 sleep(Duration::from_millis(1)).await;
2845 } else {
2846 tokio::select! {
2847 _ = sleep(Duration::from_millis(10)) => {}
2848 _ = shared.pty_input_wake.notified() => {}
2849 }
2850 }
2851 }
2852}
2853
2854#[derive(Clone)]
2855pub struct NodeShutdownHandle {
2856 shared: Arc<NodeShared>,
2857}
2858
2859impl NodeShutdownHandle {
2860 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2861 self.shared
2862 .begin_shutdown()
2863 .await
2864 .map_err(NodeServerError::ShutdownDispatch)
2865 }
2866}
2867
2868async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2869 #[cfg(windows)]
2870 {
2871 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2872 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2873 return tokio::select! {
2874 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2875 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2876 };
2877 }
2878 #[cfg(unix)]
2879 {
2880 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2881 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2882 tokio::select! {
2883 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2884 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2885 }
2886 }
2887}
2888
2889fn shell_host_spec() -> AgentSpec {
2903 #[cfg(windows)]
2907 let (detect_command, program, fixed_args, process_name) = {
2908 let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2909 r"C:\Windows\System32\cmd.exe".to_owned()
2910 });
2911 ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2912 };
2913 #[cfg(not(windows))]
2914 let (detect_command, program, fixed_args, process_name) = (
2915 "bash".to_owned(),
2916 "/bin/bash".to_owned(),
2917 vec!["-i".to_owned()],
2918 "bash".to_owned(),
2919 );
2920 AgentSpec {
2921 id: AgentId::new("shell").expect("shell agent id"),
2922 revision: "hatchery:shell-pty-1".to_owned(),
2923 display_name: "Host shell".to_owned(),
2924 detection: DetectionSpec {
2925 command: detect_command,
2926 aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2927 required_commands: Vec::new(),
2928 unsupported_platforms: Vec::new(),
2929 },
2930 launch: LaunchSpec {
2931 program,
2932 fixed_args,
2933 },
2934 expected_processes: vec![ProcessMatcher::Exact {
2935 name: process_name,
2936 }],
2937 prompt: PromptSpec {
2938 initial: InitialPromptMode::None,
2939 native_draft: None,
2940 },
2941 readiness: AgentReadinessSpec::default(),
2942 capabilities: AgentCapabilities {
2943 agent_commands: None,
2944 transports: AgentTransportCapabilities {
2945 pty: true,
2946 pty_adapter: None,
2947 pipe: None,
2948 acp: None,
2949 },
2950 adapters: AgentAdapterCapabilities::default(),
2951 },
2952 verification: SpecVerification::Gate4AgentVerified,
2953 }
2954}
2955
2956fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2957 let mut specs = vec![shell_host_spec()];
2958 specs.extend(
2959 builtin_registry()
2960 .iter()
2961 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2962 .cloned(),
2963 );
2964 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2965}
2966
2967fn provider_contract_manifest(
2968 catalog: &AgentRegistry,
2969) -> Result<
2970 (
2971 Vec<ProviderContractSupport>,
2972 Vec<ProviderAdapterContractSupport>,
2973 ),
2974 NodeServerError,
2975> {
2976 let mut provider_contracts = Vec::new();
2977 let mut provider_adapter_contracts = Vec::new();
2978 for spec in catalog.iter() {
2979 let provider = spec.id.clone();
2980 let revision = ProviderContractRevision::new(spec.revision.clone())
2981 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2982 provider_contracts.push(ProviderContractSupport {
2983 provider: provider.clone(),
2984 revision,
2985 });
2986 for (family, binding) in declared_adapter_bindings(spec)? {
2987 let revision = AdapterContractRevision::new(binding.revision.clone())
2988 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2989 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2990 provider: provider.clone(),
2991 family,
2992 adapter_id: binding.id.clone(),
2993 revision,
2994 });
2995 }
2996 }
2997 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2998 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2999 Ok((provider_contracts, provider_adapter_contracts))
3000}
3001
3002fn declared_adapter_bindings(
3003 spec: &AgentSpec,
3004) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
3005 let mut bindings = Vec::new();
3006 push_declared_binding(
3007 &mut bindings,
3008 AdapterFamily::PtySemantic,
3009 spec.capabilities.transports.pty_adapter.as_ref(),
3010 )?;
3011 push_declared_binding(
3012 &mut bindings,
3013 AdapterFamily::Pipe,
3014 spec.capabilities.adapters.pty_sidecar.as_ref(),
3015 )?;
3016 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3017 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3018 gate4agent_types::PipeProtocol::SemanticNdjson
3019 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3020 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3021 });
3022 push_declared_binding(
3023 &mut bindings,
3024 AdapterFamily::Pipe,
3025 pipe_transport
3026 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3027 .map(|transport| &transport.adapter),
3028 )?;
3029 push_declared_binding(
3030 &mut bindings,
3031 AdapterFamily::Acp,
3032 spec.capabilities
3033 .transports
3034 .acp
3035 .as_ref()
3036 .map(|transport| &transport.adapter),
3037 )?;
3038 push_declared_binding(
3039 &mut bindings,
3040 AdapterFamily::Hook,
3041 spec.capabilities.adapters.hook.as_ref(),
3042 )?;
3043 push_declared_binding(
3044 &mut bindings,
3045 AdapterFamily::ManagedHook,
3046 spec.capabilities.adapters.managed_hook.as_ref(),
3047 )?;
3048 push_declared_binding(
3049 &mut bindings,
3050 AdapterFamily::OneShot,
3051 pipe_transport
3052 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3053 .map(|transport| &transport.adapter),
3054 )?;
3055 push_declared_binding(
3056 &mut bindings,
3057 AdapterFamily::OneShot,
3058 spec.capabilities.adapters.one_shot.as_ref(),
3059 )?;
3060 push_declared_binding(
3061 &mut bindings,
3062 AdapterFamily::History,
3063 spec.capabilities.adapters.history.as_ref(),
3064 )?;
3065 push_declared_binding(
3066 &mut bindings,
3067 AdapterFamily::Resume,
3068 spec.capabilities.adapters.resume.as_ref(),
3069 )?;
3070 push_declared_binding(
3071 &mut bindings,
3072 AdapterFamily::SessionOptions,
3073 spec.capabilities.adapters.session_options.as_ref(),
3074 )?;
3075 push_declared_binding(
3076 &mut bindings,
3077 AdapterFamily::CapabilityProbe,
3078 spec.capabilities.adapters.capability_probe.as_ref(),
3079 )?;
3080 Ok(bindings)
3081}
3082
3083fn push_declared_binding<'a>(
3084 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3085 family: AdapterFamily,
3086 binding: Option<&'a AdapterBinding>,
3087) -> Result<(), NodeServerError> {
3088 let Some(binding) = binding else {
3089 return Ok(());
3090 };
3091 if let Some((_, existing)) = bindings
3092 .iter()
3093 .find(|(existing_family, _)| *existing_family == family)
3094 {
3095 if *existing == binding {
3096 return Ok(());
3097 }
3098 return Err(NodeServerError::ProviderContractManifest(format!(
3099 "provider declares conflicting {family:?} adapter bindings"
3100 )));
3101 }
3102 bindings.push((family, binding));
3103 Ok(())
3104}
3105
3106fn merge_durable_state(
3107 configured: &[WorkspaceConfig],
3108 mut loaded: LoadedNodeState,
3109) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3110 let mut workspaces = configured
3111 .iter()
3112 .cloned()
3113 .map(|workspace| (workspace.workspace_id.clone(), workspace))
3114 .collect::<BTreeMap<_, _>>();
3115 let mut roots = workspaces
3116 .values()
3117 .map(|workspace| {
3118 (
3119 platform::root_identity(&workspace.canonical_root),
3120 workspace.workspace_id.clone(),
3121 )
3122 })
3123 .collect::<BTreeMap<_, _>>();
3124 let mut warning = loaded
3125 .warning
3126 .take()
3127 .map(|message| session_registry::sanitized_persistence_summary(&message));
3128
3129 for (workspace_id, canonical_root) in loaded.workspaces {
3130 if let Some(existing) = workspaces.get(&workspace_id) {
3131 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3132 return Err(NodeServerError::DurableState(io::Error::new(
3133 io::ErrorKind::InvalidData,
3134 DURABLE_STATE_CONFLICT_ERROR,
3135 )));
3136 }
3137 continue;
3138 }
3139 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3140 return Err(NodeServerError::DurableState(io::Error::new(
3141 io::ErrorKind::InvalidData,
3142 DURABLE_STATE_CONFLICT_ERROR,
3143 )));
3144 }
3145 if !Path::new(&canonical_root).is_dir() {
3146 warning.get_or_insert_with(|| {
3147 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3148 });
3149 continue;
3150 }
3151 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3152 NodeServerError::DurableState(io::Error::new(
3153 io::ErrorKind::InvalidData,
3154 DURABLE_STATE_CONFLICT_ERROR,
3155 ))
3156 })?;
3157 roots.insert(
3158 platform::root_identity(&workspace.canonical_root),
3159 workspace.workspace_id.clone(),
3160 );
3161 workspaces.insert(workspace_id, workspace);
3162 }
3163
3164 for record in &mut loaded.records {
3165 match workspaces.get(&record.workspace_id) {
3166 Some(workspace)
3167 if platform::roots_equal(
3168 &workspace.canonical_root,
3169 windows_path_text(&record.canonical_root),
3170 ) => {}
3171 Some(_) => {
3172 return Err(NodeServerError::DurableState(io::Error::new(
3173 io::ErrorKind::InvalidData,
3174 DURABLE_STATE_CONFLICT_ERROR,
3175 )));
3176 }
3177 None => {
3178 record.state = ManagedSessionState::Unavailable;
3179 record.active_session = None;
3180 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3181 }
3182 }
3183 }
3184 Ok((workspaces.into_values().collect(), loaded.records, warning))
3185}
3186
3187struct RejectedCommandReason {
3190 message: String,
3191 unsupported_transport: bool,
3198 expires_at: Instant,
3199}
3200
3201struct NodeShared {
3202 handle: Gate4AgentHandle,
3203 access_token: String,
3204 node_id: NodeId,
3205 incarnation_id: NodeIncarnationId,
3206 started_at_unix_ms: u64,
3207 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3208 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3209 managed_worktree_profiles:
3210 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3211 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3212 enabled_providers: Vec<AgentId>,
3213 provider_runtime_statuses: ProviderRuntimeStatuses,
3214 provider_runtime_status_updates:
3215 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3216 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3217 provider_contracts: Vec<ProviderContractSupport>,
3218 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3219 spawn_profiles: SpawnProfileRegistry,
3220 environment_profiles:
3221 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3222 environment_materialization_profiles:
3223 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3224 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3232 #[cfg(feature = "dig2-station-probe")]
3236 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3237 bundle_catalog: RwLock<BundleCatalog>,
3238 delivery_store: Mutex<Option<DeliveryStore>>,
3239 context_pack_store: Mutex<Option<ContextPackStore>>,
3240 context_catalog: RwLock<ContextPackCatalog>,
3241 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3242 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3243 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3244 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3245 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3247 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3249 m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3251 m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3253 grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3255 #[cfg(unix)]
3258 signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3259 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3260 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3261 managed_spawn_replays:
3262 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3263 controller: Mutex<Option<ControllerLease>>,
3264 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3267 pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3269 history: Mutex<NodeEventHistory>,
3270 event_tx: broadcast::Sender<NodeEventEnvelope>,
3271 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3272 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3280 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3289 terminal_frame_watermarks:
3290 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3291 pty_input_wake: tokio::sync::Notify,
3293 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3296 provider_errors: Mutex<crate::provider_error::Tracker>,
3298 next_connection_id: AtomicU64,
3299 next_instance_id: AtomicU64,
3300 next_command_id: AtomicU64,
3301 shutdown: AtomicBool,
3302 shutdown_notify: Notify,
3303 preauth_slots: Arc<Semaphore>,
3304 authenticated_slots: Arc<Semaphore>,
3305 inspection_slots: Arc<Semaphore>,
3306 mutation_gate: AsyncMutex<()>,
3307 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3308 session_records: Mutex<SessionRecords>,
3309 state_path: Option<PathBuf>,
3310 persistence_error: RwLock<Option<String>>,
3311 state_transaction: Mutex<()>,
3312 input_settle_timeout_ms: u64,
3313 drive_loop_profile: Mutex<DriveLoopProfiler>,
3317 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3323 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3335 native_session_gauge: AtomicUsize,
3342 connection_loop_iterations: AtomicU64,
3353 connection_events_sent: AtomicU64,
3354 rejected_commands_total: AtomicU64,
3363 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3383 drive_loop_iterations_total: AtomicU64,
3384 drive_loop_iterations_idle: AtomicU64,
3385 session_record_retention: SessionRecordRetentionConfig,
3389 retired_records_total: AtomicU64,
3395 #[cfg(feature = "fixture")]
3396 fixture_semantic_hook_policy: bool,
3397 #[cfg(feature = "fixture")]
3398 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3399}
3400
3401impl Drop for NodeShared {
3402 fn drop(&mut self) {
3403 SessionLifecycleHook::on_shutdown(self);
3404 }
3405}
3406
3407#[derive(Clone, Debug, Eq, PartialEq)]
3408struct SessionBinding {
3409 workspace_id: WorkspaceId,
3410 generation: SessionGeneration,
3411 runtime_policy: ProviderRuntimePolicy,
3412 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3413 record_id: Option<SessionRecordId>,
3414 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3415 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3416 bundle: Option<ResolvedBundleReceipt>,
3417 context: Option<ResolvedContextPackReceipt>,
3418 materialization_id: Option<MaterializationId>,
3419}
3420
3421#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3422enum SpawnRecordPolicy {
3423 ProviderIdentityOnly,
3424 Always,
3425}
3426
3427struct NativeEnvironmentSelectionGuard {
3428 control: Option<NativeLaunchProfileControl>,
3429 instance_id: AgentInstanceId,
3430}
3431
3432struct NativeInstanceOverlayGuard {
3433 control: Option<NativeLaunchProfileControl>,
3434 instance_id: AgentInstanceId,
3435}
3436
3437struct NativeHarnessMcpOverlayGuard {
3438 control: NativeLaunchProfileControl,
3439 instance_id: AgentInstanceId,
3440}
3441
3442impl NativeHarnessMcpOverlayGuard {
3443 fn retain(self) { std::mem::forget(self); }
3444}
3445
3446impl Drop for NativeHarnessMcpOverlayGuard {
3447 fn drop(&mut self) {
3448 self.control
3449 .clear_native_mcp_server_launch_overlay(self.instance_id);
3450 }
3451}
3452
3453impl NativeInstanceOverlayGuard {
3454 fn retain(mut self) {
3455 self.control = None;
3456 }
3457}
3458
3459impl Drop for NativeInstanceOverlayGuard {
3460 fn drop(&mut self) {
3461 if let Some(control) = self.control.as_ref() {
3462 control.clear_native_instance_launch_overlay(self.instance_id);
3463 }
3464 }
3465}
3466
3467enum PreparedNativeLaunchOverlay {
3468 Environment(NativeLaunchEnvironmentOverlay),
3469 Instance(NativeInstanceLaunchOverlay),
3470}
3471
3472impl NativeEnvironmentSelectionGuard {
3473 fn retain(mut self) {
3474 self.control = None;
3475 }
3476}
3477
3478impl Drop for NativeEnvironmentSelectionGuard {
3479 fn drop(&mut self) {
3480 if let Some(control) = self.control.as_ref() {
3481 control.clear_native_launch_profile_selection(self.instance_id);
3482 }
3483 }
3484}
3485
3486struct SessionMaterializationGuard<'a> {
3487 shared: &'a NodeShared,
3488 id: Option<MaterializationId>,
3489 provider_home: Option<PathBuf>,
3492}
3493
3494impl SessionMaterializationGuard<'_> {
3495 fn id(&self) -> Option<&MaterializationId> {
3496 self.id.as_ref()
3497 }
3498
3499 fn provider_home(&self) -> Option<&Path> {
3500 self.provider_home.as_deref()
3501 }
3502
3503 fn retain(mut self) {
3504 self.id = None;
3505 }
3506}
3507
3508impl Drop for SessionMaterializationGuard<'_> {
3509 fn drop(&mut self) {
3510 if let Some(id) = self.id.take() {
3511 let _ = self.shared.cleanup_materialization(&id);
3512 }
3513 }
3514}
3515
3516
3517trait SessionLifecycleHook {
3520 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3522 fn on_session_ended(&self, address: &SessionAddress);
3524 fn on_shutdown(&self);
3526 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3528 fn cleanup_session_materialization(
3530 &self,
3531 address: &SessionAddress,
3532 binding: &SessionBinding,
3533 ) -> Result<(), NodeFailure>;
3534}
3535
3536impl SessionLifecycleHook for NodeShared {
3537 fn on_session_ended(&self, address: &SessionAddress) {
3538 self.retire_session_grok_leader(address.session.instance_id);
3539 if !self.shutdown.load(Ordering::Acquire) {
3543 if let Some(file) = self.session_resume_journal() {
3544 let id = address.session.instance_id.0;
3545 let revision = crate::pty_resume::load(&file).iter()
3546 .find(|entry| entry.instance_id == id).map(|entry| entry.revision);
3547 let _ = std::thread::Builder::new().name("resume-forget".into()).spawn(move || {
3548 std::thread::sleep(std::time::Duration::from_secs(5));
3549 if let Some(revision) = revision {
3550 crate::pty_resume::remove_revision(&file, id, revision);
3551 }
3552 });
3553 }
3554 }
3555 if let Some(accounts) = self.m4a_accounts.as_ref() {
3556 let instance = address.session.instance_id;
3557 let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3558 for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3559 accounts.remove_session_file(nick);
3560 }
3561 }
3562 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3563 registry.revoke_session(address);
3564 }
3565 if let Some(control) = self.native_launch_profile_control.as_ref() {
3566 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3567 }
3568 }
3569
3570 fn on_shutdown(&self) {
3571 let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3572 .into_values()
3573 .collect();
3574 for socket in leaders {
3575 crate::grok_leader::retire(&socket);
3576 }
3577 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3578 let instances = registry.shutdown();
3579 if let Some(control) = self.native_launch_profile_control.as_ref() {
3580 for instance_id in instances {
3581 control.clear_native_mcp_server_launch_overlay(instance_id);
3582 }
3583 }
3584 }
3585 }
3586
3587 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3588 if let Some(control) = self.native_launch_profile_control.as_ref() {
3589 control.clear_native_mcp_server_launch_overlay(instance_id);
3590 }
3591 }
3592
3593 fn cleanup_session_materialization(
3594 &self,
3595 address: &SessionAddress,
3596 binding: &SessionBinding,
3597 ) -> Result<(), NodeFailure> {
3598 self.cleanup_session_owned_materialization(address, binding)
3599 }
3600
3601 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3602 #[cfg(feature = "dig2-station-probe")]
3605 {
3606 crate::dig2_station_lease::release_lease_for_holder(
3607 &self.browser_station_leases,
3608 address.session.instance_id,
3609 );
3610 }
3611 if let Some(control) = self.native_launch_profile_control.as_ref() {
3612 control.clear_native_mcp_server_launch_overlay(
3613 address.session.instance_id,
3614 );
3615 }
3616 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3617 registry.revoke_session(address);
3618 }
3619 if removed
3620 .is_some_and(|binding| {
3621 binding.environment_profile.is_some()
3622 || binding.bundle.is_some()
3623 || binding.context.is_some()
3624 })
3625 {
3626 if let Some(control) = self.native_launch_profile_control.as_ref() {
3627 control.clear_native_launch_profile_selection(
3628 address.session.instance_id,
3629 );
3630 control.clear_native_instance_launch_overlay(
3631 address.session.instance_id,
3632 );
3633 }
3634 }
3635 }
3636}
3637
3638struct SpawnPrepareRequest<'r> {
3639 instance_id: AgentInstanceId,
3640 address: &'r SessionAddress,
3641 provider: &'r AgentId,
3642 mode: SessionMode,
3643 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3644 bundle: Option<&'r ResolvedBundleReceipt>,
3645 context: Option<&'r ResolvedContextPackReceipt>,
3646 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3647 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3648 approval_level: ApprovalLevel,
3649 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3650 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3651}
3652
3653struct PreparedSpawn<'a> {
3657 instance_overlay: Option<NativeInstanceOverlayGuard>,
3658 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3659 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3660 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3661 #[cfg(feature = "dig2-station-probe")]
3662 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3663 materialization_id: Option<MaterializationId>,
3664}
3665
3666impl PreparedSpawn<'_> {
3667 fn retain_session_guards(&mut self) {
3669 if let Some(selection) = self.environment_selection.take() {
3670 selection.retain();
3671 }
3672 if let Some(overlay) = self.instance_overlay.take() {
3673 overlay.retain();
3674 }
3675 if let Some(guard) = self.materialization_guard.take() {
3676 guard.retain();
3677 }
3678 }
3679
3680 fn retain_launch_guards(&mut self) {
3682 if let Some(overlay) = self.harness_mcp_overlay.take() {
3683 overlay.retain();
3684 }
3685 #[cfg(feature = "dig2-station-probe")]
3686 if let Some(guard) = self.browser_station_lease_guard.take() {
3687 guard.retain();
3688 }
3689 }
3690}
3691
3692trait SpawnPreparer {
3696 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3697 fn select_environment(
3699 &self,
3700 instance_id: AgentInstanceId,
3701 provider: &AgentId,
3702 mode: SessionMode,
3703 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3704 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3705 fn install_launch_overlay(
3707 &self,
3708 instance_id: AgentInstanceId,
3709 overlay: PreparedNativeLaunchOverlay,
3710 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3711}
3712
3713impl NodeShared {
3714 fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3717 let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3718 let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3719 self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3720 if let Some(file) = self.session_resume_journal() {
3721 crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3722 }
3723 key
3724 }
3725
3726 fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3727 let accounts = self.m4a_accounts.as_ref()?;
3728 let nick = self.m4a_nick_of(instance_id)?;
3729 match accounts.session_workdir(root, provider.as_str(), &nick) {
3730 Ok(dir) => dir,
3731 Err(error) => {
3732 tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3733 None
3734 }
3735 }
3736 }
3737
3738 fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3739 self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3740 }
3741
3742 fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3745 let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3746 for record in records.records.values_mut() {
3747 if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3748 record.display_name = nick.to_owned();
3749 }
3750 }
3751 }
3752
3753 fn m4a_session_overlay(
3756 &self,
3757 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3758 m4a_provider: &str,
3759 provider: &AgentId,
3760 instance_id: AgentInstanceId,
3761 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3762 session_key: &str,
3763 mode: SessionMode,
3764 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3765 let session_key = session_key.to_owned();
3766 if mode == SessionMode::Acp {
3767 return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3768 }
3769 Ok( match accounts.provision(m4a_provider, &session_key) {
3770 Ok(account) => {
3771 crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3772 let mut identity = account.env_mutations();
3773 identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3774 let merged = match environment_overlay {
3775 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3776 .map(PreparedNativeLaunchOverlay::Instance),
3777 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3778 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3779 }
3780 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3781 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3782 }
3783 }
3784 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3785 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3786 let mut merged = merged;
3787 match accounts.write_session_file(&account) {
3788 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3789 Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3790 Ok(args) if !args.is_empty() => {
3791 merged = match merged {
3792 PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3793 .append_extra_args(args)
3794 .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3795 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3796 PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3797 .into_instance_with_extra_args(args)
3798 .map(PreparedNativeLaunchOverlay::Instance)
3799 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3800 };
3801 tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3802 }
3803 Ok(_) => {}
3804 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3805 },
3806 }
3807 self.m4a_live
3808 .lock()
3809 .unwrap_or_else(|p| p.into_inner())
3810 .insert(account.nick.clone(), (instance_id, provider.clone()));
3811 self.name_record_after_nick(instance_id, &account.nick);
3812 crate::git_watch::bind_nick(instance_id.0, &account.nick);
3813 tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3814 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3816 Some(merged)
3817 }
3818 Err(error) => {
3819 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3820 environment_overlay
3821 }
3822 })
3823 }
3824}
3825
3826impl NodeShared {
3827 fn m4a_acp_mcp_overlay(
3830 &self,
3831 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3832 m4a_provider: &str,
3833 provider: &AgentId,
3834 instance_id: AgentInstanceId,
3835 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3836 session_key: &str,
3837 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3838 let Some(control) = self.native_launch_profile_control.as_ref() else {
3839 tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3840 return Ok(environment_overlay);
3841 };
3842 let account = match accounts.provision(m4a_provider, session_key) {
3843 Ok(a) => a,
3844 Err(error) => {
3845 tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3846 return Ok(environment_overlay);
3847 }
3848 };
3849 let session_file = match accounts.write_session_file(&account) {
3850 Ok(p) => p,
3851 Err(error) => {
3852 tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3853 return Ok(environment_overlay);
3854 }
3855 };
3856 let env_entries: Vec<(OsString, OsString)> = account
3857 .env_mutations()
3858 .into_iter()
3859 .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3860 .filter_map(|m| m.value.map(|v| (m.key, v)))
3861 .collect();
3862 let args: Vec<OsString> = vec![
3863 "--session-file".into(),
3864 session_file.into_os_string(),
3865 "mcp".into(),
3866 "--provider".into(),
3867 provider.as_str().into(),
3868 ];
3869 let overlay = NativeMcpServerLaunchOverlay::new(
3870 provider.clone(),
3871 crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3872 accounts.bin().as_os_str().to_os_string(),
3873 args,
3874 env_entries,
3875 Vec::new(),
3876 );
3877 match overlay.map_err(|e| e.to_string()).and_then(|o| control.install_native_mcp_server_launch_overlay(instance_id, o).map_err(|e| e.to_string())) {
3878 Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3879 Err(error) => {
3880 tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3881 return Ok(environment_overlay);
3882 }
3883 }
3884 self.m4a_live
3885 .lock()
3886 .unwrap_or_else(|p| p.into_inner())
3887 .insert(account.nick.clone(), (instance_id, provider.clone()));
3888 self.name_record_after_nick(instance_id, &account.nick);
3889 crate::git_watch::bind_nick(instance_id.0, &account.nick);
3890 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3891 Ok(environment_overlay)
3892 }
3893}
3894
3895impl SpawnPreparer for NodeShared {
3896 fn select_environment(
3897 &self,
3898 instance_id: AgentInstanceId,
3899 provider: &AgentId,
3900 mode: SessionMode,
3901 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3902 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3903 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3904 }
3905
3906 fn install_launch_overlay(
3907 &self,
3908 instance_id: AgentInstanceId,
3909 overlay: PreparedNativeLaunchOverlay,
3910 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3911 self.install_prepared_launch_overlay(instance_id, overlay)
3912 }
3913
3914 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3915 let SpawnPrepareRequest {
3916 instance_id,
3917 address,
3918 provider,
3919 mode,
3920 environment_profile,
3921 bundle,
3922 context,
3923 managed_authority,
3924 harness_mcp,
3925 approval_level,
3926 network_allowlist,
3927 browser_profile_id,
3928 } = request;
3929 #[cfg(feature = "dig2-station-probe")]
3934 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3935 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3936 match acquire_lease(
3937 &self.browser_station_leases,
3938 profile_id,
3939 instance_id,
3940 unix_time_ms(),
3941 ) {
3942 Ok(guard) => Some(guard),
3943 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3944 return Err(failure(
3945 NodeFailureCode::BrowserStationProfileBusy,
3946 "dig2browser station profile is already leased by another session",
3947 ));
3948 }
3949 Err(BrowserStationLeaseError::CapacityExceeded) => {
3950 return Err(failure(
3951 NodeFailureCode::BrowserStationProfileBusy,
3952 "dig2browser station profile lease capacity exhausted",
3953 ));
3954 }
3955 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3956 return Err(failure(
3957 NodeFailureCode::BrowserStationProfileBusy,
3958 "session already holds a dig2browser station profile lease",
3959 ));
3960 }
3961 }
3962 } else {
3963 None
3964 };
3965 #[cfg(not(feature = "dig2-station-probe"))]
3966 let _ = browser_profile_id;
3967 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3968 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3977 {
3978 return Err(failure(
3979 NodeFailureCode::BindingMismatch,
3980 "harness MCP reservation does not match the exact PTY/ACP provider",
3981 ));
3982 }
3983 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3984 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3985 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3986 })?;
3987 let launch = prepared.launch();
3992 let mut env_entries = vec![
3993 (
3994 OsString::from(launch.endpoint_env.as_str()),
3995 prepared.endpoint().as_os_str().to_os_string(),
3996 ),
3997 (
3998 OsString::from(launch.token_env.as_str()),
3999 prepared.token().expose().into(),
4000 ),
4001 (
4002 OsString::from(launch.program_env.as_str()),
4003 prepared.helper_program().as_os_str().to_os_string(),
4004 ),
4005 ];
4006 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
4007 env_entries.push(trace_entry);
4008 }
4009 if let Some(door) = crate::door::door_listen_addr() {
4010 env_entries.push((
4011 OsString::from("HATCHERY_NODE_DOOR"),
4012 OsString::from(door.to_string()),
4013 ));
4014 let session_key = format!(
4018 "pty-{}-{}-{}",
4019 self.incarnation_id,
4020 address.workspace_id.as_str(),
4021 address.session.instance_id.0
4022 );
4023 env_entries.push((
4024 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
4025 OsString::from(session_key),
4026 ));
4027 }
4028 let overlay = NativeMcpServerLaunchOverlay::new(
4029 provider.clone(),
4030 launch.server_name.as_str(),
4031 prepared.helper_program().as_os_str().to_os_string(),
4032 launch.args.iter().map(OsString::from).collect(),
4033 env_entries,
4034 launch.scrub_env.iter().map(OsString::from).collect(),
4035 ).map_err(|_| failure(
4036 NodeFailureCode::HarnessMcpUnavailable,
4037 "harness MCP launch overlay is unavailable",
4038 ))?;
4039 control
4040 .install_native_mcp_server_launch_overlay(instance_id, overlay)
4041 .map_err(|_| failure(
4042 NodeFailureCode::HarnessMcpUnavailable,
4043 "harness MCP launch overlay could not be installed",
4044 ))?;
4045 tracing::info!(
4051 node_id = %self.node_id,
4052 instance_id = ?instance_id,
4053 provider = %provider,
4054 mode = ?mode,
4055 "harness MCP overlay installed for spawn",
4056 );
4057 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4058 } else {
4059 None
4060 };
4061 let prepared_materialization = self.prepare_session_materialization(
4062 &address,
4063 &provider,
4064 mode,
4065 environment_profile,
4066 bundle,
4067 context,
4068 managed_authority.cloned(),
4069 )?;
4070 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4071 Some((overlay, guard)) => (Some(overlay), Some(guard)),
4072 None => (None, None),
4073 };
4074 let materialization_id = materialization_guard
4075 .as_ref()
4076 .and_then(SessionMaterializationGuard::id)
4077 .cloned();
4078 let mut environment_selection = SpawnPreparer::select_environment(self,
4079 instance_id,
4080 &provider,
4081 mode,
4082 environment_profile,
4083 )?;
4084 let provider_home = materialization_guard
4087 .as_ref()
4088 .and_then(SessionMaterializationGuard::provider_home);
4089 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4090 &provider,
4091 mode,
4092 approval_level,
4093 network_allowlist,
4094 provider_home,
4095 )?;
4096 let mut network_overlay_args = network_overlay_args;
4097 {
4099 let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4100 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4101 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4102 homes.extend(std::env::split_paths(&extra));
4103 }
4104 if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4105 crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4106 network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4107 }
4108 if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4109 crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4110 }
4111 if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::journalled(provider.as_str(), mode == SessionMode::Acp) {
4113 if let Some(file) = self.session_resume_journal() {
4114 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4115 revision: 0,
4116 instance_id: instance_id.0,
4117 provider: provider.as_str().to_owned(),
4118 workspace_id: address.workspace_id.as_str().to_owned(),
4119 provider_session_id: String::new(),
4120 session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4121 .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4122 .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4123 cwd: String::new(),
4124 acp: mode == SessionMode::Acp,
4125 options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4126 cols: 0,
4127 rows: 0,
4128 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4129 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4130 });
4131 if let Some(dir) = file.parent() {
4132 crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4133 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4134 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4135 provider: Some(provider.as_str().to_owned()),
4136 ..crate::session_meta::Meta::default()
4137 });
4138 crate::session_meta::revive(dir, instance_id.0);
4139 }
4140 tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4141 }
4142 }
4143 if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4144 network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4145 }
4146 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4147 (prepared, args) if args.is_empty() => prepared,
4148 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4149 overlay.append_extra_args(args).map_err(|_| {
4150 failure(
4151 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4152 "network allowlist overlay could not merge with instance launch args",
4153 )
4154 })?;
4155 Some(PreparedNativeLaunchOverlay::Instance(overlay))
4156 }
4157 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4158 Some(PreparedNativeLaunchOverlay::Instance(
4159 overlay
4160 .into_instance_with_extra_args(args)
4161 .map_err(|_| {
4162 failure(
4163 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4164 "network allowlist overlay could not merge with environment overlay",
4165 )
4166 })?,
4167 ))
4168 }
4169 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4170 NativeInstanceLaunchOverlay::new(
4171 provider.clone(),
4172 match mode {
4173 SessionMode::Pty => TransportKind::Pty,
4174 SessionMode::Acp => TransportKind::Acp,
4175 SessionMode::Inline => TransportKind::Pipe,
4176 },
4177 Vec::new(),
4178 args,
4179 )
4180 .map_err(|_| {
4181 failure(
4182 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4183 "network allowlist overlay is invalid for this transport",
4184 )
4185 })?,
4186 )),
4187 };
4188 #[cfg(unix)]
4191 let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4193 (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4194 });
4195 #[cfg(unix)]
4196 let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4199 #[cfg(unix)]
4200 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4201 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4202 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4203 let uid = unsafe { libc_geteuid() };
4204 let handle = proxy.reserve(&session_key, m4a_provider, uid);
4205 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4206 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4207 Some(vec![
4208 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4209 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4210 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4211 ])
4212 }
4213 _ => None,
4214 };
4215 #[cfg(not(unix))]
4216 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4217 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4218 (SessionMode::Pty, _, _, Some(identity)) => {
4219 let merged = match environment_overlay {
4220 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4221 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4222 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4223 }
4224 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4225 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4226 }
4227 }
4228 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4229 Some(merged)
4230 }
4231 (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4232 let session_key = self.m4a_session_key(instance_id);
4233 self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4234 }
4235 (SessionMode::Pty, Some(_), None, _) => {
4236 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4237 environment_overlay
4238 }
4239 _ => environment_overlay,
4240 };
4241 let mut instance_overlay = environment_overlay
4242 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4243 .transpose()?
4244 .flatten();
4245 Ok(PreparedSpawn {
4246 instance_overlay,
4247 environment_selection,
4248 materialization_guard,
4249 harness_mcp_overlay,
4250 #[cfg(feature = "dig2-station-probe")]
4251 browser_station_lease_guard,
4252 materialization_id,
4253 })
4254 }
4255}
4256
4257struct SessionRecords {
4258 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4259}
4260
4261struct SpawnIdempotencyCache {
4262 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4263}
4264
4265struct SpawnIdempotencyEntry {
4266 value: SpawnIdempotencyValue,
4267 expires_at: Instant,
4268}
4269
4270enum SpawnIdempotencyValue {
4271 Standard {
4272 spec: SpawnSpec,
4273 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4274 },
4275 Managed {
4276 request: ManagedWorktreeSpawnRequest,
4277 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4278 },
4279 HarnessMcp,
4280}
4281
4282enum DurableManagedSpawnReplayDecision {
4283 Reserved,
4284 PendingLinked,
4285 Committed(ManagedWorktreeSpawnReceipt),
4286}
4287
4288impl SpawnIdempotencyValue {
4289 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4290 match self {
4291 Self::Standard { result, .. } => result
4292 .as_ref()
4293 .ok()
4294 .and_then(|receipt| receipt.context.as_ref()),
4295 Self::Managed { result, .. } => result
4296 .as_ref()
4297 .ok()
4298 .and_then(|receipt| receipt.spawn.context.as_ref()),
4299 Self::HarnessMcp => None,
4300 }
4301 .is_some_and(|receipt| &receipt.id == context_id)
4302 }
4303}
4304
4305#[derive(Clone, Copy)]
4306struct ControllerLease {
4307 connection_id: u64,
4308 expires_at: Instant,
4309}
4310
4311struct NodeEventHistory {
4312 last_sequence: u64,
4313 replay_floor_sequence: u64,
4314 events: VecDeque<NodeEventEnvelope>,
4315 record_providers: BTreeMap<SessionRecordId, AgentId>,
4316 removed_record_providers: BTreeMap<u64, AgentId>,
4317}
4318
4319impl NodeEventHistory {
4320 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4321 Self {
4322 last_sequence: 0,
4323 replay_floor_sequence: 1,
4324 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4325 record_providers,
4326 removed_record_providers: BTreeMap::new(),
4327 }
4328 }
4329}
4330
4331impl NodeShared {
4332 fn session_resume_journal(&self) -> Option<PathBuf> {
4336 self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4337 }
4338
4339 async fn restore_journalled_sessions(&self) {
4342 let Some(file) = self.session_resume_journal() else { return };
4343 if !crate::pty_resume::enabled() { return; }
4344 for e in crate::pty_resume::load(&file) {
4345 if e.provider == "codex" {
4346 if let Err(err) = self.restore_codex_session(&e).await {
4347 crate::pty_resume::upsert(&file, e.clone());
4349 tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled codex resume failed");
4350 }
4351 continue;
4352 }
4353 crate::pty_resume::remove(&file, e.instance_id);
4354 if !e.session_key.is_empty() {
4356 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4357 }
4358 let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4359 if e.acp {
4360 let record_id = {
4361 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4362 records.records.values().find(|r| r.mode == SessionMode::Acp && r.provider_session.as_ref().is_some_and(|i| !e.provider_session_id.is_empty() && i.id == e.provider_session_id)).map(|r| r.record_id.clone())
4363 };
4364 if let Some(next) = e.instance_id.checked_add(1) { self.next_instance_id.fetch_max(next, Ordering::AcqRel); }
4365 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4366 let resumed = match record_id {
4367 Some(rid) => RESTORE_INSTANCE_ID.scope(AgentInstanceId(e.instance_id), SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None))).await.map(|(_, a)| a),
4368 None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4369 };
4370 let r = match resumed {
4372 Ok(a) => Ok(a),
4373 Err(err) => {
4374 tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4375 self.next_instance_id.fetch_max(e.instance_id.saturating_add(1), Ordering::AcqRel);
4376 RESTORE_INSTANCE_ID.scope(AgentInstanceId(e.instance_id), SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None))).await
4377 }
4378 };
4379 match r {
4380 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4381 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4382 }
4383 continue;
4384 }
4385 if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4386 if let Some(next) = e.instance_id.checked_add(1) { self.next_instance_id.fetch_max(next, Ordering::AcqRel); }
4388 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4389 match RESTORE_INSTANCE_ID.scope(AgentInstanceId(e.instance_id), SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None))).await {
4390 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4391 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4392 }
4393 continue;
4394 }
4395 let folder = std::path::Path::new(&e.cwd);
4396 let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4397 let name = match (in_session_dir, folder.file_name()) {
4398 (true, Some(n)) => n.to_string_lossy().into_owned(),
4399 _ => format!("{} #{}", e.provider, e.instance_id),
4400 };
4401 let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4402 let record = match self.index_provider_session(ws, provider, identity, name) {
4403 Ok(r) => r,
4404 Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4405 };
4406 if let Some(next) = e.instance_id.checked_add(1) { self.next_instance_id.fetch_max(next, Ordering::AcqRel); }
4407 let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4408 let r = RESTORE_INSTANCE_ID.scope(AgentInstanceId(e.instance_id), SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None))).await;
4409 match r {
4410 Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4411 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4412 }
4413 }
4414 }
4415
4416 fn reserve_journalled_instance_ids(&self) {
4417 if let Some(file) = self.session_resume_journal() {
4418 for entry in crate::pty_resume::load(&file) {
4419 if let Some(next) = entry.instance_id.checked_add(1) {
4420 self.next_instance_id.fetch_max(next, Ordering::AcqRel);
4421 }
4422 }
4423 }
4424 }
4425
4426 async fn restore_codex_session(
4427 &self,
4428 entry: &crate::pty_resume::Entry,
4429 ) -> Result<(), NodeFailure> {
4430 if entry.provider_session_id.is_empty() || entry.instance_id == u64::MAX {
4431 return Err(failure(NodeFailureCode::SessionRecordNotResumable, "journal has no resumable Codex identity or slot"));
4432 }
4433 let instance_id = AgentInstanceId(entry.instance_id);
4434 if self.session_bindings.lock().unwrap_or_else(|p| p.into_inner()).contains_key(&instance_id) {
4435 return Err(failure(NodeFailureCode::SessionRecordBusy, "journalled instance is already bound"));
4436 }
4437 self.next_instance_id.fetch_max(entry.instance_id + 1, Ordering::AcqRel);
4438 let workspace = WorkspaceId::new(entry.workspace_id.clone())
4439 .map_err(|e| failure(NodeFailureCode::InvalidRequest, &e.to_string()))?;
4440 let provider = AgentId::new("codex").expect("static provider id");
4441 let identity = ProviderSessionIdentity {
4442 key: gate4agent_types::ProviderSessionKey::SessionId,
4443 id: entry.provider_session_id.clone(),
4444 transcript_path: None,
4445 };
4446 let record = self.index_provider_session(
4449 workspace, provider, identity, format!("codex #{}", entry.instance_id),
4450 )?;
4451 if !entry.session_key.is_empty() {
4452 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner())
4453 .insert(instance_id, entry.session_key.clone());
4454 }
4455 let size = gate4agent_types::TerminalSize {
4456 rows: if entry.rows > 0 { entry.rows } else { 40 },
4457 columns: if entry.cols > 0 { entry.cols } else { 120 },
4458 };
4459 RESTORE_INSTANCE_ID.scope(instance_id, SPAWN_ORIGIN.scope(entry.origin.clone(),
4460 SPAWN_PARENT.scope(entry.parent_session_id.clone(), SPAWN_SESSION_OPTIONS.scope(
4461 entry.options.clone(), self.resume_session_record(&record.record_id, size, None),
4462 )),
4463 )).await?;
4464 Ok(())
4465 }
4466
4467 fn refresh_session_journal(&self) {
4468 let Some(file) = self.session_resume_journal() else { return };
4469 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4470 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4471 crate::pty_resume::refresh(&file, &homes);
4472 let ids: Vec<(u64, String)> = {
4473 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4474 records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4475 };
4476 for (inst, sid) in ids {
4477 crate::pty_resume::set_session_id(&file, inst, &sid);
4478 }
4479 }
4480
4481 fn session_grok_leader_socket(
4482 &self,
4483 provider: &AgentId,
4484 mode: SessionMode,
4485 instance_id: AgentInstanceId,
4486 ) -> Option<std::path::PathBuf> {
4487 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4488 return None;
4489 }
4490 let root = self
4491 .m4a_accounts
4492 .as_ref()
4493 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4494 .unwrap_or_else(std::env::temp_dir);
4495 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4496 if let Some(previous) = self
4497 .grok_leaders
4498 .lock()
4499 .unwrap_or_else(|p| p.into_inner())
4500 .insert(instance_id, socket.clone())
4501 {
4502 crate::grok_leader::retire(&previous);
4503 }
4504 Some(socket)
4505 }
4506
4507 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4509 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4510 if let Some(socket) = socket {
4511 crate::grok_leader::retire(&socket);
4512 }
4513 }
4514
4515 fn new_with_incarnation(
4516 handle: Gate4AgentHandle,
4517 access_token: String,
4518 node_id: NodeId,
4519 incarnation_id: NodeIncarnationId,
4520 workspaces: Vec<WorkspaceConfig>,
4521 enabled_providers: Vec<AgentId>,
4522 provider_runtime_statuses: ProviderRuntimeStatuses,
4523 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4524 provider_contracts: Vec<ProviderContractSupport>,
4525 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4526 spawn_profiles: SpawnProfileRegistry,
4527 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4528 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4529 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4530 state_path: Option<PathBuf>,
4531 records: Vec<ManagedSessionRecord>,
4532 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4533 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4534 materializations: Vec<MaterializationOwnershipRecord>,
4535 persistence_warning: Option<String>,
4536 input_settle_timeout_ms: u64,
4537 ) -> Self {
4538 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4539 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4540 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4541 let (harness_mcp_proxy_event_tx, _) =
4542 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4543 let record_providers = records
4544 .iter()
4545 .map(|record| (record.record_id.clone(), record.provider.clone()))
4546 .collect();
4547 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4548 .iter()
4549 .map(|workspace| {
4550 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4551 })
4552 .collect();
4553 for lease in &managed_worktrees {
4554 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4555 }
4556 let managed_worktree_profiles = workspaces.iter()
4557 .map(|workspace| (
4558 workspace.workspace_id.clone(),
4559 workspace.managed_worktree_profiles.clone(),
4560 ))
4561 .collect();
4562 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4563 managed_worktrees,
4564 managed_worktree_tombstones,
4565 ).expect("durable managed worktree registry was validated while loading");
4566 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4567 managed_worktrees.reattach_record_holders(
4568 &managed_worktree_record_holders(&records),
4569 unix_time_ms(),
4570 );
4571 Self {
4572 handle,
4573 access_token,
4574 node_id,
4575 incarnation_id,
4576 started_at_unix_ms: std::time::SystemTime::now()
4577 .duration_since(std::time::UNIX_EPOCH)
4578 .unwrap_or_default()
4579 .as_millis()
4580 .min(u64::MAX as u128) as u64,
4581 workspaces: RwLock::new(
4582 workspaces
4583 .into_iter()
4584 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4585 .collect(),
4586 ),
4587 worktree_service_modes,
4588 managed_worktree_profiles,
4589 managed_worktrees: Mutex::new(managed_worktrees),
4590 enabled_providers,
4591 provider_runtime_statuses,
4592 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4593 provider_runtime_monitor,
4594 provider_contracts,
4595 provider_adapter_contracts,
4596 spawn_profiles,
4597 environment_profiles: RwLock::new(BTreeMap::new()),
4598 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4599 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4600 #[cfg(feature = "dig2-station-probe")]
4601 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4602 bundle_catalog: RwLock::new(BundleCatalog::default()),
4603 delivery_store: Mutex::new(None),
4604 context_pack_store: Mutex::new(None),
4605 context_catalog: RwLock::new(ContextPackCatalog::default()),
4606 native_launch_profile_control,
4607 harness_mcp_registry: None,
4608 native_session_catalog,
4609 session_environment_materializer,
4610 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4611 m4a_live: Mutex::new(BTreeMap::new()),
4612 m4a_keys: Mutex::new(BTreeMap::new()),
4613 m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4614 grok_leaders: Mutex::new(BTreeMap::new()),
4615 #[cfg(unix)]
4616 #[cfg(unix)]
4617 signer_proxy: std::sync::OnceLock::new(),
4618 materializations: Mutex::new(
4619 materializations
4620 .into_iter()
4621 .map(|record| (record.id().clone(), record))
4622 .collect(),
4623 ),
4624 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4625 entries: BTreeMap::new(),
4626 }),
4627 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4628 controller: Mutex::new(None),
4629 provisional_spawns: Mutex::new(BTreeMap::new()),
4630 pending_acp_options: Mutex::new(BTreeMap::new()),
4631 history: Mutex::new(NodeEventHistory::new(record_providers)),
4632 event_tx,
4633 terminal_event_tx,
4634 agent_stream_event_tx,
4635 harness_mcp_proxy_event_tx,
4636 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4637 pty_input_wake: tokio::sync::Notify::new(),
4638 pty_input_marks: Mutex::new(BTreeMap::new()),
4639 provider_errors: Mutex::new(crate::provider_error::Tracker::default()),
4640 next_connection_id: AtomicU64::new(1),
4641 next_instance_id: AtomicU64::new(1),
4642 next_command_id: AtomicU64::new(1),
4643 shutdown: AtomicBool::new(false),
4644 shutdown_notify: Notify::new(),
4645 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4646 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4647 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4648 mutation_gate: AsyncMutex::new(()),
4649 session_bindings: Mutex::new(BTreeMap::new()),
4650 session_records: Mutex::new(SessionRecords {
4651 records: records
4652 .into_iter()
4653 .map(|record| (record.record_id.clone(), record))
4654 .collect(),
4655 }),
4656 state_path,
4657 persistence_error: RwLock::new(
4658 persistence_warning
4659 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4660 ),
4661 state_transaction: Mutex::new(()),
4662 input_settle_timeout_ms,
4663 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4664 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4665 shell_efficiency_profile: OnceLock::new(),
4666 native_session_gauge: AtomicUsize::new(0),
4667 connection_loop_iterations: AtomicU64::new(0),
4668 connection_events_sent: AtomicU64::new(0),
4669 rejected_commands_total: AtomicU64::new(0),
4670 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4671 drive_loop_iterations_total: AtomicU64::new(0),
4672 drive_loop_iterations_idle: AtomicU64::new(0),
4673 session_record_retention: SessionRecordRetentionConfig::default(),
4674 retired_records_total: AtomicU64::new(0),
4675 #[cfg(feature = "fixture")]
4676 fixture_semantic_hook_policy: false,
4677 #[cfg(feature = "fixture")]
4678 fixture_spawn_managed_worktree_v2_failure_probe:
4679 SpawnManagedWorktreeV2FailureProbe::default(),
4680 }
4681 }
4682
4683 #[cfg(test)]
4684 fn new(
4685 handle: Gate4AgentHandle,
4686 access_token: String,
4687 node_id: NodeId,
4688 workspaces: Vec<WorkspaceConfig>,
4689 enabled_providers: Vec<AgentId>,
4690 ) -> Self {
4691 Self::new_with_incarnation(
4692 handle,
4693 access_token,
4694 node_id,
4695 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4696 workspaces,
4697 enabled_providers,
4698 ProviderRuntimeStatuses::default(),
4699 None,
4700 Vec::new(),
4701 Vec::new(),
4702 SpawnProfileRegistry::default(),
4703 None,
4704 None,
4705 None,
4706 None,
4707 Vec::new(),
4708 Vec::new(),
4709 Vec::new(),
4710 Vec::new(),
4711 None,
4712 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4713 )
4714 }
4715
4716 fn persistence_error(&self) -> Option<String> {
4717 self.persistence_error
4718 .read()
4719 .unwrap_or_else(|poisoned| poisoned.into_inner())
4720 .clone()
4721 }
4722
4723 async fn admit_provider_runtime(
4724 &self,
4725 provider: &AgentId,
4726 requirement: ProviderRuntimeRequirement,
4727 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4728 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4729 let pipe_transport = monitor.supports_pipe_transport(provider);
4730 let acp_transport = monitor.supports_acp_transport(provider);
4731 let provider = provider.clone();
4732 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4733 let (status, admission) = timeout(
4734 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4735 refresh,
4736 )
4737 .await
4738 .map_err(|_| failure(
4739 NodeFailureCode::BackendBusy,
4740 "provider runtime probe exceeded its bounded deadline",
4741 ))?
4742 .map_err(|_| failure(
4743 NodeFailureCode::BackendOperationFailed,
4744 "provider runtime probe task failed",
4745 ))?;
4746 if let Some(status) = status {
4747 self
4748 .provider_runtime_status_updates
4749 .write()
4750 .unwrap_or_else(|poisoned| poisoned.into_inner())
4751 .insert(status.provider().clone(), status);
4752 }
4753 admission.and_then(|policy| {
4754 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4755 })
4756 } else {
4757 crate::provider_runtime::admit_status(
4763 &self.provider_runtime_statuses,
4764 provider,
4765 false,
4766 false,
4767 requirement,
4768 )
4769 };
4770 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4771 admission.map_err(|error| match error {
4772 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4773 NodeFailureCode::BackendOperationFailed,
4774 "provider launcher is unavailable",
4775 ),
4776 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4777 NodeFailureCode::UnsupportedCapability,
4778 "provider semantic capability is not verified",
4779 ),
4780 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4781 NodeFailureCode::BackendBusy,
4782 "provider runtime probe is already in progress",
4783 ),
4784 })
4785 }
4786
4787 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4793 match self.provider_runtime_monitor.as_deref() {
4794 Some(monitor) => monitor.supports_pipe_transport(provider),
4795 None => false,
4796 }
4797 }
4798
4799 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4805 match self.provider_runtime_monitor.as_deref() {
4806 Some(monitor) => monitor.supports_acp_transport(provider),
4807 None => false,
4808 }
4809 }
4810
4811 fn admit_declared_hook_semantics(
4827 &self,
4828 provider: &AgentId,
4829 policy: ProviderRuntimePolicy,
4830 ) -> ProviderRuntimePolicy {
4831 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4832 return policy;
4833 }
4834 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4835 contract.provider == *provider && contract.family == AdapterFamily::Hook
4836 });
4837 if !declares_hook {
4838 return policy;
4839 }
4840 ProviderRuntimePolicy::new(
4841 policy.raw_pty_lifecycle,
4842 policy.semantic_readiness,
4843 policy.structured_prompt,
4844 policy.provider_session_identity,
4845 policy.semantic_resume,
4846 true,
4847 )
4848 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4849 }
4850
4851 fn resolve_network_allowlist_overlay_args(
4866 &self,
4867 provider: &AgentId,
4868 mode: SessionMode,
4869 approval_level: ApprovalLevel,
4870 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4871 provider_home: Option<&Path>,
4872 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4873 let Some(allowlist_id) = network_allowlist else {
4874 return Ok(Vec::new());
4875 };
4876 let catalog = self
4877 .network_allowlist_catalog
4878 .read()
4879 .unwrap_or_else(|poisoned| poisoned.into_inner());
4880 let Some(entry) = catalog.get(allowlist_id) else {
4881 return Err(failure(
4882 NodeFailureCode::UnknownNetworkAllowlist,
4883 "spawn network allowlist is unavailable on this node",
4884 ));
4885 };
4886
4887 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4889 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4890 |message| {
4891 failure(
4892 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4893 &message,
4894 )
4895 },
4896 )?;
4897 if !matches!(mode, SessionMode::Pty) {
4898 return Err(failure(
4899 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4900 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4901 ));
4902 }
4903 let Some(home) = provider_home else {
4904 return Err(failure(
4905 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4906 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4907 ));
4908 };
4909 let domains =
4910 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4911 if domains.is_empty() {
4912 return Err(failure(
4913 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4914 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4915 ));
4916 }
4917 let settings_path = home.join(
4918 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4919 );
4920 let json =
4921 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4922 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4923 |_| {
4924 failure(
4925 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4926 "Claude station network settings sidecar could not be written under ProviderHome",
4927 )
4928 },
4929 )?;
4930 let overlay =
4931 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4932 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4933 }
4934
4935 let Some(native) = entry.provider_native.as_ref() else {
4936 return Ok(Vec::new());
4937 };
4938 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4939 provider.as_str(),
4940 approval_level,
4941 native,
4942 )
4943 .map_err(|message| {
4944 failure(
4945 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4946 &message,
4947 )
4948 })?;
4949 if overlay.is_empty() {
4950 return Ok(Vec::new());
4951 }
4952 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4953 return Err(failure(
4954 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4955 "Codex network_access -c overlay requires PTY or ACP transport",
4956 ));
4957 }
4958 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4959 }
4960
4961 fn resolve_codex_network_access_overlay_args(
4963 &self,
4964 provider: &AgentId,
4965 mode: SessionMode,
4966 approval_level: ApprovalLevel,
4967 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4968 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4969 self.resolve_network_allowlist_overlay_args(
4970 provider,
4971 mode,
4972 approval_level,
4973 network_allowlist,
4974 None,
4975 )
4976 }
4977
4978 fn resolve_environment_profile(
4979 &self,
4980 resolved: &ResolvedSpawnSpec,
4981 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4982 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4988 let catalog = self
4989 .network_allowlist_catalog
4990 .read()
4991 .unwrap_or_else(|poisoned| poisoned.into_inner());
4992 let Some(entry) = catalog.get(allowlist_id) else {
4993 return Err(failure(
4994 NodeFailureCode::UnknownNetworkAllowlist,
4995 "spawn network allowlist is unavailable on this node",
4996 ));
4997 };
4998 if let Some(native) = entry.provider_native.as_ref() {
5001 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
5002 resolved.provider.as_str(),
5003 resolved.approval_level,
5004 native,
5005 ) {
5006 return Err(failure(
5007 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
5008 &message,
5009 ));
5010 }
5011 }
5012 }
5013 #[cfg(feature = "dig2-station-probe")]
5022 if resolved.browser_profile_id.is_some() {
5023 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
5024 match probe_station_reachable() {
5025 Ok(()) => {}
5026 Err(StationProbeError::UnsupportedPlatform)
5027 | Err(StationProbeError::InvalidPipeSuffix) => {
5028 return Err(failure(
5029 NodeFailureCode::BrowserStationProbeUnavailable,
5030 "dig2browser station probe unavailable on this platform",
5031 ));
5032 }
5033 Err(StationProbeError::Unreachable) => {
5034 return Err(failure(
5035 NodeFailureCode::BrowserStationUnreachable,
5036 "dig2browser station named-pipe is unreachable",
5037 ));
5038 }
5039 }
5040 }
5041 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
5042 return Ok(None);
5043 };
5044 let profiles = self
5045 .environment_profiles
5046 .read()
5047 .unwrap_or_else(|poisoned| poisoned.into_inner());
5048 let binding = profiles.get(profile_id).ok_or_else(|| {
5049 failure(
5050 NodeFailureCode::UnknownEnvironmentProfile,
5051 "spawn environment profile is unavailable on this node",
5052 )
5053 })?;
5054 if binding.provider != resolved.provider
5055 || binding.native_profile_id(resolved.mode).is_none()
5056 {
5057 return Err(failure(
5058 NodeFailureCode::EnvironmentProfileBindingMismatch,
5059 "spawn environment profile does not match the provider and mode",
5060 ));
5061 }
5062 Ok(Some(ResolvedEnvironmentProfileReceipt {
5066 profile_id: binding.id.clone(),
5067 profile_revision: binding.revision.clone(),
5068 network_allowlist: resolved.network_allowlist.clone(),
5069 browser_profile_id: resolved.browser_profile_id.clone(),
5070 }))
5071 }
5072
5073 fn resolve_bundle(
5074 &self,
5075 resolved: &ResolvedSpawnSpec,
5076 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5077 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
5078 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
5079 return Ok(None);
5080 };
5081 let catalog = self.bundle_catalog
5082 .read()
5083 .unwrap_or_else(|poisoned| poisoned.into_inner());
5084 let bundle = catalog.get(bundle_id).ok_or_else(|| {
5085 failure(
5086 NodeFailureCode::UnknownBundle,
5087 "spawn bundle is unavailable on this node",
5088 )
5089 })?;
5090 self.bundle_layout(
5091 &resolved.provider,
5092 resolved.mode,
5093 environment_profile,
5094 bundle,
5095 )?;
5096 Ok(Some(bundle.receipt()))
5097 }
5098
5099 fn begin_delivery_stage(
5100 &self,
5101 manifest: DeliveryBundleManifestV2,
5102 ) -> Result<NodeResponse, NodeFailure> {
5103 let mut store = self
5104 .delivery_store
5105 .lock()
5106 .unwrap_or_else(|poisoned| poisoned.into_inner());
5107 let store = store.as_mut().ok_or_else(|| {
5108 failure(
5109 NodeFailureCode::DeliveryStageStorageFailed,
5110 "node delivery store is unavailable",
5111 )
5112 })?;
5113 let begun = store.begin(manifest).map_err(delivery_failure)?;
5114 Ok(NodeResponse::DeliveryStageBegun {
5115 stage_id: begun.stage_id,
5116 manifest_digest: begun.manifest_digest,
5117 missing_blobs: begun.missing_blobs,
5118 })
5119 }
5120
5121 fn put_delivery_blob_chunk(
5122 &self,
5123 stage_id: DeliveryStageId,
5124 blob_digest: DeliveryBlobDigestV1,
5125 offset: u64,
5126 chunk_hex: DeliveryBlobChunkHexV1,
5127 ) -> Result<NodeResponse, NodeFailure> {
5128 let mut store = self
5129 .delivery_store
5130 .lock()
5131 .unwrap_or_else(|poisoned| poisoned.into_inner());
5132 let store = store.as_mut().ok_or_else(|| {
5133 failure(
5134 NodeFailureCode::DeliveryStageStorageFailed,
5135 "node delivery store is unavailable",
5136 )
5137 })?;
5138 let accepted = store
5139 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5140 .map_err(delivery_failure)?;
5141 Ok(NodeResponse::DeliveryBlobChunkAccepted {
5142 stage_id: accepted.stage_id,
5143 blob_digest: accepted.blob_digest,
5144 next_offset: accepted.next_offset,
5145 })
5146 }
5147
5148 fn commit_delivery_stage(
5149 &self,
5150 stage_id: DeliveryStageId,
5151 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5152 let mut store = self
5153 .delivery_store
5154 .lock()
5155 .unwrap_or_else(|poisoned| poisoned.into_inner());
5156 let store = store.as_mut().ok_or_else(|| {
5157 failure(
5158 NodeFailureCode::DeliveryStageStorageFailed,
5159 "node delivery store is unavailable",
5160 )
5161 })?;
5162 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5163 let mut catalog = self
5164 .bundle_catalog
5165 .write()
5166 .unwrap_or_else(|poisoned| poisoned.into_inner());
5167 let mut next_catalog = catalog.clone();
5168 next_catalog
5169 .insert_idempotent(prepared.bundle().clone())
5170 .map_err(|_| {
5171 failure(
5172 NodeFailureCode::DeliveryStageConflict,
5173 "delivery bundle identity conflicts with installed content",
5174 )
5175 })?;
5176 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5177 *catalog = next_catalog;
5178 Ok(receipt)
5179 }
5180
5181 fn abort_delivery_stage(
5182 &self,
5183 stage_id: &DeliveryStageId,
5184 ) -> Result<(), NodeFailure> {
5185 let mut store = self
5186 .delivery_store
5187 .lock()
5188 .unwrap_or_else(|poisoned| poisoned.into_inner());
5189 let store = store.as_mut().ok_or_else(|| {
5190 failure(
5191 NodeFailureCode::DeliveryStageStorageFailed,
5192 "node delivery store is unavailable",
5193 )
5194 })?;
5195 store.abort(stage_id).map_err(delivery_failure)
5196 }
5197
5198 fn resolve_context(
5199 &self,
5200 resolved: &ResolvedSpawnSpec,
5201 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5202 let Some(context_id) = resolved.context_id.as_ref() else {
5203 return Ok(None);
5204 };
5205 self.context_catalog
5206 .read()
5207 .unwrap_or_else(|poisoned| poisoned.into_inner())
5208 .get(context_id)
5209 .map(|context| context.receipt().clone())
5210 .ok_or_else(|| {
5211 failure(
5212 NodeFailureCode::UnknownContextPack,
5213 "spawn context pack is unavailable on this node",
5214 )
5215 })
5216 .map(Some)
5217 }
5218
5219 fn bundle_layout(
5220 &self,
5221 provider: &AgentId,
5222 mode: SessionMode,
5223 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5224 bundle: &NodeBundle,
5225 ) -> Result<BundleProviderLayout, NodeFailure> {
5226 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5227 failure(
5228 NodeFailureCode::BundleBindingMismatch,
5229 "spawn bundle does not match the provider and mode",
5230 )
5231 })?;
5232 if layout != BundleProviderLayout::Codex {
5233 return Ok(layout);
5234 }
5235 let profile = environment_profile.and_then(|receipt| {
5236 let profiles = self
5237 .environment_profiles
5238 .read()
5239 .unwrap_or_else(|poisoned| poisoned.into_inner());
5240 let binding = profiles.get(&receipt.profile_id)?;
5241 if binding.revision != receipt.profile_revision
5242 || &binding.provider != provider
5243 || binding.native_profile_id(mode).is_none()
5244 {
5245 return None;
5246 }
5247 self.environment_materialization_profiles
5248 .read()
5249 .unwrap_or_else(|poisoned| poisoned.into_inner())
5250 .get(&receipt.profile_id)
5251 .cloned()
5252 });
5253 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5254 return Err(failure(
5255 NodeFailureCode::BundleBindingMismatch,
5256 "Codex bundle requires an exact isolated CODEX_HOME profile",
5257 ));
5258 }
5259 Ok(layout)
5260 }
5261
5262 fn select_environment_profile(
5263 &self,
5264 instance_id: AgentInstanceId,
5265 provider: &AgentId,
5266 mode: SessionMode,
5267 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5268 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5269 let Some(environment_profile) = environment_profile else {
5270 return Ok(None);
5271 };
5272 let native_profile_id = {
5273 let profiles = self
5274 .environment_profiles
5275 .read()
5276 .unwrap_or_else(|poisoned| poisoned.into_inner());
5277 let binding = profiles
5278 .get(&environment_profile.profile_id)
5279 .ok_or_else(|| {
5280 failure(
5281 NodeFailureCode::UnknownEnvironmentProfile,
5282 "session environment profile is unavailable on this node",
5283 )
5284 })?;
5285 if &binding.revision != &environment_profile.profile_revision
5286 || &binding.provider != provider
5287 {
5288 return Err(failure(
5289 NodeFailureCode::EnvironmentProfileBindingMismatch,
5290 "session environment profile revision or provider changed",
5291 ));
5292 }
5293 binding.native_profile_id(mode).cloned().ok_or_else(|| {
5294 failure(
5295 NodeFailureCode::EnvironmentProfileBindingMismatch,
5296 "session environment profile does not support this mode",
5297 )
5298 })?
5299 };
5300 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5301 failure(
5302 NodeFailureCode::BackendOperationFailed,
5303 "native environment profile controller is unavailable",
5304 )
5305 })?;
5306 control
5307 .select_native_launch_profile(instance_id, native_profile_id)
5308 .map_err(|_| {
5309 failure(
5310 NodeFailureCode::BackendOperationFailed,
5311 "native environment profile selection failed",
5312 )
5313 })?;
5314 Ok(Some(NativeEnvironmentSelectionGuard {
5315 control: Some(control),
5316 instance_id,
5317 }))
5318 }
5319
5320 fn install_prepared_launch_overlay(
5321 &self,
5322 instance_id: AgentInstanceId,
5323 overlay: PreparedNativeLaunchOverlay,
5324 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5325 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5326 failure(
5327 NodeFailureCode::BackendOperationFailed,
5328 "native launch profile controller is unavailable",
5329 )
5330 })?;
5331 match overlay {
5332 PreparedNativeLaunchOverlay::Environment(overlay) => {
5333 control
5334 .install_native_launch_environment_overlay(instance_id, overlay)
5335 .map_err(|_| failure(
5336 NodeFailureCode::BackendOperationFailed,
5337 "session environment overlay installation failed",
5338 ))?;
5339 Ok(None)
5340 }
5341 PreparedNativeLaunchOverlay::Instance(overlay) => {
5342 control
5343 .install_native_instance_launch_overlay(instance_id, overlay)
5344 .map_err(|_| failure(
5345 NodeFailureCode::BundleBindingMismatch,
5346 "session bundle launch overlay installation failed",
5347 ))?;
5348 Ok(Some(NativeInstanceOverlayGuard {
5349 control: Some(control),
5350 instance_id,
5351 }))
5352 }
5353 }
5354 }
5355
5356 fn environment_materialization_profile(
5357 &self,
5358 environment_profile: &ResolvedEnvironmentProfileReceipt,
5359 ) -> Option<NodeSessionMaterializationProfile> {
5360 self.environment_materialization_profiles
5361 .read()
5362 .unwrap_or_else(|poisoned| poisoned.into_inner())
5363 .get(&environment_profile.profile_id)
5364 .cloned()
5365 }
5366
5367 fn materialization_profile(
5368 &self,
5369 provider: &AgentId,
5370 mode: SessionMode,
5371 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5372 bundle: Option<&ResolvedBundleReceipt>,
5373 context: Option<&ResolvedContextPackReceipt>,
5374 ) -> Result<Option<(
5375 NodeSessionMaterializationProfile,
5376 bool,
5377 Option<BundleProviderLayout>,
5378 )>, NodeFailure> {
5379 let environment = environment_profile
5380 .and_then(|receipt| self.environment_materialization_profile(receipt));
5381 let (bundle, bundle_layout) = match bundle {
5382 Some(receipt) => {
5383 let catalog = self.bundle_catalog.read()
5384 .unwrap_or_else(|poisoned| poisoned.into_inner());
5385 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5386 NodeFailureCode::UnknownBundle,
5387 "session bundle is unavailable on this node",
5388 ))?;
5389 if installed.receipt() != *receipt {
5390 return Err(failure(
5391 NodeFailureCode::BundleBindingMismatch,
5392 "session bundle revision or digest changed",
5393 ));
5394 }
5395 let layout = self.bundle_layout(
5396 provider,
5397 mode,
5398 environment_profile,
5399 installed,
5400 )?;
5401 (Some(installed.clone()), Some(layout))
5402 }
5403 None => (None, None),
5404 };
5405 let has_environment_overlay = environment.is_some() || context.is_some();
5406 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5407 (None, None, None) => None,
5408 (Some(profile), None, None) => Some(Ok(profile)),
5409 (None, Some(bundle), Some(layout)) => {
5410 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5411 }
5412 (Some(profile), Some(bundle), Some(layout)) => {
5413 Some(profile.with_bundle(bundle, layout))
5414 }
5415 _ => {
5416 return Err(failure(
5417 NodeFailureCode::BundleBindingMismatch,
5418 "session bundle provider layout is unavailable",
5419 ));
5420 }
5421 };
5422 let profile = profile
5423 .transpose()
5424 .map_err(|_| failure(
5425 NodeFailureCode::BundleMaterializationFailed,
5426 "session bundle materialization profile is invalid",
5427 ))?;
5428 let profile = match (profile, context) {
5429 (Some(profile), Some(receipt)) => {
5430 let catalog = self.context_catalog
5431 .read()
5432 .unwrap_or_else(|poisoned| poisoned.into_inner());
5433 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5434 NodeFailureCode::UnknownContextPack,
5435 "session context pack is unavailable on this node",
5436 ))?;
5437 if pack.receipt() != receipt {
5438 return Err(failure(
5439 NodeFailureCode::ContextPackMaterializationFailed,
5440 "session context pack identity changed",
5441 ));
5442 }
5443 profile.with_context(pack)
5444 }
5445 (None, Some(receipt)) => {
5446 let catalog = self.context_catalog
5447 .read()
5448 .unwrap_or_else(|poisoned| poisoned.into_inner());
5449 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5450 NodeFailureCode::UnknownContextPack,
5451 "session context pack is unavailable on this node",
5452 ))?;
5453 if pack.receipt() != receipt {
5454 return Err(failure(
5455 NodeFailureCode::ContextPackMaterializationFailed,
5456 "session context pack identity changed",
5457 ));
5458 }
5459 NodeSessionMaterializationProfile::from_context(pack)
5460 }
5461 (Some(profile), None) => Ok(profile),
5462 (None, None) => return Ok(None),
5463 }
5464 .map_err(|_| failure(
5465 NodeFailureCode::ContextPackMaterializationFailed,
5466 "session context pack materialization profile is invalid",
5467 ))?;
5468 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5469 }
5470
5471 fn prepare_session_materialization<'a>(
5472 &'a self,
5473 address: &SessionAddress,
5474 provider: &AgentId,
5475 mode: SessionMode,
5476 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5477 bundle: Option<&ResolvedBundleReceipt>,
5478 context: Option<&ResolvedContextPackReceipt>,
5479 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5480 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5481 let Some((profile, has_environment_overlay, bundle_layout)) =
5482 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5483 else {
5484 return Ok(None);
5485 };
5486 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5487 failure(
5488 NodeFailureCode::BackendOperationFailed,
5489 "session environment materializer is unavailable",
5490 )
5491 })?;
5492 let materialization_failure_code = if context.is_some() {
5493 NodeFailureCode::ContextPackMaterializationFailed
5494 } else if bundle.is_some() {
5495 NodeFailureCode::BundleMaterializationFailed
5496 } else {
5497 NodeFailureCode::BackendOperationFailed
5498 };
5499 let id = MaterializationId::new(format!(
5500 "mat-{}-{}-{}",
5501 self.incarnation_id,
5502 address.session.instance_id.0,
5503 address.session.generation.0,
5504 ))
5505 .map_err(|_| {
5506 failure(
5507 NodeFailureCode::BackendOperationFailed,
5508 "session environment materialization identity failed",
5509 )
5510 })?;
5511 let owner = MaterializationOwner::Session {
5512 incarnation_id: self.incarnation_id,
5513 instance_id: address.session.instance_id,
5514 generation: address.session.generation,
5515 };
5516 let mut ownership = materializer
5517 .begin(
5518 id.clone(),
5519 environment_profile.cloned(),
5520 bundle.cloned(),
5521 context.cloned(),
5522 owner,
5523 managed_lease_id,
5524 &profile,
5525 unix_time_ms(),
5526 )
5527 .map_err(|_| {
5528 failure(
5529 materialization_failure_code,
5530 "session materialization preparation failed",
5531 )
5532 })?;
5533 {
5534 let _transaction = self
5535 .state_transaction
5536 .lock()
5537 .unwrap_or_else(|poisoned| poisoned.into_inner());
5538 let duplicate = self
5539 .materializations
5540 .lock()
5541 .unwrap_or_else(|poisoned| poisoned.into_inner())
5542 .insert(id.clone(), ownership.clone());
5543 if duplicate.is_some() {
5544 return Err(failure(
5545 NodeFailureCode::BackendOperationFailed,
5546 "session environment materialization identity conflict",
5547 ));
5548 }
5549 if let Err(error) = self.persist_state_locked() {
5550 self.materializations
5551 .lock()
5552 .unwrap_or_else(|poisoned| poisoned.into_inner())
5553 .remove(&id);
5554 return Err(persistence_failure(error));
5555 }
5556 }
5557 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5558 Ok(environment) => environment,
5559 Err(_) => {
5560 let transaction = self
5561 .state_transaction
5562 .lock()
5563 .unwrap_or_else(|poisoned| poisoned.into_inner());
5564 self.materializations
5565 .lock()
5566 .unwrap_or_else(|poisoned| poisoned.into_inner())
5567 .insert(id.clone(), ownership.clone());
5568 if self.persist_state_locked().is_err() {
5569 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5570 }
5571 drop(transaction);
5572 if ownership.state() == MaterializationState::CleanupRequired {
5573 let _ = self.cleanup_materialization(&id);
5574 }
5575 return Err(failure(
5576 materialization_failure_code,
5577 "session materialization failed",
5578 ));
5579 }
5580 };
5581 {
5582 let _transaction = self
5583 .state_transaction
5584 .lock()
5585 .unwrap_or_else(|poisoned| poisoned.into_inner());
5586 self.materializations
5587 .lock()
5588 .unwrap_or_else(|poisoned| poisoned.into_inner())
5589 .insert(id.clone(), ownership.clone());
5590 if let Err(error) = self.persist_state_locked() {
5591 drop(_transaction);
5592 let _ = self.cleanup_materialization(&id);
5593 return Err(persistence_failure(error));
5594 }
5595 }
5596 let guard = SessionMaterializationGuard {
5597 shared: self,
5598 id: Some(id),
5599 provider_home: Some(ownership.provider_home().to_path_buf()),
5600 };
5601 let bundle_arguments = match bundle {
5602 Some(receipt) => {
5603 let catalog = self.bundle_catalog.read()
5604 .unwrap_or_else(|poisoned| poisoned.into_inner());
5605 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5606 NodeFailureCode::UnknownBundle,
5607 "session bundle is unavailable on this node",
5608 ))?;
5609 if installed.receipt() != *receipt {
5610 return Err(failure(
5611 NodeFailureCode::BundleBindingMismatch,
5612 "session bundle revision or digest changed",
5613 ));
5614 }
5615 let layout = bundle_layout.ok_or_else(|| failure(
5616 NodeFailureCode::BundleBindingMismatch,
5617 "materialized bundle provider layout is unavailable",
5618 ))?;
5619 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5620 failure(
5621 NodeFailureCode::BundleMaterializationFailed,
5622 "session bundle byte revalidation failed",
5623 )
5624 })?;
5625 Some(bundle_launch_arguments(
5626 layout,
5627 ownership.bundle_root(),
5628 ownership.provider_home(),
5629 ).map_err(|_| failure(
5630 NodeFailureCode::BundleBindingMismatch,
5631 "session bundle launch binding is invalid",
5632 ))?)
5633 }
5634 None => None,
5635 };
5636 if let Some(receipt) = context {
5637 materializer
5638 .revalidate_context(&ownership, receipt)
5639 .map_err(|_| failure(
5640 NodeFailureCode::ContextPackMaterializationFailed,
5641 "session context pack byte revalidation failed",
5642 ))?;
5643 }
5644 let transport = match mode {
5645 SessionMode::Pty => TransportKind::Pty,
5646 SessionMode::Inline => TransportKind::Pipe,
5647 SessionMode::Acp => TransportKind::Acp,
5648 };
5649 let overlay = if let Some(extra_args) = bundle_arguments {
5650 Some(PreparedNativeLaunchOverlay::Instance(
5651 NativeInstanceLaunchOverlay::new(
5652 provider.clone(),
5653 transport,
5654 if has_environment_overlay {
5655 environment
5656 } else {
5657 Vec::new()
5658 },
5659 extra_args,
5660 )
5661 .map_err(|_| {
5662 failure(
5663 NodeFailureCode::BundleBindingMismatch,
5664 "session bundle launch overlay is invalid",
5665 )
5666 })?,
5667 ))
5668 } else if has_environment_overlay {
5669 let context_only = environment_profile.is_none()
5670 && bundle.is_none()
5671 && context.is_some();
5672 let overlay = if context_only {
5673 NativeLaunchEnvironmentOverlay::new_context_root(
5674 provider.clone(),
5675 transport,
5676 environment,
5677 )
5678 .map_err(|_| {
5679 failure(
5680 NodeFailureCode::ContextPackMaterializationFailed,
5681 "context-only launch overlay is invalid",
5682 )
5683 })?
5684 } else {
5685 NativeLaunchEnvironmentOverlay::new(
5686 provider.clone(),
5687 transport,
5688 environment,
5689 )
5690 .map_err(|_| {
5691 failure(
5692 NodeFailureCode::BackendOperationFailed,
5693 "session environment overlay is invalid",
5694 )
5695 })?
5696 };
5697 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5698 } else {
5699 None
5700 };
5701 Ok(Some((overlay, guard)))
5702 }
5703
5704 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5705 let Some(mut ownership) = self
5706 .materializations
5707 .lock()
5708 .unwrap_or_else(|poisoned| poisoned.into_inner())
5709 .get(id)
5710 .cloned()
5711 else {
5712 return Ok(());
5713 };
5714 if ownership.state() != MaterializationState::CleanupRequired {
5715 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5716 failure(
5717 NodeFailureCode::BackendOperationFailed,
5718 "session environment cleanup state is invalid",
5719 )
5720 })?;
5721 }
5722 {
5723 let _transaction = self
5724 .state_transaction
5725 .lock()
5726 .unwrap_or_else(|poisoned| poisoned.into_inner());
5727 let previous = self
5728 .materializations
5729 .lock()
5730 .unwrap_or_else(|poisoned| poisoned.into_inner())
5731 .insert(id.clone(), ownership.clone());
5732 if let Err(error) = self.persist_state_locked() {
5733 let mut materializations = self
5734 .materializations
5735 .lock()
5736 .unwrap_or_else(|poisoned| poisoned.into_inner());
5737 if let Some(previous) = previous {
5738 materializations.insert(id.clone(), previous);
5739 }
5740 return Err(persistence_failure(error));
5741 }
5742 }
5743 let cleanup = self
5744 .session_environment_materializer
5745 .as_ref()
5746 .ok_or(())
5747 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5748 if cleanup.is_err() {
5749 let _ = ownership.mark_recovery_required(unix_time_ms());
5750 self.materializations
5751 .lock()
5752 .unwrap_or_else(|poisoned| poisoned.into_inner())
5753 .insert(id.clone(), ownership);
5754 if self.persist_state().is_err() {
5755 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5756 }
5757 return Err(failure(
5758 NodeFailureCode::BackendOperationFailed,
5759 "session environment cleanup requires recovery",
5760 ));
5761 }
5762 let _transaction = self
5763 .state_transaction
5764 .lock()
5765 .unwrap_or_else(|poisoned| poisoned.into_inner());
5766 self.materializations
5767 .lock()
5768 .unwrap_or_else(|poisoned| poisoned.into_inner())
5769 .remove(id);
5770 if let Err(error) = self.persist_state_locked() {
5771 self.materializations
5772 .lock()
5773 .unwrap_or_else(|poisoned| poisoned.into_inner())
5774 .insert(id.clone(), ownership);
5775 return Err(persistence_failure(error));
5776 }
5777 Ok(())
5778 }
5779
5780 fn resolve_record_materialization(
5781 &self,
5782 record_id: &SessionRecordId,
5783 provider: &AgentId,
5784 mode: SessionMode,
5785 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5786 bundle: Option<&ResolvedBundleReceipt>,
5787 context: Option<&ResolvedContextPackReceipt>,
5788 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5789 let ownership = {
5790 let materializations = self
5791 .materializations
5792 .lock()
5793 .unwrap_or_else(|poisoned| poisoned.into_inner());
5794 let mut matches = materializations.values().filter(|record| {
5795 record.owner()
5796 == &MaterializationOwner::Record {
5797 record_id: record_id.clone(),
5798 }
5799 });
5800 let ownership = matches.next().cloned();
5801 if matches.next().is_some() {
5802 return Err(failure(
5803 NodeFailureCode::BackendOperationFailed,
5804 "managed session has conflicting environment ownership",
5805 ));
5806 }
5807 ownership
5808 };
5809 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5810 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5811 failure(
5812 NodeFailureCode::ContextPackMaterializationFailed,
5813 "session context materializer is unavailable",
5814 )
5815 })?;
5816 let pack = materializer
5817 .revalidate_context(ownership, receipt)
5818 .map_err(|_| {
5819 self.mark_materialization_recovery_required(ownership.id());
5820 failure(
5821 NodeFailureCode::ContextPackMaterializationFailed,
5822 "managed session context pack revalidation failed",
5823 )
5824 })?;
5825 self.context_catalog
5826 .write()
5827 .unwrap_or_else(|poisoned| poisoned.into_inner())
5828 .insert(pack)
5829 .map_err(|_| failure(
5830 NodeFailureCode::ContextPackMaterializationFailed,
5831 "managed session context pack catalog restore failed",
5832 ))?;
5833 }
5834 let materialization = self.materialization_profile(
5835 provider,
5836 mode,
5837 environment_profile,
5838 bundle,
5839 context,
5840 )?;
5841 match (ownership, materialization) {
5842 (None, None) => Ok(None),
5843 (None, Some(_)) | (Some(_), None) => Err(failure(
5844 NodeFailureCode::BackendOperationFailed,
5845 "managed session materialization is unavailable",
5846 )),
5847 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5848 if ownership.environment_profile() != environment_profile
5849 || ownership.bundle() != bundle
5850 || ownership.context() != context
5851 || ownership.state() != MaterializationState::Ready
5852 {
5853 self.mark_materialization_recovery_required(ownership.id());
5854 return Err(failure(
5855 NodeFailureCode::BackendOperationFailed,
5856 "managed session environment ownership requires recovery",
5857 ));
5858 }
5859 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5860 failure(
5861 NodeFailureCode::BackendOperationFailed,
5862 "session environment materializer is unavailable",
5863 )
5864 })?;
5865 if materializer.revalidate(&ownership).is_err() {
5866 self.mark_materialization_recovery_required(ownership.id());
5867 return Err(failure(
5868 NodeFailureCode::BackendOperationFailed,
5869 "managed session environment revalidation failed",
5870 ));
5871 }
5872 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5873 let catalog = self.bundle_catalog.read()
5874 .unwrap_or_else(|poisoned| poisoned.into_inner());
5875 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5876 return Err(failure(
5877 NodeFailureCode::UnknownBundle,
5878 "managed session bundle is unavailable",
5879 ));
5880 };
5881 let layout = bundle_layout.ok_or_else(|| failure(
5882 NodeFailureCode::BundleBindingMismatch,
5883 "record bundle provider layout is unavailable",
5884 ))?;
5885 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5886 drop(catalog);
5887 self.mark_materialization_recovery_required(ownership.id());
5888 return Err(failure(
5889 NodeFailureCode::BundleMaterializationFailed,
5890 "managed session bundle revalidation failed",
5891 ));
5892 }
5893 Some(bundle_launch_arguments(
5894 layout,
5895 ownership.bundle_root(),
5896 ownership.provider_home(),
5897 ).map_err(|_| failure(
5898 NodeFailureCode::BundleBindingMismatch,
5899 "managed session bundle launch binding is invalid",
5900 ))?)
5901 } else {
5902 None
5903 };
5904 if !has_environment_materialization {
5905 let overlay = bundle_arguments.map(|extra_args| {
5906 NativeInstanceLaunchOverlay::new(
5907 provider.clone(),
5908 TransportKind::Pty,
5909 Vec::new(),
5910 extra_args,
5911 )
5912 .map(PreparedNativeLaunchOverlay::Instance)
5913 .map_err(|_| failure(
5914 NodeFailureCode::BundleBindingMismatch,
5915 "managed session bundle launch overlay is invalid",
5916 ))
5917 }).transpose()?;
5918 return Ok(Some((ownership.id().clone(), overlay)));
5919 }
5920 let environment = match materializer.resolve_environment(&ownership, &profile) {
5921 Ok(environment) => environment,
5922 Err(
5923 SessionEnvironmentMaterializeError::SecretUnavailable
5924 | SessionEnvironmentMaterializeError::SecretDenied
5925 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5926 ) => {
5927 return Err(failure(
5928 NodeFailureCode::BackendOperationFailed,
5929 "managed session environment resolution failed",
5930 ));
5931 }
5932 Err(
5933 SessionEnvironmentMaterializeError::InvalidRoot
5934 | SessionEnvironmentMaterializeError::OwnershipMismatch
5935 | SessionEnvironmentMaterializeError::Filesystem(_)
5936 | SessionEnvironmentMaterializeError::Record(_),
5937 ) => {
5938 self.mark_materialization_recovery_required(ownership.id());
5939 return Err(failure(
5940 NodeFailureCode::BackendOperationFailed,
5941 "managed session environment resolution failed",
5942 ));
5943 }
5944 };
5945 let transport = match mode {
5946 SessionMode::Pty => TransportKind::Pty,
5947 SessionMode::Inline => TransportKind::Pipe,
5948 SessionMode::Acp => TransportKind::Acp,
5949 };
5950 let overlay = match bundle_arguments {
5951 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5952 NativeInstanceLaunchOverlay::new(
5953 provider.clone(),
5954 transport,
5955 environment,
5956 extra_args,
5957 )
5958 .map_err(|_| failure(
5959 NodeFailureCode::BundleBindingMismatch,
5960 "managed session bundle launch overlay is invalid",
5961 ))?,
5962 ),
5963 None => PreparedNativeLaunchOverlay::Environment(
5964 NativeLaunchEnvironmentOverlay::new(
5965 provider.clone(),
5966 transport,
5967 environment,
5968 )
5969 .map_err(|_| {
5970 failure(
5971 NodeFailureCode::BackendOperationFailed,
5972 "managed session environment overlay is invalid",
5973 )
5974 })?,
5975 ),
5976 };
5977 Ok(Some((ownership.id().clone(), Some(overlay))))
5978 }
5979 }
5980 }
5981
5982 fn destroy_record_materialization(
5983 &self,
5984 record_id: &SessionRecordId,
5985 ) -> Result<bool, NodeFailure> {
5986 let ids = self
5987 .materializations
5988 .lock()
5989 .unwrap_or_else(|poisoned| poisoned.into_inner())
5990 .values()
5991 .filter_map(|record| {
5992 (record.owner()
5993 == &MaterializationOwner::Record {
5994 record_id: record_id.clone(),
5995 })
5996 .then(|| record.id().clone())
5997 })
5998 .collect::<Vec<_>>();
5999 if ids.len() > 1 {
6000 return Err(failure(
6001 NodeFailureCode::BackendOperationFailed,
6002 "managed session has conflicting environment ownership",
6003 ));
6004 }
6005 let Some(id) = ids.first().cloned() else {
6006 return Ok(false);
6007 };
6008 let (terminal_record, mut cleanup_ownership) = {
6009 let _transaction = self
6010 .state_transaction
6011 .lock()
6012 .unwrap_or_else(|poisoned| poisoned.into_inner());
6013 let original_record = self.record(record_id)?;
6014 let mut terminal_record = original_record.clone();
6015 terminal_record.provider_session = None;
6016 terminal_record.active_session = None;
6017 terminal_record.state = ManagedSessionState::Unavailable;
6018 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
6019 terminal_record.updated_at_unix_ms = unix_time_ms();
6020 let original_ownership = self
6021 .materializations
6022 .lock()
6023 .unwrap_or_else(|poisoned| poisoned.into_inner())
6024 .get(&id)
6025 .cloned()
6026 .ok_or_else(|| {
6027 failure(
6028 NodeFailureCode::BackendOperationFailed,
6029 "managed session environment ownership disappeared during cleanup",
6030 )
6031 })?;
6032 let mut cleanup_ownership = original_ownership.clone();
6033 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
6034 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
6035 failure(
6036 NodeFailureCode::BackendOperationFailed,
6037 "managed session environment cleanup state is invalid",
6038 )
6039 })?;
6040 }
6041 self.session_records
6042 .lock()
6043 .unwrap_or_else(|poisoned| poisoned.into_inner())
6044 .records
6045 .insert(record_id.clone(), terminal_record.clone());
6046 self.materializations
6047 .lock()
6048 .unwrap_or_else(|poisoned| poisoned.into_inner())
6049 .insert(id.clone(), cleanup_ownership.clone());
6050 if let Err(error) = self.persist_state_locked() {
6051 self.session_records
6052 .lock()
6053 .unwrap_or_else(|poisoned| poisoned.into_inner())
6054 .records
6055 .insert(record_id.clone(), original_record);
6056 self.materializations
6057 .lock()
6058 .unwrap_or_else(|poisoned| poisoned.into_inner())
6059 .insert(id.clone(), original_ownership);
6060 return Err(persistence_failure(error));
6061 }
6062 (terminal_record, cleanup_ownership)
6063 };
6064 let cleanup = self
6065 .session_environment_materializer
6066 .as_ref()
6067 .ok_or(())
6068 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
6069 if cleanup.is_err() {
6070 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
6071 self.materializations
6072 .lock()
6073 .unwrap_or_else(|poisoned| poisoned.into_inner())
6074 .insert(id.clone(), cleanup_ownership);
6075 if self.persist_state().is_err() {
6076 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6077 }
6078 return Err(failure(
6079 NodeFailureCode::BackendOperationFailed,
6080 "managed session environment cleanup requires recovery",
6081 ));
6082 }
6083 let _transaction = self
6084 .state_transaction
6085 .lock()
6086 .unwrap_or_else(|poisoned| poisoned.into_inner());
6087 self.remove_record_memory(record_id);
6088 self.materializations
6089 .lock()
6090 .unwrap_or_else(|poisoned| poisoned.into_inner())
6091 .remove(&id);
6092 if let Err(error) = self.persist_state_locked() {
6093 self.session_records
6094 .lock()
6095 .unwrap_or_else(|poisoned| poisoned.into_inner())
6096 .records
6097 .insert(record_id.clone(), terminal_record);
6098 self.materializations
6099 .lock()
6100 .unwrap_or_else(|poisoned| poisoned.into_inner())
6101 .insert(id, cleanup_ownership);
6102 return Err(persistence_failure(error));
6103 }
6104 Ok(true)
6105 }
6106
6107 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6108 let changed = {
6109 let mut materializations = self
6110 .materializations
6111 .lock()
6112 .unwrap_or_else(|poisoned| poisoned.into_inner());
6113 let Some(ownership) = materializations.get_mut(id) else {
6114 return;
6115 };
6116 ownership.mark_recovery_required(unix_time_ms()).is_ok()
6117 };
6118 if changed && self.persist_state().is_err() {
6119 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6120 }
6121 }
6122
6123 fn reconcile_materializations(&self) {
6124 let records = self
6125 .materializations
6126 .lock()
6127 .unwrap_or_else(|poisoned| poisoned.into_inner())
6128 .values()
6129 .cloned()
6130 .collect::<Vec<_>>();
6131 for mut ownership in records {
6132 let result = match (ownership.state(), ownership.owner()) {
6133 (MaterializationState::Preparing, _)
6134 | (MaterializationState::CleanupRequired, _) => {
6135 self.cleanup_materialization(ownership.id())
6136 }
6137 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6138 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6139 self.materializations
6140 .lock()
6141 .unwrap_or_else(|poisoned| poisoned.into_inner())
6142 .insert(ownership.id().clone(), ownership.clone());
6143 self.persist_state().map_err(persistence_failure)
6144 } else {
6145 Err(failure(
6146 NodeFailureCode::BackendOperationFailed,
6147 "session environment reconciliation failed",
6148 ))
6149 }
6150 }
6151 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6152 .session_environment_materializer
6153 .as_ref()
6154 .ok_or_else(|| {
6155 failure(
6156 NodeFailureCode::BackendOperationFailed,
6157 "session environment materializer is unavailable",
6158 )
6159 })
6160 .and_then(|materializer| {
6161 materializer.revalidate(&ownership).map_err(|_| failure(
6162 NodeFailureCode::BackendOperationFailed,
6163 "session materialization revalidation failed",
6164 ))?;
6165 let record = self.record(record_id)?;
6166 if record.environment_profile.as_ref() != ownership.environment_profile()
6167 || record.bundle.as_ref() != ownership.bundle()
6168 || record.context.as_ref() != ownership.context()
6169 {
6170 return Err(failure(
6171 NodeFailureCode::BackendOperationFailed,
6172 "session materialization ownership changed after restart",
6173 ));
6174 }
6175 if let Some(receipt) = ownership.bundle() {
6176 let catalog = self.bundle_catalog.read()
6177 .unwrap_or_else(|poisoned| poisoned.into_inner());
6178 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6179 NodeFailureCode::UnknownBundle,
6180 "session bundle is unavailable after restart",
6181 ))?;
6182 if bundle.receipt() != *receipt {
6183 return Err(failure(
6184 NodeFailureCode::BundleBindingMismatch,
6185 "session bundle changed after restart",
6186 ));
6187 }
6188 let layout = self.bundle_layout(
6189 &record.provider,
6190 record.mode,
6191 record.environment_profile.as_ref(),
6192 bundle,
6193 )?;
6194 materializer
6195 .revalidate_bundle(&ownership, bundle, layout)
6196 .map_err(|_| failure(
6197 NodeFailureCode::BundleMaterializationFailed,
6198 "session bundle revalidation failed",
6199 ))?;
6200 }
6201 if let Some(receipt) = ownership.context() {
6202 let pack = materializer
6203 .revalidate_context(&ownership, receipt)
6204 .map_err(|_| {
6205 failure(
6206 NodeFailureCode::ContextPackMaterializationFailed,
6207 "session context revalidation failed",
6208 )
6209 })?;
6210 self.context_catalog
6211 .write()
6212 .unwrap_or_else(|poisoned| poisoned.into_inner())
6213 .insert(pack)
6214 .map_err(|_| {
6215 failure(
6216 NodeFailureCode::ContextPackMaterializationFailed,
6217 "session context catalog restore failed",
6218 )
6219 })?;
6220 }
6221 Ok(())
6222 }),
6223 (MaterializationState::RecoveryRequired, _) => Ok(()),
6224 };
6225 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6226 let _ = ownership.mark_recovery_required(unix_time_ms());
6227 self.materializations
6228 .lock()
6229 .unwrap_or_else(|poisoned| poisoned.into_inner())
6230 .insert(ownership.id().clone(), ownership);
6231 if self.persist_state().is_err() {
6232 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6233 }
6234 }
6235 }
6236 let materialized_profiles = self
6237 .environment_materialization_profiles
6238 .read()
6239 .unwrap_or_else(|poisoned| poisoned.into_inner())
6240 .keys()
6241 .cloned()
6242 .collect::<Vec<_>>();
6243 let ownership = self
6244 .materializations
6245 .lock()
6246 .unwrap_or_else(|poisoned| poisoned.into_inner())
6247 .values()
6248 .cloned()
6249 .collect::<Vec<_>>();
6250 let changed = {
6251 let mut session_records = self
6252 .session_records
6253 .lock()
6254 .unwrap_or_else(|poisoned| poisoned.into_inner());
6255 let mut changed = false;
6256 for record in session_records.records.values_mut() {
6257 let environment_profile = record.environment_profile.as_ref();
6258 if environment_profile.is_none()
6259 && record.bundle.is_none()
6260 && record.context.is_none()
6261 {
6262 continue;
6263 }
6264 if record.bundle.is_none()
6265 && environment_profile.is_some_and(|profile| {
6266 !materialized_profiles.contains(&profile.profile_id)
6267 })
6268 {
6269 continue;
6270 }
6271 let has_exact_ownership = ownership.iter().any(|candidate| {
6272 candidate.state() == MaterializationState::Ready
6273 && candidate.environment_profile() == environment_profile
6274 && candidate.bundle() == record.bundle.as_ref()
6275 && candidate.context() == record.context.as_ref()
6276 && candidate.owner()
6277 == &MaterializationOwner::Record {
6278 record_id: record.record_id.clone(),
6279 }
6280 });
6281 if !has_exact_ownership {
6282 record.provider_session = None;
6283 record.active_session = None;
6284 record.state = ManagedSessionState::Unavailable;
6285 record.last_error = Some(if record.bundle.is_some() {
6286 "bundle-unavailable".to_owned()
6287 } else if record.context.is_some() {
6288 "context-unavailable".to_owned()
6289 } else {
6290 "environment-profile-unavailable".to_owned()
6291 });
6292 record.updated_at_unix_ms = unix_time_ms();
6293 changed = true;
6294 }
6295 }
6296 changed
6297 };
6298 if changed && self.persist_state().is_err() {
6299 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6300 }
6301 }
6302
6303 fn replay_spawn_spec(
6304 &self,
6305 spec: &SpawnSpec,
6306 now: Instant,
6307 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6308 let mut cache = self
6309 .spawn_idempotency
6310 .lock()
6311 .unwrap_or_else(|poisoned| poisoned.into_inner());
6312 cache.entries.retain(|_, entry| entry.expires_at > now);
6313 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6314 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6315 return Err(failure(
6316 NodeFailureCode::SpawnIdempotencyConflict,
6317 "spawn idempotency key was reused across spawn kinds",
6318 ));
6319 };
6320 if existing != spec {
6321 return Err(failure(
6322 NodeFailureCode::SpawnIdempotencyConflict,
6323 "spawn idempotency key was reused with a different specification",
6324 ));
6325 }
6326 return Ok(Some(result.clone()));
6327 }
6328 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6329 return Err(failure(
6330 NodeFailureCode::SpawnIdempotencyCapacity,
6331 "spawn idempotency cache reached its bounded capacity",
6332 ));
6333 }
6334 Ok(None)
6335 }
6336
6337 fn remember_spawn_spec(
6338 &self,
6339 spec: SpawnSpec,
6340 result: Result<ResolvedSpawnReceipt, NodeFailure>,
6341 accepted_at: Instant,
6342 ) {
6343 self
6344 .spawn_idempotency
6345 .lock()
6346 .unwrap_or_else(|poisoned| poisoned.into_inner())
6347 .entries
6348 .insert(
6349 spec.idempotency_key.clone(),
6350 SpawnIdempotencyEntry {
6351 value: SpawnIdempotencyValue::Standard { spec, result },
6352 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6353 },
6354 );
6355 }
6356
6357 fn claim_harness_mcp_spawn(
6358 &self,
6359 _reservation_id: &HarnessMcpReservationId,
6360 _activation_digest: &HarnessMcpActivationDigest,
6361 spec: &SpawnSpec,
6362 now: Instant,
6363 ) -> Result<(), NodeFailure> {
6364 let mut cache = self.spawn_idempotency.lock()
6365 .unwrap_or_else(|poisoned| poisoned.into_inner());
6366 cache.entries.retain(|_, entry| entry.expires_at > now);
6367 if cache.entries.contains_key(&spec.idempotency_key) {
6368 return Err(failure(
6369 NodeFailureCode::SpawnIdempotencyConflict,
6370 "spawn idempotency key was reused across spawn kinds",
6371 ));
6372 }
6373 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6374 return Err(failure(
6375 NodeFailureCode::SpawnIdempotencyCapacity,
6376 "spawn idempotency cache reached its bounded capacity",
6377 ));
6378 }
6379 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6380 value: SpawnIdempotencyValue::HarnessMcp,
6381 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6382 });
6383 Ok(())
6384 }
6385
6386 fn replay_managed_spawn(
6387 &self,
6388 request: &ManagedWorktreeSpawnRequest,
6389 now: Instant,
6390 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6391 let mut cache = self.spawn_idempotency.lock()
6392 .unwrap_or_else(|poisoned| poisoned.into_inner());
6393 cache.entries.retain(|_, entry| entry.expires_at > now);
6394 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6395 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6396 return Err(failure(
6397 NodeFailureCode::SpawnIdempotencyConflict,
6398 "spawn idempotency key was reused across spawn kinds",
6399 ));
6400 };
6401 if existing != request {
6402 return Err(failure(
6403 NodeFailureCode::SpawnIdempotencyConflict,
6404 "spawn idempotency key was reused with a different managed specification",
6405 ));
6406 }
6407 return Ok(Some(result.clone()));
6408 }
6409 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6410 return Err(failure(
6411 NodeFailureCode::SpawnIdempotencyCapacity,
6412 "spawn idempotency cache reached its bounded capacity",
6413 ));
6414 }
6415 Ok(None)
6416 }
6417
6418 fn remember_managed_spawn(
6419 &self,
6420 request: ManagedWorktreeSpawnRequest,
6421 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6422 accepted_at: Instant,
6423 ) {
6424 self.spawn_idempotency.lock()
6425 .unwrap_or_else(|poisoned| poisoned.into_inner())
6426 .entries
6427 .insert(
6428 request.spawn_spec.idempotency_key.clone(),
6429 SpawnIdempotencyEntry {
6430 value: SpawnIdempotencyValue::Managed { request, result },
6431 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6432 },
6433 );
6434 }
6435
6436 fn remember_managed_spawn_attempt(
6437 &self,
6438 durable_v2_key: Option<&SpawnIdempotencyKey>,
6439 request: ManagedWorktreeSpawnRequest,
6440 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6441 accepted_at: Instant,
6442 ) {
6443 if durable_v2_key.is_none() {
6444 self.remember_managed_spawn(request, result, accepted_at);
6445 }
6446 }
6447
6448 fn acquire_durable_managed_spawn_v2(
6449 &self,
6450 request: &ManagedWorktreeSpawnRequestV2,
6451 request_digest: String,
6452 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6453 if self.state_path.is_none() {
6454 return Err(failure(
6455 NodeFailureCode::BackendOperationFailed,
6456 "managed worktree V2 requires durable node state",
6457 ));
6458 }
6459 let _transaction = self.state_transaction
6460 .lock()
6461 .unwrap_or_else(|poisoned| poisoned.into_inner());
6462 let key = request.spawn_spec.idempotency_key.clone();
6463 let mut previous = None;
6464 {
6465 let mut replays = self.managed_spawn_replays
6466 .lock()
6467 .unwrap_or_else(|poisoned| poisoned.into_inner());
6468 if let Some(record) = replays.get_mut(&key) {
6469 if record.request_digest != request_digest {
6470 return Err(failure(
6471 NodeFailureCode::SpawnIdempotencyConflict,
6472 "managed worktree V2 idempotency key was reused with a different request",
6473 ));
6474 }
6475 match &record.state {
6476 ManagedWorktreeSpawnReplayStateV10::Pending {
6477 owner_incarnation_id,
6478 lease_id: None,
6479 } if owner_incarnation_id != &self.incarnation_id => {
6480 previous = Some(record.clone());
6481 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6482 owner_incarnation_id: self.incarnation_id,
6483 lease_id: None,
6484 };
6485 record.updated_at_unix_ms = unix_time_ms();
6486 }
6487 ManagedWorktreeSpawnReplayStateV10::Pending {
6488 lease_id: None,
6489 ..
6490 } => {
6491 return Err(failure(
6492 NodeFailureCode::BackendBusy,
6493 "managed worktree V2 reservation is already active",
6494 ));
6495 }
6496 ManagedWorktreeSpawnReplayStateV10::Pending {
6497 lease_id: Some(_),
6498 ..
6499 } => {
6500 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6501 }
6502 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6503 return Ok(DurableManagedSpawnReplayDecision::Committed(
6504 receipt.clone(),
6505 ));
6506 }
6507 }
6508 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6509 return Err(failure(
6510 NodeFailureCode::SpawnIdempotencyCapacity,
6511 "durable managed worktree replay ledger reached its bounded capacity",
6512 ));
6513 } else {
6514 let now = unix_time_ms();
6515 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6516 idempotency_key: key.clone(),
6517 request_digest,
6518 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6519 profile_id: request.worktree_profile_id.clone(),
6520 expected_profile_revision: request.expected_profile_revision.clone(),
6521 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6522 owner_incarnation_id: self.incarnation_id,
6523 lease_id: None,
6524 },
6525 created_at_unix_ms: now,
6526 updated_at_unix_ms: now,
6527 });
6528 }
6529 }
6530 if let Err(error) = self.persist_state_locked() {
6531 let mut replays = self.managed_spawn_replays
6532 .lock()
6533 .unwrap_or_else(|poisoned| poisoned.into_inner());
6534 match previous {
6535 Some(previous) => {
6536 replays.insert(key, previous);
6537 }
6538 None => {
6539 replays.remove(&key);
6540 }
6541 }
6542 return Err(persistence_failure(error));
6543 }
6544 Ok(DurableManagedSpawnReplayDecision::Reserved)
6545 }
6546
6547 fn link_durable_managed_spawn_v2_lease_locked(
6548 &self,
6549 key: &SpawnIdempotencyKey,
6550 lease_id: &ManagedWorktreeLeaseId,
6551 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6552 let mut replays = self.managed_spawn_replays
6553 .lock()
6554 .unwrap_or_else(|poisoned| poisoned.into_inner());
6555 let record = replays.get_mut(key).ok_or_else(|| {
6556 failure(
6557 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6558 "managed worktree V2 reservation disappeared before allocation",
6559 )
6560 })?;
6561 let previous = record.clone();
6562 match &mut record.state {
6563 ManagedWorktreeSpawnReplayStateV10::Pending {
6564 lease_id: current,
6565 ..
6566 }
6567 if current.is_none() =>
6568 {
6569 *current = Some(lease_id.clone());
6570 record.updated_at_unix_ms = unix_time_ms();
6571 Ok(previous)
6572 }
6573 _ => Err(failure(
6574 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6575 "managed worktree V2 reservation is not allocation-ready",
6576 )),
6577 }
6578 }
6579
6580 fn restore_durable_managed_spawn_v2_locked(
6581 &self,
6582 key: &SpawnIdempotencyKey,
6583 previous: ManagedWorktreeSpawnReplayRecordV10,
6584 ) {
6585 self.managed_spawn_replays
6586 .lock()
6587 .unwrap_or_else(|poisoned| poisoned.into_inner())
6588 .insert(key.clone(), previous);
6589 }
6590
6591 fn commit_durable_managed_spawn_v2(
6592 &self,
6593 request: &ManagedWorktreeSpawnRequestV2,
6594 receipt: ManagedWorktreeSpawnReceipt,
6595 ) -> Result<(), NodeFailure> {
6596 let _transaction = self.state_transaction
6597 .lock()
6598 .unwrap_or_else(|poisoned| poisoned.into_inner());
6599 let key = &request.spawn_spec.idempotency_key;
6600 let previous = {
6601 let mut replays = self.managed_spawn_replays
6602 .lock()
6603 .unwrap_or_else(|poisoned| poisoned.into_inner());
6604 let record = replays.get_mut(key).ok_or_else(|| {
6605 failure(
6606 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6607 "managed worktree V2 reservation disappeared before commit",
6608 )
6609 })?;
6610 let previous = record.clone();
6611 match &record.state {
6612 ManagedWorktreeSpawnReplayStateV10::Pending {
6613 lease_id: Some(lease_id),
6614 ..
6615 } if lease_id == &receipt.lease.lease_id => {}
6616 _ => {
6617 return Err(failure(
6618 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6619 "managed worktree V2 reservation is not linked to the spawned lease",
6620 ));
6621 }
6622 }
6623 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6624 record.updated_at_unix_ms = unix_time_ms();
6625 previous
6626 };
6627 if let Err(error) = self.persist_state_locked() {
6628 self.restore_durable_managed_spawn_v2_locked(key, previous);
6629 return Err(persistence_failure(error));
6630 }
6631 Ok(())
6632 }
6633
6634 fn remove_unallocated_durable_managed_spawn_v2(
6635 &self,
6636 request: &ManagedWorktreeSpawnRequestV2,
6637 ) -> Result<(), NodeFailure> {
6638 let _transaction = self.state_transaction
6639 .lock()
6640 .unwrap_or_else(|poisoned| poisoned.into_inner());
6641 let key = &request.spawn_spec.idempotency_key;
6642 let removed = {
6643 let mut replays = self.managed_spawn_replays
6644 .lock()
6645 .unwrap_or_else(|poisoned| poisoned.into_inner());
6646 match replays.get(key).map(|record| &record.state) {
6647 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6648 lease_id: None,
6649 ..
6650 }) => {
6651 replays.remove(key)
6652 }
6653 _ => None,
6654 }
6655 };
6656 let Some(removed) = removed else {
6657 return Ok(());
6658 };
6659 if let Err(error) = self.persist_state_locked() {
6660 self.managed_spawn_replays
6661 .lock()
6662 .unwrap_or_else(|poisoned| poisoned.into_inner())
6663 .insert(key.clone(), removed);
6664 return Err(persistence_failure(error));
6665 }
6666 Ok(())
6667 }
6668
6669 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6670 if spec.target.node_id != self.node_id {
6671 return Err(failure(
6672 NodeFailureCode::SpawnTargetMismatch,
6673 "spawn target does not match this node",
6674 ));
6675 }
6676 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6677 failure(
6678 NodeFailureCode::UnknownSpawnProfile,
6679 "spawn profile is unavailable on this node",
6680 )
6681 })?;
6682 let resolved = spec.resolve(defaults).map_err(|error| match error {
6683 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6684 NodeFailureCode::SpawnProfileRevisionMismatch,
6685 "spawn profile revision does not match the loaded profile",
6686 ),
6687 _ => failure(
6688 NodeFailureCode::InvalidRequest,
6689 "spawn specification could not be resolved",
6690 ),
6691 })?;
6692 if resolved.target.worktree_id.is_some() {
6693 self.require_worktree_service(&resolved.target.workspace_id)?;
6694 }
6695 let environment_profile = self.resolve_environment_profile(&resolved)?;
6696 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6697 self.resolve_context(&resolved)?;
6698 Ok(resolved)
6699 }
6700
6701 async fn resolve_spawn_workspace(
6702 &self,
6703 resolved: &ResolvedSpawnSpec,
6704 deadline: Instant,
6705 ) -> Result<WorkspaceId, NodeFailure> {
6706 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6707 return Ok(resolved.target.workspace_id.clone());
6708 };
6709 if self.managed_worktrees.lock()
6710 .unwrap_or_else(|poisoned| poisoned.into_inner())
6711 .lease_for_workspace(worktree_id)
6712 .is_some()
6713 {
6714 return Err(failure(
6715 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6716 "managed worktree targets may only be spawned through their lease request",
6717 ));
6718 }
6719 self.require_worktree_service(&resolved.target.workspace_id)?;
6720 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6721 let selected_root = self.workspace_root(worktree_id)?;
6722 let remaining = spawn_deadline_remaining(deadline)?;
6723 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6724 .await
6725 .map_err(|_| {
6726 failure(
6727 NodeFailureCode::SpawnDeadlineExceeded,
6728 "Git worktree selection exceeded the spawn deadline",
6729 )
6730 })?
6731 .map_err(git_worktree_failure)?;
6732 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6733 Ok(worktree_id.clone())
6734 }
6735
6736 async fn spawn_from_spec(
6737 &self,
6738 spec: SpawnSpec,
6739 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6740 self.spawn_from_spec_with_proxy(spec, None).await
6741 }
6742
6743 async fn spawn_from_spec_with_proxy(
6744 &self,
6745 spec: SpawnSpec,
6746 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6747 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6748 let accepted_at = Instant::now();
6749 if harness_mcp.is_none() {
6750 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6751 return replayed;
6752 }
6753 }
6754 let resolved = self.resolve_spawn_spec(&spec)?;
6755 let environment_profile = self.resolve_environment_profile(&resolved)?;
6756 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6757 let context = self.resolve_context(&resolved)?;
6758 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6759 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6760 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6761 let spawn_workspace_id = match spawn_workspace_id {
6762 Ok(workspace_id) => workspace_id,
6763 Err(error) => {
6764 let result = Err(error);
6765 if harness_mcp.is_none() {
6766 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6767 }
6768 return result;
6769 }
6770 };
6771 if self.managed_worktrees.lock()
6772 .unwrap_or_else(|poisoned| poisoned.into_inner())
6773 .lease_for_workspace(&spawn_workspace_id)
6774 .is_some()
6775 {
6776 let result = Err(failure(
6777 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6778 "managed worktree targets may only be spawned through their lease request",
6779 ));
6780 if harness_mcp.is_none() {
6781 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6782 }
6783 return result;
6784 }
6785 let result = self
6786 .spawn_session_with_deadline(
6787 spawn_workspace_id,
6788 resolved.provider.clone(),
6789 resolved.mode,
6790 resolved.terminal_size,
6791 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6792 environment_profile.clone(),
6793 bundle.clone(),
6794 context.clone(),
6795 None,
6796 None,
6797 SpawnRecordPolicy::Always,
6798 Some(deadline),
6799 &required_capabilities,
6800 harness_mcp,
6801 resolved.approval_level,
6802 resolved.network_allowlist.clone(),
6803 resolved.browser_profile_id.clone(),
6804 )
6805 .await
6806 .map(|(session, _runtime_policy)| {
6807 resolved.receipt_with_materialization(
6808 self.incarnation_id,
6809 session,
6810 environment_profile,
6811 bundle,
6812 context,
6813 )
6814 });
6815 if harness_mcp.is_none() {
6816 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6817 }
6818 result
6819 }
6820
6821 async fn spawn_from_spec_with_harness_mcp(
6822 &self,
6823 reservation_id: HarnessMcpReservationId,
6824 activation_digest: HarnessMcpActivationDigest,
6825 spec: SpawnSpec,
6826 deadline_unix_ms: u64,
6827 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6828 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6829 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6830 })?;
6831 if let Some(receipt) = registry
6832 .replay_spawn(&reservation_id, &activation_digest, &spec)
6833 .map_err(harness_mcp_failure)?
6834 {
6835 return Ok(receipt);
6836 }
6837 let resolved = self.resolve_spawn_spec(&spec)?;
6838 let prepared = registry
6839 .prepare_spawn(
6840 &reservation_id,
6841 &activation_digest,
6842 &spec,
6843 deadline_unix_ms,
6844 resolved.provider.clone(),
6845 )
6846 .map_err(harness_mcp_failure)?;
6847 self.claim_harness_mcp_spawn(
6848 &reservation_id,
6849 &activation_digest,
6850 &spec,
6851 Instant::now(),
6852 )?;
6853 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6854 let mut receipt = match spawn {
6855 Ok(receipt) => receipt,
6856 Err(error) => {
6857 let _ = registry.abort(&reservation_id, &activation_digest);
6858 return Err(error);
6859 }
6860 };
6861 let record_id = self
6862 .session_bindings
6863 .lock()
6864 .unwrap_or_else(|poisoned| poisoned.into_inner())
6865 .get(&receipt.session.session.instance_id)
6866 .and_then(|binding| binding.record_id.clone());
6867 let record_id = match record_id {
6868 Some(record_id) => record_id,
6869 None => {
6870 let rollback = self.remove_session(&receipt.session).await;
6871 let _ = registry.abort(&reservation_id, &activation_digest);
6872 if let Err(rollback_error) = rollback {
6873 return Err(rollback_error);
6874 }
6875 return Err(failure(
6876 NodeFailureCode::BindingMismatch,
6877 "harness MCP spawn did not retain its exact managed record",
6878 ));
6879 }
6880 };
6881 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6882 reservation_id: reservation_id.clone(),
6883 activation_digest: activation_digest.clone(),
6884 });
6885 if let Err(error) = registry.mark_spawned(
6886 &prepared,
6887 receipt.session.clone(),
6888 record_id,
6889 receipt.clone(),
6890 ) {
6891 let rollback = self.remove_session(&receipt.session).await;
6892 let _ = registry.abort(&reservation_id, &activation_digest);
6893 if let Err(rollback_error) = rollback {
6894 return Err(rollback_error);
6895 }
6896 return Err(harness_mcp_failure(error));
6897 }
6898 Ok(receipt)
6899 }
6900
6901 async fn spawn_managed_worktree(
6902 &self,
6903 request: ManagedWorktreeSpawnRequest,
6904 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6905 self.spawn_managed_worktree_inner(request, None).await
6906 }
6907
6908 async fn spawn_managed_worktree_inner(
6909 &self,
6910 request: ManagedWorktreeSpawnRequest,
6911 durable_v2_key: Option<&SpawnIdempotencyKey>,
6912 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6913 let accepted_at = Instant::now();
6914 if durable_v2_key.is_none() {
6915 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6916 return replayed;
6917 }
6918 }
6919 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6920 if request.spawn_spec.target.worktree_id.is_some() {
6921 let result = Err(failure(
6922 NodeFailureCode::InvalidRequest,
6923 "managed worktree spawn must not select a caller-provided worktree",
6924 ));
6925 self.remember_managed_spawn_attempt(
6926 durable_v2_key,
6927 request,
6928 result.clone(),
6929 accepted_at,
6930 );
6931 return result;
6932 }
6933 let environment_profile = self.resolve_environment_profile(&resolved)?;
6934 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6935 let context = self.resolve_context(&resolved)?;
6936 let source_workspace_id = resolved.target.workspace_id.clone();
6937 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6938 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6939 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6940 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6941 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6942 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6943 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6944 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6945 };
6946 let runtime_policy = timeout(
6947 spawn_deadline_remaining(deadline)?,
6948 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6949 ).await.map_err(|_| failure(
6950 NodeFailureCode::SpawnDeadlineExceeded,
6951 "provider admission exceeded the managed spawn deadline",
6952 ))??;
6953 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6954 return Err(failure(
6955 NodeFailureCode::UnsupportedSpawnCapability,
6956 "provider runtime does not admit a required spawn capability",
6957 ));
6958 }
6959 self.ensure_binding_capacity()?;
6960 let source_root = self.workspace_root(&source_workspace_id)?;
6961 profile.validate_for_workspace(&source_root)
6962 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6963 let base_timeout_ms = u64::try_from(
6964 spawn_deadline_remaining(deadline)?.as_millis(),
6965 ).unwrap_or(u64::MAX).max(1);
6966 let base_commit = resolve_base_commit_with_timeout(
6967 &source_root,
6968 profile.base(),
6969 base_timeout_ms,
6970 ).await.map_err(|error| {
6971 if Instant::now() >= deadline || error.message.contains("timed out") {
6972 failure(
6973 NodeFailureCode::SpawnDeadlineExceeded,
6974 "managed worktree base resolution exceeded the spawn deadline",
6975 )
6976 } else {
6977 managed_git_worktree_failure(error)
6978 }
6979 })?;
6980
6981 let lease = {
6982 let _transaction = self.state_transaction.lock()
6983 .unwrap_or_else(|poisoned| poisoned.into_inner());
6984 let previous = self.managed_worktrees.lock()
6985 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6986 let lease = self.managed_worktrees.lock()
6987 .unwrap_or_else(|poisoned| poisoned.into_inner())
6988 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6989 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6990 let previous_replay = match durable_v2_key
6991 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6992 .transpose()
6993 {
6994 Ok(previous) => previous,
6995 Err(error) => {
6996 *self.managed_worktrees.lock()
6997 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6998 return Err(error);
6999 }
7000 };
7001 if let Err(error) = self.persist_state_locked() {
7002 *self.managed_worktrees.lock()
7003 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
7004 if let (Some(key), Some(previous_replay)) =
7005 (durable_v2_key, previous_replay)
7006 {
7007 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
7008 }
7009 return Err(persistence_failure(error));
7010 }
7011 lease
7012 };
7013 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
7014 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
7015 self.managed_worktrees.lock()
7016 .unwrap_or_else(|poisoned| poisoned.into_inner())
7017 .remove_unmutated(&lease.lease_id);
7018 if self.persist_state().is_err() {
7019 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
7020 }
7021 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
7022 self.remember_managed_spawn_attempt(
7023 durable_v2_key,
7024 request,
7025 result.clone(),
7026 accepted_at,
7027 );
7028 return result;
7029 }
7030 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
7031 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
7032 Err(error) => {
7033 self.reconcile_failed_allocation(&lease, &source_root).await;
7034 let result = Err(error);
7035 self.remember_managed_spawn_attempt(
7036 durable_v2_key,
7037 request,
7038 result.clone(),
7039 accepted_at,
7040 );
7041 return result;
7042 }
7043 };
7044 let created = match create_git_worktree_with_timeout(
7045 &source_root,
7046 &lease.target_root,
7047 &lease.branch,
7048 Some(&base_commit),
7049 mutation_timeout_ms,
7050 ).await {
7051 Ok(created) => created,
7052 Err(error) => {
7053 self.reconcile_failed_allocation(&lease, &source_root).await;
7054 let result = Err(if Instant::now() >= deadline
7055 || error.message.contains("timed out")
7056 || error.message.contains("deadline")
7057 {
7058 failure(
7059 NodeFailureCode::SpawnDeadlineExceeded,
7060 "managed worktree creation exceeded the spawn deadline",
7061 )
7062 } else {
7063 managed_git_worktree_failure(error)
7064 });
7065 self.remember_managed_spawn_attempt(
7066 durable_v2_key,
7067 request,
7068 result.clone(),
7069 accepted_at,
7070 );
7071 return result;
7072 }
7073 };
7074 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
7075 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
7076 let result = Err(failure(
7077 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7078 "created worktree identity did not match its durable lease",
7079 ));
7080 self.remember_managed_spawn_attempt(
7081 durable_v2_key,
7082 request,
7083 result.clone(),
7084 accepted_at,
7085 );
7086 return result;
7087 }
7088 {
7089 let mut registry = self.managed_worktrees.lock()
7090 .unwrap_or_else(|poisoned| poisoned.into_inner());
7091 let current = registry.get_mut(&lease.lease_id)
7092 .expect("allocated managed worktree lease remains present");
7093 current.expected_head = Some(created.head.clone());
7094 current.state = ManagedWorktreeLeaseState::Ready;
7095 current.updated_at_unix_ms = unix_time_ms();
7096 }
7097 if let Err(error) = self.persist_state() {
7098 self.mark_managed_recovery_required(
7099 &lease.lease_id,
7100 ManagedWorktreeCleanupFailure::Backend,
7101 );
7102 let result = Err(persistence_failure(error));
7103 self.remember_managed_spawn_attempt(
7104 durable_v2_key,
7105 request,
7106 result.clone(),
7107 accepted_at,
7108 );
7109 return result;
7110 }
7111 let ready = self.managed_worktrees.lock()
7112 .unwrap_or_else(|poisoned| poisoned.into_inner())
7113 .get(&lease.lease_id)
7114 .expect("ready managed worktree lease remains present")
7115 .snapshot();
7116 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7117 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7118 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7119 let result = Err(error);
7120 self.remember_managed_spawn_attempt(
7121 durable_v2_key,
7122 request,
7123 result.clone(),
7124 accepted_at,
7125 );
7126 return result;
7127 }
7128 let spawn = self.spawn_session_with_deadline(
7129 lease.workspace_id.clone(),
7130 resolved.provider.clone(),
7131 resolved.mode,
7132 resolved.terminal_size,
7133 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7134 environment_profile.clone(),
7135 bundle.clone(),
7136 context.clone(),
7137 Some(lease.lease_id.clone()),
7138 Some(runtime_policy),
7139 SpawnRecordPolicy::Always,
7140 Some(deadline),
7141 &required_capabilities,
7142 None,
7143 resolved.approval_level,
7144 resolved.network_allowlist.clone(),
7145 resolved.browser_profile_id.clone(),
7146 ).await;
7147 let (session, effective_runtime_policy) = match spawn {
7148 Ok(spawned) => spawned,
7149 Err(error) => {
7150 if self
7151 .cleanup_managed_worktree(&lease.lease_id, true)
7152 .await
7153 .is_err()
7154 {
7155 self.mark_managed_recovery_required(
7156 &lease.lease_id,
7157 ManagedWorktreeCleanupFailure::Backend,
7158 );
7159 }
7160 let result = Err(error);
7161 self.remember_managed_spawn_attempt(
7162 durable_v2_key,
7163 request,
7164 result.clone(),
7165 accepted_at,
7166 );
7167 return result;
7168 }
7169 };
7170 let (record_id, raw_inventory_record_id) = {
7171 let mut bindings = self.session_bindings.lock()
7172 .unwrap_or_else(|poisoned| poisoned.into_inner());
7173 let binding = bindings.get_mut(&session.session.instance_id)
7174 .expect("successful spawn retains its session binding");
7175 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7176 managed_spawn_record_ownership(binding, effective_runtime_policy)
7177 };
7178 let bound = self.managed_worktrees.lock()
7179 .unwrap_or_else(|poisoned| poisoned.into_inner())
7180 .bind_session(
7181 &lease.lease_id,
7182 ManagedWorktreeSessionHolder {
7183 incarnation_id: self.incarnation_id,
7184 instance_id: session.session.instance_id,
7185 generation: session.session.generation,
7186 },
7187 record_id,
7188 unix_time_ms(),
7189 )
7190 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7191 let snapshot = match bound {
7192 Ok(snapshot) => snapshot,
7193 Err(error) => {
7194 if raw_inventory_record_id
7195 .as_ref()
7196 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7197 {
7198 self.mark_managed_recovery_required(
7199 &lease.lease_id,
7200 ManagedWorktreeCleanupFailure::Backend,
7201 );
7202 }
7203 let _ = self.remove_session(&session).await;
7204 self.mark_managed_recovery_required(
7205 &lease.lease_id,
7206 ManagedWorktreeCleanupFailure::Backend,
7207 );
7208 let result = Err(error);
7209 self.remember_managed_spawn_attempt(
7210 durable_v2_key,
7211 request,
7212 result.clone(),
7213 accepted_at,
7214 );
7215 return result;
7216 }
7217 };
7218 if let Err(error) = self.persist_state() {
7219 if raw_inventory_record_id
7220 .as_ref()
7221 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7222 {
7223 self.mark_managed_recovery_required(
7224 &lease.lease_id,
7225 ManagedWorktreeCleanupFailure::Backend,
7226 );
7227 }
7228 let _ = self.remove_session(&session).await;
7229 if self
7230 .cleanup_managed_worktree(&lease.lease_id, true)
7231 .await
7232 .is_err()
7233 {
7234 self.mark_managed_recovery_required(
7235 &lease.lease_id,
7236 ManagedWorktreeCleanupFailure::Backend,
7237 );
7238 }
7239 let result = Err(persistence_failure(error));
7240 self.remember_managed_spawn_attempt(
7241 durable_v2_key,
7242 request,
7243 result.clone(),
7244 accepted_at,
7245 );
7246 return result;
7247 }
7248 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7249 let receipt = managed_spawn_receipt(
7250 &resolved,
7251 self.incarnation_id,
7252 session,
7253 snapshot,
7254 environment_profile,
7255 bundle,
7256 context,
7257 );
7258 let result = Ok(receipt);
7259 self.remember_managed_spawn_attempt(
7260 durable_v2_key,
7261 request,
7262 result.clone(),
7263 accepted_at,
7264 );
7265 result
7266 }
7267
7268 async fn spawn_managed_worktree_v2(
7269 &self,
7270 request: ManagedWorktreeSpawnRequestV2,
7271 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7272 if request.spawn_spec.target.node_id != self.node_id {
7273 return Err(failure(
7274 NodeFailureCode::SpawnTargetMismatch,
7275 "spawn target does not match this node",
7276 ));
7277 }
7278 if request.spawn_spec.target.worktree_id.is_some() {
7279 return Err(failure(
7280 NodeFailureCode::InvalidRequest,
7281 "managed worktree spawn must not select a caller-provided worktree",
7282 ));
7283 }
7284 let request_digest = managed_spawn_request_digest_v2(&request)?;
7285 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7286 match replay {
7287 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7288 DurableManagedSpawnReplayDecision::PendingLinked => {
7289 return Err(failure(
7290 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7291 "managed worktree V2 reservation has an incomplete durable allocation",
7292 ));
7293 }
7294 DurableManagedSpawnReplayDecision::Reserved => {}
7295 }
7296 let validated = (|| {
7297 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7298 let profile = self.managed_profile(
7299 &resolved.target.workspace_id,
7300 &request.worktree_profile_id,
7301 )?;
7302 if profile.revision() != &request.expected_profile_revision {
7303 return Err(failure(
7304 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7305 "managed worktree profile revision does not match the loaded profile",
7306 ));
7307 }
7308 Ok(())
7309 })();
7310 match validated {
7311 Ok(()) => {}
7312 Err(error) => {
7313 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7314 return Err(error);
7315 }
7316 }
7317 let legacy = ManagedWorktreeSpawnRequest {
7318 spawn_spec: request.spawn_spec.clone(),
7319 worktree_profile_id: request.worktree_profile_id.clone(),
7320 };
7321 match self
7322 .spawn_managed_worktree_inner(
7323 legacy,
7324 Some(&request.spawn_spec.idempotency_key),
7325 )
7326 .await
7327 {
7328 Ok(receipt) => {
7329 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7330 Ok(receipt)
7331 }
7332 Err(error) => {
7333 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7334 Err(error)
7335 }
7336 }
7337 }
7338
7339 fn managed_profile(
7340 &self,
7341 workspace_id: &WorkspaceId,
7342 profile_id: &WorktreeProfileId,
7343 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7344 if self.worktree_service_modes.get(workspace_id).copied()
7345 != Some(WorktreeServiceMode::Managed)
7346 {
7347 return Err(failure(
7348 NodeFailureCode::UnsupportedSpawnCapability,
7349 "workspace does not enable managed worktrees",
7350 ));
7351 }
7352 self.managed_worktree_profiles.get(workspace_id)
7353 .and_then(|profiles| profiles.get(profile_id))
7354 .cloned()
7355 .ok_or_else(|| failure(
7356 NodeFailureCode::InvalidRequest,
7357 "managed worktree profile is unavailable for this workspace",
7358 ))
7359 }
7360
7361 async fn reconcile_failed_allocation(
7362 &self,
7363 lease: &ManagedWorktreeLeaseRecord,
7364 source_root: &str,
7365 ) {
7366 match list_git_worktrees(source_root).await {
7367 Ok(worktrees) => {
7368 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7369 None => {
7370 match std::fs::symlink_metadata(&lease.target_root) {
7371 Err(error) if error.kind() == io::ErrorKind::NotFound => {
7372 self.managed_worktrees.lock()
7373 .unwrap_or_else(|poisoned| poisoned.into_inner())
7374 .remove_unmutated(&lease.lease_id);
7375 let _ = self.persist_state();
7376 }
7377 _ => self.mark_managed_recovery_required(
7378 &lease.lease_id,
7379 ManagedWorktreeCleanupFailure::OwnershipConflict,
7380 ),
7381 }
7382 }
7383 Some(item) if exact_owned_worktree(
7384 lease,
7385 &item.path,
7386 item.branch.as_deref(),
7387 &item.head,
7388 ) => {
7389 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7390 }
7391 Some(_) => self.mark_managed_recovery_required(
7392 &lease.lease_id,
7393 ManagedWorktreeCleanupFailure::OwnershipConflict,
7394 ),
7395 }
7396 }
7397 Err(_) => self.mark_managed_recovery_required(
7398 &lease.lease_id,
7399 ManagedWorktreeCleanupFailure::Backend,
7400 ),
7401 }
7402 }
7403
7404 async fn reconcile_managed_worktrees(&self) {
7405 let leases = self.managed_worktrees.lock()
7406 .unwrap_or_else(|poisoned| poisoned.into_inner())
7407 .active_records()
7408 .cloned()
7409 .collect::<Vec<_>>();
7410 for lease in leases {
7411 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7412 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7413 _ => {
7414 self.mark_managed_recovery_required(
7415 &lease.lease_id,
7416 ManagedWorktreeCleanupFailure::OwnershipConflict,
7417 );
7418 continue;
7419 }
7420 };
7421 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7422 self.mark_managed_recovery_required(
7423 &lease.lease_id,
7424 ManagedWorktreeCleanupFailure::OwnershipConflict,
7425 );
7426 continue;
7427 }
7428 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7429 Ok(root) => root,
7430 Err(_) => {
7431 self.mark_managed_recovery_required(
7432 &lease.lease_id,
7433 ManagedWorktreeCleanupFailure::OwnershipConflict,
7434 );
7435 continue;
7436 }
7437 };
7438 let worktrees = match list_git_worktrees(&source_root).await {
7439 Ok(worktrees) => worktrees,
7440 Err(_) => {
7441 self.mark_managed_recovery_required(
7442 &lease.lease_id,
7443 ManagedWorktreeCleanupFailure::Backend,
7444 );
7445 continue;
7446 }
7447 };
7448 let listed = worktrees.iter()
7449 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7450 let Some(listed) = listed else {
7451 match std::fs::symlink_metadata(&lease.target_root) {
7452 Err(error) if error.kind() == io::ErrorKind::NotFound
7453 && lease.state == ManagedWorktreeLeaseState::Allocating
7454 && !lease.has_holders() => {
7455 let removed = self.managed_worktrees.lock()
7456 .unwrap_or_else(|poisoned| poisoned.into_inner())
7457 .tombstone(&lease.lease_id, unix_time_ms());
7458 if self.persist_state().is_ok() && removed.is_some() {
7459 self.publish(NodeEvent::ManagedWorktreeRemoved {
7460 lease_id: lease.lease_id.clone(),
7461 });
7462 }
7463 }
7464 _ => self.mark_managed_recovery_required(
7465 &lease.lease_id,
7466 ManagedWorktreeCleanupFailure::OwnershipConflict,
7467 ),
7468 }
7469 continue;
7470 };
7471 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7472 &lease,
7473 &listed.path,
7474 listed.branch.as_deref(),
7475 &listed.head,
7476 ) {
7477 self.mark_managed_recovery_required(
7478 &lease.lease_id,
7479 ManagedWorktreeCleanupFailure::OwnershipConflict,
7480 );
7481 continue;
7482 }
7483 if !self.workspaces.read()
7484 .unwrap_or_else(|poisoned| poisoned.into_inner())
7485 .contains_key(&lease.workspace_id)
7486 {
7487 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7488 self.mark_managed_recovery_required(
7489 &lease.lease_id,
7490 ManagedWorktreeCleanupFailure::Backend,
7491 );
7492 continue;
7493 }
7494 }
7495 let snapshot = {
7496 let mut registry = self.managed_worktrees.lock()
7497 .unwrap_or_else(|poisoned| poisoned.into_inner());
7498 let current = registry.get_mut(&lease.lease_id)
7499 .expect("reconciled managed lease remains present");
7500 current.expected_head = Some(listed.head.clone());
7501 current.cleanup_failure = None;
7502 current.state = if current.has_holders() {
7503 ManagedWorktreeLeaseState::InUse
7504 } else if current.retention == ManagedWorktreeRetention::Retain {
7505 ManagedWorktreeLeaseState::Retained
7506 } else {
7507 ManagedWorktreeLeaseState::Ready
7508 };
7509 current.updated_at_unix_ms = unix_time_ms();
7510 current.snapshot()
7511 };
7512 if self.persist_state().is_err() {
7513 self.mark_managed_recovery_required(
7514 &lease.lease_id,
7515 ManagedWorktreeCleanupFailure::Backend,
7516 );
7517 continue;
7518 }
7519 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7520 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7521 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7522 }
7523 }
7524 }
7525
7526 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7546 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7547 .session_records
7548 .lock()
7549 .unwrap_or_else(|poisoned| poisoned.into_inner())
7550 .records
7551 .values()
7552 .filter(|record| {
7553 record.state == ManagedSessionState::Live
7554 && record.exported_context.is_none()
7555 && record.provider_session.is_some()
7556 })
7557 .filter_map(|record| {
7558 record
7559 .active_session
7560 .clone()
7561 .map(|session| (record.record_id.clone(), session))
7562 })
7563 .collect();
7564 for (record_id, session) in candidates {
7565 let shared = Arc::clone(self);
7566 tokio::spawn(async move {
7567 shared
7568 .reconcile_context_pack_export_for_record(&record_id, session)
7569 .await;
7570 });
7571 }
7572 }
7573
7574 async fn reconcile_context_pack_export_for_record(
7593 &self,
7594 record_id: &SessionRecordId,
7595 session: SessionAddress,
7596 ) {
7597 let Ok(record) = self.record(record_id) else {
7598 return;
7599 };
7600 if !matches!(
7601 record.state,
7602 ManagedSessionState::Live | ManagedSessionState::Dormant
7603 ) || record.exported_context.is_some()
7604 || record.provider_session.is_none()
7605 {
7606 return;
7607 }
7608 let Ok(receipt) = self
7615 .export_context_pack_for_session_record(&record.record_id, &session, true)
7616 .await
7617 else {
7618 return;
7619 };
7620 self.set_exported_context(&record.record_id, receipt);
7621 }
7622
7623 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7628 let transaction = self
7629 .state_transaction
7630 .lock()
7631 .unwrap_or_else(|poisoned| poisoned.into_inner());
7632 let (previous, updated) = {
7633 let mut records = self
7634 .session_records
7635 .lock()
7636 .unwrap_or_else(|poisoned| poisoned.into_inner());
7637 let Some(record) = records.records.get_mut(record_id) else {
7638 return;
7639 };
7640 if record.exported_context.is_some() {
7641 return;
7642 }
7643 let previous = record.clone();
7644 record.exported_context = Some(receipt);
7645 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7646 (previous, record.clone())
7647 };
7648 if self.persist_state_locked().is_err() {
7649 self.session_records
7650 .lock()
7651 .unwrap_or_else(|poisoned| poisoned.into_inner())
7652 .records
7653 .insert(record_id.clone(), previous);
7654 return;
7655 }
7656 drop(transaction);
7657 self.publish_record(updated);
7658 }
7659
7660 fn managed_record_export_target_for_instance(
7670 &self,
7671 instance_id: AgentInstanceId,
7672 generation: SessionGeneration,
7673 ) -> Option<(SessionRecordId, SessionAddress)> {
7674 let bindings = self
7675 .session_bindings
7676 .lock()
7677 .unwrap_or_else(|poisoned| poisoned.into_inner());
7678 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7679 let record_id = binding.record_id.clone()?;
7680 Some((
7681 record_id,
7682 SessionAddress {
7683 workspace_id: binding.workspace_id.clone(),
7684 session: SessionKey { instance_id, generation },
7685 },
7686 ))
7687 }
7688
7689 fn mark_managed_recovery_required(
7690 &self,
7691 lease_id: &ManagedWorktreeLeaseId,
7692 failure_kind: ManagedWorktreeCleanupFailure,
7693 ) {
7694 let snapshot = {
7695 let mut registry = self.managed_worktrees.lock()
7696 .unwrap_or_else(|poisoned| poisoned.into_inner());
7697 let Some(lease) = registry.get_mut(lease_id) else { return };
7698 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7699 lease.cleanup_failure = Some(failure_kind);
7700 lease.updated_at_unix_ms = unix_time_ms();
7701 lease.snapshot()
7702 };
7703 let _ = self.persist_state();
7704 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7705 }
7706
7707 fn set_persistence_error(&self, error: Option<String>) {
7708 *self
7709 .persistence_error
7710 .write()
7711 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7712 .map(|message| session_registry::sanitized_persistence_summary(&message));
7713 }
7714
7715 fn persist_state(&self) -> io::Result<()> {
7716 let _transaction = self
7717 .state_transaction
7718 .lock()
7719 .unwrap_or_else(|poisoned| poisoned.into_inner());
7720 self.persist_state_locked()
7721 }
7722
7723 fn persist_state_locked(&self) -> io::Result<()> {
7724 let workspaces = self
7725 .workspaces
7726 .read()
7727 .unwrap_or_else(|poisoned| poisoned.into_inner())
7728 .clone();
7729 let records = self
7730 .session_records
7731 .lock()
7732 .unwrap_or_else(|poisoned| poisoned.into_inner())
7733 .records
7734 .values()
7735 .cloned()
7736 .collect::<Vec<_>>();
7737 let mut managed = self.managed_worktrees
7738 .lock()
7739 .unwrap_or_else(|poisoned| poisoned.into_inner());
7740 managed.reattach_record_holders(
7741 &managed_worktree_record_holders(&records),
7742 unix_time_ms(),
7743 );
7744 let materializations = self
7745 .materializations
7746 .lock()
7747 .unwrap_or_else(|poisoned| poisoned.into_inner())
7748 .values()
7749 .cloned()
7750 .collect::<Vec<_>>();
7751 let managed_spawn_replays = self.managed_spawn_replays
7752 .lock()
7753 .unwrap_or_else(|poisoned| poisoned.into_inner())
7754 .values()
7755 .cloned()
7756 .collect::<Vec<_>>();
7757 let result = session_registry::save_v10(
7758 self.state_path.as_deref(),
7759 &self.node_id,
7760 &workspaces,
7761 &records,
7762 &managed.records(),
7763 &managed.tombstones(),
7764 &materializations,
7765 &managed_spawn_replays,
7766 );
7767 match &result {
7768 Ok(warning) => self.set_persistence_error(warning.clone()),
7769 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7770 }
7771 result.map(|_| ())
7772 }
7773
7774 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7775 let _mutation_guard = self.mutation_gate.lock().await;
7776 self.begin_shutdown_locked().await
7777 }
7778
7779 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7780 if self.shutdown.swap(true, Ordering::AcqRel) {
7781 return Ok(());
7782 }
7783 SessionLifecycleHook::on_shutdown(self);
7784 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7785 let mut first_error = None;
7786 for session in &self.handle.snapshot().sessions {
7787 let remaining = deadline.saturating_duration_since(Instant::now());
7788 if remaining.is_zero() {
7789 first_error.get_or_insert_with(|| {
7790 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7791 });
7792 break;
7793 }
7794 if let Err(error) = self
7795 .dispatch_bounded(
7796 ControlCommand::Stop {
7797 instance_id: session.instance_id,
7798 force: true,
7799 },
7800 remaining,
7801 )
7802 .await
7803 {
7804 first_error.get_or_insert(error);
7805 }
7806 }
7807 self.shutdown_notify.notify_waiters();
7808 match first_error {
7809 Some(error) => Err(error),
7810 None => Ok(()),
7811 }
7812 }
7813
7814 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7815 let bindings = self
7816 .session_bindings
7817 .lock()
7818 .unwrap_or_else(|poisoned| poisoned.into_inner());
7819 if bindings.len() >= CONTROL_SESSIONS_MAX {
7820 Err(failure(
7821 NodeFailureCode::BackendBusy,
7822 "node session-binding capacity is full; remove an existing session first",
7823 ))
7824 } else {
7825 Ok(())
7826 }
7827 }
7828
7829 fn ensure_workspace_environment_profile_compatible(
7836 &self,
7837 workspace_id: &WorkspaceId,
7838 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7839 ) -> Result<(), NodeFailure> {
7840 let bindings = self
7841 .session_bindings
7842 .lock()
7843 .unwrap_or_else(|poisoned| poisoned.into_inner());
7844 for binding in bindings.values() {
7845 if &binding.workspace_id != workspace_id {
7846 continue;
7847 }
7848 if binding.environment_profile.as_ref() != environment_profile {
7849 return Err(failure(
7850 NodeFailureCode::WorkspaceBusy,
7851 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7852 ));
7853 }
7854 }
7855 Ok(())
7856 }
7857
7858 #[cfg(test)]
7859 fn bind_session(
7860 &self,
7861 instance_id: AgentInstanceId,
7862 workspace_id: WorkspaceId,
7863 generation: SessionGeneration,
7864 ) {
7865 self.bind_session_with_policy(
7866 &SessionAddress {
7867 workspace_id,
7868 session: SessionKey { instance_id, generation },
7869 },
7870 ProviderRuntimePolicy::raw_pty(),
7871 None,
7872 );
7873 }
7874
7875 #[cfg(test)]
7876 fn bind_session_with_policy(
7877 &self,
7878 address: &SessionAddress,
7879 runtime_policy: ProviderRuntimePolicy,
7880 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7881 ) {
7882 self.bind_session_with_materialization(
7883 address,
7884 runtime_policy,
7885 environment_profile,
7886 None,
7887 None,
7888 None,
7889 );
7890 }
7891
7892 fn bind_session_with_materialization(
7893 &self,
7894 address: &SessionAddress,
7895 runtime_policy: ProviderRuntimePolicy,
7896 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7897 bundle: Option<ResolvedBundleReceipt>,
7898 context: Option<ResolvedContextPackReceipt>,
7899 materialization_id: Option<MaterializationId>,
7900 ) {
7901 let mut bindings = self
7902 .session_bindings
7903 .lock()
7904 .unwrap_or_else(|poisoned| poisoned.into_inner());
7905 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7906 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7907 bindings.insert(address.session.instance_id, SessionBinding {
7908 workspace_id: address.workspace_id.clone(),
7909 generation: address.session.generation,
7910 runtime_policy,
7911 pending_resume: None,
7912 record_id: None,
7913 managed_worktree_lease_id: None,
7914 environment_profile,
7915 bundle,
7916 context,
7917 materialization_id,
7918 });
7919 }
7920
7921 #[cfg(test)]
7922 fn bind_managed_session(
7923 &self,
7924 address: &SessionAddress,
7925 record_id: SessionRecordId,
7926 runtime_policy: ProviderRuntimePolicy,
7927 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7928 ) {
7929 self.bind_managed_session_with_materialization(
7930 address,
7931 record_id,
7932 runtime_policy,
7933 environment_profile,
7934 None,
7935 None,
7936 None,
7937 );
7938 }
7939
7940 fn bind_managed_session_with_materialization(
7941 &self,
7942 address: &SessionAddress,
7943 record_id: SessionRecordId,
7944 runtime_policy: ProviderRuntimePolicy,
7945 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7946 bundle: Option<ResolvedBundleReceipt>,
7947 context: Option<ResolvedContextPackReceipt>,
7948 materialization_id: Option<MaterializationId>,
7949 ) {
7950 let mut bindings = self
7951 .session_bindings
7952 .lock()
7953 .unwrap_or_else(|poisoned| poisoned.into_inner());
7954 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7955 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7956 bindings.insert(
7957 address.session.instance_id,
7958 SessionBinding {
7959 workspace_id: address.workspace_id.clone(),
7960 generation: address.session.generation,
7961 runtime_policy,
7962 pending_resume: None,
7963 record_id: Some(record_id),
7964 managed_worktree_lease_id: None,
7965 environment_profile,
7966 bundle,
7967 context,
7968 materialization_id,
7969 },
7970 );
7971 }
7972
7973 #[cfg(test)]
7974 fn bind_spawn_session(
7975 &self,
7976 address: &SessionAddress,
7977 provider: AgentId,
7978 mode: SessionMode,
7979 runtime_policy: ProviderRuntimePolicy,
7980 record_policy: SpawnRecordPolicy,
7981 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7982 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7983 self.bind_spawn_session_with_materialization(
7984 address,
7985 provider,
7986 mode,
7987 runtime_policy,
7988 record_policy,
7989 environment_profile,
7990 None,
7991 None,
7992 None,
7993 )
7994 }
7995
7996 fn bind_spawn_session_with_materialization(
7997 &self,
7998 address: &SessionAddress,
7999 provider: AgentId,
8000 mode: SessionMode,
8001 runtime_policy: ProviderRuntimePolicy,
8002 record_policy: SpawnRecordPolicy,
8003 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8004 bundle: Option<ResolvedBundleReceipt>,
8005 context: Option<ResolvedContextPackReceipt>,
8006 materialization_id: Option<MaterializationId>,
8007 ) -> Result<Option<SessionRecordId>, NodeFailure> {
8008 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
8009 && !runtime_policy.provider_session_identity
8010 {
8011 self.bind_session_with_materialization(
8012 address,
8013 runtime_policy,
8014 environment_profile,
8015 bundle,
8016 context,
8017 materialization_id,
8018 );
8019 return Ok(None);
8020 }
8021 let record_owns_lifecycle = runtime_policy.provider_session_identity;
8022 let record = self.new_record(
8023 address,
8024 provider,
8025 mode,
8026 active_record_state(runtime_policy.provider_session_identity, false),
8027 None,
8028 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
8029 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
8030 record_owns_lifecycle.then(|| context.clone()).flatten(),
8031 )?;
8032 let record_id = record.record_id.clone();
8033 let transaction = self
8034 .state_transaction
8035 .lock()
8036 .unwrap_or_else(|poisoned| poisoned.into_inner());
8037 self.insert_record(record.clone())?;
8038 let previous_materialization = if record_owns_lifecycle {
8039 if let Some(materialization_id) = materialization_id.as_ref() {
8040 let mut materializations = self
8041 .materializations
8042 .lock()
8043 .unwrap_or_else(|poisoned| poisoned.into_inner());
8044 let Some(ownership) = materializations.get_mut(materialization_id) else {
8045 drop(materializations);
8046 self.remove_record_memory(&record_id);
8047 return Err(failure(
8048 NodeFailureCode::BackendOperationFailed,
8049 "session environment ownership disappeared before binding",
8050 ));
8051 };
8052 let previous = ownership.clone();
8053 if ownership
8054 .transfer_to_record(record_id.clone(), unix_time_ms())
8055 .is_err()
8056 {
8057 drop(materializations);
8058 self.remove_record_memory(&record_id);
8059 return Err(failure(
8060 NodeFailureCode::BackendOperationFailed,
8061 "session environment ownership transfer failed",
8062 ));
8063 }
8064 Some(previous)
8065 } else {
8066 None
8067 }
8068 } else {
8069 None
8070 };
8071 self.bind_managed_session_with_materialization(
8072 address,
8073 record_id.clone(),
8074 runtime_policy,
8075 environment_profile,
8076 bundle,
8077 context,
8078 materialization_id.clone(),
8079 );
8080 if let Err(error) = self.persist_state_locked() {
8081 self.remove_binding(address);
8082 self.remove_record_memory(&record_id);
8083 if let (Some(materialization_id), Some(previous)) =
8084 (materialization_id.as_ref(), previous_materialization)
8085 {
8086 self.materializations
8087 .lock()
8088 .unwrap_or_else(|poisoned| poisoned.into_inner())
8089 .insert(materialization_id.clone(), previous);
8090 }
8091 return Err(persistence_failure(error));
8092 }
8093 drop(transaction);
8094 self.publish_record(record);
8095 Ok(Some(record_id))
8096 }
8097
8098 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8099 for _ in 0..8 {
8100 let bytes = random_nonce().map_err(|error| {
8101 failure(
8102 NodeFailureCode::BackendOperationFailed,
8103 &format!("session record identity generation failed: {error}"),
8104 )
8105 })?;
8106 let mut value = String::with_capacity(27);
8107 value.push_str("sr-");
8108 for byte in &bytes[..12] {
8109 use std::fmt::Write as _;
8110 let _ = write!(&mut value, "{byte:02x}");
8111 }
8112 let record_id = SessionRecordId::new(value).map_err(|error| {
8113 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8114 })?;
8115 let records = self
8116 .session_records
8117 .lock()
8118 .unwrap_or_else(|poisoned| poisoned.into_inner());
8119 if !records.records.contains_key(&record_id) {
8120 return Ok(record_id);
8121 }
8122 }
8123 Err(failure(
8124 NodeFailureCode::BackendBusy,
8125 "could not allocate a unique session record ID",
8126 ))
8127 }
8128
8129 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8130 for _ in 0..8 {
8131 let bytes = random_nonce().map_err(|error| {
8132 failure(
8133 NodeFailureCode::BackendOperationFailed,
8134 &format!("task identity generation failed: {error}"),
8135 )
8136 })?;
8137 let mut nonce = [0_u8; 12];
8138 nonce.copy_from_slice(&bytes[..12]);
8139 let task_id = TaskId::from_nonce(nonce);
8140 let records = self
8141 .session_records
8142 .lock()
8143 .unwrap_or_else(|poisoned| poisoned.into_inner());
8144 if !records.records.values().any(|record| {
8145 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8146 == Some(&task_id)
8147 }) {
8148 return Ok(task_id);
8149 }
8150 }
8151 Err(failure(
8152 NodeFailureCode::BackendBusy,
8153 "could not allocate a unique task ID",
8154 ))
8155 }
8156
8157 fn default_record_name(&self, provider: &AgentId) -> String {
8158 let records = self
8159 .session_records
8160 .lock()
8161 .unwrap_or_else(|poisoned| poisoned.into_inner());
8162 let ordinal = records
8163 .records
8164 .values()
8165 .filter(|record| &record.provider == provider)
8166 .count()
8167 .saturating_add(1);
8168 format!("{} #{ordinal}", provider.as_str())
8169 }
8170
8171 fn new_record(
8172 &self,
8173 address: &SessionAddress,
8174 provider: AgentId,
8175 mode: SessionMode,
8176 state: ManagedSessionState,
8177 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8178 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8179 bundle: Option<ResolvedBundleReceipt>,
8180 context: Option<ResolvedContextPackReceipt>,
8181 ) -> Result<ManagedSessionRecord, NodeFailure> {
8182 let canonical_root = self.workspace_root(&address.workspace_id)?;
8183 let now = unix_time_ms();
8184 Ok(ManagedSessionRecord {
8185 record_id: self.allocate_record_id()?,
8186 display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8187 provider,
8188 mode,
8189 state,
8190 workspace_id: address.workspace_id.clone(),
8191 canonical_root: opaque_windows_path(canonical_root),
8192 provider_session,
8193 active_session: Some(address.clone()),
8194 environment_profile,
8195 bundle,
8196 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8197 context,
8198 exported_context: None,
8199 task_binding: None,
8200 created_at_unix_ms: now,
8201 updated_at_unix_ms: now,
8202 last_error: None,
8203 })
8204 }
8205
8206 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8207 let mut records = self
8208 .session_records
8209 .lock()
8210 .unwrap_or_else(|poisoned| poisoned.into_inner());
8211 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8212 return Err(failure(
8213 NodeFailureCode::BackendBusy,
8214 "managed session record capacity is full; forget an inactive record first",
8215 ));
8216 }
8217 if let Some(identity) = record.provider_session.as_ref() {
8218 if records.records.values().any(|candidate| {
8219 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8220 session_registry::same_provider_session(
8221 &candidate.provider,
8222 candidate_identity,
8223 &record.provider,
8224 identity,
8225 )
8226 })
8227 }) {
8228 return Err(failure(
8229 NodeFailureCode::SessionRecordConflict,
8230 "provider session is already represented by another managed record",
8231 ));
8232 }
8233 }
8234 match records.records.entry(record.record_id.clone()) {
8235 std::collections::btree_map::Entry::Vacant(entry) => {
8236 entry.insert(record);
8237 }
8238 std::collections::btree_map::Entry::Occupied(_) => {
8239 return Err(failure(
8240 NodeFailureCode::SessionRecordConflict,
8241 "session record ID collision",
8242 ));
8243 }
8244 }
8245 Ok(())
8246 }
8247
8248 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8249 self.session_records
8250 .lock()
8251 .unwrap_or_else(|poisoned| poisoned.into_inner())
8252 .records
8253 .get(record_id)
8254 .cloned()
8255 .ok_or_else(|| {
8256 failure(
8257 NodeFailureCode::UnknownSessionRecord,
8258 "managed session record does not exist",
8259 )
8260 })
8261 }
8262
8263 fn revalidate_session_record_preview(
8264 &self,
8265 expected: &ManagedSessionRecord,
8266 identity: &ProviderSessionIdentity,
8267 ) -> Result<(), NodeFailure> {
8268 let current = self.record(&expected.record_id)?;
8269 let current_root = self.workspace_root(¤t.workspace_id)?;
8270 if current.provider != expected.provider
8271 || current.workspace_id != expected.workspace_id
8272 || current.canonical_root != expected.canonical_root
8273 || current.provider_session.as_ref() != Some(identity)
8274 || !platform::roots_equal(
8275 ¤t_root,
8276 windows_path_text(¤t.canonical_root),
8277 )
8278 {
8279 return Err(failure(
8280 NodeFailureCode::SessionRecordConflict,
8281 "managed session changed while its preview was loading",
8282 ));
8283 }
8284 Ok(())
8285 }
8286
8287 fn revalidate_session_record_context_export(
8288 &self,
8289 expected: &ManagedSessionRecord,
8290 identity: Option<&ProviderSessionIdentity>,
8291 session: &SessionAddress,
8292 allow_clean_detachment: bool,
8293 ) -> Result<(), NodeFailure> {
8294 let current = self.record(&expected.record_id)?;
8295 let current_root = self.workspace_root(¤t.workspace_id)?;
8296 let snapshot = self.internal_session_snapshot(session)?;
8297 let binding = self
8298 .session_bindings
8299 .lock()
8300 .unwrap_or_else(|poisoned| poisoned.into_inner())
8301 .get(&session.session.instance_id)
8302 .cloned()
8303 .ok_or_else(|| {
8304 failure(
8305 NodeFailureCode::SessionRecordConflict,
8306 "managed session runtime binding disappeared during context export",
8307 )
8308 })?;
8309 if !session_record_context_export_binding_is_exact(
8310 expected,
8311 ¤t,
8312 identity,
8313 session,
8314 &binding,
8315 &snapshot.agent_id,
8316 ¤t_root,
8317 allow_clean_detachment,
8318 ) || !session_record_context_export_source_is_usable(
8319 ¤t,
8320 &snapshot.status,
8321 allow_clean_detachment,
8322 ) {
8323 return Err(failure(
8324 NodeFailureCode::SessionRecordConflict,
8325 "managed session binding changed during context export",
8326 ));
8327 }
8328 Ok(())
8329 }
8330
8331 fn revalidate_loaded_history(
8332 &self,
8333 session: &SessionAddress,
8334 candidate_id: &str,
8335 loaded: &HistorySessionRecord,
8336 ) -> Result<(), NodeFailure> {
8337 let snapshot = self.internal_session_snapshot(session)?;
8338 if snapshot.history.pending.is_some()
8339 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8340 || snapshot.history.loaded.as_ref() != Some(loaded)
8341 {
8342 return Err(failure(
8343 NodeFailureCode::SessionRecordConflict,
8344 "managed session history changed during context export",
8345 ));
8346 }
8347 Ok(())
8348 }
8349
8350 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8351 self.session_records
8352 .lock()
8353 .unwrap_or_else(|poisoned| poisoned.into_inner())
8354 .records
8355 .remove(record_id)
8356 }
8357
8358 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8359 if self.destroy_record_materialization(record_id)? {
8360 self.publish(NodeEvent::SessionRecordRemoved {
8361 record_id: record_id.clone(),
8362 });
8363 return Ok(());
8364 }
8365 let transaction = self
8366 .state_transaction
8367 .lock()
8368 .unwrap_or_else(|poisoned| poisoned.into_inner());
8369 let Some(removed) = self.remove_record_memory(record_id) else {
8370 return Ok(());
8371 };
8372 if let Err(error) = self.persist_state_locked() {
8373 let _ = self.insert_record(removed);
8374 return Err(persistence_failure(error));
8375 }
8376 drop(transaction);
8377 self.publish(NodeEvent::SessionRecordRemoved {
8378 record_id: record_id.clone(),
8379 });
8380 Ok(())
8381 }
8382
8383 fn publish_record(&self, record: ManagedSessionRecord) {
8384 self.publish(NodeEvent::SessionRecordUpserted { record });
8385 }
8386
8387 fn mark_record_error(
8388 &self,
8389 record_id: &SessionRecordId,
8390 message: &str,
8391 ) -> Result<(), NodeFailure> {
8392 let transaction = self
8393 .state_transaction
8394 .lock()
8395 .unwrap_or_else(|poisoned| poisoned.into_inner());
8396 let previous = {
8397 let mut records = self
8398 .session_records
8399 .lock()
8400 .unwrap_or_else(|poisoned| poisoned.into_inner());
8401 let Some(record) = records.records.get_mut(record_id) else {
8402 return Ok(());
8403 };
8404 let previous = record.clone();
8405 record.state = ManagedSessionState::Unavailable;
8406 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8407 record.updated_at_unix_ms = unix_time_ms();
8408 previous
8409 };
8410 if let Err(error) = self.persist_state_locked() {
8411 self.session_records
8412 .lock()
8413 .unwrap_or_else(|poisoned| poisoned.into_inner())
8414 .records
8415 .insert(record_id.clone(), previous);
8416 return Err(persistence_failure(error));
8417 }
8418 let record = self.record(record_id)?;
8419 drop(transaction);
8420 self.publish_record(record);
8421 Ok(())
8422 }
8423
8424 fn rename_session_record(
8425 &self,
8426 record_id: &SessionRecordId,
8427 display_name: String,
8428 ) -> Result<ManagedSessionRecord, NodeFailure> {
8429 validate_display_name(&display_name)
8430 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8431 let _transaction = self
8432 .state_transaction
8433 .lock()
8434 .unwrap_or_else(|poisoned| poisoned.into_inner());
8435 let previous = {
8436 let mut records = self
8437 .session_records
8438 .lock()
8439 .unwrap_or_else(|poisoned| poisoned.into_inner());
8440 let record = records.records.get_mut(record_id).ok_or_else(|| {
8441 failure(
8442 NodeFailureCode::UnknownSessionRecord,
8443 "managed session record does not exist",
8444 )
8445 })?;
8446 let previous = record.clone();
8447 record.display_name = display_name;
8448 record.updated_at_unix_ms = unix_time_ms();
8449 previous
8450 };
8451 if let Err(error) = self.persist_state_locked() {
8452 self.session_records
8453 .lock()
8454 .unwrap_or_else(|poisoned| poisoned.into_inner())
8455 .records
8456 .insert(record_id.clone(), previous);
8457 return Err(persistence_failure(error));
8458 }
8459 let updated = self.record(record_id)?;
8460 self.publish_record(updated.clone());
8461 Ok(updated)
8462 }
8463
8464 fn set_session_task(
8465 &self,
8466 record_id: &SessionRecordId,
8467 expected_revision: u64,
8468 target: SessionTaskTargetV1,
8469 ) -> Result<ManagedSessionRecord, NodeFailure> {
8470 let transaction = self
8471 .state_transaction
8472 .lock()
8473 .unwrap_or_else(|poisoned| poisoned.into_inner());
8474 let current = self.record(record_id)?;
8475 let current_task_id = current
8476 .task_binding
8477 .as_ref()
8478 .and_then(|binding| binding.task_id.as_ref());
8479 let current_revision = current
8480 .task_binding
8481 .as_ref()
8482 .map_or(0, |binding| binding.revision);
8483 if expected_revision != current_revision {
8484 return Err(failure(
8485 NodeFailureCode::SessionRecordConflict,
8486 "managed session task binding revision changed",
8487 ));
8488 }
8489 let exact = match &target {
8490 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8491 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8492 SessionTaskTargetV1::New => false,
8493 };
8494 if exact {
8495 return Ok(current);
8496 }
8497 let revision = current_revision.checked_add(1).ok_or_else(|| {
8498 failure(
8499 NodeFailureCode::SessionRecordConflict,
8500 "managed session task binding revision is exhausted",
8501 )
8502 })?;
8503 let task_id = match target {
8504 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8505 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8506 SessionTaskTargetV1::Clear => None,
8507 };
8508 let changed_at_unix_ms = unix_time_ms()
8509 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8510 failure(
8511 NodeFailureCode::SessionRecordConflict,
8512 "managed session record timestamp is exhausted",
8513 )
8514 })?);
8515 let updated = {
8516 let mut records = self
8517 .session_records
8518 .lock()
8519 .unwrap_or_else(|poisoned| poisoned.into_inner());
8520 let record = records.records.get_mut(record_id).ok_or_else(|| {
8521 failure(
8522 NodeFailureCode::UnknownSessionRecord,
8523 "managed session record does not exist",
8524 )
8525 })?;
8526 record.task_binding = Some(SessionTaskBindingV1 {
8527 revision,
8528 task_id,
8529 changed_at_unix_ms,
8530 });
8531 record.updated_at_unix_ms = changed_at_unix_ms;
8532 record.clone()
8533 };
8534 if let Err(error) = self.persist_state_locked() {
8535 self.session_records
8536 .lock()
8537 .unwrap_or_else(|poisoned| poisoned.into_inner())
8538 .records
8539 .insert(record_id.clone(), current);
8540 return Err(persistence_failure(error));
8541 }
8542 drop(transaction);
8543 self.publish_record(updated.clone());
8544 Ok(updated)
8545 }
8546
8547 fn index_provider_session(
8548 &self,
8549 workspace_id: WorkspaceId,
8550 provider: AgentId,
8551 identity: ProviderSessionIdentity,
8552 display_name: String,
8553 ) -> Result<ManagedSessionRecord, NodeFailure> {
8554 self.index_provider_session_with_policy(
8555 workspace_id,
8556 provider,
8557 identity,
8558 display_name,
8559 false,
8560 )
8561 }
8562
8563 fn index_provider_session_with_policy(
8564 &self,
8565 workspace_id: WorkspaceId,
8566 provider: AgentId,
8567 identity: ProviderSessionIdentity,
8568 display_name: String,
8569 allow_validated_transcript_path: bool,
8570 ) -> Result<ManagedSessionRecord, NodeFailure> {
8571 validate_display_name(&display_name)
8572 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8573 identity
8574 .validate()
8575 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8576 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8577 return Err(failure(
8578 NodeFailureCode::InvalidRequest,
8579 "provider session reference index v1 does not accept transcript paths",
8580 ));
8581 }
8582 let canonical_root = self.workspace_root(&workspace_id)?;
8583 if !self.enabled_providers.contains(&provider) {
8584 return Err(failure(
8585 NodeFailureCode::InvalidRequest,
8586 "provider is not enabled on this node",
8587 ));
8588 }
8589
8590 let transaction = self
8591 .state_transaction
8592 .lock()
8593 .unwrap_or_else(|poisoned| poisoned.into_inner());
8594 {
8595 let records = self
8596 .session_records
8597 .lock()
8598 .unwrap_or_else(|poisoned| poisoned.into_inner());
8599 if let Some(existing) = records.records.values().find(|candidate| {
8600 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8601 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8602 })
8603 }) {
8604 if existing.provider == provider && existing.workspace_id == workspace_id {
8605 return Ok(existing.clone());
8606 }
8607 return Err(failure(
8608 NodeFailureCode::SessionRecordConflict,
8609 "provider session reference conflicts with another provider or workspace",
8610 ));
8611 }
8612 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8613 return Err(failure(
8614 NodeFailureCode::BackendBusy,
8615 "managed session record capacity is full; forget an inactive record first",
8616 ));
8617 }
8618 }
8619
8620 let now = unix_time_ms();
8621 let record = ManagedSessionRecord {
8622 record_id: self.allocate_record_id()?,
8623 display_name,
8624 provider,
8625 mode: SessionMode::Pty,
8626 state: ManagedSessionState::Dormant,
8627 workspace_id,
8628 canonical_root: opaque_windows_path(canonical_root),
8629 provider_session: Some(identity),
8630 active_session: None,
8631 environment_profile: None,
8632 bundle: None,
8633 context_id: None,
8634 context: None,
8635 exported_context: None,
8636 task_binding: None,
8637 created_at_unix_ms: now,
8638 updated_at_unix_ms: now,
8639 last_error: None,
8640 };
8641 self.insert_record(record.clone())?;
8642 if let Err(error) = self.persist_state_locked() {
8643 self.remove_record_memory(&record.record_id);
8644 return Err(persistence_failure(error));
8645 }
8646 drop(transaction);
8647 self.publish_record(record.clone());
8648 Ok(record)
8649 }
8650
8651 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8652 self.session_bindings
8653 .lock()
8654 .unwrap_or_else(|poisoned| poisoned.into_inner())
8655 .iter()
8656 .find_map(|(instance_id, binding)| {
8657 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8658 workspace_id: binding.workspace_id.clone(),
8659 session: SessionKey {
8660 instance_id: *instance_id,
8661 generation: binding.generation,
8662 },
8663 })
8664 })
8665 }
8666
8667 async fn forget_session_record(
8668 &self,
8669 record_id: &SessionRecordId,
8670 ) -> Result<(), NodeFailure> {
8671 let record = self.record(record_id)?;
8672 if record.active_session.is_some()
8673 || matches!(
8674 record.state,
8675 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8676 )
8677 {
8678 return Err(failure(
8679 NodeFailureCode::SessionRecordBusy,
8680 "stop the managed session before forgetting it",
8681 ));
8682 }
8683 if let Some(address) = self.bound_address_for_record(record_id) {
8684 self.remove_session(&address).await?;
8685 }
8686 self.remove_managed_session_record(record_id, &record.workspace_id).await
8687 }
8688
8689 async fn remove_managed_session_record(
8704 &self,
8705 record_id: &SessionRecordId,
8706 workspace_id: &WorkspaceId,
8707 ) -> Result<(), NodeFailure> {
8708 let managed_lease_id = self.managed_worktrees.lock()
8709 .unwrap_or_else(|poisoned| poisoned.into_inner())
8710 .lease_for_workspace(workspace_id);
8711 if !self.destroy_record_materialization(record_id)? {
8712 let transaction = self
8713 .state_transaction
8714 .lock()
8715 .unwrap_or_else(|poisoned| poisoned.into_inner());
8716 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8717 failure(
8718 NodeFailureCode::UnknownSessionRecord,
8719 "managed session record does not exist",
8720 )
8721 })?;
8722 if let Err(error) = self.persist_state_locked() {
8723 let _ = self.insert_record(removed);
8724 return Err(persistence_failure(error));
8725 }
8726 drop(transaction);
8727 }
8728 self.publish(NodeEvent::SessionRecordRemoved {
8729 record_id: record_id.clone(),
8730 });
8731 if let Some(lease_id) = managed_lease_id {
8732 if let Some(snapshot) = self.managed_worktrees.lock()
8733 .unwrap_or_else(|poisoned| poisoned.into_inner())
8734 .get(&lease_id)
8735 .map(ManagedWorktreeLeaseRecord::snapshot)
8736 {
8737 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8738 }
8739 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8740 }
8741 Ok(())
8742 }
8743
8744 async fn retire_unavailable_session_records(&self) {
8757 let policy = self.session_record_retention;
8758 let age_enabled = policy.age_ms > 0;
8759 let keep_enabled = policy.keep_per_workspace > 0;
8760 if !age_enabled && !keep_enabled {
8761 return;
8762 }
8763 let now = unix_time_ms();
8764 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8765 self.session_records
8766 .lock()
8767 .unwrap_or_else(|poisoned| poisoned.into_inner())
8768 .records
8769 .values()
8770 .filter(|record| record.state == ManagedSessionState::Unavailable)
8771 .map(|record| {
8772 (
8773 record.record_id.clone(),
8774 record.workspace_id.clone(),
8775 record.provider.clone(),
8776 record.updated_at_unix_ms,
8777 )
8778 })
8779 .collect()
8780 };
8781 if candidates.is_empty() {
8782 return;
8783 }
8784 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8785 BTreeMap::new();
8786 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8787 by_workspace
8788 .entry(workspace_id)
8789 .or_default()
8790 .push((record_id, provider, updated_at_unix_ms));
8791 }
8792 let reason = if age_enabled && keep_enabled {
8793 "age+keep-n"
8794 } else if age_enabled {
8795 "age"
8796 } else {
8797 "keep-n"
8798 };
8799 let mut retired: u64 = 0;
8800 for (workspace_id, mut records) in by_workspace {
8801 records.sort_by(|left, right| right.2.cmp(&left.2));
8804 for (index, (record_id, provider, updated_at_unix_ms)) in
8805 records.into_iter().enumerate()
8806 {
8807 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8808 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8809 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8810 let should_retire = if age_enabled && keep_enabled {
8811 age_hit && keep_hit
8812 } else {
8813 age_hit || keep_hit
8814 };
8815 if !should_retire {
8816 continue;
8817 }
8818 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8819 Ok(()) => {
8820 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8821 retired += 1;
8822 tracing::info!(
8823 node_id = %self.node_id,
8824 record_id = %record_id,
8825 workspace_id = %workspace_id,
8826 provider = %provider,
8827 age_ms = record_age_ms,
8828 reason,
8829 "managed session record retired"
8830 );
8831 }
8832 Err(error) => tracing::warn!(
8833 node_id = %self.node_id,
8834 record_id = %record_id,
8835 workspace_id = %workspace_id,
8836 code = ?error.code,
8837 cause = %error.message,
8838 "session record retention sweep failed to retire a record"
8839 ),
8840 }
8841 }
8842 }
8843 if retired > 0 {
8844 let remaining_unavailable = self
8845 .session_records
8846 .lock()
8847 .unwrap_or_else(|poisoned| poisoned.into_inner())
8848 .records
8849 .values()
8850 .filter(|record| record.state == ManagedSessionState::Unavailable)
8851 .count();
8852 tracing::info!(
8853 node_id = %self.node_id,
8854 retired,
8855 remaining_unavailable,
8856 "session record retention sweep completed"
8857 );
8858 }
8859 }
8860
8861 async fn resume_session_record(
8862 &self,
8863 record_id: &SessionRecordId,
8864 terminal_size: gate4agent_types::TerminalSize,
8865 initial_prompt: Option<String>,
8866 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8867 let record = self.record(record_id)?;
8868 let acp_codex = record.mode == SessionMode::Acp && record.provider.as_str() == "codex";
8869 let instance_id = RESTORE_INSTANCE_ID.try_with(|id| *id).unwrap_or_else(|_| {
8870 AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel))
8871 });
8872 RESTORE_INSTANCE_ID.scope(instance_id, async {
8874 let result = self.resume_session_record_with_mode(
8875 record_id, terminal_size, initial_prompt.clone(), None,
8876 ).await;
8877 match result {
8878 Err(error) if acp_codex && matches!(
8879 error.code,
8880 NodeFailureCode::BackendOperationFailed | NodeFailureCode::SessionRecordNotResumable
8881 ) => {
8882 tracing::info!(record = %record_id, "ACP resume unavailable; trying native PTY resume");
8883 self.resume_session_record_with_mode(
8884 record_id, terminal_size, initial_prompt, Some(SessionMode::Pty),
8885 ).await
8886 }
8887 other => other,
8888 }
8889 }).await
8890 }
8891
8892 async fn resume_session_record_with_mode(
8893 &self,
8894 record_id: &SessionRecordId,
8895 terminal_size: gate4agent_types::TerminalSize,
8896 initial_prompt: Option<String>,
8897 mode: Option<SessionMode>,
8898 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8899 let mut record = self.record(record_id)?;
8900 if let Some(mode) = mode {
8901 record.mode = mode;
8902 }
8903 if record.mode == SessionMode::Acp
8905 && record.provider_session.as_ref().is_some_and(|i| {
8906 record.provider.as_str() == "claude" && provider_session_exists("claude", &i.id)
8907 })
8908 {
8909 record.mode = SessionMode::Pty;
8910 tracing::info!(record = %record_id, "acp record resumed through its native pty resume command");
8911 }
8912 if record.active_session.is_some()
8913 || matches!(
8914 record.state,
8915 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8916 )
8917 {
8918 return Err(failure(
8919 NodeFailureCode::SessionRecordBusy,
8920 "managed session already has a live runtime binding",
8921 ));
8922 }
8923 let identity = record.provider_session.clone().ok_or_else(|| {
8924 failure(
8925 NodeFailureCode::SessionRecordNotResumable,
8926 "managed session has no verified provider session identity",
8927 )
8928 })?;
8929 let working_directory = self.workspace_root(&record.workspace_id)?;
8930 if !platform::roots_equal(
8931 &working_directory,
8932 windows_path_text(&record.canonical_root),
8933 ) {
8934 return Err(failure(
8935 NodeFailureCode::SessionRecordConflict,
8936 "managed session workspace root changed; refusing to resume in another directory",
8937 ));
8938 }
8939 {
8940 let records = self
8941 .session_records
8942 .lock()
8943 .unwrap_or_else(|poisoned| poisoned.into_inner());
8944 if records.records.values().any(|candidate| {
8945 candidate.record_id != *record_id
8946 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8947 session_registry::same_provider_session(
8948 &candidate.provider,
8949 candidate_identity,
8950 &record.provider,
8951 &identity,
8952 )
8953 })
8954 && (candidate.active_session.is_some()
8955 || matches!(
8956 candidate.state,
8957 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8958 ))
8959 }) {
8960 return Err(failure(
8961 NodeFailureCode::SessionRecordConflict,
8962 "provider session is already active through another managed record",
8963 ));
8964 }
8965 }
8966 let runtime_requirement = if initial_prompt.is_some() {
8967 ProviderRuntimeRequirement::ResumeWithPrompt
8968 } else {
8969 ProviderRuntimeRequirement::Resume
8970 };
8971 let runtime_policy = self.admit_provider_runtime(
8972 &record.provider,
8973 runtime_requirement,
8974 )
8975 .await?;
8976 let working_directory = match self.m4a_accounts.as_ref() {
8978 Some(_) => {
8979 let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8980 if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8981 }
8982 None => working_directory,
8983 };
8984 let request = ResumeLaunchRequest {
8985 working_directory,
8986 terminal_size,
8987 initial_prompt,
8988 };
8989 request
8990 .validate()
8991 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8992 self.ensure_binding_capacity()?;
8993 let resolved_materialization = match self.resolve_record_materialization(
8994 record_id,
8995 &record.provider,
8996 record.mode,
8997 record.environment_profile.as_ref(),
8998 record.bundle.as_ref(),
8999 record.context.as_ref(),
9000 ) {
9001 Ok(materialization) => materialization,
9002 Err(error) => {
9003 let label = match error.code {
9004 NodeFailureCode::UnknownBundle => "bundle-unavailable",
9005 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
9006 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
9007 _ => "environment-profile-unavailable",
9008 };
9009 self.mark_record_error(record_id, label)?;
9010 return Err(error);
9011 }
9012 };
9013 let (materialization_id, environment_overlay) = match resolved_materialization {
9014 Some((id, overlay)) => (Some(id), overlay),
9015 None => (None, None),
9016 };
9017 let instance_id = RESTORE_INSTANCE_ID.try_with(|id| *id)
9018 .unwrap_or_else(|_| AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel)));
9019 let environment_overlay = {
9021 let mut args: Vec<OsString> = Vec::new();
9022 if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
9023 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
9024 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
9025 crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
9026 }
9027 args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
9028 let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
9029 if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
9030 if let Some(sel) = options.as_ref() {
9031 args.push("--model".into()); args.push(sel.model.clone().into());
9032 if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
9033 args.push("--effort".into()); args.push(effort.to_owned().into());
9034 }
9035 }
9036 }
9037 if record.mode == SessionMode::Pty && record.provider.as_str() == "codex" {
9038 if let Some(selection) = options.as_ref() {
9039 args.push("--model".into());
9040 args.push(selection.model.clone().into());
9041 if let Some(effort) = selection.values.get("effort").and_then(|v| v.as_str()) {
9042 args.push("-c".into());
9043 args.push(format!("model_reasoning_effort={}", serde_json::to_string(effort).expect("string serialization")).into());
9044 }
9045 }
9046 }
9047 if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::journalled(record.provider.as_str(), record.mode == SessionMode::Acp) {
9048 if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
9049 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
9050 revision: 0,
9051 instance_id: instance_id.0,
9052 provider: record.provider.as_str().to_owned(),
9053 workspace_id: record.workspace_id.as_str().to_owned(),
9054 provider_session_id: identity.id.clone(),
9055 session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
9056 cwd: request.working_directory.clone(),
9057 acp: record.mode == SessionMode::Acp,
9058 options,
9059 cols: terminal_size.columns,
9060 rows: terminal_size.rows,
9061 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
9062 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
9063 });
9064 }
9065 }
9066 overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
9067 };
9068 let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
9070 (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
9071 if let Some(old) = self.bound_address_for_record(record_id) {
9072 if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
9073 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
9074 }
9075 }
9076 let session_key = self.m4a_session_key(instance_id);
9077 self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
9078 }
9079 _ => environment_overlay,
9080 };
9081 let mut environment_selection = match SpawnPreparer::select_environment(self,
9082 instance_id,
9083 &record.provider,
9084 record.mode,
9085 record.environment_profile.as_ref(),
9086 ) {
9087 Ok(selection) => selection,
9088 Err(error) => {
9089 self.mark_record_error(record_id, "environment-profile-unavailable")?;
9090 return Err(error);
9091 }
9092 };
9093 let mut instance_overlay = environment_overlay
9094 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
9095 .transpose()?
9096 .flatten();
9097 if let Some(stale_address) = self.bound_address_for_record(record_id) {
9098 self.remove_session(&stale_address).await?;
9099 }
9100 let mut address = SessionAddress {
9101 workspace_id: record.workspace_id.clone(),
9102 session: SessionKey {
9103 instance_id,
9104 generation: SessionGeneration(1),
9105 },
9106 };
9107 let transaction = self
9108 .state_transaction
9109 .lock()
9110 .unwrap_or_else(|poisoned| poisoned.into_inner());
9111 let current = self.record(record_id)?;
9112 if current.active_session.is_some()
9113 || matches!(
9114 current.state,
9115 ManagedSessionState::Live | ManagedSessionState::IdentityPending
9116 )
9117 {
9118 return Err(failure(
9119 NodeFailureCode::SessionRecordBusy,
9120 "managed session became active while resume was being prepared",
9121 ));
9122 }
9123 if let Some(current) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
9124 current.mode = record.mode;
9125 }
9126 self.bind_managed_session_with_materialization(
9127 &address,
9128 record_id.clone(),
9129 runtime_policy,
9130 record.environment_profile.clone(),
9131 record.bundle.clone(),
9132 record.context.clone(),
9133 materialization_id,
9134 );
9135 if let Some(selection) = environment_selection.take() {
9136 selection.retain();
9137 }
9138 if let Some(overlay) = instance_overlay.take() {
9139 overlay.retain();
9140 }
9141 drop(transaction);
9142
9143 let transport = match record.mode {
9144 SessionMode::Pty => TransportKind::Pty,
9145 SessionMode::Inline => TransportKind::Pipe,
9146 SessionMode::Acp => TransportKind::Acp,
9147 };
9148 let agent_id = record.provider.clone();
9149 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9150 let registration = self
9151 .dispatch_bounded(
9152 ControlCommand::Register {
9153 instance_id,
9154 agent_id,
9155 transport,
9156 },
9157 dispatch_timeout,
9158 )
9159 .await;
9160 let registration = match registration {
9161 Ok(command_id) => command_id,
9162 Err(error) => {
9163 if let Some(binding) = self.remove_binding(&address) {
9164 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9165 }
9166 return Err(error);
9167 }
9168 };
9169 let deadline = Instant::now() + dispatch_timeout;
9173 loop {
9174 if let Some(reason) = self.take_rejected_command_reason(registration) {
9175 if let Some(binding) = self.remove_binding(&address) {
9176 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9177 }
9178 return Err(failure(NodeFailureCode::BackendOperationFailed, &reason.message));
9179 }
9180 if let Some(session) = self.handle.snapshot().sessions.iter()
9181 .find(|session| session.instance_id == instance_id)
9182 {
9183 address.session.generation = session.generation;
9184 if let Some(binding) = self.session_bindings.lock()
9185 .unwrap_or_else(|p| p.into_inner()).get_mut(&instance_id)
9186 {
9187 binding.generation = session.generation;
9188 }
9189 break;
9190 }
9191 if Instant::now() >= deadline {
9192 self.rollback_spawn(&address, dispatch_timeout).await?;
9193 return Err(failure(NodeFailureCode::BackendBusy,
9194 "resume registration did not settle before the bounded deadline"));
9195 }
9196 sleep(Duration::from_millis(2)).await;
9197 }
9198 let command = self.prepare_command(ControlCommand::Resume {
9199 instance_id,
9200 target: ResumeTarget::ProviderSession { identity },
9201 runtime_policy,
9202 request,
9203 });
9204 let started_after = self.current_sequence();
9205 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9206 self.rollback_spawn(&address, dispatch_timeout).await?;
9207 return Err(error);
9208 }
9209 if let Err(error) = self.dispatch_envelope(command) {
9210 self.clear_armed_resume(&address);
9211 self.rollback_spawn(&address, dispatch_timeout).await?;
9212 return Err(error);
9213 }
9214 match self
9215 .wait_for_record_resume(&address, started_after)
9216 .await
9217 {
9218 Ok(settled_address) => {
9219 let managed_lease_id = {
9220 self.managed_worktrees.lock()
9221 .unwrap_or_else(|poisoned| poisoned.into_inner())
9222 .lease_for_workspace(&record.workspace_id)
9223 };
9224 if let Some(lease_id) = managed_lease_id {
9225 {
9226 let mut bindings = self.session_bindings.lock()
9227 .unwrap_or_else(|poisoned| poisoned.into_inner());
9228 let binding = bindings.get_mut(&settled_address.session.instance_id)
9229 .ok_or_else(|| failure(
9230 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9231 "resumed managed session lost its exact binding",
9232 ))?;
9233 if binding.generation != settled_address.session.generation {
9234 return Err(failure(
9235 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9236 "resumed managed session generation diverged",
9237 ));
9238 }
9239 binding.managed_worktree_lease_id = Some(lease_id.clone());
9240 }
9241 let snapshot = self.managed_worktrees.lock()
9242 .unwrap_or_else(|poisoned| poisoned.into_inner())
9243 .bind_session(
9244 &lease_id,
9245 ManagedWorktreeSessionHolder {
9246 incarnation_id: self.incarnation_id,
9247 instance_id: settled_address.session.instance_id,
9248 generation: settled_address.session.generation,
9249 },
9250 Some(record_id.clone()),
9251 unix_time_ms(),
9252 ).map_err(|message| failure(
9253 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9254 &message,
9255 ))?;
9256 if let Err(error) = self.persist_state() {
9257 let _ = self.remove_session(&settled_address).await;
9258 return Err(persistence_failure(error));
9259 }
9260 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9261 }
9262 Ok((self.record(record_id)?, settled_address))
9263 }
9264 Err(error) => {
9265 if matches!(
9266 error.code,
9267 NodeFailureCode::SessionRecordNotResumable
9268 | NodeFailureCode::BackendOperationFailed
9269 ) {
9270 if let Some(bound) = self.bound_address_for_record(record_id) {
9271 let _ = self.remove_session(&bound).await;
9272 }
9273 }
9274 Err(error)
9275 }
9276 }
9277 }
9278
9279 async fn wait_for_record_resume(
9280 &self,
9281 initial_address: &SessionAddress,
9282 after_sequence: u64,
9283 ) -> Result<SessionAddress, NodeFailure> {
9284 let deadline = Instant::now()
9285 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9286 let mut scan_after = after_sequence;
9287 loop {
9288 let events = self
9289 .history
9290 .lock()
9291 .unwrap_or_else(|poisoned| poisoned.into_inner())
9292 .events
9293 .iter()
9294 .filter(|event| event.sequence > scan_after)
9295 .cloned()
9296 .collect::<Vec<_>>();
9297 for envelope in events {
9298 scan_after = scan_after.max(envelope.sequence);
9299 let NodeEvent::Control { address, event } = envelope.event else {
9300 continue;
9301 };
9302 if address.session.instance_id != initial_address.session.instance_id {
9303 continue;
9304 }
9305 match event.event {
9306 ControlEventKind::Resumed { .. } => return Ok(address),
9307 ControlEventKind::ResumeDenied { reason } => {
9308 return Err(failure(
9309 NodeFailureCode::SessionRecordNotResumable,
9310 &reason,
9311 ));
9312 }
9313 ControlEventKind::ResumeFailed { message }
9314 | ControlEventKind::CommandRejected { message } => {
9315 return Err(failure(
9316 NodeFailureCode::BackendOperationFailed,
9317 &message,
9318 ));
9319 }
9320 _ => {}
9321 }
9322 }
9323 if Instant::now() >= deadline {
9324 return Err(failure(
9325 NodeFailureCode::BackendBusy,
9326 "managed resume did not settle before the bounded deadline",
9327 ));
9328 }
9329 sleep(Duration::from_millis(5)).await;
9330 }
9331 }
9332
9333 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9334 self.workspaces
9335 .read()
9336 .unwrap_or_else(|poisoned| poisoned.into_inner())
9337 .get(workspace_id)
9338 .cloned()
9339 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9340 }
9341
9342 fn workspace_roots(&self) -> Vec<PathBuf> {
9343 self.workspaces
9344 .read()
9345 .unwrap_or_else(|poisoned| poisoned.into_inner())
9346 .values()
9347 .map(PathBuf::from)
9348 .collect()
9349 }
9350
9351 async fn inspect_workspace(
9352 &self,
9353 workspace_id: WorkspaceId,
9354 ) -> Result<WorkspaceInspection, NodeFailure> {
9355 let _permit = self
9356 .inspection_slots
9357 .clone()
9358 .try_acquire_owned()
9359 .map_err(|_| {
9360 tracing::warn!(
9361 workspace_id = %workspace_id,
9362 "workspace inspection rejected: inspection capacity is busy",
9363 );
9364 failure(
9365 NodeFailureCode::BackendBusy,
9366 "workspace inspection capacity is busy",
9367 )
9368 })?;
9369 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9370 tracing::warn!(
9371 workspace_id = %workspace_id,
9372 code = ?error.code,
9373 "workspace inspection rejected: workspace is not registered",
9374 );
9375 error
9376 })?;
9377 let tree_root = canonical_root.clone();
9378 let started_at = Instant::now();
9379 let time_budget_ms = workspace_inspection_time_budget_ms();
9380 let entry_cap = workspace_inspection_entry_cap();
9381 let deadline = started_at + Duration::from_millis(time_budget_ms);
9382 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9383 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9384 })
9385 .await
9386 .map_err(|error| {
9387 tracing::warn!(
9388 workspace_id = %workspace_id,
9389 error = %error,
9390 "workspace inspection rejected: walk task failed",
9391 );
9392 failure(
9393 NodeFailureCode::BackendOperationFailed,
9394 &format!("workspace inspection task failed: {error}"),
9395 )
9396 })?;
9397 let (mut git, git_time_budget_exceeded) =
9398 inspect_git_workspace(&canonical_root, deadline).await;
9399 if !git.worktrees.is_empty() {
9400 let registered = self
9401 .workspaces
9402 .read()
9403 .unwrap_or_else(|poisoned| poisoned.into_inner())
9404 .iter()
9405 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9406 .collect::<Vec<_>>();
9407 for worktree in &mut git.worktrees {
9408 worktree.workspace_id = registered
9409 .iter()
9410 .find(|(_, root)| {
9411 worktree_paths_equal(root, windows_path_text(&worktree.path))
9412 })
9413 .map(|(workspace_id, _)| workspace_id.clone());
9414 }
9415 }
9416 if git.is_repository {
9417 git.managed_worktree = self
9418 .managed_worktrees
9419 .lock()
9420 .unwrap_or_else(|poisoned| poisoned.into_inner())
9421 .git_scope(&workspace_id, git.branch.as_deref());
9422 }
9423 let truncation = workspace_inspection_truncation(
9424 &walk_budget,
9425 git_time_budget_exceeded,
9426 started_at.elapsed(),
9427 );
9428 if let Some(truncation) = &truncation {
9429 tracing::info!(
9430 workspace_id = %workspace_id,
9431 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9432 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9433 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9434 entries_visited = truncation.entries_visited,
9435 elapsed_ms = truncation.elapsed_ms,
9436 "workspace inspection truncated by its inner budget",
9437 );
9438 }
9439 Ok(WorkspaceInspection {
9440 workspace_id,
9441 entries,
9442 tree_truncated,
9443 git,
9444 truncation,
9445 })
9446 }
9447
9448 async fn browse_host_directories(
9449 &self,
9450 directory: Option<OpaqueHostPath>,
9451 after: Option<OpaqueHostPath>,
9452 ) -> Result<HostDirectoryListing, NodeFailure> {
9453 let permit = self
9454 .inspection_slots
9455 .clone()
9456 .try_acquire_owned()
9457 .map_err(|_| {
9458 failure(
9459 NodeFailureCode::BackendBusy,
9460 "host directory browse capacity is busy",
9461 )
9462 })?;
9463 let task = tokio::task::spawn_blocking(move || {
9464 let _permit = permit;
9465 browse_host_directories(directory, after)
9466 });
9467 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9468 .await
9469 .map_err(|_| {
9470 failure(
9471 NodeFailureCode::HostDirectoryReadTimedOut,
9472 "host directory browse exceeded its bounded deadline",
9473 )
9474 })?
9475 .map_err(|_| {
9476 failure(
9477 NodeFailureCode::HostDirectoryReadFailed,
9478 "host directory browse task failed",
9479 )
9480 })?
9481 .map_err(host_directory_failure)
9482 }
9483
9484 async fn read_workspace_file(
9485 &self,
9486 workspace_id: WorkspaceId,
9487 path: RepositoryPath,
9488 ) -> Result<WorkspaceFileRead, NodeFailure> {
9489 let canonical_root = self.workspace_root(&workspace_id)?;
9490 let permit = self
9491 .inspection_slots
9492 .clone()
9493 .try_acquire_owned()
9494 .map_err(|_| {
9495 failure(
9496 NodeFailureCode::BackendBusy,
9497 "workspace file read capacity is busy",
9498 )
9499 })?;
9500 let path_to_read = path.clone();
9501 let task = tokio::task::spawn_blocking(move || {
9502 let _permit = permit;
9503 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9504 });
9505 let content = timeout(
9506 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9507 task,
9508 )
9509 .await
9510 .map_err(|_| {
9511 failure(
9512 NodeFailureCode::RepositoryFileReadTimedOut,
9513 "repository file read exceeded its bounded deadline",
9514 )
9515 })?
9516 .map_err(|_| {
9517 failure(
9518 NodeFailureCode::RepositoryFileReadFailed,
9519 "repository file read task failed",
9520 )
9521 })?
9522 .map_err(workspace_file_failure)?;
9523 let content = match content {
9524 WorkspaceFileBytes::Utf8(text) => {
9525 let revision = Some(workspace_file_revision(text.as_bytes()));
9526 return Ok(WorkspaceFileRead {
9527 workspace_id,
9528 path,
9529 content: WorkspaceFileContent::Utf8 {
9530 byte_len: u32::try_from(text.len())
9531 .expect("bounded workspace text length must fit u32"),
9532 text,
9533 },
9534 revision,
9535 });
9536 }
9537 WorkspaceFileBytes::NonUtf8 { byte_length } => {
9538 WorkspaceFileContent::NonUtf8 {
9539 byte_len: u32::try_from(byte_length)
9540 .expect("bounded workspace file length must fit u32"),
9541 }
9542 }
9543 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9544 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9545 },
9546 };
9547 Ok(WorkspaceFileRead {
9548 workspace_id,
9549 path,
9550 content,
9551 revision: None,
9552 })
9553 }
9554
9555 async fn write_workspace_file(
9556 &self,
9557 workspace_id: WorkspaceId,
9558 path: RepositoryPath,
9559 expected_revision: WorkspaceFileRevision,
9560 text: String,
9561 ) -> Result<WorkspaceFileRead, NodeFailure> {
9562 let canonical_root = self.workspace_root(&workspace_id)?;
9563 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9564 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9565 })?;
9566 let path_to_write = path.clone();
9567 let expected = expected_revision.as_str().to_owned();
9568 let text_to_write = text.clone();
9569 let task = tokio::task::spawn_blocking(move || {
9570 let _permit = permit;
9571 write_workspace_file_to_disk(
9572 Path::new(&canonical_root),
9573 &path_to_write,
9574 &expected,
9575 &text_to_write,
9576 )
9577 });
9578 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9579 .await
9580 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9581 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9582 .map_err(workspace_file_write_failure)?;
9583 Ok(WorkspaceFileRead {
9584 workspace_id,
9585 path,
9586 content: WorkspaceFileContent::Utf8 {
9587 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9588 text,
9589 },
9590 revision: Some(WorkspaceFileRevision::new(revision)
9591 .expect("workspace writer returns a valid SHA-256 revision")),
9592 })
9593 }
9594
9595 async fn create_workspace_file(
9596 &self,
9597 workspace_id: WorkspaceId,
9598 path: RepositoryPath,
9599 ) -> Result<WorkspaceFileRead, NodeFailure> {
9600 let canonical_root = self.workspace_root(&workspace_id)?;
9601 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9602 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9603 })?;
9604 let path_to_create = path.clone();
9605 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9606 let task_commit_state = Arc::clone(&commit_state);
9607 let task = tokio::task::spawn_blocking(move || {
9608 let _permit = permit;
9609 create_workspace_file_on_disk(
9610 Path::new(&canonical_root),
9611 &path_to_create,
9612 &task_commit_state,
9613 )
9614 });
9615 let revision = settle_workspace_entry_create(
9616 task,
9617 commit_state,
9618 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9619 "repository file create exceeded its bounded deadline",
9620 "repository file create task failed",
9621 )
9622 .await?;
9623 Ok(WorkspaceFileRead {
9624 workspace_id,
9625 path,
9626 content: WorkspaceFileContent::Utf8 {
9627 text: String::new(),
9628 byte_len: 0,
9629 },
9630 revision: Some(WorkspaceFileRevision::new(revision)
9631 .expect("workspace creator returns a valid SHA-256 revision")),
9632 })
9633 }
9634
9635 async fn create_workspace_directory(
9636 &self,
9637 workspace_id: WorkspaceId,
9638 path: RepositoryPath,
9639 ) -> Result<WorkspaceEntry, NodeFailure> {
9640 let canonical_root = self.workspace_root(&workspace_id)?;
9641 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9642 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9643 })?;
9644 let path_to_create = path.clone();
9645 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9646 let task_commit_state = Arc::clone(&commit_state);
9647 let task = tokio::task::spawn_blocking(move || {
9648 let _permit = permit;
9649 create_workspace_directory_on_disk(
9650 Path::new(&canonical_root),
9651 &path_to_create,
9652 &task_commit_state,
9653 )
9654 });
9655 settle_workspace_entry_create(
9656 task,
9657 commit_state,
9658 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9659 "repository directory create exceeded its bounded deadline",
9660 "repository directory create task failed",
9661 )
9662 .await?;
9663 Ok(WorkspaceEntry {
9664 relative_path: path,
9665 kind: WorkspaceEntryKind::Directory,
9666 })
9667 }
9668
9669 async fn read_git_history(
9670 &self,
9671 workspace_id: WorkspaceId,
9672 path: Option<RepositoryPath>,
9673 before: Option<GitObjectId>,
9674 limit: u16,
9675 ) -> Result<GitHistoryPage, NodeFailure> {
9676 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9677 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9678 })?;
9679 let root = self.workspace_root(&workspace_id)?;
9680 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9681 }
9682
9683 async fn read_git_diff(
9684 &self,
9685 workspace_id: WorkspaceId,
9686 request: GitDiffRequest,
9687 ) -> Result<GitDiff, NodeFailure> {
9688 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9689 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9690 })?;
9691 let root = self.workspace_root(&workspace_id)?;
9692 read_git_diff_bounded(&root, request).await
9693 }
9694
9695 fn native_session_route_root(
9696 &self,
9697 route: &NativeSessionCatalogRoute,
9698 ) -> Result<Option<String>, NodeFailure> {
9699 route
9700 .validate()
9701 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9702 match route.scope {
9703 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9704 .workspace_id
9705 .as_ref()
9706 .map(|workspace_id| self.workspace_root(workspace_id))
9707 .transpose(),
9708 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9709 }
9710 }
9711
9712 fn project_native_session_entries(
9713 &self,
9714 route: &NativeSessionCatalogRoute,
9715 entries: Vec<ScopedNativeSessionCatalogEntry>,
9716 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9717 let current_root = route
9718 .workspace_id
9719 .as_ref()
9720 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9721 let records = self
9722 .session_records
9723 .lock()
9724 .unwrap_or_else(|poisoned| poisoned.into_inner());
9725 let mut seen_records = std::collections::HashSet::new();
9726 entries
9727 .into_iter()
9728 .map(|entry| {
9729 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9730 let matches = records
9731 .records
9732 .values()
9733 .filter(|record| {
9734 record.provider == route.provider
9735 && record.workspace_id == *workspace_id
9736 && current_root.as_deref().is_some_and(|root| {
9737 platform::roots_equal(
9738 root,
9739 windows_path_text(&record.canonical_root),
9740 )
9741 })
9742 && record.provider_session.as_ref().is_some_and(|identity| {
9743 session_registry::same_provider_session(
9744 &record.provider,
9745 identity,
9746 &route.provider,
9747 &entry.provider_session,
9748 )
9749 })
9750 })
9751 .map(|record| record.record_id.clone())
9752 .collect::<Vec<_>>();
9753 (matches.len() == 1
9754 && seen_records.insert(matches[0].clone()))
9755 .then(|| matches[0].clone())
9756 });
9757 let metadata = entry.metadata;
9758 crate::protocol::NativeSessionCatalogEntry {
9759 selection_id: metadata.selection_id,
9760 title: metadata.title,
9761 modified_at_unix_ms: metadata.modified_at_unix_ms,
9762 model: metadata.model,
9763 message_count: metadata.message_count,
9764 completed_turn_count: metadata.completed_turn_count,
9765 external_group: entry.external_group,
9766 record_id,
9767 }
9768 })
9769 .collect()
9770 }
9771
9772 async fn catalog_native_sessions(
9773 &self,
9774 route: NativeSessionCatalogRoute,
9775 limit: u16,
9776 ) -> Result<(
9777 Vec<crate::protocol::NativeSessionCatalogEntry>,
9778 crate::protocol::NativeSessionCatalogSummary,
9779 ), NodeFailure> {
9780 let canonical_root = self.native_session_route_root(&route)?;
9781 let registered_workspace_roots = self.workspace_roots();
9782 let provider = route.provider.clone();
9783 let scope = route.scope;
9784 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9785 failure(
9786 NodeFailureCode::InvalidRequest,
9787 "native session catalog is not configured",
9788 )
9789 })?;
9790 let _permit = Arc::clone(&self.inspection_slots)
9791 .try_acquire_owned()
9792 .map_err(|_| {
9793 failure(
9794 NodeFailureCode::BackendBusy,
9795 "native session catalog capacity is busy",
9796 )
9797 })?;
9798 let page = run_native_session_catalog_operation(
9799 catalog,
9800 "native session catalog",
9801 move |catalog| {
9802 catalog.catalog_initial_for_scope(
9803 &provider,
9804 scope,
9805 canonical_root.as_deref().map(Path::new),
9806 ®istered_workspace_roots,
9807 limit,
9808 )
9809 },
9810 )
9811 .await?;
9812 let summary = crate::protocol::NativeSessionCatalogSummary {
9813 catalog_revision: page.revision,
9814 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9815 recent_total_count: page.recent_total_count,
9816 older_total_count: page.older_total_count,
9817 recent_next_after_selection_id: page.next_after_selection_id,
9818 recent_has_more: page.remaining_count > 0,
9819 };
9820 Ok((self.project_native_session_entries(&route, page.entries), summary))
9821 }
9822
9823 async fn page_native_sessions(
9824 &self,
9825 route: NativeSessionCatalogRoute,
9826 window: crate::protocol::NativeSessionCatalogWindow,
9827 catalog_revision: u64,
9828 recent_cutoff_unix_ms: u64,
9829 after_selection_id: Option<String>,
9830 limit: u16,
9831 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9832 let canonical_root = self.native_session_route_root(&route)?;
9833 let registered_workspace_roots = self.workspace_roots();
9834 let provider = route.provider.clone();
9835 let scope = route.scope;
9836 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9837 failure(
9838 NodeFailureCode::InvalidRequest,
9839 "native session catalog is not configured",
9840 )
9841 })?;
9842 let _permit = Arc::clone(&self.inspection_slots)
9843 .try_acquire_owned()
9844 .map_err(|_| {
9845 failure(
9846 NodeFailureCode::BackendBusy,
9847 "native session catalog capacity is busy",
9848 )
9849 })?;
9850 let page = run_native_session_catalog_operation(
9851 catalog,
9852 "native session catalog page",
9853 move |catalog| {
9854 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9855 &provider,
9856 scope,
9857 canonical_root.as_deref().map(Path::new),
9858 ®istered_workspace_roots,
9859 window,
9860 catalog_revision,
9861 recent_cutoff_unix_ms,
9862 after_selection_id.as_deref(),
9863 limit,
9864 ))
9865 },
9866 )
9867 .await?;
9868 let page = page.map_err(|error| match error {
9869 NativeSessionCatalogError::StaleCatalog => failure(
9870 NodeFailureCode::StaleNativeSessionCatalog,
9871 "native session catalog revision or cursor is stale",
9872 ),
9873 _ => failure(
9874 NodeFailureCode::BackendOperationFailed,
9875 "native session catalog page failed",
9876 ),
9877 })?;
9878 let entries = self.project_native_session_entries(&route, page.entries);
9879 Ok(crate::protocol::NativeSessionCatalogPage {
9880 window: page.window,
9881 revision: page.revision,
9882 entries,
9883 next_after_selection_id: page.next_after_selection_id,
9884 remaining_count: page.remaining_count,
9885 has_more: page.remaining_count > 0,
9886 })
9887 }
9888
9889 async fn preview_native_session(
9890 &self,
9891 selection: NativeSessionSelection,
9892 message_limit: u16,
9893 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9894 selection
9895 .validate()
9896 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9897 let canonical_root = self.native_session_route_root(&selection.route)?;
9898 let registered_workspace_roots = self.workspace_roots();
9899 let provider = selection.route.provider.clone();
9900 let scope = selection.route.scope;
9901 let catalog_revision = selection.catalog_revision;
9902 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9903 let selection_id = selection.selection_id.clone();
9904 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9905 failure(
9906 NodeFailureCode::InvalidRequest,
9907 "native session preview is not configured",
9908 )
9909 })?;
9910 let _permit = Arc::clone(&self.inspection_slots)
9911 .try_acquire_owned()
9912 .map_err(|_| {
9913 failure(
9914 NodeFailureCode::BackendBusy,
9915 "native session preview capacity is busy",
9916 )
9917 })?;
9918 let preview = run_native_session_catalog_operation(
9919 catalog,
9920 "native session preview",
9921 move |catalog| {
9922 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9923 &provider,
9924 scope,
9925 canonical_root.as_deref().map(Path::new),
9926 ®istered_workspace_roots,
9927 catalog_revision,
9928 recent_cutoff_unix_ms,
9929 &selection_id,
9930 message_limit,
9931 ))
9932 },
9933 )
9934 .await?;
9935 preview
9936 .map(Into::into)
9937 .map_err(native_session_preview_failure)
9938 }
9939
9940 async fn index_native_session(
9941 &self,
9942 selection: NativeSessionSelection,
9943 display_name: String,
9944 ) -> Result<ManagedSessionRecord, NodeFailure> {
9945 selection
9946 .validate()
9947 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9948 if selection.route.scope
9949 != gate4agent_types::NativeSessionCatalogScope::Workspace
9950 {
9951 return Err(failure(
9952 NodeFailureCode::WorkspaceRegistrationRequired,
9953 "register the external project as a workspace before indexing or resuming it",
9954 ));
9955 }
9956 let workspace_id = selection
9957 .route
9958 .workspace_id
9959 .clone()
9960 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9961 let canonical_root = self
9962 .native_session_route_root(&selection.route)?
9963 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9964 let registered_workspace_roots = self.workspace_roots();
9965 let provider = selection.route.provider.clone();
9966 let catalog_revision = selection.catalog_revision;
9967 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9968 let selection_id = selection.selection_id.clone();
9969 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9970 failure(
9971 NodeFailureCode::InvalidRequest,
9972 "native session index is not configured",
9973 )
9974 })?;
9975 let _permit = Arc::clone(&self.inspection_slots)
9976 .try_acquire_owned()
9977 .map_err(|_| {
9978 failure(
9979 NodeFailureCode::BackendBusy,
9980 "native session index capacity is busy",
9981 )
9982 })?;
9983 let resolved = run_native_session_catalog_operation(
9984 catalog,
9985 "native session index resolution",
9986 move |catalog| {
9987 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9988 &provider,
9989 gate4agent_types::NativeSessionCatalogScope::Workspace,
9990 Some(Path::new(&canonical_root)),
9991 ®istered_workspace_roots,
9992 catalog_revision,
9993 recent_cutoff_unix_ms,
9994 &selection_id,
9995 ))
9996 },
9997 )
9998 .await?
9999 .map_err(native_session_preview_failure)?;
10000 self.index_provider_session_with_policy(
10001 workspace_id,
10002 selection.route.provider,
10003 resolved.identity,
10004 display_name,
10005 true,
10006 )
10007 }
10008
10009 async fn preview_native_session_by_session_id(
10010 &self,
10011 workspace_id: WorkspaceId,
10012 provider: AgentId,
10013 session_id: String,
10014 message_limit: u16,
10015 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
10016 let canonical_root = self.workspace_root(&workspace_id)?;
10017 let registered_workspace_roots = self.workspace_roots();
10018 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
10019 failure(
10020 NodeFailureCode::InvalidRequest,
10021 "native session preview is not configured",
10022 )
10023 })?;
10024 let _permit = Arc::clone(&self.inspection_slots)
10025 .try_acquire_owned()
10026 .map_err(|_| {
10027 failure(
10028 NodeFailureCode::BackendBusy,
10029 "native session preview capacity is busy",
10030 )
10031 })?;
10032 run_native_session_catalog_operation(
10033 catalog,
10034 "native session preview",
10035 move |catalog| {
10036 catalog.preview_session_id_for_workspace(
10037 &provider,
10038 Path::new(&canonical_root),
10039 ®istered_workspace_roots,
10040 &session_id,
10041 message_limit,
10042 )
10043 },
10044 )
10045 .await
10046 }
10047
10048 async fn register_workspace(
10049 &self,
10050 workspace_id: WorkspaceId,
10051 root: String,
10052 ) -> Result<WorkspaceSnapshot, NodeFailure> {
10053 validate_workspace_request_root(&root)?;
10054 let canonicalize_workspace_id = workspace_id.clone();
10055 let workspace = tokio::task::spawn_blocking(move || {
10056 WorkspaceConfig::new(canonicalize_workspace_id, root)
10057 })
10058 .await
10059 .map_err(|error| {
10060 failure(
10061 NodeFailureCode::BackendOperationFailed,
10062 &format!("workspace canonicalization task failed: {error}"),
10063 )
10064 })?
10065 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10066 let transaction = self
10067 .state_transaction
10068 .lock()
10069 .unwrap_or_else(|poisoned| poisoned.into_inner());
10070 let mut workspaces = self
10071 .workspaces
10072 .write()
10073 .unwrap_or_else(|poisoned| poisoned.into_inner());
10074 if workspaces.contains_key(&workspace_id) {
10075 return Err(failure(
10076 NodeFailureCode::DuplicateWorkspaceId,
10077 "workspace ID is already registered",
10078 ));
10079 }
10080 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10081 platform::roots_equal(existing_root, workspace.canonical_root())
10082 }) {
10083 return Err(failure(
10084 NodeFailureCode::DuplicateWorkspaceRoot,
10085 &format!("workspace root is already registered as '{existing_id}'"),
10086 ));
10087 }
10088 {
10089 let records = self
10090 .session_records
10091 .lock()
10092 .unwrap_or_else(|poisoned| poisoned.into_inner());
10093 if let Some(record) = records.records.values().find(|record| {
10094 record.workspace_id == workspace_id
10095 && !record
10096 .canonical_root
10097 .as_utf8()
10098 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
10099 }) {
10100 return Err(failure(
10101 NodeFailureCode::SessionRecordConflict,
10102 &format!(
10103 "workspace ID is retained by session record '{}' at another root",
10104 record.record_id,
10105 ),
10106 ));
10107 }
10108 }
10109 let snapshot = WorkspaceSnapshot {
10110 workspace_id: workspace_id.clone(),
10111 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
10112 sessions: Vec::new(),
10113 worktree_service_mode: Some(WorktreeServiceMode::Manual),
10114 managed_worktree_profiles: Some(WorktreeProfileInventory {
10115 profiles: Vec::new(),
10116 }),
10117 };
10118 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
10119 drop(workspaces);
10120 let previous_records = {
10121 let mut records = self
10122 .session_records
10123 .lock()
10124 .unwrap_or_else(|poisoned| poisoned.into_inner());
10125 let previous = records.records.clone();
10126 for record in records.records.values_mut().filter(|record| {
10127 record.workspace_id == workspace_id
10128 && record
10129 .canonical_root
10130 .as_utf8()
10131 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
10132 }) {
10133 if record.active_session.is_none() {
10134 record.state = detached_record_state(record);
10135 }
10136 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
10137 record.last_error = None;
10138 }
10139 record.updated_at_unix_ms = unix_time_ms();
10140 }
10141 previous
10142 };
10143 if let Err(error) = self.persist_state_locked() {
10144 self.workspaces
10145 .write()
10146 .unwrap_or_else(|poisoned| poisoned.into_inner())
10147 .remove(&workspace_id);
10148 self.session_records
10149 .lock()
10150 .unwrap_or_else(|poisoned| poisoned.into_inner())
10151 .records = previous_records;
10152 return Err(persistence_failure(error));
10153 }
10154 let restored = self
10155 .session_records
10156 .lock()
10157 .unwrap_or_else(|poisoned| poisoned.into_inner())
10158 .records
10159 .values()
10160 .filter(|record| record.workspace_id == workspace_id)
10161 .cloned()
10162 .collect::<Vec<_>>();
10163 drop(transaction);
10164 self.publish(NodeEvent::WorkspaceAdded {
10165 workspace: snapshot.clone(),
10166 });
10167 for record in restored {
10168 self.publish_record(record);
10169 }
10170 Ok(snapshot)
10171 }
10172
10173 async fn create_standalone_workspace(
10174 &self,
10175 workspace_id: WorkspaceId,
10176 root: String,
10177 initial_branch: Option<String>,
10178 ) -> Result<WorkspaceSnapshot, NodeFailure> {
10179 validate_workspace_request_root(&root)?;
10180 validate_git_revision("initial branch", initial_branch.as_deref())?;
10181 let preflight_workspace_id = workspace_id.clone();
10182 let preflight_root = root.clone();
10183 let canonical_candidate = tokio::task::spawn_blocking(move || {
10184 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10185 })
10186 .await
10187 .map_err(|_| failure(
10188 NodeFailureCode::BackendOperationFailed,
10189 "standalone workspace root preflight task failed",
10190 ))?
10191 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10192 {
10193 let workspaces = self
10194 .workspaces
10195 .read()
10196 .unwrap_or_else(|poisoned| poisoned.into_inner());
10197 if workspaces.contains_key(&workspace_id) {
10198 return Err(failure(
10199 NodeFailureCode::DuplicateWorkspaceId,
10200 "workspace ID is already registered",
10201 ));
10202 }
10203 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10204 platform::roots_equal(existing_root, &canonical_candidate)
10205 }) {
10206 return Err(failure(
10207 NodeFailureCode::DuplicateWorkspaceRoot,
10208 &format!("workspace root is already registered as '{existing_id}'"),
10209 ));
10210 }
10211 }
10212 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10213 .await
10214 .map_err(standalone_workspace_failure)?;
10215 prepared
10216 .verify_for_registration()
10217 .await
10218 .map_err(standalone_workspace_failure)?;
10219 match self
10220 .register_workspace(workspace_id, prepared.root().to_owned())
10221 .await
10222 {
10223 Ok(workspace) => Ok(workspace),
10224 Err(registration_error) => {
10225 if prepared.compensate_registration_failure().await {
10226 Err(registration_error)
10227 } else {
10228 Err(failure(
10229 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10230 "standalone workspace initialization succeeded but registration failed; recovery is required",
10231 ))
10232 }
10233 }
10234 }
10235 }
10236
10237 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10238 let bindings = self
10239 .session_bindings
10240 .lock()
10241 .unwrap_or_else(|poisoned| poisoned.into_inner());
10242 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10243 return Err(failure(
10244 NodeFailureCode::WorkspaceBusy,
10245 "workspace retains a bound session; remove every session before unregistering it",
10246 ));
10247 }
10248 drop(bindings);
10249 let transaction = self
10250 .state_transaction
10251 .lock()
10252 .unwrap_or_else(|poisoned| poisoned.into_inner());
10253 let mut workspaces = self
10254 .workspaces
10255 .write()
10256 .unwrap_or_else(|poisoned| poisoned.into_inner());
10257 if !workspaces.contains_key(workspace_id) {
10258 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10259 }
10260 if workspaces.len() == 1 {
10261 return Err(failure(
10262 NodeFailureCode::LastWorkspace,
10263 "node must retain at least one workspace",
10264 ));
10265 }
10266 let removed_root = workspaces
10267 .remove(workspace_id)
10268 .expect("workspace presence was validated");
10269 drop(workspaces);
10270 let previous_records = {
10271 let mut records = self
10272 .session_records
10273 .lock()
10274 .unwrap_or_else(|poisoned| poisoned.into_inner());
10275 let previous = records.records.clone();
10276 for record in records
10277 .records
10278 .values_mut()
10279 .filter(|record| &record.workspace_id == workspace_id)
10280 {
10281 record.active_session = None;
10282 record.state = ManagedSessionState::Unavailable;
10283 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10284 record.updated_at_unix_ms = unix_time_ms();
10285 }
10286 previous
10287 };
10288 if let Err(error) = self.persist_state_locked() {
10289 self.workspaces
10290 .write()
10291 .unwrap_or_else(|poisoned| poisoned.into_inner())
10292 .insert(workspace_id.clone(), removed_root);
10293 self.session_records
10294 .lock()
10295 .unwrap_or_else(|poisoned| poisoned.into_inner())
10296 .records = previous_records;
10297 return Err(persistence_failure(error));
10298 }
10299 let unavailable = self
10300 .session_records
10301 .lock()
10302 .unwrap_or_else(|poisoned| poisoned.into_inner())
10303 .records
10304 .values()
10305 .filter(|record| &record.workspace_id == workspace_id)
10306 .cloned()
10307 .collect::<Vec<_>>();
10308 drop(transaction);
10309 self.publish(NodeEvent::WorkspaceRemoved {
10310 workspace_id: workspace_id.clone(),
10311 });
10312 for record in unavailable {
10313 self.publish_record(record);
10314 }
10315 Ok(())
10316 }
10317
10318 async fn create_worktree(
10319 &self,
10320 source_workspace_id: WorkspaceId,
10321 workspace_id: WorkspaceId,
10322 target_root: String,
10323 branch: String,
10324 base: Option<String>,
10325 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10326 self.require_worktree_service(&source_workspace_id)?;
10327 validate_workspace_request_root(&target_root)?;
10328 validate_git_revision("worktree base", base.as_deref())?;
10329 let source_root = self.workspace_root(&source_workspace_id)?;
10330 {
10331 let workspaces = self
10332 .workspaces
10333 .read()
10334 .unwrap_or_else(|poisoned| poisoned.into_inner());
10335 if workspaces.contains_key(&workspace_id) {
10336 return Err(failure(
10337 NodeFailureCode::DuplicateWorkspaceId,
10338 "workspace ID is already registered",
10339 ));
10340 }
10341 if let Some((existing_id, _)) = workspaces
10342 .iter()
10343 .find(|(_, root)| worktree_paths_equal(root, &target_root))
10344 {
10345 return Err(failure(
10346 NodeFailureCode::DuplicateWorkspaceRoot,
10347 &format!("workspace root is already registered as '{existing_id}'"),
10348 ));
10349 }
10350 }
10351 let mut worktree = create_git_worktree(
10352 &source_root,
10353 &target_root,
10354 &branch,
10355 base.as_deref(),
10356 )
10357 .await
10358 .map_err(git_worktree_failure)?;
10359 let workspace = match self
10360 .register_workspace(workspace_id, worktree.path.clone())
10361 .await
10362 {
10363 Ok(workspace) => workspace,
10364 Err(registration_error) => {
10365 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10366 return Err(failure(
10367 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10368 "worktree registration failed and compensation requires recovery",
10369 ));
10370 }
10371 return Err(failure(
10372 registration_error.code,
10373 ®istration_error.message,
10374 ));
10375 }
10376 };
10377 worktree.workspace_id = Some(workspace.workspace_id.clone());
10378 Ok((protocol_worktree(worktree), workspace))
10379 }
10380
10381 async fn remove_worktree(
10382 &self,
10383 source_workspace_id: WorkspaceId,
10384 target_root: String,
10385 ) -> Result<Option<WorkspaceId>, NodeFailure> {
10386 self.require_worktree_service(&source_workspace_id)?;
10387 validate_workspace_request_root(&target_root)?;
10388 let source_root = self.workspace_root(&source_workspace_id)?;
10389 let target_root = normalize_worktree_removal_target(&target_root)
10390 .map_err(git_worktree_failure)?;
10391 let target = list_git_worktrees(&source_root)
10392 .await
10393 .map_err(git_worktree_failure)?
10394 .into_iter()
10395 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10396 .ok_or_else(|| {
10397 failure(
10398 NodeFailureCode::WorktreeProtected,
10399 "refusing to remove a path that is not in Git's worktree listing",
10400 )
10401 })?;
10402 let registered_workspace_id = {
10403 let workspaces = self
10404 .workspaces
10405 .read()
10406 .unwrap_or_else(|poisoned| poisoned.into_inner());
10407 let matched = workspaces
10408 .iter()
10409 .find(|(_, root)| worktree_paths_equal(root, &target.path))
10410 .map(|(workspace_id, _)| workspace_id.clone());
10411 if matched.is_some() && workspaces.len() == 1 {
10412 return Err(failure(
10413 NodeFailureCode::LastWorkspace,
10414 "node must retain at least one workspace",
10415 ));
10416 }
10417 matched
10418 };
10419 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10420 let bindings = self
10421 .session_bindings
10422 .lock()
10423 .unwrap_or_else(|poisoned| poisoned.into_inner());
10424 if bindings
10425 .values()
10426 .any(|binding| &binding.workspace_id == workspace_id)
10427 {
10428 return Err(failure(
10429 NodeFailureCode::WorkspaceBusy,
10430 "worktree workspace retains a bound session; remove every session first",
10431 ));
10432 }
10433 }
10434 remove_git_worktree(&source_root, &target.path)
10435 .await
10436 .map_err(git_worktree_failure)?;
10437 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10438 self.unregister_workspace(workspace_id)
10439 .map_err(|error| failure(error.code, &error.message))?;
10440 }
10441 Ok(registered_workspace_id)
10442 }
10443
10444 async fn cleanup_managed_worktree(
10445 &self,
10446 lease_id: &ManagedWorktreeLeaseId,
10447 explicit: bool,
10448 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10449 let lease = self.managed_worktrees.lock()
10450 .unwrap_or_else(|poisoned| poisoned.into_inner())
10451 .get(lease_id)
10452 .cloned()
10453 .ok_or_else(|| failure(
10454 NodeFailureCode::UnknownManagedWorktreeLease,
10455 "managed worktree lease does not exist",
10456 ))?;
10457 if lease.state == ManagedWorktreeLeaseState::Removed {
10458 return Ok(lease.snapshot());
10459 }
10460 if lease.has_holders() {
10461 return Err(failure(
10462 NodeFailureCode::ManagedWorktreeBusy,
10463 "managed worktree lease still has a session or durable-record holder",
10464 ));
10465 }
10466 if self
10467 .materializations
10468 .lock()
10469 .unwrap_or_else(|poisoned| poisoned.into_inner())
10470 .values()
10471 .any(|record| record.managed_lease_id() == Some(lease_id))
10472 {
10473 return Err(failure(
10474 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10475 "managed worktree cleanup is blocked by session environment ownership",
10476 ));
10477 }
10478 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10479 let snapshot = {
10480 let mut registry = self.managed_worktrees.lock()
10481 .unwrap_or_else(|poisoned| poisoned.into_inner());
10482 let current = registry.get_mut(lease_id)
10483 .expect("managed worktree lease remains present");
10484 current.state = ManagedWorktreeLeaseState::Retained;
10485 current.cleanup_failure = None;
10486 current.updated_at_unix_ms = unix_time_ms();
10487 current.snapshot()
10488 };
10489 if self.persist_state().is_err() {
10490 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10491 }
10492 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10493 return Ok(snapshot);
10494 }
10495 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10496 if profile.revision() != &lease.profile_revision {
10497 self.mark_managed_recovery_required(
10498 lease_id,
10499 ManagedWorktreeCleanupFailure::OwnershipConflict,
10500 );
10501 return Err(failure(
10502 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10503 "managed worktree profile revision changed",
10504 ));
10505 }
10506 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10507 self.mark_managed_recovery_required(
10508 lease_id,
10509 ManagedWorktreeCleanupFailure::OwnershipConflict,
10510 );
10511 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10512 })?;
10513 let source_root = self.workspace_root(&lease.source_workspace_id)?;
10514 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10515 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10516 managed_git_worktree_failure(error)
10517 })?;
10518 let target = listed.iter()
10519 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10520 if let Some(target) = target {
10521 if target.is_main || target.is_bare || !exact_owned_worktree(
10522 &lease,
10523 &target.path,
10524 target.branch.as_deref(),
10525 &target.head,
10526 ) {
10527 self.mark_managed_recovery_required(
10528 lease_id,
10529 ManagedWorktreeCleanupFailure::OwnershipConflict,
10530 );
10531 return Err(failure(
10532 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10533 "Git worktree identity no longer matches the managed lease",
10534 ));
10535 }
10536 if target.prunable {
10537 self.mark_managed_cleanup_blocked(
10538 lease_id,
10539 ManagedWorktreeCleanupFailure::Prunable,
10540 );
10541 return Err(failure(
10542 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10543 "managed worktree is prunable and requires recovery",
10544 ));
10545 }
10546 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10547 let failure_kind = match error.kind {
10548 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10549 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10550 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10551 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10552 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10553 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10554 };
10555 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10556 self.mark_managed_recovery_required(lease_id, failure_kind);
10557 } else {
10558 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10559 }
10560 return Err(managed_git_worktree_failure(error));
10561 }
10562 } else {
10563 match std::fs::symlink_metadata(&lease.target_root) {
10564 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10565 Ok(_) => {
10566 self.mark_managed_recovery_required(
10567 lease_id,
10568 ManagedWorktreeCleanupFailure::OwnershipConflict,
10569 );
10570 return Err(failure(
10571 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10572 "managed target exists but is not owned by Git",
10573 ));
10574 }
10575 Err(_) => {
10576 self.mark_managed_cleanup_blocked(
10577 lease_id,
10578 ManagedWorktreeCleanupFailure::Backend,
10579 );
10580 return Err(failure(
10581 NodeFailureCode::BackendOperationFailed,
10582 "managed target identity could not be inspected",
10583 ));
10584 }
10585 }
10586 }
10587 let registered = self.workspaces.read()
10588 .unwrap_or_else(|poisoned| poisoned.into_inner())
10589 .contains_key(&lease.workspace_id);
10590 if registered {
10591 self.unregister_workspace(&lease.workspace_id)?;
10592 }
10593 let removed = {
10594 self.managed_worktrees.lock()
10595 .unwrap_or_else(|poisoned| poisoned.into_inner())
10596 .tombstone(lease_id, unix_time_ms())
10597 .expect("managed worktree lease remains present during cleanup")
10598 };
10599 self.persist_state().map_err(persistence_failure)?;
10600 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10601 Ok(removed.snapshot())
10602 }
10603
10604 fn mark_managed_cleanup_blocked(
10605 &self,
10606 lease_id: &ManagedWorktreeLeaseId,
10607 failure_kind: ManagedWorktreeCleanupFailure,
10608 ) {
10609 let snapshot = {
10610 let mut registry = self.managed_worktrees.lock()
10611 .unwrap_or_else(|poisoned| poisoned.into_inner());
10612 let Some(lease) = registry.get_mut(lease_id) else { return };
10613 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10614 lease.cleanup_failure = Some(failure_kind);
10615 lease.updated_at_unix_ms = unix_time_ms();
10616 lease.snapshot()
10617 };
10618 let _ = self.persist_state();
10619 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10620 }
10621
10622 fn require_worktree_service(
10623 &self,
10624 source_workspace_id: &WorkspaceId,
10625 ) -> Result<(), NodeFailure> {
10626 if self.managed_worktrees.lock()
10627 .unwrap_or_else(|poisoned| poisoned.into_inner())
10628 .lease_for_workspace(source_workspace_id)
10629 .is_some()
10630 {
10631 return Err(failure(
10632 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10633 "managed worktree targets are internal-only workspaces",
10634 ));
10635 }
10636 match self
10637 .worktree_service_modes
10638 .get(source_workspace_id)
10639 .copied()
10640 .unwrap_or(WorktreeServiceMode::Manual)
10641 {
10642 WorktreeServiceMode::Manual => Ok(()),
10643 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10644 NodeFailureCode::UnsupportedSpawnCapability,
10645 "Git worktree service mode is unavailable in this node build",
10646 )),
10647 }
10648 }
10649
10650 fn managed_reservation_conflict(
10651 &self,
10652 workspace_id: Option<&WorkspaceId>,
10653 root: Option<&str>,
10654 ) -> bool {
10655 self.managed_worktrees.lock()
10656 .unwrap_or_else(|poisoned| poisoned.into_inner())
10657 .active_records()
10658 .any(|lease| {
10659 workspace_id.is_some_and(|workspace_id| {
10660 workspace_id == &lease.source_workspace_id
10661 || workspace_id == &lease.workspace_id
10662 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10663 })
10664 }
10665
10666 fn reject_managed_reservation(
10667 &self,
10668 workspace_id: Option<&WorkspaceId>,
10669 root: Option<&str>,
10670 ) -> Result<(), NodeFailure> {
10671 if self.managed_reservation_conflict(workspace_id, root) {
10672 Err(failure(
10673 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10674 "managed worktree ownership may only be mutated through its lease cleanup",
10675 ))
10676 } else {
10677 Ok(())
10678 }
10679 }
10680
10681 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10682 self.validate_address(address)?;
10683 self.workspace_root(&address.workspace_id)
10684 }
10685
10686 fn require_session_runtime_policy(
10720 &self,
10721 address: &SessionAddress,
10722 requirement: ProviderRuntimeRequirement,
10723 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10724 let runtime_policy = {
10725 let bindings = self
10726 .session_bindings
10727 .lock()
10728 .unwrap_or_else(|poisoned| poisoned.into_inner());
10729 let binding = bindings
10730 .get(&address.session.instance_id)
10731 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10732 if binding.workspace_id != address.workspace_id {
10733 return Err(failure(
10734 NodeFailureCode::SessionWorkspaceMismatch,
10735 "session belongs to another workspace",
10736 ));
10737 }
10738 if binding.generation != address.session.generation {
10739 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10740 }
10741 binding.runtime_policy
10742 };
10743 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10744 let snapshot = self.internal_session_snapshot(address)?;
10745 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10746 && snapshot.provider.lead_activity != ProviderActivity::Idle
10747 {
10748 return Err(failure(
10749 NodeFailureCode::TurnInFlight,
10750 "session already has a turn in flight; wait for it to complete before sending another prompt",
10751 ));
10752 }
10753 }
10754 require_policy(runtime_policy, false, false, requirement)
10755 .map(|()| runtime_policy)
10756 .map_err(|_| failure(
10757 NodeFailureCode::UnsupportedCapability,
10758 "session runtime policy does not admit this semantic operation",
10759 ))
10760 }
10761
10762 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10763 let mut bindings = self
10764 .session_bindings
10765 .lock()
10766 .unwrap_or_else(|poisoned| poisoned.into_inner());
10767 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10768 binding.workspace_id == address.workspace_id
10769 && binding.generation == address.session.generation
10770 }) {
10771 let removed = bindings.remove(&address.session.instance_id);
10772 self.clear_terminal_frame_watermark(address);
10773 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10774 return removed;
10775 }
10776 None
10777 }
10778
10779 fn cleanup_session_owned_materialization(
10780 &self,
10781 address: &SessionAddress,
10782 binding: &SessionBinding,
10783 ) -> Result<(), NodeFailure> {
10784 let Some(id) = binding.materialization_id.as_ref() else {
10785 return Ok(());
10786 };
10787 let owner = self
10788 .materializations
10789 .lock()
10790 .unwrap_or_else(|poisoned| poisoned.into_inner())
10791 .get(id)
10792 .map(|record| record.owner().clone());
10793 match owner {
10794 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10795 Some(MaterializationOwner::Session {
10796 incarnation_id,
10797 instance_id,
10798 generation,
10799 }) if incarnation_id == self.incarnation_id
10800 && instance_id == address.session.instance_id
10801 && generation == address.session.generation =>
10802 {
10803 self.cleanup_materialization(id)
10804 }
10805 Some(MaterializationOwner::Session { .. }) => Err(failure(
10806 NodeFailureCode::BackendOperationFailed,
10807 "session environment ownership requires recovery",
10808 )),
10809 }
10810 }
10811
10812 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10813 let mut watermarks = self
10814 .terminal_frame_watermarks
10815 .lock()
10816 .unwrap_or_else(|poisoned| poisoned.into_inner());
10817 if watermarks
10818 .get(&address.session.instance_id)
10819 .is_some_and(|(current, _)| current == address)
10820 {
10821 watermarks.remove(&address.session.instance_id);
10822 }
10823 }
10824
10825 #[cfg(test)]
10826 fn address_for(
10827 &self,
10828 instance_id: AgentInstanceId,
10829 generation: SessionGeneration,
10830 ) -> Option<SessionAddress> {
10831 let binding = self.session_bindings
10832 .lock()
10833 .unwrap_or_else(|poisoned| poisoned.into_inner())
10834 .get(&instance_id)
10835 .cloned()?;
10836 (binding.generation == generation).then_some(SessionAddress {
10837 workspace_id: binding.workspace_id,
10838 session: SessionKey { instance_id, generation },
10839 })
10840 }
10841
10842 fn arm_resume(
10843 &self,
10844 address: &SessionAddress,
10845 command_id: CommandId,
10846 runtime_policy: ProviderRuntimePolicy,
10847 ) -> Result<(), NodeFailure> {
10848 let mut bindings = self
10849 .session_bindings
10850 .lock()
10851 .unwrap_or_else(|poisoned| poisoned.into_inner());
10852 let binding = bindings
10853 .get_mut(&address.session.instance_id)
10854 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10855 if binding.workspace_id != address.workspace_id {
10856 return Err(failure(
10857 NodeFailureCode::SessionWorkspaceMismatch,
10858 "session belongs to another workspace",
10859 ));
10860 }
10861 if binding.generation != address.session.generation {
10862 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10863 }
10864 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10865 Ok(())
10866 }
10867
10868 fn clear_armed_resume(&self, address: &SessionAddress) {
10869 let mut bindings = self
10870 .session_bindings
10871 .lock()
10872 .unwrap_or_else(|poisoned| poisoned.into_inner());
10873 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10874 if binding.workspace_id == address.workspace_id
10875 && binding.generation == address.session.generation
10876 {
10877 binding.pending_resume = None;
10878 }
10879 }
10880 }
10881
10882 fn publish_control(&self, event: ControlEvent) {
10883 let revokes_harness_mcp = matches!(
10884 &event.event,
10885 ControlEventKind::Exited { .. }
10886 | ControlEventKind::Failed { .. }
10887 | ControlEventKind::Removed
10888 );
10889 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10890 let mut bindings = self
10891 .session_bindings
10892 .lock()
10893 .unwrap_or_else(|poisoned| poisoned.into_inner());
10894 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10895 return;
10896 };
10897 let previous_address = SessionAddress {
10898 workspace_id: binding.workspace_id.clone(),
10899 session: SessionKey {
10900 instance_id: event.instance_id,
10901 generation: binding.generation,
10902 },
10903 };
10904 let mut resume_command_rejected = false;
10905 if binding.generation == event.generation {
10906 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10907 let rejected_resume = matches!(
10908 &event.event,
10909 ControlEventKind::CommandRejected { .. }
10910 ) && event.command_id == Some(pending_command_id);
10911 let resume_failed = matches!(
10912 &event.event,
10913 ControlEventKind::ResumeDenied { .. }
10914 | ControlEventKind::ResumeFailed { .. }
10915 );
10916 if pending_generation == binding.generation
10917 && (rejected_resume || resume_failed)
10918 {
10919 resume_command_rejected = rejected_resume;
10920 binding.pending_resume = None;
10921 }
10922 }
10923 } else {
10924 let expected = binding.generation.0.checked_add(1);
10925 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10926 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10927 generation == binding.generation
10928 })
10929 || expected != Some(event.generation.0)
10930 || !authorized
10931 {
10932 return;
10933 }
10934 let (_, _, runtime_policy) = binding
10935 .pending_resume
10936 .expect("authorized resume retains its pending runtime policy");
10937 binding.generation = event.generation;
10938 binding.runtime_policy = runtime_policy;
10939 binding.pending_resume = None;
10940 }
10941 (
10942 SessionAddress {
10943 workspace_id: binding.workspace_id.clone(),
10944 session: SessionKey {
10945 instance_id: event.instance_id,
10946 generation: event.generation,
10947 },
10948 },
10949 (previous_address.session.generation != event.generation)
10950 .then_some(previous_address),
10951 binding.record_id.clone(),
10952 binding.runtime_policy.provider_session_identity,
10953 resume_command_rejected,
10954 )
10955 };
10956 if let Some(previous_address) = previous_address.as_ref() {
10957 self.clear_terminal_frame_watermark(previous_address);
10958 }
10959 if let Some(record_id) = record_id {
10960 self.reconcile_managed_record(
10961 &record_id,
10962 &address,
10963 &event,
10964 identity_admitted,
10965 resume_command_rejected,
10966 );
10967 }
10968 match &event.event {
10976 ControlEventKind::Running { process_id } => {
10977 if let Some(pid) = process_id {
10978 crate::git_watch::running(address.workspace_id.as_str(), address.session.instance_id.0, "", *pid);
10979 }
10980 #[cfg(unix)]
10981 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10982 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10983 }
10984 tracing::info!(
10985 node_id = %self.node_id,
10986 workspace_id = %address.workspace_id,
10987 session = ?address.session,
10988 pid = ?process_id,
10989 "session process is running",
10990 );
10991 }
10992 ControlEventKind::Exited { exit_code, forced } => {
10993 crate::git_watch::exited(address.workspace_id.as_str(), address.session.instance_id.0);
10994 #[cfg(unix)]
10995 if let Some(proxy) = self.signer_proxy.get().cloned() {
10996 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10997 if proxy.has_session(&key) {
10998 std::thread::spawn(move || proxy.release(&key));
11000 }
11001 }
11002 tracing::info!(
11003 node_id = %self.node_id,
11004 workspace_id = %address.workspace_id,
11005 session = ?address.session,
11006 exit_code = ?exit_code,
11007 forced = forced,
11008 "session process exited",
11009 );
11010 }
11011 ControlEventKind::Failed { message } => {
11012 tracing::warn!(
11013 node_id = %self.node_id,
11014 workspace_id = %address.workspace_id,
11015 session = ?address.session,
11016 cause = %message,
11017 "session failed",
11018 );
11019 }
11020 ControlEventKind::CommandRejected { message } => {
11021 tracing::warn!(
11022 node_id = %self.node_id,
11023 workspace_id = %address.workspace_id,
11024 session = ?address.session,
11025 cause = %message,
11026 "session command rejected",
11027 );
11028 }
11029 ControlEventKind::Removed => {
11030 tracing::info!(
11031 node_id = %self.node_id,
11032 workspace_id = %address.workspace_id,
11033 session = ?address.session,
11034 "session removed",
11035 );
11036 }
11037 ControlEventKind::ResumeAuthorized { session: provider_session } => {
11038 tracing::info!(
11039 node_id = %self.node_id,
11040 workspace_id = %address.workspace_id,
11041 session = ?address.session,
11042 provider_session = ?provider_session,
11043 "session resume authorized",
11044 );
11045 }
11046 ControlEventKind::Resumed { session: provider_session, process_id } => {
11047 tracing::info!(
11048 node_id = %self.node_id,
11049 workspace_id = %address.workspace_id,
11050 session = ?address.session,
11051 provider_session = ?provider_session,
11052 pid = ?process_id,
11053 "session resumed",
11054 );
11055 }
11056 ControlEventKind::ResumeDenied { reason } => {
11057 tracing::warn!(
11058 node_id = %self.node_id,
11059 workspace_id = %address.workspace_id,
11060 session = ?address.session,
11061 cause = %reason,
11062 "session resume denied",
11063 );
11064 }
11065 ControlEventKind::ResumeFailed { message } => {
11066 tracing::warn!(
11067 node_id = %self.node_id,
11068 workspace_id = %address.workspace_id,
11069 session = ?address.session,
11070 cause = %message,
11071 "session resume failed",
11072 );
11073 }
11074 _ => {}
11075 }
11076 self.apply_pending_acp_options(&address, &event);
11077 self.apply_acp_auto_permit(&address, &event);
11078 trace_acp_turn(&address, &event);
11079 acp_tool_evidence(&address, &event);
11080 self.observe_acp_provider_error(&address, &event);
11081 let stream_chunk = agent_stream_chunk(&event);
11082 self.publish(NodeEvent::Control {
11083 address: address.clone(),
11084 event,
11085 });
11086 if let Some(chunk) = stream_chunk {
11087 self.publish_agent_stream_chunk(address.clone(), chunk);
11088 }
11089 if revokes_harness_mcp {
11090 SessionLifecycleHook::on_session_ended(self, &address);
11091 }
11092 }
11093
11094 fn reconcile_managed_record(
11095 &self,
11096 record_id: &SessionRecordId,
11097 address: &SessionAddress,
11098 event: &ControlEvent,
11099 identity_admitted: bool,
11100 resume_command_rejected: bool,
11101 ) {
11102 let _transaction = self
11103 .state_transaction
11104 .lock()
11105 .unwrap_or_else(|poisoned| poisoned.into_inner());
11106 let observed_identity = match &event.event {
11107 ControlEventKind::ProviderEvent {
11108 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
11109 ..
11110 } if identity_admitted => Some(identity.clone()),
11111 _ => None,
11112 };
11113 let identity_was_observed = observed_identity.is_some();
11114 let replacement_id = observed_identity
11115 .as_ref()
11116 .and_then(|_| self.allocate_record_id().ok());
11117 let mut upserts = Vec::new();
11118 let mut removals = Vec::new();
11119 let mut rebind = None;
11120 let original_records;
11121 let original_materializations = self
11122 .materializations
11123 .lock()
11124 .unwrap_or_else(|poisoned| poisoned.into_inner())
11125 .clone();
11126 {
11127 let mut records = self
11128 .session_records
11129 .lock()
11130 .unwrap_or_else(|poisoned| poisoned.into_inner());
11131 original_records = records.records.clone();
11132 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
11133 return;
11134 };
11135 if let Some(identity) = observed_identity {
11136 let existing_id = records
11137 .records
11138 .values()
11139 .find(|record| {
11140 record.record_id != *record_id
11141 && record.provider_session.as_ref().is_some_and(|record_identity| {
11142 session_registry::same_provider_session(
11143 &record.provider,
11144 record_identity,
11145 ¤t_snapshot.provider,
11146 &identity,
11147 )
11148 })
11149 })
11150 .map(|record| record.record_id.clone());
11151 if let Some(existing_id) = existing_id {
11152 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
11153 record.mode == current_snapshot.mode
11154 && record.workspace_id == current_snapshot.workspace_id
11155 && record.environment_profile == current_snapshot.environment_profile
11156 && record.bundle == current_snapshot.bundle
11157 && record.context_id == current_snapshot.context_id
11158 && record.context == current_snapshot.context
11159 && record
11160 .canonical_root
11161 .as_utf8()
11162 .zip(current_snapshot.canonical_root.as_utf8())
11163 .is_some_and(|(left, right)| platform::roots_equal(left, right))
11164 });
11165 if !scope_matches {
11166 if let Some(current) = records.records.get_mut(record_id) {
11167 current.state = ManagedSessionState::Unavailable;
11168 current.last_error =
11169 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11170 current.updated_at_unix_ms = unix_time_ms();
11171 upserts.push(current.clone());
11172 }
11173 } else {
11174 let existing_is_active = records
11175 .records
11176 .get(&existing_id)
11177 .is_some_and(|record| record.active_session.is_some());
11178 if existing_is_active {
11179 if let Some(current) = records.records.get_mut(record_id) {
11180 current.state = ManagedSessionState::Unavailable;
11181 current.last_error =
11182 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11183 current.updated_at_unix_ms = unix_time_ms();
11184 upserts.push(current.clone());
11185 }
11186 } else {
11187 if current_snapshot.provider_session.is_some() {
11188 if let Some(current) = records.records.get_mut(record_id) {
11189 current.active_session = None;
11190 current.state = ManagedSessionState::Dormant;
11191 current.updated_at_unix_ms = unix_time_ms();
11192 upserts.push(current.clone());
11193 }
11194 } else {
11195 records.records.remove(record_id);
11196 removals.push(record_id.clone());
11197 }
11198 if let Some(existing) = records.records.get_mut(&existing_id) {
11199 existing.provider_session = Some(identity.clone());
11200 existing.active_session = Some(address.clone());
11201 existing.state = ManagedSessionState::Live;
11202 existing.updated_at_unix_ms = unix_time_ms();
11203 existing.last_error = None;
11204 upserts.push(existing.clone());
11205 }
11206 rebind = Some(existing_id);
11207 }
11208 }
11209 } else if current_snapshot
11210 .provider_session
11211 .as_ref()
11212 .is_some_and(|current| {
11213 !session_registry::same_provider_session(
11214 ¤t_snapshot.provider,
11215 current,
11216 ¤t_snapshot.provider,
11217 &identity,
11218 )
11219 })
11220 {
11221 if let Some(current) = records.records.get_mut(record_id) {
11222 current.active_session = None;
11223 if current.bundle.is_some() || current.context.is_some() {
11224 let had_bundle = current.bundle.is_some();
11225 current.provider_session = None;
11226 current.bundle = None;
11227 current.context_id = None;
11228 current.context = None;
11229 current.state = ManagedSessionState::Unavailable;
11230 current.last_error = Some(if had_bundle {
11231 "bundle-unavailable".to_owned()
11232 } else {
11233 "context-unavailable".to_owned()
11234 });
11235 } else {
11236 current.state = ManagedSessionState::Dormant;
11237 }
11238 current.updated_at_unix_ms = unix_time_ms();
11239 upserts.push(current.clone());
11240 }
11241 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11242 if let Some(current) = records.records.get_mut(record_id) {
11243 current.state = ManagedSessionState::Unavailable;
11244 current.last_error =
11245 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11246 current.updated_at_unix_ms = unix_time_ms();
11247 upserts.push(current.clone());
11248 }
11249 } else if let Some(new_record_id) = replacement_id {
11250 let now = unix_time_ms();
11251 let replacement = ManagedSessionRecord {
11252 record_id: new_record_id.clone(),
11253 display_name: format!(
11254 "{} #{}",
11255 current_snapshot.provider.as_str(),
11256 records
11257 .records
11258 .values()
11259 .filter(|record| {
11260 record.provider == current_snapshot.provider
11261 })
11262 .count()
11263 .saturating_add(1),
11264 ),
11265 provider: current_snapshot.provider.clone(),
11266 mode: current_snapshot.mode,
11267 state: ManagedSessionState::Live,
11268 workspace_id: current_snapshot.workspace_id,
11269 canonical_root: current_snapshot.canonical_root,
11270 provider_session: Some(identity),
11271 active_session: Some(address.clone()),
11272 environment_profile: current_snapshot.environment_profile,
11273 bundle: current_snapshot.bundle,
11274 context_id: current_snapshot.context_id,
11275 context: current_snapshot.context,
11276 exported_context: None,
11282 task_binding: current_snapshot.task_binding,
11283 created_at_unix_ms: now,
11284 updated_at_unix_ms: now,
11285 last_error: None,
11286 };
11287 records
11288 .records
11289 .insert(new_record_id.clone(), replacement.clone());
11290 upserts.push(replacement);
11291 rebind = Some(new_record_id);
11292 } else if let Some(current) = records.records.get_mut(record_id) {
11293 current.state = ManagedSessionState::Unavailable;
11294 current.last_error =
11295 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11296 upserts.push(current.clone());
11297 }
11298 } else if let Some(current) = records.records.get_mut(record_id) {
11299 current.provider_session = Some(identity);
11300 current.active_session = Some(address.clone());
11301 current.state = ManagedSessionState::Live;
11302 current.updated_at_unix_ms = unix_time_ms();
11303 current.last_error = None;
11304 upserts.push(current.clone());
11305 }
11306 } else if let Some(current) = records.records.get_mut(record_id) {
11307 if resume_command_rejected {
11308 current.active_session = None;
11309 current.state = detached_record_state(current);
11310 current.updated_at_unix_ms = unix_time_ms();
11311 current.last_error =
11312 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11313 upserts.push(current.clone());
11314 } else {
11315 match &event.event {
11316 ControlEventKind::Running { .. }
11317 | ControlEventKind::ResumeAuthorized { .. }
11318 | ControlEventKind::Resumed { .. } => {
11319 current.active_session = Some(address.clone());
11320 current.state = active_record_state(
11321 identity_admitted,
11322 current.provider_session.is_some(),
11323 );
11324 current.updated_at_unix_ms = unix_time_ms();
11325 current.last_error = None;
11326 upserts.push(current.clone());
11327 }
11328 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11329 current.active_session = None;
11330 current.state = detached_record_state(current);
11331 current.updated_at_unix_ms = unix_time_ms();
11332 upserts.push(current.clone());
11333 }
11334 ControlEventKind::Failed { message }
11335 | ControlEventKind::ResumeFailed { message } => {
11336 current.active_session = None;
11337 current.state = detached_record_state(current);
11338 current.updated_at_unix_ms = unix_time_ms();
11339 current.last_error = Some(
11340 session_registry::sanitized_record_error_summary(message),
11341 );
11342 upserts.push(current.clone());
11343 }
11344 ControlEventKind::ResumeDenied { reason } => {
11345 current.active_session = None;
11346 current.state = detached_record_state(current);
11347 current.updated_at_unix_ms = unix_time_ms();
11348 current.last_error = Some(
11349 session_registry::sanitized_record_error_summary(reason),
11350 );
11351 upserts.push(current.clone());
11352 }
11353 _ => {}
11354 }
11355 }
11356 }
11357 }
11358 let materialization_owner_record = identity_was_observed.then(|| {
11359 rebind.clone().unwrap_or_else(|| record_id.clone())
11360 }).and_then(|candidate| {
11361 self.session_records
11362 .lock()
11363 .unwrap_or_else(|poisoned| poisoned.into_inner())
11364 .records
11365 .get(&candidate)
11366 .filter(|record| {
11367 record.provider_session.is_some()
11368 && record.active_session.as_ref() == Some(address)
11369 })
11370 .map(|_| candidate)
11371 });
11372 if let Some(owner_record_id) = materialization_owner_record {
11373 let materialization_id = self
11374 .session_bindings
11375 .lock()
11376 .unwrap_or_else(|poisoned| poisoned.into_inner())
11377 .get(&address.session.instance_id)
11378 .and_then(|binding| binding.materialization_id.clone());
11379 if let Some(materialization_id) = materialization_id {
11380 let mut transfer_failed = false;
11381 {
11382 let mut materializations = self
11383 .materializations
11384 .lock()
11385 .unwrap_or_else(|poisoned| poisoned.into_inner());
11386 if let Some(ownership) = materializations.get_mut(&materialization_id) {
11387 match ownership.owner() {
11388 MaterializationOwner::Session {
11389 incarnation_id,
11390 instance_id,
11391 generation,
11392 } if *incarnation_id == self.incarnation_id
11393 && *instance_id == address.session.instance_id
11394 && *generation == address.session.generation =>
11395 {
11396 transfer_failed = ownership
11397 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11398 .is_err();
11399 }
11400 MaterializationOwner::Record { record_id }
11401 if record_id == &owner_record_id => {}
11402 MaterializationOwner::Record { record_id } => {
11403 let expected = record_id.clone();
11404 transfer_failed = ownership
11405 .transfer_record_owner(
11406 &expected,
11407 owner_record_id.clone(),
11408 unix_time_ms(),
11409 )
11410 .is_err();
11411 }
11412 _ => transfer_failed = true,
11413 }
11414 if transfer_failed {
11415 let _ = ownership.mark_recovery_required(unix_time_ms());
11416 }
11417 } else {
11418 transfer_failed = true;
11419 }
11420 }
11421 if transfer_failed {
11422 if let Some(record) = self
11423 .session_records
11424 .lock()
11425 .unwrap_or_else(|poisoned| poisoned.into_inner())
11426 .records
11427 .get_mut(&owner_record_id)
11428 {
11429 record.state = ManagedSessionState::Unavailable;
11430 record.last_error = Some("environment-profile-unavailable".to_owned());
11431 record.updated_at_unix_ms = unix_time_ms();
11432 upserts.push(record.clone());
11433 }
11434 }
11435 }
11436 }
11437 if let Some(new_record_id) = rebind.clone() {
11438 if let Some(binding) = self
11439 .session_bindings
11440 .lock()
11441 .unwrap_or_else(|poisoned| poisoned.into_inner())
11442 .get_mut(&address.session.instance_id)
11443 {
11444 binding.record_id = Some(new_record_id);
11445 }
11446 }
11447 if upserts.is_empty() && removals.is_empty() {
11448 return;
11449 }
11450 if self.persist_state_locked().is_err() {
11451 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11452 let mut records = self
11453 .session_records
11454 .lock()
11455 .unwrap_or_else(|poisoned| poisoned.into_inner());
11456 records.records = original_records;
11457 *self
11458 .materializations
11459 .lock()
11460 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11461 if let Some(current) = records.records.get_mut(record_id) {
11462 current.state = ManagedSessionState::Unavailable;
11463 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11464 current.updated_at_unix_ms = unix_time_ms();
11465 upserts = vec![current.clone()];
11466 } else {
11467 upserts.clear();
11468 }
11469 removals.clear();
11470 drop(records);
11471 if let Some(binding) = self
11472 .session_bindings
11473 .lock()
11474 .unwrap_or_else(|poisoned| poisoned.into_inner())
11475 .get_mut(&address.session.instance_id)
11476 {
11477 binding.record_id = Some(record_id.clone());
11478 }
11479 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11480 }
11481 for record in upserts {
11482 if record.active_session.as_ref() == Some(address) {
11483 if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
11484 crate::pty_resume::set_session_id(&file, address.session.instance_id.0, &identity.id);
11485 }
11486 }
11487 self.publish_record(record);
11488 }
11489 for record_id in removals {
11490 self.publish(NodeEvent::SessionRecordRemoved { record_id });
11491 }
11492 }
11493
11494 fn snapshot(&self) -> NodeSnapshot {
11495 let control = self.handle.snapshot();
11496 let bindings = self
11497 .session_bindings
11498 .lock()
11499 .unwrap_or_else(|poisoned| poisoned.into_inner())
11500 .clone();
11501 let workspace_roots = self
11502 .workspaces
11503 .read()
11504 .unwrap_or_else(|poisoned| poisoned.into_inner())
11505 .clone();
11506 let environment_profiles = self
11507 .environment_profiles
11508 .read()
11509 .unwrap_or_else(|poisoned| poisoned.into_inner());
11510 let mut workspaces = workspace_roots
11511 .iter()
11512 .map(|(workspace_id, canonical_root)| {
11513 (
11514 workspace_id.clone(),
11515 WorkspaceSnapshot {
11516 workspace_id: workspace_id.clone(),
11517 canonical_root: opaque_windows_path(canonical_root.clone()),
11518 sessions: Vec::new(),
11519 worktree_service_mode: Some(
11520 self.worktree_service_modes
11521 .get(workspace_id)
11522 .copied()
11523 .unwrap_or(WorktreeServiceMode::Manual),
11524 ),
11525 managed_worktree_profiles: Some(WorktreeProfileInventory {
11526 profiles: self
11527 .managed_worktree_profiles
11528 .get(workspace_id)
11529 .into_iter()
11530 .flat_map(BTreeMap::values)
11531 .map(|profile| ManagedWorktreeProfileSummary {
11532 id: profile.profile_id().clone(),
11533 revision: profile.revision().clone(),
11534 retention: profile.retention(),
11535 })
11536 .collect(),
11537 }),
11538 },
11539 )
11540 })
11541 .collect::<BTreeMap<_, _>>();
11542 let mut agent_progress = Vec::new();
11543 for session in &control.sessions {
11544 let Some(binding) = bindings.get(&session.instance_id) else {
11545 continue;
11546 };
11547 if binding.generation != session.generation {
11548 continue;
11549 }
11550 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11551 let address = SessionAddress {
11552 workspace_id: binding.workspace_id.clone(),
11553 session: SessionKey {
11554 instance_id: session.instance_id,
11555 generation: session.generation,
11556 },
11557 };
11558 if let Some(progress) =
11559 agent_progress_from_provider_snapshot(address, &session.provider)
11560 {
11561 agent_progress.push(progress);
11562 }
11563 workspace.sessions.push(session.clone());
11564 }
11565 }
11566 NodeSnapshot {
11567 node_id: self.node_id.clone(),
11568 enabled_providers: self.enabled_providers.clone(),
11569 provider_runtime_statuses: {
11570 let updates = self
11571 .provider_runtime_status_updates
11572 .read()
11573 .unwrap_or_else(|poisoned| poisoned.into_inner());
11574 ProviderRuntimeStatuses::new(
11575 self.provider_runtime_statuses
11576 .iter()
11577 .filter(|status| !updates.contains_key(status.provider()))
11578 .cloned()
11579 .chain(updates.values().cloned()),
11580 )
11581 .expect("refreshed provider runtime inventory remains bounded and unique")
11582 },
11583 workspaces: workspaces.into_values().collect(),
11584 session_records: self
11585 .session_records
11586 .lock()
11587 .unwrap_or_else(|poisoned| poisoned.into_inner())
11588 .records
11589 .values()
11590 .cloned()
11591 .collect(),
11592 managed_worktrees: self.managed_worktrees
11593 .lock()
11594 .unwrap_or_else(|poisoned| poisoned.into_inner())
11595 .snapshots(),
11596 launch_inventory: Some(LaunchInventory {
11597 spawn_profiles: Some(
11598 self.spawn_profiles
11599 .iter()
11600 .filter_map(|profile| {
11601 resolved_spawn_profile_summary(profile, &environment_profiles)
11602 })
11603 .collect(),
11604 ),
11605 bundles: Some(
11606 self.bundle_catalog
11607 .read()
11608 .unwrap_or_else(|poisoned| poisoned.into_inner())
11609 .iter()
11610 .map(NodeBundle::receipt)
11611 .collect(),
11612 ),
11613 network_allowlists: {
11616 let catalog = self
11617 .network_allowlist_catalog
11618 .read()
11619 .unwrap_or_else(|poisoned| poisoned.into_inner());
11620 if catalog.is_empty() {
11621 None
11622 } else {
11623 Some(catalog.ids().cloned().collect())
11624 }
11625 },
11626 }),
11627 agent_progress,
11628 }
11629 }
11630
11631 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11632 assert!(
11633 !matches!(
11634 &event,
11635 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11636 ),
11637 "terminal frame and agent stream events must use their own dedicated live channel",
11638 );
11639 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11640 let sequence = history
11641 .last_sequence
11642 .checked_add(1)
11643 .expect("node event sequence exhausted");
11644 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11645 if let Some(evicted) = history.events.pop_front() {
11646 history.replay_floor_sequence = evicted
11647 .sequence
11648 .checked_add(1)
11649 .expect("node replay floor sequence exhausted");
11650 history.removed_record_providers.remove(&evicted.sequence);
11651 }
11652 }
11653 match &event {
11654 NodeEvent::HarnessMcpReadCall { .. } => {
11655 panic!("transient harness MCP calls must not enter durable event history")
11656 }
11657 NodeEvent::SessionRecordUpserted { record } => {
11658 history
11659 .record_providers
11660 .insert(record.record_id.clone(), record.provider.clone());
11661 }
11662 NodeEvent::SessionRecordRemoved { record_id } => {
11663 if let Some(provider) = history.record_providers.remove(record_id) {
11664 history.removed_record_providers.insert(sequence, provider);
11665 }
11666 }
11667 NodeEvent::ManagedWorktreeUpserted { .. }
11668 | NodeEvent::ManagedWorktreeRemoved { .. }
11669 | NodeEvent::Control { .. }
11670 | NodeEvent::ControllerChanged { .. }
11671 | NodeEvent::SessionRecordHistorySummarized { .. }
11672 | NodeEvent::WorkspaceAdded { .. }
11673 | NodeEvent::WorkspaceRemoved { .. }
11674 | NodeEvent::ResyncRequired { .. }
11675 | NodeEvent::AgentStream { .. }
11676 | NodeEvent::TerminalFrame { .. } => {}
11677 }
11678 let envelope = NodeEventEnvelope { sequence, event };
11679 history.events.push_back(envelope.clone());
11680 history.last_sequence = sequence;
11681 let _ = self.event_tx.send(envelope.clone());
11682 drop(history);
11683 envelope
11684 }
11685
11686 fn publish_transient(&self, event: NodeEvent) -> bool {
11697 assert!(event.requires_harness_mcp_proxy_capability());
11698 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11699 let envelope = NodeEventEnvelope { sequence: 0, event };
11700 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11701 Ok(_) => true,
11702 Err(broadcast::error::SendError(undelivered)) => {
11703 if let Some(NodeEventEnvelope {
11704 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11705 ..
11706 }) = undelivered.first() {
11707 tracing::warn!(
11713 call_id = call_id.as_str(),
11714 content_type = ?request.content_type,
11715 cause = "no-subscribers",
11716 "harness MCP proxy event could not be published",
11717 );
11718 }
11719 false
11720 }
11721 }
11722 }
11723
11724 fn publish_terminal_frames(&self) {
11725 let snapshot = self.handle.snapshot();
11726 let bindings = self
11727 .session_bindings
11728 .lock()
11729 .unwrap_or_else(|poisoned| poisoned.into_inner());
11730 let candidates = snapshot
11731 .sessions
11732 .iter()
11733 .filter_map(|session| {
11734 let binding = bindings.get(&session.instance_id)?;
11735 if binding.generation != session.generation {
11736 return None;
11737 }
11738 let frame = session.terminal_frame.clone()?;
11739 crate::session_signals::feed(
11740 binding.workspace_id.as_str(),
11741 session.instance_id.0,
11742 session.agent_id.as_str(),
11743 &frame.contents,
11744 );
11745 crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11746 let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_screen(session.instance_id.0, &frame.contents, Instant::now());
11747 if let Some(act) = act {
11748 provider_error_act(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str(), "pty", act);
11749 }
11750 crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11751 Some((
11752 SessionAddress {
11753 workspace_id: binding.workspace_id.clone(),
11754 session: SessionKey {
11755 instance_id: session.instance_id,
11756 generation: session.generation,
11757 },
11758 },
11759 frame,
11760 ))
11761 })
11762 .collect();
11763 drop(bindings);
11764 self.publish_terminal_frame_candidates(candidates);
11765 }
11766
11767 fn publish_terminal_frame_candidates(
11768 &self,
11769 candidates: Vec<(SessionAddress, TerminalFrame)>,
11770 ) {
11771 let mut watermarks = self
11772 .terminal_frame_watermarks
11773 .lock()
11774 .unwrap_or_else(|poisoned| poisoned.into_inner());
11775 let mut events = Vec::with_capacity(candidates.len());
11776 for (address, frame) in candidates {
11777 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11778 let advanced = watermarks
11779 .get(&address.session.instance_id)
11780 .map_or(true, |(current_address, sequence)| {
11781 current_address != &address || frame.sequence > *sequence
11782 });
11783 if !advanced {
11784 continue;
11785 }
11786 if is_first_frame {
11787 if self.controller_state().is_none() {
11801 tracing::warn!(
11802 node_id = %self.node_id,
11803 workspace_id = %address.workspace_id,
11804 session = ?address.session,
11805 terminal_size = ?frame.size,
11806 "session producing output with no controller attached",
11807 );
11808 } else {
11809 tracing::info!(
11810 node_id = %self.node_id,
11811 workspace_id = %address.workspace_id,
11812 session = ?address.session,
11813 terminal_size = ?frame.size,
11814 "session produced its first output",
11815 );
11816 }
11817 }
11818 watermarks.insert(
11819 address.session.instance_id,
11820 (address.clone(), frame.sequence),
11821 );
11822 if let Some((at, reported)) = self
11823 .pty_input_marks
11824 .lock()
11825 .unwrap_or_else(|poisoned| poisoned.into_inner())
11826 .get_mut(&address.session.instance_id)
11827 {
11828 if !*reported && at.elapsed() < Duration::from_secs(5) {
11830 *reported = true;
11831 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");
11832 }
11833 }
11834 events.push(NodeEvent::TerminalFrame { address, frame });
11835 }
11836 drop(watermarks);
11837 if events.is_empty() {
11838 return;
11839 }
11840 let mut history = self
11841 .history
11842 .lock()
11843 .unwrap_or_else(|poisoned| poisoned.into_inner());
11844 let mut envelopes = Vec::with_capacity(events.len());
11845 for event in events {
11846 let sequence = history
11847 .last_sequence
11848 .checked_add(1)
11849 .expect("node event sequence exhausted");
11850 history.last_sequence = sequence;
11851 envelopes.push(NodeEventEnvelope { sequence, event });
11852 }
11853 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11854 drop(history);
11855 }
11856
11857 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11866 let mut history = self
11867 .history
11868 .lock()
11869 .unwrap_or_else(|poisoned| poisoned.into_inner());
11870 let sequence = history
11871 .last_sequence
11872 .checked_add(1)
11873 .expect("node event sequence exhausted");
11874 history.last_sequence = sequence;
11875 drop(history);
11876 tracing::debug!(
11884 source_sequence = chunk.source_sequence,
11885 kind = agent_stream_chunk_kind_label(&chunk.kind),
11886 envelope_sequence = sequence,
11887 "publishing agent stream chunk",
11888 );
11889 let envelope = NodeEventEnvelope {
11890 sequence,
11891 event: NodeEvent::AgentStream { address, chunk },
11892 };
11893 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11894 }
11895
11896 fn current_sequence(&self) -> u64 {
11897 self.history
11898 .lock()
11899 .unwrap_or_else(|poisoned| poisoned.into_inner())
11900 .last_sequence
11901 }
11902
11903 fn controller_state(&self) -> Option<ControllerState> {
11904 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11905 let lease = controller.as_ref().copied()?;
11906 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11907 if remaining.is_zero() {
11908 *controller = None;
11909 return None;
11910 }
11911 Some(ControllerState {
11912 connection_id: lease.connection_id,
11913 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11914 })
11915 }
11916
11917 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11918 if role != ClientRole::Operator {
11919 tracing::warn!(
11920 node_id = %self.node_id,
11921 connection_id,
11922 "controller lease rejected: observer connections cannot acquire control",
11923 );
11924 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11925 }
11926 let lease_ms = if lease_ms == 0 {
11927 DEFAULT_CONTROLLER_LEASE_MS
11928 } else {
11929 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11930 };
11931 let now = Instant::now();
11932 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11933 if let Some(active) = controller.as_ref() {
11934 if active.connection_id != connection_id && active.expires_at > now {
11935 tracing::warn!(
11936 node_id = %self.node_id,
11937 connection_id,
11938 holder_connection_id = active.connection_id,
11939 "controller lease rejected: another operator holds it",
11940 );
11941 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11942 }
11943 }
11944 let is_new_attach = match controller.as_ref() {
11951 Some(active) => active.connection_id != connection_id,
11952 None => true,
11953 };
11954 *controller = Some(ControllerLease {
11955 connection_id,
11956 expires_at: now + Duration::from_millis(lease_ms),
11957 });
11958 drop(controller);
11959 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11960 if is_new_attach {
11961 tracing::info!(
11962 node_id = %self.node_id,
11963 connection_id,
11964 lease_ms,
11965 "controller lease acquired",
11966 );
11967 } else {
11968 tracing::debug!(
11969 node_id = %self.node_id,
11970 connection_id,
11971 lease_ms,
11972 "controller lease renewed",
11973 );
11974 }
11975 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11976 Ok(state)
11977 }
11978
11979 fn release_controller(&self, connection_id: u64) -> bool {
11980 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11981 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11982 if released {
11983 *controller = None;
11984 }
11985 drop(controller);
11986 if released {
11987 tracing::info!(
11988 node_id = %self.node_id,
11989 connection_id,
11990 "controller lease released",
11991 );
11992 self.publish(NodeEvent::ControllerChanged { controller: None });
11993 }
11994 released
11995 }
11996
11997 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
12006 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
12007 return;
12008 };
12009 let id = address.session.instance_id;
12010 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
12011 return;
12012 };
12013 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
12014 .iter()
12015 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
12016 .collect();
12017 let (writes, set_model, effort_gap) =
12018 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
12019 if set_model {
12020 if let Some(model_id) = model.clone() {
12021 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
12022 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
12023 }
12024 }
12025 }
12026 for (option_id, value_json) in writes {
12027 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
12028 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
12029 }
12030 }
12031 if effort_gap {
12032 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
12033 }
12034 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
12035 }
12036
12037 fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
12040 let id = address.session.instance_id;
12041 let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
12042 if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
12043 crate::auto_permit::forget_acp(id.0);
12044 return;
12045 }
12046 let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
12047 if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
12048 tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
12049 }
12050 match provider_event {
12051 ProviderEvent::ModeChanged { mode_id, available } => {
12052 if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
12053 match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
12054 Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
12055 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
12056 }
12057 }
12058 }
12059 ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
12060 match self.dispatch(ControlCommand::ResolveInteraction {
12061 instance_id: id,
12062 generation: address.session.generation,
12063 interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
12064 response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
12065 }) {
12066 Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
12067 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
12068 }
12069 }
12070 _ => {}
12073 }
12074 }
12075
12076 fn observe_acp_provider_error(&self, address: &SessionAddress, event: &ControlEvent) {
12079 let id = address.session.instance_id.0;
12080 let found = match &event.event {
12081 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
12082 self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).forget(id);
12083 return;
12084 }
12085 ControlEventKind::ProviderEvent { event: e, .. } => match e {
12086 ProviderEvent::TurnStarted { prompt } => {
12087 self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_started(id, prompt.as_deref());
12088 return;
12089 }
12090 ProviderEvent::Text { text, is_delta } => {
12091 self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_text(id, text, *is_delta);
12092 return;
12093 }
12094 ProviderEvent::ToolStarted { .. } => {
12095 self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_tool(id);
12096 return;
12097 }
12098 ProviderEvent::SessionEnded { is_error: false, result, .. } => {
12099 let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_ended(id, result, Instant::now());
12100 if let Some(act) = act {
12101 let provider = crate::auto_permit::acp_provider(id).map(|p| p.to_string()).unwrap_or_else(|| "acp".into());
12102 provider_error_act(address.workspace_id.as_str(), id, &provider, "acp", act);
12103 }
12104 return;
12105 }
12106 ProviderEvent::SessionEnded { stop_reason: Some(ProviderStopReason::ProviderError { message, vendor_code, .. }), .. } => {
12107 let found = crate::provider_error::classify_message(message, vendor_code.as_deref());
12108 let provider = crate::auto_permit::acp_provider(id).unwrap_or("acp");
12109 let window = crate::provider_limits::fresh_5h_used(provider, 10 * 60 * 1000);
12110 crate::provider_error::limit_from_window(found, vendor_code.as_deref(), window)
12111 }
12112 ProviderEvent::Error { message } => crate::provider_error::classify_message(message, None),
12113 _ => return,
12114 },
12115 _ => return,
12116 };
12117 let provider = crate::auto_permit::acp_provider(id).map(|p| p.to_string()).unwrap_or_else(|| "acp".into());
12118 let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_acp_error(id, found, Instant::now());
12119 if let Some(act) = act {
12120 provider_error_act(address.workspace_id.as_str(), id, &provider, "acp", act);
12121 }
12122 }
12123
12124 fn run_due_provider_retries(&self) {
12126 let due = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).take_due(Instant::now());
12127 for (id, how, attempt) in due {
12128 let instance_id = AgentInstanceId(id);
12129 let r = match how {
12130 crate::provider_error::Retry::Pty => self
12131 .dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalText(TerminalText { text: "continue".to_owned() }) })
12132 .and_then(|_| self.dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalControl(TerminalControl::Enter) })),
12133 crate::provider_error::Retry::Acp { prompt } => self.dispatch(ControlCommand::SendInput {
12134 instance_id,
12135 action: InputAction::SubmitPrompt(PromptPayload { text: prompt, framing: PromptFraming::BracketedPaste }),
12136 }),
12137 };
12138 match r {
12139 Ok(_) => tracing::info!(target: "provider_retry", instance_id = id, attempt, "provider error: turn retried"),
12140 Err(e) => tracing::warn!(target: "provider_retry", instance_id = id, attempt, message = %e.message, "provider error: retry not sent"),
12141 }
12142 }
12143 }
12144
12145 fn mark_spawn_provisional(&self, address: SessionAddress) {
12146 let id = address.session.instance_id;
12147 self.provisional_spawns
12148 .lock()
12149 .unwrap_or_else(|poisoned| poisoned.into_inner())
12150 .insert(id, (address, Instant::now()));
12151 tracing::info!(
12152 node_id = %self.node_id,
12153 instance_id = id.0,
12154 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
12155 "draft spawn; waiting for HQ view",
12156 );
12157 }
12158
12159 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
12160 let removed = self
12161 .provisional_spawns
12162 .lock()
12163 .unwrap_or_else(|poisoned| poisoned.into_inner())
12164 .remove(&instance_id);
12165 if removed.is_some() {
12166 tracing::info!(
12167 node_id = %self.node_id,
12168 instance_id = instance_id.0,
12169 "draft spawn claimed by HQ view",
12170 );
12171 }
12172 }
12173
12174 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
12175 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
12176 let mut map = self
12177 .provisional_spawns
12178 .lock()
12179 .unwrap_or_else(|poisoned| poisoned.into_inner());
12180 let due: Vec<AgentInstanceId> = map
12181 .iter()
12182 .filter(|(_, (_, at))| at.elapsed() >= grace)
12183 .map(|(id, _)| *id)
12184 .collect();
12185 due.into_iter()
12186 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
12187 .collect()
12188 }
12189
12190 async fn reap_provisional_spawn(&self, address: SessionAddress) {
12191 let instance_id = address.session.instance_id;
12192 tracing::warn!(
12193 node_id = %self.node_id,
12194 instance_id = instance_id.0,
12195 workspace = %address.workspace_id,
12196 reason = "attach_never_arrived",
12197 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
12198 "draft spawn never viewed; stopping PTY",
12199 );
12200 let _ = self.dispatch(ControlCommand::Stop {
12201 instance_id,
12202 force: true,
12203 });
12204 let _ = self.remove_session(&address).await;
12205 }
12206
12207 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
12208 if role != ClientRole::Operator {
12209 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
12210 }
12211 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12212 let now = Instant::now();
12213 match controller.as_ref().copied() {
12214 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
12215 Some(active) if active.expires_at <= now => {
12216 *controller = None;
12217 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
12218 }
12219 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
12220 }
12221 }
12222
12223 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
12224 if !self
12225 .workspaces
12226 .read()
12227 .unwrap_or_else(|poisoned| poisoned.into_inner())
12228 .contains_key(&address.workspace_id)
12229 {
12230 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
12231 }
12232 let binding = self
12233 .session_bindings
12234 .lock()
12235 .unwrap_or_else(|poisoned| poisoned.into_inner())
12236 .get(&address.session.instance_id)
12237 .cloned()
12238 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
12239 if binding.workspace_id != address.workspace_id {
12240 return Err(failure(
12241 NodeFailureCode::SessionWorkspaceMismatch,
12242 "session belongs to another workspace",
12243 ));
12244 }
12245 if binding.generation != address.session.generation {
12246 return Err(failure(
12247 NodeFailureCode::StaleGeneration,
12248 &format!(
12249 "stale session generation: expected {}, received {}",
12250 binding.generation.0,
12251 address.session.generation.0,
12252 ),
12253 ));
12254 }
12255 let snapshot = self.handle.snapshot();
12256 let Some(current) = snapshot
12257 .sessions
12258 .iter()
12259 .find(|item| item.instance_id == address.session.instance_id)
12260 else {
12261 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12262 };
12263 if current.generation != address.session.generation {
12264 return Err(failure(
12265 NodeFailureCode::StaleGeneration,
12266 &format!(
12267 "stale session generation: expected {}, received {}",
12268 current.generation.0,
12269 address.session.generation.0,
12270 ),
12271 ));
12272 }
12273 Ok(current.agent_id.clone())
12274 }
12275
12276 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12277 self.dispatch_envelope(self.prepare_command(command))
12278 }
12279
12280 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12281 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12282 CommandEnvelope {
12283 id: CommandId(id),
12284 command,
12285 }
12286 }
12287
12288 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12289 let command_id = envelope.id;
12290 self.handle
12291 .dispatch(envelope)
12292 .map_err(|error| match error {
12293 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12294 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12295 })?;
12296 Ok(command_id)
12297 }
12298
12299 fn record_rejected_command(
12308 &self,
12309 command_id: CommandId,
12310 message: String,
12311 unsupported_transport: bool,
12312 ) {
12313 let now = Instant::now();
12314 let mut reasons = self.rejected_command_reasons.lock()
12315 .unwrap_or_else(|poisoned| poisoned.into_inner());
12316 reasons.retain(|_, reason| reason.expires_at > now);
12317 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12318 if let Some(&oldest) = reasons.keys().next() {
12319 reasons.remove(&oldest);
12320 }
12321 }
12322 reasons.insert(
12323 command_id,
12324 RejectedCommandReason {
12325 message,
12326 unsupported_transport,
12327 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12328 },
12329 );
12330 }
12331
12332 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12339 let now = Instant::now();
12340 let mut reasons = self.rejected_command_reasons.lock()
12341 .unwrap_or_else(|poisoned| poisoned.into_inner());
12342 match reasons.remove(&command_id) {
12343 Some(reason) if reason.expires_at > now => Some(reason),
12344 _ => None,
12345 }
12346 }
12347
12348 async fn dispatch_bounded(
12349 &self,
12350 command: ControlCommand,
12351 timeout_duration: Duration,
12352 ) -> Result<CommandId, NodeFailure> {
12353 let deadline = Instant::now() + timeout_duration;
12354 loop {
12355 match self.dispatch(command.clone()) {
12356 Ok(command_id) => return Ok(command_id),
12357 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12358 sleep(Duration::from_millis(2)).await;
12359 }
12360 Err(error) => return Err(error),
12361 }
12362 }
12363 }
12364
12365 fn internal_session_snapshot(
12366 &self,
12367 address: &SessionAddress,
12368 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12369 self.validate_address(address)?;
12370 self.handle
12371 .snapshot()
12372 .sessions
12373 .clone()
12374 .into_iter()
12375 .find(|session| {
12376 session.instance_id == address.session.instance_id
12377 && session.generation == address.session.generation
12378 })
12379 .ok_or_else(|| {
12380 failure(
12381 NodeFailureCode::UnknownSession,
12382 "session snapshot is unavailable",
12383 )
12384 })
12385 }
12386
12387 fn require_acp_session(
12395 &self,
12396 address: &SessionAddress,
12397 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12398 let snapshot = self.internal_session_snapshot(address)?;
12399 if snapshot.transport != TransportKind::Acp {
12400 return Err(failure(
12401 NodeFailureCode::UnsupportedTransport,
12402 "this verb requires a session spawned over the ACP transport",
12403 ));
12404 }
12405 Ok(snapshot)
12406 }
12407
12408 async fn dispatch_history_bounded(
12409 &self,
12410 address: &SessionAddress,
12411 command: ControlCommand,
12412 operation: HistoryOperation,
12413 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12414 self.validate_address(address)?;
12415 let started_after = self.current_sequence();
12416 let command_id = self
12417 .dispatch_bounded(
12418 command,
12419 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12420 )
12421 .await?;
12422 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12423 let mut scan_after = started_after;
12424 let mut accepted_after = None;
12425 loop {
12426 let events = self
12427 .history
12428 .lock()
12429 .unwrap_or_else(|poisoned| poisoned.into_inner())
12430 .events
12431 .iter()
12432 .filter(|envelope| envelope.sequence > scan_after)
12433 .cloned()
12434 .collect::<Vec<_>>();
12435 for envelope in events {
12436 scan_after = scan_after.max(envelope.sequence);
12437 let NodeEvent::Control {
12438 address: event_address,
12439 event,
12440 } = envelope.event
12441 else {
12442 continue;
12443 };
12444 if event_address != *address {
12445 continue;
12446 }
12447 if event.command_id == Some(command_id) {
12448 match &event.event {
12449 ControlEventKind::CommandRejected { .. } => {
12450 return Err(failure(
12451 NodeFailureCode::InvalidRequest,
12452 "history request was rejected",
12453 ));
12454 }
12455 ControlEventKind::HistoryRequested {
12456 operation: requested,
12457 ..
12458 } if requested == &operation => {
12459 accepted_after = Some(envelope.sequence);
12460 continue;
12461 }
12462 _ => {}
12463 }
12464 }
12465 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12466 match (&operation, &event.event) {
12467 (
12468 HistoryOperation::Discover { .. },
12469 ControlEventKind::HistoryDiscovered { .. },
12470 )
12471 | (
12472 HistoryOperation::Load { .. },
12473 ControlEventKind::HistoryLoaded { .. },
12474 ) => return self.internal_session_snapshot(address),
12475 (_, ControlEventKind::HistoryFailed { .. }) => {
12476 return Err(failure(
12477 NodeFailureCode::BackendOperationFailed,
12478 "history operation failed",
12479 ));
12480 }
12481 _ => {}
12482 }
12483 }
12484 }
12485 if Instant::now() >= deadline {
12486 return Err(failure(
12487 NodeFailureCode::BackendBusy,
12488 if accepted_after.is_some() {
12489 "history operation did not settle before the bounded deadline"
12490 } else {
12491 "history operation was not accepted before the bounded deadline"
12492 },
12493 ));
12494 }
12495 sleep(Duration::from_millis(2)).await;
12496 }
12497 }
12498
12499 async fn discover_history(
12500 &self,
12501 address: &SessionAddress,
12502 limit: u16,
12503 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12504 let query = HistoryQuery {
12505 working_directory: Some(self.workspace_root(&address.workspace_id)?),
12506 limit,
12507 };
12508 query
12509 .validate()
12510 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12511 let snapshot = self
12512 .dispatch_history_bounded(
12513 address,
12514 ControlCommand::DiscoverHistory {
12515 instance_id: address.session.instance_id,
12516 query: query.clone(),
12517 },
12518 HistoryOperation::Discover { query },
12519 )
12520 .await?;
12521 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12522 return Err(failure(
12523 NodeFailureCode::BackendOperationFailed,
12524 "history discovery settled into an invalid state",
12525 ));
12526 }
12527 Ok(snapshot.history.candidates)
12528 }
12529
12530 async fn load_history(
12531 &self,
12532 address: &SessionAddress,
12533 candidate_id: String,
12534 ) -> Result<HistorySessionRecord, NodeFailure> {
12535 validate_candidate_id(&candidate_id)
12536 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12537 let snapshot = self
12538 .dispatch_history_bounded(
12539 address,
12540 ControlCommand::LoadHistory {
12541 instance_id: address.session.instance_id,
12542 candidate_id: candidate_id.clone(),
12543 },
12544 HistoryOperation::Load {
12545 candidate_id: candidate_id.clone(),
12546 },
12547 )
12548 .await?;
12549 if snapshot.history.pending.is_some()
12550 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12551 {
12552 return Err(failure(
12553 NodeFailureCode::BackendOperationFailed,
12554 "history load settled into an invalid state",
12555 ));
12556 }
12557 snapshot.history.loaded.ok_or_else(|| {
12558 failure(
12559 NodeFailureCode::BackendOperationFailed,
12560 "history load produced no bounded session",
12561 )
12562 })
12563 }
12564
12565 async fn export_context_pack_for_session_record(
12572 &self,
12573 record_id: &SessionRecordId,
12574 session: &SessionAddress,
12575 allow_clean_detachment: bool,
12576 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12577 let outcome = self
12583 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12584 .await;
12585 if let Err(failure) = &outcome {
12586 tracing::warn!(
12587 record = %record_id,
12588 code = ?failure.code,
12589 reason = %failure.message,
12590 "session-record context-pack export refused",
12591 );
12592 }
12593 outcome
12594 }
12595
12596 async fn export_context_pack_for_session_record_inner(
12597 &self,
12598 record_id: &SessionRecordId,
12599 session: &SessionAddress,
12600 allow_clean_detachment: bool,
12601 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12602 let step = |name: &'static str| move |failure: NodeFailure| {
12607 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12608 failure
12609 };
12610 let record = self.record(record_id).map_err(step("record-lookup"))?;
12611 let identity = match record.provider_session.clone() {
12627 Some(identity) => {
12628 if identity.key != ProviderSessionKey::SessionId {
12629 return Err(step("provider-identity-key")(failure(
12630 NodeFailureCode::BackendOperationFailed,
12631 "managed session provider identity cannot bind history exactly",
12632 )));
12633 }
12634 Some(identity)
12635 }
12636 None if record.state == ManagedSessionState::IdentityPending => {
12637 return Err(step("provider-session-identity")(failure(
12638 NodeFailureCode::BackendOperationFailed,
12639 "managed session has not yet observed its provider session identity",
12640 )));
12641 }
12642 None => None,
12643 };
12644 self.revalidate_session_record_context_export(
12645 &record,
12646 identity.as_ref(),
12647 session,
12648 allow_clean_detachment,
12649 ).map_err(step("revalidate-before-discovery"))?;
12650 let candidates = self
12651 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12652 .await
12653 .map_err(step("discover-history"))?;
12654 let candidate_id = select_history_candidate(
12659 &candidates,
12660 identity.as_ref().map(|identity| identity.id.as_str()),
12661 ).map_err(step("unique-candidate"))?;
12662 let loaded = self.load_history(session, candidate_id.clone()).await
12663 .map_err(step("load-history"))?;
12664 if let Some(identity) = identity.as_ref() {
12665 if loaded.session_id != identity.id {
12666 return Err(failure(
12667 NodeFailureCode::SessionRecordConflict,
12668 "loaded history identity does not match the managed session record",
12669 ));
12670 }
12671 }
12672 self.revalidate_session_record_context_export(
12673 &record,
12674 identity.as_ref(),
12675 session,
12676 allow_clean_detachment,
12677 )?;
12678 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12679 .map_err(step("revalidate-loaded-history"))?;
12680 let pack = self.materialize_context_pack(session).await
12681 .map_err(step("materialize-context-pack"))?;
12682 self.commit_context_pack_for_session_record(
12683 &record,
12684 identity.as_ref(),
12685 session,
12686 &candidate_id,
12687 &loaded,
12688 pack,
12689 allow_clean_detachment,
12690 )
12691 }
12692
12693 async fn export_context_pack(
12694 &self,
12695 address: &SessionAddress,
12696 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12697 let pack = self.materialize_context_pack(address).await?;
12698 self.commit_context_pack(pack)
12699 }
12700
12701 async fn materialize_context_pack(
12702 &self,
12703 address: &SessionAddress,
12704 ) -> Result<NodeContextPack, NodeFailure> {
12705 let snapshot = self.internal_session_snapshot(address)?;
12706 if !context_pack_source_status_is_usable(&snapshot.status) {
12707 return Err(failure(
12708 NodeFailureCode::InvalidRequest,
12709 "context export requires a running or successfully exited source session",
12710 ));
12711 }
12712 if snapshot.history.pending.is_some()
12713 || snapshot.history.loaded_candidate_id.is_none()
12714 {
12715 return Err(failure(
12716 NodeFailureCode::InvalidRequest,
12717 "context export requires settled loaded history",
12718 ));
12719 }
12720 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12721 failure(
12722 NodeFailureCode::InvalidRequest,
12723 "context export requires settled loaded history",
12724 )
12725 })?;
12726 let source_provider = snapshot.agent_id.clone();
12727 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12728 let repository = self
12729 .context_pack_repository(&address.workspace_id)
12730 .await?;
12731 let current = self.internal_session_snapshot(address)?;
12732 if !context_pack_source_status_is_usable(¤t.status)
12733 || current.agent_id != source_provider
12734 || current.history.pending.is_some()
12735 || current.history.loaded_candidate_id != loaded_candidate_id
12736 || current.history.loaded.as_ref() != Some(&loaded)
12737 {
12738 return Err(failure(
12739 NodeFailureCode::ContextPackMaterializationFailed,
12740 "context source changed during bounded repository capture",
12741 ));
12742 }
12743 NodeContextPack::export_with_repository(
12744 ContextPackLineageReceipt {
12745 source_node_id: self.node_id.clone(),
12746 source_session: address.clone(),
12747 source_provider,
12748 },
12749 &loaded,
12750 Some(repository),
12751 )
12752 .map_err(|_| {
12753 failure(
12754 NodeFailureCode::ContextPackMaterializationFailed,
12755 "bounded context pack export failed",
12756 )
12757 })
12758 }
12759
12760 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12766 let mut store = self
12767 .context_pack_store
12768 .lock()
12769 .unwrap_or_else(|poisoned| poisoned.into_inner());
12770 let Some(store) = store.as_mut() else {
12771 return Ok(());
12772 };
12773 store.commit(pack).map_err(|_| {
12774 failure(
12775 NodeFailureCode::ContextPackMaterializationFailed,
12776 "durable context pack commit failed",
12777 )
12778 })
12779 }
12780
12781 fn commit_context_pack(
12782 &self,
12783 pack: NodeContextPack,
12784 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12785 self.commit_context_pack_durably(&pack)?;
12786 self.context_catalog
12787 .write()
12788 .unwrap_or_else(|poisoned| poisoned.into_inner())
12789 .insert(pack)
12790 .map_err(|_| {
12791 failure(
12792 NodeFailureCode::ContextPackMaterializationFailed,
12793 "context pack catalog is full",
12794 )
12795 })
12796 }
12797
12798 fn commit_context_pack_for_session_record(
12799 &self,
12800 expected: &ManagedSessionRecord,
12801 identity: Option<&ProviderSessionIdentity>,
12802 session: &SessionAddress,
12803 candidate_id: &str,
12804 loaded: &HistorySessionRecord,
12805 pack: NodeContextPack,
12806 allow_clean_detachment: bool,
12807 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12808 let current_root = self.workspace_root(&expected.workspace_id)?;
12809 let _transaction = self
12810 .state_transaction
12811 .lock()
12812 .unwrap_or_else(|poisoned| poisoned.into_inner());
12813 let records = self
12814 .session_records
12815 .lock()
12816 .unwrap_or_else(|poisoned| poisoned.into_inner());
12817 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12818 failure(
12819 NodeFailureCode::SessionRecordConflict,
12820 "managed session record disappeared during context export",
12821 )
12822 })?;
12823 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12824 return Err(failure(
12825 NodeFailureCode::SessionRecordConflict,
12826 "managed session record changed during context export",
12827 ));
12828 }
12829 let bindings = self
12830 .session_bindings
12831 .lock()
12832 .unwrap_or_else(|poisoned| poisoned.into_inner());
12833 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12834 failure(
12835 NodeFailureCode::SessionRecordConflict,
12836 "managed session runtime binding disappeared during context export",
12837 )
12838 })?;
12839 let runtime = self
12840 .handle
12841 .snapshot()
12842 .sessions
12843 .iter()
12844 .find(|snapshot| {
12845 snapshot.instance_id == session.session.instance_id
12846 && snapshot.generation == session.session.generation
12847 })
12848 .cloned()
12849 .ok_or_else(|| {
12850 failure(
12851 NodeFailureCode::SessionRecordConflict,
12852 "managed session runtime disappeared during context export",
12853 )
12854 })?;
12855 let receipt = pack.receipt();
12856 if !session_record_context_export_binding_is_exact(
12857 expected,
12858 current,
12859 identity,
12860 session,
12861 binding,
12862 &runtime.agent_id,
12863 ¤t_root,
12864 allow_clean_detachment,
12865 ) || !session_record_context_export_source_is_usable(
12866 current,
12867 &runtime.status,
12868 allow_clean_detachment,
12869 ) || runtime.history.pending.is_some()
12870 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12871 || runtime.history.loaded.as_ref() != Some(loaded)
12872 || receipt.lineage.source_node_id != self.node_id
12873 || receipt.lineage.source_session != *session
12874 || receipt.lineage.source_provider != current.provider
12875 || receipt.source_message_count != loaded.message_count
12876 {
12877 return Err(failure(
12878 NodeFailureCode::SessionRecordConflict,
12879 "managed session source changed before context catalog commit",
12880 ));
12881 }
12882 self.commit_context_pack_durably(&pack)?;
12883 self.context_catalog
12884 .write()
12885 .unwrap_or_else(|poisoned| poisoned.into_inner())
12886 .insert(pack)
12887 .map_err(|_| {
12888 failure(
12889 NodeFailureCode::ContextPackMaterializationFailed,
12890 "context pack catalog is full",
12891 )
12892 })
12893 }
12894
12895 async fn context_pack_repository(
12896 &self,
12897 workspace_id: &WorkspaceId,
12898 ) -> Result<ContextPackRepository, NodeFailure> {
12899 let first = self
12900 .context_pack_repository_observation(workspace_id)
12901 .await?;
12902 let second = self
12903 .context_pack_repository_observation(workspace_id)
12904 .await?;
12905 stable_context_pack_repository(first, second)
12906 }
12907
12908 async fn context_pack_repository_observation(
12909 &self,
12910 workspace_id: &WorkspaceId,
12911 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12912 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12913 let head = if git.is_repository {
12914 let root = self.workspace_root(workspace_id)?;
12915 match resolve_base_commit_with_timeout(
12916 &root,
12917 "HEAD",
12918 GIT_COMMAND_TIMEOUT_MS,
12919 )
12920 .await
12921 {
12922 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12923 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12924 ContextPackRepositoryHead::Unborn
12925 }
12926 Err(_) => {
12927 return Err(failure(
12928 NodeFailureCode::ContextPackMaterializationFailed,
12929 "context pack HEAD observation failed",
12930 ));
12931 }
12932 }
12933 } else {
12934 ContextPackRepositoryHead::NotRepository
12935 };
12936 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12937 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12938 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12939 failure(
12940 NodeFailureCode::InvalidRepositoryPath,
12941 "context pack selected file path is invalid",
12942 )
12943 })?;
12944 match self.read_workspace_file(workspace_id.clone(), path).await {
12945 Ok(WorkspaceFileRead {
12946 content: WorkspaceFileContent::Utf8 { text, byte_len },
12947 ..
12948 }) => files.push(ContextPackRepositoryFileSource::utf8(
12949 selected_path,
12950 text,
12951 byte_len,
12952 )),
12953 Ok(WorkspaceFileRead {
12954 content: WorkspaceFileContent::NonUtf8 { byte_len },
12955 ..
12956 }) => files.push(ContextPackRepositoryFileSource::skipped(
12957 selected_path,
12958 ContextPackSelectedFileSkipReason::NonUtf8,
12959 Some(byte_len),
12960 )),
12961 Ok(WorkspaceFileRead {
12962 content: WorkspaceFileContent::TooLarge { .. },
12963 ..
12964 }) => files.push(ContextPackRepositoryFileSource::skipped(
12965 selected_path,
12966 ContextPackSelectedFileSkipReason::TooLarge,
12967 None,
12968 )),
12969 Err(error) => {
12970 let reason = if matches!(
12971 error.code,
12972 NodeFailureCode::InvalidRepositoryPath
12973 | NodeFailureCode::RepositoryFileNotRegular
12974 | NodeFailureCode::RepositoryPathUnsafe
12975 ) {
12976 ContextPackSelectedFileSkipReason::Unsafe
12977 } else {
12978 ContextPackSelectedFileSkipReason::Unavailable
12979 };
12980 files.push(ContextPackRepositoryFileSource::skipped(
12981 selected_path,
12982 reason,
12983 None,
12984 ));
12985 }
12986 }
12987 }
12988 Ok(ContextPackRepositoryObservation { head, git, files })
12989 }
12990
12991 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12992 let referenced_by_binding = self
12993 .session_bindings
12994 .lock()
12995 .unwrap_or_else(|poisoned| poisoned.into_inner())
12996 .values()
12997 .any(|binding| {
12998 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12999 });
13000 let referenced_by_record = self
13001 .session_records
13002 .lock()
13003 .unwrap_or_else(|poisoned| poisoned.into_inner())
13004 .records
13005 .values()
13006 .any(|record| record.context_id.as_ref() == Some(context_id));
13007 let referenced_by_materialization = self
13008 .materializations
13009 .lock()
13010 .unwrap_or_else(|poisoned| poisoned.into_inner())
13011 .values()
13012 .any(|ownership| {
13013 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
13014 });
13015 if referenced_by_binding
13016 || referenced_by_record
13017 || referenced_by_materialization
13018 {
13019 return Err(failure(
13020 NodeFailureCode::ContextPackBusy,
13021 "context pack is still referenced",
13022 ));
13023 }
13024 let mut catalog = self.context_catalog
13025 .write()
13026 .unwrap_or_else(|poisoned| poisoned.into_inner());
13027 if catalog.get(context_id).is_none() {
13028 return Err(failure(
13029 NodeFailureCode::UnknownContextPack,
13030 "context pack is unavailable",
13031 ));
13032 }
13033 self.spawn_idempotency
13034 .lock()
13035 .unwrap_or_else(|poisoned| poisoned.into_inner())
13036 .entries
13037 .retain(|_, entry| !entry.value.references_context(context_id));
13038 let removed = catalog.remove(context_id);
13039 debug_assert!(removed.is_some(), "validated context pack remains present");
13040 Ok(())
13041 }
13042
13043 async fn dispatch_input_bounded(
13044 &self,
13045 address: &SessionAddress,
13046 action: InputAction,
13047 ) -> Result<(), NodeFailure> {
13048 let started_after = self.current_sequence();
13049 self.pty_input_marks
13050 .lock()
13051 .unwrap_or_else(|poisoned| poisoned.into_inner())
13052 .insert(address.session.instance_id, (Instant::now(), false));
13053 self.pty_input_wake.notify_one();
13054 let command_id = self
13055 .dispatch_bounded(
13056 ControlCommand::SendInput {
13057 instance_id: address.session.instance_id,
13058 action,
13059 },
13060 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13061 )
13062 .await?;
13063 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
13067 let mut scan_after = started_after;
13068 let mut accepted_after = None;
13069 loop {
13070 let events = self
13071 .history
13072 .lock()
13073 .unwrap_or_else(|poisoned| poisoned.into_inner())
13074 .events
13075 .iter()
13076 .filter(|envelope| envelope.sequence > scan_after)
13077 .cloned()
13078 .collect::<Vec<_>>();
13079 for envelope in events {
13080 scan_after = scan_after.max(envelope.sequence);
13081 let NodeEvent::Control {
13082 address: event_address,
13083 event,
13084 } = envelope.event
13085 else {
13086 continue;
13087 };
13088 if event_address != *address {
13089 continue;
13090 }
13091 if event.command_id == Some(command_id) {
13092 match &event.event {
13093 ControlEventKind::CommandRejected { message } => {
13094 return Err(failure(NodeFailureCode::InvalidRequest, message));
13095 }
13096 ControlEventKind::InputRequested { .. } => {
13097 accepted_after = Some(envelope.sequence);
13098 continue;
13099 }
13100 _ => {}
13101 }
13102 }
13103 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
13104 match &event.event {
13105 ControlEventKind::InputCompleted { .. } => return Ok(()),
13106 ControlEventKind::InputFailed { message, .. } => {
13107 return Err(failure(
13108 NodeFailureCode::BackendOperationFailed,
13109 message,
13110 ));
13111 }
13112 _ => {}
13113 }
13114 }
13115 }
13116 if Instant::now() >= deadline {
13117 return Err(failure(
13118 NodeFailureCode::BackendBusy,
13119 if accepted_after.is_some() {
13120 "input was accepted but did not settle before the bounded deadline"
13121 } else {
13122 "input was not accepted before the bounded deadline"
13123 },
13124 ));
13125 }
13126 sleep(Duration::from_millis(2)).await;
13127 }
13128 }
13129
13130 async fn dispatch_resize_bounded(
13131 &self,
13132 address: &SessionAddress,
13133 size: gate4agent_types::TerminalSize,
13134 ) -> Result<(), NodeFailure> {
13135 let started_after = self.current_sequence();
13136 let command_id = self
13137 .dispatch_bounded(
13138 ControlCommand::Resize {
13139 instance_id: address.session.instance_id,
13140 size,
13141 },
13142 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13143 )
13144 .await?;
13145 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
13146 let mut scan_after = started_after;
13147 let mut accepted_after = None;
13148 loop {
13149 let events = self
13150 .history
13151 .lock()
13152 .unwrap_or_else(|poisoned| poisoned.into_inner())
13153 .events
13154 .iter()
13155 .filter(|envelope| envelope.sequence > scan_after)
13156 .cloned()
13157 .collect::<Vec<_>>();
13158 for envelope in events {
13159 scan_after = scan_after.max(envelope.sequence);
13160 let NodeEvent::Control {
13161 address: event_address,
13162 event,
13163 } = envelope.event
13164 else {
13165 continue;
13166 };
13167 if event_address != *address {
13168 continue;
13169 }
13170 if event.command_id == Some(command_id) {
13171 match &event.event {
13172 ControlEventKind::CommandRejected { message } => {
13173 return Err(failure(NodeFailureCode::InvalidRequest, message));
13174 }
13175 ControlEventKind::ResizeRequested {
13176 size: requested_size,
13177 ..
13178 } if *requested_size == size => {
13179 accepted_after = Some(envelope.sequence);
13180 continue;
13181 }
13182 _ => {}
13183 }
13184 }
13185 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
13186 match &event.event {
13187 ControlEventKind::Resized { size: settled_size }
13188 if *settled_size == size =>
13189 {
13190 return Ok(());
13191 }
13192 ControlEventKind::ResizeFailed { message } => {
13193 return Err(failure(
13194 NodeFailureCode::BackendOperationFailed,
13195 message,
13196 ));
13197 }
13198 _ => {}
13199 }
13200 }
13201 }
13202 if Instant::now() >= deadline {
13203 return Err(failure(
13204 NodeFailureCode::BackendBusy,
13205 if accepted_after.is_some() {
13206 "resize was accepted but did not settle before the bounded deadline"
13207 } else {
13208 "resize was not accepted before the bounded deadline"
13209 },
13210 ));
13211 }
13212 sleep(Duration::from_millis(2)).await;
13213 }
13214 }
13215
13216 async fn spawn_session(
13217 &self,
13218 workspace_id: WorkspaceId,
13219 provider: AgentId,
13220 mode: SessionMode,
13221 terminal_size: gate4agent_types::TerminalSize,
13222 initial_prompt: Option<String>,
13223 ) -> Result<SessionAddress, NodeFailure> {
13224 if self.managed_worktrees.lock()
13225 .unwrap_or_else(|poisoned| poisoned.into_inner())
13226 .lease_for_workspace(&workspace_id)
13227 .is_some()
13228 {
13229 return Err(failure(
13230 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13231 "managed worktree targets may only be spawned through their lease request",
13232 ));
13233 }
13234 self
13235 .spawn_session_with_deadline(
13236 workspace_id,
13237 provider.clone(),
13238 mode,
13239 terminal_size,
13240 initial_prompt,
13241 None,
13242 None,
13243 None,
13244 None,
13245 None,
13246 SpawnRecordPolicy::ProviderIdentityOnly,
13247 None,
13248 &[],
13249 None,
13250 crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13257 None,
13258 None,
13259 )
13260 .await
13261 .map(|(session, _runtime_policy)| session)
13262 }
13263
13264 async fn spawn_session_with_deadline(
13265 &self,
13266 workspace_id: WorkspaceId,
13267 provider: AgentId,
13268 mode: SessionMode,
13269 terminal_size: gate4agent_types::TerminalSize,
13270 initial_prompt: Option<String>,
13271 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13272 bundle: Option<ResolvedBundleReceipt>,
13273 context: Option<ResolvedContextPackReceipt>,
13274 managed_authority: Option<ManagedWorktreeLeaseId>,
13275 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13276 record_policy: SpawnRecordPolicy,
13277 deadline: Option<Instant>,
13278 required_capabilities: &[ProviderRuntimeCapability],
13279 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13280 approval_level: ApprovalLevel,
13281 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13282 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13288 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13289 let reserved_lease = self.managed_worktrees.lock()
13290 .unwrap_or_else(|poisoned| poisoned.into_inner())
13291 .lease_for_workspace(&workspace_id);
13292 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13293 (Some(expected), Some(provided)) if expected == provided => {}
13294 (None, None) => {}
13295 _ => {
13296 return Err(failure(
13297 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13298 "spawn lacks exact managed worktree lease authority",
13299 ));
13300 }
13301 }
13302 self.ensure_workspace_environment_profile_compatible(
13306 &workspace_id,
13307 environment_profile.as_ref(),
13308 )?;
13309 let runtime_requirement = match (mode, initial_prompt.is_some()) {
13310 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13311 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13312 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13313 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13314 };
13315 let transport = match mode {
13321 SessionMode::Pty => TransportKind::Pty,
13322 SessionMode::Inline => TransportKind::Pipe,
13323 SessionMode::Acp => TransportKind::Acp,
13324 };
13325 let pipe_transport = self.provider_supports_pipe_transport(&provider);
13326 let acp_transport = self.provider_supports_acp_transport(&provider);
13327 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13328 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13329 NodeFailureCode::UnsupportedSpawnCapability,
13330 "pre-admitted provider runtime no longer satisfies spawn requirements",
13331 ))?;
13332 runtime_policy
13333 } else if let Some(deadline) = deadline {
13334 let remaining = spawn_deadline_remaining(deadline)?;
13335 timeout(
13336 remaining,
13337 self.admit_provider_runtime(&provider, runtime_requirement),
13338 )
13339 .await
13340 .map_err(|_| {
13341 failure(
13342 NodeFailureCode::SpawnDeadlineExceeded,
13343 "provider admission exceeded the spawn deadline",
13344 )
13345 })??
13346 } else {
13347 self.admit_provider_runtime(&provider, runtime_requirement).await?
13348 };
13349 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13350 let runtime_policy =
13362 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13363 if required_capabilities
13364 .iter()
13365 .any(|capability| !runtime_policy.admits(*capability))
13366 {
13367 return Err(failure(
13368 NodeFailureCode::UnsupportedSpawnCapability,
13369 "provider runtime does not admit a required spawn capability",
13370 ));
13371 }
13372 if let Some(deadline) = deadline {
13373 spawn_deadline_remaining(deadline)?;
13374 }
13375 self.ensure_binding_capacity()?;
13376 let mut working_directory = self.workspace_root(&workspace_id)?;
13377 let instance_id = RESTORE_INSTANCE_ID.try_with(|id| *id)
13378 .unwrap_or_else(|_| AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel)));
13379 let session = SessionKey {
13380 instance_id,
13381 generation: SessionGeneration(1),
13382 };
13383 let address = SessionAddress {
13384 workspace_id: workspace_id.clone(),
13385 session,
13386 };
13387 tracing::info!(
13395 node_id = %self.node_id,
13396 workspace_id = %workspace_id,
13397 provider = %provider,
13398 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13399 session = ?address.session,
13400 "session spawn request accepted",
13401 );
13402 let mut prepared_spawn = SpawnPreparer::prepare(
13403 self,
13404 SpawnPrepareRequest {
13405 instance_id,
13406 address: &address,
13407 provider: &provider,
13408 mode,
13409 environment_profile: environment_profile.as_ref(),
13410 bundle: bundle.as_ref(),
13411 context: context.as_ref(),
13412 managed_authority: managed_authority.as_ref(),
13413 harness_mcp,
13414 approval_level,
13415 network_allowlist: network_allowlist.as_ref(),
13416 browser_profile_id,
13417 },
13418 )?;
13419 let materialization_id = prepared_spawn.materialization_id.take();
13420 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13421 let record_id = self.bind_spawn_session_with_materialization(
13422 &address,
13423 provider.clone(),
13424 mode,
13425 runtime_policy,
13426 record_policy,
13427 environment_profile,
13428 bundle,
13429 context,
13430 materialization_id,
13431 )?;
13432 prepared_spawn.retain_session_guards();
13433 let session_provider = provider.clone();
13437 let agent_id = provider;
13438 let register_command_id = match self.dispatch_bounded(
13439 ControlCommand::Register {
13440 instance_id,
13441 agent_id,
13442 transport,
13443 },
13444 dispatch_timeout,
13445 )
13446 .await {
13447 Ok(command_id) => command_id,
13448 Err(error) => {
13449 if let Some(binding) = self.remove_binding(&address) {
13450 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13451 }
13452 if let Some(record_id) = record_id.as_ref() {
13453 self.discard_record(record_id)?;
13454 }
13455 return Err(spawn_dispatch_error(error, deadline));
13456 }
13457 };
13458 let start_timeout = match spawn_dispatch_timeout(deadline) {
13459 Ok(timeout) => timeout,
13460 Err(error) => {
13461 let recovery = self
13462 .rollback_spawn(
13463 &address,
13464 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13465 )
13466 .await;
13467 return match recovery {
13468 Ok(()) => {
13469 if let Some(record_id) = record_id.as_ref() {
13470 self.discard_record(record_id)?;
13471 }
13472 Err(error)
13473 }
13474 Err(recovery_error) => {
13475 if let Some(record_id) = record_id.as_ref() {
13476 self.mark_record_error(
13477 record_id,
13478 "spawn deadline elapsed and registration recovery failed",
13479 )?;
13480 }
13481 Err(recovery_error)
13482 }
13483 };
13484 }
13485 };
13486 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13487 if let Err(recovery_error) = self
13488 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13489 .await
13490 {
13491 return Err(recovery_error);
13492 }
13493 if let Some(record_id) = record_id.as_ref() {
13494 self.discard_record(record_id)?;
13495 }
13496 return Err(start_error);
13497 }
13498 if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13500 working_directory = dir.to_string_lossy().into_owned();
13501 }
13502 let start_result = self
13503 .dispatch_bounded(
13504 ControlCommand::Start {
13505 instance_id,
13506 runtime_policy,
13507 request: StartRequest {
13508 working_directory,
13509 terminal_size,
13510 initial_prompt,
13511 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13512 approval_level,
13513 },
13514 },
13515 start_timeout,
13516 )
13517 .await;
13518 let start_command_id = match start_result {
13519 Ok(command_id) => command_id,
13520 Err(start_error) => {
13521 let start_error = spawn_dispatch_error(start_error, deadline);
13522 let recovery = self
13523 .rollback_spawn(
13524 &address,
13525 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13526 )
13527 .await;
13528 return match recovery {
13529 Ok(()) => {
13530 if let Some(record_id) = record_id.as_ref() {
13531 self.discard_record(record_id)?;
13532 }
13533 Err(start_error)
13534 }
13535 Err(recovery_error) => {
13536 let diagnostic = format!(
13537 "start failed and registration recovery failed: {}",
13538 recovery_error.message,
13539 );
13540 if let Some(record_id) = record_id.as_ref() {
13541 self.mark_record_error(record_id, &diagnostic)?;
13542 }
13543 Err(failure(recovery_error.code, &diagnostic))
13544 }
13545 };
13546 }
13547 };
13548
13549 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13550 loop {
13551 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13560 .or_else(|| self.take_rejected_command_reason(start_command_id))
13561 {
13562 self.rollback_spawn(
13563 &address,
13564 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13565 ).await.map_err(|error| {
13566 failure(
13567 error.code,
13568 &format!(
13569 "spawn was rejected ({}) and registration recovery failed: {}",
13570 reason.message, error.message
13571 ),
13572 )
13573 })?;
13574 if let Some(record_id) = record_id.as_ref() {
13575 self.discard_record(record_id)?;
13576 }
13577 let code = if reason.unsupported_transport {
13578 NodeFailureCode::UnsupportedTransport
13579 } else {
13580 NodeFailureCode::BackendOperationFailed
13581 };
13582 return Err(failure(code, &reason.message));
13583 }
13584 if let Some(current) = self
13585 .handle
13586 .snapshot()
13587 .sessions
13588 .iter()
13589 .find(|current| current.instance_id == instance_id)
13590 {
13591 if current.generation != session.generation {
13592 let divergence = failure(
13593 NodeFailureCode::StaleGeneration,
13594 "spawn generation diverged",
13595 );
13596 return match self
13597 .rollback_spawn(
13598 &address,
13599 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13600 )
13601 .await
13602 {
13603 Ok(()) => {
13604 if let Some(record_id) = record_id.as_ref() {
13605 self.discard_record(record_id)?;
13606 }
13607 Err(divergence)
13608 }
13609 Err(recovery_error) => {
13610 if let Some(record_id) = record_id.as_ref() {
13611 self.mark_record_error(
13612 record_id,
13613 "spawn generation diverged and recovery failed",
13614 )?;
13615 }
13616 Err(recovery_error)
13617 }
13618 };
13619 }
13620 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13621 prepared_spawn.retain_launch_guards();
13622 return Ok((address, runtime_policy));
13623 }
13624 }
13625 if Instant::now() >= commit_deadline {
13626 self.rollback_spawn(
13627 &address,
13628 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13629 ).await.map_err(|error| {
13630 failure(
13631 error.code,
13632 &format!(
13633 "start did not commit and registration recovery failed: {}",
13634 error.message
13635 ),
13636 )
13637 })?;
13638 if let Some(record_id) = record_id.as_ref() {
13639 self.discard_record(record_id)?;
13640 }
13641 return Err(failure(
13642 if deadline.is_some() {
13643 NodeFailureCode::SpawnDeadlineExceeded
13644 } else {
13645 NodeFailureCode::BackendBusy
13646 },
13647 "start did not commit before the bounded deadline; registration was removed",
13648 ));
13649 }
13650 sleep(Duration::from_millis(2)).await;
13651 }
13652 }
13653
13654 fn effective_spawn_runtime_policy(
13655 &self,
13656 runtime_policy: ProviderRuntimePolicy,
13657 ) -> ProviderRuntimePolicy {
13658 #[cfg(feature = "fixture")]
13659 let runtime_policy = if self.fixture_semantic_hook_policy {
13660 ProviderRuntimePolicy::new(true, true, true, true, false, true)
13661 .expect("monitoring Hook fixture policy is internally valid")
13662 } else {
13663 runtime_policy
13664 };
13665 runtime_policy
13666 }
13667
13668 fn verify_harness_mcp_before_start(
13669 &self,
13670 prepared: Option<&PreparedHarnessMcpSpawn>,
13671 ) -> Result<(), NodeFailure> {
13672 prepared
13673 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13674 .transpose()
13675 .map(|_| ())
13676 .map_err(harness_mcp_failure)
13677 }
13678
13679 async fn rollback_spawn(
13680 &self,
13681 address: &SessionAddress,
13682 commit_timeout: Duration,
13683 ) -> Result<(), NodeFailure> {
13684 self.dispatch_bounded(
13685 ControlCommand::Remove {
13686 instance_id: address.session.instance_id,
13687 },
13688 commit_timeout,
13689 )
13690 .await?;
13691 self.wait_until_removed(address, commit_timeout).await?;
13692 if let Some(binding) = self.remove_binding(address) {
13693 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13694 }
13695 Ok(())
13696 }
13697
13698 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13699 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13700 self.dispatch_bounded(
13701 ControlCommand::Remove {
13702 instance_id: address.session.instance_id,
13703 },
13704 commit_timeout,
13705 )
13706 .await?;
13707 self.wait_until_removed(address, commit_timeout).await?;
13708 let removed = self.remove_binding(address);
13709 if let Some(binding) = removed.as_ref() {
13710 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13711 }
13712 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13713 let snapshot = self.managed_worktrees.lock()
13714 .unwrap_or_else(|poisoned| poisoned.into_inner())
13715 .release_session(
13716 &lease_id,
13717 self.incarnation_id,
13718 address.session.instance_id,
13719 address.session.generation,
13720 unix_time_ms(),
13721 );
13722 if self.persist_state().is_err() {
13723 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13724 }
13725 if let Some(snapshot) = snapshot {
13726 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13727 }
13728 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13729 }
13730 Ok(())
13731 }
13732
13733 async fn wait_until_removed(
13779 &self,
13780 address: &SessionAddress,
13781 commit_timeout: Duration,
13782 ) -> Result<(), NodeFailure> {
13783 let deadline = Instant::now() + commit_timeout;
13784 let mut next_dispatch = Instant::now();
13785 loop {
13786 let snapshot = self.handle.snapshot();
13787 let status = snapshot
13788 .sessions
13789 .iter()
13790 .find(|session| session.instance_id == address.session.instance_id)
13791 .map(|session| session.status.clone());
13792 let Some(status) = status else {
13793 return Ok(());
13794 };
13795 let now = Instant::now();
13796 if now >= deadline {
13797 return Err(failure(
13798 NodeFailureCode::BackendBusy,
13799 "remove did not commit before the bounded deadline; session binding was retained",
13800 ));
13801 }
13802 if status.allows_remove() && now >= next_dispatch {
13803 let _ = self.dispatch(ControlCommand::Remove {
13804 instance_id: address.session.instance_id,
13805 });
13806 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13807 }
13808 sleep(Duration::from_millis(2)).await;
13809 }
13810 }
13811
13812 fn resync(&self, after_sequence: u64) -> NodeResponse {
13813 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13814 let event_sequence = history.last_sequence;
13815 let oldest_available_sequence = history.replay_floor_sequence;
13816 let events = history
13817 .events
13818 .iter()
13819 .filter(|event| event.sequence > after_sequence)
13820 .cloned()
13821 .collect();
13822 drop(history);
13823 NodeResponse::Resync {
13824 event_sequence,
13825 oldest_available_sequence,
13826 snapshot: self.snapshot(),
13827 events,
13828 }
13829 }
13830}
13831
13832fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13837 match kind {
13838 AgentStreamChunkKindV1::Text { .. } => "text",
13839 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13840 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13841 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13842 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13843 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13844 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13845 }
13846}
13847
13848async fn accept_connections(
13862 endpoint: &str,
13863 call_home: Option<std::net::SocketAddr>,
13864 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13865 shared: Arc<NodeShared>,
13866) -> Result<(), NodeServerError> {
13867 let mut connections = JoinSet::new();
13868 let result = match call_home {
13869 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13870 Some(relay) => {
13871 let listener =
13872 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13873 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13874 tokio::pin!(listener);
13875 tokio::pin!(dialler);
13876 tokio::select! {
13877 result = &mut listener => result,
13878 result = &mut dialler => result,
13879 }
13880 }
13881 };
13882 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13883 while !connections.is_empty() {
13884 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13885 if remaining.is_zero()
13886 || timeout(remaining, connections.join_next()).await.is_err()
13887 {
13888 break;
13889 }
13890 }
13891 connections.abort_all();
13892 while connections.join_next().await.is_some() {}
13893 result
13894}
13895
13896const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13900
13901async fn call_home_loop(
13915 relay: std::net::SocketAddr,
13916 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13917 shared: Arc<NodeShared>,
13918) -> Result<(), NodeServerError> {
13919 let mut failures = 0_usize;
13920 loop {
13921 if shared.shutdown.load(Ordering::Acquire) {
13922 return Ok(());
13923 }
13924 let permit = tokio::select! {
13925 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13926 permit.map_err(|_| NodeServerError::PreauthClosed)?
13927 }
13928 _ = shared.shutdown_notify.notified() => return Ok(()),
13929 };
13930 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13931 Ok(()) => {
13932 failures = 0;
13936 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13937 }
13938 Err(error) => {
13939 failures = failures.saturating_add(1);
13940 tracing::warn!(
13941 node_id = %shared.node_id,
13942 %relay,
13943 cause = %error,
13944 attempt = failures,
13945 "call-home connection failed",
13946 );
13947 let backoff = call_home_backoff(failures);
13948 tokio::select! {
13949 _ = sleep(backoff) => {}
13950 _ = shared.shutdown_notify.notified() => return Ok(()),
13951 }
13952 }
13953 }
13954 }
13955}
13956
13957const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13959const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13961
13962fn call_home_keepalive() -> socket2::TcpKeepalive {
13963 let keepalive = socket2::TcpKeepalive::new()
13964 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13965 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13966 #[cfg(not(windows))]
13967 let keepalive = keepalive.with_retries(3);
13968 keepalive
13969}
13970
13971fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13972 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13973}
13974
13975pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13977 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13978}
13979
13980fn call_home_backoff(attempt: usize) -> Duration {
13982 Duration::from_millis(
13983 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13984 )
13985}
13986
13987
13988async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13991 #[cfg(feature = "wireguard")]
13992 {
13993 let std_stream = tokio::task::spawn_blocking(move || {
13994 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13995 })
13996 .await
13997 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13998 .map_err(NodeServerError::CallHomeTransport)?;
13999 std_stream
14000 .set_nonblocking(true)
14001 .map_err(NodeServerError::CallHomeTransport)?;
14002 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
14003 }
14004 #[cfg(not(feature = "wireguard"))]
14005 {
14006 tokio::net::TcpStream::connect(relay)
14007 .await
14008 .map_err(NodeServerError::CallHomeTransport)
14009 }
14010}
14011
14012async fn dial_relay_and_serve(
14014 relay: std::net::SocketAddr,
14015 identity: Option<&hatchery_enroll::NodeIdentity>,
14016 shared: Arc<NodeShared>,
14017 preauth_permit: OwnedSemaphorePermit,
14018) -> Result<(), NodeServerError> {
14019 let mut stream = dial_call_home(relay).await?;
14020 let _ = stream.set_nodelay(true);
14024 if let Err(error) = enable_call_home_keepalive(&stream) {
14030 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
14031 }
14032 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
14033 .await
14034 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
14035 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
14036 let mut session_token = None;
14037 if let Some(identity) = identity {
14038 let mut magic = [0_u8; 4];
14042 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
14043 .await
14044 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
14045 .map_err(NodeServerError::CallHomeTransport)?;
14046 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
14047 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
14048 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
14049 .await
14050 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
14051 .map_err(NodeServerError::CallHomeTransport)?;
14052 let (answer, token) = identity
14053 .answer_challenge(shared.node_id.as_str(), &frame)
14054 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
14055 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
14056 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
14057 session_token = Some(token);
14058 }
14059 }
14060 serve_connection(stream, shared, session_token, preauth_permit).await
14061}
14062
14063async fn accept_connections_inner(
14064 endpoint: &str,
14065 shared: Arc<NodeShared>,
14066 connections: &mut JoinSet<Result<(), NodeServerError>>,
14067) -> Result<(), NodeServerError> {
14068 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
14069 loop {
14070 while connections.try_join_next().is_some() {}
14071 if shared.shutdown.load(Ordering::Acquire) {
14072 return Ok(());
14073 }
14074 let preauth_permit = tokio::select! {
14075 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
14076 permit.map_err(|_| NodeServerError::PreauthClosed)?
14077 }
14078 _ = shared.shutdown_notify.notified() => return Ok(()),
14079 };
14080 let server = tokio::select! {
14081 result = listener.accept() => result?,
14082 _ = shared.shutdown_notify.notified() => return Ok(()),
14083 };
14084 let connection_shared = Arc::clone(&shared);
14085 connections.spawn(async move {
14086 serve_connection(server, connection_shared, None, preauth_permit).await
14087 });
14088 if shared.shutdown.load(Ordering::Acquire) {
14089 return Ok(());
14090 }
14091 }
14092}
14093
14094async fn serve_connection<S>(
14102 mut pipe: S,
14103 shared: Arc<NodeShared>,
14104 session_token: Option<String>,
14105 preauth_permit: OwnedSemaphorePermit,
14106) -> Result<(), NodeServerError>
14107where
14108 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
14109{
14110 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
14112 let hello_frame = timeout(
14113 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
14114 read_json_frame_limited_body_timeout(
14115 &mut pipe,
14116 MAX_NODE_HELLO_FRAME_BYTES,
14117 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14118 ),
14119 )
14120 .await
14121 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
14122 let ClientFrame::Hello(hello) = hello_frame else {
14123 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
14124 };
14125 if hello.build_stamp != BUILD_STAMP {
14126 return Err(NodeServerError::Handshake(format!(
14127 "build stamp mismatch: local={BUILD_STAMP} remote={}",
14128 hello.build_stamp,
14129 )));
14130 }
14131 let compatibility = match hello.compatibility.as_ref() {
14132 Some(offer) => {
14133 let mut selected = node_compatibility_support(&shared)?
14134 .negotiate(offer)
14135 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
14136 project_negotiated_provider_ids(&mut selected);
14137 Some(selected)
14138 }
14139 None => None,
14140 };
14141 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
14142 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
14143 (Some(offer), Some(selected)) => negotiated_auth_proof(
14144 auth_token,
14145 AuthDirection::Server,
14146 hello.role,
14147 &hello.client_nonce,
14148 &server_nonce,
14149 offer,
14150 selected,
14151 ),
14152 (None, None) => auth_proof(
14153 auth_token,
14154 AuthDirection::Server,
14155 hello.role,
14156 &hello.client_nonce,
14157 &server_nonce,
14158 ),
14159 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
14160 }
14161 .map_err(NodeServerError::Authentication)?;
14162 write_json_frame_limited(
14163 &mut pipe,
14164 &ServerFrame::Challenge(ServerChallenge {
14165 build_stamp: BUILD_STAMP.to_owned(),
14166 server_nonce,
14167 server_proof,
14168 compatibility: compatibility.clone(),
14169 }),
14170 MAX_NODE_HELLO_FRAME_BYTES,
14171 )
14172 .await?;
14173 let authentication = timeout(
14174 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
14175 read_json_frame_limited_body_timeout(
14176 &mut pipe,
14177 MAX_NODE_HELLO_FRAME_BYTES,
14178 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14179 ),
14180 )
14181 .await
14182 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
14183 let ClientFrame::Authenticate(authentication) = authentication else {
14184 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
14185 };
14186 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
14187 (Some(offer), Some(selected)) => negotiated_auth_proof(
14188 auth_token,
14189 AuthDirection::Client,
14190 hello.role,
14191 &hello.client_nonce,
14192 &server_nonce,
14193 offer,
14194 selected,
14195 ),
14196 (None, None) => auth_proof(
14197 auth_token,
14198 AuthDirection::Client,
14199 hello.role,
14200 &hello.client_nonce,
14201 &server_nonce,
14202 ),
14203 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
14204 }
14205 .map_err(NodeServerError::Authentication)?;
14206 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
14207 return Err(NodeServerError::Handshake("access denied".to_owned()));
14208 }
14209 let selected_capabilities = compatibility
14210 .as_ref()
14211 .map(|selected| selected.capabilities.clone())
14212 .unwrap_or_default();
14213 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
14214 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
14215 });
14216 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
14217 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14218 });
14219 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
14220 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
14221 });
14222 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
14223 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
14224 });
14225 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
14226 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
14227 });
14228 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
14229 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
14230 }) && selected_capabilities.iter().any(|capability| {
14231 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
14232 });
14233 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
14234 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
14235 });
14236 let include_session_bundles = selected_capabilities.iter().any(|capability| {
14237 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
14238 });
14239 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
14240 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
14241 });
14242 let include_agent_progress = selected_capabilities.iter().any(|capability| {
14243 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
14244 });
14245 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
14246 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14247 });
14248 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14249 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14250 });
14251 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14252 .try_acquire_owned()
14253 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14254 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14255 let _release_guard = ControllerReleaseGuard {
14256 shared: Arc::clone(&shared),
14257 connection_id,
14258 };
14259 drop(preauth_permit);
14260 let _authenticated_permit = authenticated_permit;
14261 let mut event_rx = shared.event_tx.subscribe();
14262 let mut terminal_event_rx = terminal_event_subscription(
14263 &shared,
14264 include_terminal_frame_events,
14265 );
14266 let mut agent_stream_event_rx = agent_stream_event_subscription(
14267 &shared,
14268 include_agent_stream_events,
14269 );
14270 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14271 &shared,
14272 include_harness_mcp_proxy,
14273 );
14274 write_json_frame(
14275 &mut pipe,
14276 &ServerFrame::Hello(NodeHello {
14277 build_stamp: BUILD_STAMP.to_owned(),
14278 incarnation_id: shared.incarnation_id,
14279 connection_id,
14280 role: hello.role,
14281 event_sequence: shared.current_sequence(),
14282 controller: shared.controller_state(),
14283 snapshot: snapshot_for_wire(
14284 &shared,
14285 include_provider_runtime_status,
14286 include_open_provider_ids,
14287 include_spawn_profiles,
14288 include_managed_worktrees,
14289 include_child_environment_profiles,
14290 include_session_bundles,
14291 include_history_context_packs,
14292 include_agent_progress,
14293 include_session_task_correlation,
14294 ),
14295 compatibility,
14296 }),
14297 )
14298 .await?;
14299 let (mut reader, mut writer) = tokio::io::split(pipe);
14300 let (frame_tx, mut frame_rx) = mpsc::channel(16);
14301 let reader_task = tokio::spawn(async move {
14302 loop {
14303 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14304 &mut reader,
14305 MAX_NODE_CLIENT_FRAME_BYTES,
14306 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14307 )
14308 .await;
14309 let terminal = frame.is_err();
14310 if frame_tx.send(frame).await.is_err() || terminal {
14311 break;
14312 }
14313 }
14314 });
14315 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14316 let mut pending_events = Vec::new();
14317 let mut resync_required = false;
14318 let mut discard_events_through = 0_u64;
14319 loop {
14320 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14325 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14326 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14327 for _ in 0..durable_drain_budget {
14328 match event_rx.try_recv() {
14329 Ok(event) => queue_connection_event(
14330 &mut pending_events,
14331 event,
14332 discard_events_through,
14333 ),
14334 Err(broadcast::error::TryRecvError::Empty) => break,
14335 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14336 resync_required = true;
14337 break;
14338 }
14339 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14340 }
14341 }
14342 if let Some(receiver) = terminal_event_rx.as_mut() {
14343 match receiver.try_recv() {
14344 Ok(events) => queue_connection_event_batch(
14345 &mut pending_events,
14346 &events,
14347 discard_events_through,
14348 ),
14349 Err(broadcast::error::TryRecvError::Empty) => {}
14350 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14351 resync_required = true;
14352 }
14353 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14354 }
14355 }
14356 if let Some(receiver) = agent_stream_event_rx.as_mut() {
14357 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14367 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14368 for _ in 0..agent_stream_drain_budget {
14369 match receiver.try_recv() {
14370 Ok(events) => queue_connection_event_batch(
14371 &mut pending_events,
14372 &events,
14373 discard_events_through,
14374 ),
14375 Err(broadcast::error::TryRecvError::Empty) => break,
14376 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14377 tracing::warn!(
14378 connection_id,
14379 dropped,
14380 "agent stream event subscription lagged; forcing a resync",
14381 );
14382 resync_required = true;
14383 break;
14384 }
14385 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14386 }
14387 }
14388 }
14389 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14390 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14396 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14397 for _ in 0..harness_mcp_proxy_drain_budget {
14398 match receiver.try_recv() {
14399 Ok(events) => queue_connection_event_batch(
14400 &mut pending_events,
14401 &events,
14402 discard_events_through,
14403 ),
14404 Err(broadcast::error::TryRecvError::Empty) => break,
14405 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14406 tracing::warn!(
14407 connection_id,
14408 dropped,
14409 "harness MCP proxy event subscription lagged; forcing a resync",
14410 );
14411 resync_required = true;
14412 break;
14413 }
14414 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14415 }
14416 }
14417 }
14418 if resync_required {
14419 let event = resync_required_event(&shared);
14420 apply_connection_resync_watermark(
14421 &mut pending_events,
14422 &mut discard_events_through,
14423 event.sequence,
14424 );
14425 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14426 resync_required = false;
14427 continue;
14428 }
14429 if !pending_events.is_empty() {
14430 pending_events.sort_unstable_by_key(|event| event.sequence);
14431 let burst_len = connection_event_burst_len(pending_events.len());
14432 shared
14433 .connection_events_sent
14434 .fetch_add(burst_len as u64, Ordering::Relaxed);
14435 for event in pending_events.drain(..burst_len) {
14436 let event = if include_managed_worktrees {
14437 Some(event)
14438 } else {
14439 project_event_without_managed_worktrees(event)
14440 };
14441 let event = event.and_then(|event| if include_open_provider_ids {
14442 Some(event)
14443 } else {
14444 project_event_legacy_provider_ids(&shared, event)
14445 });
14446 let event = event.map(|event| if include_child_environment_profiles {
14447 event
14448 } else {
14449 project_event_without_child_environment_profile(event)
14450 });
14451 let event = event.map(|event| if include_session_bundles {
14452 event
14453 } else {
14454 project_event_without_session_bundle(event)
14455 });
14456 let event = event.map(|event| {
14457 project_event_history_for_wire(event, include_history_context_packs)
14458 });
14459 let event = event.map(|event| if include_history_context_packs {
14460 event
14461 } else {
14462 project_event_without_context_pack(event)
14463 });
14464 let event = event.map(|event| if include_session_task_correlation {
14465 event
14466 } else {
14467 project_event_without_session_task_binding(event)
14468 });
14469 let exact_harness_controller = hello.role == ClientRole::Operator
14470 && shared.controller_state().is_some_and(|controller| {
14471 controller.connection_id == connection_id
14472 });
14473 let event = event.and_then(|event| {
14474 if include_harness_mcp_proxy && exact_harness_controller {
14475 Some(event)
14476 } else {
14477 project_event_without_harness_mcp_proxy(event)
14478 }
14479 });
14480 if let Some(event) = event {
14481 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14482 }
14483 }
14484 }
14485 tokio::select! {
14486 biased;
14487 frame = frame_rx.recv() => {
14488 let frame = match frame {
14489 Some(frame) => frame,
14490 None => break,
14491 };
14492 let frame = match frame {
14493 Ok(frame) => frame,
14494 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14495 Err(error) => return Err(error.into()),
14496 };
14497 let ClientFrame::Request(request) = frame else {
14498 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14499 };
14500 #[cfg(feature = "fixture")]
14501 let probe_spawn_managed_worktree_v2 = matches!(
14502 &request.request,
14503 NodeRequest::SpawnManagedWorktreeV2 { .. }
14504 );
14505 let requires_open_provider_ids =
14506 request_requires_open_provider_ids(&shared, &request.request);
14507 let requires_child_environment_profile =
14508 request_requires_child_environment_profile(&shared, &request.request);
14509 let requires_session_bundle =
14510 request_requires_session_bundle(&shared, &request.request);
14511 let requires_history_context_pack =
14512 request_requires_history_context_pack(&shared, &request.request);
14513 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14514 ResponseEnvelope {
14515 request_id: request.request_id,
14516 result: Err(failure(
14517 NodeFailureCode::UnsupportedCapability,
14518 "open provider IDs were not negotiated",
14519 )),
14520 }
14521 } else if requires_child_environment_profile
14522 && !include_child_environment_profiles
14523 {
14524 ResponseEnvelope {
14525 request_id: request.request_id,
14526 result: Err(failure(
14527 NodeFailureCode::UnsupportedCapability,
14528 "child environment profiles were not negotiated",
14529 )),
14530 }
14531 } else if requires_session_bundle && !include_session_bundles {
14532 ResponseEnvelope {
14533 request_id: request.request_id,
14534 result: Err(failure(
14535 NodeFailureCode::UnsupportedCapability,
14536 "session bundles were not negotiated",
14537 )),
14538 }
14539 } else if requires_history_context_pack && !include_history_context_packs {
14540 ResponseEnvelope {
14541 request_id: request.request_id,
14542 result: Err(failure(
14543 NodeFailureCode::UnsupportedCapability,
14544 "history context packs were not negotiated",
14545 )),
14546 }
14547 } else if request_uses_unnegotiated_capability(
14548 &request.request,
14549 &selected_capabilities,
14550 )
14551 {
14552 ResponseEnvelope {
14553 request_id: request.request_id,
14554 result: Err(failure(
14555 NodeFailureCode::UnsupportedCapability,
14556 "request capability was not negotiated",
14557 )),
14558 }
14559 } else {
14560 process_request(&shared, connection_id, hello.role, request).await
14561 };
14562 #[cfg(feature = "fixture")]
14563 if probe_spawn_managed_worktree_v2 {
14564 shared
14565 .fixture_spawn_managed_worktree_v2_failure_probe
14566 .record(reply.result.as_ref().err().map(|failure| failure.code));
14567 }
14568 if !include_provider_runtime_status {
14569 clear_response_provider_runtime_status(&mut reply);
14570 }
14571 if !include_managed_worktrees {
14572 project_response_without_managed_worktrees(&mut reply);
14573 }
14574 if !include_open_provider_ids {
14575 project_response_legacy_provider_ids(&shared, &mut reply);
14576 }
14577 if !include_child_environment_profiles {
14578 project_response_without_child_environment_profile(&mut reply);
14579 }
14580 if !include_session_bundles {
14581 project_response_without_session_bundle(&mut reply);
14582 }
14583 project_response_history_for_wire(
14584 &mut reply,
14585 include_history_context_packs,
14586 );
14587 if !include_history_context_packs {
14588 project_response_without_context_pack(&mut reply);
14589 }
14590 if !include_agent_progress {
14591 project_response_without_agent_progress(&mut reply);
14592 }
14593 if !include_session_task_correlation {
14594 project_response_without_session_task_binding(&mut reply);
14595 }
14596 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14597 }
14598 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14599 event = event_rx.recv() => {
14600 match event {
14601 Ok(event) => queue_connection_event(
14602 &mut pending_events,
14603 event,
14604 discard_events_through,
14605 ),
14606 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14607 Err(broadcast::error::RecvError::Closed) => break,
14608 }
14609 }
14610 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14611 match events {
14612 Ok(events) => queue_connection_event_batch(
14613 &mut pending_events,
14614 &events,
14615 discard_events_through,
14616 ),
14617 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14618 Err(broadcast::error::RecvError::Closed) => break,
14619 }
14620 }
14621 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14622 match events {
14623 Ok(events) => queue_connection_event_batch(
14624 &mut pending_events,
14625 &events,
14626 discard_events_through,
14627 ),
14628 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14629 Err(broadcast::error::RecvError::Closed) => break,
14630 }
14631 }
14632 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14633 match events {
14634 Ok(events) => queue_connection_event_batch(
14635 &mut pending_events,
14636 &events,
14637 discard_events_through,
14638 ),
14639 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14640 Err(broadcast::error::RecvError::Closed) => break,
14641 }
14642 }
14643 _ = shared.shutdown_notify.notified() => break,
14644 }
14645 }
14646 Ok(())
14647}
14648
14649async fn receive_terminal_event_batch(
14650 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14651) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14652 match receiver {
14653 Some(receiver) => receiver.recv().await,
14654 None => std::future::pending().await,
14655 }
14656}
14657
14658fn terminal_event_subscription(
14659 shared: &NodeShared,
14660 enabled: bool,
14661) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14662 enabled.then(|| shared.terminal_event_tx.subscribe())
14663}
14664
14665fn agent_stream_event_subscription(
14669 shared: &NodeShared,
14670 enabled: bool,
14671) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14672 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14673}
14674
14675fn harness_mcp_proxy_event_subscription(
14680 shared: &NodeShared,
14681 enabled: bool,
14682) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14683 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14684}
14685
14686fn queue_connection_event(
14687 pending: &mut Vec<NodeEventEnvelope>,
14688 event: NodeEventEnvelope,
14689 discard_through: u64,
14690) {
14691 if event.sequence <= discard_through
14692 && !event.event.requires_harness_mcp_proxy_capability()
14693 {
14694 return;
14695 }
14696 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14697 if let Some(index) = pending.iter().position(|current| {
14698 matches!(
14699 ¤t.event,
14700 NodeEvent::TerminalFrame {
14701 address: current_address,
14702 ..
14703 } if current_address == address
14704 )
14705 }) {
14706 pending.remove(index);
14707 }
14708 }
14709 pending.push(event);
14710}
14711
14712fn project_event_without_harness_mcp_proxy(
14713 envelope: NodeEventEnvelope,
14714) -> Option<NodeEventEnvelope> {
14715 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14716}
14717
14718fn queue_connection_event_batch(
14719 pending: &mut Vec<NodeEventEnvelope>,
14720 events: &[NodeEventEnvelope],
14721 discard_through: u64,
14722) {
14723 for event in events {
14724 queue_connection_event(pending, event.clone(), discard_through);
14725 }
14726}
14727
14728fn apply_connection_resync_watermark(
14729 pending: &mut Vec<NodeEventEnvelope>,
14730 discard_through: &mut u64,
14731 marker_sequence: u64,
14732) {
14733 *discard_through = (*discard_through).max(marker_sequence);
14734 pending.retain(|event| event.sequence > *discard_through);
14735}
14736
14737fn connection_event_burst_len(pending_len: usize) -> usize {
14738 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14739}
14740
14741fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14742 let history = shared
14743 .history
14744 .lock()
14745 .unwrap_or_else(|poisoned| poisoned.into_inner());
14746 let sequence = history.last_sequence;
14747 let oldest_available_sequence = history.replay_floor_sequence;
14748 NodeEventEnvelope {
14749 sequence,
14750 event: NodeEvent::ResyncRequired {
14751 oldest_available_sequence,
14752 },
14753 }
14754}
14755
14756fn node_compatibility_support(
14757 shared: &NodeShared,
14758) -> Result<NodeCompatibilitySupport, NodeServerError> {
14759 let mut support = node_compatibility_support_for_manifest(
14760 &shared.provider_contracts,
14761 &shared.provider_adapter_contracts,
14762 )?;
14763 if shared
14764 .delivery_store
14765 .lock()
14766 .unwrap_or_else(|poisoned| poisoned.into_inner())
14767 .is_some()
14768 {
14769 support.capabilities.push(
14770 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14771 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14772 );
14773 }
14774 if shared.harness_mcp_registry.is_some() {
14775 support.capabilities.push(
14776 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14777 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14778 );
14779 }
14780 Ok(support)
14781}
14782
14783fn node_compatibility_support_for_manifest(
14784 provider_contracts: &[ProviderContractSupport],
14785 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14786) -> Result<NodeCompatibilitySupport, NodeServerError> {
14787 Ok(NodeCompatibilitySupport {
14788 build_stamp: BUILD_STAMP.to_owned(),
14789 capabilities: baseline_capabilities()?,
14790 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14791 path_semantics: platform::path_semantics(),
14792 local_transport: platform::local_transport(),
14793 state_schema: StateSchemaSupport {
14794 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14795 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14796 },
14797 provider_contracts: provider_contracts.to_vec(),
14798 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14799 })
14800}
14801
14802fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14803 let capabilities = [
14804 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14805 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14806 NODE_REPOSITORY_PATH_CAPABILITY,
14807 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14808 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14809 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14810 NODE_GIT_READ_CAPABILITY,
14811 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14812 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14813 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14814 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14815 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14816 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14817 NODE_ACP_CONTROL_CAPABILITY,
14818 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14819 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14820 NODE_WORKTREE_SELECTION_CAPABILITY,
14821 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14822 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14823 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14824 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14825 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14826 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14827 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14828 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14829 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14830 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14831 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14832 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14833 ]
14834 .into_iter()
14835 .map(|capability| {
14836 CapabilityId::new(capability)
14837 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14838 })
14839 .collect::<Result<Vec<_>, _>>()?;
14840 #[cfg(windows)]
14841 let capabilities = {
14842 let mut capabilities = capabilities;
14843 capabilities.push(
14844 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14845 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14846 );
14847 capabilities
14848 };
14849 Ok(capabilities)
14850}
14851
14852fn request_uses_unnegotiated_capability(
14853 request: &NodeRequest,
14854 selected_capabilities: &[CapabilityId],
14855) -> bool {
14856 let selected = |required: &str| {
14857 selected_capabilities
14858 .iter()
14859 .any(|capability| capability.as_str() == required)
14860 };
14861 request
14862 .required_capability()
14863 .is_some_and(|required| !selected(required))
14864 || (request.requires_worktree_selection_capability()
14865 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14866 || (request.requires_spawn_spec_defaults_overrides_capability()
14867 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14868 || (request.requires_spawn_profile_revision_capability()
14869 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14870 || (request.requires_child_environment_profile_capability()
14871 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14872 || (request.requires_session_bundle_materialization_capability()
14873 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14874 || (request.requires_history_context_pack_capability()
14875 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14876}
14877
14878fn snapshot_for_wire(
14879 shared: &NodeShared,
14880 include_provider_runtime_status: bool,
14881 include_open_provider_ids: bool,
14882 include_spawn_profiles: bool,
14883 include_managed_worktrees: bool,
14884 include_child_environment_profiles: bool,
14885 include_session_bundles: bool,
14886 include_history_context_packs: bool,
14887 include_agent_progress: bool,
14888 include_session_task_correlation: bool,
14889) -> NodeSnapshot {
14890 let mut snapshot = shared.snapshot();
14891 if !include_provider_runtime_status {
14892 snapshot.provider_runtime_statuses.clear();
14893 }
14894 if !include_open_provider_ids {
14895 project_snapshot_legacy_provider_ids(&mut snapshot);
14896 }
14897 if !include_managed_worktrees {
14898 snapshot.managed_worktrees.clear();
14899 for workspace in &mut snapshot.workspaces {
14900 workspace.managed_worktree_profiles = None;
14901 }
14902 }
14903 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14904 if !include_spawn_profiles {
14905 inventory.spawn_profiles = None;
14906 }
14907 if !include_session_bundles {
14908 inventory.bundles = None;
14909 }
14910 if inventory.spawn_profiles.is_none()
14911 && inventory.bundles.is_none()
14912 && inventory.network_allowlists.is_none()
14913 {
14914 snapshot.launch_inventory = None;
14915 }
14916 }
14917 if !include_child_environment_profiles {
14918 clear_snapshot_child_environment_profiles(&mut snapshot);
14919 }
14920 if !include_session_bundles {
14921 clear_snapshot_session_bundles(&mut snapshot);
14922 }
14923 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14924 if !include_history_context_packs {
14925 clear_snapshot_context_packs(&mut snapshot);
14926 }
14927 if !include_agent_progress {
14928 snapshot.agent_progress.clear();
14929 }
14930 if !include_session_task_correlation {
14931 clear_snapshot_session_task_bindings(&mut snapshot);
14932 }
14933 snapshot
14934}
14935
14936fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14937 for record in &mut snapshot.session_records {
14938 record.task_binding = None;
14939 }
14940}
14941
14942fn project_event_without_session_task_binding(
14943 mut envelope: NodeEventEnvelope,
14944) -> NodeEventEnvelope {
14945 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14946 record.task_binding = None;
14947 }
14948 envelope
14949}
14950
14951fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14952 let Ok(response) = reply.result.as_mut() else { return };
14953 match response {
14954 NodeResponse::Snapshot { snapshot, .. } => {
14955 clear_snapshot_session_task_bindings(snapshot);
14956 }
14957 NodeResponse::Resync { snapshot, events, .. } => {
14958 clear_snapshot_session_task_bindings(snapshot);
14959 for event in events {
14960 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14961 record.task_binding = None;
14962 }
14963 }
14964 }
14965 NodeResponse::SessionRecordUpdated { record }
14966 | NodeResponse::ProviderSessionIndexed { record }
14967 | NodeResponse::NativeSessionIndexed { record, .. }
14968 | NodeResponse::SessionRecordResumed { record, .. } => {
14969 record.task_binding = None;
14970 }
14971 _ => {}
14972 }
14973}
14974
14975fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14976 let Ok(response) = reply.result.as_mut() else { return };
14977 match response {
14978 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14979 snapshot.agent_progress.clear();
14980 }
14981 _ => {}
14982 }
14983}
14984
14985fn project_event_without_managed_worktrees(
14986 mut envelope: NodeEventEnvelope,
14987) -> Option<NodeEventEnvelope> {
14988 if matches!(
14989 envelope.event,
14990 NodeEvent::ManagedWorktreeUpserted { .. }
14991 | NodeEvent::ManagedWorktreeRemoved { .. }
14992 ) {
14993 return None;
14994 }
14995 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14996 workspace.managed_worktree_profiles = None;
14997 }
14998 Some(envelope)
14999}
15000
15001fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
15002 if matches!(
15003 reply.result,
15004 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
15005 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
15006 ) {
15007 reply.result = Err(failure(
15008 NodeFailureCode::UnsupportedCapability,
15009 "managed worktree capability was not negotiated",
15010 ));
15011 return;
15012 }
15013 let Ok(response) = reply.result.as_mut() else { return };
15014 match response {
15015 NodeResponse::Snapshot { snapshot, .. } => {
15016 snapshot.managed_worktrees.clear();
15017 for workspace in &mut snapshot.workspaces {
15018 workspace.managed_worktree_profiles = None;
15019 }
15020 }
15021 NodeResponse::Resync { snapshot, events, .. } => {
15022 snapshot.managed_worktrees.clear();
15023 for workspace in &mut snapshot.workspaces {
15024 workspace.managed_worktree_profiles = None;
15025 }
15026 *events = std::mem::take(events)
15027 .into_iter()
15028 .filter_map(project_event_without_managed_worktrees)
15029 .collect();
15030 }
15031 NodeResponse::WorkspaceRegistered { workspace }
15032 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15033 | NodeResponse::WorktreeCreated { workspace, .. } => {
15034 workspace.managed_worktree_profiles = None;
15035 }
15036 NodeResponse::WorkspaceInspected { inspection } => {
15037 inspection.git.managed_worktree = None;
15038 }
15039 _ => {}
15040 }
15041}
15042
15043fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
15044 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
15045 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
15046 });
15047 if includes_open_provider_ids {
15048 return;
15049 }
15050 compatibility
15051 .provider_contracts
15052 .retain(|contract| provider_id_is_legacy(&contract.provider));
15053 compatibility
15054 .provider_adapter_contracts
15055 .retain(|contract| provider_id_is_legacy(&contract.provider));
15056}
15057
15058fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
15059 if let Some(spec) = match request {
15060 NodeRequest::SpawnSpec { spec }
15061 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15062 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15063 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15064 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15065 _ => None,
15066 } {
15067 return shared
15068 .resolve_spawn_spec(spec)
15069 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
15070 }
15071 request_requires_open_provider_ids_with(
15072 request,
15073 |session| shared.validate_address(session).ok(),
15074 |record_id| shared.record(record_id).ok().map(|record| record.provider),
15075 )
15076}
15077
15078fn request_requires_child_environment_profile(
15079 shared: &NodeShared,
15080 request: &NodeRequest,
15081) -> bool {
15082 if let Some(spec) = match request {
15083 NodeRequest::SpawnSpec { spec }
15084 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15085 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15086 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15087 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15088 _ => None,
15089 } {
15090 return shared
15091 .resolve_spawn_spec(spec)
15092 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
15093 }
15094 match request {
15095 NodeRequest::Resume { session, .. }
15096 | NodeRequest::Prompt { session, .. }
15097 | NodeRequest::Paste { session, .. }
15098 | NodeRequest::Input { session, .. }
15099 | NodeRequest::TerminalBytes { session, .. }
15100 | NodeRequest::TerminalControl { session, .. }
15101 | NodeRequest::Resize { session, .. }
15102 | NodeRequest::Interrupt { session }
15103 | NodeRequest::Stop { session, .. }
15104 | NodeRequest::Remove { session }
15105 | NodeRequest::ResolveInteraction { session, .. }
15106 | NodeRequest::SetSessionMode { session, .. }
15107 | NodeRequest::SetSessionConfigOption { session, .. }
15108 | NodeRequest::SetSessionModel { session, .. }
15109 | NodeRequest::DiscoverHistory { session, .. }
15110 | NodeRequest::LoadHistory { session, .. }
15111 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15112 | NodeRequest::ExportContextPack { session } => shared
15113 .session_bindings
15114 .lock()
15115 .unwrap_or_else(|poisoned| poisoned.into_inner())
15116 .get(&session.session.instance_id)
15117 .filter(|binding| {
15118 binding.workspace_id == session.workspace_id
15119 && binding.generation == session.session.generation
15120 })
15121 .map_or(true, |binding| binding.environment_profile.is_some()),
15122 NodeRequest::RenameSessionRecord { record_id, .. }
15123 | NodeRequest::SetSessionTask { record_id, .. }
15124 | NodeRequest::ResumeSessionRecord { record_id, .. }
15125 | NodeRequest::ForgetSessionRecord { record_id } => shared
15126 .record(record_id)
15127 .map_or(true, |record| record.environment_profile.is_some()),
15128 NodeRequest::ArmHarnessMcpReservation { .. }
15129 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15130 | NodeRequest::ActivateHarnessMcpReservation { .. }
15131 | NodeRequest::AbortHarnessMcpReservation { .. }
15132 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15133 | NodeRequest::RejectHarnessMcpCall { .. }
15134 | NodeRequest::Snapshot
15135 | NodeRequest::Resync { .. }
15136 | NodeRequest::BeginDeliveryStage { .. }
15137 | NodeRequest::PutDeliveryBlobChunk { .. }
15138 | NodeRequest::CommitDeliveryStage { .. }
15139 | NodeRequest::AbortDeliveryStage { .. }
15140 | NodeRequest::BrowseHostDirectories { .. }
15141 | NodeRequest::InspectWorkspace { .. }
15142 | NodeRequest::ReadWorkspaceFile { .. }
15143 | NodeRequest::WriteWorkspaceFile { .. }
15144 | NodeRequest::CreateWorkspaceFile { .. }
15145 | NodeRequest::CreateWorkspaceDirectory { .. }
15146 | NodeRequest::ReadGitHistory { .. }
15147 | NodeRequest::ReadGitDiff { .. }
15148 | NodeRequest::AcquireController { .. }
15149 | NodeRequest::ReleaseController
15150 | NodeRequest::Spawn { .. }
15151 | NodeRequest::SpawnWithOptions { .. }
15152 | NodeRequest::SpawnSpec { .. }
15153 | NodeRequest::RegisterWorkspace { .. }
15154 | NodeRequest::CreateStandaloneWorkspace { .. }
15155 | NodeRequest::UnregisterWorkspace { .. }
15156 | NodeRequest::CreateWorktree { .. }
15157 | NodeRequest::RemoveWorktree { .. }
15158 | NodeRequest::SpawnManagedWorktree { .. }
15159 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15160 | NodeRequest::CleanupManagedWorktree { .. }
15161 | NodeRequest::IndexProviderSession { .. }
15162 | NodeRequest::IndexNativeSession { .. }
15163 | NodeRequest::CatalogNativeSessions { .. }
15164 | NodeRequest::PageNativeSessions { .. }
15165 | NodeRequest::PreviewNativeSession { .. }
15166 | NodeRequest::PreviewSessionRecord { .. }
15167 | NodeRequest::ForgetContextPack { .. }
15168 | NodeRequest::ResolveDurableContextPack { .. }
15169 | NodeRequest::ReadContextPack { .. }
15170 | NodeRequest::Shutdown => false,
15171 }
15172}
15173
15174fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
15175 if let Some(spec) = match request {
15176 NodeRequest::SpawnSpec { spec }
15177 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15178 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15179 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15180 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15181 _ => None,
15182 } {
15183 return shared
15184 .resolve_spawn_spec(spec)
15185 .is_ok_and(|resolved| resolved.bundle_id.is_some());
15186 }
15187 match request {
15188 NodeRequest::Resume { session, .. }
15189 | NodeRequest::Prompt { session, .. }
15190 | NodeRequest::Paste { session, .. }
15191 | NodeRequest::Input { session, .. }
15192 | NodeRequest::TerminalBytes { session, .. }
15193 | NodeRequest::TerminalControl { session, .. }
15194 | NodeRequest::Resize { session, .. }
15195 | NodeRequest::Interrupt { session }
15196 | NodeRequest::Stop { session, .. }
15197 | NodeRequest::Remove { session }
15198 | NodeRequest::ResolveInteraction { session, .. }
15199 | NodeRequest::SetSessionMode { session, .. }
15200 | NodeRequest::SetSessionConfigOption { session, .. }
15201 | NodeRequest::SetSessionModel { session, .. }
15202 | NodeRequest::DiscoverHistory { session, .. }
15203 | NodeRequest::LoadHistory { session, .. }
15204 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15205 | NodeRequest::ExportContextPack { session } => shared
15206 .session_bindings
15207 .lock()
15208 .unwrap_or_else(|poisoned| poisoned.into_inner())
15209 .get(&session.session.instance_id)
15210 .filter(|binding| {
15211 binding.workspace_id == session.workspace_id
15212 && binding.generation == session.session.generation
15213 })
15214 .map_or(true, |binding| binding.bundle.is_some()),
15215 NodeRequest::RenameSessionRecord { record_id, .. }
15216 | NodeRequest::SetSessionTask { record_id, .. }
15217 | NodeRequest::ResumeSessionRecord { record_id, .. }
15218 | NodeRequest::ForgetSessionRecord { record_id } => shared
15219 .record(record_id)
15220 .map_or(true, |record| record.bundle.is_some()),
15221 NodeRequest::ArmHarnessMcpReservation { .. }
15222 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15223 | NodeRequest::ActivateHarnessMcpReservation { .. }
15224 | NodeRequest::AbortHarnessMcpReservation { .. }
15225 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15226 | NodeRequest::RejectHarnessMcpCall { .. }
15227 | NodeRequest::Snapshot
15228 | NodeRequest::Resync { .. }
15229 | NodeRequest::BeginDeliveryStage { .. }
15230 | NodeRequest::PutDeliveryBlobChunk { .. }
15231 | NodeRequest::CommitDeliveryStage { .. }
15232 | NodeRequest::AbortDeliveryStage { .. }
15233 | NodeRequest::BrowseHostDirectories { .. }
15234 | NodeRequest::InspectWorkspace { .. }
15235 | NodeRequest::ReadWorkspaceFile { .. }
15236 | NodeRequest::WriteWorkspaceFile { .. }
15237 | NodeRequest::CreateWorkspaceFile { .. }
15238 | NodeRequest::CreateWorkspaceDirectory { .. }
15239 | NodeRequest::ReadGitHistory { .. }
15240 | NodeRequest::ReadGitDiff { .. }
15241 | NodeRequest::AcquireController { .. }
15242 | NodeRequest::ReleaseController
15243 | NodeRequest::Spawn { .. }
15244 | NodeRequest::SpawnWithOptions { .. }
15245 | NodeRequest::SpawnSpec { .. }
15246 | NodeRequest::RegisterWorkspace { .. }
15247 | NodeRequest::CreateStandaloneWorkspace { .. }
15248 | NodeRequest::UnregisterWorkspace { .. }
15249 | NodeRequest::CreateWorktree { .. }
15250 | NodeRequest::RemoveWorktree { .. }
15251 | NodeRequest::SpawnManagedWorktree { .. }
15252 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15253 | NodeRequest::CleanupManagedWorktree { .. }
15254 | NodeRequest::IndexProviderSession { .. }
15255 | NodeRequest::IndexNativeSession { .. }
15256 | NodeRequest::CatalogNativeSessions { .. }
15257 | NodeRequest::PageNativeSessions { .. }
15258 | NodeRequest::PreviewNativeSession { .. }
15259 | NodeRequest::PreviewSessionRecord { .. }
15260 | NodeRequest::ForgetContextPack { .. }
15261 | NodeRequest::ResolveDurableContextPack { .. }
15262 | NodeRequest::ReadContextPack { .. }
15263 | NodeRequest::Shutdown => false,
15264 }
15265}
15266
15267fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15268 if request.requires_history_context_pack_capability() {
15269 return true;
15270 }
15271 if let Some(spec) = match request {
15272 NodeRequest::SpawnSpec { spec }
15273 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15274 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15275 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15276 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15277 _ => None,
15278 } {
15279 return shared
15280 .resolve_spawn_spec(spec)
15281 .map_or(true, |resolved| resolved.context_id.is_some());
15282 }
15283 match request {
15284 NodeRequest::Resume { session, .. }
15285 | NodeRequest::Prompt { session, .. }
15286 | NodeRequest::Paste { session, .. }
15287 | NodeRequest::Input { session, .. }
15288 | NodeRequest::TerminalBytes { session, .. }
15289 | NodeRequest::TerminalControl { session, .. }
15290 | NodeRequest::Resize { session, .. }
15291 | NodeRequest::Interrupt { session }
15292 | NodeRequest::Stop { session, .. }
15293 | NodeRequest::Remove { session }
15294 | NodeRequest::ResolveInteraction { session, .. }
15295 | NodeRequest::SetSessionMode { session, .. }
15296 | NodeRequest::SetSessionConfigOption { session, .. }
15297 | NodeRequest::SetSessionModel { session, .. } => shared
15298 .session_bindings
15299 .lock()
15300 .unwrap_or_else(|poisoned| poisoned.into_inner())
15301 .get(&session.session.instance_id)
15302 .filter(|binding| {
15303 binding.workspace_id == session.workspace_id
15304 && binding.generation == session.session.generation
15305 })
15306 .map_or(true, |binding| binding.context.is_some()),
15307 NodeRequest::RenameSessionRecord { record_id, .. }
15308 | NodeRequest::SetSessionTask { record_id, .. }
15309 | NodeRequest::ResumeSessionRecord { record_id, .. }
15310 | NodeRequest::ForgetSessionRecord { record_id } => shared
15311 .record(record_id)
15312 .map_or(true, |record| record.context.is_some()),
15313 NodeRequest::ArmHarnessMcpReservation { .. }
15314 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15315 | NodeRequest::ActivateHarnessMcpReservation { .. }
15316 | NodeRequest::AbortHarnessMcpReservation { .. }
15317 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15318 | NodeRequest::RejectHarnessMcpCall { .. }
15319 | NodeRequest::Snapshot
15320 | NodeRequest::Resync { .. }
15321 | NodeRequest::BeginDeliveryStage { .. }
15322 | NodeRequest::PutDeliveryBlobChunk { .. }
15323 | NodeRequest::CommitDeliveryStage { .. }
15324 | NodeRequest::AbortDeliveryStage { .. }
15325 | NodeRequest::BrowseHostDirectories { .. }
15326 | NodeRequest::InspectWorkspace { .. }
15327 | NodeRequest::ReadWorkspaceFile { .. }
15328 | NodeRequest::WriteWorkspaceFile { .. }
15329 | NodeRequest::CreateWorkspaceFile { .. }
15330 | NodeRequest::CreateWorkspaceDirectory { .. }
15331 | NodeRequest::ReadGitHistory { .. }
15332 | NodeRequest::ReadGitDiff { .. }
15333 | NodeRequest::AcquireController { .. }
15334 | NodeRequest::ReleaseController
15335 | NodeRequest::Spawn { .. }
15336 | NodeRequest::SpawnWithOptions { .. }
15337 | NodeRequest::SpawnSpec { .. }
15338 | NodeRequest::RegisterWorkspace { .. }
15339 | NodeRequest::CreateStandaloneWorkspace { .. }
15340 | NodeRequest::UnregisterWorkspace { .. }
15341 | NodeRequest::CreateWorktree { .. }
15342 | NodeRequest::RemoveWorktree { .. }
15343 | NodeRequest::SpawnManagedWorktree { .. }
15344 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15345 | NodeRequest::CleanupManagedWorktree { .. }
15346 | NodeRequest::IndexProviderSession { .. }
15347 | NodeRequest::IndexNativeSession { .. }
15348 | NodeRequest::CatalogNativeSessions { .. }
15349 | NodeRequest::PageNativeSessions { .. }
15350 | NodeRequest::PreviewNativeSession { .. }
15351 | NodeRequest::PreviewSessionRecord { .. }
15352 | NodeRequest::DiscoverHistory { .. }
15353 | NodeRequest::LoadHistory { .. }
15354 | NodeRequest::ExportContextPackForSessionRecord { .. }
15355 | NodeRequest::ExportContextPack { .. }
15356 | NodeRequest::ForgetContextPack { .. }
15357 | NodeRequest::ResolveDurableContextPack { .. }
15358 | NodeRequest::ReadContextPack { .. }
15359 | NodeRequest::Shutdown => false,
15360 }
15361}
15362
15363fn request_requires_open_provider_ids_with(
15364 request: &NodeRequest,
15365 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15366 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15367) -> bool {
15368 match request {
15369 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15370 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15371 NodeRequest::CatalogNativeSessions { route, .. }
15372 | NodeRequest::PageNativeSessions { route, .. } => {
15373 !provider_id_is_legacy(&route.provider)
15374 }
15375 NodeRequest::PreviewNativeSession { selection, .. }
15376 | NodeRequest::IndexNativeSession { selection, .. } => {
15377 !provider_id_is_legacy(&selection.route.provider)
15378 }
15379 NodeRequest::SpawnSpec { .. }
15380 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15381 NodeRequest::SpawnManagedWorktree { .. }
15382 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15383 NodeRequest::Resume { session, .. }
15384 | NodeRequest::Prompt { session, .. }
15385 | NodeRequest::Paste { session, .. }
15386 | NodeRequest::Input { session, .. }
15387 | NodeRequest::TerminalBytes { session, .. }
15388 | NodeRequest::TerminalControl { session, .. }
15389 | NodeRequest::Resize { session, .. }
15390 | NodeRequest::Interrupt { session }
15391 | NodeRequest::Stop { session, .. }
15392 | NodeRequest::Remove { session }
15393 | NodeRequest::ResolveInteraction { session, .. }
15394 | NodeRequest::SetSessionMode { session, .. }
15395 | NodeRequest::SetSessionConfigOption { session, .. }
15396 | NodeRequest::SetSessionModel { session, .. }
15397 | NodeRequest::DiscoverHistory { session, .. }
15398 | NodeRequest::LoadHistory { session, .. }
15399 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15400 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15401 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15402 NodeRequest::RenameSessionRecord { record_id, .. }
15403 | NodeRequest::SetSessionTask { record_id, .. }
15404 | NodeRequest::ResumeSessionRecord { record_id, .. }
15405 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15406 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15407 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15408 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15409 NodeRequest::ArmHarnessMcpReservation { .. }
15410 | NodeRequest::ActivateHarnessMcpReservation { .. }
15411 | NodeRequest::AbortHarnessMcpReservation { .. }
15412 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15413 | NodeRequest::RejectHarnessMcpCall { .. }
15414 | NodeRequest::Snapshot
15415 | NodeRequest::Resync { .. }
15416 | NodeRequest::BeginDeliveryStage { .. }
15417 | NodeRequest::PutDeliveryBlobChunk { .. }
15418 | NodeRequest::CommitDeliveryStage { .. }
15419 | NodeRequest::AbortDeliveryStage { .. }
15420 | NodeRequest::BrowseHostDirectories { .. }
15421 | NodeRequest::InspectWorkspace { .. }
15422 | NodeRequest::ReadWorkspaceFile { .. }
15423 | NodeRequest::WriteWorkspaceFile { .. }
15424 | NodeRequest::CreateWorkspaceFile { .. }
15425 | NodeRequest::CreateWorkspaceDirectory { .. }
15426 | NodeRequest::ReadGitHistory { .. }
15427 | NodeRequest::ReadGitDiff { .. }
15428 | NodeRequest::AcquireController { .. }
15429 | NodeRequest::ReleaseController
15430 | NodeRequest::RegisterWorkspace { .. }
15431 | NodeRequest::CreateStandaloneWorkspace { .. }
15432 | NodeRequest::UnregisterWorkspace { .. }
15433 | NodeRequest::CreateWorktree { .. }
15434 | NodeRequest::RemoveWorktree { .. }
15435 | NodeRequest::CleanupManagedWorktree { .. }
15436 | NodeRequest::Shutdown => false,
15437 NodeRequest::ForgetContextPack { .. } => true,
15438 NodeRequest::ResolveDurableContextPack { .. }
15444 | NodeRequest::ReadContextPack { .. } => true,
15445 }
15446}
15447
15448fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15449 snapshot.enabled_providers.retain(provider_id_is_legacy);
15450 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15451 snapshot
15452 .provider_runtime_statuses
15453 .as_slice()
15454 .iter()
15455 .filter(|status| provider_id_is_legacy(status.provider()))
15456 .cloned(),
15457 )
15458 .expect("a filtered provider runtime projection remains valid");
15459 snapshot
15460 .session_records
15461 .retain(|record| provider_id_is_legacy(&record.provider));
15462 for workspace in &mut snapshot.workspaces {
15463 workspace
15464 .sessions
15465 .retain(|session| provider_id_is_legacy(&session.agent_id));
15466 }
15467}
15468
15469fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15470 for record in &mut snapshot.session_records {
15471 record.environment_profile = None;
15472 }
15473 if let Some(profiles) = snapshot
15474 .launch_inventory
15475 .as_mut()
15476 .and_then(|inventory| inventory.spawn_profiles.as_mut())
15477 {
15478 for profile in profiles {
15479 profile.environment_profile = None;
15480 }
15481 }
15482}
15483
15484fn project_event_without_child_environment_profile(
15485 mut envelope: NodeEventEnvelope,
15486) -> NodeEventEnvelope {
15487 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15488 record.environment_profile = None;
15489 }
15490 envelope
15491}
15492
15493fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15494 let contains_environment_profile = match reply.result.as_ref() {
15495 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15496 receipt.environment_profile.is_some()
15497 }
15498 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15499 receipt.spawn.environment_profile.is_some()
15500 }
15501 _ => false,
15502 };
15503 if contains_environment_profile {
15504 reply.result = Err(failure(
15505 NodeFailureCode::UnsupportedCapability,
15506 "child environment profiles were not negotiated",
15507 ));
15508 return;
15509 }
15510 let Ok(response) = reply.result.as_mut() else {
15511 return;
15512 };
15513 match response {
15514 NodeResponse::Snapshot { snapshot, .. } => {
15515 clear_snapshot_child_environment_profiles(snapshot);
15516 }
15517 NodeResponse::Resync { snapshot, events, .. } => {
15518 clear_snapshot_child_environment_profiles(snapshot);
15519 for event in events {
15520 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15521 record.environment_profile = None;
15522 }
15523 }
15524 }
15525 NodeResponse::SessionRecordUpdated { record }
15526 | NodeResponse::ProviderSessionIndexed { record }
15527 | NodeResponse::NativeSessionIndexed { record, .. }
15528 | NodeResponse::SessionRecordResumed { record, .. } => {
15529 record.environment_profile = None;
15530 }
15531 NodeResponse::Armed { .. }
15532 | NodeResponse::Spawned { .. }
15533 | NodeResponse::Activated { .. }
15534 | NodeResponse::Aborted { .. }
15535 | NodeResponse::ReplyChunkAccepted { .. }
15536 | NodeResponse::CallRejected { .. }
15537 | NodeResponse::DeliveryStageBegun { .. }
15538 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15539 | NodeResponse::DeliveryCommitted { .. }
15540 | NodeResponse::DeliveryStageAborted { .. }
15541 | NodeResponse::WorkspaceInspected { .. }
15542 | NodeResponse::HostDirectoriesBrowsed { .. }
15543 | NodeResponse::WorkspaceFileRead { .. }
15544 | NodeResponse::WorkspaceFileWritten { .. }
15545 | NodeResponse::WorkspaceFileCreated { .. }
15546 | NodeResponse::WorkspaceDirectoryCreated { .. }
15547 | NodeResponse::GitHistoryRead { .. }
15548 | NodeResponse::GitDiffRead { .. }
15549 | NodeResponse::Controller { .. }
15550 | NodeResponse::SpawnAccepted { .. }
15551 | NodeResponse::SpawnSpecAccepted { .. }
15552 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15553 | NodeResponse::ManagedWorktreeCleanup { .. }
15554 | NodeResponse::NativeSessionsCataloged { .. }
15555 | NodeResponse::NativeSessionsPaged { .. }
15556 | NodeResponse::NativeSessionPreviewed { .. }
15557 | NodeResponse::SessionRecordPreviewed { .. }
15558 | NodeResponse::HistoryDiscovered { .. }
15559 | NodeResponse::HistoryLoaded { .. }
15560 | NodeResponse::ContextPackForSessionRecordExported { .. }
15561 | NodeResponse::ContextPackExported { .. }
15562 | NodeResponse::ContextPackForgotten { .. }
15563 | NodeResponse::DurableContextPackResolved { .. }
15564 | NodeResponse::ContextPackBytesRead { .. }
15565 | NodeResponse::SessionRecordForgotten { .. }
15566 | NodeResponse::WorkspaceRegistered { .. }
15567 | NodeResponse::StandaloneWorkspaceCreated { .. }
15568 | NodeResponse::WorkspaceUnregistered { .. }
15569 | NodeResponse::WorktreeCreated { .. }
15570 | NodeResponse::WorktreeRemoved { .. }
15571 | NodeResponse::Accepted
15572 | NodeResponse::ShuttingDown => {}
15573 }
15574}
15575
15576fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15577 for record in &mut snapshot.session_records {
15578 record.bundle = None;
15579 }
15580}
15581
15582fn project_event_without_session_bundle(
15583 mut envelope: NodeEventEnvelope,
15584) -> NodeEventEnvelope {
15585 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15586 record.bundle = None;
15587 }
15588 envelope
15589}
15590
15591fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15592 let contains_bundle = match reply.result.as_ref() {
15593 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15594 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15595 receipt.spawn.bundle.is_some()
15596 }
15597 _ => false,
15598 };
15599 if contains_bundle {
15600 reply.result = Err(failure(
15601 NodeFailureCode::UnsupportedCapability,
15602 "session bundles were not negotiated",
15603 ));
15604 return;
15605 }
15606 let Ok(response) = reply.result.as_mut() else {
15607 return;
15608 };
15609 match response {
15610 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15611 NodeResponse::Resync { snapshot, events, .. } => {
15612 clear_snapshot_session_bundles(snapshot);
15613 for event in events {
15614 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15615 record.bundle = None;
15616 }
15617 }
15618 }
15619 NodeResponse::SessionRecordUpdated { record }
15620 | NodeResponse::ProviderSessionIndexed { record }
15621 | NodeResponse::NativeSessionIndexed { record, .. }
15622 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15623 NodeResponse::Armed { .. }
15624 | NodeResponse::Spawned { .. }
15625 | NodeResponse::Activated { .. }
15626 | NodeResponse::Aborted { .. }
15627 | NodeResponse::ReplyChunkAccepted { .. }
15628 | NodeResponse::CallRejected { .. }
15629 | NodeResponse::DeliveryStageBegun { .. }
15630 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15631 | NodeResponse::DeliveryCommitted { .. }
15632 | NodeResponse::DeliveryStageAborted { .. }
15633 | NodeResponse::WorkspaceInspected { .. }
15634 | NodeResponse::HostDirectoriesBrowsed { .. }
15635 | NodeResponse::WorkspaceFileRead { .. }
15636 | NodeResponse::WorkspaceFileWritten { .. }
15637 | NodeResponse::WorkspaceFileCreated { .. }
15638 | NodeResponse::WorkspaceDirectoryCreated { .. }
15639 | NodeResponse::GitHistoryRead { .. }
15640 | NodeResponse::GitDiffRead { .. }
15641 | NodeResponse::Controller { .. }
15642 | NodeResponse::SpawnAccepted { .. }
15643 | NodeResponse::SpawnSpecAccepted { .. }
15644 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15645 | NodeResponse::ManagedWorktreeCleanup { .. }
15646 | NodeResponse::NativeSessionsCataloged { .. }
15647 | NodeResponse::NativeSessionsPaged { .. }
15648 | NodeResponse::NativeSessionPreviewed { .. }
15649 | NodeResponse::SessionRecordPreviewed { .. }
15650 | NodeResponse::HistoryDiscovered { .. }
15651 | NodeResponse::HistoryLoaded { .. }
15652 | NodeResponse::ContextPackForSessionRecordExported { .. }
15653 | NodeResponse::ContextPackExported { .. }
15654 | NodeResponse::ContextPackForgotten { .. }
15655 | NodeResponse::DurableContextPackResolved { .. }
15656 | NodeResponse::ContextPackBytesRead { .. }
15657 | NodeResponse::SessionRecordForgotten { .. }
15658 | NodeResponse::WorkspaceRegistered { .. }
15659 | NodeResponse::StandaloneWorkspaceCreated { .. }
15660 | NodeResponse::WorkspaceUnregistered { .. }
15661 | NodeResponse::WorktreeCreated { .. }
15662 | NodeResponse::WorktreeRemoved { .. }
15663 | NodeResponse::Accepted
15664 | NodeResponse::ShuttingDown => {}
15665 }
15666}
15667
15668fn project_snapshot_history_for_wire(
15669 snapshot: &mut NodeSnapshot,
15670 include_history_metadata: bool,
15671) {
15672 for workspace in &mut snapshot.workspaces {
15673 for session in &mut workspace.sessions {
15674 if include_history_metadata {
15675 session.history.loaded = None;
15676 } else {
15677 session.history = Default::default();
15678 }
15679 }
15680 }
15681}
15682
15683fn project_event_history_for_wire(
15684 mut envelope: NodeEventEnvelope,
15685 include_history_metadata: bool,
15686) -> NodeEventEnvelope {
15687 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15688 for session in &mut workspace.sessions {
15689 if include_history_metadata {
15690 session.history.loaded = None;
15691 } else {
15692 session.history = Default::default();
15693 }
15694 }
15695 }
15696 envelope
15697}
15698
15699fn project_response_history_for_wire(
15700 reply: &mut ResponseEnvelope,
15701 include_history_metadata: bool,
15702) {
15703 let Ok(response) = reply.result.as_mut() else {
15704 return;
15705 };
15706 match response {
15707 NodeResponse::Snapshot { snapshot, .. } => {
15708 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15709 }
15710 NodeResponse::Resync {
15711 snapshot, events, ..
15712 } => {
15713 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15714 for event in events {
15715 *event = project_event_history_for_wire(
15716 event.clone(),
15717 include_history_metadata,
15718 );
15719 }
15720 }
15721 NodeResponse::WorkspaceRegistered { workspace }
15722 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15723 | NodeResponse::WorktreeCreated { workspace, .. } => {
15724 for session in &mut workspace.sessions {
15725 if include_history_metadata {
15726 session.history.loaded = None;
15727 } else {
15728 session.history = Default::default();
15729 }
15730 }
15731 }
15732 _ => {}
15733 }
15734}
15735
15736fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15737 for record in &mut snapshot.session_records {
15738 record.context_id = None;
15739 record.context = None;
15740 record.exported_context = None;
15741 }
15742}
15743
15744fn project_event_without_context_pack(
15745 mut envelope: NodeEventEnvelope,
15746) -> NodeEventEnvelope {
15747 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15748 record.context_id = None;
15749 record.context = None;
15750 record.exported_context = None;
15751 }
15752 envelope
15753}
15754
15755fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15756 let contains_context = match reply.result.as_ref() {
15757 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15758 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15759 receipt.spawn.context.is_some()
15760 }
15761 Ok(NodeResponse::HistoryDiscovered { .. })
15762 | Ok(NodeResponse::HistoryLoaded { .. })
15763 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15764 | Ok(NodeResponse::ContextPackExported { .. })
15765 | Ok(NodeResponse::ContextPackForgotten { .. })
15766 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15767 _ => false,
15768 };
15769 if contains_context {
15770 reply.result = Err(failure(
15771 NodeFailureCode::UnsupportedCapability,
15772 "history context packs were not negotiated",
15773 ));
15774 return;
15775 }
15776 let Ok(response) = reply.result.as_mut() else {
15777 return;
15778 };
15779 match response {
15780 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15781 NodeResponse::Resync {
15782 snapshot, events, ..
15783 } => {
15784 clear_snapshot_context_packs(snapshot);
15785 for event in events {
15786 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15787 record.context_id = None;
15788 record.context = None;
15789 record.exported_context = None;
15790 }
15791 }
15792 }
15793 NodeResponse::SessionRecordUpdated { record }
15794 | NodeResponse::ProviderSessionIndexed { record }
15795 | NodeResponse::NativeSessionIndexed { record, .. }
15796 | NodeResponse::SessionRecordResumed { record, .. } => {
15797 record.context_id = None;
15798 record.context = None;
15799 record.exported_context = None;
15800 }
15801 _ => {}
15802 }
15803}
15804
15805fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15806 let contains_open_record = match reply.result.as_ref() {
15807 Ok(NodeResponse::SessionRecordUpdated { record })
15808 | Ok(NodeResponse::ProviderSessionIndexed { record })
15809 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15810 !provider_id_is_legacy(&record.provider)
15811 }
15812 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15813 !provider_id_is_legacy(&selection.route.provider)
15814 || !provider_id_is_legacy(&record.provider)
15815 }
15816 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15817 !provider_id_is_legacy(&receipt.provider)
15818 }
15819 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15820 !provider_id_is_legacy(&receipt.spawn.provider)
15821 }
15822 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15823 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15824 !provider_id_is_legacy(&route.provider)
15825 }
15826 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15827 !provider_id_is_legacy(&selection.route.provider)
15828 }
15829 _ => false,
15830 };
15831 if contains_open_record {
15832 reply.result = Err(failure(
15833 NodeFailureCode::UnsupportedCapability,
15834 "open provider IDs were not negotiated",
15835 ));
15836 return;
15837 }
15838 let Ok(response) = reply.result.as_mut() else {
15839 return;
15840 };
15841 match response {
15842 NodeResponse::Snapshot { snapshot, .. } => {
15843 project_snapshot_legacy_provider_ids(snapshot);
15844 }
15845 NodeResponse::Resync { snapshot, events, .. } => {
15846 project_snapshot_legacy_provider_ids(snapshot);
15847 *events = std::mem::take(events)
15848 .into_iter()
15849 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15850 .collect();
15851 }
15852 NodeResponse::WorkspaceRegistered { workspace }
15853 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15854 | NodeResponse::WorktreeCreated { workspace, .. } => {
15855 workspace
15856 .sessions
15857 .retain(|session| provider_id_is_legacy(&session.agent_id));
15858 }
15859 NodeResponse::Armed { .. }
15860 | NodeResponse::Spawned { .. }
15861 | NodeResponse::Activated { .. }
15862 | NodeResponse::Aborted { .. }
15863 | NodeResponse::ReplyChunkAccepted { .. }
15864 | NodeResponse::CallRejected { .. }
15865 | NodeResponse::DeliveryStageBegun { .. }
15866 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15867 | NodeResponse::DeliveryCommitted { .. }
15868 | NodeResponse::DeliveryStageAborted { .. }
15869 | NodeResponse::WorkspaceInspected { .. }
15870 | NodeResponse::HostDirectoriesBrowsed { .. }
15871 | NodeResponse::WorkspaceFileRead { .. }
15872 | NodeResponse::WorkspaceFileWritten { .. }
15873 | NodeResponse::WorkspaceFileCreated { .. }
15874 | NodeResponse::WorkspaceDirectoryCreated { .. }
15875 | NodeResponse::GitHistoryRead { .. }
15876 | NodeResponse::GitDiffRead { .. }
15877 | NodeResponse::Controller { .. }
15878 | NodeResponse::SpawnAccepted { .. }
15879 | NodeResponse::SpawnSpecAccepted { .. }
15880 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15881 | NodeResponse::ManagedWorktreeCleanup { .. }
15882 | NodeResponse::NativeSessionsCataloged { .. }
15883 | NodeResponse::NativeSessionsPaged { .. }
15884 | NodeResponse::NativeSessionPreviewed { .. }
15885 | NodeResponse::SessionRecordPreviewed { .. }
15886 | NodeResponse::HistoryDiscovered { .. }
15887 | NodeResponse::HistoryLoaded { .. }
15888 | NodeResponse::ContextPackForSessionRecordExported { .. }
15889 | NodeResponse::ContextPackExported { .. }
15890 | NodeResponse::ContextPackForgotten { .. }
15891 | NodeResponse::DurableContextPackResolved { .. }
15892 | NodeResponse::ContextPackBytesRead { .. }
15893 | NodeResponse::SessionRecordUpdated { .. }
15894 | NodeResponse::ProviderSessionIndexed { .. }
15895 | NodeResponse::NativeSessionIndexed { .. }
15896 | NodeResponse::SessionRecordResumed { .. }
15897 | NodeResponse::SessionRecordForgotten { .. }
15898 | NodeResponse::WorkspaceUnregistered { .. }
15899 | NodeResponse::WorktreeRemoved { .. }
15900 | NodeResponse::Accepted
15901 | NodeResponse::ShuttingDown => {}
15902 }
15903}
15904
15905fn project_event_legacy_provider_ids(
15906 shared: &NodeShared,
15907 mut envelope: NodeEventEnvelope,
15908) -> Option<NodeEventEnvelope> {
15909 let include = match &mut envelope.event {
15910 NodeEvent::HarnessMcpReadCall { .. } => true,
15911 NodeEvent::Control { address, .. }
15912 | NodeEvent::AgentStream { address, .. }
15913 | NodeEvent::TerminalFrame { address, .. } => shared
15914 .validate_address(address)
15915 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15916 NodeEvent::WorkspaceAdded { workspace } => {
15917 workspace
15918 .sessions
15919 .retain(|session| provider_id_is_legacy(&session.agent_id));
15920 true
15921 }
15922 NodeEvent::SessionRecordUpserted { record } => {
15923 provider_id_is_legacy(&record.provider)
15924 }
15925 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15926 NodeEvent::SessionRecordRemoved { .. } => shared
15927 .history
15928 .lock()
15929 .unwrap_or_else(|poisoned| poisoned.into_inner())
15930 .removed_record_providers
15931 .get(&envelope.sequence)
15932 .is_some_and(provider_id_is_legacy),
15933 NodeEvent::ControllerChanged { .. }
15934 | NodeEvent::ManagedWorktreeUpserted { .. }
15935 | NodeEvent::ManagedWorktreeRemoved { .. }
15936 | NodeEvent::WorkspaceRemoved { .. }
15937 | NodeEvent::ResyncRequired { .. } => true,
15938 };
15939 include.then_some(envelope)
15940}
15941
15942fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15943 let Ok(response) = reply.result.as_mut() else {
15944 return;
15945 };
15946 match response {
15947 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15948 snapshot.provider_runtime_statuses.clear();
15949 }
15950 NodeResponse::Armed { .. }
15951 | NodeResponse::Spawned { .. }
15952 | NodeResponse::Activated { .. }
15953 | NodeResponse::Aborted { .. }
15954 | NodeResponse::ReplyChunkAccepted { .. }
15955 | NodeResponse::CallRejected { .. }
15956 | NodeResponse::DeliveryStageBegun { .. }
15957 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15958 | NodeResponse::DeliveryCommitted { .. }
15959 | NodeResponse::DeliveryStageAborted { .. }
15960 | NodeResponse::WorkspaceInspected { .. }
15961 | NodeResponse::HostDirectoriesBrowsed { .. }
15962 | NodeResponse::WorkspaceFileRead { .. }
15963 | NodeResponse::WorkspaceFileWritten { .. }
15964 | NodeResponse::WorkspaceFileCreated { .. }
15965 | NodeResponse::WorkspaceDirectoryCreated { .. }
15966 | NodeResponse::GitHistoryRead { .. }
15967 | NodeResponse::GitDiffRead { .. }
15968 | NodeResponse::Controller { .. }
15969 | NodeResponse::SpawnAccepted { .. }
15970 | NodeResponse::SpawnSpecAccepted { .. }
15971 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15972 | NodeResponse::ManagedWorktreeCleanup { .. }
15973 | NodeResponse::NativeSessionsCataloged { .. }
15974 | NodeResponse::NativeSessionsPaged { .. }
15975 | NodeResponse::NativeSessionPreviewed { .. }
15976 | NodeResponse::SessionRecordPreviewed { .. }
15977 | NodeResponse::HistoryDiscovered { .. }
15978 | NodeResponse::HistoryLoaded { .. }
15979 | NodeResponse::ContextPackForSessionRecordExported { .. }
15980 | NodeResponse::ContextPackExported { .. }
15981 | NodeResponse::ContextPackForgotten { .. }
15982 | NodeResponse::DurableContextPackResolved { .. }
15983 | NodeResponse::ContextPackBytesRead { .. }
15984 | NodeResponse::SessionRecordUpdated { .. }
15985 | NodeResponse::ProviderSessionIndexed { .. }
15986 | NodeResponse::NativeSessionIndexed { .. }
15987 | NodeResponse::SessionRecordResumed { .. }
15988 | NodeResponse::SessionRecordForgotten { .. }
15989 | NodeResponse::WorkspaceRegistered { .. }
15990 | NodeResponse::StandaloneWorkspaceCreated { .. }
15991 | NodeResponse::WorkspaceUnregistered { .. }
15992 | NodeResponse::WorktreeCreated { .. }
15993 | NodeResponse::WorktreeRemoved { .. }
15994 | NodeResponse::Accepted
15995 | NodeResponse::ShuttingDown => {}
15996 }
15997}
15998
15999struct ControllerReleaseGuard {
16000 shared: Arc<NodeShared>,
16001 connection_id: u64,
16002}
16003
16004impl Drop for ControllerReleaseGuard {
16005 fn drop(&mut self) {
16006 self.shared.release_controller(self.connection_id);
16007 }
16008}
16009
16010struct AbortTaskOnDrop(AbortHandle);
16011
16012impl Drop for AbortTaskOnDrop {
16013 fn drop(&mut self) {
16014 self.0.abort();
16015 }
16016}
16017
16018fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
16023 std::env::var(name)
16024 .ok()
16025 .and_then(|value| value.parse::<u64>().ok())
16026 .filter(|value| *value > 0)
16027 .unwrap_or(default)
16028 .min(max)
16029}
16030
16031fn workspace_inspection_time_budget_ms() -> u64 {
16032 env_config_bounded(
16033 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
16034 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
16035 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
16036 )
16037}
16038
16039fn workspace_inspection_entry_cap() -> usize {
16040 env_config_bounded(
16041 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
16042 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
16043 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
16044 ) as usize
16045}
16046
16047struct WorkspaceWalkBudget {
16053 deadline: Instant,
16054 entry_cap: usize,
16055 entries_visited: u64,
16056 time_budget_exceeded: bool,
16057 entry_cap_exceeded: bool,
16058}
16059
16060impl WorkspaceWalkBudget {
16061 fn new(deadline: Instant, entry_cap: usize) -> Self {
16062 Self {
16063 deadline,
16064 entry_cap,
16065 entries_visited: 0,
16066 time_budget_exceeded: false,
16067 entry_cap_exceeded: false,
16068 }
16069 }
16070
16071 fn is_exhausted(&self) -> bool {
16072 self.time_budget_exceeded || self.entry_cap_exceeded
16073 }
16074
16075 fn record_visit(&mut self) -> bool {
16078 self.entries_visited += 1;
16079 if self.entries_visited as usize >= self.entry_cap {
16080 self.entry_cap_exceeded = true;
16081 }
16082 if Instant::now() >= self.deadline {
16083 self.time_budget_exceeded = true;
16084 }
16085 self.is_exhausted()
16086 }
16087}
16088
16089fn collect_workspace_entries(
16090 root: &Path,
16091 deadline: Instant,
16092 entry_cap: usize,
16093) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
16094 let mut entries = Vec::new();
16095 let mut truncated = false;
16096 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
16097 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
16098 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
16102 (entries, truncated, budget)
16103}
16104
16105fn workspace_inspection_truncation(
16109 walk_budget: &WorkspaceWalkBudget,
16110 git_time_budget_exceeded: bool,
16111 elapsed: Duration,
16112) -> Option<WorkspaceInspectionTruncationV1> {
16113 if !walk_budget.time_budget_exceeded
16114 && !walk_budget.entry_cap_exceeded
16115 && !git_time_budget_exceeded
16116 {
16117 return None;
16118 }
16119 Some(WorkspaceInspectionTruncationV1 {
16120 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
16121 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
16122 git_time_budget_exceeded,
16123 entries_visited: walk_budget.entries_visited,
16124 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
16125 })
16126}
16127
16128fn walk_workspace_directory(
16129 directory: &Path,
16130 relative_directory: Option<&RepositoryPath>,
16131 depth: usize,
16132 entries: &mut Vec<WorkspaceEntry>,
16133 truncated: &mut bool,
16134 budget: &mut WorkspaceWalkBudget,
16135) {
16136 if budget.is_exhausted() {
16137 *truncated = true;
16138 return;
16139 }
16140 let read_dir = match std::fs::read_dir(directory) {
16141 Ok(read_dir) => read_dir,
16142 Err(_) => {
16143 *truncated = true;
16144 return;
16145 }
16146 };
16147 let mut children = Vec::new();
16148 for child in read_dir {
16149 match child {
16150 Ok(child) => children.push(child),
16151 Err(_) => *truncated = true,
16152 }
16153 }
16154 children.sort_by_key(|child| child.file_name());
16155 for child in children {
16156 if budget.record_visit() {
16157 *truncated = true;
16158 return;
16159 }
16160 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
16161 *truncated = true;
16162 return;
16163 }
16164 let file_type = match child.file_type() {
16165 Ok(file_type) => file_type,
16166 Err(_) => {
16167 *truncated = true;
16168 continue;
16169 }
16170 };
16171 if file_type.is_symlink() {
16172 continue;
16173 }
16174 let name = match child.file_name().into_string() {
16175 Ok(name) => name,
16176 Err(_) => {
16177 *truncated = true;
16178 continue;
16179 }
16180 };
16181 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
16182 continue;
16183 }
16184 let relative_path = match relative_directory {
16185 None => windows_repository_path(name),
16186 Some(parent) => parent
16187 .as_utf8()
16188 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
16189 };
16190 let Some(relative_path) = relative_path else {
16191 *truncated = true;
16192 continue;
16193 };
16194 let kind = if file_type.is_dir() {
16195 WorkspaceEntryKind::Directory
16196 } else if file_type.is_file() {
16197 WorkspaceEntryKind::File
16198 } else {
16199 continue;
16200 };
16201 entries.push(WorkspaceEntry {
16202 relative_path: relative_path.clone(),
16203 kind,
16204 });
16205 if file_type.is_dir() {
16206 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
16207 walk_workspace_directory(
16208 &child.path(),
16209 Some(&relative_path),
16210 depth + 1,
16211 entries,
16212 truncated,
16213 budget,
16214 );
16215 } else {
16216 *truncated = true;
16217 }
16218 }
16219 }
16220}
16221
16222fn windows_repository_path(value: String) -> Option<RepositoryPath> {
16223 if value.contains('\\') {
16224 return None;
16225 }
16226 RepositoryPath::utf8(value).ok()
16227}
16228
16229fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
16230 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
16231}
16232
16233fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
16242 if name.eq_ignore_ascii_case(".git")
16243 || name.eq_ignore_ascii_case("target")
16244 || name.eq_ignore_ascii_case("node_modules")
16245 {
16246 return true;
16247 }
16248 path.join("CACHEDIR.TAG").is_file()
16249}
16250
16251fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16252 matches!(
16253 status,
16254 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16255 )
16256}
16257
16258#[derive(Clone, Debug, Eq, PartialEq)]
16259enum ContextPackRepositoryHead {
16260 NotRepository,
16261 Unborn,
16262 Commit(String),
16263}
16264
16265#[derive(Clone, Debug, Eq, PartialEq)]
16266struct ContextPackRepositoryObservation {
16267 head: ContextPackRepositoryHead,
16268 git: GitSnapshot,
16269 files: Vec<ContextPackRepositoryFileSource>,
16270}
16271
16272fn stable_context_pack_repository(
16273 first: ContextPackRepositoryObservation,
16274 second: ContextPackRepositoryObservation,
16275) -> Result<ContextPackRepository, NodeFailure> {
16276 if first != second {
16277 return Err(failure(
16278 NodeFailureCode::ContextPackMaterializationFailed,
16279 "context repository changed during bounded capture",
16280 ));
16281 }
16282 Ok(ContextPackRepository::from_sources(
16283 &second.git,
16284 second.files,
16285 ))
16286}
16287
16288fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16295 if Instant::now() < deadline {
16296 return false;
16297 }
16298 append_git_diagnostic(
16299 snapshot,
16300 &format!("{stage} skipped: workspace inspection time budget exhausted"),
16301 );
16302 true
16303}
16304
16305async fn run_git_bounded_with_deadline(
16314 root: &str,
16315 arguments: &[&str],
16316 output_limit: usize,
16317 deadline: Instant,
16318) -> io::Result<GitCommandOutput> {
16319 let remaining = deadline.checked_duration_since(Instant::now());
16320 let timeout_ms = remaining
16321 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16322 .unwrap_or(0)
16323 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16324 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16325}
16326
16327async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16334 let mut snapshot = GitSnapshot {
16335 is_repository: false,
16336 branch: None,
16337 status: Vec::new(),
16338 recent_commits: Vec::new(),
16339 worktrees: Vec::new(),
16340 managed_worktree: None,
16341 truncated: false,
16342 diagnostic: None,
16343 };
16344 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16345 return (snapshot, true);
16346 }
16347 let repository = match run_git_bounded_with_deadline(
16348 root,
16349 &["rev-parse", "--is-inside-work-tree"],
16350 4 * 1_024,
16351 deadline,
16352 )
16353 .await
16354 {
16355 Ok(output) => output,
16356 Err(error) => {
16357 append_git_diagnostic(
16358 &mut snapshot,
16359 &format!("git inspection unavailable: {error}"),
16360 );
16361 return (snapshot, false);
16362 }
16363 };
16364 snapshot.truncated |= repository.truncated;
16365 if repository.timed_out {
16366 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16367 return (snapshot, false);
16368 }
16369 if !repository.success
16370 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16371 {
16372 let stderr = String::from_utf8_lossy(&repository.stderr);
16373 if !stderr.to_ascii_lowercase().contains("not a git repository") {
16374 append_git_diagnostic(&mut snapshot, stderr.trim());
16375 }
16376 return (snapshot, false);
16377 }
16378 snapshot.is_repository = true;
16379
16380 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16381 return (snapshot, true);
16382 }
16383 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16384 Ok(output) => {
16385 snapshot.truncated |= output.truncated;
16386 if output.timed_out {
16387 append_git_diagnostic(&mut snapshot, "git branch query timed out");
16388 } else if output.success {
16389 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16390 if !branch.is_empty() {
16391 snapshot.branch = Some(truncate_git_field(
16392 &branch,
16393 MAX_REPOSITORY_PATH_BYTES,
16394 &mut snapshot.truncated,
16395 ));
16396 }
16397 }
16398 }
16399 Err(error) => append_git_diagnostic(
16400 &mut snapshot,
16401 &format!("git branch query failed: {error}"),
16402 ),
16403 }
16404 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16405 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16406 snapshot.truncated |= output.truncated;
16407 if output.success && !output.timed_out {
16408 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16409 if !head.is_empty() {
16410 snapshot.branch = Some(format!("detached:{head}"));
16411 }
16412 }
16413 }
16414 }
16415
16416 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16417 return (snapshot, true);
16418 }
16419 match run_git_bounded_with_deadline(
16420 root,
16421 &[
16422 "status",
16423 "--porcelain=v1",
16424 "-z",
16425 "--renames",
16426 "--untracked-files=normal",
16427 "--",
16428 ".",
16429 ],
16430 GIT_OUTPUT_MAX_BYTES,
16431 deadline,
16432 )
16433 .await
16434 {
16435 Ok(output) => {
16436 snapshot.truncated |= output.truncated;
16437 if output.timed_out {
16438 append_git_diagnostic(&mut snapshot, "git status timed out");
16439 } else if output.success {
16440 parse_git_status(&output.stdout, &mut snapshot);
16441 } else {
16442 append_git_diagnostic(
16443 &mut snapshot,
16444 String::from_utf8_lossy(&output.stderr).trim(),
16445 );
16446 }
16447 }
16448 Err(error) => append_git_diagnostic(
16449 &mut snapshot,
16450 &format!("git status failed: {error}"),
16451 ),
16452 }
16453
16454 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16455 return (snapshot, true);
16456 }
16457 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16458 match run_git_bounded_with_deadline(
16459 root,
16460 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16461 16 * 1_024,
16462 deadline,
16463 )
16464 .await
16465 {
16466 Ok(output) => {
16467 snapshot.truncated |= output.truncated;
16468 if output.timed_out {
16469 append_git_diagnostic(&mut snapshot, "git log timed out");
16470 } else if output.success {
16471 parse_git_commits(&output.stdout, &mut snapshot);
16472 } else {
16473 let stderr = String::from_utf8_lossy(&output.stderr);
16474 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16475 append_git_diagnostic(&mut snapshot, stderr.trim());
16476 }
16477 }
16478 }
16479 Err(error) => append_git_diagnostic(
16480 &mut snapshot,
16481 &format!("git log failed: {error}"),
16482 ),
16483 }
16484
16485 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16486 return (snapshot, true);
16487 }
16488 match list_git_worktrees_with_deadline(root, deadline).await {
16489 Ok(worktrees) => {
16490 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16491 }
16492 Err(error) => append_git_diagnostic(
16493 &mut snapshot,
16494 &format!("git worktree list failed: {}", error.message),
16495 ),
16496 }
16497 (snapshot, false)
16498}
16499
16500fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16501 let mut remaining = output;
16502 while !remaining.is_empty() {
16503 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16504 snapshot.truncated = true;
16505 break;
16506 }
16507 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16508 snapshot.truncated = true;
16509 break;
16510 };
16511 let record = &remaining[..record_end];
16512 remaining = &remaining[record_end + 1..];
16513 let has_previous_path = record
16514 .get(..2)
16515 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16516 let previous_path_bytes = if has_previous_path {
16517 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16518 snapshot.truncated = true;
16519 break;
16520 };
16521 let previous = &remaining[..previous_end];
16522 remaining = &remaining[previous_end + 1..];
16523 Some(previous)
16524 } else {
16525 None
16526 };
16527 if record.len() < 4
16528 || record[2] != b' '
16529 || !is_git_status_code(record[0])
16530 || !is_git_status_code(record[1])
16531 {
16532 snapshot.truncated = true;
16533 continue;
16534 }
16535 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16536 snapshot.truncated = true;
16537 continue;
16538 };
16539 let previous_path = match previous_path_bytes {
16540 Some(previous) => {
16541 let Some(previous) = windows_repository_path_from_bytes(previous) else {
16542 snapshot.truncated = true;
16543 continue;
16544 };
16545 Some(previous)
16546 }
16547 None => None,
16548 };
16549 snapshot.status.push(GitStatusEntry {
16550 index_status: (record[0] as char).to_string(),
16551 worktree_status: (record[1] as char).to_string(),
16552 path,
16553 previous_path,
16554 });
16555 }
16556}
16557
16558fn is_git_status_code(byte: u8) -> bool {
16559 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16560}
16561
16562fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16563 for line in String::from_utf8_lossy(output).lines() {
16564 let Some((id, summary)) = line.split_once('\u{1f}') else {
16565 continue;
16566 };
16567 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16568 snapshot.truncated = true;
16569 break;
16570 }
16571 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16572 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16573 snapshot.recent_commits.push(GitCommitSummary { id, summary });
16574 }
16575}
16576
16577fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16578 if value.len() <= max_bytes {
16579 return value.to_owned();
16580 }
16581 *truncated = true;
16582 let mut boundary = max_bytes;
16583 while boundary > 0 && !value.is_char_boundary(boundary) {
16584 boundary -= 1;
16585 }
16586 value[..boundary].to_owned()
16587}
16588
16589fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16590 if message.is_empty() {
16591 return;
16592 }
16593 let normalized = message
16594 .chars()
16595 .map(|character| if character.is_control() { ' ' } else { character })
16596 .collect::<String>();
16597 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16598 if !diagnostic.is_empty() {
16599 diagnostic.push_str("; ");
16600 }
16601 diagnostic.push_str(normalized.trim());
16602 snapshot.diagnostic = Some(truncate_git_field(
16603 &diagnostic,
16604 GIT_DIAGNOSTIC_MAX_BYTES,
16605 &mut snapshot.truncated,
16606 ));
16607}
16608
16609async fn run_git_bounded(
16610 root: &str,
16611 arguments: &[&str],
16612 output_limit: usize,
16613) -> io::Result<GitCommandOutput> {
16614 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16615}
16616
16617async fn read_git_history_bounded(
16618 root: &str,
16619 path: Option<&RepositoryPath>,
16620 before: Option<&GitObjectId>,
16621 limit: u16,
16622) -> Result<GitHistoryPage, NodeFailure> {
16623 let path = path
16624 .map(|path| {
16625 path.as_utf8().ok_or_else(|| {
16626 failure(
16627 NodeFailureCode::InvalidRepositoryPath,
16628 "git history path must use UTF-8",
16629 )
16630 })
16631 })
16632 .transpose()?
16633 .unwrap_or(".");
16634 let count = (usize::from(limit) + 1).to_string();
16635 let start = before
16636 .map(|revision| format!("{}^", revision.as_str()))
16637 .unwrap_or_else(|| "HEAD".to_owned());
16638 let arguments = [
16639 "--no-pager",
16640 "log",
16641 "--no-decorate",
16642 "--date=iso-strict",
16643 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16644 "-n",
16645 count.as_str(),
16646 start.as_str(),
16647 "--",
16648 path,
16649 ];
16650 let output = run_git_bounded(root, &arguments, 256 * 1_024)
16651 .await
16652 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16653 if output.timed_out {
16654 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16655 }
16656 if !output.success {
16657 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16658 }
16659 let mut commits = parse_git_history(&output.stdout);
16660 let has_more = commits.len() > usize::from(limit) || output.truncated;
16661 commits.truncate(usize::from(limit));
16662 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16663 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16664}
16665
16666fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16667 String::from_utf8_lossy(output)
16668 .split('\u{1e}')
16669 .filter_map(|record| {
16670 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16671 if fields.len() != 9 {
16672 return None;
16673 }
16674 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16675 let parents = if fields[1].is_empty() {
16676 Vec::new()
16677 } else {
16678 fields[1]
16679 .split(' ')
16680 .map(|value| GitObjectId::new(value.to_owned()))
16681 .collect::<Result<Vec<_>, _>>()
16682 .ok()?
16683 };
16684 Some(GitCommitDetails {
16685 id,
16686 parents,
16687 subject: bounded_git_text(fields[2], 512),
16688 author_name: bounded_git_text(fields[3], 256),
16689 author_email: bounded_git_text(fields[4], 320),
16690 authored_at: bounded_git_text(fields[5], 64),
16691 committer_name: bounded_git_text(fields[6], 256),
16692 committer_email: bounded_git_text(fields[7], 320),
16693 committed_at: bounded_git_text(fields[8], 64),
16694 signature_status: GitSignatureStatus::NoSignature,
16695 signer: None,
16696 })
16697 })
16698 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16699 .collect()
16700}
16701
16702fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16703 let normalized = value
16704 .chars()
16705 .map(|character| if character.is_control() { ' ' } else { character })
16706 .collect::<String>();
16707 if normalized.len() <= max_bytes {
16708 return normalized;
16709 }
16710 let mut boundary = max_bytes;
16711 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16712 boundary -= 1;
16713 }
16714 normalized[..boundary].to_owned()
16715}
16716
16717async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16718 let path = request.path.as_ref().map(|path| {
16719 path.as_utf8()
16720 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16721 }).transpose()?;
16722 let mut arguments = match &request.mode {
16723 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16724 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16725 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16726 };
16727 if let GitDiffMode::Commit { revision } = &request.mode {
16728 arguments.push(revision.as_str());
16729 }
16730 arguments.push("--");
16731 if let Some(path) = path {
16732 arguments.push(path);
16733 }
16734 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16735 .await
16736 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16737 if output.timed_out {
16738 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16739 }
16740 if !output.success {
16741 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16742 }
16743 Ok(GitDiff {
16744 mode: request.mode,
16745 path: request.path,
16746 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16747 truncated: output.truncated,
16748 })
16749}
16750
16751async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16752 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16753 ResponseEnvelope { request_id: envelope.request_id, result }
16754}
16755
16756async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16757 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16758 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16759 }
16760 let read_only = matches!(
16761 &request,
16762 NodeRequest::Snapshot
16763 | NodeRequest::Resync { .. }
16764 | NodeRequest::BrowseHostDirectories { .. }
16765 | NodeRequest::InspectWorkspace { .. }
16766 | NodeRequest::ReadWorkspaceFile { .. }
16767 | NodeRequest::ReadGitHistory { .. }
16768 | NodeRequest::ReadGitDiff { .. }
16769 | NodeRequest::CatalogNativeSessions { .. }
16770 | NodeRequest::PageNativeSessions { .. }
16771 | NodeRequest::PreviewNativeSession { .. }
16772 | NodeRequest::PreviewSessionRecord { .. }
16773 );
16774 let _mutation_guard = if read_only {
16775 None
16776 } else {
16777 let guard = shared.mutation_gate.lock().await;
16778 if shared.shutdown.load(Ordering::Acquire) {
16779 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16780 }
16781 Some(guard)
16782 };
16783 match request {
16784 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16785 event_sequence: shared.current_sequence(),
16786 controller: shared.controller_state(),
16787 snapshot: shared.snapshot(),
16788 }),
16789 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16790 NodeRequest::ArmHarnessMcpReservation {
16791 reservation_id,
16792 activation_digest,
16793 spawn_spec,
16794 launch,
16795 expires_at_unix_ms,
16796 } => {
16797 shared.require_controller(connection_id, role)?;
16798 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16799 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16800 })?;
16801 let expires_at_unix_ms = registry
16802 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16803 .await
16804 .map_err(harness_mcp_failure)?;
16805 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16806 }
16807 NodeRequest::SpawnSpecWithHarnessMcp {
16808 reservation_id,
16809 activation_digest,
16810 spec,
16811 deadline_unix_ms,
16812 } => {
16813 shared.require_controller(connection_id, role)?;
16814 let receipt = shared
16815 .spawn_from_spec_with_harness_mcp(
16816 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16817 )
16818 .await?;
16819 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16820 }
16821 NodeRequest::ActivateHarnessMcpReservation {
16822 reservation_id,
16823 activation_digest,
16824 record_id,
16825 session,
16826 } => {
16827 shared.require_controller(connection_id, role)?;
16828 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16829 current.instance_id == session.session.instance_id
16830 && current.generation == session.session.generation
16831 }).and_then(|current| current.process_id).ok_or_else(|| {
16832 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16833 })?;
16834 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16835 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16836 })?.activate(
16837 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16838 ).map_err(harness_mcp_failure)?;
16839 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16840 }
16841 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16842 shared.require_controller(connection_id, role)?;
16843 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16844 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16845 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16846 if let Some(instance_id) = instance_id {
16847 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16848 }
16849 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16850 }
16851 NodeRequest::PutHarnessMcpReplyChunk {
16852 reservation_id, activation_digest, record_id, session, call_id,
16853 offset, final_chunk, chunk_hex,
16854 } => {
16855 shared.require_controller(connection_id, role)?;
16856 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16857 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16858 })?.put_reply_chunk(
16859 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16860 offset, final_chunk, &chunk_hex,
16861 ).map_err(harness_mcp_failure)?;
16862 Ok(NodeResponse::ReplyChunkAccepted {
16863 reservation_id, activation_digest, record_id, session, call_id,
16864 next_offset, completed,
16865 })
16866 }
16867 NodeRequest::RejectHarnessMcpCall {
16868 reservation_id, activation_digest, record_id, session, call_id, reason,
16869 } => {
16870 shared.require_controller(connection_id, role)?;
16871 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16872 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16873 })?.reject_call(
16874 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16875 ).map_err(harness_mcp_failure)?;
16876 Ok(NodeResponse::CallRejected {
16877 reservation_id, activation_digest, record_id, session, call_id,
16878 })
16879 }
16880 NodeRequest::BeginDeliveryStage { manifest } => {
16881 shared.require_controller(connection_id, role)?;
16882 shared.begin_delivery_stage(manifest)
16883 }
16884 NodeRequest::PutDeliveryBlobChunk {
16885 stage_id,
16886 blob_digest,
16887 offset,
16888 chunk_hex,
16889 } => {
16890 shared.require_controller(connection_id, role)?;
16891 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16892 }
16893 NodeRequest::CommitDeliveryStage { stage_id } => {
16894 shared.require_controller(connection_id, role)?;
16895 let receipt = shared.commit_delivery_stage(stage_id)?;
16896 Ok(NodeResponse::DeliveryCommitted { receipt })
16897 }
16898 NodeRequest::AbortDeliveryStage { stage_id } => {
16899 shared.require_controller(connection_id, role)?;
16900 shared.abort_delivery_stage(&stage_id)?;
16901 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16902 }
16903 NodeRequest::BrowseHostDirectories { directory, after } => {
16904 let listing = shared.browse_host_directories(directory, after).await?;
16905 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16906 }
16907 NodeRequest::InspectWorkspace { workspace_id } => {
16908 let inspection = shared.inspect_workspace(workspace_id).await?;
16909 Ok(NodeResponse::WorkspaceInspected { inspection })
16910 }
16911 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16912 let file = shared.read_workspace_file(workspace_id, path).await?;
16913 Ok(NodeResponse::WorkspaceFileRead { file })
16914 }
16915 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16916 shared.require_controller(connection_id, role)?;
16917 let file = shared
16918 .write_workspace_file(workspace_id, path, expected_revision, text)
16919 .await?;
16920 Ok(NodeResponse::WorkspaceFileWritten { file })
16921 }
16922 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16923 shared.require_controller(connection_id, role)?;
16924 let file = shared.create_workspace_file(workspace_id, path).await?;
16925 Ok(NodeResponse::WorkspaceFileCreated { file })
16926 }
16927 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16928 shared.require_controller(connection_id, role)?;
16929 let entry = shared
16930 .create_workspace_directory(workspace_id.clone(), path)
16931 .await?;
16932 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16933 }
16934 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16935 if role != ClientRole::Operator {
16936 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16937 }
16938 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16939 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16940 }
16941 let page = shared
16942 .read_git_history(workspace_id.clone(), path, before, limit)
16943 .await?;
16944 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16945 }
16946 NodeRequest::ReadGitDiff { workspace_id, request } => {
16947 if role != ClientRole::Operator {
16948 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16949 }
16950 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16951 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16952 }
16953 NodeRequest::CatalogNativeSessions { route, limit } => {
16954 if role != ClientRole::Operator {
16955 return Err(failure(
16956 NodeFailureCode::ObserverReadOnly,
16957 "native session catalog requires an operator connection",
16958 ));
16959 }
16960 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16961 return Err(failure(
16962 NodeFailureCode::InvalidRequest,
16963 "native session catalog limit is invalid",
16964 ));
16965 }
16966 let (entries, summary) = shared
16967 .catalog_native_sessions(route.clone(), limit)
16968 .await?;
16969 Ok(NodeResponse::NativeSessionsCataloged {
16970 route,
16971 entries,
16972 summary: Some(summary),
16973 })
16974 }
16975 NodeRequest::PageNativeSessions {
16976 route,
16977 window,
16978 catalog_revision,
16979 recent_cutoff_unix_ms,
16980 after_selection_id,
16981 limit,
16982 } => {
16983 if role != ClientRole::Operator {
16984 return Err(failure(
16985 NodeFailureCode::ObserverReadOnly,
16986 "native session catalog paging requires an operator connection",
16987 ));
16988 }
16989 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16990 || after_selection_id
16991 .as_deref()
16992 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16993 {
16994 return Err(failure(
16995 NodeFailureCode::InvalidRequest,
16996 "native session catalog page request is invalid",
16997 ));
16998 }
16999 let page = shared
17000 .page_native_sessions(
17001 route.clone(),
17002 window,
17003 catalog_revision,
17004 recent_cutoff_unix_ms,
17005 after_selection_id,
17006 limit,
17007 )
17008 .await?;
17009 Ok(NodeResponse::NativeSessionsPaged {
17010 route,
17011 page,
17012 })
17013 }
17014 NodeRequest::PreviewNativeSession {
17015 selection,
17016 message_limit,
17017 } => {
17018 if role != ClientRole::Operator {
17019 return Err(failure(
17020 NodeFailureCode::ObserverReadOnly,
17021 "native session preview requires an operator connection",
17022 ));
17023 }
17024 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
17025 .contains(&message_limit)
17026 || selection.validate().is_err()
17027 {
17028 return Err(failure(
17029 NodeFailureCode::InvalidRequest,
17030 "native session preview request is invalid",
17031 ));
17032 }
17033 let preview = shared
17034 .preview_native_session(selection.clone(), message_limit)
17035 .await?;
17036 Ok(NodeResponse::NativeSessionPreviewed {
17037 selection,
17038 preview,
17039 })
17040 }
17041 NodeRequest::IndexNativeSession {
17042 selection,
17043 display_name,
17044 } => {
17045 shared.require_controller(connection_id, role)?;
17046 let record = shared
17047 .index_native_session(selection.clone(), display_name)
17048 .await?;
17049 Ok(NodeResponse::NativeSessionIndexed { selection, record })
17050 }
17051 NodeRequest::PreviewSessionRecord {
17052 record_id,
17053 message_limit,
17054 } => {
17055 if role != ClientRole::Operator {
17056 return Err(failure(
17057 NodeFailureCode::ObserverReadOnly,
17058 "native session preview requires an operator connection",
17059 ));
17060 }
17061 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
17062 .contains(&message_limit)
17063 {
17064 return Err(failure(
17065 NodeFailureCode::InvalidRequest,
17066 "native session preview request is invalid",
17067 ));
17068 }
17069 let record = shared.record(&record_id)?;
17070 let working_directory = shared.workspace_root(&record.workspace_id)?;
17071 if !platform::roots_equal(
17072 &working_directory,
17073 windows_path_text(&record.canonical_root),
17074 ) {
17075 return Err(failure(
17076 NodeFailureCode::SessionRecordConflict,
17077 "managed session workspace root changed; refusing to preview another directory",
17078 ));
17079 }
17080 let identity = record.provider_session.clone().ok_or_else(|| {
17081 failure(
17082 NodeFailureCode::BackendOperationFailed,
17083 "native session preview is unavailable for this session record",
17084 )
17085 })?;
17086 if identity.key != ProviderSessionKey::SessionId {
17087 return Err(failure(
17088 NodeFailureCode::BackendOperationFailed,
17089 "native session preview is unavailable for this session identity",
17090 ));
17091 }
17092 let preview = shared
17093 .preview_native_session_by_session_id(
17094 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
17095 )
17096 .await?;
17097 if preview.session_id != identity.id {
17098 return Err(failure(
17099 NodeFailureCode::SessionRecordConflict,
17100 "native session preview identity changed while loading",
17101 ));
17102 }
17103 shared.revalidate_session_record_preview(&record, &identity)?;
17104 let preview: gate4agent_types::SessionRecordPreview = preview.into();
17105 shared.publish(NodeEvent::SessionRecordHistorySummarized {
17106 record_id: record_id.clone(),
17107 summary: SessionHistorySummaryV1::from(&preview),
17108 });
17109 Ok(NodeResponse::SessionRecordPreviewed {
17110 record_id,
17111 preview,
17112 })
17113 }
17114 NodeRequest::AcquireController { lease_ms } => {
17115 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
17116 Ok(NodeResponse::Controller { controller: Some(controller) })
17117 }
17118 NodeRequest::ReleaseController => {
17119 shared.release_controller(connection_id);
17120 Ok(NodeResponse::Controller { controller: shared.controller_state() })
17121 }
17122 NodeRequest::RegisterWorkspace { workspace_id, root } => {
17123 shared.require_controller(connection_id, role)?;
17124 let root = require_windows_path(root)?;
17125 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
17126 let workspace = shared.register_workspace(workspace_id, root).await?;
17127 Ok(NodeResponse::WorkspaceRegistered { workspace })
17128 }
17129 NodeRequest::CreateStandaloneWorkspace {
17130 workspace_id,
17131 root,
17132 initial_branch,
17133 } => {
17134 shared.require_controller(connection_id, role)?;
17135 let root = require_windows_path(root)?;
17136 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
17137 let workspace = shared
17138 .create_standalone_workspace(workspace_id, root, initial_branch)
17139 .await?;
17140 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
17141 }
17142 NodeRequest::UnregisterWorkspace { workspace_id } => {
17143 shared.require_controller(connection_id, role)?;
17144 shared.reject_managed_reservation(Some(&workspace_id), None)?;
17145 shared.unregister_workspace(&workspace_id)?;
17146 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
17147 }
17148 NodeRequest::CreateWorktree {
17149 source_workspace_id,
17150 workspace_id,
17151 target_root,
17152 branch,
17153 base,
17154 } => {
17155 shared.require_controller(connection_id, role)?;
17156 let target_root = require_windows_path(target_root)?;
17157 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
17158 let (worktree, workspace) = shared
17159 .create_worktree(
17160 source_workspace_id,
17161 workspace_id,
17162 target_root,
17163 branch,
17164 base,
17165 )
17166 .await?;
17167 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
17168 }
17169 NodeRequest::RemoveWorktree {
17170 source_workspace_id,
17171 target_root,
17172 } => {
17173 shared.require_controller(connection_id, role)?;
17174 let native_target_root = require_windows_path(target_root.clone())?;
17175 shared.reject_managed_reservation(None, Some(&native_target_root))?;
17176 let workspace_id = shared
17177 .remove_worktree(source_workspace_id, native_target_root)
17178 .await?;
17179 Ok(NodeResponse::WorktreeRemoved {
17180 target_root,
17181 workspace_id,
17182 })
17183 }
17184 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
17185 shared.require_controller(connection_id, role)?;
17186 if !terminal_size.is_valid() {
17187 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17188 }
17189 if let Some(prompt) = initial_prompt.as_deref() {
17190 validate_node_text("spawn initial prompt", prompt)?;
17191 }
17192 match shared
17193 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
17194 .await
17195 {
17196 Ok(session) => {
17197 shared.mark_spawn_provisional(session.clone());
17198 Ok(NodeResponse::SpawnAccepted { session })
17199 }
17200 Err(err) => {
17201 tracing::warn!(
17202 %provider,
17203 %workspace_id,
17204 ?mode,
17205 code = ?err.code,
17206 message = %err.message,
17207 "session spawn refused"
17208 );
17209 Err(err)
17210 }
17211 }
17212 }
17213 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
17214 shared.require_controller(connection_id, role)?;
17215 if !terminal_size.is_valid() {
17216 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17217 }
17218 if let Some(prompt) = initial_prompt.as_deref() {
17219 validate_node_text("spawn initial prompt", prompt)?;
17220 }
17221 let plan = backend_paths::plan_launch_options(
17222 provider.as_str(),
17223 mode,
17224 options.model.is_some(),
17225 options.effort.is_some(),
17226 )
17227 .map_err(|r| {
17228 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
17229 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
17230 })?;
17231 let selection = spawn_selection(&options)?;
17232 let nick_model = selection.as_ref().map(|s| s.model.clone());
17233 let pty_selection = match plan {
17234 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
17235 _ => None,
17236 };
17237 let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
17238 SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
17239 SPAWN_NICK_MODEL.scope(nick_model, SPAWN_SESSION_OPTIONS
17240 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)))
17241 .await
17242 }).await
17243 }).await;
17244 match spawned {
17245 Ok(session) => {
17246 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
17247 shared
17248 .pending_acp_options
17249 .lock()
17250 .unwrap_or_else(|p| p.into_inner())
17251 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17252 }
17253 shared.mark_spawn_provisional(session.clone());
17254 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17255 Ok(NodeResponse::SpawnAccepted { session })
17256 }
17257 Err(err) => {
17258 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17259 Err(err)
17260 }
17261 }
17262 }
17263 NodeRequest::SpawnSpec { spec } => {
17264 shared.require_controller(connection_id, role)?;
17265 let receipt = shared.spawn_from_spec(spec).await?;
17266 Ok(NodeResponse::SpawnSpecAccepted { receipt })
17267 }
17268 NodeRequest::SpawnManagedWorktree { request } => {
17269 shared.require_controller(connection_id, role)?;
17270 let receipt = shared.spawn_managed_worktree(request).await?;
17271 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17272 }
17273 NodeRequest::SpawnManagedWorktreeV2 { request } => {
17274 shared.require_controller(connection_id, role)?;
17275 let receipt = shared.spawn_managed_worktree_v2(request).await?;
17276 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17277 }
17278 NodeRequest::CleanupManagedWorktree { lease_id } => {
17279 shared.require_controller(connection_id, role)?;
17280 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17281 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17282 }
17283 NodeRequest::DiscoverHistory { session, limit } => {
17284 controlled_session(shared, connection_id, role, &session)?;
17285 let candidates = shared.discover_history(&session, limit).await?;
17286 Ok(NodeResponse::HistoryDiscovered {
17287 session,
17288 candidates,
17289 })
17290 }
17291 NodeRequest::LoadHistory {
17292 session,
17293 candidate_id,
17294 } => {
17295 controlled_session(shared, connection_id, role, &session)?;
17296 let loaded = shared
17297 .load_history(&session, candidate_id)
17298 .await?;
17299 Ok(NodeResponse::HistoryLoaded {
17300 session,
17301 session_id: loaded.session_id,
17302 message_count: loaded.message_count,
17303 completed_turn_count: loaded.completed_turn_count,
17304 })
17305 }
17306 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17307 shared.require_controller(connection_id, role)?;
17308 let context = shared
17309 .export_context_pack_for_session_record(&record_id, &session, false)
17310 .await?;
17311 Ok(NodeResponse::ContextPackForSessionRecordExported {
17312 record_id,
17313 session,
17314 context,
17315 })
17316 }
17317 NodeRequest::ExportContextPack { session } => {
17318 controlled_session(shared, connection_id, role, &session)?;
17319 let context = shared.export_context_pack(&session).await?;
17320 Ok(NodeResponse::ContextPackExported { context })
17321 }
17322 NodeRequest::ForgetContextPack { context_id } => {
17323 shared.require_controller(connection_id, role)?;
17324 shared.forget_context_pack(&context_id)?;
17325 Ok(NodeResponse::ContextPackForgotten { context_id })
17326 }
17327 NodeRequest::ResolveDurableContextPack { context_id } => {
17328 let context = shared
17332 .context_catalog
17333 .read()
17334 .unwrap_or_else(|poisoned| poisoned.into_inner())
17335 .get(&context_id)
17336 .map(|pack| pack.receipt().clone())
17337 .ok_or_else(|| {
17338 failure(
17339 NodeFailureCode::UnknownContextPack,
17340 "context pack is not present",
17341 )
17342 })?;
17343 Ok(NodeResponse::DurableContextPackResolved { context })
17344 }
17345 NodeRequest::ReadContextPack { digest } => {
17346 let (id, byte_len, bytes) = shared
17351 .context_catalog
17352 .read()
17353 .unwrap_or_else(|poisoned| poisoned.into_inner())
17354 .get_by_digest(&digest)
17355 .map(|pack| {
17356 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17357 })
17358 .ok_or_else(|| {
17359 failure(
17360 NodeFailureCode::UnknownContextPack,
17361 &format!("context pack {digest} not on this node"),
17362 )
17363 })?;
17364 tracing::debug!(
17365 digest = digest.as_str(),
17366 byte_len,
17367 "read context pack bytes",
17368 );
17369 Ok(NodeResponse::ContextPackBytesRead {
17370 pack: ContextPackBytesRead {
17371 digest,
17372 id,
17373 byte_len,
17374 bytes,
17375 },
17376 })
17377 }
17378 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17379 let provider = controlled_session(shared, connection_id, role, &session)?;
17380 if let Some(prompt) = initial_prompt.as_deref() {
17381 validate_node_text("resume initial prompt", prompt)?;
17382 }
17383 let runtime_requirement = if initial_prompt.is_some() {
17384 ProviderRuntimeRequirement::ResumeWithPrompt
17385 } else {
17386 ProviderRuntimeRequirement::Resume
17387 };
17388 shared.require_session_runtime_policy(&session, runtime_requirement)?;
17389 let runtime_policy = shared
17390 .admit_provider_runtime(&provider, runtime_requirement)
17391 .await?;
17392 let working_directory = shared.bound_workspace_root(&session)?;
17393 let request = ResumeLaunchRequest {
17394 working_directory,
17395 terminal_size,
17396 initial_prompt,
17397 };
17398 request
17399 .validate()
17400 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17401 let command = shared.prepare_command(ControlCommand::Resume {
17402 instance_id: session.session.instance_id,
17403 target: ResumeTarget::CurrentProvider,
17404 runtime_policy,
17405 request,
17406 });
17407 shared.arm_resume(&session, command.id, runtime_policy)?;
17408 let dispatch = shared.dispatch_envelope(command);
17409 if let Err(error) = dispatch {
17410 shared.clear_armed_resume(&session);
17411 return Err(error);
17412 }
17413 Ok(NodeResponse::Accepted)
17414 }
17415 NodeRequest::RenameSessionRecord {
17416 record_id,
17417 display_name,
17418 } => {
17419 shared.require_controller(connection_id, role)?;
17420 let record = shared.rename_session_record(&record_id, display_name)?;
17421 Ok(NodeResponse::SessionRecordUpdated { record })
17422 }
17423 NodeRequest::SetSessionTask {
17424 record_id,
17425 expected_revision,
17426 target,
17427 } => {
17428 shared.require_controller(connection_id, role)?;
17429 let record = shared.set_session_task(&record_id, expected_revision, target)?;
17430 Ok(NodeResponse::SessionRecordUpdated { record })
17431 }
17432 NodeRequest::IndexProviderSession {
17433 workspace_id,
17434 provider,
17435 identity,
17436 display_name,
17437 } => {
17438 shared.require_controller(connection_id, role)?;
17439 let record = shared.index_provider_session(
17440 workspace_id,
17441 provider,
17442 identity,
17443 display_name,
17444 )?;
17445 Ok(NodeResponse::ProviderSessionIndexed { record })
17446 }
17447 NodeRequest::ResumeSessionRecord {
17448 record_id,
17449 terminal_size,
17450 initial_prompt,
17451 } => {
17452 shared.require_controller(connection_id, role)?;
17453 if let Some(prompt) = initial_prompt.as_deref() {
17454 validate_node_text("managed resume initial prompt", prompt)?;
17455 }
17456 let (record, session) = shared
17457 .resume_session_record(
17458 &record_id,
17459 terminal_size,
17460 initial_prompt,
17461 )
17462 .await?;
17463 Ok(NodeResponse::SessionRecordResumed { record, session })
17464 }
17465 NodeRequest::ForgetSessionRecord { record_id } => {
17466 shared.require_controller(connection_id, role)?;
17467 shared.forget_session_record(&record_id).await?;
17468 Ok(NodeResponse::SessionRecordForgotten { record_id })
17469 }
17470 NodeRequest::Prompt { session, text } => {
17471 let agent_id = controlled_session(shared, connection_id, role, &session)?;
17472 validate_node_text("prompt", &text)?;
17473 shared.require_session_runtime_policy(
17474 &session,
17475 ProviderRuntimeRequirement::SemanticPrompt,
17476 )?;
17477 let framing = prompt_framing(&agent_id);
17478 shared
17479 .dispatch_input_bounded(
17480 &session,
17481 InputAction::SubmitPrompt(PromptPayload { text, framing }),
17482 )
17483 .await?;
17484 Ok(NodeResponse::Accepted)
17485 }
17486 NodeRequest::Paste { session, text } => {
17487 controlled_session(shared, connection_id, role, &session)?;
17488 validate_node_text("paste", &text)?;
17489 shared.require_session_runtime_policy(
17490 &session,
17491 ProviderRuntimeRequirement::SemanticPrompt,
17492 )?;
17493 shared
17494 .dispatch_input_bounded(
17495 &session,
17496 InputAction::InsertDraft(PromptPayload {
17497 text,
17498 framing: PromptFraming::BracketedPaste,
17499 }),
17500 )
17501 .await?;
17502 Ok(NodeResponse::Accepted)
17503 }
17504 NodeRequest::Input { session, text } => {
17505 controlled_session(shared, connection_id, role, &session)?;
17506 validate_node_text("terminal input", &text)?;
17507 shared
17508 .dispatch_input_bounded(
17509 &session,
17510 InputAction::TerminalText(TerminalText { text }),
17511 )
17512 .await?;
17513 Ok(NodeResponse::Accepted)
17514 }
17515 NodeRequest::TerminalBytes { session, bytes } => {
17516 controlled_session(shared, connection_id, role, &session)?;
17517 shared.sessionize_spawn(session.session.instance_id);
17518 let action = terminal_bytes_action(bytes)?;
17519 shared
17520 .dispatch_input_bounded(&session, action)
17521 .await?;
17522 Ok(NodeResponse::Accepted)
17523 }
17524 NodeRequest::TerminalControl { session, control } => {
17525 controlled_session(shared, connection_id, role, &session)?;
17526 shared
17527 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17528 .await?;
17529 Ok(NodeResponse::Accepted)
17530 }
17531 NodeRequest::Resize { session, size } => {
17532 controlled_session(shared, connection_id, role, &session)?;
17533 shared.sessionize_spawn(session.session.instance_id);
17534 if !size.is_valid() {
17535 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17536 }
17537 shared.dispatch_resize_bounded(&session, size).await?;
17538 Ok(NodeResponse::Accepted)
17539 }
17540 NodeRequest::Interrupt { session } => {
17541 controlled_session(shared, connection_id, role, &session)?;
17542 shared
17543 .dispatch_input_bounded(
17544 &session,
17545 InputAction::TerminalControl(TerminalControl::Interrupt),
17546 )
17547 .await?;
17548 Ok(NodeResponse::Accepted)
17549 }
17550 NodeRequest::Stop { session, force } => {
17551 controlled_session(shared, connection_id, role, &session)?;
17552 shared.sessionize_spawn(session.session.instance_id);
17553 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17554 shared.retire_session_grok_leader(session.session.instance_id);
17555 Ok(NodeResponse::Accepted)
17556 }
17557 NodeRequest::Remove { session } => {
17558 controlled_session(shared, connection_id, role, &session)?;
17559 shared.remove_session(&session).await?;
17560 Ok(NodeResponse::Accepted)
17561 }
17562 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17563 shared.require_controller(connection_id, role)?;
17564 let snapshot = shared.require_acp_session(&session)?;
17565 let interaction = snapshot
17566 .provider
17567 .interactions
17568 .iter()
17569 .find(|interaction| {
17570 interaction_correlation(
17571 session.session.instance_id,
17572 session.session.generation,
17573 interaction.id.0,
17574 ) == correlation_id
17575 })
17576 .ok_or_else(|| failure(
17577 NodeFailureCode::InvalidRequest,
17578 "unknown interaction correlation",
17579 ))?;
17580 if interaction.status != ProviderInteractionStatus::Pending {
17581 return Err(failure(
17582 NodeFailureCode::BackendOperationFailed,
17583 "interaction has already been resolved",
17584 ));
17585 }
17586 response
17587 .validate_for(interaction.interaction_kind)
17588 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17589 let interaction_id = interaction.id;
17590 let response_kind = response.kind();
17591 let response_outcome = response.outcome();
17592 shared.dispatch(ControlCommand::ResolveInteraction {
17593 instance_id: session.session.instance_id,
17594 generation: session.session.generation,
17595 interaction_id,
17596 response,
17597 })?;
17598 tracing::info!(
17604 node_id = %shared.node_id,
17605 session = ?session.session,
17606 correlation_id = %correlation_id,
17607 interaction_id = interaction_id.0,
17608 response_kind = ?response_kind,
17609 outcome = ?response_outcome,
17610 "interaction resolved",
17611 );
17612 Ok(NodeResponse::Accepted)
17613 }
17614 NodeRequest::SetSessionMode { session, mode_id } => {
17615 shared.require_controller(connection_id, role)?;
17616 shared.require_acp_session(&session)?;
17617 shared.dispatch(ControlCommand::SetSessionMode {
17618 instance_id: session.session.instance_id,
17619 mode_id,
17620 })?;
17621 Ok(NodeResponse::Accepted)
17622 }
17623 NodeRequest::SetSessionConfigOption {
17624 session,
17625 option_id,
17626 value_json,
17627 } => {
17628 shared.require_controller(connection_id, role)?;
17629 shared.require_acp_session(&session)?;
17630 shared.dispatch(ControlCommand::SetSessionConfigOption {
17631 instance_id: session.session.instance_id,
17632 option_id,
17633 value_json,
17634 })?;
17635 Ok(NodeResponse::Accepted)
17636 }
17637 NodeRequest::SetSessionModel { session, model_id } => {
17638 shared.require_controller(connection_id, role)?;
17639 shared.require_acp_session(&session)?;
17640 shared.dispatch(ControlCommand::SetSessionModel {
17641 instance_id: session.session.instance_id,
17642 model_id,
17643 })?;
17644 Ok(NodeResponse::Accepted)
17645 }
17646 NodeRequest::Shutdown => {
17647 shared.require_controller(connection_id, role)?;
17648 shared.begin_shutdown_locked().await?;
17649 Ok(NodeResponse::ShuttingDown)
17650 }
17651 }
17652}
17653
17654fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17655 shared.require_controller(connection_id, role)?;
17656 shared.validate_address(session)
17657}
17658
17659fn select_history_candidate(
17665 candidates: &[HistoryCandidateSummary],
17666 provider_session_id: Option<&str>,
17667) -> Result<String, NodeFailure> {
17668 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17669 Some(id) => candidate.session_id_hint == id,
17670 None => true,
17671 });
17672 let candidate = matches.next().ok_or_else(|| {
17673 failure(
17674 NodeFailureCode::SessionRecordConflict,
17675 if provider_session_id.is_some() {
17676 "no history candidate matches the managed provider session"
17677 } else {
17678 "no history candidate exists for the managed session"
17679 },
17680 )
17681 })?;
17682 if matches.next().is_some() {
17683 return Err(failure(
17684 NodeFailureCode::SessionRecordConflict,
17685 if provider_session_id.is_some() {
17686 "history identity is ambiguous for the managed provider session"
17687 } else {
17688 "history is ambiguous without a verified provider session identity"
17689 },
17690 ));
17691 }
17692 Ok(candidate.id.clone())
17693}
17694
17695fn resolved_spawn_profile_summary(
17696 profile: &SpawnProfileDefaults,
17697 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17698) -> Option<SpawnProfileSummary> {
17699 let environment_profile = match profile.environment_profile_id.as_ref() {
17700 None => None,
17701 Some(profile_id) => {
17702 let binding = environment_profiles.get(profile_id)?;
17703 if binding.provider != profile.provider
17704 || binding.native_profile_id(profile.mode).is_none()
17705 {
17706 return None;
17707 }
17708 Some(ResolvedEnvironmentProfileReceipt {
17709 profile_id: binding.id.clone(),
17710 profile_revision: binding.revision.clone(),
17711 network_allowlist: None,
17712 browser_profile_id: None,
17713 })
17714 }
17715 };
17716 Some(SpawnProfileSummary {
17717 id: profile.profile_id.clone(),
17718 revision: profile.revision.clone(),
17719 environment_profile,
17720 })
17721}
17722
17723fn session_record_export_target_matches(
17742 expected: &ManagedSessionRecord,
17743 current: &ManagedSessionRecord,
17744 session: &SessionAddress,
17745 allow_clean_detachment: bool,
17746) -> bool {
17747 if current == expected && current.active_session.as_ref() == Some(session) {
17748 return current.state == ManagedSessionState::Live;
17749 }
17750 allow_clean_detachment
17751 && current.state == ManagedSessionState::Dormant
17752 && current.active_session.is_none()
17753 && current.record_id == expected.record_id
17754 && current.display_name == expected.display_name
17755 && current.provider == expected.provider
17756 && current.mode == expected.mode
17757 && current.workspace_id == expected.workspace_id
17758 && current.canonical_root == expected.canonical_root
17759 && current.provider_session == expected.provider_session
17760 && current.environment_profile == expected.environment_profile
17761 && current.bundle == expected.bundle
17762 && current.context_id == expected.context_id
17763 && current.context == expected.context
17764 && current.exported_context == expected.exported_context
17765 && current.task_binding == expected.task_binding
17766 && current.created_at_unix_ms == expected.created_at_unix_ms
17767 && current.last_error == expected.last_error
17768}
17769
17770fn session_record_context_export_binding_is_exact(
17771 expected: &ManagedSessionRecord,
17772 current: &ManagedSessionRecord,
17773 identity: Option<&ProviderSessionIdentity>,
17774 session: &SessionAddress,
17775 binding: &SessionBinding,
17776 provider: &AgentId,
17777 current_root: &str,
17778 allow_clean_detachment: bool,
17779) -> bool {
17780 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17781 && current.provider_session.as_ref() == identity
17782 && current.workspace_id == session.workspace_id
17783 && provider == ¤t.provider
17784 && binding.workspace_id == session.workspace_id
17785 && binding.generation == session.session.generation
17786 && binding.record_id.as_ref() == Some(¤t.record_id)
17787 && platform::roots_equal(
17788 current_root,
17789 windows_path_text(¤t.canonical_root),
17790 )
17791}
17792
17793fn session_record_context_export_source_is_usable(
17794 record: &ManagedSessionRecord,
17795 status: &SessionStatus,
17796 allow_clean_detachment: bool,
17797) -> bool {
17798 (record.state == ManagedSessionState::Live
17799 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17800 && context_pack_source_status_is_usable(status)
17801}
17802
17803fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17804 PromptFraming::BracketedPaste
17805}
17806
17807fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17808 if text.len() > MAX_NODE_TEXT_BYTES {
17809 return Err(failure(
17810 NodeFailureCode::InvalidRequest,
17811 &format!(
17812 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17813 text.len(),
17814 ),
17815 ));
17816 }
17817 Ok(())
17818}
17819
17820fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17821 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17822 return Err(failure(
17823 NodeFailureCode::InvalidRequest,
17824 &format!(
17825 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17826 bytes.len(),
17827 ),
17828 ));
17829 }
17830 Ok(())
17831}
17832
17833fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17834 validate_terminal_bytes(&bytes)?;
17835 Ok(InputAction::TerminalBytes(bytes))
17836}
17837
17838fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17839 if root.is_empty()
17840 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17841 || root.chars().any(char::is_control)
17842 {
17843 return Err(failure(
17844 NodeFailureCode::InvalidWorkspaceRoot,
17845 &format!(
17846 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17847 ),
17848 ));
17849 }
17850 Ok(())
17851}
17852
17853fn canonical_standalone_workspace_candidate(
17854 workspace_id: &WorkspaceId,
17855 root: &str,
17856) -> Result<String, NodeServerError> {
17857 let path = Path::new(root);
17858 match std::fs::symlink_metadata(path) {
17859 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17860 .map(|workspace| workspace.canonical_root),
17861 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17862 if !path.is_absolute() {
17863 return Err(NodeServerError::InvalidWorkspaceRoot {
17864 workspace_id: workspace_id.clone(),
17865 path: root.to_owned(),
17866 message: "path is not absolute".to_owned(),
17867 });
17868 }
17869 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17870 workspace_id: workspace_id.clone(),
17871 path: root.to_owned(),
17872 message: "path has no standalone directory name".to_owned(),
17873 })?;
17874 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17875 workspace_id: workspace_id.clone(),
17876 path: root.to_owned(),
17877 message: "path has no parent directory".to_owned(),
17878 })?;
17879 let canonical = std::fs::canonicalize(parent)
17880 .map(|parent| parent.join(name))
17881 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17882 workspace_id: workspace_id.clone(),
17883 path: root.to_owned(),
17884 message: error.to_string(),
17885 })?;
17886 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17887 NodeServerError::InvalidWorkspaceRoot {
17888 workspace_id: workspace_id.clone(),
17889 path: path.to_string_lossy().into_owned(),
17890 message: "canonical path is not valid Unicode".to_owned(),
17891 }
17892 })?;
17893 let canonical_root = platform::normalize_canonical_root(canonical_root);
17894 validate_workspace_root(workspace_id, &canonical_root)?;
17895 Ok(canonical_root)
17896 }
17897 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17898 workspace_id: workspace_id.clone(),
17899 path: root.to_owned(),
17900 message: error.to_string(),
17901 }),
17902 }
17903}
17904
17905fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17906 let Some(revision) = revision else {
17907 return Ok(());
17908 };
17909 if revision.is_empty()
17910 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17911 || revision.chars().any(char::is_control)
17912 {
17913 return Err(failure(
17914 NodeFailureCode::InvalidRequest,
17915 &format!(
17916 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17917 ),
17918 ));
17919 }
17920 Ok(())
17921}
17922
17923fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17924 let code = match error.kind {
17925 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17926 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17927 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17928 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17929 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17930 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17931 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17932 };
17933 failure(code, &error.message)
17934}
17935
17936fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17937 let code = match error.kind {
17938 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17939 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17940 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17941 NodeFailureCode::BackendOperationFailed
17942 }
17943 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17944 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17945 }
17946 };
17947 failure(code, error.message)
17948}
17949
17950fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17951 let (code, message) = match error.kind {
17952 GitWorktreeErrorKind::Invalid => (
17953 NodeFailureCode::InvalidRequest,
17954 "managed Git input is invalid",
17955 ),
17956 GitWorktreeErrorKind::NotRepository => (
17957 NodeFailureCode::NotGitRepository,
17958 "managed source is not a Git repository",
17959 ),
17960 GitWorktreeErrorKind::Conflict => (
17961 NodeFailureCode::WorktreeConflict,
17962 "managed Git identity conflicts with existing repository state",
17963 ),
17964 GitWorktreeErrorKind::Protected => (
17965 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17966 "managed worktree ownership validation failed",
17967 ),
17968 GitWorktreeErrorKind::Dirty => (
17969 NodeFailureCode::WorktreeDirty,
17970 "managed worktree contains uncommitted changes",
17971 ),
17972 GitWorktreeErrorKind::Locked => (
17973 NodeFailureCode::WorktreeLocked,
17974 "managed worktree is locked",
17975 ),
17976 GitWorktreeErrorKind::Failed => (
17977 NodeFailureCode::BackendOperationFailed,
17978 "managed Git operation failed",
17979 ),
17980 };
17981 NodeFailure { code, message: message.to_owned() }
17982}
17983
17984fn managed_spawn_receipt(
17985 resolved: &ResolvedSpawnSpec,
17986 incarnation_id: NodeIncarnationId,
17987 session: SessionAddress,
17988 lease: ManagedWorktreeLeaseSnapshot,
17989 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17990 bundle: Option<ResolvedBundleReceipt>,
17991 context: Option<ResolvedContextPackReceipt>,
17992) -> ManagedWorktreeSpawnReceipt {
17993 let mut managed = resolved.clone();
17994 managed.target.worktree_id = Some(lease.workspace_id.clone());
17995 ManagedWorktreeSpawnReceipt {
17996 spawn: managed.receipt_with_materialization(
17997 incarnation_id,
17998 session,
17999 environment_profile,
18000 bundle,
18001 context,
18002 ),
18003 lease,
18004 }
18005}
18006
18007fn validate_selected_worktree(
18008 source_root: &str,
18009 selected_root: &str,
18010 worktrees: &[NativeGitWorktreeSnapshot],
18011) -> Result<(), NodeFailure> {
18012 if !worktrees
18013 .iter()
18014 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
18015 {
18016 return Err(failure(
18017 NodeFailureCode::WorktreeProtected,
18018 "source workspace is not an authoritative non-bare Git worktree root",
18019 ));
18020 }
18021 if !worktrees.iter().any(|worktree| {
18022 worktree_paths_equal(&worktree.path, selected_root)
18023 && !worktree.is_main
18024 && !worktree.is_bare
18025 }) {
18026 return Err(failure(
18027 NodeFailureCode::WorktreeProtected,
18028 "selected workspace is not an eligible Git worktree in the source repository",
18029 ));
18030 }
18031 Ok(())
18032}
18033
18034fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
18035 let code = match error.kind() {
18036 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
18037 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
18038 NodeFailureCode::RepositoryPathUnsafe
18039 }
18040 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
18041 WorkspaceFileReadErrorKind::NotRegularFile => {
18042 NodeFailureCode::RepositoryFileNotRegular
18043 }
18044 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
18045 NodeFailureCode::RepositoryFileReadFailed
18046 }
18047 WorkspaceFileReadErrorKind::RevisionConflict => {
18048 NodeFailureCode::RepositoryFileRevisionConflict
18049 }
18050 WorkspaceFileReadErrorKind::AlreadyExists
18051 | WorkspaceFileReadErrorKind::ParentNotFound
18052 | WorkspaceFileReadErrorKind::ParentNotDirectory
18053 | WorkspaceFileReadErrorKind::Canceled => {
18054 NodeFailureCode::RepositoryFileReadFailed
18055 }
18056 };
18057 failure(code, "repository file read failed")
18058}
18059
18060fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
18061 let code = match error.kind() {
18062 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
18063 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
18064 NodeFailureCode::RepositoryPathUnsafe
18065 }
18066 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
18067 WorkspaceFileReadErrorKind::NotRegularFile => {
18068 NodeFailureCode::RepositoryFileNotRegular
18069 }
18070 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
18071 NodeFailureCode::RepositoryFileWriteFailed
18072 }
18073 WorkspaceFileReadErrorKind::RevisionConflict => {
18074 NodeFailureCode::RepositoryFileRevisionConflict
18075 }
18076 WorkspaceFileReadErrorKind::AlreadyExists
18077 | WorkspaceFileReadErrorKind::ParentNotFound
18078 | WorkspaceFileReadErrorKind::ParentNotDirectory
18079 | WorkspaceFileReadErrorKind::Canceled => {
18080 NodeFailureCode::RepositoryFileWriteFailed
18081 }
18082 };
18083 failure(code, "repository file write failed")
18084}
18085
18086async fn settle_workspace_entry_create<T>(
18087 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
18088 commit_state: Arc<AtomicU8>,
18089 deadline: Duration,
18090 timeout_message: &'static str,
18091 task_failure_message: &'static str,
18092) -> Result<T, NodeFailure> {
18093 let joined = match timeout(deadline, &mut task).await {
18094 Ok(joined) => joined,
18095 Err(_) => {
18096 if commit_state
18097 .compare_exchange(
18098 WORKSPACE_ENTRY_CREATE_PENDING,
18099 WORKSPACE_ENTRY_CREATE_CANCELED,
18100 Ordering::AcqRel,
18101 Ordering::Acquire,
18102 )
18103 .is_ok()
18104 {
18105 return Err(failure(
18106 NodeFailureCode::RepositoryEntryCreateTimedOut,
18107 timeout_message,
18108 ));
18109 }
18110 debug_assert_eq!(
18111 commit_state.load(Ordering::Acquire),
18112 WORKSPACE_ENTRY_CREATE_COMMITTING,
18113 );
18114 task.await
18115 }
18116 };
18117 joined
18118 .map_err(|_| {
18119 failure(
18120 NodeFailureCode::RepositoryEntryCreateFailed,
18121 task_failure_message,
18122 )
18123 })?
18124 .map_err(workspace_entry_create_failure)
18125}
18126
18127fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
18128 let code = match error.kind() {
18129 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
18130 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
18131 NodeFailureCode::RepositoryPathUnsafe
18132 }
18133 WorkspaceFileReadErrorKind::AlreadyExists => {
18134 NodeFailureCode::RepositoryEntryAlreadyExists
18135 }
18136 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
18137 NodeFailureCode::RepositoryParentNotFound
18138 }
18139 WorkspaceFileReadErrorKind::ParentNotDirectory
18140 | WorkspaceFileReadErrorKind::NotRegularFile => {
18141 NodeFailureCode::RepositoryParentNotDirectory
18142 }
18143 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
18144 NodeFailureCode::RepositoryEntryCreateFailed
18145 }
18146 WorkspaceFileReadErrorKind::RevisionConflict => {
18147 NodeFailureCode::RepositoryEntryCreateFailed
18148 }
18149 WorkspaceFileReadErrorKind::Canceled => {
18150 NodeFailureCode::RepositoryEntryCreateTimedOut
18151 }
18152 };
18153 failure(code, "repository entry create failed")
18154}
18155
18156fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
18157 let value = digest(&SHA256, bytes)
18158 .as_ref()
18159 .iter()
18160 .map(|byte| format!("{byte:02x}"))
18161 .collect();
18162 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
18163}
18164
18165fn managed_spawn_request_digest_v2(
18166 request: &ManagedWorktreeSpawnRequestV2,
18167) -> Result<String, NodeFailure> {
18168 let encoded = serde_json::to_vec(request).map_err(|_| {
18169 failure(
18170 NodeFailureCode::InvalidRequest,
18171 "managed worktree V2 request could not be canonicalized",
18172 )
18173 })?;
18174 let mut value = String::with_capacity(71);
18175 value.push_str("sha256:");
18176 for byte in digest(&SHA256, &encoded).as_ref() {
18177 use std::fmt::Write as _;
18178 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
18179 }
18180 Ok(value)
18181}
18182
18183fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
18184 let code = match error {
18185 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
18186 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
18187 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
18188 NativeSessionPreviewError::UnsupportedProvider
18189 | NativeSessionPreviewError::PreviewUnavailable
18190 | NativeSessionPreviewError::SessionNotFound
18191 | NativeSessionPreviewError::AmbiguousSession => {
18192 NodeFailureCode::BackendOperationFailed
18193 }
18194 };
18195 failure(code, "native session preview failed")
18196}
18197
18198fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
18199 let code = match error.kind() {
18200 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
18201 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
18202 };
18203 failure(code, "host directory browse failed")
18204}
18205
18206fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
18207 let code = match error {
18208 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
18209 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
18210 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
18211 NodeFailureCode::DeliveryStageConflict
18212 }
18213 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
18214 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
18215 NodeFailureCode::DeliveryChunkOutOfOrder
18216 }
18217 DeliveryStoreError::BlobDigestMismatch => {
18218 NodeFailureCode::DeliveryBlobDigestMismatch
18219 }
18220 DeliveryStoreError::BundleDigestMismatch => {
18221 NodeFailureCode::DeliveryBundleDigestMismatch
18222 }
18223 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
18224 DeliveryStoreError::Unavailable
18225 | DeliveryStoreError::Corrupt
18226 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
18227 };
18228 failure(code, "delivery operation failed")
18229}
18230
18231fn validate_workspace_root(
18232 workspace_id: &WorkspaceId,
18233 canonical_root: &str,
18234) -> Result<(), NodeServerError> {
18235 #[cfg(windows)]
18236 if canonical_root.starts_with(r"\\") {
18237 return Err(NodeServerError::InvalidWorkspaceRoot {
18238 workspace_id: workspace_id.clone(),
18239 path: canonical_root.to_owned(),
18240 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
18241 });
18242 }
18243 if !platform::workspace_root_supported(canonical_root)
18244 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
18245 {
18246 return Err(NodeServerError::InvalidWorkspaceRoot {
18247 workspace_id: workspace_id.clone(),
18248 path: canonical_root.to_owned(),
18249 message: "canonical path must be absolute, bounded, and free of control characters"
18250 .to_owned(),
18251 });
18252 }
18253 Ok(())
18254}
18255
18256fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18257 NodeFailure {
18258 code,
18259 message: node_failure_category(code).to_owned(),
18260 }
18261}
18262
18263fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18264 let code = match error {
18265 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18266 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18267 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18268 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18269 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18270 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18271 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18272 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18273 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18274 };
18275 failure(code, "harness MCP proxy operation failed")
18276}
18277
18278static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18282
18283fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18290 let trace = launch.trace.as_ref()?;
18291 let trace_dir = std::env::var(&trace.dir_env).ok()?;
18292 if trace_dir.is_empty() {
18293 return None;
18294 }
18295 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18296 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18297 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18298}
18299
18300fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18301 let remaining = deadline.saturating_duration_since(Instant::now());
18302 if remaining.is_zero() {
18303 Err(failure(
18304 NodeFailureCode::SpawnDeadlineExceeded,
18305 "spawn deadline elapsed",
18306 ))
18307 } else {
18308 Ok(remaining)
18309 }
18310}
18311
18312fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18313 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18314 match deadline {
18315 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18316 None => Ok(bounded),
18317 }
18318}
18319
18320fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18321 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18322 failure(
18323 NodeFailureCode::SpawnDeadlineExceeded,
18324 "spawn dispatch exceeded the spawn deadline",
18325 )
18326 } else {
18327 error
18328 }
18329}
18330
18331fn spawn_runtime_capabilities(
18332 capabilities: &SpawnRequiredCapabilities,
18333) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18334 capabilities
18335 .iter()
18336 .map(|capability| match capability.as_str() {
18337 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18338 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18339 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18340 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18341 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18342 }
18343 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18344 _ => Err(failure(
18345 NodeFailureCode::UnsupportedSpawnCapability,
18346 "spawn required capability is unknown",
18347 )),
18348 })
18349 .collect()
18350}
18351
18352fn persistence_failure(_error: io::Error) -> NodeFailure {
18353 NodeFailure {
18354 code: NodeFailureCode::BackendOperationFailed,
18355 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18356 }
18357}
18358
18359fn node_failure_category(code: NodeFailureCode) -> &'static str {
18360 match code {
18361 NodeFailureCode::InvalidRequest => "invalid-request",
18362 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18363 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18364 NodeFailureCode::ReservationNotFound => "reservation-not-found",
18365 NodeFailureCode::ReservationConflict => "reservation-conflict",
18366 NodeFailureCode::ReservationExpired => "reservation-expired",
18367 NodeFailureCode::BindingMismatch => "binding-mismatch",
18368 NodeFailureCode::NotActivated => "not-activated",
18369 NodeFailureCode::CallNotFound => "call-not-found",
18370 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18371 NodeFailureCode::ResponseTooLarge => "response-too-large",
18372 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18373 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18374 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18375 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18376 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18377 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18378 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18379 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18380 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18381 NodeFailureCode::Unauthorized => "unauthorized",
18382 NodeFailureCode::ObserverReadOnly => "observer-read-only",
18383 NodeFailureCode::ControllerBusy => "controller-busy",
18384 NodeFailureCode::ControllerRequired => "controller-required",
18385 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18386 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18387 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18388 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18389 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18390 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18391 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18392 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18393 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18394 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18395 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18396 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18397 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18398 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18399 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18400 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18401 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18402 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18403 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18404 NodeFailureCode::GitReadFailed => "git-read-failed",
18405 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18406 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18407 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18408 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18409 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18410 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18411 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18412 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18413 NodeFailureCode::TurnInFlight => "turn-in-flight",
18414 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18415 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18416 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18417 "unsupported-network-allowlist-mapping"
18418 }
18419 NodeFailureCode::BrowserStationProbeUnavailable => {
18420 "browser-station-probe-unavailable"
18421 }
18422 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18423 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18424 NodeFailureCode::EnvironmentProfileBindingMismatch => {
18425 "environment-profile-binding-mismatch"
18426 }
18427 NodeFailureCode::UnknownBundle => "unknown-bundle",
18428 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18429 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18430 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18431 NodeFailureCode::ContextPackBusy => "context-pack-busy",
18432 NodeFailureCode::ContextPackMaterializationFailed => {
18433 "context-pack-materialization-failed"
18434 }
18435 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18436 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18437 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18438 NodeFailureCode::WorkspaceBusy => "workspace-busy",
18439 NodeFailureCode::LastWorkspace => "last-workspace",
18440 NodeFailureCode::NotGitRepository => "not-git-repository",
18441 NodeFailureCode::WorktreeConflict => "worktree-conflict",
18442 NodeFailureCode::WorktreeProtected => "worktree-protected",
18443 NodeFailureCode::WorktreeDirty => "worktree-dirty",
18444 NodeFailureCode::WorktreeLocked => "worktree-locked",
18445 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18446 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18447 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18448 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18449 "managed-worktree-profile-revision-mismatch"
18450 }
18451 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18452 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18453 "standalone-workspace-recovery-required"
18454 }
18455 NodeFailureCode::UnknownSession => "unknown-session",
18456 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18457 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18458 NodeFailureCode::SessionRecordBusy => "session-record-busy",
18459 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18460 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18461 NodeFailureCode::WorkspaceRegistrationRequired => {
18462 "workspace-registration-required"
18463 }
18464 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18465 NodeFailureCode::StaleGeneration => "stale-generation",
18466 NodeFailureCode::BackendBusy => "backend-busy",
18467 NodeFailureCode::BackendDisconnected => "backend-disconnected",
18468 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18469 NodeFailureCode::ShuttingDown => "shutting-down",
18470 }
18471}
18472
18473fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18474 NodeServerError::DurableState(io::Error::new(error.kind(), category))
18475}
18476
18477fn durable_state_load_error(error: io::Error) -> NodeServerError {
18478 let category = match session_registry::state_load_refusal(&error) {
18479 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18480 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18481 }
18482 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18483 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18484 }
18485 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18486 DURABLE_STATE_CONFLICT_ERROR
18487 }
18488 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18489 };
18490 durable_state_server_error(error, category)
18491}
18492
18493fn unix_time_ms() -> u64 {
18494 std::time::SystemTime::now()
18495 .duration_since(std::time::UNIX_EPOCH)
18496 .unwrap_or_default()
18497 .as_millis()
18498 .min(u64::MAX as u128) as u64
18499}
18500
18501fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18502 if record.provider_session.is_some() {
18503 ManagedSessionState::Dormant
18504 } else {
18505 ManagedSessionState::Unavailable
18506 }
18507}
18508
18509fn active_record_state(
18510 identity_admitted: bool,
18511 provider_session_is_known: bool,
18512) -> ManagedSessionState {
18513 if identity_admitted && !provider_session_is_known {
18514 ManagedSessionState::IdentityPending
18515 } else {
18516 ManagedSessionState::Live
18517 }
18518}
18519
18520fn managed_worktree_record_holders(
18521 records: &[ManagedSessionRecord],
18522) -> Vec<(SessionRecordId, WorkspaceId)> {
18523 records
18524 .iter()
18525 .filter(|record| record.provider_session.is_some())
18526 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18527 .collect()
18528}
18529
18530fn managed_spawn_record_ownership(
18531 binding: &SessionBinding,
18532 effective_runtime_policy: ProviderRuntimePolicy,
18533) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18534 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18535 if effective_runtime_policy.provider_session_identity {
18536 (binding.record_id.clone(), None)
18537 } else {
18538 (None, binding.record_id.clone())
18539 }
18540}
18541
18542#[derive(Debug, Error)]
18543pub enum NodeServerError {
18544 #[error("node endpoint must be a bounded absolute local transport path")]
18545 InvalidEndpoint,
18546 #[error("node access token must contain 1..=4096 bytes")]
18547 InvalidAccessToken,
18548 #[error("node HTTP observer must listen on a loopback address: {0}")]
18549 InvalidApiListen(std::net::SocketAddr),
18550 #[error("node browser bridge must listen on a loopback address: {0}")]
18551 InvalidBridgeListen(std::net::SocketAddr),
18552 #[error("node browser bridge token must contain 1..=4096 bytes")]
18553 InvalidBridgeToken,
18554 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18555 BridgeUnderlayRequiresBridgeListen,
18556 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18557 BridgeUnderlayRequiresBridgeToken,
18558 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18559 InvalidBridgeUnderlayToken,
18560 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18561 InvalidBridgeUnderlayKey,
18562 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18563 InvalidCallHome(std::net::SocketAddr),
18564 #[error("node could not reach its call-home relay: {0}")]
18565 CallHomeTransport(#[source] std::io::Error),
18566 #[error("node durable state path must be an absolute file path: {0}")]
18567 InvalidStatePath(String),
18568 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18569 InvalidHarnessMcpHelper,
18570 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18571 InvalidNetworkAllowlistCatalogPath(String),
18572 #[error("network allowlist catalog could not be read: {0}")]
18573 NetworkAllowlistCatalogIo(#[source] std::io::Error),
18574 #[error("network allowlist catalog line {line}: {message}")]
18575 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18576 #[error("network allowlist catalog: {0}")]
18577 InvalidNetworkAllowlistCatalog(String),
18578 #[error("network allowlist catalog exceeds the {max}-entry limit")]
18579 NetworkAllowlistCatalogCapacity { max: usize },
18580 #[error("the local state directory is unavailable; supply an explicit state path")]
18581 LocalStateDirectoryUnavailable,
18582 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18583 LocalRuntimeDirectoryUnavailable,
18584 #[error("node durable state failed: {0}")]
18585 DurableState(io::Error),
18586 #[error("node requires at least one configured workspace")]
18587 NoWorkspaces,
18588 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18589 InvalidWorkspaceRoot {
18590 workspace_id: WorkspaceId,
18591 path: String,
18592 message: String,
18593 },
18594 #[error("duplicate workspace ID: {0}")]
18595 DuplicateWorkspaceId(WorkspaceId),
18596 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18597 DuplicateWorkspaceRoot {
18598 first: WorkspaceId,
18599 second: WorkspaceId,
18600 root: String,
18601 },
18602 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18603 ManagedWorktreeProfileRequired(WorkspaceId),
18604 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18605 ManagedWorktreeProfileModeMismatch(WorkspaceId),
18606 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18607 DuplicateManagedWorktreeProfile {
18608 workspace_id: WorkspaceId,
18609 profile_id: WorktreeProfileId,
18610 },
18611 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18612 ManagedWorktreeProfileCapacity {
18613 workspace_id: WorkspaceId,
18614 max: usize,
18615 },
18616 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18617 InvalidManagedWorktreeProfile {
18618 workspace_id: WorkspaceId,
18619 profile_id: WorktreeProfileId,
18620 message: String,
18621 },
18622 #[error("duplicate node environment profile: {0}")]
18623 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18624 #[error("node environment profile provider is unavailable: {0}")]
18625 EnvironmentProfileProviderUnavailable(AgentId),
18626 #[error("node environment profile capacity is {max}")]
18627 EnvironmentProfileCapacity { max: usize },
18628 #[error("node environment profiles reuse native profile ID '{0}'")]
18629 DuplicateNativeEnvironmentProfile(String),
18630 #[error("node native environment profile is invalid: {0}")]
18631 NativeEnvironmentProfile(String),
18632 #[error("node session-environment materialization root must be absolute")]
18633 InvalidSessionEnvironmentRoot,
18634 #[error("node session-environment materializer is unavailable")]
18635 SessionEnvironmentMaterializerRequired,
18636 #[error("node session-environment materializer failed to initialize")]
18637 SessionEnvironmentMaterializer,
18638 #[error("node bundle catalog is invalid: {0}")]
18639 BundleCatalog(String),
18640 #[error("node delivery store failed to initialize: {0}")]
18641 DeliveryStore(String),
18642 #[error("node context pack store failed to initialize")]
18643 ContextPackStore,
18644 #[error("active agent registry failed: {0}")]
18645 Registry(String),
18646 #[error("node provider contract manifest is invalid: {0}")]
18647 ProviderContractManifest(String),
18648 #[error("named pipe I/O failed: {0}")]
18649 Io(#[from] io::Error),
18650 #[error("node HTTP observer failed: {0}")]
18651 HttpApi(io::Error),
18652 #[error("node browser bridge failed: {0}")]
18653 BridgeApi(io::Error),
18654 #[error(transparent)]
18655 Frame(#[from] FrameError),
18656 #[error("node handshake failed: {0}")]
18657 Handshake(String),
18658 #[error("node authentication frame was not received before the bounded deadline")]
18659 AuthenticationTimedOut,
18660 #[error("node authentication primitive failed: {0}")]
18661 Authentication(String),
18662 #[error("node incarnation identity generation failed: {0}")]
18663 IncarnationIdentity(String),
18664 #[error("node pre-authentication limiter is closed")]
18665 PreauthClosed,
18666 #[error("node authenticated connection limit was reached")]
18667 AuthenticatedConnectionLimit,
18668 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18669 SignalStreamClosed,
18670 #[error("node stop-all dispatch failed: {0:?}")]
18671 ShutdownDispatch(NodeFailure),
18672 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18673 ShutdownTimedOut { active_native_sessions: usize },
18674}
18675
18676impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18677 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18678 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18679 match error {
18680 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18681 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18682 E::InvalidEntry { line, message } => {
18683 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18684 }
18685 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18686 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18687 }
18688 }
18689}
18690
18691#[cfg(test)]
18692mod agent_stream_blocked_tests {
18693 use super::*;
18694 use gate4agent_types::{
18695 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18696 ProviderSource,
18697 };
18698
18699 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18700 ProviderSource {
18701 family,
18702 binding: AdapterBinding::new(
18703 AdapterId::new(adapter).unwrap(),
18704 "fixture/v1",
18705 AdapterVerification::SyntheticFixture,
18706 )
18707 .unwrap(),
18708 }
18709 }
18710
18711 fn provider_control_event_at(
18712 family: AdapterFamily,
18713 adapter: &str,
18714 source_sequence: u64,
18715 provider_event: ProviderEvent,
18716 ) -> ControlEvent {
18717 ControlEvent {
18718 sequence: 41,
18719 command_id: None,
18720 instance_id: AgentInstanceId(7),
18721 generation: SessionGeneration(3),
18722 event: ControlEventKind::ProviderEvent {
18723 sequence: 8,
18724 source: provider_source(family, adapter),
18725 source_sequence,
18726 event: provider_event,
18727 },
18728 }
18729 }
18730
18731 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18732 provider_control_event_at(family, "fixture", 9, provider_event)
18733 }
18734
18735 fn denied_host_request(
18736 by: ProviderHostDecisionAuthority,
18737 reason: Option<String>,
18738 ) -> ControlEvent {
18739 provider_control_event_at(
18740 AdapterFamily::Acp,
18741 "claude-code",
18742 2,
18743 ProviderEvent::HostRequestObserved {
18744 method: "terminal/create".to_owned(),
18745 params_json: String::new(),
18746 decision: ProviderHostRequestDecision::Denied { by },
18747 outcome: ProviderHostRequestOutcome::Executed,
18748 reason,
18749 },
18750 )
18751 }
18752
18753 fn blocked(
18754 event: &ControlEvent,
18755 ) -> (
18756 Option<String>,
18757 String,
18758 BlockAuthorityV1,
18759 Option<String>,
18760 String,
18761 Option<String>,
18762 ) {
18763 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18764 chunk.validate().expect("minted chunk must validate");
18765 let AgentStreamChunkKindV1::Blocked {
18766 correlation_id,
18767 tool_class,
18768 authority,
18769 reason_kind,
18770 reason,
18771 help,
18772 } = chunk.kind
18773 else {
18774 panic!("expected AgentStreamChunkKindV1::Blocked");
18775 };
18776 (correlation_id, tool_class, authority, reason_kind, reason, help)
18777 }
18778
18779 #[test]
18783 fn the_enabled_provider_matrix_is_exact() {
18784 let registry = active_registry().unwrap();
18785 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18786 assert_eq!(
18787 providers
18788 .iter()
18789 .map(|contract| contract.provider.as_str())
18790 .collect::<Vec<_>>(),
18791 vec!["shell", "claude", "codex", "grok", "kimi"],
18793 );
18794 assert!(adapters.iter().all(|contract| {
18795 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18796 }));
18797 }
18798
18799 #[test]
18802 fn gate_denied_host_request_mints_a_blocked_chunk() {
18803 let gate_text =
18804 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18805 let event = denied_host_request(
18806 ProviderHostDecisionAuthority::Gate,
18807 Some(gate_text.to_owned()),
18808 );
18809 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18810 assert_eq!(chunk.source_sequence, 2);
18811 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18812 assert_eq!(authority, BlockAuthorityV1::HostGate);
18813 assert_eq!(reason, gate_text);
18814 assert_eq!(correlation_id, None);
18815 assert_eq!(reason_kind, None);
18816 assert_eq!(help, None);
18817 assert_eq!(tool_class, "Write");
18818 }
18819
18820 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.";
18821
18822 #[test]
18828 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18829 let event = provider_control_event(
18830 AdapterFamily::Acp,
18831 ProviderEvent::ToolCompleted {
18832 id: "toolu_9".to_owned(),
18833 output: CLASSIFIER_SAMPLE.to_owned(),
18834 is_error: true,
18835 duration_ms: None,
18836 agent_id: None,
18837 non_execution_kind: Some("permission-rule".to_owned()),
18838 },
18839 );
18840 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18841 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18842 assert_eq!(reason, "Blocked by classifier");
18843 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18844 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18845 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18846 }
18847
18848 #[test]
18853 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18854 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.";
18855 let event = provider_control_event(
18856 AdapterFamily::Acp,
18857 ProviderEvent::SessionEnded {
18858 result: "JSON-RPC error -32603: Internal error".to_owned(),
18859 cost_usd: None,
18860 is_error: true,
18861 stop_reason: Some(ProviderStopReason::ProviderError {
18862 code: -32603,
18863 message: message.to_owned(),
18864 vendor_code: Some("usageLimitExceeded".to_owned()),
18865 }),
18866 },
18867 );
18868 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18869 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18870 assert_eq!(reason, message);
18871 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18872 assert_eq!(tool_class, "Session");
18873 }
18874
18875 #[test]
18878 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18879 let event = provider_control_event(
18880 AdapterFamily::Acp,
18881 ProviderEvent::SessionEnded {
18882 result: "refusal".to_owned(),
18883 cost_usd: None,
18884 is_error: true,
18885 stop_reason: Some(ProviderStopReason::Refusal),
18886 },
18887 );
18888 let (_, _, authority, _, _, _) = blocked(&event);
18889 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18890 }
18891
18892 #[test]
18896 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18897 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18898 let event = provider_control_event(
18899 AdapterFamily::Acp,
18900 ProviderEvent::ToolCompleted {
18901 id: "toolu_1".to_owned(),
18902 output: "command exited with status 1".to_owned(),
18903 is_error: true,
18904 duration_ms: Some(12),
18905 agent_id: None,
18906 non_execution_kind,
18907 },
18908 );
18909 assert!(agent_stream_chunk(&event).is_none());
18910 }
18911 let narrative = provider_control_event(
18912 AdapterFamily::Acp,
18913 ProviderEvent::ToolCompleted {
18914 id: "toolu_2".to_owned(),
18915 output: "the harness blocked the write, approval needed".to_owned(),
18916 is_error: false,
18917 duration_ms: Some(3),
18918 agent_id: None,
18919 non_execution_kind: None,
18920 },
18921 );
18922 assert!(agent_stream_chunk(&narrative).is_none());
18923 }
18924
18925 #[test]
18929 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18930 for (decision, outcome) in [
18931 (
18932 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18933 ProviderHostRequestOutcome::Executed,
18934 ),
18935 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18936 (
18937 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18938 ProviderHostRequestOutcome::Failed {
18939 error: "terminal/create spawn failed: os error 3".to_owned(),
18940 },
18941 ),
18942 ] {
18943 let event = provider_control_event_at(
18944 AdapterFamily::Acp,
18945 "claude-code",
18946 3,
18947 ProviderEvent::HostRequestObserved {
18948 method: "fs/read_text_file".to_owned(),
18949 params_json: String::new(),
18950 decision: decision.clone(),
18951 outcome,
18952 reason: None,
18953 },
18954 );
18955 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18956 }
18957 }
18958
18959 #[test]
18963 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18964 for (by, expected_authority, expected_reason) in [
18965 (
18966 ProviderHostDecisionAuthority::Operator,
18967 BlockAuthorityV1::Operator,
18968 "declined by the operator",
18969 ),
18970 (
18971 ProviderHostDecisionAuthority::DeadlinePolicy,
18972 BlockAuthorityV1::HostDeadline,
18973 "denied by host policy after the operator deadline expired",
18974 ),
18975 ] {
18976 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18977 assert_eq!(authority, expected_authority);
18978 assert_eq!(reason, expected_reason);
18979 }
18980 }
18981
18982 #[test]
18986 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18987 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18988 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18989 ProviderHostDecisionAuthority::Policy,
18990 Some(oversized),
18991 ));
18992 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18993 }
18994}
18995
18996#[cfg(test)]
18997mod standalone_capability_platform_tests {
18998 use super::*;
18999
19000 #[test]
19001 fn standalone_workspace_capability_requires_windows_identity_guards() {
19002 let capabilities = baseline_capabilities().unwrap();
19003 let advertised = capabilities.iter().any(|capability| {
19004 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
19005 });
19006 assert_eq!(advertised, cfg!(windows));
19007
19008 let request = NodeRequest::CreateStandaloneWorkspace {
19009 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
19010 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
19011 initial_branch: None,
19012 };
19013 assert_eq!(
19014 request_uses_unnegotiated_capability(&request, &capabilities),
19015 !cfg!(windows),
19016 );
19017 }
19018}
19019
19020
19021#[cfg(test)]
19022mod station_knobs_resolve_tests {
19023 use super::*;
19024 use gate4agent_catalog::EnvMutation;
19025 use gate4agent_runtime_native::{
19026 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19027 NativeLaunchProfileId,
19028 };
19029 use std::ffi::OsString;
19030 use std::sync::Arc;
19031
19032 struct EmptyEnvironmentResolver;
19033
19034 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19035 fn resolve_child_environment(
19036 &self,
19037 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19038 Ok(vec![EnvMutation {
19039 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19040 value: None,
19041 }])
19042 }
19043 }
19044
19045 fn agent(value: &str) -> AgentId {
19046 AgentId::new(value).unwrap()
19047 }
19048
19049 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
19050 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19051 let profile_revision =
19052 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
19053 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
19054 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19055 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19056 provider: agent("claude"),
19057 mode: SessionMode::Pty,
19058 terminal_size: gate4agent_types::TerminalSize {
19059 rows: 24,
19060 columns: 80,
19061 },
19062 prompt: None,
19063 bundle_id: None,
19064 context_id: None,
19065 environment_profile_id: Some(profile_id.clone()),
19066 };
19067 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
19068 let catalog = active_registry().unwrap();
19069 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19070 let control = runtime.native_launch_profile_control();
19071 let node_profile = NodeEnvironmentProfile::new(
19072 profile_id.clone(),
19073 profile_revision,
19074 agent("claude"),
19075 [NativeLaunchProfile::new(
19076 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19077 agent("claude"),
19078 TransportKind::Pty,
19079 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19080 Arc::new(EmptyEnvironmentResolver),
19081 )
19082 .unwrap()],
19083 )
19084 .unwrap();
19085 let (binding, native_profiles, _materialization) = node_profile.into_parts();
19086 for native_profile in native_profiles {
19087 runtime.upsert_native_launch_profile(native_profile).unwrap();
19088 }
19089 let workspace = WorkspaceConfig::new(
19090 WorkspaceId::new("primary").unwrap(),
19091 std::env::current_dir().unwrap(),
19092 )
19093 .unwrap();
19094 let shared = NodeShared::new_with_incarnation(
19095 handle,
19096 "fixture-token".to_owned(),
19097 NodeId::new("node-station-knobs-test").unwrap(),
19098 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19099 vec![workspace],
19100 vec![agent("claude")],
19101 ProviderRuntimeStatuses::default(),
19102 None,
19103 Vec::new(),
19104 Vec::new(),
19105 spawn_profiles,
19106 Some(control),
19107 None,
19108 None,
19109 None,
19110 Vec::new(),
19111 Vec::new(),
19112 Vec::new(),
19113 Vec::new(),
19114 None,
19115 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19116 );
19117 shared
19118 .environment_profiles
19119 .write()
19120 .unwrap_or_else(|poisoned| poisoned.into_inner())
19121 .insert(binding.id.clone(), binding);
19122 (shared, spawn_defaults)
19123 }
19124
19125 #[test]
19128 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
19129 let (shared, defaults) = station_knobs_fixture();
19130 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
19131 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19132 let spec = crate::protocol::SpawnSpec {
19133 target: crate::protocol::SpawnTarget {
19134 node_id: shared.node_id.clone(),
19135 workspace_id: WorkspaceId::new("primary").unwrap(),
19136 worktree_id: None,
19137 },
19138 profile_id: defaults.profile_id.clone(),
19139 expected_profile_revision: defaults.revision.clone(),
19140 overrides: crate::protocol::SpawnOverrides {
19141 network_allowlist: Some(unknown),
19142 browser_profile_id: Some(browser),
19143 ..crate::protocol::SpawnOverrides::default()
19144 },
19145 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19146 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19147 "station-knobs-unknown-allowlist",
19148 )
19149 .unwrap(),
19150 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19151 };
19152 let resolved = spec.resolve(&defaults).unwrap();
19153 let failure = shared
19154 .resolve_environment_profile(&resolved)
19155 .expect_err("unknown allowlist must refuse");
19156 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
19157 }
19158
19159 #[test]
19165 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
19166 let (shared, defaults) = station_knobs_fixture();
19167 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19168 #[cfg(not(feature = "dig2-station-probe"))]
19169 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19170 shared
19171 .network_allowlist_catalog
19172 .write()
19173 .unwrap_or_else(|poisoned| poisoned.into_inner())
19174 .insert_id(allowlist.clone())
19175 .unwrap();
19176 let spec = crate::protocol::SpawnSpec {
19177 target: crate::protocol::SpawnTarget {
19178 node_id: shared.node_id.clone(),
19179 workspace_id: WorkspaceId::new("primary").unwrap(),
19180 worktree_id: None,
19181 },
19182 profile_id: defaults.profile_id.clone(),
19183 expected_profile_revision: defaults.revision.clone(),
19184 overrides: crate::protocol::SpawnOverrides {
19185 network_allowlist: Some(allowlist.clone()),
19186 #[cfg(not(feature = "dig2-station-probe"))]
19187 browser_profile_id: Some(browser.clone()),
19188 ..crate::protocol::SpawnOverrides::default()
19189 },
19190 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19191 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
19192 .unwrap(),
19193 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19194 };
19195 let resolved = spec.resolve(&defaults).unwrap();
19196 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
19197 #[cfg(not(feature = "dig2-station-probe"))]
19198 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
19199 let receipt = shared
19200 .resolve_environment_profile(&resolved)
19201 .expect("resolve ok")
19202 .expect("env profile present");
19203 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19204 #[cfg(not(feature = "dig2-station-probe"))]
19205 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19206 #[cfg(feature = "dig2-station-probe")]
19207 assert_eq!(receipt.browser_profile_id, None);
19208 assert_eq!(receipt.profile_id.as_str(), "local-claude");
19209
19210 let omitted = crate::protocol::SpawnSpec {
19211 overrides: crate::protocol::SpawnOverrides::default(),
19212 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
19213 .unwrap(),
19214 ..spec
19215 };
19216 let resolved_omit = omitted.resolve(&defaults).unwrap();
19217 let bare = shared
19218 .resolve_environment_profile(&resolved_omit)
19219 .expect("resolve ok")
19220 .expect("env profile present");
19221 assert_eq!(bare.network_allowlist, None);
19222 assert_eq!(bare.browser_profile_id, None);
19223 }
19224
19225 #[cfg(feature = "dig2-station-probe")]
19230 #[test]
19231 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
19232 let (shared, defaults) = station_knobs_fixture();
19233 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19234 let spec = crate::protocol::SpawnSpec {
19235 target: crate::protocol::SpawnTarget {
19236 node_id: shared.node_id.clone(),
19237 workspace_id: WorkspaceId::new("primary").unwrap(),
19238 worktree_id: None,
19239 },
19240 profile_id: defaults.profile_id.clone(),
19241 expected_profile_revision: defaults.revision.clone(),
19242 overrides: crate::protocol::SpawnOverrides {
19243 browser_profile_id: Some(browser),
19244 ..crate::protocol::SpawnOverrides::default()
19245 },
19246 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19247 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19248 "station-knobs-probe-refuse",
19249 )
19250 .unwrap(),
19251 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19252 };
19253 let resolved = spec.resolve(&defaults).unwrap();
19254 let failure = shared
19255 .resolve_environment_profile(&resolved)
19256 .expect_err("browser_profile with failing probe must refuse");
19257 #[cfg(windows)]
19258 {
19259 assert!(
19260 matches!(
19261 failure.code,
19262 NodeFailureCode::BrowserStationUnreachable
19263 | NodeFailureCode::BrowserStationProbeUnavailable
19264 ),
19265 "unexpected code {:?}",
19266 failure.code
19267 );
19268 }
19269 #[cfg(not(windows))]
19270 {
19271 assert_eq!(
19272 failure.code,
19273 NodeFailureCode::BrowserStationProbeUnavailable
19274 );
19275 }
19276 }
19277
19278 #[cfg(feature = "dig2-station-probe")]
19280 #[test]
19281 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19282 let (shared, defaults) = station_knobs_fixture();
19283 let spec = crate::protocol::SpawnSpec {
19284 target: crate::protocol::SpawnTarget {
19285 node_id: shared.node_id.clone(),
19286 workspace_id: WorkspaceId::new("primary").unwrap(),
19287 worktree_id: None,
19288 },
19289 profile_id: defaults.profile_id.clone(),
19290 expected_profile_revision: defaults.revision.clone(),
19291 overrides: crate::protocol::SpawnOverrides::default(),
19292 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19293 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19294 "station-knobs-probe-omit",
19295 )
19296 .unwrap(),
19297 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19298 };
19299 let resolved = spec.resolve(&defaults).unwrap();
19300 let receipt = shared
19301 .resolve_environment_profile(&resolved)
19302 .expect("omit browser_profile must not probe")
19303 .expect("env profile present");
19304 assert_eq!(receipt.browser_profile_id, None);
19305 }
19306
19307 #[cfg(feature = "dig2-station-probe")]
19311 #[test]
19312 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19313 let (shared, _defaults) = station_knobs_fixture();
19314 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19315 let workspace_id = WorkspaceId::new("primary").unwrap();
19316 let address_a = SessionAddress {
19317 workspace_id: workspace_id.clone(),
19318 session: SessionKey {
19319 instance_id: AgentInstanceId(101),
19320 generation: SessionGeneration(1),
19321 },
19322 };
19323 let address_b = SessionAddress {
19324 workspace_id: workspace_id.clone(),
19325 session: SessionKey {
19326 instance_id: AgentInstanceId(102),
19327 generation: SessionGeneration(1),
19328 },
19329 };
19330 let guard_a = crate::dig2_station_lease::acquire_lease(
19331 &shared.browser_station_leases,
19332 profile.clone(),
19333 address_a.session.instance_id,
19334 1,
19335 )
19336 .expect("first lease");
19337 let busy = crate::dig2_station_lease::acquire_lease(
19338 &shared.browser_station_leases,
19339 profile.clone(),
19340 address_b.session.instance_id,
19341 2,
19342 )
19343 .expect_err("second holder must refuse");
19344 assert!(matches!(
19345 busy,
19346 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19347 ));
19348 shared.bind_session_with_policy(
19350 &address_a,
19351 ProviderRuntimePolicy::raw_pty(),
19352 None,
19353 );
19354 guard_a.retain();
19355 assert!(shared.remove_binding(&address_a).is_some());
19356 crate::dig2_station_lease::acquire_lease(
19357 &shared.browser_station_leases,
19358 profile,
19359 address_b.session.instance_id,
19360 3,
19361 )
19362 .expect("reacquire after clear")
19363 .retain();
19364 assert!(
19365 crate::dig2_station_lease::release_lease_for_holder(
19366 &shared.browser_station_leases,
19367 address_b.session.instance_id,
19368 )
19369 );
19370 }
19371
19372 #[cfg(not(feature = "dig2-station-probe"))]
19375 #[test]
19376 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19377 let (shared, defaults) = station_knobs_fixture();
19378 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19379 let spec = crate::protocol::SpawnSpec {
19380 target: crate::protocol::SpawnTarget {
19381 node_id: shared.node_id.clone(),
19382 workspace_id: WorkspaceId::new("primary").unwrap(),
19383 worktree_id: None,
19384 },
19385 profile_id: defaults.profile_id.clone(),
19386 expected_profile_revision: defaults.revision.clone(),
19387 overrides: crate::protocol::SpawnOverrides {
19388 browser_profile_id: Some(browser.clone()),
19389 ..crate::protocol::SpawnOverrides::default()
19390 },
19391 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19392 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19393 .unwrap(),
19394 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19395 };
19396 let resolved = spec.resolve(&defaults).unwrap();
19397 let receipt = shared
19398 .resolve_environment_profile(&resolved)
19399 .expect("stub resolve")
19400 .expect("env profile");
19401 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19402 }
19403
19404
19405 #[test]
19408 fn network_allowlist_catalog_loads_from_temp_file() {
19409 let unique = std::time::SystemTime::now()
19410 .duration_since(std::time::UNIX_EPOCH)
19411 .unwrap()
19412 .as_nanos();
19413 let path = std::env::temp_dir().join(format!(
19414 "gate4agent-node-allowlist-config-{}-{unique}.txt",
19415 std::process::id(),
19416 ));
19417 std::fs::write(
19418 &path,
19419 "# opaque station allowlist ids\negress-default\nlab-egress\n",
19420 )
19421 .unwrap();
19422 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19423 .expect("temp catalog file must load");
19424 let _ = std::fs::remove_file(&path);
19425 assert_eq!(loaded.len(), 2);
19426 let (shared, defaults) = station_knobs_fixture();
19427 {
19428 let mut catalog = shared
19429 .network_allowlist_catalog
19430 .write()
19431 .unwrap_or_else(|poisoned| poisoned.into_inner());
19432 *catalog = loaded;
19433 }
19434 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19435 let spec = crate::protocol::SpawnSpec {
19436 target: crate::protocol::SpawnTarget {
19437 node_id: shared.node_id.clone(),
19438 workspace_id: WorkspaceId::new("primary").unwrap(),
19439 worktree_id: None,
19440 },
19441 profile_id: defaults.profile_id.clone(),
19442 expected_profile_revision: defaults.revision.clone(),
19443 overrides: crate::protocol::SpawnOverrides {
19444 network_allowlist: Some(allowlist.clone()),
19445 ..crate::protocol::SpawnOverrides::default()
19446 },
19447 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19448 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19449 "station-knobs-catalog-file",
19450 )
19451 .unwrap(),
19452 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19453 };
19454 let resolved = spec.resolve(&defaults).unwrap();
19455 let receipt = shared
19456 .resolve_environment_profile(&resolved)
19457 .expect("registered via file load")
19458 .expect("env profile present");
19459 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19460 }
19461
19462 #[test]
19465 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19466 let (shared, _defaults) = station_knobs_fixture();
19467 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19468 shared
19469 .network_allowlist_catalog
19470 .write()
19471 .unwrap_or_else(|poisoned| poisoned.into_inner())
19472 .insert_id(allowlist.clone())
19473 .unwrap();
19474 let snapshot = shared.snapshot();
19475 let inventory = snapshot
19476 .launch_inventory
19477 .expect("launch inventory present");
19478 let ids = inventory
19479 .network_allowlists
19480 .expect("non-empty catalog surfaces ids");
19481 assert_eq!(ids, vec![allowlist]);
19482 }
19483
19484 #[test]
19488 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19489 let (shared, defaults) = station_knobs_fixture();
19490 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19491 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19492 id: allowlist.clone(),
19493 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19494 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19495 peer: "127.0.0.1:443".to_owned(),
19496 }],
19497 provider_native: Some(
19498 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19499 codex_network_access: Some(true),
19500 },
19501 ),
19502 };
19503 shared
19504 .network_allowlist_catalog
19505 .write()
19506 .unwrap_or_else(|poisoned| poisoned.into_inner())
19507 .insert(entry)
19508 .unwrap();
19509 let spec = crate::protocol::SpawnSpec {
19510 target: crate::protocol::SpawnTarget {
19511 node_id: shared.node_id.clone(),
19512 workspace_id: WorkspaceId::new("primary").unwrap(),
19513 worktree_id: None,
19514 },
19515 profile_id: defaults.profile_id.clone(),
19516 expected_profile_revision: defaults.revision.clone(),
19517 overrides: crate::protocol::SpawnOverrides {
19518 network_allowlist: Some(allowlist),
19519 ..crate::protocol::SpawnOverrides::default()
19520 },
19521 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19522 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19523 "station-knobs-unsupported-mapping",
19524 )
19525 .unwrap(),
19526 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19527 };
19528 let resolved = spec.resolve(&defaults).unwrap();
19529 assert_eq!(resolved.provider.as_str(), "claude");
19530 let failure = shared
19531 .resolve_environment_profile(&resolved)
19532 .expect_err("claude + codex_network_access must refuse");
19533 assert_eq!(
19534 failure.code,
19535 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19536 );
19537 }
19538
19539 #[test]
19543 fn resolve_codex_network_access_overlay_moderate_only() {
19544 let (shared, defaults) = station_knobs_fixture();
19545 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19546 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19547 id: allowlist.clone(),
19548 permits: Vec::new(),
19549 provider_native: Some(
19550 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19551 codex_network_access: Some(true),
19552 },
19553 ),
19554 };
19555 shared
19556 .network_allowlist_catalog
19557 .write()
19558 .unwrap_or_else(|poisoned| poisoned.into_inner())
19559 .insert(entry)
19560 .unwrap();
19561
19562 let overlay = shared
19563 .resolve_codex_network_access_overlay_args(
19564 &agent("codex"),
19565 SessionMode::Pty,
19566 ApprovalLevel::Moderate,
19567 Some(&allowlist),
19568 )
19569 .expect("Moderate Codex maps network_access");
19570 assert_eq!(
19571 overlay,
19572 vec![
19573 std::ffi::OsString::from("-c"),
19574 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19575 ]
19576 );
19577
19578 let acp = shared
19579 .resolve_codex_network_access_overlay_args(
19580 &agent("codex"),
19581 SessionMode::Acp,
19582 ApprovalLevel::Moderate,
19583 Some(&allowlist),
19584 )
19585 .unwrap();
19586 assert_eq!(acp, overlay);
19587
19588 let inline = shared
19589 .resolve_codex_network_access_overlay_args(
19590 &agent("codex"),
19591 SessionMode::Inline,
19592 ApprovalLevel::Moderate,
19593 Some(&allowlist),
19594 )
19595 .unwrap_err();
19596 assert_eq!(
19597 inline.code,
19598 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19599 );
19600
19601 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19602 let spec = crate::protocol::SpawnSpec {
19603 target: crate::protocol::SpawnTarget {
19604 node_id: shared.node_id.clone(),
19605 workspace_id: WorkspaceId::new("primary").unwrap(),
19606 worktree_id: None,
19607 },
19608 profile_id: defaults.profile_id.clone(),
19609 expected_profile_revision: defaults.revision.clone(),
19610 overrides: crate::protocol::SpawnOverrides {
19611 provider: crate::protocol::SpawnOverride::Set {
19612 value: agent("codex"),
19613 },
19614 approval_level: Some(level),
19615 network_allowlist: Some(allowlist.clone()),
19616 ..crate::protocol::SpawnOverrides::default()
19617 },
19618 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19619 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19620 "codex-net-refuse-{}",
19621 match level {
19622 ApprovalLevel::ReadOnly => "ro",
19623 ApprovalLevel::FullAuto => "fa",
19624 _ => "other",
19625 }
19626 ))
19627 .unwrap(),
19628 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19629 };
19630 let resolved = spec.resolve(&defaults).unwrap();
19631 let failure = shared
19632 .resolve_environment_profile(&resolved)
19633 .expect_err("RO/FullAuto must refuse");
19634 assert_eq!(
19635 failure.code,
19636 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19637 );
19638 }
19639 }
19640
19641 #[test]
19645 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19646 let (shared, _defaults) = station_knobs_fixture();
19647 let allowlist =
19648 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19649 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19650 id: allowlist.clone(),
19651 permits: vec![
19652 crate::network_allowlist_catalog::NetworkPermitSketch {
19653 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19654 peer: "github.com:443".to_owned(),
19655 },
19656 crate::network_allowlist_catalog::NetworkPermitSketch {
19657 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19658 peer: "*.npmjs.org".to_owned(),
19659 },
19660 ],
19661 provider_native: None,
19662 };
19663 shared
19664 .network_allowlist_catalog
19665 .write()
19666 .unwrap_or_else(|poisoned| poisoned.into_inner())
19667 .insert(entry)
19668 .unwrap();
19669
19670 let unique = std::time::SystemTime::now()
19671 .duration_since(std::time::UNIX_EPOCH)
19672 .unwrap()
19673 .as_nanos();
19674 let provider_home = std::env::temp_dir().join(format!(
19675 "gate4agent-claude-provider-home-{}-{unique}",
19676 std::process::id(),
19677 ));
19678 std::fs::create_dir_all(&provider_home).unwrap();
19679
19680 let overlay = shared
19681 .resolve_network_allowlist_overlay_args(
19682 &agent("claude"),
19683 SessionMode::Pty,
19684 ApprovalLevel::Moderate,
19685 Some(&allowlist),
19686 Some(provider_home.as_path()),
19687 )
19688 .expect("Claude PTY settings overlay on unix");
19689 let settings_path = provider_home.join(
19690 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19691 );
19692 assert_eq!(
19693 overlay,
19694 vec![
19695 std::ffi::OsString::from("--settings"),
19696 std::ffi::OsString::from(settings_path.as_os_str()),
19697 ]
19698 );
19699 let body = std::fs::read_to_string(&settings_path).unwrap();
19700 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19701 assert_eq!(value["sandbox"]["enabled"], true);
19702 assert_eq!(
19703 value["sandbox"]["network"]["allowedDomains"],
19704 serde_json::json!(["github.com", "*.npmjs.org"])
19705 );
19706 assert!(settings_path.starts_with(&provider_home));
19707
19708 let acp = shared
19709 .resolve_network_allowlist_overlay_args(
19710 &agent("claude"),
19711 SessionMode::Acp,
19712 ApprovalLevel::Moderate,
19713 Some(&allowlist),
19714 Some(provider_home.as_path()),
19715 )
19716 .expect_err("Claude ACP must refuse settings overlay");
19717 assert_eq!(
19718 acp.code,
19719 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19720 );
19721
19722 let no_home = shared
19723 .resolve_network_allowlist_overlay_args(
19724 &agent("claude"),
19725 SessionMode::Pty,
19726 ApprovalLevel::Moderate,
19727 Some(&allowlist),
19728 None,
19729 )
19730 .expect_err("Claude overlay requires ProviderHome");
19731 assert_eq!(
19732 no_home.code,
19733 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19734 );
19735
19736 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19737 shared
19738 .network_allowlist_catalog
19739 .write()
19740 .unwrap_or_else(|poisoned| poisoned.into_inner())
19741 .insert_id(bare.clone())
19742 .unwrap();
19743 let empty = shared
19744 .resolve_network_allowlist_overlay_args(
19745 &agent("claude"),
19746 SessionMode::Pty,
19747 ApprovalLevel::Moderate,
19748 Some(&bare),
19749 Some(provider_home.as_path()),
19750 )
19751 .unwrap();
19752 assert!(empty.is_empty());
19753
19754 let _ = std::fs::remove_dir_all(&provider_home);
19755 }
19756
19757 #[test]
19759 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19760 let (shared, _defaults) = station_knobs_fixture();
19761 let allowlist =
19762 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19763 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19764 id: allowlist.clone(),
19765 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19766 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19767 peer: "example.com:443".to_owned(),
19768 }],
19769 provider_native: None,
19770 };
19771 shared
19772 .network_allowlist_catalog
19773 .write()
19774 .unwrap_or_else(|poisoned| poisoned.into_inner())
19775 .insert(entry)
19776 .unwrap();
19777 for provider in ["kimi", "grok"] {
19778 let overlay = shared
19779 .resolve_network_allowlist_overlay_args(
19780 &agent(provider),
19781 SessionMode::Pty,
19782 ApprovalLevel::Moderate,
19783 Some(&allowlist),
19784 None,
19785 )
19786 .unwrap();
19787 assert!(
19788 overlay.is_empty(),
19789 "{provider} must not invent network argv from permits"
19790 );
19791 }
19792 }
19793
19794 #[test]
19796 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19797 let unique = std::time::SystemTime::now()
19798 .duration_since(std::time::UNIX_EPOCH)
19799 .unwrap()
19800 .as_nanos();
19801 let path = std::env::temp_dir().join(format!(
19802 "gate4agent-node-allowlist-json-{}-{unique}.json",
19803 std::process::id(),
19804 ));
19805 std::fs::write(
19806 &path,
19807 r#"{
19808 "schema_version": 2,
19809 "entries": [
19810 {
19811 "id": "egress-default",
19812 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19813 },
19814 { "id": "lab-egress" }
19815 ]
19816 }"#,
19817 )
19818 .unwrap();
19819 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19820 .expect("json v2 catalog must load");
19821 let _ = std::fs::remove_file(&path);
19822 assert_eq!(loaded.len(), 2);
19823 let entry = loaded
19824 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19825 .unwrap();
19826 assert_eq!(entry.permits.len(), 1);
19827 let (shared, defaults) = station_knobs_fixture();
19828 {
19829 let mut catalog = shared
19830 .network_allowlist_catalog
19831 .write()
19832 .unwrap_or_else(|poisoned| poisoned.into_inner());
19833 *catalog = loaded;
19834 }
19835 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19836 let spec = crate::protocol::SpawnSpec {
19837 target: crate::protocol::SpawnTarget {
19838 node_id: shared.node_id.clone(),
19839 workspace_id: WorkspaceId::new("primary").unwrap(),
19840 worktree_id: None,
19841 },
19842 profile_id: defaults.profile_id.clone(),
19843 expected_profile_revision: defaults.revision.clone(),
19844 overrides: crate::protocol::SpawnOverrides {
19845 network_allowlist: Some(allowlist.clone()),
19846 ..crate::protocol::SpawnOverrides::default()
19847 },
19848 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19849 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19850 "station-knobs-catalog-json",
19851 )
19852 .unwrap(),
19853 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19854 };
19855 let resolved = spec.resolve(&defaults).unwrap();
19856 let receipt = shared
19857 .resolve_environment_profile(&resolved)
19858 .expect("permits-only entry ok for claude")
19859 .expect("env profile present");
19860 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19861 let inventory = shared
19862 .snapshot()
19863 .launch_inventory
19864 .expect("launch inventory present");
19865 let ids = inventory.network_allowlists.expect("ids surface");
19866 assert_eq!(
19867 ids,
19868 vec![
19869 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19870 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19871 ]
19872 );
19873 }
19874
19875
19876}
19877
19878
19879#[cfg(test)]
19880mod workspace_environment_collision_tests {
19881 use super::*;
19882 use crate::session_environment::{
19883 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19884 };
19885 use gate4agent_catalog::EnvMutation;
19886 use gate4agent_runtime_native::{
19887 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19888 NativeLaunchProfileId,
19889 };
19890 use std::ffi::OsString;
19891 use std::sync::Arc;
19892
19893 struct EmptyEnvironmentResolver;
19894
19895 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19896 fn resolve_child_environment(
19897 &self,
19898 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19899 Ok(vec![EnvMutation {
19900 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19901 value: None,
19902 }])
19903 }
19904 }
19905
19906 fn agent(value: &str) -> AgentId {
19907 AgentId::new(value).unwrap()
19908 }
19909
19910 fn claude_config_dir_collision_fixture() -> (
19912 NodeShared,
19913 ResolvedEnvironmentProfileReceipt,
19914 ResolvedEnvironmentProfileReceipt,
19915 ) {
19916 let catalog = active_registry().unwrap();
19917 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19918 let control = runtime.native_launch_profile_control();
19919 let workspace = WorkspaceConfig::new(
19920 WorkspaceId::new("primary").unwrap(),
19921 std::env::current_dir().unwrap(),
19922 )
19923 .unwrap();
19924 let shared = NodeShared::new_with_incarnation(
19925 handle,
19926 "fixture-token".to_owned(),
19927 NodeId::new("node-env-collision-test").unwrap(),
19928 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19929 vec![workspace],
19930 vec![agent("claude")],
19931 ProviderRuntimeStatuses::default(),
19932 None,
19933 Vec::new(),
19934 Vec::new(),
19935 SpawnProfileRegistry::default(),
19936 Some(control),
19937 None,
19938 None,
19939 None,
19940 Vec::new(),
19941 Vec::new(),
19942 Vec::new(),
19943 Vec::new(),
19944 None,
19945 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19946 );
19947
19948 let mut receipts = Vec::new();
19949 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19950 let profile_id =
19951 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19952 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19953 "claude-config-dir-{suffix}-r1"
19954 ))
19955 .unwrap();
19956 let materialization = NodeSessionMaterializationProfile::new(
19957 Vec::new(),
19958 vec![NodeSessionPathBinding::new(
19959 "CLAUDE_CONFIG_DIR",
19960 NodeSessionPathClass::ProviderHome,
19961 )
19962 .unwrap()],
19963 Vec::new(),
19964 )
19965 .unwrap();
19966 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19967 profile_id.clone(),
19968 profile_revision.clone(),
19969 agent("claude"),
19970 [NativeLaunchProfile::new(
19971 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19972 .unwrap(),
19973 agent("claude"),
19974 TransportKind::Pty,
19975 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19976 Arc::new(EmptyEnvironmentResolver),
19977 )
19978 .unwrap()],
19979 Some(materialization),
19980 )
19981 .unwrap();
19982 let (binding, native_profiles, materialization) = node_profile.into_parts();
19983 for native_profile in native_profiles {
19984 runtime.upsert_native_launch_profile(native_profile).unwrap();
19985 }
19986 shared
19987 .environment_profiles
19988 .write()
19989 .unwrap_or_else(|poisoned| poisoned.into_inner())
19990 .insert(binding.id.clone(), binding);
19991 shared
19992 .environment_materialization_profiles
19993 .write()
19994 .unwrap_or_else(|poisoned| poisoned.into_inner())
19995 .insert(profile_id.clone(), materialization.unwrap());
19996 receipts.push(ResolvedEnvironmentProfileReceipt {
19997 profile_id,
19998 profile_revision,
19999 network_allowlist: None,
20000 browser_profile_id: None,
20001 });
20002 }
20003 let profile_b = receipts.pop().unwrap();
20004 let profile_a = receipts.pop().unwrap();
20005 (shared, profile_a, profile_b)
20006 }
20007
20008 #[test]
20009 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
20010 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
20011 let primary = WorkspaceId::new("primary").unwrap();
20012 let worktree = WorkspaceId::new("worktree-b").unwrap();
20013 let address = SessionAddress {
20014 workspace_id: primary.clone(),
20015 session: SessionKey {
20016 instance_id: AgentInstanceId(41),
20017 generation: SessionGeneration(1),
20018 },
20019 };
20020 shared.bind_session_with_policy(
20021 &address,
20022 ProviderRuntimePolicy::raw_pty(),
20023 Some(profile_a.clone()),
20024 );
20025
20026 let refused = shared
20027 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
20028 .unwrap_err();
20029 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
20030
20031 let refused_none = shared
20033 .ensure_workspace_environment_profile_compatible(&primary, None)
20034 .unwrap_err();
20035 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
20036
20037 shared
20039 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
20040 .unwrap();
20041
20042 shared
20044 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
20045 .unwrap();
20046 }
20047
20048 #[tokio::test]
20049 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
20050 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
20051 let primary = WorkspaceId::new("primary").unwrap();
20052 let address = SessionAddress {
20053 workspace_id: primary.clone(),
20054 session: SessionKey {
20055 instance_id: AgentInstanceId(41),
20056 generation: SessionGeneration(1),
20057 },
20058 };
20059 shared.bind_session_with_policy(
20060 &address,
20061 ProviderRuntimePolicy::raw_pty(),
20062 Some(profile_a),
20063 );
20064
20065 let failure = shared
20066 .spawn_session_with_deadline(
20067 primary,
20068 agent("claude"),
20069 SessionMode::Pty,
20070 gate4agent_types::TerminalSize {
20071 rows: 24,
20072 columns: 80,
20073 },
20074 None,
20075 Some(profile_b),
20076 None,
20077 None,
20078 None,
20079 None,
20080 SpawnRecordPolicy::ProviderIdentityOnly,
20081 Some(Instant::now() + Duration::from_secs(30)),
20082 &[],
20083 None,
20084 ApprovalLevel::default(),
20085 None,
20086 None,
20087 )
20088 .await
20089 .unwrap_err();
20090 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
20091 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20093 assert!(shared.handle.snapshot().sessions.is_empty());
20094 }
20095}
20096
20097#[cfg(test)]
20098mod spawn_characterization_tests {
20099 use super::*;
20100 use crate::session_environment::{
20101 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20102 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20103 NodeSessionPathClass,
20104 };
20105 use gate4agent_catalog::EnvMutation;
20106 use gate4agent_runtime_native::{
20107 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20108 NativeLaunchProfile, NativeLaunchProfileId,
20109 };
20110 use std::ffi::OsString;
20111 use std::sync::Arc;
20112
20113 struct EmptyEnvironmentResolver;
20114
20115 struct FixtureSecretResolver {
20116 deny: Arc<AtomicBool>,
20117 }
20118
20119 impl NodeSecretResolver for FixtureSecretResolver {
20120 fn resolve(
20121 &self,
20122 _reference: &NodeSecretReference,
20123 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20124 if self.deny.load(Ordering::Acquire) {
20125 Err(NodeSecretResolveError::Denied)
20126 } else {
20127 NodeSecretValue::text("fixture-secret")
20128 .map_err(|_| NodeSecretResolveError::Unavailable)
20129 }
20130 }
20131 }
20132
20133 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20134 fn resolve_child_environment(
20135 &self,
20136 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20137 Ok(vec![EnvMutation {
20138 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20139 value: None,
20140 }])
20141 }
20142 }
20143
20144 fn agent(value: &str) -> AgentId {
20145 AgentId::new(value).unwrap()
20146 }
20147
20148 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
20149 let unique = std::time::SystemTime::now()
20150 .duration_since(std::time::UNIX_EPOCH)
20151 .unwrap()
20152 .as_nanos();
20153 std::env::temp_dir().join(format!(
20154 "gate4agent-node-{label}-{}-{unique}",
20155 std::process::id(),
20156 ))
20157 }
20158
20159 fn materialization_test_shared(
20160 deny_secret: bool,
20161 ) -> (
20162 NodeShared,
20163 NativeRuntime,
20164 ResolvedEnvironmentProfileReceipt,
20165 PathBuf,
20166 Arc<AtomicBool>,
20167 ) {
20168 let workspace = WorkspaceConfig::new(
20169 WorkspaceId::new("primary").unwrap(),
20170 std::env::current_dir().unwrap(),
20171 )
20172 .unwrap();
20173 materialization_test_shared_with_workspace(deny_secret, workspace)
20174 }
20175
20176 fn materialization_test_shared_with_workspace(
20177 deny_secret: bool,
20178 workspace: WorkspaceConfig,
20179 ) -> (
20180 NodeShared,
20181 NativeRuntime,
20182 ResolvedEnvironmentProfileReceipt,
20183 PathBuf,
20184 Arc<AtomicBool>,
20185 ) {
20186 let root = temporary_workspace_root(if deny_secret {
20187 "materialization-denied"
20188 } else {
20189 "materialization-ready"
20190 });
20191 std::fs::create_dir_all(&root).unwrap();
20192 let materialization_root = root.join("session-environments");
20193 let deny = Arc::new(AtomicBool::new(deny_secret));
20194 let resolver = Arc::new(FixtureSecretResolver {
20195 deny: Arc::clone(&deny),
20196 });
20197 let materializer = SessionEnvironmentMaterializer::new(
20198 materialization_root,
20199 resolver,
20200 )
20201 .unwrap();
20202 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20203 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20204 "materialized-claude-r1",
20205 )
20206 .unwrap();
20207 let materialization = NodeSessionMaterializationProfile::new(
20208 vec![NodeSessionEnvironmentMutation::SetSecret {
20209 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20210 reference: NodeSecretReference::new("fixture-token").unwrap(),
20211 }],
20212 vec![NodeSessionPathBinding::new(
20213 "GATE4AGENT_PROVIDER_HOME",
20214 NodeSessionPathClass::ProviderHome,
20215 )
20216 .unwrap()],
20217 vec![NodeSessionFile::secret(
20218 NodeSessionPathClass::Config,
20219 "auth/token",
20220 NodeSecretReference::new("fixture-token").unwrap(),
20221 )
20222 .unwrap()],
20223 )
20224 .unwrap();
20225 let catalog = active_registry().unwrap();
20226 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20227 let control = runtime.native_launch_profile_control();
20228 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20229 profile_id.clone(),
20230 profile_revision.clone(),
20231 agent("claude"),
20232 [NativeLaunchProfile::new(
20233 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20234 agent("claude"),
20235 TransportKind::Pty,
20236 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20237 Arc::new(EmptyEnvironmentResolver),
20238 )
20239 .unwrap()],
20240 Some(materialization),
20241 )
20242 .unwrap();
20243 let (binding, native_profiles, materialization) = node_profile.into_parts();
20244 for native_profile in native_profiles {
20245 runtime.upsert_native_launch_profile(native_profile).unwrap();
20246 }
20247 let shared = NodeShared::new_with_incarnation(
20248 handle,
20249 "fixture-token".to_owned(),
20250 NodeId::new("node-materialization-test").unwrap(),
20251 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20252 vec![workspace],
20253 vec![agent("claude")],
20254 ProviderRuntimeStatuses::default(),
20255 None,
20256 Vec::new(),
20257 Vec::new(),
20258 SpawnProfileRegistry::default(),
20259 Some(control),
20260 None,
20261 Some(materializer),
20262 None,
20263 Vec::new(),
20264 Vec::new(),
20265 Vec::new(),
20266 Vec::new(),
20267 None,
20268 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20269 );
20270 shared
20271 .environment_profiles
20272 .write()
20273 .unwrap_or_else(|poisoned| poisoned.into_inner())
20274 .insert(binding.id.clone(), binding);
20275 shared
20276 .environment_materialization_profiles
20277 .write()
20278 .unwrap_or_else(|poisoned| poisoned.into_inner())
20279 .insert(profile_id.clone(), materialization.unwrap());
20280 (
20281 shared,
20282 runtime,
20283 ResolvedEnvironmentProfileReceipt {
20284 profile_id,
20285 profile_revision,
20286 network_allowlist: None,
20287 browser_profile_id: None,
20288 },
20289 root,
20290 deny,
20291 )
20292 }
20293
20294
20295 async fn characterization_spawn(
20298 shared: &NodeShared,
20299 mode: SessionMode,
20300 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20301 required: &[ProviderRuntimeCapability],
20302 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20303 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20304 shared
20305 .spawn_session_with_deadline(
20306 WorkspaceId::new("primary").unwrap(),
20307 agent("claude"),
20308 mode,
20309 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20310 None,
20311 environment_profile,
20312 None,
20313 None,
20314 None,
20315 Some(ProviderRuntimePolicy::raw_pty()),
20316 SpawnRecordPolicy::ProviderIdentityOnly,
20317 Some(Instant::now() + Duration::from_secs(30)),
20318 required,
20319 harness_mcp,
20320 ApprovalLevel::default(),
20321 None,
20322 None,
20323 )
20324 .await
20325 }
20326
20327 fn assert_no_session_state(shared: &NodeShared) {
20328 assert!(shared.handle.snapshot().sessions.is_empty());
20329 assert!(shared
20330 .session_bindings
20331 .lock()
20332 .unwrap_or_else(|poisoned| poisoned.into_inner())
20333 .is_empty());
20334 assert!(shared
20335 .materializations
20336 .lock()
20337 .unwrap_or_else(|poisoned| poisoned.into_inner())
20338 .is_empty());
20339 }
20340
20341 #[tokio::test]
20342 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20343 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20344 let error = characterization_spawn(
20345 &shared,
20346 SessionMode::Pty,
20347 None,
20348 &[ProviderRuntimeCapability::SemanticReadiness],
20349 None,
20350 )
20351 .await
20352 .unwrap_err();
20353 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20354 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20355 assert_no_session_state(&shared);
20356 drop(shared);
20357 drop(runtime);
20358 std::fs::remove_dir_all(root).unwrap();
20359 }
20360
20361 #[tokio::test]
20362 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20363 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20364 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20365 .await
20366 .unwrap_err();
20367 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20368 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20369 assert_no_session_state(&shared);
20370 drop(shared);
20371 drop(runtime);
20372 std::fs::remove_dir_all(root).unwrap();
20373 }
20374
20375 #[test]
20376 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20377 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20378 let address = SessionAddress {
20379 workspace_id: WorkspaceId::new("primary").unwrap(),
20380 session: SessionKey {
20381 instance_id: AgentInstanceId(77),
20382 generation: SessionGeneration(1),
20383 },
20384 };
20385 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20386 let removed = shared.remove_binding(&address).expect("binding removed");
20387 assert!(removed.environment_profile.is_some());
20388 assert!(shared.remove_binding(&address).is_none());
20389 assert_no_session_state(&shared);
20390 drop(shared);
20391 drop(runtime);
20392 std::fs::remove_dir_all(root).unwrap();
20393 }
20394
20395 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20396 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20397 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20398 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20399 .expect("review current test executable as fixture helper");
20400 let registry = HarnessMcpProxyRegistry::new(helper);
20401 let spec = crate::protocol::SpawnSpec {
20402 target: crate::protocol::SpawnTarget {
20403 node_id: NodeId::new("node-characterization").unwrap(),
20404 workspace_id: WorkspaceId::new("primary").unwrap(),
20405 worktree_id: None,
20406 },
20407 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20408 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20409 overrides: crate::protocol::SpawnOverrides::default(),
20410 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20411 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20412 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20413 };
20414 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20415 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20416 let launch = HarnessMcpLaunchV1 {
20417 server_name: "fixture-harness".to_owned(),
20418 args: vec!["--session-proxy".to_owned()],
20419 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20420 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20421 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20422 trace: None,
20423 scrub_env: Vec::new(),
20424 };
20425 let expires = unix_time_ms().saturating_add(30_000);
20426 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20427 registry
20428 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20429 .unwrap()
20430 }
20431
20432 #[tokio::test]
20433 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20434 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20435 let prepared = characterization_prepared_harness("claude").await;
20436 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20437 .await
20438 .unwrap_err();
20439 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20441 let mismatched = characterization_prepared_harness("codex").await;
20442 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20443 .await
20444 .unwrap_err();
20445 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20446 assert_no_session_state(&shared);
20447 drop(shared);
20448 drop(runtime);
20449 std::fs::remove_dir_all(root).unwrap();
20450 }
20451
20452 #[tokio::test]
20453 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20454 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20455 shared.native_launch_profile_control = None;
20456 let prepared = characterization_prepared_harness("claude").await;
20457 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20458 .await
20459 .unwrap_err();
20460 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20461 assert_no_session_state(&shared);
20462 drop(shared);
20463 drop(runtime);
20464 std::fs::remove_dir_all(root).unwrap();
20465 }
20466}
20467
20468#[cfg(all(test, windows))]
20469mod tests {
20470 use super::*;
20471 use gate4agent_types::ProviderActivity;
20472 use crate::session_environment::{
20473 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20474 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20475 NodeSessionPathClass,
20476 };
20477 use gate4agent_catalog::EnvMutation;
20478 use gate4agent_runtime_native::{
20479 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20480 NativeLaunchProfile, NativeLaunchProfileId,
20481 };
20482 use std::ffi::OsString;
20483 use std::sync::Arc;
20484
20485 struct EmptyEnvironmentResolver;
20486
20487 struct FixtureSecretResolver {
20488 deny: Arc<AtomicBool>,
20489 }
20490
20491 impl NodeSecretResolver for FixtureSecretResolver {
20492 fn resolve(
20493 &self,
20494 _reference: &NodeSecretReference,
20495 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20496 if self.deny.load(Ordering::Acquire) {
20497 Err(NodeSecretResolveError::Denied)
20498 } else {
20499 NodeSecretValue::text("fixture-secret")
20500 .map_err(|_| NodeSecretResolveError::Unavailable)
20501 }
20502 }
20503 }
20504
20505 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20506 fn resolve_child_environment(
20507 &self,
20508 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20509 Ok(vec![EnvMutation {
20510 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20511 value: None,
20512 }])
20513 }
20514 }
20515
20516 fn agent(value: &str) -> AgentId {
20517 AgentId::new(value).unwrap()
20518 }
20519
20520 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20521 let catalog = active_registry().unwrap();
20522 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20523 let workspace = WorkspaceConfig::new(
20524 WorkspaceId::new("primary").unwrap(),
20525 std::env::current_dir().unwrap(),
20526 )
20527 .unwrap()
20528 .with_worktree_service_mode(mode);
20529 NodeShared::new(
20530 handle,
20531 "fixture-token".to_owned(),
20532 NodeId::new("node-terminal-test").unwrap(),
20533 vec![workspace],
20534 vec![agent("claude")],
20535 )
20536 }
20537
20538 fn terminal_test_shared() -> NodeShared {
20539 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20540 }
20541
20542 #[cfg(feature = "fixture")]
20543 #[test]
20544 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20545 let probe = SpawnManagedWorktreeV2FailureProbe::default();
20546 assert_eq!(probe.latest_code(), None);
20547
20548 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20549 assert_eq!(
20550 probe.latest_code(),
20551 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20552 );
20553
20554 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20555 assert_eq!(
20556 probe.latest_code(),
20557 Some(NodeFailureCode::SpawnDeadlineExceeded),
20558 );
20559
20560 probe.record(None);
20561 assert_eq!(probe.latest_code(), None);
20562 }
20563
20564 #[test]
20565 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20566 let source = temporary_workspace_root("launch-inventory-source");
20567 let allocation = temporary_workspace_root("launch-inventory-allocation");
20568 std::fs::create_dir_all(&source).unwrap();
20569 std::fs::create_dir_all(&allocation).unwrap();
20570 let workspace_id = WorkspaceId::new("primary").unwrap();
20571 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20572 .unwrap()
20573 .with_worktree_service_mode(WorktreeServiceMode::Managed);
20574 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20575 workspace = workspace
20576 .with_managed_worktree_profile(
20577 ManagedWorktreeProfile::new(
20578 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20579 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20580 &allocation,
20581 "gate4agent",
20582 "HEAD",
20583 ManagedWorktreeRetention::RemoveWhenReleased,
20584 )
20585 .unwrap(),
20586 )
20587 .unwrap();
20588 }
20589 let overflow = workspace.clone().with_managed_worktree_profile(
20590 ManagedWorktreeProfile::new(
20591 WorktreeProfileId::new("overflow").unwrap(),
20592 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20593 &allocation,
20594 "gate4agent",
20595 "HEAD",
20596 ManagedWorktreeRetention::Retain,
20597 )
20598 .unwrap(),
20599 );
20600 assert!(matches!(
20601 overflow,
20602 Err(NodeServerError::ManagedWorktreeProfileCapacity {
20603 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20604 ..
20605 }),
20606 ));
20607
20608 let catalog = active_registry().unwrap();
20609 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20610 let shared = NodeShared::new(
20611 handle,
20612 "fixture-token".to_owned(),
20613 NodeId::new("node-launch-inventory").unwrap(),
20614 vec![workspace],
20615 vec![agent("claude")],
20616 );
20617 let snapshot = shared.snapshot();
20618 let launch = snapshot.launch_inventory.as_ref().unwrap();
20619 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20620 assert!(launch.bundles.as_ref().unwrap().is_empty());
20621 let profiles = snapshot.workspaces[0]
20622 .managed_worktree_profiles
20623 .as_ref()
20624 .unwrap();
20625 assert_eq!(
20626 snapshot.workspaces[0].worktree_service_mode,
20627 Some(WorktreeServiceMode::Managed),
20628 );
20629 assert_eq!(
20630 profiles.profiles.len(),
20631 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20632 );
20633 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20634 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20635 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20636 }
20637 let legacy = snapshot_for_wire(
20638 &shared,
20639 true,
20640 true,
20641 false,
20642 false,
20643 true,
20644 false,
20645 true,
20646 false,
20647 true,
20648 );
20649 assert!(legacy.launch_inventory.is_none());
20650 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20651
20652 drop(shared);
20653 std::fs::remove_dir_all(source).unwrap();
20654 std::fs::remove_dir_all(allocation).unwrap();
20655 }
20656
20657 #[test]
20658 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20659 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20660 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20661 }));
20662 let source = gate4agent_types::ProviderSource {
20663 family: AdapterFamily::PtySemantic,
20664 binding: AdapterBinding::new(
20665 gate4agent_types::AdapterId::new("claude").unwrap(),
20666 "fixture/v1",
20667 gate4agent_types::AdapterVerification::SyntheticFixture,
20668 )
20669 .unwrap(),
20670 };
20671 let provider = ProviderSnapshot {
20672 sequence: 17,
20673 session: Some(ProviderSessionIdentity {
20674 key: ProviderSessionKey::SessionId,
20675 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20676 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20677 }),
20678 model: Some("PRIVATE_MODEL".to_owned()),
20679 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20680 completed_turns: 3,
20681 usage: gate4agent_types::TokenUsage {
20682 input_tokens: 10,
20683 output_tokens: 20,
20684 cache_read_tokens: 30,
20685 cache_write_tokens: 40,
20686 reasoning_tokens: 50,
20687 context_window: Some(200_000),
20688 },
20689 lead_activity: ProviderActivity::WaitingForInput,
20690 activity: ProviderActivity::WaitingForInput,
20691 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20692 active_tools: (0..9)
20693 .map(|index| gate4agent_types::ActiveProviderTool {
20694 id: format!("private-tool-id-{index}"),
20695 name: if index == 0 {
20696 format!(" Read\u{0000}{}", "x".repeat(80))
20697 } else {
20698 format!("Tool-{index}")
20699 },
20700 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20701 })
20702 .collect(),
20703 interactions: vec![gate4agent_types::ProviderInteraction {
20704 id: gate4agent_types::ProviderInteractionId(17),
20705 source: source.clone(),
20706 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20707 interaction_kind: ProviderInteractionKind::Approval,
20708 tool_name: " Approve\u{0007}Tool ".to_owned(),
20709 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20710 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20711 resume_lead_activity: Some(ProviderActivity::Working),
20712 status: ProviderInteractionStatus::Pending,
20713 }],
20714 subagents: vec![gate4agent_types::ProviderSubagent {
20715 source: source.clone(),
20716 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20717 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20718 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20719 }],
20720 sources: vec![gate4agent_types::ProviderSourceCursor {
20721 source,
20722 sequence: 17,
20723 gap_count: 2,
20724 stale: true,
20725 }],
20726 last_event: Some(ProviderEvent::Error {
20727 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20728 }),
20729 gap_count: 2,
20730 stale: true,
20731 };
20732 let address = terminal_address(3);
20733 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20734 assert_eq!(entry.address, address);
20735 assert_eq!(entry.progress.provider_sequence, 17);
20736 assert_eq!(entry.progress.active_tool_count, 9);
20737 assert_eq!(
20738 entry.progress.active_tool_labels.len(),
20739 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20740 );
20741 assert_eq!(entry.progress.subagent_count, 1);
20742 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20743 assert!(entry.progress.truncated);
20744 let encoded = serde_json::to_string(&entry).unwrap();
20745 for forbidden in [
20746 "PRIVATE_PROVIDER_SESSION",
20747 "PRIVATE_PROVIDER_PATH",
20748 "PRIVATE_MODEL",
20749 "PRIVATE_TOOL_INVENTORY",
20750 "PRIVATE_CURRENT_PROMPT",
20751 "PRIVATE_TOOL_INPUT_JSON",
20752 "PRIVATE_PROVIDER_REQUEST",
20753 "PRIVATE_INTERACTION_PROMPT",
20754 "PRIVATE_INTERACTION_AGENT",
20755 "PRIVATE_SUBAGENT_ID",
20756 "PRIVATE_SUBAGENT_TYPE",
20757 "PRIVATE_SUBAGENT_DESCRIPTION",
20758 "PRIVATE_PROVIDER_ERROR",
20759 "input_json",
20760 "current_prompt",
20761 "session_id",
20762 ] {
20763 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20764 }
20765
20766 let snapshot = NodeSnapshot {
20767 node_id: NodeId::new("node-progress-test").unwrap(),
20768 enabled_providers: vec![agent("claude")],
20769 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20770 workspaces: Vec::new(),
20771 session_records: Vec::new(),
20772 managed_worktrees: Vec::new(),
20773 launch_inventory: None,
20774 agent_progress: vec![entry],
20775 };
20776 let mut legacy_reply = ResponseEnvelope {
20777 request_id: 1,
20778 result: Ok(NodeResponse::Snapshot {
20779 event_sequence: 0,
20780 controller: None,
20781 snapshot: snapshot.clone(),
20782 }),
20783 };
20784 project_response_without_agent_progress(&mut legacy_reply);
20785 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20786 panic!("agent progress projection changed response kind");
20787 };
20788 assert!(legacy.agent_progress.is_empty());
20789 assert_eq!(snapshot.agent_progress.len(), 1);
20790 }
20791
20792 fn terminal_address(generation: u64) -> SessionAddress {
20793 SessionAddress {
20794 workspace_id: WorkspaceId::new("primary").unwrap(),
20795 session: SessionKey {
20796 instance_id: AgentInstanceId(41),
20797 generation: SessionGeneration(generation),
20798 },
20799 }
20800 }
20801
20802 fn spawn_spec_fixture() -> SpawnSpec {
20803 SpawnSpec {
20804 target: crate::protocol::SpawnTarget {
20805 node_id: NodeId::new("node-terminal-test").unwrap(),
20806 workspace_id: WorkspaceId::new("primary").unwrap(),
20807 worktree_id: None,
20808 },
20809 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20810 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20811 .unwrap(),
20812 overrides: crate::protocol::SpawnOverrides::default(),
20813 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20814 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20815 required_capabilities: SpawnRequiredCapabilities::default(),
20816 }
20817 }
20818
20819 fn environment_profile_test_shared(
20820 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20821 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20822 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20823 "local-claude-r1",
20824 )
20825 .unwrap();
20826 let spawn_profiles = SpawnProfileRegistry::new([
20827 crate::protocol::SpawnProfileDefaults {
20828 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20829 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20830 provider: agent("claude"),
20831 mode: SessionMode::Pty,
20832 terminal_size: gate4agent_types::TerminalSize {
20833 rows: 24,
20834 columns: 80,
20835 },
20836 prompt: None,
20837 bundle_id: None,
20838 context_id: None,
20839 environment_profile_id: Some(profile_id.clone()),
20840 },
20841 ])
20842 .unwrap();
20843 let catalog = active_registry().unwrap();
20844 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20845 let control = runtime.native_launch_profile_control();
20846 let node_profile = NodeEnvironmentProfile::new(
20847 profile_id.clone(),
20848 profile_revision.clone(),
20849 agent("claude"),
20850 [NativeLaunchProfile::new(
20851 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20852 agent("claude"),
20853 TransportKind::Pty,
20854 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20855 Arc::new(EmptyEnvironmentResolver),
20856 )
20857 .unwrap()],
20858 )
20859 .unwrap();
20860 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20861 for native_profile in native_profiles {
20862 runtime.upsert_native_launch_profile(native_profile).unwrap();
20863 }
20864 let workspace = WorkspaceConfig::new(
20865 WorkspaceId::new("primary").unwrap(),
20866 std::env::current_dir().unwrap(),
20867 )
20868 .unwrap();
20869 let shared = NodeShared::new_with_incarnation(
20870 handle,
20871 "fixture-token".to_owned(),
20872 NodeId::new("node-terminal-test").unwrap(),
20873 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20874 vec![workspace],
20875 vec![agent("claude")],
20876 ProviderRuntimeStatuses::default(),
20877 None,
20878 Vec::new(),
20879 Vec::new(),
20880 spawn_profiles,
20881 Some(control),
20882 None,
20883 None,
20884 None,
20885 Vec::new(),
20886 Vec::new(),
20887 Vec::new(),
20888 Vec::new(),
20889 None,
20890 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20891 );
20892 shared
20893 .environment_profiles
20894 .write()
20895 .unwrap_or_else(|poisoned| poisoned.into_inner())
20896 .insert(binding.id.clone(), binding);
20897 (
20898 shared,
20899 runtime,
20900 ResolvedEnvironmentProfileReceipt {
20901 profile_id,
20902 profile_revision,
20903 network_allowlist: None,
20904 browser_profile_id: None,
20905 },
20906 )
20907 }
20908
20909 fn materialization_test_shared(
20910 deny_secret: bool,
20911 ) -> (
20912 NodeShared,
20913 NativeRuntime,
20914 ResolvedEnvironmentProfileReceipt,
20915 PathBuf,
20916 Arc<AtomicBool>,
20917 ) {
20918 let workspace = WorkspaceConfig::new(
20919 WorkspaceId::new("primary").unwrap(),
20920 std::env::current_dir().unwrap(),
20921 )
20922 .unwrap();
20923 materialization_test_shared_with_workspace(deny_secret, workspace)
20924 }
20925
20926 fn materialization_test_shared_with_workspace(
20927 deny_secret: bool,
20928 workspace: WorkspaceConfig,
20929 ) -> (
20930 NodeShared,
20931 NativeRuntime,
20932 ResolvedEnvironmentProfileReceipt,
20933 PathBuf,
20934 Arc<AtomicBool>,
20935 ) {
20936 let root = temporary_workspace_root(if deny_secret {
20937 "materialization-denied"
20938 } else {
20939 "materialization-ready"
20940 });
20941 std::fs::create_dir_all(&root).unwrap();
20942 let materialization_root = root.join("session-environments");
20943 let deny = Arc::new(AtomicBool::new(deny_secret));
20944 let resolver = Arc::new(FixtureSecretResolver {
20945 deny: Arc::clone(&deny),
20946 });
20947 let materializer = SessionEnvironmentMaterializer::new(
20948 materialization_root,
20949 resolver,
20950 )
20951 .unwrap();
20952 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20953 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20954 "materialized-claude-r1",
20955 )
20956 .unwrap();
20957 let materialization = NodeSessionMaterializationProfile::new(
20958 vec![NodeSessionEnvironmentMutation::SetSecret {
20959 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20960 reference: NodeSecretReference::new("fixture-token").unwrap(),
20961 }],
20962 vec![NodeSessionPathBinding::new(
20963 "GATE4AGENT_PROVIDER_HOME",
20964 NodeSessionPathClass::ProviderHome,
20965 )
20966 .unwrap()],
20967 vec![NodeSessionFile::secret(
20968 NodeSessionPathClass::Config,
20969 "auth/token",
20970 NodeSecretReference::new("fixture-token").unwrap(),
20971 )
20972 .unwrap()],
20973 )
20974 .unwrap();
20975 let catalog = active_registry().unwrap();
20976 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20977 let control = runtime.native_launch_profile_control();
20978 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20979 profile_id.clone(),
20980 profile_revision.clone(),
20981 agent("claude"),
20982 [NativeLaunchProfile::new(
20983 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20984 agent("claude"),
20985 TransportKind::Pty,
20986 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20987 Arc::new(EmptyEnvironmentResolver),
20988 )
20989 .unwrap()],
20990 Some(materialization),
20991 )
20992 .unwrap();
20993 let (binding, native_profiles, materialization) = node_profile.into_parts();
20994 for native_profile in native_profiles {
20995 runtime.upsert_native_launch_profile(native_profile).unwrap();
20996 }
20997 let shared = NodeShared::new_with_incarnation(
20998 handle,
20999 "fixture-token".to_owned(),
21000 NodeId::new("node-materialization-test").unwrap(),
21001 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
21002 vec![workspace],
21003 vec![agent("claude")],
21004 ProviderRuntimeStatuses::default(),
21005 None,
21006 Vec::new(),
21007 Vec::new(),
21008 SpawnProfileRegistry::default(),
21009 Some(control),
21010 None,
21011 Some(materializer),
21012 None,
21013 Vec::new(),
21014 Vec::new(),
21015 Vec::new(),
21016 Vec::new(),
21017 None,
21018 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
21019 );
21020 shared
21021 .environment_profiles
21022 .write()
21023 .unwrap_or_else(|poisoned| poisoned.into_inner())
21024 .insert(binding.id.clone(), binding);
21025 shared
21026 .environment_materialization_profiles
21027 .write()
21028 .unwrap_or_else(|poisoned| poisoned.into_inner())
21029 .insert(profile_id.clone(), materialization.unwrap());
21030 (
21031 shared,
21032 runtime,
21033 ResolvedEnvironmentProfileReceipt {
21034 profile_id,
21035 profile_revision,
21036 network_allowlist: None,
21037 browser_profile_id: None,
21038 },
21039 root,
21040 deny,
21041 )
21042 }
21043
21044 #[cfg(feature = "fixture")]
21045 #[derive(Clone, Copy)]
21046 enum CodexHomeProfile {
21047 Missing,
21048 WrongClass,
21049 Exact,
21050 }
21051
21052 #[cfg(feature = "fixture")]
21053 fn codex_bundle_test_shared(
21054 home_profile: CodexHomeProfile,
21055 ) -> (
21056 NodeShared,
21057 NativeRuntime,
21058 ResolvedEnvironmentProfileReceipt,
21059 NodeBundle,
21060 PathBuf,
21061 ) {
21062 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
21063 const CLAUDE_MANIFEST: &[u8] =
21064 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
21065 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
21066 const DIGEST: &str =
21067 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
21068
21069 let (shared, mut runtime, _claude_receipt, root, _deny) =
21070 materialization_test_shared(false);
21071 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
21072 CodexHomeProfile::Missing => "codex-home-missing",
21073 CodexHomeProfile::WrongClass => "codex-home-wrong",
21074 CodexHomeProfile::Exact => "codex-home-exact",
21075 })
21076 .unwrap();
21077 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
21078 "codex-home-r1",
21079 )
21080 .unwrap();
21081 let materialization = match home_profile {
21082 CodexHomeProfile::Missing => None,
21083 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
21084 Some(NodeSessionMaterializationProfile::new(
21085 Vec::new(),
21086 vec![NodeSessionPathBinding::new(
21087 "CODEX_HOME",
21088 if matches!(home_profile, CodexHomeProfile::Exact) {
21089 NodeSessionPathClass::ProviderHome
21090 } else {
21091 NodeSessionPathClass::Config
21092 },
21093 )
21094 .unwrap()],
21095 Vec::new(),
21096 )
21097 .unwrap())
21098 }
21099 };
21100 let node_profile = NodeEnvironmentProfile::new_with_materialization(
21101 profile_id.clone(),
21102 profile_revision.clone(),
21103 agent("codex"),
21104 [NativeLaunchProfile::new(
21105 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
21106 agent("codex"),
21107 TransportKind::Pty,
21108 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
21109 Arc::new(EmptyEnvironmentResolver),
21110 )
21111 .unwrap()],
21112 materialization,
21113 )
21114 .unwrap();
21115 let (binding, native_profiles, materialization) = node_profile.into_parts();
21116 for native_profile in native_profiles {
21117 runtime.upsert_native_launch_profile(native_profile).unwrap();
21118 }
21119 shared
21120 .environment_profiles
21121 .write()
21122 .unwrap_or_else(|poisoned| poisoned.into_inner())
21123 .insert(binding.id.clone(), binding);
21124 if let Some(materialization) = materialization {
21125 shared
21126 .environment_materialization_profiles
21127 .write()
21128 .unwrap_or_else(|poisoned| poisoned.into_inner())
21129 .insert(profile_id.clone(), materialization);
21130 }
21131
21132 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
21133 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
21134 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
21135 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
21136 std::fs::write(
21137 bundle_source.join(".claude-plugin/plugin.json"),
21138 CLAUDE_MANIFEST,
21139 )
21140 .unwrap();
21141 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
21142 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
21143 let bundle = NodeBundle::new(
21144 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
21145 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
21146 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
21147 &bundle_source,
21148 )
21149 .unwrap();
21150 *shared
21151 .bundle_catalog
21152 .write()
21153 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21154 BundleCatalog::new([bundle.clone()]).unwrap();
21155 (
21156 shared,
21157 runtime,
21158 ResolvedEnvironmentProfileReceipt {
21159 profile_id,
21160 profile_revision,
21161 network_allowlist: None,
21162 browser_profile_id: None,
21163 },
21164 bundle,
21165 root,
21166 )
21167 }
21168
21169 #[cfg(feature = "fixture")]
21170 #[test]
21171 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
21172 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
21173 let (shared, runtime, receipt, bundle, root) =
21174 codex_bundle_test_shared(home_profile);
21175 let error = shared
21176 .bundle_layout(
21177 &agent("codex"),
21178 SessionMode::Pty,
21179 Some(&receipt),
21180 &bundle,
21181 )
21182 .unwrap_err();
21183 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
21184 assert!(shared
21185 .materializations
21186 .lock()
21187 .unwrap_or_else(|poisoned| poisoned.into_inner())
21188 .is_empty());
21189 let materialization_root = root.join("session-environments");
21190 assert!(!std::fs::read_dir(&materialization_root)
21191 .unwrap()
21192 .filter_map(Result::ok)
21193 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
21194 drop(shared);
21195 drop(runtime);
21196 let _ = std::fs::remove_dir_all(root);
21197 }
21198 }
21199
21200 #[cfg(feature = "fixture")]
21201 #[test]
21202 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
21203 let (shared, runtime, receipt, bundle, root) =
21204 codex_bundle_test_shared(CodexHomeProfile::Exact);
21205 let address = SessionAddress {
21206 workspace_id: WorkspaceId::new("primary").unwrap(),
21207 session: SessionKey {
21208 instance_id: AgentInstanceId(310),
21209 generation: SessionGeneration(1),
21210 },
21211 };
21212 let (overlay, guard) = shared
21213 .prepare_session_materialization(
21214 &address,
21215 &agent("codex"),
21216 SessionMode::Pty,
21217 Some(&receipt),
21218 Some(&bundle.receipt()),
21219 None,
21220 None,
21221 )
21222 .unwrap()
21223 .unwrap();
21224 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
21225 let ownership = shared
21226 .materializations
21227 .lock()
21228 .unwrap_or_else(|poisoned| poisoned.into_inner())
21229 .get(guard.id().unwrap())
21230 .unwrap()
21231 .clone();
21232 let (profile, _, _) = shared
21233 .materialization_profile(
21234 &agent("codex"),
21235 SessionMode::Pty,
21236 Some(&receipt),
21237 Some(&bundle.receipt()),
21238 None,
21239 )
21240 .unwrap()
21241 .unwrap();
21242 let environment = shared
21243 .session_environment_materializer
21244 .as_ref()
21245 .unwrap()
21246 .resolve_environment(&ownership, &profile)
21247 .unwrap();
21248 assert!(environment.iter().any(|mutation| {
21249 mutation.key == OsString::from("CODEX_HOME")
21250 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21251 }));
21252 assert_eq!(
21253 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21254 .unwrap(),
21255 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21256 );
21257 assert!(std::fs::read_dir(ownership.bundle_root())
21258 .unwrap()
21259 .next()
21260 .is_none());
21261 drop(guard);
21262 drop(shared);
21263 drop(runtime);
21264 let _ = std::fs::remove_dir_all(root);
21265 }
21266
21267 #[cfg(feature = "fixture")]
21268 #[test]
21269 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21270 let (shared, runtime, receipt, bundle, root) =
21271 codex_bundle_test_shared(CodexHomeProfile::Exact);
21272 let bundle_receipt = bundle.receipt();
21273 let address = SessionAddress {
21274 workspace_id: WorkspaceId::new("primary").unwrap(),
21275 session: SessionKey {
21276 instance_id: AgentInstanceId(311),
21277 generation: SessionGeneration(1),
21278 },
21279 };
21280 let (_overlay, guard) = shared
21281 .prepare_session_materialization(
21282 &address,
21283 &agent("codex"),
21284 SessionMode::Pty,
21285 Some(&receipt),
21286 Some(&bundle_receipt),
21287 None,
21288 None,
21289 )
21290 .unwrap()
21291 .unwrap();
21292 let materialization_id = guard.id().unwrap().clone();
21293 let skill_path = shared
21294 .materializations
21295 .lock()
21296 .unwrap_or_else(|poisoned| poisoned.into_inner())
21297 .get(&materialization_id)
21298 .unwrap()
21299 .provider_home()
21300 .join("skills/review-code/SKILL.md");
21301 let record_id = shared
21302 .bind_spawn_session_with_materialization(
21303 &address,
21304 agent("codex"),
21305 SessionMode::Pty,
21306 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21307 SpawnRecordPolicy::ProviderIdentityOnly,
21308 Some(receipt.clone()),
21309 Some(bundle_receipt.clone()),
21310 None,
21311 Some(materialization_id.clone()),
21312 )
21313 .unwrap()
21314 .unwrap();
21315 guard.retain();
21316 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21317
21318 let error = match shared.resolve_record_materialization(
21319 &record_id,
21320 &agent("codex"),
21321 SessionMode::Pty,
21322 Some(&receipt),
21323 Some(&bundle_receipt),
21324 None,
21325 ) {
21326 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21327 Err(error) => error,
21328 };
21329 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21330 assert_eq!(
21331 shared
21332 .materializations
21333 .lock()
21334 .unwrap_or_else(|poisoned| poisoned.into_inner())
21335 .get(&materialization_id)
21336 .unwrap()
21337 .state(),
21338 MaterializationState::RecoveryRequired,
21339 );
21340 drop(shared);
21341 drop(runtime);
21342 let _ = std::fs::remove_dir_all(root);
21343 }
21344
21345 fn bind_record_owned_materialization_fixture(
21346 shared: &NodeShared,
21347 receipt: &ResolvedEnvironmentProfileReceipt,
21348 instance_id: u64,
21349 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21350 let address = SessionAddress {
21351 workspace_id: WorkspaceId::new("primary").unwrap(),
21352 session: SessionKey {
21353 instance_id: AgentInstanceId(instance_id),
21354 generation: SessionGeneration(1),
21355 },
21356 };
21357 let (overlay, materialization_guard) = shared
21358 .prepare_session_materialization(
21359 &address,
21360 &agent("claude"),
21361 SessionMode::Pty,
21362 Some(receipt),
21363 None,
21364 None,
21365 None,
21366 )
21367 .unwrap()
21368 .unwrap();
21369 let materialization_id = materialization_guard.id().unwrap().clone();
21370 let materialization_root = shared
21371 .materializations
21372 .lock()
21373 .unwrap_or_else(|poisoned| poisoned.into_inner())
21374 .get(&materialization_id)
21375 .unwrap()
21376 .root()
21377 .to_path_buf();
21378 let selection = shared
21379 .select_environment_profile(
21380 address.session.instance_id,
21381 &agent("claude"),
21382 SessionMode::Pty,
21383 Some(receipt),
21384 )
21385 .unwrap()
21386 .unwrap();
21387 assert!(shared
21388 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21389 .unwrap()
21390 .is_none());
21391 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21392 let record_id = shared
21393 .bind_spawn_session_with_materialization(
21394 &address,
21395 agent("claude"),
21396 SessionMode::Pty,
21397 policy,
21398 SpawnRecordPolicy::ProviderIdentityOnly,
21399 Some(receipt.clone()),
21400 None,
21401 None,
21402 Some(materialization_id.clone()),
21403 )
21404 .unwrap()
21405 .unwrap();
21406 selection.retain();
21407 materialization_guard.retain();
21408 (address, record_id, materialization_id, materialization_root)
21409 }
21410
21411 #[test]
21412 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21413 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21414 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21415 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21416 }));
21417 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21418 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21419 let mut spec = spawn_spec_fixture();
21420 spec.expected_profile_revision =
21421 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21422 assert!(matches!(
21423 &spec.overrides.environment_profile_id,
21424 crate::protocol::SpawnOverride::Inherit,
21425 ));
21426 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21427 .requires_child_environment_profile_capability());
21428 assert!(request_requires_child_environment_profile(
21429 &shared,
21430 &NodeRequest::SpawnSpec { spec: spec.clone() },
21431 ));
21432
21433 let address = terminal_address(1);
21434 shared.bind_session_with_policy(
21435 &address,
21436 ProviderRuntimePolicy::raw_pty(),
21437 Some(environment_profile.clone()),
21438 );
21439 assert!(request_requires_child_environment_profile(
21440 &shared,
21441 &NodeRequest::Input {
21442 session: address.clone(),
21443 text: "x".to_owned(),
21444 },
21445 ));
21446
21447 let mut record = record("claude", "profile-record");
21448 record.active_session = Some(address.clone());
21449 record.environment_profile = Some(environment_profile.clone());
21450 let record_id = record.record_id.clone();
21451 shared.insert_record(record.clone()).unwrap();
21452 assert!(request_requires_child_environment_profile(
21453 &shared,
21454 &NodeRequest::ResumeSessionRecord {
21455 record_id,
21456 terminal_size: gate4agent_types::TerminalSize {
21457 rows: 24,
21458 columns: 80,
21459 },
21460 initial_prompt: None,
21461 },
21462 ));
21463
21464 let snapshot = snapshot_for_wire(
21465 &shared, false, true, true, true, false, false, false, false, true,
21466 );
21467 assert_eq!(snapshot.session_records.len(), 1);
21468 assert!(snapshot.session_records[0].environment_profile.is_none());
21469 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21470 sequence: 1,
21471 event: NodeEvent::SessionRecordUpserted { record },
21472 });
21473 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21474 panic!("record projection changed the event kind");
21475 };
21476 assert!(record.environment_profile.is_none());
21477
21478 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21479 let mut reply = ResponseEnvelope {
21480 request_id: 1,
21481 result: Ok(NodeResponse::SpawnSpecAccepted {
21482 receipt: resolved.receipt_with_environment(
21483 shared.incarnation_id,
21484 address,
21485 Some(environment_profile),
21486 ),
21487 }),
21488 };
21489 project_response_without_child_environment_profile(&mut reply);
21490 assert_eq!(
21491 reply.result.unwrap_err().code,
21492 NodeFailureCode::UnsupportedCapability,
21493 );
21494 }
21495
21496 #[test]
21497 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21498 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21499 let snapshot = shared.snapshot();
21500 let profiles = snapshot
21501 .launch_inventory
21502 .as_ref()
21503 .and_then(|inventory| inventory.spawn_profiles.as_ref())
21504 .unwrap();
21505 assert_eq!(profiles.len(), 1);
21506 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21507 assert!(snapshot.requires_child_environment_profile_capability());
21508
21509 let mut legacy = snapshot.clone();
21510 clear_snapshot_child_environment_profiles(&mut legacy);
21511 assert!(legacy
21512 .launch_inventory
21513 .as_ref()
21514 .unwrap()
21515 .spawn_profiles
21516 .as_ref()
21517 .unwrap()[0]
21518 .environment_profile
21519 .is_none());
21520 assert!(!legacy.requires_child_environment_profile_capability());
21521
21522 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21523 let bindings = shared
21524 .environment_profiles
21525 .read()
21526 .unwrap_or_else(|poisoned| poisoned.into_inner())
21527 .clone();
21528 let mut provider_mismatch = bindings.clone();
21529 provider_mismatch
21530 .values_mut()
21531 .next()
21532 .unwrap()
21533 .provider = agent("codex");
21534 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21535 let mut mode_mismatch = profile;
21536 mode_mismatch.mode = SessionMode::Inline;
21537 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21538
21539 shared
21540 .environment_profiles
21541 .write()
21542 .unwrap_or_else(|poisoned| poisoned.into_inner())
21543 .clear();
21544 assert!(shared
21545 .snapshot()
21546 .launch_inventory
21547 .unwrap()
21548 .spawn_profiles
21549 .unwrap()
21550 .is_empty());
21551 }
21552
21553 #[test]
21554 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21555 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21556 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21557 }));
21558 let shared = terminal_test_shared();
21559 let bundle = ResolvedBundleReceipt {
21560 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21561 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21562 digest: crate::protocol::SpawnBundleDigest::new(format!(
21563 "sha256:{}",
21564 "0".repeat(64),
21565 ))
21566 .unwrap(),
21567 };
21568 let mut bundled = record("claude", "bundle-record");
21569 bundled.bundle = Some(bundle.clone());
21570 let record_id = bundled.record_id.clone();
21571 shared.insert_record(bundled.clone()).unwrap();
21572 assert!(request_requires_session_bundle(
21573 &shared,
21574 &NodeRequest::ForgetSessionRecord {
21575 record_id: record_id.clone(),
21576 },
21577 ));
21578
21579 let snapshot = snapshot_for_wire(
21580 &shared, false, true, true, true, true, false, false, false, true,
21581 );
21582 assert!(snapshot.session_records[0].bundle.is_none());
21583 let launch = snapshot.launch_inventory.as_ref().unwrap();
21584 assert!(launch.spawn_profiles.is_some());
21585 assert!(launch.bundles.is_none());
21586 let event = project_event_without_session_bundle(NodeEventEnvelope {
21587 sequence: 1,
21588 event: NodeEvent::SessionRecordUpserted { record: bundled },
21589 });
21590 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21591 panic!("record projection changed the event kind");
21592 };
21593 assert!(record.bundle.is_none());
21594
21595 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21596 let mut reply = ResponseEnvelope {
21597 request_id: 1,
21598 result: Ok(NodeResponse::SpawnSpecAccepted {
21599 receipt: resolved.receipt_with_materialization(
21600 shared.incarnation_id,
21601 terminal_address(17),
21602 None,
21603 Some(bundle),
21604 None,
21605 ),
21606 }),
21607 };
21608 project_response_without_session_bundle(&mut reply);
21609 assert_eq!(
21610 reply.result.unwrap_err().code,
21611 NodeFailureCode::UnsupportedCapability,
21612 );
21613 }
21614
21615 #[test]
21616 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21617 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21618 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21619 }));
21620 let context_id = SpawnContextId::new("context-review").unwrap();
21621 let context = ResolvedContextPackReceipt {
21622 id: context_id.clone(),
21623 digest: crate::protocol::SpawnContextDigest::new(format!(
21624 "sha256:{}",
21625 "c".repeat(64),
21626 ))
21627 .unwrap(),
21628 lineage: ContextPackLineageReceipt {
21629 source_node_id: NodeId::new("node-source").unwrap(),
21630 source_session: terminal_address(1),
21631 source_provider: agent("kimi"),
21632 },
21633 source_message_count: 2,
21634 retained_message_count: 2,
21635 byte_len: 256,
21636 truncated: false,
21637 };
21638 assert!(context.is_valid());
21639 let mut bound_record = record("codex", "context-record");
21640 bound_record.context_id = Some(context_id);
21641 bound_record.context = Some(context.clone());
21642 let session = gate4agent_types::SessionSnapshot {
21643 instance_id: AgentInstanceId(91),
21644 agent_id: agent("kimi"),
21645 transport: TransportKind::Pty,
21646 generation: SessionGeneration(3),
21647 status: SessionStatus::Running,
21648 pending_operation: None,
21649 pending_input: None,
21650 process_id: Some(9001),
21651 terminal_size: None,
21652 terminal_frame: None,
21653 terminal_stale: None,
21654 session_options: None,
21655 capabilities: gate4agent_types::CapabilitySnapshot::default(),
21656 history: gate4agent_types::HistorySnapshot {
21657 pending: None,
21658 candidates: vec![HistoryCandidateSummary {
21659 id: "candidate-opaque".to_owned(),
21660 session_id_hint: "session-hint".to_owned(),
21661 modified_at_unix_ms: Some(1),
21662 }],
21663 loaded_candidate_id: Some("candidate-opaque".to_owned()),
21664 loaded: Some(HistorySessionRecord {
21665 session_id: "private-vendor-session".to_owned(),
21666 title: Some("private title".to_owned()),
21667 cwd: Some(r"C:\private\history-root".to_owned()),
21668 model: Some("private-model".to_owned()),
21669 message_count: 2,
21670 completed_turn_count: None,
21671 total_tokens: 19,
21672 messages: vec![
21673 gate4agent_types::HistoryMessageRecord {
21674 role: gate4agent_types::HistoryMessageRole::User,
21675 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21676 },
21677 gate4agent_types::HistoryMessageRecord {
21678 role: gate4agent_types::HistoryMessageRole::Assistant,
21679 text: "private answer".to_owned(),
21680 },
21681 ],
21682 }),
21683 last_error: None,
21684 },
21685 resume: gate4agent_types::ResumeSnapshot::default(),
21686 foreground: gate4agent_types::ForegroundSnapshot::default(),
21687 provider: gate4agent_types::ProviderSnapshot::default(),
21688 screen_state: gate4agent_types::PtyScreenState::default(),
21689 };
21690 let original = NodeSnapshot {
21691 node_id: NodeId::new("node-terminal-test").unwrap(),
21692 enabled_providers: vec![agent("kimi"), agent("codex")],
21693 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21694 workspaces: vec![WorkspaceSnapshot {
21695 workspace_id: WorkspaceId::new("primary").unwrap(),
21696 canonical_root: opaque_windows_path(
21697 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21698 ),
21699 sessions: vec![session],
21700 worktree_service_mode: None,
21701 managed_worktree_profiles: None,
21702 }],
21703 session_records: vec![bound_record],
21704 managed_worktrees: Vec::new(),
21705 launch_inventory: None,
21706 agent_progress: Vec::new(),
21707 };
21708
21709 let mut negotiated = original.clone();
21710 project_snapshot_history_for_wire(&mut negotiated, true);
21711 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21712 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21713 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21714 assert!(!serde_json::to_string(&negotiated)
21715 .unwrap()
21716 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21717
21718 let mut legacy = original;
21719 project_snapshot_history_for_wire(&mut legacy, false);
21720 clear_snapshot_context_packs(&mut legacy);
21721 assert_eq!(
21722 legacy.workspaces[0].sessions[0].history,
21723 gate4agent_types::HistorySnapshot::default(),
21724 );
21725 assert!(legacy.session_records[0].context_id.is_none());
21726 assert!(legacy.session_records[0].context.is_none());
21727 let encoded = serde_json::to_string(&legacy).unwrap();
21728 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21729 assert!(!encoded.contains("context-review"));
21730
21731 let mut reply = ResponseEnvelope {
21732 request_id: 7,
21733 result: Ok(NodeResponse::ContextPackExported { context }),
21734 };
21735 project_response_without_context_pack(&mut reply);
21736 assert_eq!(
21737 reply.result.unwrap_err().code,
21738 NodeFailureCode::UnsupportedCapability,
21739 );
21740 }
21741
21742 #[test]
21743 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21744 let shared = terminal_test_shared();
21745 let history = HistorySessionRecord {
21746 session_id: "fixture-history".to_owned(),
21747 title: None,
21748 cwd: None,
21749 model: None,
21750 message_count: 1,
21751 completed_turn_count: None,
21752 total_tokens: 0,
21753 messages: vec![gate4agent_types::HistoryMessageRecord {
21754 role: gate4agent_types::HistoryMessageRole::User,
21755 text: "bounded handoff".to_owned(),
21756 }],
21757 };
21758 let pack = NodeContextPack::export(
21759 ContextPackLineageReceipt {
21760 source_node_id: shared.node_id.clone(),
21761 source_session: terminal_address(1),
21762 source_provider: agent("claude"),
21763 },
21764 &history,
21765 )
21766 .unwrap();
21767 let context = shared
21768 .context_catalog
21769 .write()
21770 .unwrap_or_else(|poisoned| poisoned.into_inner())
21771 .insert(pack)
21772 .unwrap();
21773 let mut retained = record("codex", "context-retained-record");
21774 retained.context_id = Some(context.id.clone());
21775 retained.context = Some(context.clone());
21776 let record_id = retained.record_id.clone();
21777 shared.insert_record(retained).unwrap();
21778 assert_eq!(
21779 shared.forget_context_pack(&context.id).unwrap_err().code,
21780 NodeFailureCode::ContextPackBusy,
21781 );
21782 assert!(shared
21783 .context_catalog
21784 .read()
21785 .unwrap_or_else(|poisoned| poisoned.into_inner())
21786 .get(&context.id)
21787 .is_some());
21788 shared
21789 .session_records
21790 .lock()
21791 .unwrap_or_else(|poisoned| poisoned.into_inner())
21792 .records
21793 .remove(&record_id);
21794
21795 let mut spec = spawn_spec_fixture();
21796 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21797 value: context.id.clone(),
21798 };
21799 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21800 let receipt = resolved.receipt_with_materialization(
21801 shared.incarnation_id,
21802 terminal_address(2),
21803 None,
21804 None,
21805 Some(context.clone()),
21806 );
21807 assert!(receipt.context_binding_is_valid());
21808 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21809
21810 shared.forget_context_pack(&context.id).unwrap();
21811 assert!(shared
21812 .context_catalog
21813 .read()
21814 .unwrap_or_else(|poisoned| poisoned.into_inner())
21815 .get(&context.id)
21816 .is_none());
21817 assert!(shared
21818 .replay_spawn_spec(&spec, Instant::now())
21819 .unwrap()
21820 .is_none());
21821 }
21822
21823 #[test]
21824 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21825 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21826 let address = terminal_address(1);
21827 let selection = shared
21828 .select_environment_profile(
21829 address.session.instance_id,
21830 &agent("claude"),
21831 SessionMode::Pty,
21832 Some(&environment_profile),
21833 )
21834 .unwrap()
21835 .unwrap();
21836 shared.bind_session_with_policy(
21837 &address,
21838 ProviderRuntimePolicy::raw_pty(),
21839 Some(environment_profile.clone()),
21840 );
21841 selection.retain();
21842 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21843 assert!(matches!(
21844 runtime.remove_native_launch_profile(&native_id),
21845 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21846 ));
21847 assert!(shared.remove_binding(&address).is_some());
21848 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21849
21850 let mismatched = ResolvedEnvironmentProfileReceipt {
21851 profile_id: environment_profile.profile_id,
21852 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21853 "local-claude-r2",
21854 )
21855 .unwrap(),
21856 network_allowlist: None,
21857 browser_profile_id: None,
21858 };
21859 let error = match shared.select_environment_profile(
21860 AgentInstanceId(42),
21861 &agent("claude"),
21862 SessionMode::Pty,
21863 Some(&mismatched),
21864 ) {
21865 Ok(_) => panic!("mismatched environment profile revision was selected"),
21866 Err(error) => error,
21867 };
21868 assert_eq!(
21869 error.code,
21870 NodeFailureCode::EnvironmentProfileBindingMismatch,
21871 );
21872 assert!(!shared
21873 .session_bindings
21874 .lock()
21875 .unwrap_or_else(|poisoned| poisoned.into_inner())
21876 .contains_key(&AgentInstanceId(42)));
21877 }
21878
21879 #[test]
21880 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21881 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21882 let address = terminal_address(1);
21883 let selection = shared
21884 .select_environment_profile(
21885 address.session.instance_id,
21886 &agent("claude"),
21887 SessionMode::Pty,
21888 Some(&environment_profile),
21889 )
21890 .unwrap()
21891 .unwrap();
21892 let overlay = NativeInstanceLaunchOverlay::new(
21893 agent("claude"),
21894 TransportKind::Pty,
21895 vec![EnvMutation {
21896 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21897 value: Some(OsString::from("fixture-secret")),
21898 }],
21899 vec![
21900 OsString::from("--plugin-dir"),
21901 std::env::current_dir().unwrap().into_os_string(),
21902 ],
21903 )
21904 .unwrap();
21905 let instance_overlay = shared
21906 .install_prepared_launch_overlay(
21907 address.session.instance_id,
21908 PreparedNativeLaunchOverlay::Instance(overlay),
21909 )
21910 .unwrap()
21911 .unwrap();
21912 let bundle = ResolvedBundleReceipt {
21913 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21914 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21915 digest: crate::protocol::SpawnBundleDigest::new(format!(
21916 "sha256:{}",
21917 "0".repeat(64),
21918 ))
21919 .unwrap(),
21920 };
21921 shared.bind_session_with_materialization(
21922 &address,
21923 ProviderRuntimePolicy::raw_pty(),
21924 Some(environment_profile),
21925 Some(bundle),
21926 None,
21927 None,
21928 );
21929 selection.retain();
21930 instance_overlay.retain();
21931
21932 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21933 assert!(matches!(
21934 runtime.remove_native_launch_profile(&native_id),
21935 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21936 ));
21937 assert!(shared.remove_binding(&address).is_some());
21938 assert!(!shared
21939 .native_launch_profile_control
21940 .as_ref()
21941 .unwrap()
21942 .clear_native_instance_launch_overlay(address.session.instance_id));
21943 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21944 }
21945
21946 #[test]
21947 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21948 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21949 let address = SessionAddress {
21950 workspace_id: WorkspaceId::new("primary").unwrap(),
21951 session: SessionKey {
21952 instance_id: AgentInstanceId(201),
21953 generation: SessionGeneration(1),
21954 },
21955 };
21956 let error = match shared.prepare_session_materialization(
21957 &address,
21958 &agent("claude"),
21959 SessionMode::Pty,
21960 Some(&receipt),
21961 None,
21962 None,
21963 None,
21964 ) {
21965 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21966 Err(error) => error,
21967 };
21968 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21969 assert!(shared.handle.snapshot().sessions.is_empty());
21970 assert!(shared
21971 .session_bindings
21972 .lock()
21973 .unwrap_or_else(|poisoned| poisoned.into_inner())
21974 .is_empty());
21975 assert!(shared
21976 .materializations
21977 .lock()
21978 .unwrap_or_else(|poisoned| poisoned.into_inner())
21979 .is_empty());
21980 drop(shared);
21981 drop(runtime);
21982 std::fs::remove_dir_all(root).unwrap();
21983 }
21984
21985 #[test]
21986 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21987 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21988 let address = SessionAddress {
21989 workspace_id: WorkspaceId::new("primary").unwrap(),
21990 session: SessionKey {
21991 instance_id: AgentInstanceId(202),
21992 generation: SessionGeneration(1),
21993 },
21994 };
21995 let (overlay, materialization_guard) = shared
21996 .prepare_session_materialization(
21997 &address,
21998 &agent("claude"),
21999 SessionMode::Pty,
22000 Some(&receipt),
22001 None,
22002 None,
22003 None,
22004 )
22005 .unwrap()
22006 .unwrap();
22007 let materialization_id = materialization_guard.id().unwrap().clone();
22008 let materialization_root = shared
22009 .materializations
22010 .lock()
22011 .unwrap_or_else(|poisoned| poisoned.into_inner())
22012 .get(&materialization_id)
22013 .unwrap()
22014 .root()
22015 .to_path_buf();
22016 let selection = shared
22017 .select_environment_profile(
22018 address.session.instance_id,
22019 &agent("claude"),
22020 SessionMode::Pty,
22021 Some(&receipt),
22022 )
22023 .unwrap()
22024 .unwrap();
22025 assert!(shared
22026 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22027 .unwrap()
22028 .is_none());
22029 shared.bind_session_with_materialization(
22030 &address,
22031 ProviderRuntimePolicy::raw_pty(),
22032 Some(receipt),
22033 None,
22034 None,
22035 Some(materialization_id),
22036 );
22037 selection.retain();
22038 materialization_guard.retain();
22039
22040 assert!(materialization_root.is_dir());
22041 assert!(shared
22042 .session_bindings
22043 .lock()
22044 .unwrap_or_else(|poisoned| poisoned.into_inner())
22045 .contains_key(&address.session.instance_id));
22046
22047 let removed = shared.remove_binding(&address).unwrap();
22048 shared
22049 .cleanup_session_owned_materialization(&address, &removed)
22050 .unwrap();
22051 assert!(!materialization_root.exists());
22052 assert!(shared
22053 .materializations
22054 .lock()
22055 .unwrap_or_else(|poisoned| poisoned.into_inner())
22056 .is_empty());
22057 drop(shared);
22058 drop(runtime);
22059 std::fs::remove_dir_all(root).unwrap();
22060 }
22061
22062 #[tokio::test]
22063 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
22064 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22065 let address = SessionAddress {
22066 workspace_id: WorkspaceId::new("primary").unwrap(),
22067 session: SessionKey {
22068 instance_id: AgentInstanceId(203),
22069 generation: SessionGeneration(1),
22070 },
22071 };
22072 let (overlay, materialization_guard) = shared
22073 .prepare_session_materialization(
22074 &address,
22075 &agent("claude"),
22076 SessionMode::Pty,
22077 Some(&receipt),
22078 None,
22079 None,
22080 None,
22081 )
22082 .unwrap()
22083 .unwrap();
22084 let materialization_id = materialization_guard.id().unwrap().clone();
22085 let materialization_root = shared
22086 .materializations
22087 .lock()
22088 .unwrap_or_else(|poisoned| poisoned.into_inner())
22089 .get(&materialization_id)
22090 .unwrap()
22091 .root()
22092 .to_path_buf();
22093 let selection = shared
22094 .select_environment_profile(
22095 address.session.instance_id,
22096 &agent("claude"),
22097 SessionMode::Pty,
22098 Some(&receipt),
22099 )
22100 .unwrap()
22101 .unwrap();
22102 assert!(shared
22103 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22104 .unwrap()
22105 .is_none());
22106 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
22107 let record_id = shared
22108 .bind_spawn_session_with_materialization(
22109 &address,
22110 agent("claude"),
22111 SessionMode::Pty,
22112 policy,
22113 SpawnRecordPolicy::ProviderIdentityOnly,
22114 Some(receipt.clone()),
22115 None,
22116 None,
22117 Some(materialization_id.clone()),
22118 )
22119 .unwrap()
22120 .unwrap();
22121 selection.retain();
22122 materialization_guard.retain();
22123 assert_eq!(
22124 shared
22125 .materializations
22126 .lock()
22127 .unwrap_or_else(|poisoned| poisoned.into_inner())
22128 .get(&materialization_id)
22129 .unwrap()
22130 .owner(),
22131 &MaterializationOwner::Record {
22132 record_id: record_id.clone(),
22133 },
22134 );
22135
22136 let removed = shared.remove_binding(&address).unwrap();
22137 shared
22138 .cleanup_session_owned_materialization(&address, &removed)
22139 .unwrap();
22140 assert!(materialization_root.is_dir());
22141 assert!(shared
22142 .resolve_record_materialization(
22143 &record_id,
22144 &agent("claude"),
22145 SessionMode::Pty,
22146 Some(&receipt),
22147 None,
22148 None,
22149 )
22150 .unwrap()
22151 .is_some());
22152 {
22153 let mut records = shared
22154 .session_records
22155 .lock()
22156 .unwrap_or_else(|poisoned| poisoned.into_inner());
22157 let record = records.records.get_mut(&record_id).unwrap();
22158 record.active_session = None;
22159 record.state = ManagedSessionState::Unavailable;
22160 }
22161 shared.forget_session_record(&record_id).await.unwrap();
22162 assert!(!materialization_root.exists());
22163 assert!(shared
22164 .materializations
22165 .lock()
22166 .unwrap_or_else(|poisoned| poisoned.into_inner())
22167 .is_empty());
22168 drop(shared);
22169 drop(runtime);
22170 std::fs::remove_dir_all(root).unwrap();
22171 }
22172
22173 #[test]
22174 fn record_materialization_revalidation_failure_persists_recovery_required() {
22175 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22176 let state_path = root.join("node-state-v6.json");
22177 shared.state_path = Some(state_path.clone());
22178 let (_address, _record_id, materialization_id, materialization_root) =
22179 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
22180 std::fs::write(
22181 materialization_root.join(".gate4agent-materialization-owner"),
22182 b"tampered",
22183 )
22184 .unwrap();
22185
22186 let error = match shared.resolve_record_materialization(
22187 &_record_id,
22188 &agent("claude"),
22189 SessionMode::Pty,
22190 Some(&receipt),
22191 None,
22192 None,
22193 ) {
22194 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
22195 Err(error) => error,
22196 };
22197 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22198 assert_eq!(
22199 shared
22200 .materializations
22201 .lock()
22202 .unwrap_or_else(|poisoned| poisoned.into_inner())
22203 .get(&materialization_id)
22204 .unwrap()
22205 .state(),
22206 MaterializationState::RecoveryRequired,
22207 );
22208 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
22209 assert_eq!(loaded.materializations.len(), 1);
22210 assert_eq!(
22211 loaded.materializations[0].state(),
22212 MaterializationState::RecoveryRequired,
22213 );
22214 drop(shared);
22215 drop(runtime);
22216 std::fs::remove_dir_all(root).unwrap();
22217 }
22218
22219 #[test]
22220 fn transient_record_materialization_resolution_failure_remains_ready() {
22221 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
22222 let (_address, record_id, materialization_id, _materialization_root) =
22223 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
22224 deny.store(true, Ordering::Release);
22225
22226 let error = match shared.resolve_record_materialization(
22227 &record_id,
22228 &agent("claude"),
22229 SessionMode::Pty,
22230 Some(&receipt),
22231 None,
22232 None,
22233 ) {
22234 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
22235 Err(error) => error,
22236 };
22237 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22238 assert_eq!(
22239 shared
22240 .materializations
22241 .lock()
22242 .unwrap_or_else(|poisoned| poisoned.into_inner())
22243 .get(&materialization_id)
22244 .unwrap()
22245 .state(),
22246 MaterializationState::Ready,
22247 );
22248 drop(shared);
22249 drop(runtime);
22250 std::fs::remove_dir_all(root).unwrap();
22251 }
22252
22253 #[test]
22254 fn startup_missing_record_materialization_is_not_resumable() {
22255 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22256 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22257 shared
22258 .insert_record(ManagedSessionRecord {
22259 record_id: record_id.clone(),
22260 display_name: "missing materialization".to_owned(),
22261 provider: agent("claude"),
22262 mode: SessionMode::Pty,
22263 state: ManagedSessionState::Dormant,
22264 workspace_id: WorkspaceId::new("primary").unwrap(),
22265 canonical_root: opaque_windows_path(
22266 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22267 ),
22268 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22269 key: gate4agent_types::ProviderSessionKey::SessionId,
22270 id: "provider-session-missing-materialization".to_owned(),
22271 transcript_path: None,
22272 }),
22273 active_session: None,
22274 environment_profile: Some(receipt),
22275 bundle: None,
22276 context_id: None,
22277 context: None,
22278 exported_context: None,
22279 task_binding: None,
22280 created_at_unix_ms: 1,
22281 updated_at_unix_ms: 1,
22282 last_error: None,
22283 })
22284 .unwrap();
22285
22286 shared.reconcile_materializations();
22287 let record = shared.record(&record_id).unwrap();
22288 assert_eq!(record.state, ManagedSessionState::Unavailable);
22289 assert!(record.provider_session.is_none());
22290 assert_eq!(
22291 record.last_error.as_deref(),
22292 Some("environment-profile-unavailable"),
22293 );
22294 drop(shared);
22295 drop(runtime);
22296 std::fs::remove_dir_all(root).unwrap();
22297 }
22298
22299 #[tokio::test]
22300 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22301 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22302 let lease = install_managed_lease(&shared);
22303 let address = SessionAddress {
22304 workspace_id: WorkspaceId::new("primary").unwrap(),
22305 session: SessionKey {
22306 instance_id: AgentInstanceId(204),
22307 generation: SessionGeneration(1),
22308 },
22309 };
22310 let (overlay, materialization_guard) = shared
22311 .prepare_session_materialization(
22312 &address,
22313 &agent("claude"),
22314 SessionMode::Pty,
22315 Some(&receipt),
22316 None,
22317 None,
22318 Some(lease.lease_id.clone()),
22319 )
22320 .unwrap()
22321 .unwrap();
22322 let materialization_id = materialization_guard.id().unwrap().clone();
22323 let materialization_root = shared
22324 .materializations
22325 .lock()
22326 .unwrap_or_else(|poisoned| poisoned.into_inner())
22327 .get(&materialization_id)
22328 .unwrap()
22329 .root()
22330 .to_path_buf();
22331 let selection = shared
22332 .select_environment_profile(
22333 address.session.instance_id,
22334 &agent("claude"),
22335 SessionMode::Pty,
22336 Some(&receipt),
22337 )
22338 .unwrap()
22339 .unwrap();
22340 assert!(shared
22341 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22342 .unwrap()
22343 .is_none());
22344 shared.bind_session_with_materialization(
22345 &address,
22346 ProviderRuntimePolicy::raw_pty(),
22347 Some(receipt),
22348 None,
22349 None,
22350 Some(materialization_id),
22351 );
22352 selection.retain();
22353 materialization_guard.retain();
22354
22355 let blocked = shared
22356 .cleanup_managed_worktree(&lease.lease_id, false)
22357 .await
22358 .unwrap_err();
22359 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22360 assert!(materialization_root.is_dir());
22361 let removed = shared.remove_binding(&address).unwrap();
22362 shared
22363 .cleanup_session_owned_materialization(&address, &removed)
22364 .unwrap();
22365 assert!(!materialization_root.exists());
22366 drop(shared);
22367 drop(runtime);
22368 std::fs::remove_dir_all(root).unwrap();
22369 }
22370
22371 #[tokio::test]
22372 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22373 let git_root = temporary_workspace_root("raw-managed-inventory");
22374 let source = git_root.join("source");
22375 let allocation = git_root.join("allocation");
22376 std::fs::create_dir_all(&source).unwrap();
22377 std::fs::create_dir_all(&allocation).unwrap();
22378 run_test_git(&source, &["init"]);
22379 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22380 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22381 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22382 run_test_git(&source, &["add", "seed.txt"]);
22383 run_test_git(&source, &["commit", "-m", "seed"]);
22384 let profile = ManagedWorktreeProfile::new(
22385 WorktreeProfileId::new("default").unwrap(),
22386 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22387 &allocation,
22388 "gate4agent",
22389 "HEAD",
22390 ManagedWorktreeRetention::RemoveWhenReleased,
22391 )
22392 .unwrap();
22393 let workspace = WorkspaceConfig::new(
22394 WorkspaceId::new("primary").unwrap(),
22395 &source,
22396 )
22397 .unwrap()
22398 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22399 .with_managed_worktree_profile(profile.clone())
22400 .unwrap();
22401 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22402 materialization_test_shared_with_workspace(false, workspace);
22403 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22404 let source_text = source.to_string_lossy().into_owned();
22405 let target_text = target.to_string_lossy().into_owned();
22406 let created = create_git_worktree(
22407 &source_text,
22408 &target_text,
22409 "gate4agent/mw-raw-inventory",
22410 Some("HEAD"),
22411 )
22412 .await
22413 .unwrap();
22414 let lease = ManagedWorktreeLeaseRecord {
22415 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22416 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22417 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22418 profile_id: profile.profile_id().clone(),
22419 profile_revision: profile.revision().clone(),
22420 target_root: created.path.clone(),
22421 branch: "gate4agent/mw-raw-inventory".to_owned(),
22422 base_commit: created.head.clone(),
22423 expected_head: Some(created.head.clone()),
22424 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22425 state: ManagedWorktreeLeaseState::Ready,
22426 session_holders: Vec::new(),
22427 record_holders: Vec::new(),
22428 cleanup_failure: None,
22429 created_at_unix_ms: 1,
22430 updated_at_unix_ms: 1,
22431 };
22432 shared
22433 .workspaces
22434 .write()
22435 .unwrap_or_else(|poisoned| poisoned.into_inner())
22436 .insert(lease.workspace_id.clone(), created.path.clone());
22437 *shared
22438 .managed_worktrees
22439 .lock()
22440 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22441 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22442 let address = SessionAddress {
22443 workspace_id: lease.workspace_id.clone(),
22444 session: SessionKey {
22445 instance_id: AgentInstanceId(205),
22446 generation: SessionGeneration(1),
22447 },
22448 };
22449 let (overlay, materialization_guard) = shared
22450 .prepare_session_materialization(
22451 &address,
22452 &agent("claude"),
22453 SessionMode::Pty,
22454 Some(&receipt),
22455 None,
22456 None,
22457 Some(lease.lease_id.clone()),
22458 )
22459 .unwrap()
22460 .unwrap();
22461 let materialization_id = materialization_guard.id().unwrap().clone();
22462 let materialization_root = shared
22463 .materializations
22464 .lock()
22465 .unwrap_or_else(|poisoned| poisoned.into_inner())
22466 .get(&materialization_id)
22467 .unwrap()
22468 .root()
22469 .to_path_buf();
22470 let selection = shared
22471 .select_environment_profile(
22472 address.session.instance_id,
22473 &agent("claude"),
22474 SessionMode::Pty,
22475 Some(&receipt),
22476 )
22477 .unwrap()
22478 .unwrap();
22479 assert!(shared
22480 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22481 .unwrap()
22482 .is_none());
22483 let record_id = shared
22484 .bind_spawn_session_with_materialization(
22485 &address,
22486 agent("claude"),
22487 SessionMode::Pty,
22488 ProviderRuntimePolicy::raw_pty(),
22489 SpawnRecordPolicy::Always,
22490 Some(receipt.clone()),
22491 None,
22492 None,
22493 Some(materialization_id.clone()),
22494 )
22495 .unwrap()
22496 .unwrap();
22497 selection.retain();
22498 materialization_guard.retain();
22499 let record = shared.record(&record_id).unwrap();
22500 assert_eq!(record.state, ManagedSessionState::Live);
22501 assert!(record.provider_session.is_none());
22502 assert!(record.environment_profile.is_none());
22503 assert!(record.bundle.is_none());
22504 assert!(record.context_id.is_none());
22505 assert!(record.context.is_none());
22506 assert_eq!(record.active_session.as_ref(), Some(&address));
22507 assert_eq!(
22508 shared
22509 .materializations
22510 .lock()
22511 .unwrap_or_else(|poisoned| poisoned.into_inner())
22512 .get(&materialization_id)
22513 .unwrap()
22514 .owner(),
22515 &MaterializationOwner::Session {
22516 incarnation_id: shared.incarnation_id,
22517 instance_id: address.session.instance_id,
22518 generation: address.session.generation,
22519 },
22520 );
22521 {
22522 let mut bindings = shared
22523 .session_bindings
22524 .lock()
22525 .unwrap_or_else(|poisoned| poisoned.into_inner());
22526 bindings
22527 .get_mut(&address.session.instance_id)
22528 .unwrap()
22529 .managed_worktree_lease_id = Some(lease.lease_id.clone());
22530 }
22531 let lease_snapshot = shared
22532 .managed_worktrees
22533 .lock()
22534 .unwrap_or_else(|poisoned| poisoned.into_inner())
22535 .bind_session(
22536 &lease.lease_id,
22537 ManagedWorktreeSessionHolder {
22538 incarnation_id: shared.incarnation_id,
22539 instance_id: address.session.instance_id,
22540 generation: address.session.generation,
22541 },
22542 None,
22543 unix_time_ms(),
22544 )
22545 .unwrap();
22546 assert_eq!(lease_snapshot.active_session_count, 1);
22547 assert_eq!(lease_snapshot.managed_record_count, 0);
22548 shared.persist_state().unwrap();
22549 assert!(shared
22550 .managed_worktrees
22551 .lock()
22552 .unwrap_or_else(|poisoned| poisoned.into_inner())
22553 .get(&lease.lease_id)
22554 .unwrap()
22555 .record_holders
22556 .is_empty());
22557 shared
22558 .handle
22559 .dispatch(shared.prepare_command(ControlCommand::Register {
22560 instance_id: address.session.instance_id,
22561 agent_id: agent("claude"),
22562 transport: TransportKind::Pty,
22563 }))
22564 .unwrap();
22565 runtime.tick().await;
22566 shared.reconcile_managed_record(
22567 &record_id,
22568 &address,
22569 &ControlEvent {
22570 sequence: 1,
22571 command_id: None,
22572 instance_id: address.session.instance_id,
22573 generation: address.session.generation,
22574 event: ControlEventKind::Removed,
22575 },
22576 false,
22577 false,
22578 );
22579 let remove = shared.remove_session(&address);
22580 let drive_runtime = async {
22581 runtime.tick().await;
22582 };
22583 let (removed, ()) = tokio::join!(remove, drive_runtime);
22584 removed.unwrap();
22585
22586 assert!(!materialization_root.exists());
22587 assert!(!Path::new(&created.path).exists());
22588 assert_eq!(
22589 shared
22590 .managed_worktrees
22591 .lock()
22592 .unwrap_or_else(|poisoned| poisoned.into_inner())
22593 .get(&lease.lease_id)
22594 .unwrap()
22595 .state,
22596 ManagedWorktreeLeaseState::Removed,
22597 );
22598 let renamed = shared
22599 .rename_session_record(&record_id, "raw managed inventory".to_owned())
22600 .unwrap();
22601 assert_eq!(renamed.record_id, record_id);
22602 assert_eq!(renamed.display_name, "raw managed inventory");
22603 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22604 assert!(renamed.active_session.is_none());
22605
22606 drop(shared);
22607 drop(runtime);
22608 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22609 std::fs::remove_dir_all(git_root).unwrap();
22610 }
22611
22612 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22613 let root = std::env::current_dir().unwrap()
22614 .join("managed-test-target")
22615 .to_string_lossy()
22616 .into_owned();
22617 let lease = ManagedWorktreeLeaseRecord {
22618 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22619 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22620 workspace_id: WorkspaceId::new("managed-test").unwrap(),
22621 profile_id: WorktreeProfileId::new("default").unwrap(),
22622 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22623 target_root: root.clone(),
22624 branch: "gate4agent/mw-test".to_owned(),
22625 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22626 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22627 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22628 state: ManagedWorktreeLeaseState::Ready,
22629 session_holders: Vec::new(),
22630 record_holders: Vec::new(),
22631 cleanup_failure: None,
22632 created_at_unix_ms: 1,
22633 updated_at_unix_ms: 1,
22634 };
22635 shared.workspaces.write()
22636 .unwrap_or_else(|poisoned| poisoned.into_inner())
22637 .insert(lease.workspace_id.clone(), root);
22638 *shared.managed_worktrees.lock()
22639 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22640 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22641 lease
22642 }
22643
22644 #[cfg(feature = "fixture")]
22645 #[test]
22646 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22647 let mut shared = terminal_test_shared();
22648 shared.fixture_semantic_hook_policy = true;
22649 let lease = install_managed_lease(&shared);
22650 let address = SessionAddress {
22651 workspace_id: lease.workspace_id.clone(),
22652 session: SessionKey {
22653 instance_id: AgentInstanceId(206),
22654 generation: SessionGeneration(1),
22655 },
22656 };
22657 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22658 let effective_runtime_policy =
22659 shared.effective_spawn_runtime_policy(pre_admission_policy);
22660 assert_ne!(effective_runtime_policy, pre_admission_policy);
22661 assert!(effective_runtime_policy.provider_session_identity);
22662
22663 let record_id = shared
22664 .bind_spawn_session(
22665 &address,
22666 agent("claude"),
22667 SessionMode::Pty,
22668 effective_runtime_policy,
22669 SpawnRecordPolicy::Always,
22670 None,
22671 )
22672 .unwrap()
22673 .unwrap();
22674 assert_eq!(
22675 shared.record(&record_id).unwrap().state,
22676 ManagedSessionState::IdentityPending,
22677 );
22678 let (record_holder, raw_inventory_record_id) = {
22679 let mut bindings = shared
22680 .session_bindings
22681 .lock()
22682 .unwrap_or_else(|poisoned| poisoned.into_inner());
22683 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22684 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22685 managed_spawn_record_ownership(binding, effective_runtime_policy)
22686 };
22687 assert_eq!(record_holder.as_ref(), Some(&record_id));
22688 assert!(raw_inventory_record_id.is_none());
22689 let snapshot = shared
22690 .managed_worktrees
22691 .lock()
22692 .unwrap_or_else(|poisoned| poisoned.into_inner())
22693 .bind_session(
22694 &lease.lease_id,
22695 ManagedWorktreeSessionHolder {
22696 incarnation_id: shared.incarnation_id,
22697 instance_id: address.session.instance_id,
22698 generation: address.session.generation,
22699 },
22700 record_holder,
22701 unix_time_ms(),
22702 )
22703 .unwrap();
22704 assert_eq!(snapshot.managed_record_count, 1);
22705 assert_eq!(
22706 shared
22707 .managed_worktrees
22708 .lock()
22709 .unwrap_or_else(|poisoned| poisoned.into_inner())
22710 .get(&lease.lease_id)
22711 .unwrap()
22712 .record_holders,
22713 vec![record_id.clone()],
22714 );
22715
22716 let identity = ProviderSessionIdentity {
22717 key: ProviderSessionKey::SessionId,
22718 id: "monitoring-fixture-managed-session".to_owned(),
22719 transcript_path: None,
22720 };
22721 shared.publish_control(ControlEvent {
22722 sequence: 1,
22723 command_id: None,
22724 instance_id: address.session.instance_id,
22725 generation: address.session.generation,
22726 event: ControlEventKind::ProviderEvent {
22727 sequence: 1,
22728 source: gate4agent_types::ProviderSource {
22729 family: AdapterFamily::Hook,
22730 binding: AdapterBinding::new(
22731 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22732 "fixture/v1",
22733 gate4agent_types::AdapterVerification::SyntheticFixture,
22734 )
22735 .unwrap(),
22736 },
22737 source_sequence: 1,
22738 event: ProviderEvent::SessionIdentityObserved {
22739 identity: identity.clone(),
22740 },
22741 },
22742 });
22743
22744 let live = shared.record(&record_id).unwrap();
22745 assert_eq!(live.state, ManagedSessionState::Live);
22746 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22747 assert_eq!(live.active_session.as_ref(), Some(&address));
22748 assert_eq!(
22749 shared
22750 .managed_worktrees
22751 .lock()
22752 .unwrap_or_else(|poisoned| poisoned.into_inner())
22753 .get(&lease.lease_id)
22754 .unwrap()
22755 .record_holders,
22756 vec![record_id],
22757 );
22758 }
22759
22760 fn managed_v2_test_shared(
22761 allocation_root: &Path,
22762 state_path: Option<PathBuf>,
22763 profile_revision: &str,
22764 ) -> NodeShared {
22765 let profile = ManagedWorktreeProfile::new(
22766 WorktreeProfileId::new("default").unwrap(),
22767 WorktreeProfileRevision::new(profile_revision).unwrap(),
22768 allocation_root,
22769 "gate4agent",
22770 "HEAD",
22771 ManagedWorktreeRetention::RemoveWhenReleased,
22772 )
22773 .unwrap();
22774 let workspace = WorkspaceConfig::new(
22775 WorkspaceId::new("primary").unwrap(),
22776 std::env::current_dir().unwrap(),
22777 )
22778 .unwrap()
22779 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22780 .with_managed_worktree_profile(profile)
22781 .unwrap();
22782 let catalog = active_registry().unwrap();
22783 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22784 let mut shared = NodeShared::new(
22785 handle,
22786 "fixture-token".to_owned(),
22787 NodeId::new("node-terminal-test").unwrap(),
22788 vec![workspace],
22789 vec![agent("claude")],
22790 );
22791 shared.state_path = state_path;
22792 shared
22793 }
22794
22795 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22796 let mut spawn_spec = spawn_spec_fixture();
22797 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22798 ManagedWorktreeSpawnRequestV2 {
22799 spawn_spec,
22800 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22801 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22802 }
22803 }
22804
22805 #[test]
22806 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22807 let shared = terminal_test_shared();
22808 let mut lease = install_managed_lease(&shared);
22809 let session = SessionAddress {
22810 workspace_id: lease.workspace_id.clone(),
22811 session: SessionKey {
22812 instance_id: AgentInstanceId(73),
22813 generation: SessionGeneration(4),
22814 },
22815 };
22816 lease.state = ManagedWorktreeLeaseState::InUse;
22817 lease.session_holders.push(ManagedWorktreeSessionHolder {
22818 incarnation_id: shared.incarnation_id,
22819 instance_id: session.session.instance_id,
22820 generation: session.session.generation,
22821 });
22822 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22823 assert!(resolved.target.worktree_id.is_none());
22824
22825 let receipt = managed_spawn_receipt(
22826 &resolved,
22827 shared.incarnation_id,
22828 session.clone(),
22829 lease.snapshot(),
22830 None,
22831 None,
22832 None,
22833 );
22834 assert!(resolved.target.worktree_id.is_none());
22835 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22836 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22837 assert_eq!(receipt.spawn.session, session);
22838 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22839 let encoded = serde_json::to_vec(&receipt).unwrap();
22840 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22841 assert_eq!(decoded, receipt);
22842 }
22843
22844 #[tokio::test]
22845 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22846 let root = temporary_workspace_root("managed-v2-revision");
22847 let allocation = root.join("allocation");
22848 std::fs::create_dir_all(&allocation).unwrap();
22849 let shared = managed_v2_test_shared(
22850 &allocation,
22851 Some(root.join("node-state.json")),
22852 "v1",
22853 );
22854 let mut request = managed_v2_request("managed-v2-revision");
22855 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22856
22857 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22858
22859 assert_eq!(
22860 failure.code,
22861 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22862 );
22863 assert!(shared
22864 .managed_worktrees
22865 .lock()
22866 .unwrap_or_else(|poisoned| poisoned.into_inner())
22867 .records()
22868 .is_empty());
22869 assert!(shared
22870 .managed_spawn_replays
22871 .lock()
22872 .unwrap_or_else(|poisoned| poisoned.into_inner())
22873 .is_empty());
22874 std::fs::remove_dir_all(root).unwrap();
22875 }
22876
22877 #[test]
22878 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22879 let root = temporary_workspace_root("managed-v2-concurrent");
22880 let allocation = root.join("allocation");
22881 std::fs::create_dir_all(&allocation).unwrap();
22882 let shared = Arc::new(managed_v2_test_shared(
22883 &allocation,
22884 Some(root.join("node-state.json")),
22885 "v1",
22886 ));
22887 let request = managed_v2_request("managed-v2-concurrent");
22888 let barrier = Arc::new(std::sync::Barrier::new(3));
22889 let mut joins = Vec::new();
22890 for _ in 0..2 {
22891 let shared = Arc::clone(&shared);
22892 let request = request.clone();
22893 let barrier = Arc::clone(&barrier);
22894 joins.push(std::thread::spawn(move || {
22895 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22896 barrier.wait();
22897 shared.acquire_durable_managed_spawn_v2(&request, digest)
22898 }));
22899 }
22900 barrier.wait();
22901 let results = joins
22902 .into_iter()
22903 .map(|join| join.join().unwrap())
22904 .collect::<Vec<_>>();
22905 assert_eq!(
22906 results
22907 .iter()
22908 .filter(|result| {
22909 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22910 })
22911 .count(),
22912 1,
22913 );
22914 assert_eq!(
22915 results
22916 .iter()
22917 .filter(|result| {
22918 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22919 })
22920 .count(),
22921 1,
22922 );
22923 assert_eq!(
22924 shared
22925 .managed_spawn_replays
22926 .lock()
22927 .unwrap_or_else(|poisoned| poisoned.into_inner())
22928 .len(),
22929 1,
22930 );
22931 assert!(shared
22932 .managed_worktrees
22933 .lock()
22934 .unwrap_or_else(|poisoned| poisoned.into_inner())
22935 .records()
22936 .is_empty());
22937 drop(shared);
22938 std::fs::remove_dir_all(root).unwrap();
22939 }
22940
22941 #[tokio::test]
22942 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22943 let root = temporary_workspace_root("managed-v2-linked-pending");
22944 let allocation = root.join("allocation");
22945 std::fs::create_dir_all(&allocation).unwrap();
22946 let state_path = root.join("node-state.json");
22947 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22948 let lease = install_managed_lease(&shared);
22949 let request = managed_v2_request("managed-v2-linked-pending");
22950 assert!(matches!(
22951 shared
22952 .acquire_durable_managed_spawn_v2(
22953 &request,
22954 managed_spawn_request_digest_v2(&request).unwrap(),
22955 )
22956 .unwrap(),
22957 DurableManagedSpawnReplayDecision::Reserved,
22958 ));
22959 {
22960 let _transaction = shared
22961 .state_transaction
22962 .lock()
22963 .unwrap_or_else(|poisoned| poisoned.into_inner());
22964 shared
22965 .link_durable_managed_spawn_v2_lease_locked(
22966 &request.spawn_spec.idempotency_key,
22967 &lease.lease_id,
22968 )
22969 .unwrap();
22970 shared.persist_state_locked().unwrap();
22971 }
22972 drop(shared);
22973
22974 let mut loaded = session_registry::load(
22975 Some(&state_path),
22976 &NodeId::new("node-terminal-test").unwrap(),
22977 )
22978 .unwrap();
22979 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22980 *reopened
22981 .managed_worktrees
22982 .lock()
22983 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22984 ManagedWorktreeRegistry::from_records(
22985 std::mem::take(&mut loaded.managed_worktrees),
22986 std::mem::take(&mut loaded.managed_worktree_tombstones),
22987 )
22988 .unwrap();
22989 *reopened
22990 .managed_spawn_replays
22991 .lock()
22992 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22993 .managed_spawn_replays
22994 .into_iter()
22995 .map(|record| (record.idempotency_key.clone(), record))
22996 .collect();
22997 let lease_count = reopened
22998 .managed_worktrees
22999 .lock()
23000 .unwrap_or_else(|poisoned| poisoned.into_inner())
23001 .records()
23002 .len();
23003
23004 let failure = reopened
23005 .spawn_managed_worktree_v2(request)
23006 .await
23007 .unwrap_err();
23008
23009 assert_eq!(
23010 failure.code,
23011 NodeFailureCode::ManagedWorktreeRecoveryRequired,
23012 );
23013 assert_eq!(
23014 reopened
23015 .managed_worktrees
23016 .lock()
23017 .unwrap_or_else(|poisoned| poisoned.into_inner())
23018 .records()
23019 .len(),
23020 lease_count,
23021 );
23022 std::fs::remove_dir_all(root).unwrap();
23023 }
23024
23025 #[tokio::test]
23026 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
23027 let root = temporary_workspace_root("managed-v2-reopen");
23028 let allocation = root.join("allocation");
23029 std::fs::create_dir_all(&allocation).unwrap();
23030 let state_path = root.join("node-state.json");
23031 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
23032 let lease = install_managed_lease(&shared);
23033 let request = managed_v2_request("managed-v2-reopen");
23034 let session = SessionAddress {
23035 workspace_id: lease.workspace_id.clone(),
23036 session: SessionKey {
23037 instance_id: AgentInstanceId(91),
23038 generation: SessionGeneration(1),
23039 },
23040 };
23041 let snapshot = {
23042 let mut registry = shared
23043 .managed_worktrees
23044 .lock()
23045 .unwrap_or_else(|poisoned| poisoned.into_inner());
23046 let current = registry.get_mut(&lease.lease_id).unwrap();
23047 current.state = ManagedWorktreeLeaseState::InUse;
23048 current.session_holders.push(ManagedWorktreeSessionHolder {
23049 incarnation_id: shared.incarnation_id,
23050 instance_id: session.session.instance_id,
23051 generation: session.session.generation,
23052 });
23053 current.snapshot()
23054 };
23055 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
23056 let receipt = managed_spawn_receipt(
23057 &resolved,
23058 shared.incarnation_id,
23059 session,
23060 snapshot,
23061 None,
23062 None,
23063 None,
23064 );
23065 assert!(matches!(
23066 shared
23067 .acquire_durable_managed_spawn_v2(
23068 &request,
23069 managed_spawn_request_digest_v2(&request).unwrap(),
23070 )
23071 .unwrap(),
23072 DurableManagedSpawnReplayDecision::Reserved,
23073 ));
23074 {
23075 let _transaction = shared
23076 .state_transaction
23077 .lock()
23078 .unwrap_or_else(|poisoned| poisoned.into_inner());
23079 shared
23080 .link_durable_managed_spawn_v2_lease_locked(
23081 &request.spawn_spec.idempotency_key,
23082 &lease.lease_id,
23083 )
23084 .unwrap();
23085 shared.persist_state_locked().unwrap();
23086 }
23087 shared
23088 .commit_durable_managed_spawn_v2(&request, receipt.clone())
23089 .unwrap();
23090 let legacy_same_key = ManagedWorktreeSpawnRequest {
23091 spawn_spec: request.spawn_spec.clone(),
23092 worktree_profile_id: request.worktree_profile_id.clone(),
23093 };
23094 shared.remember_managed_spawn_attempt(
23095 Some(&request.spawn_spec.idempotency_key),
23096 legacy_same_key.clone(),
23097 Ok(receipt.clone()),
23098 Instant::now(),
23099 );
23100 assert!(shared
23101 .replay_managed_spawn(&legacy_same_key, Instant::now())
23102 .unwrap()
23103 .is_none());
23104 drop(shared);
23105
23106 let mut loaded = session_registry::load(
23107 Some(&state_path),
23108 &NodeId::new("node-terminal-test").unwrap(),
23109 )
23110 .unwrap();
23111 assert_eq!(loaded.managed_spawn_replays.len(), 1);
23112 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
23113 *reopened
23114 .managed_worktrees
23115 .lock()
23116 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
23117 ManagedWorktreeRegistry::from_records(
23118 std::mem::take(&mut loaded.managed_worktrees),
23119 std::mem::take(&mut loaded.managed_worktree_tombstones),
23120 )
23121 .unwrap();
23122 *reopened
23123 .managed_spawn_replays
23124 .lock()
23125 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
23126 .managed_spawn_replays
23127 .into_iter()
23128 .map(|record| (record.idempotency_key.clone(), record))
23129 .collect();
23130 let lease_count_before = reopened
23131 .managed_worktrees
23132 .lock()
23133 .unwrap_or_else(|poisoned| poisoned.into_inner())
23134 .records()
23135 .len();
23136
23137 assert_eq!(
23138 reopened
23139 .spawn_managed_worktree_v2(request.clone())
23140 .await
23141 .unwrap(),
23142 receipt,
23143 );
23144 assert_eq!(
23145 reopened
23146 .managed_worktrees
23147 .lock()
23148 .unwrap_or_else(|poisoned| poisoned.into_inner())
23149 .records()
23150 .len(),
23151 lease_count_before,
23152 );
23153 let mut changed = request;
23154 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
23155 let failure = reopened
23156 .spawn_managed_worktree_v2(changed)
23157 .await
23158 .unwrap_err();
23159 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23160 assert_eq!(
23161 reopened
23162 .managed_worktrees
23163 .lock()
23164 .unwrap_or_else(|poisoned| poisoned.into_inner())
23165 .records()
23166 .len(),
23167 lease_count_before,
23168 );
23169 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
23170 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
23171 let failure = reopened
23172 .spawn_managed_worktree_v2(new_key)
23173 .await
23174 .unwrap_err();
23175 assert_eq!(
23176 failure.code,
23177 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
23178 );
23179 assert_eq!(
23180 reopened
23181 .managed_worktrees
23182 .lock()
23183 .unwrap_or_else(|poisoned| poisoned.into_inner())
23184 .records()
23185 .len(),
23186 lease_count_before,
23187 );
23188 assert_eq!(
23189 reopened
23190 .managed_spawn_replays
23191 .lock()
23192 .unwrap_or_else(|poisoned| poisoned.into_inner())
23193 .len(),
23194 1,
23195 );
23196 std::fs::remove_dir_all(root).unwrap();
23197 }
23198
23199 #[test]
23200 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
23201 let shared = terminal_test_shared();
23202 let request = ManagedWorktreeSpawnRequest {
23203 spawn_spec: spawn_spec_fixture(),
23204 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
23205 };
23206 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
23207 shared.remember_managed_spawn(
23208 request.clone(),
23209 Err(expected.clone()),
23210 Instant::now(),
23211 );
23212
23213 assert_eq!(
23214 shared
23215 .replay_managed_spawn(&request, Instant::now())
23216 .unwrap()
23217 .unwrap(),
23218 Err(expected),
23219 );
23220 assert!(shared
23221 .managed_spawn_replays
23222 .lock()
23223 .unwrap_or_else(|poisoned| poisoned.into_inner())
23224 .is_empty());
23225 }
23226
23227 #[tokio::test]
23228 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
23229 let shared = terminal_test_shared();
23230 let lease = install_managed_lease(&shared);
23231 let raw = shared.spawn_session(
23232 lease.workspace_id.clone(),
23233 agent("claude"),
23234 SessionMode::Pty,
23235 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
23236 None,
23237 ).await.unwrap_err();
23238 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23239 let mut spec = spawn_spec_fixture();
23240 spec.target.workspace_id = lease.workspace_id.clone();
23241 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
23242 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23243 let mut selected = spawn_spec_fixture();
23244 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
23245 selected.target.worktree_id = Some(lease.workspace_id);
23246 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
23247 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23248 assert!(shared.handle.snapshot().sessions.is_empty());
23249 }
23250
23251 #[test]
23252 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23253 let shared = terminal_test_shared();
23254 let lease = install_managed_lease(&shared);
23255 let snapshot = snapshot_for_wire(
23256 &shared, true, true, true, false, true, true, false, false, true,
23257 );
23258 assert!(snapshot.managed_worktrees.is_empty());
23259 assert!(snapshot.workspaces.iter().all(|workspace| {
23260 workspace.managed_worktree_profiles.is_none()
23261 }));
23262 let mut reply = ResponseEnvelope {
23263 request_id: 1,
23264 result: Ok(NodeResponse::Resync {
23265 event_sequence: 1,
23266 oldest_available_sequence: 1,
23267 snapshot: shared.snapshot(),
23268 events: vec![NodeEventEnvelope {
23269 sequence: 1,
23270 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23271 }],
23272 }),
23273 };
23274 project_response_without_managed_worktrees(&mut reply);
23275 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23276 panic!("projection changed the response kind");
23277 };
23278 assert!(snapshot.managed_worktrees.is_empty());
23279 assert!(events.is_empty());
23280 let mut git = git_snapshot_for_parser();
23281 git.branch = Some(lease.branch.clone());
23282 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23283 lease_id: lease.lease_id.clone(),
23284 source_workspace_id: lease.source_workspace_id.clone(),
23285 branch: lease.branch.clone(),
23286 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23287 active_session_count: 1,
23288 managed_record_count: 0,
23289 });
23290 let mut inspection_reply = ResponseEnvelope {
23291 request_id: 2,
23292 result: Ok(NodeResponse::WorkspaceInspected {
23293 inspection: WorkspaceInspection {
23294 workspace_id: lease.workspace_id.clone(),
23295 entries: Vec::new(),
23296 tree_truncated: false,
23297 git,
23298 truncation: None,
23299 },
23300 }),
23301 };
23302 project_response_without_managed_worktrees(&mut inspection_reply);
23303 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23304 panic!("projection changed the inspection response kind");
23305 };
23306 assert!(inspection.git.managed_worktree.is_none());
23307 let failure = managed_git_worktree_failure(GitWorktreeError {
23308 kind: GitWorktreeErrorKind::Failed,
23309 message: r"git failed at C:\private\managed-target".to_owned(),
23310 });
23311 assert!(!failure.message.contains("private"));
23312 assert!(!failure.message.contains("managed-target"));
23313 }
23314
23315 #[tokio::test]
23316 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23317 let root = temporary_workspace_root("managed-restart");
23318 let source = root.join("source");
23319 let allocation = root.join("allocation");
23320 std::fs::create_dir_all(&source).unwrap();
23321 std::fs::create_dir_all(&allocation).unwrap();
23322 run_test_git(&source, &["init"]);
23323 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23324 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23325 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23326 run_test_git(&source, &["add", "seed.txt"]);
23327 run_test_git(&source, &["commit", "-m", "seed"]);
23328 let profile = ManagedWorktreeProfile::new(
23329 WorktreeProfileId::new("default").unwrap(),
23330 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23331 &allocation,
23332 "gate4agent",
23333 "HEAD",
23334 ManagedWorktreeRetention::Retain,
23335 ).unwrap();
23336 let workspace = WorkspaceConfig::new(
23337 WorkspaceId::new("primary").unwrap(),
23338 &source,
23339 ).unwrap()
23340 .with_worktree_service_mode(WorktreeServiceMode::Managed)
23341 .with_managed_worktree_profile(profile.clone()).unwrap();
23342 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23343 let source_text = workspace.canonical_root().to_owned();
23344 let target_text = target.to_string_lossy().into_owned();
23345 let created = create_git_worktree(
23346 &source_text,
23347 &target_text,
23348 "gate4agent/mw-restart",
23349 Some("HEAD"),
23350 ).await.unwrap();
23351 let lease = ManagedWorktreeLeaseRecord {
23352 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23353 source_workspace_id: WorkspaceId::new("primary").unwrap(),
23354 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23355 profile_id: profile.profile_id().clone(),
23356 profile_revision: profile.revision().clone(),
23357 target_root: created.path.clone(),
23358 branch: "gate4agent/mw-restart".to_owned(),
23359 base_commit: created.head.clone(),
23360 expected_head: None,
23361 retention: ManagedWorktreeRetention::Retain,
23362 state: ManagedWorktreeLeaseState::Allocating,
23363 session_holders: Vec::new(),
23364 record_holders: Vec::new(),
23365 cleanup_failure: None,
23366 created_at_unix_ms: 1,
23367 updated_at_unix_ms: 1,
23368 };
23369 let catalog = active_registry().unwrap();
23370 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23371 let shared = NodeShared::new_with_incarnation(
23372 handle,
23373 "fixture-token".to_owned(),
23374 NodeId::new("node-managed-restart").unwrap(),
23375 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23376 vec![workspace],
23377 vec![agent("claude")],
23378 ProviderRuntimeStatuses::default(),
23379 None,
23380 Vec::new(),
23381 Vec::new(),
23382 SpawnProfileRegistry::default(),
23383 None,
23384 None,
23385 None,
23386 None,
23387 Vec::new(),
23388 vec![lease.clone()],
23389 Vec::new(),
23390 Vec::new(),
23391 None,
23392 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23393 );
23394 shared.reconcile_managed_worktrees().await;
23395 let retained = shared.managed_worktrees.lock()
23396 .unwrap_or_else(|poisoned| poisoned.into_inner())
23397 .get(&lease.lease_id).unwrap().clone();
23398 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23399 assert!(shared.workspaces.read()
23400 .unwrap_or_else(|poisoned| poisoned.into_inner())
23401 .contains_key(&lease.workspace_id));
23402 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23403 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23404 assert!(!Path::new(&created.path).exists());
23405 std::fs::remove_dir_all(root).unwrap();
23406 }
23407
23408 fn run_test_git(root: &Path, arguments: &[&str]) {
23409 let output = std::process::Command::new("git")
23410 .arg("-C").arg(root).args(arguments).output().unwrap();
23411 assert!(
23412 output.status.success(),
23413 "git {:?} failed: {}",
23414 arguments,
23415 String::from_utf8_lossy(&output.stderr),
23416 );
23417 }
23418
23419 #[tokio::test]
23420 async fn spawn_spec_validation_fails_before_session_mutation() {
23421 let shared = terminal_test_shared();
23422 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23423 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23424 }));
23425 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23426 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23427 }));
23428 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23429 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23430 }));
23431
23432 let mut target_mismatch = spawn_spec_fixture();
23433 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23434 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23435 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23436
23437 let mut unknown_profile = spawn_spec_fixture();
23438 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23439 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23440 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23441 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23442
23443 let mut revision_mismatch = spawn_spec_fixture();
23444 revision_mismatch.idempotency_key =
23445 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23446 revision_mismatch.expected_profile_revision =
23447 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23448 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23449 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23450
23451 let mut unsupported_materializer = spawn_spec_fixture();
23452 unsupported_materializer.idempotency_key =
23453 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23454 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23455 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23456 };
23457 let failure = shared
23458 .spawn_from_spec(unsupported_materializer)
23459 .await
23460 .unwrap_err();
23461 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23462
23463 let failure = shared
23464 .spawn_session_with_deadline(
23465 WorkspaceId::new("primary").unwrap(),
23466 agent("claude"),
23467 SessionMode::Pty,
23468 gate4agent_types::TerminalSize {
23469 rows: 24,
23470 columns: 80,
23471 },
23472 None,
23473 None,
23474 None,
23475 None,
23476 None,
23477 None,
23478 SpawnRecordPolicy::ProviderIdentityOnly,
23479 Some(Instant::now()),
23480 &[],
23481 None,
23482 ApprovalLevel::default(),
23483 None,
23484 None,
23485 )
23486 .await
23487 .unwrap_err();
23488 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23489 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23490 assert!(shared.handle.snapshot().sessions.is_empty());
23491 }
23492
23493 #[test]
23494 fn worktree_selection_requires_its_separate_negotiated_capability() {
23495 let mut spec = spawn_spec_fixture();
23496 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23497 let request = NodeRequest::SpawnSpec { spec };
23498 let spawn_spec_capability =
23499 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23500 assert!(request_uses_unnegotiated_capability(
23501 &request,
23502 &[spawn_spec_capability.clone()],
23503 ));
23504 let worktree_capability =
23505 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23506 assert!(request_uses_unnegotiated_capability(
23507 &request,
23508 &[spawn_spec_capability.clone(), worktree_capability.clone()],
23509 ));
23510 let profile_revision_capability =
23511 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23512 assert!(request_uses_unnegotiated_capability(
23513 &request,
23514 &[
23515 spawn_spec_capability.clone(),
23516 worktree_capability.clone(),
23517 profile_revision_capability.clone(),
23518 ],
23519 ));
23520 let bundle_materialization_capability =
23527 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23528 assert!(request_uses_unnegotiated_capability(
23529 &request,
23530 &[
23531 spawn_spec_capability.clone(),
23532 worktree_capability.clone(),
23533 profile_revision_capability.clone(),
23534 bundle_materialization_capability.clone(),
23535 ],
23536 ));
23537 let history_context_pack_capability =
23538 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23539 assert!(!request_uses_unnegotiated_capability(
23540 &request,
23541 &[
23542 spawn_spec_capability,
23543 worktree_capability,
23544 profile_revision_capability,
23545 bundle_materialization_capability,
23546 history_context_pack_capability,
23547 ],
23548 ));
23549 }
23550
23551 #[tokio::test]
23552 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23553 let mut shared = terminal_test_shared();
23554 let spec = spawn_spec_fixture();
23555 let receipt = shared
23556 .resolve_spawn_spec(&spec)
23557 .unwrap()
23558 .receipt(shared.incarnation_id, terminal_address(1));
23559 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23560
23561 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23562 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23563 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23564
23565 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23566
23567 let mut revision_conflicting = spec.clone();
23568 revision_conflicting.expected_profile_revision =
23569 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23570 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23571 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23572
23573 let mut stale_cache_miss = spec.clone();
23574 stale_cache_miss.idempotency_key =
23575 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23576 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23577 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23578
23579 let mut conflicting = spec;
23580 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23581 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23582 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23583 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23584 assert!(shared.handle.snapshot().sessions.is_empty());
23585 }
23586
23587 #[tokio::test]
23588 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23589 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23590 let mut spec = spawn_spec_fixture();
23591 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23592 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23593 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23594
23595 let failure = shared
23596 .create_worktree(
23597 WorkspaceId::new("primary").unwrap(),
23598 WorkspaceId::new("new-worktree").unwrap(),
23599 "not-an-absolute-path".to_owned(),
23600 "invalid branch".to_owned(),
23601 None,
23602 )
23603 .await
23604 .unwrap_err();
23605 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23606
23607 let failure = shared
23608 .remove_worktree(
23609 WorkspaceId::new("primary").unwrap(),
23610 "not-an-absolute-path".to_owned(),
23611 )
23612 .await
23613 .unwrap_err();
23614 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23615 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23616 assert!(shared.handle.snapshot().sessions.is_empty());
23617 }
23618
23619 #[tokio::test]
23620 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23621 let root = temporary_workspace_root("manual-worktree-compensation");
23622 let source = root.join("source");
23623 let target = root.join("target");
23624 let invalid_state_parent = root.join("state-parent-is-a-file");
23625 let invalid_state_path = invalid_state_parent.join("state.json");
23626 std::fs::create_dir_all(&source).unwrap();
23627 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23628 run_test_git(&source, &["init"]);
23629 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23630 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23631 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23632 run_test_git(&source, &["add", "seed.txt"]);
23633 run_test_git(&source, &["commit", "-m", "seed"]);
23634
23635 let catalog = active_registry().unwrap();
23636 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23637 let workspace = WorkspaceConfig::new(
23638 WorkspaceId::new("primary").unwrap(),
23639 &source,
23640 ).unwrap();
23641 let mut shared = NodeShared::new(
23642 handle,
23643 "fixture-token".to_owned(),
23644 NodeId::new("node-worktree-compensation").unwrap(),
23645 vec![workspace],
23646 vec![agent("claude")],
23647 );
23648 shared.state_path = Some(invalid_state_path);
23649
23650 let error = shared.create_worktree(
23651 WorkspaceId::new("primary").unwrap(),
23652 WorkspaceId::new("compensated").unwrap(),
23653 target.to_string_lossy().into_owned(),
23654 "gate4agent/compensated".to_owned(),
23655 Some("HEAD".to_owned()),
23656 ).await.unwrap_err();
23657
23658 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23659 assert!(!target.exists());
23660 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23661 .iter()
23662 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23663 std::fs::remove_dir_all(root).unwrap();
23664 }
23665
23666 #[test]
23667 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23668 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23669 NativeGitWorktreeSnapshot {
23670 path: path.to_owned(),
23671 head: "0123456789abcdef".to_owned(),
23672 branch: Some("refs/heads/test".to_owned()),
23673 is_bare,
23674 is_main,
23675 locked: false,
23676 lock_reason: None,
23677 prunable: false,
23678 prunable_reason: None,
23679 workspace_id: None,
23680 }
23681 }
23682
23683 let source = r"C:\repo";
23684 let selected = r"C:\repo-worktree";
23685 let listed = vec![
23686 worktree(source, true, false),
23687 worktree(selected, false, false),
23688 ];
23689 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23690
23691 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23692 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23693
23694 let bare = vec![
23695 worktree(source, true, false),
23696 worktree(selected, false, true),
23697 ];
23698 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23699 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23700
23701 let foreign_failure =
23702 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23703 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23704 }
23705
23706 fn terminal_frame(sequence: u64) -> TerminalFrame {
23707 TerminalFrame {
23708 sequence,
23709 size: gate4agent_types::TerminalSize {
23710 rows: 24,
23711 columns: 80,
23712 },
23713 cursor_row: 2,
23714 cursor_column: 3,
23715 contents: format!("frame-{sequence}"),
23716 formatted: vec![1, 2, 3],
23717 scrollback_formatted: Vec::new(),
23718 alternate_screen: false,
23719 mouse_protocol_enabled: false,
23720 mouse_protocol_encoding: Default::default(),
23721 produced_at_unix_ms: 0,
23722 screen_state: gate4agent_types::PtyScreenState::default(),
23723 bracketed_paste: None,
23724 }
23725 }
23726
23727 #[test]
23728 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23729 let shared = terminal_test_shared();
23730 let mut receiver = shared.terminal_event_tx.subscribe();
23731 let original = terminal_address(1);
23732
23733 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23734 let first = receiver.try_recv().unwrap();
23735 assert_eq!(first.len(), 1);
23736 assert!(matches!(
23737 &first[0].event,
23738 NodeEvent::TerminalFrame { address, frame }
23739 if address == &original && frame.sequence == 7
23740 ));
23741
23742 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23743 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23744 assert!(matches!(
23745 receiver.try_recv(),
23746 Err(broadcast::error::TryRecvError::Empty)
23747 ));
23748
23749 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23750 let advanced = receiver.try_recv().unwrap();
23751 assert_eq!(advanced[0].sequence, 2);
23752
23753 let resumed = terminal_address(2);
23754 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23755 let reset = receiver.try_recv().unwrap();
23756 assert_eq!(reset[0].sequence, 3);
23757 assert!(matches!(
23758 &reset[0].event,
23759 NodeEvent::TerminalFrame { address, frame }
23760 if address == &resumed && frame.sequence == 1
23761 ));
23762 shared.clear_terminal_frame_watermark(&resumed);
23763 assert!(!shared
23764 .terminal_frame_watermarks
23765 .lock()
23766 .unwrap_or_else(|poisoned| poisoned.into_inner())
23767 .contains_key(&resumed.session.instance_id));
23768 }
23769
23770 #[test]
23771 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23772 let shared = terminal_test_shared();
23773 let mut receiver = shared.terminal_event_tx.subscribe();
23774 let address = terminal_address(1);
23775 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23776
23777 for sequence in 1..=count {
23778 shared.publish_terminal_frame_candidates(vec![(
23779 address.clone(),
23780 terminal_frame(sequence),
23781 )]);
23782 }
23783
23784 let history = shared
23785 .history
23786 .lock()
23787 .unwrap_or_else(|poisoned| poisoned.into_inner());
23788 assert!(history.events.is_empty());
23789 assert_eq!(history.last_sequence, count);
23790 assert_eq!(history.replay_floor_sequence, 1);
23791 drop(history);
23792 let NodeResponse::Resync {
23793 event_sequence,
23794 oldest_available_sequence,
23795 events,
23796 ..
23797 } = shared.resync(0) else {
23798 unreachable!("resync helper returned another response")
23799 };
23800 assert_eq!(event_sequence, count);
23801 assert_eq!(oldest_available_sequence, 1);
23802 assert!(events.is_empty());
23803 assert!(matches!(
23804 resync_required_event(&shared).event,
23805 NodeEvent::ResyncRequired {
23806 oldest_available_sequence: 1,
23807 }
23808 ));
23809 assert!(matches!(
23810 receiver.try_recv(),
23811 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23812 ));
23813 let latest = receiver.try_recv().unwrap();
23814 assert_eq!(latest.len(), 1);
23815 assert_eq!(latest[0].sequence, count);
23816 }
23817
23818 #[test]
23819 fn real_durable_eviction_advances_authoritative_replay_floor() {
23820 let shared = terminal_test_shared();
23821 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23822 shared.publish(NodeEvent::ControllerChanged { controller: None });
23823 }
23824
23825 let NodeResponse::Resync {
23826 event_sequence,
23827 oldest_available_sequence,
23828 events,
23829 ..
23830 } = shared.resync(0) else {
23831 unreachable!("resync helper returned another response")
23832 };
23833 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23834 assert_eq!(oldest_available_sequence, 2);
23835 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23836 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23837 assert!(matches!(
23838 resync_required_event(&shared).event,
23839 NodeEvent::ResyncRequired {
23840 oldest_available_sequence: 2,
23841 }
23842 ));
23843 }
23844
23845 #[test]
23846 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23847 let shared = terminal_test_shared();
23848 let without_capability = terminal_event_subscription(&shared, false);
23849 let with_capability = terminal_event_subscription(&shared, true);
23850 assert!(without_capability.is_none());
23851 assert!(with_capability.is_some());
23852 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23853 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23854 }));
23855 }
23856
23857 #[test]
23858 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23859 let mut pending = Vec::new();
23860 let mut event_sequence = 0_u64;
23861 for frame_sequence in 1..=4 {
23862 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23863 event_sequence += 1;
23864 let mut address = terminal_address(1);
23865 address.session.instance_id = AgentInstanceId(instance);
23866 queue_connection_event(
23867 &mut pending,
23868 NodeEventEnvelope {
23869 sequence: event_sequence,
23870 event: NodeEvent::TerminalFrame {
23871 address,
23872 frame: terminal_frame(frame_sequence),
23873 },
23874 },
23875 0,
23876 );
23877 }
23878 }
23879 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23880 pending.sort_unstable_by_key(|event| event.sequence);
23881
23882 let mut uninterrupted = pending.clone();
23883 let first_burst = connection_event_burst_len(uninterrupted.len());
23884 uninterrupted.drain(..first_burst);
23885 let second_burst = connection_event_burst_len(uninterrupted.len());
23886 uninterrupted.drain(..second_burst);
23887 assert!(uninterrupted.is_empty());
23888
23889 let (frame_tx, mut frame_rx) =
23890 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23891 frame_tx
23892 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23893 request_id: 77,
23894 request: NodeRequest::Snapshot,
23895 })))
23896 .unwrap();
23897 let burst_len = connection_event_burst_len(pending.len());
23898 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23899 pending.drain(..burst_len);
23900 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23901 assert!(matches!(
23902 frame_rx.try_recv(),
23903 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23904 request_id: 77,
23905 request: NodeRequest::Snapshot,
23906 })))
23907 ));
23908 }
23909
23910 #[test]
23911 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23912 let mut pending = Vec::new();
23913 for sequence in [7_u64, 8] {
23914 let mut address = terminal_address(1);
23915 address.session.instance_id = AgentInstanceId(sequence);
23916 queue_connection_event(
23917 &mut pending,
23918 NodeEventEnvelope {
23919 sequence,
23920 event: NodeEvent::TerminalFrame {
23921 address,
23922 frame: terminal_frame(sequence),
23923 },
23924 },
23925 0,
23926 );
23927 }
23928 let mut discard_through = 0;
23929 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23930 assert!(pending.is_empty());
23931
23932 let replayed = NodeEventEnvelope {
23933 sequence: 8,
23934 event: NodeEvent::TerminalFrame {
23935 address: terminal_address(1),
23936 frame: terminal_frame(8),
23937 },
23938 };
23939 let advanced = NodeEventEnvelope {
23940 sequence: 9,
23941 event: NodeEvent::TerminalFrame {
23942 address: terminal_address(2),
23943 frame: terminal_frame(1),
23944 },
23945 };
23946 queue_connection_event_batch(
23947 &mut pending,
23948 &[replayed, advanced.clone()],
23949 discard_through,
23950 );
23951 assert_eq!(pending, vec![advanced]);
23952 }
23953
23954 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23955 ManagedSessionRecord {
23956 record_id: SessionRecordId::new(record_id).unwrap(),
23957 display_name: format!("{provider} record"),
23958 provider: agent(provider),
23959 mode: SessionMode::Pty,
23960 state: ManagedSessionState::Unavailable,
23961 workspace_id: WorkspaceId::new("primary").unwrap(),
23962 canonical_root: opaque_windows_path(
23963 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23964 ),
23965 provider_session: None,
23966 active_session: None,
23967 environment_profile: None,
23968 bundle: None,
23969 context_id: None,
23970 context: None,
23971 exported_context: None,
23972 task_binding: None,
23973 created_at_unix_ms: 1,
23974 updated_at_unix_ms: 1,
23975 last_error: None,
23976 }
23977 }
23978
23979 fn retention_test_record(
23980 workspace_id: &str,
23981 record_id: &str,
23982 state: ManagedSessionState,
23983 updated_at_unix_ms: u64,
23984 ) -> ManagedSessionRecord {
23985 ManagedSessionRecord {
23986 record_id: SessionRecordId::new(record_id).unwrap(),
23987 display_name: format!("{record_id} display"),
23988 provider: agent("claude"),
23989 mode: SessionMode::Pty,
23990 state,
23991 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23992 canonical_root: opaque_windows_path(
23993 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23994 ),
23995 provider_session: None,
23996 active_session: None,
23997 environment_profile: None,
23998 bundle: None,
23999 context_id: None,
24000 context: None,
24001 exported_context: None,
24002 task_binding: None,
24003 created_at_unix_ms: updated_at_unix_ms,
24004 updated_at_unix_ms,
24005 last_error: None,
24006 }
24007 }
24008
24009 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
24017 vec![
24018 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
24019 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
24020 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
24021 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
24022 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
24023 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
24024 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
24025 retention_test_record(
24026 "workspace-a",
24027 "decoy-pending",
24028 ManagedSessionState::IdentityPending,
24029 now - 500_000,
24030 ),
24031 retention_test_record(
24032 "workspace-a",
24033 "decoy-dormant",
24034 ManagedSessionState::Dormant,
24035 now - 500_000,
24036 ),
24037 ]
24038 }
24039
24040 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
24041 let root = temporary_workspace_root("session-record-retention");
24042 std::fs::create_dir_all(&root).unwrap();
24043 let catalog = active_registry().unwrap();
24044 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24045 let workspace_a =
24046 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
24047 let workspace_b =
24048 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
24049 let mut shared = NodeShared::new(
24050 handle,
24051 "fixture-token".to_owned(),
24052 NodeId::new("node-retention-test").unwrap(),
24053 vec![workspace_a, workspace_b],
24054 vec![agent("claude")],
24055 );
24056 shared.state_path = Some(root.join("node-state-v10.json"));
24057 let now = unix_time_ms();
24058 for record in retention_fixture_records(now) {
24059 shared.insert_record(record).unwrap();
24060 }
24061 shared
24062 .persist_state_locked()
24063 .expect("fixture-only, empty-history persist against a fresh temp state path");
24064 (shared, runtime, root, now)
24065 }
24066
24067 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
24068 shared
24069 .history
24070 .lock()
24071 .unwrap_or_else(|poisoned| poisoned.into_inner())
24072 .events
24073 .iter()
24074 .filter_map(|envelope| match &envelope.event {
24075 NodeEvent::SessionRecordRemoved { record_id } => {
24076 Some(record_id.as_str().to_owned())
24077 }
24078 _ => None,
24079 })
24080 .collect()
24081 }
24082
24083 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
24084 shared
24085 .session_records
24086 .lock()
24087 .unwrap_or_else(|poisoned| poisoned.into_inner())
24088 .records
24089 .keys()
24090 .map(|record_id| record_id.as_str().to_owned())
24091 .collect()
24092 }
24093
24094 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
24095 values.iter().map(|value| (*value).to_owned()).collect()
24096 }
24097
24098 #[tokio::test]
24099 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
24100 let (mut shared, runtime, root, _now) = retention_fixture_shared();
24101 shared.session_record_retention = SessionRecordRetentionConfig {
24102 age_ms: 0,
24103 keep_per_workspace: 1,
24104 };
24105 shared.retire_unavailable_session_records().await;
24106
24107 assert_eq!(
24108 retention_remaining_record_ids(&shared),
24109 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
24110 );
24111 assert_eq!(
24112 retention_removed_record_ids(&shared),
24113 owned(&["a1", "a2", "b1", "b2"]),
24114 );
24115 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
24116
24117 drop(shared);
24118 drop(runtime);
24119 std::fs::remove_dir_all(root).unwrap();
24120 }
24121
24122 #[tokio::test]
24128 async fn session_record_retention_both_set_retires_only_the_intersection() {
24129 let (mut shared, runtime, root, _now) = retention_fixture_shared();
24130 shared.session_record_retention = SessionRecordRetentionConfig {
24131 age_ms: 250_000,
24132 keep_per_workspace: 1,
24133 };
24134 shared.retire_unavailable_session_records().await;
24135
24136 assert_eq!(
24137 retention_remaining_record_ids(&shared),
24138 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
24139 );
24140 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
24141 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
24142
24143 drop(shared);
24144 drop(runtime);
24145 std::fs::remove_dir_all(root).unwrap();
24146 }
24147
24148 #[tokio::test]
24149 async fn session_record_retention_both_disabled_is_a_no_op() {
24150 let (mut shared, runtime, root, _now) = retention_fixture_shared();
24151 shared.session_record_retention = SessionRecordRetentionConfig::default();
24152 shared.retire_unavailable_session_records().await;
24153
24154 assert_eq!(
24155 retention_remaining_record_ids(&shared),
24156 owned(&[
24157 "a1", "a2", "a3", "b1", "b2", "b3",
24158 "decoy-live", "decoy-pending", "decoy-dormant",
24159 ]),
24160 );
24161 assert!(retention_removed_record_ids(&shared).is_empty());
24162 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
24163
24164 drop(shared);
24165 drop(runtime);
24166 std::fs::remove_dir_all(root).unwrap();
24167 }
24168
24169 #[test]
24170 fn session_record_context_export_selects_one_exact_history_identity() {
24171 let candidates = vec![
24172 HistoryCandidateSummary {
24173 id: "candidate-other".to_owned(),
24174 session_id_hint: "provider-other".to_owned(),
24175 modified_at_unix_ms: Some(1),
24176 },
24177 HistoryCandidateSummary {
24178 id: "candidate-exact".to_owned(),
24179 session_id_hint: "provider-exact".to_owned(),
24180 modified_at_unix_ms: Some(2),
24181 },
24182 ];
24183 assert_eq!(
24184 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
24185 "candidate-exact",
24186 );
24187 }
24188
24189 #[test]
24190 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
24191 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24192 id: id.to_owned(),
24193 session_id_hint: hint.to_owned(),
24194 modified_at_unix_ms: None,
24195 };
24196 let missing = select_history_candidate(
24197 &[candidate("candidate-other", "provider-other")],
24198 Some("provider-exact"),
24199 )
24200 .unwrap_err();
24201 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24202 let ambiguous = select_history_candidate(
24203 &[
24204 candidate("candidate-a", "provider-exact"),
24205 candidate("candidate-b", "provider-exact"),
24206 ],
24207 Some("provider-exact"),
24208 )
24209 .unwrap_err();
24210 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24211 }
24212
24213 #[test]
24221 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
24222 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24223 id: id.to_owned(),
24224 session_id_hint: hint.to_owned(),
24225 modified_at_unix_ms: None,
24226 };
24227 assert_eq!(
24228 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
24229 .unwrap(),
24230 "candidate-sole",
24231 );
24232 let missing = select_history_candidate(&[], None).unwrap_err();
24233 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24234 let ambiguous = select_history_candidate(
24235 &[
24236 candidate("candidate-a", "sidecar-session-a"),
24237 candidate("candidate-b", "sidecar-session-b"),
24238 ],
24239 None,
24240 )
24241 .unwrap_err();
24242 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24243 }
24244
24245 #[test]
24246 fn session_record_context_export_revalidation_rejects_record_change() {
24247 let session = terminal_address(1);
24248 let identity = ProviderSessionIdentity {
24249 key: ProviderSessionKey::SessionId,
24250 id: "provider-exact".to_owned(),
24251 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24252 };
24253 let mut expected = record("claude", "record-context-export");
24254 expected.state = ManagedSessionState::Live;
24255 expected.provider_session = Some(identity.clone());
24256 expected.active_session = Some(session.clone());
24257 let binding = SessionBinding {
24258 workspace_id: session.workspace_id.clone(),
24259 generation: session.session.generation,
24260 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24261 pending_resume: None,
24262 record_id: Some(expected.record_id.clone()),
24263 managed_worktree_lease_id: None,
24264 environment_profile: None,
24265 bundle: None,
24266 context: None,
24267 materialization_id: None,
24268 };
24269 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24270 assert!(session_record_context_export_binding_is_exact(
24271 &expected,
24272 &expected,
24273 Some(&identity),
24274 &session,
24275 &binding,
24276 &expected.provider,
24277 ¤t_root,
24278 false,
24279 ));
24280 let mut changed = expected.clone();
24281 changed.updated_at_unix_ms += 1;
24282 assert!(!session_record_context_export_binding_is_exact(
24283 &expected,
24284 &changed,
24285 Some(&identity),
24286 &session,
24287 &binding,
24288 &expected.provider,
24289 ¤t_root,
24290 false,
24291 ));
24292 assert!(!session_record_context_export_binding_is_exact(
24297 &expected,
24298 &changed,
24299 Some(&identity),
24300 &session,
24301 &binding,
24302 &expected.provider,
24303 ¤t_root,
24304 true,
24305 ));
24306 let mut detached = expected.clone();
24307 detached.state = ManagedSessionState::Dormant;
24308 detached.active_session = None;
24309 detached.updated_at_unix_ms += 1;
24310 assert!(!session_record_context_export_binding_is_exact(
24311 &expected,
24312 &detached,
24313 Some(&identity),
24314 &session,
24315 &binding,
24316 &expected.provider,
24317 ¤t_root,
24318 false,
24319 ));
24320 assert!(session_record_context_export_binding_is_exact(
24321 &expected,
24322 &detached,
24323 Some(&identity),
24324 &session,
24325 &binding,
24326 &expected.provider,
24327 ¤t_root,
24328 true,
24329 ));
24330 }
24331
24332 #[test]
24339 fn session_record_context_export_binding_is_exact_without_identity() {
24340 let session = terminal_address(1);
24341 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24342 expected.state = ManagedSessionState::Live;
24343 expected.active_session = Some(session.clone());
24344 let binding = SessionBinding {
24345 workspace_id: session.workspace_id.clone(),
24346 generation: session.session.generation,
24347 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24348 pending_resume: None,
24349 record_id: Some(expected.record_id.clone()),
24350 managed_worktree_lease_id: None,
24351 environment_profile: None,
24352 bundle: None,
24353 context: None,
24354 materialization_id: None,
24355 };
24356 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24357 assert!(session_record_context_export_binding_is_exact(
24358 &expected,
24359 &expected,
24360 None,
24361 &session,
24362 &binding,
24363 &expected.provider,
24364 ¤t_root,
24365 false,
24366 ));
24367 let mut drifted = expected.clone();
24371 drifted.provider_session = Some(ProviderSessionIdentity {
24372 key: ProviderSessionKey::SessionId,
24373 id: "unexpected-identity".to_owned(),
24374 transcript_path: None,
24375 });
24376 assert!(!session_record_context_export_binding_is_exact(
24377 &expected,
24378 &drifted,
24379 None,
24380 &session,
24381 &binding,
24382 &expected.provider,
24383 ¤t_root,
24384 false,
24385 ));
24386 }
24387
24388 #[test]
24389 fn session_record_context_export_preflight_requires_live_usable_source() {
24390 let mut source = record("claude", "record-context-export-preflight");
24391 source.state = ManagedSessionState::Live;
24392 assert!(session_record_context_export_source_is_usable(
24393 &source,
24394 &SessionStatus::Running,
24395 false,
24396 ));
24397 assert!(session_record_context_export_source_is_usable(
24398 &source,
24399 &SessionStatus::Exited { exit_code: Some(0) },
24400 false,
24401 ));
24402
24403 source.state = ManagedSessionState::Dormant;
24404 assert!(!session_record_context_export_source_is_usable(
24405 &source,
24406 &SessionStatus::Running,
24407 false,
24408 ));
24409 assert!(session_record_context_export_source_is_usable(
24413 &source,
24414 &SessionStatus::Exited { exit_code: Some(0) },
24415 true,
24416 ));
24417 assert!(!session_record_context_export_source_is_usable(
24418 &source,
24419 &SessionStatus::Exited { exit_code: Some(0) },
24420 false,
24421 ));
24422 source.state = ManagedSessionState::Live;
24423 assert!(!session_record_context_export_source_is_usable(
24424 &source,
24425 &SessionStatus::Starting,
24426 false,
24427 ));
24428 }
24429
24430 #[test]
24431 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24432 let catalog = active_registry().unwrap();
24433 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24434 let workspace = WorkspaceConfig::new(
24435 WorkspaceId::new("primary").unwrap(),
24436 std::env::current_dir().unwrap(),
24437 )
24438 .unwrap();
24439 let shared = NodeShared::new(
24440 handle,
24441 "fixture-token".to_owned(),
24442 NodeId::new("node-context-export-conflict").unwrap(),
24443 vec![workspace],
24444 vec![agent("claude")],
24445 );
24446 let history = HistorySessionRecord {
24447 session_id: "provider-exact".to_owned(),
24448 title: None,
24449 cwd: None,
24450 model: None,
24451 message_count: 1,
24452 completed_turn_count: None,
24453 total_tokens: 0,
24454 messages: vec![gate4agent_types::HistoryMessageRecord {
24455 role: gate4agent_types::HistoryMessageRole::User,
24456 text: "bounded context".to_owned(),
24457 }],
24458 };
24459 let pack = NodeContextPack::export(
24460 ContextPackLineageReceipt {
24461 source_node_id: shared.node_id.clone(),
24462 source_session: terminal_address(1),
24463 source_provider: agent("claude"),
24464 },
24465 &history,
24466 )
24467 .unwrap();
24468 let context_id = pack.receipt().id.clone();
24469 let session = terminal_address(1);
24470 let identity = ProviderSessionIdentity {
24471 key: ProviderSessionKey::SessionId,
24472 id: history.session_id.clone(),
24473 transcript_path: None,
24474 };
24475 let mut expected = record("claude", "record-context-export-final-conflict");
24476 expected.state = ManagedSessionState::Live;
24477 expected.provider_session = Some(identity.clone());
24478 expected.active_session = Some(session.clone());
24479 let mut changed = expected.clone();
24480 changed.updated_at_unix_ms += 1;
24481 shared.insert_record(changed).unwrap();
24482
24483 let error = shared
24484 .commit_context_pack_for_session_record(
24485 &expected,
24486 Some(&identity),
24487 &session,
24488 "candidate-exact",
24489 &history,
24490 pack,
24491 false,
24492 )
24493 .unwrap_err();
24494
24495 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24496 assert!(shared
24497 .context_catalog
24498 .read()
24499 .unwrap_or_else(|poisoned| poisoned.into_inner())
24500 .get(&context_id)
24501 .is_none());
24502 }
24503
24504 #[tokio::test]
24505 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24506 let root = temporary_workspace_root("session-task-binding");
24507 std::fs::create_dir_all(&root).unwrap();
24508 let state_path = root.join("node-state.json");
24509 let catalog = active_registry().unwrap();
24510 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24511 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24512 let node_id = NodeId::new("node-1").unwrap();
24513 let mut shared = NodeShared::new(
24514 handle,
24515 "fixture-token".to_owned(),
24516 node_id.clone(),
24517 vec![workspace],
24518 vec![agent("claude")],
24519 );
24520 shared.state_path = Some(state_path.clone());
24521 let record = record("claude", "sr-task");
24522 let record_id = record.record_id.clone();
24523 shared.insert_record(record).unwrap();
24524 shared.persist_state().unwrap();
24525 shared
24526 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24527 .unwrap();
24528
24529 let minted = process_request_inner(
24530 &shared,
24531 77,
24532 ClientRole::Operator,
24533 NodeRequest::SetSessionTask {
24534 record_id: record_id.clone(),
24535 expected_revision: 0,
24536 target: SessionTaskTargetV1::New,
24537 },
24538 )
24539 .await
24540 .unwrap();
24541 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24542 panic!("session task mutation returned another response");
24543 };
24544 let minted_binding = minted.task_binding.clone().unwrap();
24545 let minted_task_id = minted_binding.task_id.clone().unwrap();
24546 assert_eq!(minted_binding.revision, 1);
24547 assert!(minted_task_id.as_str().starts_with("task-"));
24548 assert_eq!(minted_task_id.as_str().len(), 29);
24549
24550 let event_count = shared.current_sequence();
24551 let repeated = process_request_inner(
24552 &shared,
24553 77,
24554 ClientRole::Operator,
24555 NodeRequest::SetSessionTask {
24556 record_id: record_id.clone(),
24557 expected_revision: 1,
24558 target: SessionTaskTargetV1::Existing {
24559 task_id: minted_task_id.clone(),
24560 },
24561 },
24562 )
24563 .await
24564 .unwrap();
24565 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24566 assert_eq!(shared.current_sequence(), event_count);
24567
24568 let stale = process_request_inner(
24569 &shared,
24570 77,
24571 ClientRole::Operator,
24572 NodeRequest::SetSessionTask {
24573 record_id: record_id.clone(),
24574 expected_revision: 0,
24575 target: SessionTaskTargetV1::Existing {
24576 task_id: minted_task_id,
24577 },
24578 },
24579 )
24580 .await
24581 .unwrap_err();
24582 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24583
24584 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24585 let assigned = process_request_inner(
24586 &shared,
24587 77,
24588 ClientRole::Operator,
24589 NodeRequest::SetSessionTask {
24590 record_id: record_id.clone(),
24591 expected_revision: 1,
24592 target: SessionTaskTargetV1::Existing {
24593 task_id: assigned_id.clone(),
24594 },
24595 },
24596 )
24597 .await
24598 .unwrap();
24599 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24600 panic!("session task assignment returned another response");
24601 };
24602 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24603 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24604
24605 let cleared = process_request_inner(
24606 &shared,
24607 77,
24608 ClientRole::Operator,
24609 NodeRequest::SetSessionTask {
24610 record_id: record_id.clone(),
24611 expected_revision: 2,
24612 target: SessionTaskTargetV1::Clear,
24613 },
24614 )
24615 .await
24616 .unwrap();
24617 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24618 panic!("session task clear returned another response");
24619 };
24620 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24621 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24622 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24623 assert_eq!(
24624 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24625 cleared.updated_at_unix_ms,
24626 );
24627
24628 let persisted = std::fs::read(&state_path).unwrap();
24629 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24630 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24631 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24632 std::fs::remove_dir_all(root).unwrap();
24633 }
24634
24635 #[test]
24636 fn session_task_capability_strips_snapshots_events_and_replies() {
24637 let mut bound = record("claude", "sr-task-projection");
24638 bound.updated_at_unix_ms = 2;
24639 bound.task_binding = Some(SessionTaskBindingV1 {
24640 revision: 1,
24641 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24642 changed_at_unix_ms: 2,
24643 });
24644 let request = NodeRequest::SetSessionTask {
24645 record_id: bound.record_id.clone(),
24646 expected_revision: 1,
24647 target: SessionTaskTargetV1::Clear,
24648 };
24649 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24650 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24651 }));
24652 assert!(!request_uses_unnegotiated_capability(
24653 &request,
24654 &baseline_capabilities().unwrap(),
24655 ));
24656 assert!(request_uses_unnegotiated_capability(&request, &[]));
24657
24658 let mut snapshot = terminal_test_shared().snapshot();
24659 snapshot.session_records.push(bound.clone());
24660 let mut reply = ResponseEnvelope {
24661 request_id: 1,
24662 result: Ok(NodeResponse::Resync {
24663 event_sequence: 1,
24664 oldest_available_sequence: 1,
24665 snapshot,
24666 events: vec![NodeEventEnvelope {
24667 sequence: 1,
24668 event: NodeEvent::SessionRecordUpserted {
24669 record: bound.clone(),
24670 },
24671 }],
24672 }),
24673 };
24674 project_response_without_session_task_binding(&mut reply);
24675 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24676 panic!("task projection changed the response kind");
24677 };
24678 assert!(snapshot.session_records[0].task_binding.is_none());
24679 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24680 panic!("task projection changed the event kind");
24681 };
24682 assert!(record.task_binding.is_none());
24683
24684 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24685 sequence: 2,
24686 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24687 });
24688 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24689 panic!("task event projection changed the event kind");
24690 };
24691 assert!(record.task_binding.is_none());
24692
24693 let mut direct = ResponseEnvelope {
24694 request_id: 2,
24695 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24696 };
24697 project_response_without_session_task_binding(&mut direct);
24698 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24699 panic!("task reply projection changed the response kind");
24700 };
24701 assert!(record.task_binding.is_none());
24702 }
24703
24704 #[test]
24705 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24706 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24707 let workspace = WorkspaceConfig::new(
24708 WorkspaceId::new("primary").unwrap(),
24709 std::env::current_dir().unwrap(),
24710 )
24711 .unwrap();
24712 let config = NodeServerConfig::new(
24713 DEFAULT_NODE_ENDPOINT,
24714 "fixture-token",
24715 NodeId::new("node-1").unwrap(),
24716 [workspace],
24717 )
24718 .unwrap();
24719 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24720 }
24721
24722 #[cfg(all(feature = "fixture", windows))]
24723 #[test]
24724 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24725 let root = temporary_workspace_root("provider-bundle-argv-hold");
24726 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24727 std::fs::create_dir_all(&root).unwrap();
24728 std::fs::create_dir_all(&outside).unwrap();
24729 let proof_path = root.join("provider.proof");
24730 let release_signal = root.join("release.signal");
24731 let config = NodeServerConfig::new(
24732 format!(
24733 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24734 std::process::id(),
24735 ),
24736 "fixture-token",
24737 NodeId::new("provider-bundle-hold-node").unwrap(),
24738 [WorkspaceConfig::new(
24739 WorkspaceId::new("primary").unwrap(),
24740 std::env::current_dir().unwrap(),
24741 )
24742 .unwrap()],
24743 )
24744 .unwrap()
24745 .with_spawn_profiles(SpawnProfileRegistry::new([
24746 crate::protocol::SpawnProfileDefaults {
24747 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24748 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24749 .unwrap(),
24750 provider: agent("kimi"),
24751 mode: SessionMode::Pty,
24752 terminal_size: gate4agent_types::TerminalSize {
24753 rows: 24,
24754 columns: 80,
24755 },
24756 prompt: None,
24757 bundle_id: None,
24758 context_id: None,
24759 environment_profile_id: None,
24760 },
24761 ])
24762 .unwrap());
24763 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24764 config,
24765 agent("kimi"),
24766 proof_path.clone(),
24767 release_signal.clone(),
24768 )
24769 .unwrap();
24770 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24771
24772 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24773 agent("kimi"),
24774 proof_path.clone(),
24775 Some(release_signal.clone()),
24776 )
24777 .unwrap();
24778 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24779 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24780 let release_wait = wrapper
24781 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24782 .unwrap();
24783 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24784 assert!(proof_write < release_wait);
24785 assert!(release_wait < fixture_ready);
24786 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24787 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24788 assert_eq!(
24789 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24790 &[
24791 proof_path.to_string_lossy().into_owned(),
24792 release_signal.to_string_lossy().into_owned(),
24793 ],
24794 );
24795
24796 let error = NodeServer::provider_bundle_argv_fixture_spec(
24797 agent("kimi"),
24798 proof_path,
24799 Some(outside.join("release.signal")),
24800 )
24801 .unwrap_err();
24802 assert!(matches!(
24803 error,
24804 NodeServerError::Registry(message)
24805 if message == "provider bundle argv release path must share the proof parent"
24806 ));
24807 drop(server);
24808 std::fs::remove_dir_all(root).unwrap();
24809 std::fs::remove_dir_all(outside).unwrap();
24810 }
24811
24812 #[cfg(feature = "fixture")]
24813 #[test]
24814 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24815 let root = temporary_workspace_root("context-fixture-registry");
24816 std::fs::create_dir_all(&root).unwrap();
24817 let proof_path = root.join("context.proof");
24818 let config = NodeServerConfig::new(
24819 format!(
24820 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24821 std::process::id(),
24822 ),
24823 "fixture-token",
24824 NodeId::new("context-fixture-node").unwrap(),
24825 [WorkspaceConfig::new(
24826 WorkspaceId::new("primary").unwrap(),
24827 std::env::current_dir().unwrap(),
24828 )
24829 .unwrap()],
24830 )
24831 .unwrap();
24832 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24833 let mut enabled = server
24834 .shared
24835 .enabled_providers
24836 .iter()
24837 .map(AgentId::as_str)
24838 .collect::<Vec<_>>();
24839 enabled.sort_unstable();
24840 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24841 for provider in &server.shared.enabled_providers {
24842 assert!(server
24843 .shared
24844 .provider_adapter_contracts
24845 .iter()
24846 .any(|contract| {
24847 &contract.provider == provider
24848 && contract.family == AdapterFamily::History
24849 }));
24850 }
24851 drop(server);
24852 std::fs::remove_dir_all(root).unwrap();
24853 }
24854
24855 #[cfg(feature = "fixture")]
24856 #[test]
24857 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24858 let root = temporary_workspace_root("monitoring-context-fixture");
24859 std::fs::create_dir_all(&root).unwrap();
24860 let proof_path = root.join("context.proof");
24861 let standard = NodeServer::context_pack_fixture_catalog(
24862 proof_path.clone(),
24863 None,
24864 false,
24865 ).unwrap();
24866 let monitoring = NodeServer::context_pack_fixture_catalog(
24867 proof_path.clone(),
24868 None,
24869 true,
24870 ).unwrap();
24871 let claude = AgentId::new("claude").unwrap();
24872 let standard_claude = standard.get(&claude).unwrap();
24873 let monitoring_claude = monitoring.get(&claude).unwrap();
24874 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24878 assert_eq!(
24879 monitoring_claude.capabilities.adapters.history,
24880 standard_claude.capabilities.adapters.history,
24881 );
24882 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24883 let codex = AgentId::new("codex").unwrap();
24884 let standard_codex = standard.get(&codex).unwrap();
24885 let monitoring_codex = monitoring.get(&codex).unwrap();
24886 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24887 assert_eq!(
24888 monitoring_codex.capabilities.adapters.history,
24889 standard_codex.capabilities.adapters.history,
24890 );
24891 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24892 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24893 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24894 .unwrap();
24895 for required in [
24896 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24897 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24898 "@('role', 'text')",
24899 "g4a-private-provider-session-canary",
24900 "private-provider-login-identity",
24901 "$rawProof",
24902 "$env:GATE4AGENT_HOOK_URL",
24903 "g4a-private-provider-session-canary",
24904 ] {
24905 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24906 }
24907 for provider in ["grok", "kimi"] {
24908 let provider = AgentId::new(provider).unwrap();
24909 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24910 }
24911
24912 let config = NodeServerConfig::new(
24913 format!(
24914 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24915 std::process::id(),
24916 ),
24917 "fixture-token",
24918 NodeId::new("monitoring-context-fixture-node").unwrap(),
24919 [WorkspaceConfig::new(
24920 WorkspaceId::new("primary").unwrap(),
24921 std::env::current_dir().unwrap(),
24922 )
24923 .unwrap()],
24924 )
24925 .unwrap();
24926 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24927 assert!(server.shared.fixture_semantic_hook_policy);
24928 drop(server);
24929 std::fs::remove_dir_all(root).unwrap();
24930 }
24931
24932 #[cfg(feature = "fixture")]
24933 #[test]
24934 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24935 let root = temporary_workspace_root("kimi-context-only-fixture");
24936 std::fs::create_dir_all(&root).unwrap();
24937 let proof_path = root.join("context.proof");
24938 let standard = NodeServer::context_pack_fixture_catalog(
24939 proof_path.clone(),
24940 None,
24941 false,
24942 ).unwrap();
24943 let kimi = AgentId::new("kimi").unwrap();
24944 let context_only = NodeServer::context_pack_fixture_catalog(
24945 proof_path,
24946 Some(&kimi),
24947 false,
24948 )
24949 .unwrap();
24950
24951 let codex = AgentId::new("codex").unwrap();
24952 assert_eq!(
24953 standard.get(&codex).unwrap().launch.fixed_args,
24954 context_only.get(&codex).unwrap().launch.fixed_args,
24955 );
24956 let script = context_only
24957 .get(&kimi)
24958 .unwrap()
24959 .launch
24960 .fixed_args
24961 .iter()
24962 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24963 .expect("Kimi context-only validation script is missing");
24964 for required in [
24965 "$bundleArgs.Count -ne 0",
24966 "GATE4AGENT_CONTEXT_ROOT",
24967 "context-pack.json",
24968 "$entries.Count -ne 1",
24969 "g4a-context-pack-v1",
24970 "$document.PSObject.Properties['cwd']",
24971 "'claude', 'codex', 'grok', 'kimi'",
24972 "$hasUser",
24973 "$hasAssistant",
24974 "SHA256",
24975 "context-only",
24976 ] {
24977 assert!(script.contains(required), "missing validation: {required}");
24978 }
24979 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24980 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24981 }
24982 assert!(!standard
24983 .get(&kimi)
24984 .unwrap()
24985 .launch
24986 .fixed_args
24987 .iter()
24988 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24989
24990 std::fs::remove_dir_all(root).unwrap();
24991 }
24992
24993 #[cfg(all(feature = "fixture", windows))]
24994 #[tokio::test]
24995 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24996 let root = temporary_workspace_root("kimi-context-only-spawn");
24997 let workspace_root = root.join("workspace");
24998 std::fs::create_dir_all(&workspace_root).unwrap();
24999 let materialization_root = root.join("private-materializations");
25000 let proof_path = root.join("context.proof");
25001 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
25002 let workspace_id = WorkspaceId::new("primary").unwrap();
25003 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
25004 let endpoint = format!(
25005 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
25006 std::process::id(),
25007 unix_time_ms(),
25008 );
25009 let spawn_profiles = SpawnProfileRegistry::new([
25010 crate::protocol::SpawnProfileDefaults {
25011 profile_id: profile_id.clone(),
25012 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
25013 provider: agent("kimi"),
25014 mode: SessionMode::Pty,
25015 terminal_size: gate4agent_types::TerminalSize {
25016 rows: 30,
25017 columns: 120,
25018 },
25019 prompt: None,
25020 bundle_id: None,
25021 context_id: None,
25022 environment_profile_id: None,
25023 },
25024 ])
25025 .unwrap();
25026 let mut config = NodeServerConfig::new(
25027 &endpoint,
25028 "fixture-token",
25029 node_id.clone(),
25030 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
25031 )
25032 .unwrap()
25033 .with_spawn_profiles(spawn_profiles)
25034 .with_session_environment_materialization(
25035 materialization_root,
25036 Arc::new(FixtureSecretResolver {
25037 deny: Arc::new(AtomicBool::new(false)),
25038 }),
25039 )
25040 .unwrap();
25041 config.fixture_raw_pty_runtime = true;
25042 let server = NodeServer::new_context_only_proof_fixture(
25043 config,
25044 agent("kimi"),
25045 proof_path.clone(),
25046 )
25047 .unwrap();
25048 let shared = Arc::clone(&server.shared);
25049 let shutdown = server.shutdown_handle();
25050
25051 let history = HistorySessionRecord {
25052 session_id: "context-only-source-history".to_owned(),
25053 title: None,
25054 cwd: None,
25055 model: None,
25056 message_count: 2,
25057 completed_turn_count: None,
25058 total_tokens: 0,
25059 messages: vec![
25060 gate4agent_types::HistoryMessageRecord {
25061 role: gate4agent_types::HistoryMessageRole::User,
25062 text: "continue the bounded parent run through native C2".to_owned(),
25063 },
25064 gate4agent_types::HistoryMessageRecord {
25065 role: gate4agent_types::HistoryMessageRole::Assistant,
25066 text: "the exact parent context is ready for the child run".to_owned(),
25067 },
25068 ],
25069 };
25070 let pack = NodeContextPack::export(
25071 ContextPackLineageReceipt {
25072 source_node_id: node_id.clone(),
25073 source_session: SessionAddress {
25074 workspace_id: workspace_id.clone(),
25075 session: SessionKey {
25076 instance_id: AgentInstanceId(71),
25077 generation: SessionGeneration(1),
25078 },
25079 },
25080 source_provider: agent("codex"),
25081 },
25082 &history,
25083 )
25084 .unwrap();
25085 let expected_context_hash = digest(&SHA256, pack.bytes())
25086 .as_ref()
25087 .iter()
25088 .map(|byte| format!("{byte:02x}"))
25089 .collect::<String>();
25090 let context = shared
25091 .context_catalog
25092 .write()
25093 .unwrap_or_else(|poisoned| poisoned.into_inner())
25094 .insert(pack)
25095 .unwrap();
25096 let spec = SpawnSpec {
25097 target: crate::protocol::SpawnTarget {
25098 node_id,
25099 workspace_id: workspace_id.clone(),
25100 worktree_id: None,
25101 },
25102 profile_id,
25103 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
25104 "target-kimi-r1",
25105 ).unwrap(),
25106 overrides: crate::protocol::SpawnOverrides {
25107 context_id: crate::protocol::SpawnOverride::Set {
25108 value: context.id.clone(),
25109 },
25110 ..crate::protocol::SpawnOverrides::default()
25111 },
25112 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
25113 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
25114 required_capabilities: SpawnRequiredCapabilities::default(),
25115 };
25116 let resolved = match shared.resolve_spawn_spec(&spec) {
25117 Ok(resolved) => resolved,
25118 Err(error) => panic!(
25119 "Kimi context-only resolve rejected: exact_code={:?} message={}",
25120 error.code,
25121 error.message,
25122 ),
25123 };
25124 let resolved_context = match shared.resolve_context(&resolved) {
25125 Ok(Some(resolved_context)) => resolved_context,
25126 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
25127 Err(error) => panic!(
25128 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
25129 error.code,
25130 error.message,
25131 ),
25132 };
25133 assert_eq!(resolved_context, context);
25134 let runtime_policy = match shared
25135 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
25136 .await
25137 {
25138 Ok(runtime_policy) => runtime_policy,
25139 Err(error) => panic!(
25140 "Kimi RawPty admission rejected: exact_code={:?} message={}",
25141 error.code,
25142 error.message,
25143 ),
25144 };
25145 assert!(runtime_policy.raw_pty_lifecycle);
25146
25147 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
25148 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
25149 agent("kimi"),
25150 TransportKind::Pty,
25151 vec![EnvMutation {
25152 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
25153 value: Some(OsString::from("must-remain-rejected")),
25154 }],
25155 )
25156 .unwrap();
25157 assert_eq!(
25158 launch_control
25159 .install_native_launch_environment_overlay(
25160 AgentInstanceId(72),
25161 generic_overlay,
25162 )
25163 .unwrap_err(),
25164 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
25165 );
25166
25167 let diagnostic_address = SessionAddress {
25168 workspace_id: workspace_id.clone(),
25169 session: SessionKey {
25170 instance_id: AgentInstanceId(73),
25171 generation: SessionGeneration(1),
25172 },
25173 };
25174 let (diagnostic_overlay, diagnostic_materialization) = match shared
25175 .prepare_session_materialization(
25176 &diagnostic_address,
25177 &agent("kimi"),
25178 SessionMode::Pty,
25179 None,
25180 None,
25181 Some(&resolved_context),
25182 None,
25183 )
25184 {
25185 Ok(Some(prepared)) => prepared,
25186 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
25187 Err(error) => panic!(
25188 "Kimi context-only materialization rejected: exact_code={:?} message={}",
25189 error.code,
25190 error.message,
25191 ),
25192 };
25193 let diagnostic_overlay = diagnostic_overlay
25194 .expect("Kimi context-only materialization omitted its environment overlay");
25195 assert!(matches!(
25196 &diagnostic_overlay,
25197 PreparedNativeLaunchOverlay::Environment(_),
25198 ));
25199 match shared.install_prepared_launch_overlay(
25200 diagnostic_address.session.instance_id,
25201 diagnostic_overlay,
25202 ) {
25203 Ok(None) => {}
25204 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
25205 Err(error) => panic!(
25206 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
25207 error.code,
25208 error.message,
25209 ),
25210 }
25211 assert!(launch_control.clear_native_instance_launch_overlay(
25212 diagnostic_address.session.instance_id,
25213 ));
25214 drop(diagnostic_materialization);
25215 assert!(shared
25216 .materializations
25217 .lock()
25218 .unwrap_or_else(|poisoned| poisoned.into_inner())
25219 .is_empty());
25220
25221 let node_task = tokio::spawn(server.run());
25222 let ready_client = timeout(Duration::from_secs(5), async {
25223 loop {
25224 match gate4agent_node_wire::LocalNodeClient::connect(
25225 &endpoint,
25226 &shared.node_id,
25227 ClientRole::Observer,
25228 "fixture-token",
25229 )
25230 .await
25231 {
25232 Ok(client) => break client,
25233 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
25234 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
25235 }
25236 }
25237 })
25238 .await
25239 .expect("Kimi context-only Node did not become ready");
25240 assert_eq!(
25241 ready_client
25242 .hello()
25243 .snapshot
25244 .provider_runtime_statuses
25245 .iter()
25246 .find(|status| status.provider().as_str() == "kimi")
25247 .expect("Kimi runtime status is missing")
25248 .mode(),
25249 crate::protocol::ProviderRuntimeMode::RawPassthrough,
25250 );
25251 drop(ready_client);
25252
25253 let receipt = match shared.spawn_from_spec(spec).await {
25254 Ok(receipt) => receipt,
25255 Err(error) => panic!(
25256 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25257 error.code,
25258 error.message,
25259 ),
25260 };
25261 assert_eq!(receipt.provider, agent("kimi"));
25262 assert_eq!(receipt.context.as_ref(), Some(&context));
25263 assert!(receipt.context_binding_is_valid());
25264 timeout(Duration::from_secs(5), async {
25265 while !proof_path.is_file() {
25266 sleep(Duration::from_millis(10)).await;
25267 }
25268 })
25269 .await
25270 .expect("Kimi context-only fixture did not write its proof");
25271
25272 let proof = std::fs::read_to_string(&proof_path).unwrap();
25273 let lines = proof.lines().collect::<Vec<_>>();
25274 assert_eq!(lines.len(), 7);
25275 assert_eq!(lines[0], "context-only");
25276 assert!(Path::new(lines[1]).is_absolute());
25277 assert_ne!(
25278 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25279 std::fs::canonicalize(&workspace_root).unwrap(),
25280 );
25281 assert_eq!(
25282 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25283 std::fs::canonicalize(&workspace_root).unwrap(),
25284 );
25285 assert_eq!(lines[3], expected_context_hash);
25286 assert_eq!(lines[4], "g4a-context-pack-v1");
25287 assert_eq!(lines[5], "codex");
25288 assert_eq!(lines[6], "2");
25289 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25290 session.instance_id == receipt.session.session.instance_id
25291 && session.agent_id.as_str() == "kimi"
25292 }));
25293
25294 shutdown.request_shutdown().await.unwrap();
25295 timeout(Duration::from_secs(5), node_task)
25296 .await
25297 .unwrap()
25298 .unwrap()
25299 .unwrap();
25300 drop(shutdown);
25301 drop(shared);
25302 std::fs::remove_dir_all(root).unwrap();
25303 }
25304
25305 #[cfg(feature = "fixture")]
25306 #[test]
25307 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25308 let root = temporary_workspace_root("clean-exit-constructor");
25309 std::fs::create_dir_all(&root).unwrap();
25310 let config = construction_test_config("clean-exit-constructor")
25311 .with_state_path(root.join("node-state.json"))
25312 .unwrap();
25313 let server = NodeServer::new_clean_exit_fixture(
25314 config,
25315 root.clone(),
25316 root.join("started.marker"),
25317 root.join("release.signal"),
25318 )
25319 .unwrap();
25320 assert_eq!(
25321 server
25322 .shared
25323 .enabled_providers
25324 .iter()
25325 .map(AgentId::as_str)
25326 .collect::<Vec<_>>(),
25327 ["claude"],
25328 );
25329 drop(server);
25330 std::fs::remove_dir_all(root).unwrap();
25331 }
25332
25333 #[test]
25334 fn production_registry_provider_contract_manifest_is_exact() {
25335 let registry = active_registry().unwrap();
25336 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25337 assert_eq!(
25338 providers
25339 .iter()
25340 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25341 .collect::<Vec<_>>(),
25342 vec![
25343 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25344 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25345 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25346 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25347 ]
25348 );
25349 assert_eq!(
25350 adapters
25351 .iter()
25352 .map(|contract| (
25353 contract.provider.as_str(),
25354 contract.family,
25355 contract.adapter_id.as_str(),
25356 contract.revision.as_str(),
25357 ))
25358 .collect::<Vec<_>>(),
25359 vec![
25360 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25361 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25362 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25363 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25364 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25365 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25366 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25367 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25368 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25369 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25370 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25371 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25372 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25373 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25374 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25375 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25376 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25377 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25378 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25379 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25380 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25381 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25382 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25383 ]
25384 );
25385 }
25386
25387 fn construction_test_config(name: &str) -> NodeServerConfig {
25388 let workspace = WorkspaceConfig::new(
25389 WorkspaceId::new("primary").unwrap(),
25390 std::env::current_dir().unwrap(),
25391 )
25392 .unwrap();
25393 NodeServerConfig::new(
25394 format!(r"\\.\pipe\gate4agent-{name}"),
25395 "fixture-token",
25396 NodeId::new(name).unwrap(),
25397 [workspace],
25398 )
25399 .unwrap()
25400 }
25401
25402 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25403 let adapter = AdapterBinding::new(
25404 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25405 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25406 gate4agent_types::AdapterVerification::SyntheticFixture,
25407 )
25408 .unwrap();
25409 let agent_ids = ["claude", "codex", "kimi"]
25410 .into_iter()
25411 .map(|agent_id| AgentId::new(agent_id).unwrap())
25412 .collect::<Vec<_>>();
25413 let adapters = gate4agent_catalog::AdapterRegistry::new(
25414 [
25415 AdapterFamily::PtySemantic,
25416 AdapterFamily::Pipe,
25417 AdapterFamily::OneShot,
25418 AdapterFamily::Acp,
25419 AdapterFamily::Hook,
25420 AdapterFamily::ManagedHook,
25421 AdapterFamily::History,
25422 AdapterFamily::Resume,
25423 AdapterFamily::SessionOptions,
25424 AdapterFamily::CapabilityProbe,
25425 ]
25426 .into_iter()
25427 .map(|family| gate4agent_catalog::AdapterDescriptor {
25428 family,
25429 binding: adapter.clone(),
25430 agents: agent_ids.clone(),
25431 }),
25432 )
25433 .unwrap();
25434 let specs = agent_ids
25435 .iter()
25436 .map(|agent_id| {
25437 let agent_id = agent_id.as_str();
25438 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25439 spec.revision =
25440 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25441 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25442 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25443 adapter: adapter.clone(),
25444 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25445 launch_override: None,
25446 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25447 });
25448 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25449 adapter: adapter.clone(),
25450 launch_override: None,
25451 });
25452 spec.capabilities.adapters.hook = Some(adapter.clone());
25453 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25454 spec.capabilities.adapters.one_shot = Some(adapter.clone());
25455 spec.capabilities.adapters.history = Some(adapter.clone());
25456 spec.capabilities.adapters.resume = Some(adapter.clone());
25457 spec.capabilities.adapters.session_options = Some(adapter.clone());
25458 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25459 spec
25460 })
25461 .collect::<Vec<_>>();
25462 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25463 }
25464
25465 #[test]
25473 fn node_server_construction_accepts_the_production_manifest() {
25474 let registry = active_registry().unwrap();
25475 let (providers, _) = provider_contract_manifest(®istry).unwrap();
25476 assert_eq!(providers.len(), registry.iter().count());
25477 assert!(!providers.is_empty());
25478 let server = NodeServer::new_with_registry(
25479 construction_test_config("manifest-production-bound"),
25480 registry,
25481 )
25482 .unwrap();
25483 drop(server);
25484 }
25485
25486 #[test]
25487 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25488 let registry = maximum_size_provider_manifest_registry();
25489 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25490 assert_eq!(providers.len(), 3);
25491 assert_eq!(adapters.len(), 30);
25492 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25493 let error = match NodeServer::new_with_registry(
25494 construction_test_config("manifest-handshake-overflow"),
25495 registry,
25496 ) {
25497 Ok(_) => panic!("oversized negotiated manifest was accepted"),
25498 Err(error) => error,
25499 };
25500 assert!(matches!(
25501 error,
25502 NodeServerError::ProviderContractManifest(message)
25503 if message.contains("authentication binding length")
25504 && message.contains("8192-byte limit")
25505 ));
25506 }
25507
25508 #[test]
25509 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25510 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25511 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25512 let registry = AgentRegistry::new([spec]).unwrap();
25513 assert!(matches!(
25514 provider_contract_manifest(®istry),
25515 Err(NodeServerError::ProviderContractManifest(message))
25516 if message.contains("exceeds the 128-byte limit")
25517 ));
25518 }
25519
25520 #[test]
25521 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25522 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25523 spec.revision = "invalid revision".to_owned();
25524 let registry = AgentRegistry::new([spec]).unwrap();
25525 assert!(matches!(
25526 provider_contract_manifest(®istry),
25527 Err(NodeServerError::ProviderContractManifest(message))
25528 if message.contains("bounded lowercase ASCII")
25529 ));
25530 }
25531
25532 #[test]
25533 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25534 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25535 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25536 let conflicting_binding = builtin_registry()
25537 .get_by_id("codex")
25538 .unwrap()
25539 .capabilities
25540 .adapters
25541 .one_shot
25542 .clone()
25543 .unwrap();
25544 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25545 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25546 pipe.adapter = transport_binding;
25547 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25548 let registry = AgentRegistry::new([spec]).unwrap();
25549 assert!(matches!(
25550 provider_contract_manifest(®istry),
25551 Err(NodeServerError::ProviderContractManifest(message))
25552 if message.contains("conflicting OneShot adapter bindings")
25553 ));
25554 }
25555
25556 #[test]
25557 fn public_node_failures_expose_only_fixed_categories() {
25558 let provider = failure(
25559 NodeFailureCode::BackendOperationFailed,
25560 r"provider bearer-secret failed at C:\private\session.jsonl",
25561 );
25562 assert_eq!(provider.message, "backend-operation-failed");
25563 assert!(!provider.message.contains("bearer-secret"));
25564 assert!(!provider.message.contains(r"C:\private"));
25565 let persistence = persistence_failure(io::Error::new(
25566 io::ErrorKind::Other,
25567 r"state write failed at C:\private\state-v1.json",
25568 ));
25569 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25570 assert!(!persistence.message.contains(r"C:\private"));
25571
25572 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25573 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25574 assert_eq!(
25575 generic.to_string(),
25576 "node durable state failed: durable-state-load-failed",
25577 );
25578 }
25579 }
25580
25581 #[test]
25582 fn terminal_byte_sequences_are_bounded_before_dispatch() {
25583 let sequence = b"\x1b[1;5D".to_vec();
25584 assert_eq!(
25585 terminal_bytes_action(sequence.clone()).unwrap(),
25586 InputAction::TerminalBytes(sequence),
25587 );
25588 assert_eq!(
25589 terminal_bytes_action(Vec::new()).unwrap_err().code,
25590 NodeFailureCode::InvalidRequest,
25591 );
25592 assert_eq!(
25593 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25594 .unwrap_err()
25595 .code,
25596 NodeFailureCode::InvalidRequest,
25597 );
25598 }
25599
25600 #[test]
25601 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25602 let root = temporary_workspace_root("exclusive-state-owner");
25603 std::fs::create_dir_all(&root).unwrap();
25604 let workspace = WorkspaceConfig::new(
25605 WorkspaceId::new("primary").unwrap(),
25606 &root,
25607 )
25608 .unwrap();
25609 let state_path = root.join("state-v1.json");
25610 let endpoint = format!(
25611 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25612 std::process::id(),
25613 std::time::SystemTime::now()
25614 .duration_since(std::time::UNIX_EPOCH)
25615 .unwrap()
25616 .as_nanos(),
25617 );
25618 let config = NodeServerConfig::new(
25619 endpoint,
25620 "fixture-token",
25621 NodeId::new("node-1").unwrap(),
25622 [workspace],
25623 )
25624 .unwrap()
25625 .with_state_path(&state_path)
25626 .unwrap();
25627
25628 let first = NodeServer::new(config.clone()).unwrap();
25629 let error = match NodeServer::new(config.clone()) {
25630 Ok(_) => panic!("a second node server acquired the same durable state path"),
25631 Err(error) => error,
25632 };
25633 assert!(matches!(
25634 &error,
25635 NodeServerError::DurableState(error)
25636 if error.kind() == io::ErrorKind::WouldBlock
25637 ));
25638 assert_eq!(
25639 error.to_string(),
25640 "node durable state failed: durable-state-lock-failed",
25641 );
25642 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25643 drop(first);
25644 let second = NodeServer::new(config).unwrap();
25645 drop(second);
25646 std::fs::remove_dir_all(root).unwrap();
25647 }
25648
25649 #[test]
25650 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25651 let catalog = active_registry().unwrap();
25652 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25653 let workspace_id = WorkspaceId::new("primary").unwrap();
25654 let workspace = WorkspaceConfig::new(
25655 workspace_id.clone(),
25656 std::env::current_dir().unwrap(),
25657 )
25658 .unwrap();
25659 let shared = NodeShared::new(
25660 handle,
25661 "fixture-token".to_owned(),
25662 NodeId::new("node-1").unwrap(),
25663 vec![workspace.clone()],
25664 vec![agent("claude"), agent("codex"), agent("kimi")],
25665 );
25666 let instance_id = AgentInstanceId(17);
25667 let generation = SessionGeneration(3);
25668 assert!(shared.address_for(instance_id, generation).is_none());
25669 shared.bind_session(instance_id, workspace_id.clone(), generation);
25670 let address = SessionAddress {
25671 workspace_id,
25672 session: SessionKey { instance_id, generation },
25673 };
25674 assert_eq!(
25675 shared.workspace_root(&address.workspace_id).unwrap(),
25676 workspace.canonical_root,
25677 );
25678 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25679 shared.remove_binding(&address);
25680 assert!(shared.address_for(instance_id, generation).is_none());
25681 }
25682
25683 #[test]
25684 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25685 let address = SessionAddress {
25686 workspace_id: WorkspaceId::new("primary").unwrap(),
25687 session: SessionKey {
25688 instance_id: AgentInstanceId(7),
25689 generation: SessionGeneration(2),
25690 },
25691 };
25692 let record_id = SessionRecordId::new("record-a").unwrap();
25693 let size = gate4agent_types::TerminalSize {
25694 rows: 24,
25695 columns: 80,
25696 };
25697 let address_requests = [
25698 NodeRequest::Resume {
25699 session: address.clone(),
25700 terminal_size: size,
25701 initial_prompt: None,
25702 },
25703 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25704 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25705 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25706 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25707 NodeRequest::TerminalControl {
25708 session: address.clone(),
25709 control: TerminalControl::Enter,
25710 },
25711 NodeRequest::Resize { session: address.clone(), size },
25712 NodeRequest::Interrupt { session: address.clone() },
25713 NodeRequest::Stop { session: address.clone(), force: false },
25714 NodeRequest::Remove { session: address },
25715 ];
25716 for request in &address_requests {
25717 assert!(request_requires_open_provider_ids_with(
25718 request,
25719 |_| Some(agent("third-party-agent")),
25720 |_| Some(agent("claude")),
25721 ));
25722 assert!(!request_requires_open_provider_ids_with(
25723 request,
25724 |_| Some(agent("claude")),
25725 |_| Some(agent("claude")),
25726 ));
25727 assert!(request_requires_open_provider_ids_with(
25728 request,
25729 |_| None,
25730 |_| Some(agent("claude")),
25731 ));
25732 }
25733
25734 let record_requests = [
25735 NodeRequest::RenameSessionRecord {
25736 record_id: record_id.clone(),
25737 display_name: "renamed".to_owned(),
25738 },
25739 NodeRequest::ResumeSessionRecord {
25740 record_id: record_id.clone(),
25741 terminal_size: size,
25742 initial_prompt: None,
25743 },
25744 NodeRequest::ForgetSessionRecord { record_id },
25745 ];
25746 for request in &record_requests {
25747 assert!(request_requires_open_provider_ids_with(
25748 request,
25749 |_| Some(agent("claude")),
25750 |_| Some(agent("grok")),
25751 ));
25752 assert!(!request_requires_open_provider_ids_with(
25753 request,
25754 |_| Some(agent("claude")),
25755 |_| Some(agent("kimi")),
25756 ));
25757 assert!(request_requires_open_provider_ids_with(
25758 request,
25759 |_| Some(agent("claude")),
25760 |_| None,
25761 ));
25762 }
25763
25764 assert!(request_requires_open_provider_ids_with(
25765 &NodeRequest::Spawn {
25766 workspace_id: WorkspaceId::new("primary").unwrap(),
25767 provider: agent("third-party-agent"),
25768 mode: SessionMode::Pty,
25769 terminal_size: size,
25770 initial_prompt: None,
25771 },
25772 |_| None,
25773 |_| None,
25774 ));
25775 assert!(!request_requires_open_provider_ids_with(
25776 &NodeRequest::Snapshot,
25777 |_| None,
25778 |_| None,
25779 ));
25780 assert!(request_requires_open_provider_ids_with(
25781 &NodeRequest::ForgetContextPack {
25782 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25783 },
25784 |_| None,
25785 |_| None,
25786 ));
25787 }
25788
25789 #[test]
25790 fn legacy_projection_filters_open_record_replies_and_event_history() {
25791 let catalog = active_registry().unwrap();
25792 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25793 let workspace = WorkspaceConfig::new(
25794 WorkspaceId::new("primary").unwrap(),
25795 std::env::current_dir().unwrap(),
25796 )
25797 .unwrap();
25798 let shared = NodeShared::new(
25799 handle,
25800 "fixture-token".to_owned(),
25801 NodeId::new("node-1").unwrap(),
25802 vec![workspace],
25803 vec![agent("claude"), agent("grok")],
25804 );
25805 let legacy = record("claude", "legacy-record");
25806 let open = record("grok", "open-record");
25807 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25808 record: legacy.clone(),
25809 });
25810 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25811 record: open.clone(),
25812 });
25813 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25814 record_id: legacy.record_id.clone(),
25815 });
25816 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25817 record_id: open.record_id.clone(),
25818 });
25819 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25820 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25821 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25822 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25823
25824 let mut record_reply = ResponseEnvelope {
25825 request_id: 1,
25826 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25827 };
25828 project_response_legacy_provider_ids(&shared, &mut record_reply);
25829 assert!(matches!(
25830 record_reply.result,
25831 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25832 ));
25833
25834 let mut resync = ResponseEnvelope {
25835 request_id: 2,
25836 result: Ok(NodeResponse::Resync {
25837 event_sequence: open_removed.sequence,
25838 oldest_available_sequence: 1,
25839 snapshot: shared.snapshot(),
25840 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25841 }),
25842 };
25843 project_response_legacy_provider_ids(&shared, &mut resync);
25844 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25845 panic!("legacy projection changed the resync response kind");
25846 };
25847 assert!(snapshot.session_records.iter().all(|item| {
25848 provider_id_is_legacy(&item.provider)
25849 }));
25850 assert_eq!(events.len(), 2);
25851 assert!(events.iter().all(|event| matches!(
25852 &event.event,
25853 NodeEvent::SessionRecordUpserted { record }
25854 if provider_id_is_legacy(&record.provider)
25855 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25856 }
25857
25858 #[test]
25859 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25860 let catalog = active_registry().unwrap();
25861 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25862 let workspace_id = WorkspaceId::new("primary").unwrap();
25863 let workspace = WorkspaceConfig::new(
25864 workspace_id.clone(),
25865 std::env::current_dir().unwrap(),
25866 )
25867 .unwrap();
25868 let shared = NodeShared::new(
25869 handle,
25870 "fixture-token".to_owned(),
25871 NodeId::new("node-1").unwrap(),
25872 vec![workspace],
25873 vec![agent("claude")],
25874 );
25875 for index in 0..CONTROL_SESSIONS_MAX {
25876 shared.bind_session(
25877 AgentInstanceId((index + 1) as u64),
25878 workspace_id.clone(),
25879 SessionGeneration(1),
25880 );
25881 }
25882 let error = shared.ensure_binding_capacity().unwrap_err();
25883 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25884 }
25885
25886 #[tokio::test]
25887 async fn provider_spawn_admission_rejects_before_session_mutation() {
25888 let catalog = active_registry().unwrap();
25889 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25890 let workspace = WorkspaceConfig::new(
25891 WorkspaceId::new("primary").unwrap(),
25892 std::env::current_dir().unwrap(),
25893 )
25894 .unwrap();
25895 let mut shared = NodeShared::new(
25896 handle,
25897 "fixture-token".to_owned(),
25898 NodeId::new("node-provider-admission").unwrap(),
25899 vec![workspace],
25900 vec![agent("claude")],
25901 );
25902 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25903 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25904 ])
25905 .unwrap();
25906 let terminal_size = gate4agent_types::TerminalSize {
25907 rows: 24,
25908 columns: 80,
25909 };
25910
25911 let unavailable = shared
25912 .spawn_session(
25913 WorkspaceId::new("primary").unwrap(),
25914 agent("claude"),
25915 SessionMode::Pty,
25916 terminal_size,
25917 None,
25918 )
25919 .await
25920 .unwrap_err();
25921 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25922 assert_eq!(unavailable.message, "backend-operation-failed");
25923
25924 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25925 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25926 agent("claude"),
25927 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25928 ),
25929 ])
25930 .unwrap();
25931 let semantic = shared
25932 .spawn_session(
25933 WorkspaceId::new("primary").unwrap(),
25934 agent("claude"),
25935 SessionMode::Inline,
25936 terminal_size,
25937 None,
25938 )
25939 .await
25940 .unwrap_err();
25941 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25942 assert_eq!(semantic.message, "unsupported-capability");
25943
25944 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25945 assert!(shared
25946 .session_bindings
25947 .lock()
25948 .unwrap_or_else(|poisoned| poisoned.into_inner())
25949 .is_empty());
25950 assert!(shared
25951 .session_records
25952 .lock()
25953 .unwrap_or_else(|poisoned| poisoned.into_inner())
25954 .records
25955 .is_empty());
25956 assert!(shared.handle.snapshot().sessions.is_empty());
25957 }
25958
25959 #[tokio::test]
25960 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25961 let catalog = active_registry().unwrap();
25962 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25963 let workspace_id = WorkspaceId::new("primary").unwrap();
25964 let workspace = WorkspaceConfig::new(
25965 workspace_id.clone(),
25966 std::env::current_dir().unwrap(),
25967 )
25968 .unwrap();
25969 let shared = NodeShared::new(
25970 handle,
25971 "fixture-token".to_owned(),
25972 NodeId::new("node-raw-spawn-record").unwrap(),
25973 vec![workspace],
25974 vec![agent("claude")],
25975 );
25976 let address = SessionAddress {
25977 workspace_id,
25978 session: SessionKey {
25979 instance_id: AgentInstanceId(71),
25980 generation: SessionGeneration::default(),
25981 },
25982 };
25983
25984 let record_id = shared
25985 .bind_spawn_session(
25986 &address,
25987 agent("claude"),
25988 SessionMode::Pty,
25989 ProviderRuntimePolicy::raw_pty(),
25990 SpawnRecordPolicy::Always,
25991 None,
25992 )
25993 .unwrap()
25994 .unwrap();
25995 shared
25996 .handle
25997 .dispatch(shared.prepare_command(ControlCommand::Register {
25998 instance_id: address.session.instance_id,
25999 agent_id: agent("claude"),
26000 transport: TransportKind::Pty,
26001 }))
26002 .unwrap();
26003 runtime.tick().await;
26004
26005 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
26006 session.instance_id == address.session.instance_id
26007 && session.generation == address.session.generation
26008 }));
26009 let record = shared.record(&record_id).unwrap();
26010 assert_eq!(record.display_name, "claude #1");
26011 assert_eq!(record.state, ManagedSessionState::Live);
26012 assert_eq!(record.active_session.as_ref(), Some(&address));
26013 assert!(record.provider_session.is_none());
26014 let binding_record_id = shared
26015 .session_bindings
26016 .lock()
26017 .unwrap_or_else(|poisoned| poisoned.into_inner())
26018 .get(&address.session.instance_id)
26019 .and_then(|binding| binding.record_id.clone());
26020 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
26021
26022 let renamed = shared
26023 .rename_session_record(&record_id, "raw review".to_owned())
26024 .unwrap();
26025 assert_eq!(renamed.record_id, record_id);
26026 assert_eq!(renamed.display_name, "raw review");
26027 shared.reconcile_managed_record(
26028 &record_id,
26029 &address,
26030 &ControlEvent {
26031 sequence: 1,
26032 command_id: None,
26033 instance_id: address.session.instance_id,
26034 generation: address.session.generation,
26035 event: ControlEventKind::Running { process_id: None },
26036 },
26037 false,
26038 false,
26039 );
26040 let running = shared.record(&record_id).unwrap();
26041 assert_eq!(running.state, ManagedSessionState::Live);
26042 assert_eq!(running.active_session.as_ref(), Some(&address));
26043 assert_eq!(running.display_name, "raw review");
26044
26045 shared.reconcile_managed_record(
26046 &record_id,
26047 &address,
26048 &ControlEvent {
26049 sequence: 2,
26050 command_id: None,
26051 instance_id: address.session.instance_id,
26052 generation: address.session.generation,
26053 event: ControlEventKind::Exited {
26054 exit_code: Some(0),
26055 forced: false,
26056 },
26057 },
26058 false,
26059 false,
26060 );
26061 let detached = shared.record(&record_id).unwrap();
26062 assert_eq!(detached.state, ManagedSessionState::Unavailable);
26063 assert!(detached.active_session.is_none());
26064 assert_eq!(detached.display_name, "raw review");
26065 let renamed_detached = shared
26066 .rename_session_record(&record_id, "raw review detached".to_owned())
26067 .unwrap();
26068 assert_eq!(renamed_detached.record_id, record_id);
26069 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
26070 assert!(renamed_detached.active_session.is_none());
26071 assert_eq!(renamed_detached.display_name, "raw review detached");
26072 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
26073 }
26074
26075 #[test]
26076 fn legacy_raw_spawn_binding_remains_untracked() {
26077 let catalog = active_registry().unwrap();
26078 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26079 let workspace_id = WorkspaceId::new("primary").unwrap();
26080 let workspace = WorkspaceConfig::new(
26081 workspace_id.clone(),
26082 std::env::current_dir().unwrap(),
26083 )
26084 .unwrap();
26085 let shared = NodeShared::new(
26086 handle,
26087 "fixture-token".to_owned(),
26088 NodeId::new("node-legacy-raw-spawn").unwrap(),
26089 vec![workspace],
26090 vec![agent("claude")],
26091 );
26092 let address = SessionAddress {
26093 workspace_id,
26094 session: SessionKey {
26095 instance_id: AgentInstanceId(72),
26096 generation: SessionGeneration::default(),
26097 },
26098 };
26099
26100 let record_id = shared
26101 .bind_spawn_session(
26102 &address,
26103 agent("claude"),
26104 SessionMode::Pty,
26105 ProviderRuntimePolicy::raw_pty(),
26106 SpawnRecordPolicy::ProviderIdentityOnly,
26107 None,
26108 )
26109 .unwrap();
26110
26111 assert!(record_id.is_none());
26112 let binding_record_id = shared
26113 .session_bindings
26114 .lock()
26115 .unwrap_or_else(|poisoned| poisoned.into_inner())
26116 .get(&address.session.instance_id)
26117 .and_then(|binding| binding.record_id.clone());
26118 assert!(binding_record_id.is_none());
26119 assert!(shared.snapshot().session_records.is_empty());
26120 }
26121
26122 #[tokio::test]
26123 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
26124 let catalog = active_registry().unwrap();
26125 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26126 let workspace_id = WorkspaceId::new("primary").unwrap();
26127 let workspace = WorkspaceConfig::new(
26128 workspace_id.clone(),
26129 std::env::current_dir().unwrap(),
26130 )
26131 .unwrap();
26132 let mut shared = NodeShared::new(
26133 handle,
26134 "fixture-token".to_owned(),
26135 NodeId::new("node-raw-semantic-gate").unwrap(),
26136 vec![workspace],
26137 vec![agent("claude")],
26138 );
26139 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
26140 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
26141 ])
26142 .unwrap();
26143 shared
26144 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26145 .unwrap();
26146 let address = SessionAddress {
26147 workspace_id,
26148 session: SessionKey {
26149 instance_id: AgentInstanceId(72),
26150 generation: SessionGeneration::default(),
26151 },
26152 };
26153 shared.bind_session_with_policy(
26154 &address,
26155 ProviderRuntimePolicy::raw_pty(),
26156 None,
26157 );
26158 shared
26159 .handle
26160 .dispatch(shared.prepare_command(ControlCommand::Register {
26161 instance_id: address.session.instance_id,
26162 agent_id: agent("claude"),
26163 transport: TransportKind::Pty,
26164 }))
26165 .unwrap();
26166 runtime.tick().await;
26167 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
26168 let revision_before = shared.handle.snapshot().revision;
26169 let history_sequence_before = shared.current_sequence();
26170 let terminal_size = gate4agent_types::TerminalSize {
26171 rows: 24,
26172 columns: 80,
26173 };
26174
26175 for request in [
26176 NodeRequest::Prompt {
26177 session: address.clone(),
26178 text: "prompt".to_owned(),
26179 },
26180 NodeRequest::Paste {
26181 session: address.clone(),
26182 text: "paste".to_owned(),
26183 },
26184 NodeRequest::Resume {
26190 session: address.clone(),
26191 terminal_size,
26192 initial_prompt: Some("resume".to_owned()),
26193 },
26194 ] {
26195 let error = process_request_inner(
26196 &shared,
26197 77,
26198 ClientRole::Operator,
26199 request,
26200 )
26201 .await
26202 .unwrap_err();
26203 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
26204 }
26205
26206 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
26207 assert_eq!(shared.handle.snapshot().revision, revision_before);
26208 assert_eq!(shared.current_sequence(), history_sequence_before);
26209 let bindings = shared
26210 .session_bindings
26211 .lock()
26212 .unwrap_or_else(|poisoned| poisoned.into_inner());
26213 let binding = bindings.get(&address.session.instance_id).unwrap();
26214 assert_eq!(binding.generation, address.session.generation);
26215 assert!(binding.pending_resume.is_none());
26216 assert!(binding.record_id.is_none());
26217 }
26218
26219 #[tokio::test]
26220 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
26221 let catalog = active_registry().unwrap();
26222 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26223 let primary = WorkspaceConfig::new(
26224 WorkspaceId::new("primary").unwrap(),
26225 std::env::current_dir().unwrap(),
26226 )
26227 .unwrap();
26228 let shared = NodeShared::new(
26229 handle,
26230 "fixture-token".to_owned(),
26231 NodeId::new("node-1").unwrap(),
26232 vec![primary],
26233 vec![agent("claude")],
26234 );
26235 shared
26236 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26237 .unwrap();
26238 let before = shared.snapshot();
26239 let error = process_request_inner(
26240 &shared,
26241 77,
26242 ClientRole::Operator,
26243 NodeRequest::RegisterWorkspace {
26244 workspace_id: WorkspaceId::new("foreign").unwrap(),
26245 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
26246 },
26247 )
26248 .await
26249 .unwrap_err();
26250 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26251 assert_eq!(error.message, "invalid-request");
26252 assert_eq!(shared.snapshot().workspaces, before.workspaces);
26253 }
26254
26255 #[tokio::test]
26256 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26257 let catalog = active_registry().unwrap();
26258 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26259 let primary_id = WorkspaceId::new("primary").unwrap();
26260 let primary = WorkspaceConfig::new(
26261 primary_id.clone(),
26262 std::env::current_dir().unwrap(),
26263 )
26264 .unwrap();
26265 let shared = NodeShared::new(
26266 handle,
26267 "fixture-token".to_owned(),
26268 NodeId::new("node-1").unwrap(),
26269 vec![primary.clone()],
26270 vec![agent("claude")],
26271 );
26272 let secondary_id = WorkspaceId::new("secondary").unwrap();
26273 let secondary = shared
26274 .register_workspace(
26275 secondary_id.clone(),
26276 std::env::temp_dir().to_string_lossy().into_owned(),
26277 )
26278 .await
26279 .unwrap();
26280 assert_eq!(secondary.workspace_id, secondary_id);
26281 assert!(secondary.sessions.is_empty());
26282 assert_eq!(shared.snapshot().workspaces.len(), 2);
26283
26284 let duplicate_id = shared
26285 .register_workspace(
26286 secondary_id.clone(),
26287 primary.canonical_root().to_owned(),
26288 )
26289 .await
26290 .unwrap_err();
26291 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26292 let duplicate_root = shared
26293 .register_workspace(
26294 WorkspaceId::new("duplicate-root").unwrap(),
26295 windows_path_text(&secondary.canonical_root).to_owned(),
26296 )
26297 .await
26298 .unwrap_err();
26299 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26300
26301 let address = SessionAddress {
26302 workspace_id: secondary_id.clone(),
26303 session: SessionKey {
26304 instance_id: AgentInstanceId(41),
26305 generation: SessionGeneration(1),
26306 },
26307 };
26308 shared.bind_session(
26309 address.session.instance_id,
26310 secondary_id.clone(),
26311 address.session.generation,
26312 );
26313 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26314 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26315 shared.remove_binding(&address);
26316 shared.unregister_workspace(&secondary_id).unwrap();
26317 assert_eq!(shared.snapshot().workspaces.len(), 1);
26318
26319 let last = shared.unregister_workspace(&primary_id).unwrap_err();
26320 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26321 let history = shared
26322 .history
26323 .lock()
26324 .unwrap_or_else(|poisoned| poisoned.into_inner());
26325 assert!(matches!(
26326 &history.events[0],
26327 NodeEventEnvelope {
26328 sequence: 1,
26329 event: NodeEvent::WorkspaceAdded { workspace },
26330 } if workspace.workspace_id == secondary_id
26331 ));
26332 assert!(matches!(
26333 &history.events[1],
26334 NodeEventEnvelope {
26335 sequence: 2,
26336 event: NodeEvent::WorkspaceRemoved { workspace_id },
26337 } if workspace_id == &secondary_id
26338 ));
26339 }
26340
26341 #[tokio::test]
26342 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26343 let secondary_root = temporary_workspace_root("provider-session-reference-index");
26344 std::fs::create_dir_all(&secondary_root).unwrap();
26345 let catalog = active_registry().unwrap();
26346 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26347 let primary_id = WorkspaceId::new("primary").unwrap();
26348 let secondary_id = WorkspaceId::new("secondary").unwrap();
26349 let shared = NodeShared::new(
26350 handle,
26351 "fixture-token".to_owned(),
26352 NodeId::new("node-session-reference-index").unwrap(),
26353 vec![
26354 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26355 .unwrap(),
26356 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26357 ],
26358 vec![agent("claude"), agent("codex")],
26359 );
26360 let identity = ProviderSessionIdentity {
26361 key: gate4agent_types::ProviderSessionKey::SessionId,
26362 id: "provider-session-42".to_owned(),
26363 transcript_path: None,
26364 };
26365 let index_request = |workspace_id: WorkspaceId,
26366 provider: AgentId,
26367 identity: ProviderSessionIdentity,
26368 display_name: &str| {
26369 NodeRequest::IndexProviderSession {
26370 workspace_id,
26371 provider,
26372 identity,
26373 display_name: display_name.to_owned(),
26374 }
26375 };
26376 let request = index_request(
26377 primary_id.clone(),
26378 agent("claude"),
26379 identity.clone(),
26380 "release shepherd",
26381 );
26382 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26383 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26384 }));
26385 let observer = process_request_inner(
26386 &shared,
26387 77,
26388 ClientRole::Observer,
26389 request.clone(),
26390 )
26391 .await
26392 .unwrap_err();
26393 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26394 let uncontrolled = process_request_inner(
26395 &shared,
26396 77,
26397 ClientRole::Operator,
26398 request.clone(),
26399 )
26400 .await
26401 .unwrap_err();
26402 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26403 shared
26404 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26405 .unwrap();
26406
26407 let indexed = process_request_inner(
26408 &shared,
26409 77,
26410 ClientRole::Operator,
26411 request.clone(),
26412 )
26413 .await
26414 .unwrap();
26415 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26416 panic!("provider session reference index returned another response");
26417 };
26418 assert_eq!(record.state, ManagedSessionState::Dormant);
26419 assert_eq!(record.mode, SessionMode::Pty);
26420 assert_eq!(record.workspace_id, primary_id);
26421 assert_eq!(record.provider_session.as_ref(), Some(&identity));
26422 assert!(record.active_session.is_none());
26423 assert!(record.environment_profile.is_none());
26424 assert!(record.bundle.is_none());
26425 assert!(record.context.is_none());
26426
26427 let repeated = process_request_inner(
26428 &shared,
26429 77,
26430 ClientRole::Operator,
26431 index_request(
26432 primary_id.clone(),
26433 agent("claude"),
26434 identity.clone(),
26435 "ignored replacement",
26436 ),
26437 )
26438 .await
26439 .unwrap();
26440 assert!(matches!(
26441 repeated,
26442 NodeResponse::ProviderSessionIndexed { record: ref existing }
26443 if existing.record_id == record.record_id
26444 && existing.display_name == "release shepherd"
26445 ));
26446 let conflicting_workspace = process_request_inner(
26447 &shared,
26448 77,
26449 ClientRole::Operator,
26450 index_request(
26451 secondary_id,
26452 agent("claude"),
26453 identity.clone(),
26454 "release shepherd",
26455 ),
26456 )
26457 .await
26458 .unwrap_err();
26459 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26460 let conflicting_provider = process_request_inner(
26461 &shared,
26462 77,
26463 ClientRole::Operator,
26464 index_request(
26465 primary_id.clone(),
26466 agent("codex"),
26467 identity.clone(),
26468 "release shepherd",
26469 ),
26470 )
26471 .await
26472 .unwrap_err();
26473 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26474 let transcript_rejected = process_request_inner(
26475 &shared,
26476 77,
26477 ClientRole::Operator,
26478 index_request(
26479 primary_id,
26480 agent("claude"),
26481 ProviderSessionIdentity {
26482 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26483 ..identity
26484 },
26485 "release shepherd",
26486 ),
26487 )
26488 .await
26489 .unwrap_err();
26490 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26491 let upserts = shared
26492 .history
26493 .lock()
26494 .unwrap_or_else(|poisoned| poisoned.into_inner())
26495 .events
26496 .iter()
26497 .filter(|event| matches!(
26498 &event.event,
26499 NodeEvent::SessionRecordUpserted { record: published }
26500 if published.record_id == record.record_id
26501 ))
26502 .count();
26503 assert_eq!(upserts, 1);
26504 std::fs::remove_dir_all(secondary_root).unwrap();
26505 }
26506
26507 #[tokio::test]
26508 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26509 let fixture_root = temporary_workspace_root("standalone-independent");
26510 let target = fixture_root.join("independent");
26511 std::fs::create_dir_all(&fixture_root).unwrap();
26512 let catalog = active_registry().unwrap();
26513 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26514 let selected = WorkspaceConfig::new(
26515 WorkspaceId::new("primary").unwrap(),
26516 std::env::current_dir().unwrap(),
26517 )
26518 .unwrap();
26519 let selected_root = selected.canonical_root().to_owned();
26520 let shared = NodeShared::new(
26521 handle,
26522 "fixture-token".to_owned(),
26523 NodeId::new("node-standalone").unwrap(),
26524 vec![selected],
26525 vec![agent("claude")],
26526 );
26527 let workspace_id = WorkspaceId::new("independent").unwrap();
26528 let request = NodeRequest::CreateStandaloneWorkspace {
26529 workspace_id: workspace_id.clone(),
26530 root: opaque_windows_path(target.to_string_lossy().into_owned()),
26531 initial_branch: Some("main".to_owned()),
26532 };
26533 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26534 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26535 }));
26536 assert!(!request_uses_unnegotiated_capability(
26537 &request,
26538 &baseline_capabilities().unwrap(),
26539 ));
26540 assert!(request_uses_unnegotiated_capability(&request, &[]));
26541
26542 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26543 .await
26544 .unwrap_err();
26545 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26546 assert!(!target.exists());
26547 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26548 .await
26549 .unwrap_err();
26550 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26551 assert!(!target.exists());
26552 shared
26553 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26554 .unwrap();
26555
26556 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26557 .await
26558 .unwrap();
26559 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26560 panic!("standalone workspace creation returned another response");
26561 };
26562 assert_eq!(workspace.workspace_id, workspace_id);
26563 assert!(target.join(".git").is_dir());
26564 let branch = run_git_bounded(
26565 target.to_str().unwrap(),
26566 &["branch", "--show-current"],
26567 GIT_OUTPUT_MAX_BYTES,
26568 )
26569 .await
26570 .unwrap();
26571 assert!(branch.success);
26572 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26573 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26574 assert_eq!(worktrees.len(), 1);
26575 let listed_root = WorkspaceConfig::new(
26576 WorkspaceId::new("listed-independent").unwrap(),
26577 &worktrees[0].path,
26578 )
26579 .unwrap();
26580 assert!(platform::roots_equal(
26581 listed_root.canonical_root(),
26582 windows_path_text(&workspace.canonical_root),
26583 ));
26584 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26585 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26586 snapshot.workspace_id == workspace_id
26587 && snapshot.canonical_root == workspace.canonical_root
26588 }));
26589 assert!(shared
26590 .history
26591 .lock()
26592 .unwrap_or_else(|poisoned| poisoned.into_inner())
26593 .events
26594 .iter()
26595 .any(|event| matches!(
26596 &event.event,
26597 NodeEvent::WorkspaceAdded { workspace }
26598 if workspace.workspace_id == workspace_id
26599 )));
26600 std::fs::remove_dir_all(fixture_root).unwrap();
26601 }
26602
26603 #[tokio::test]
26604 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26605 let fixture_root = temporary_workspace_root("standalone-compensation");
26606 let created_target = fixture_root.join("created-target");
26607 let existing_target = fixture_root.join("existing-target");
26608 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26609 let invalid_state_path = invalid_state_parent.join("state.json");
26610 std::fs::create_dir_all(&existing_target).unwrap();
26611 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26612 let catalog = active_registry().unwrap();
26613 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26614 let primary = WorkspaceConfig::new(
26615 WorkspaceId::new("primary").unwrap(),
26616 std::env::current_dir().unwrap(),
26617 )
26618 .unwrap();
26619 let mut shared = NodeShared::new(
26620 handle,
26621 "fixture-token".to_owned(),
26622 NodeId::new("node-standalone-compensation").unwrap(),
26623 vec![primary],
26624 vec![agent("claude")],
26625 );
26626 shared.state_path = Some(invalid_state_path);
26627
26628 let created_recovery = shared
26629 .create_standalone_workspace(
26630 WorkspaceId::new("compensated").unwrap(),
26631 created_target.to_string_lossy().into_owned(),
26632 Some("main".to_owned()),
26633 )
26634 .await
26635 .unwrap_err();
26636 assert_eq!(
26637 created_recovery.code,
26638 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26639 );
26640 assert!(created_target.join(".git").is_dir());
26641 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26642 workspace.workspace_id.as_str() != "compensated"
26643 }));
26644
26645 let existing_recovery = shared
26646 .create_standalone_workspace(
26647 WorkspaceId::new("restored").unwrap(),
26648 existing_target.to_string_lossy().into_owned(),
26649 None,
26650 )
26651 .await
26652 .unwrap_err();
26653 assert_eq!(
26654 existing_recovery.code,
26655 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26656 );
26657 assert!(existing_target.is_dir());
26658 assert!(existing_target.join(".git").is_dir());
26659 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26660 workspace.workspace_id.as_str() != "restored"
26661 }));
26662
26663 let registered_empty = fixture_root.join("registered-empty");
26664 std::fs::create_dir(®istered_empty).unwrap();
26665 let catalog = active_registry().unwrap();
26666 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26667 let registered = WorkspaceConfig::new(
26668 WorkspaceId::new("registered-empty").unwrap(),
26669 ®istered_empty,
26670 )
26671 .unwrap();
26672 let duplicate_shared = NodeShared::new(
26673 handle,
26674 "fixture-token".to_owned(),
26675 NodeId::new("node-standalone-duplicate-root").unwrap(),
26676 vec![registered],
26677 vec![agent("claude")],
26678 );
26679 let duplicate = duplicate_shared
26680 .create_standalone_workspace(
26681 WorkspaceId::new("duplicate-root").unwrap(),
26682 registered_empty.to_string_lossy().into_owned(),
26683 None,
26684 )
26685 .await
26686 .unwrap_err();
26687 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26688 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26689 std::fs::remove_dir_all(fixture_root).unwrap();
26690 }
26691
26692 #[test]
26693 fn managed_record_id_collision_preserves_the_existing_record() {
26694 let catalog = active_registry().unwrap();
26695 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26696 let workspace = WorkspaceConfig::new(
26697 WorkspaceId::new("primary").unwrap(),
26698 std::env::current_dir().unwrap(),
26699 )
26700 .unwrap();
26701 let shared = NodeShared::new(
26702 handle,
26703 "fixture-token".to_owned(),
26704 NodeId::new("node-1").unwrap(),
26705 vec![workspace.clone()],
26706 vec![agent("claude")],
26707 );
26708 let record_id = SessionRecordId::new("sr-collision").unwrap();
26709 let original = ManagedSessionRecord {
26710 record_id: record_id.clone(),
26711 display_name: "original".to_owned(),
26712 provider: agent("claude"),
26713 mode: SessionMode::Pty,
26714 state: ManagedSessionState::Dormant,
26715 workspace_id: workspace.workspace_id,
26716 canonical_root: opaque_windows_path(workspace.canonical_root),
26717 provider_session: None,
26718 active_session: None,
26719 environment_profile: None,
26720 bundle: None,
26721 context_id: None,
26722 context: None,
26723 exported_context: None,
26724 task_binding: None,
26725 created_at_unix_ms: 1,
26726 updated_at_unix_ms: 1,
26727 last_error: None,
26728 };
26729 shared.insert_record(original.clone()).unwrap();
26730 let mut collision = original.clone();
26731 collision.display_name = "replacement".to_owned();
26732 let error = shared.insert_record(collision).unwrap_err();
26733 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26734 assert_eq!(shared.record(&record_id).unwrap(), original);
26735 }
26736
26737 #[test]
26738 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26739 let catalog = active_registry().unwrap();
26740 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26741 let workspace = WorkspaceConfig::new(
26742 WorkspaceId::new("primary").unwrap(),
26743 std::env::current_dir().unwrap(),
26744 )
26745 .unwrap();
26746 let shared = NodeShared::new(
26747 handle,
26748 "fixture-token".to_owned(),
26749 NodeId::new("node-1").unwrap(),
26750 vec![workspace.clone()],
26751 vec![agent("claude")],
26752 );
26753 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26754 shared
26755 .insert_record(ManagedSessionRecord {
26756 record_id: record_id.clone(),
26757 display_name: "runtime error".to_owned(),
26758 provider: agent("claude"),
26759 mode: SessionMode::Pty,
26760 state: ManagedSessionState::IdentityPending,
26761 workspace_id: workspace.workspace_id,
26762 canonical_root: opaque_windows_path(workspace.canonical_root),
26763 provider_session: None,
26764 active_session: None,
26765 environment_profile: None,
26766 bundle: None,
26767 context_id: None,
26768 context: None,
26769 exported_context: None,
26770 task_binding: None,
26771 created_at_unix_ms: 1,
26772 updated_at_unix_ms: 1,
26773 last_error: None,
26774 })
26775 .unwrap();
26776
26777 shared
26778 .mark_record_error(
26779 &record_id,
26780 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26781 )
26782 .unwrap();
26783 let record = shared.record(&record_id).unwrap();
26784 assert_eq!(record.state, ManagedSessionState::Unavailable);
26785 assert_eq!(
26786 record.last_error.as_deref(),
26787 Some("provider-runtime-failed"),
26788 );
26789 let snapshot = shared.snapshot();
26790 assert_eq!(snapshot.session_records, vec![record]);
26791 }
26792
26793 #[test]
26802 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26803 let catalog = active_registry().unwrap();
26804 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26805 let workspace = WorkspaceConfig::new(
26806 WorkspaceId::new("primary").unwrap(),
26807 std::env::current_dir().unwrap(),
26808 )
26809 .unwrap();
26810 let shared = NodeShared::new(
26811 handle,
26812 "fixture-token".to_owned(),
26813 NodeId::new("node-1").unwrap(),
26814 vec![workspace],
26815 vec![agent("claude")],
26816 );
26817
26818 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26819
26820 shared.record_rejected_command(
26821 CommandId(1),
26822 "agent 'claude' does not support transport Acp".to_owned(),
26823 true,
26824 );
26825 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26827
26828 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26829 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26830 assert!(reason.unsupported_transport);
26831
26832 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26834
26835 shared.record_rejected_command(
26840 CommandId(3),
26841 "backend busy".to_owned(),
26842 false,
26843 );
26844 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26845 assert!(!unclassified.unsupported_transport);
26846 }
26847
26848 #[test]
26849 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26850 let catalog = active_registry().unwrap();
26851 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26852 let workspace = WorkspaceConfig::new(
26853 WorkspaceId::new("primary").unwrap(),
26854 std::env::current_dir().unwrap(),
26855 )
26856 .unwrap();
26857 let shared = NodeShared::new(
26858 handle,
26859 "fixture-token".to_owned(),
26860 NodeId::new("node-identity-policy").unwrap(),
26861 vec![workspace.clone()],
26862 vec![agent("claude")],
26863 );
26864 let address = SessionAddress {
26865 workspace_id: workspace.workspace_id.clone(),
26866 session: SessionKey {
26867 instance_id: AgentInstanceId(90),
26868 generation: SessionGeneration(1),
26869 },
26870 };
26871 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26872 shared
26873 .insert_record(ManagedSessionRecord {
26874 record_id: record_id.clone(),
26875 display_name: "policy denied".to_owned(),
26876 provider: agent("claude"),
26877 mode: SessionMode::Pty,
26878 state: ManagedSessionState::IdentityPending,
26879 workspace_id: workspace.workspace_id,
26880 canonical_root: opaque_windows_path(workspace.canonical_root),
26881 provider_session: None,
26882 active_session: Some(address.clone()),
26883 environment_profile: None,
26884 bundle: None,
26885 context_id: None,
26886 context: None,
26887 exported_context: None,
26888 task_binding: None,
26889 created_at_unix_ms: 1,
26890 updated_at_unix_ms: 1,
26891 last_error: None,
26892 })
26893 .unwrap();
26894 let event = ControlEvent {
26895 sequence: 1,
26896 command_id: None,
26897 instance_id: address.session.instance_id,
26898 generation: address.session.generation,
26899 event: ControlEventKind::ProviderEvent {
26900 sequence: 1,
26901 source: gate4agent_types::ProviderSource {
26902 family: AdapterFamily::PtySemantic,
26903 binding: AdapterBinding::new(
26904 gate4agent_types::AdapterId::new("claude").unwrap(),
26905 "fixture/v1",
26906 gate4agent_types::AdapterVerification::SyntheticFixture,
26907 )
26908 .unwrap(),
26909 },
26910 source_sequence: 1,
26911 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26912 identity: gate4agent_types::ProviderSessionIdentity {
26913 key: gate4agent_types::ProviderSessionKey::SessionId,
26914 id: "provider-session".to_owned(),
26915 transcript_path: None,
26916 },
26917 },
26918 },
26919 };
26920
26921 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26922
26923 let record = shared.record(&record_id).unwrap();
26924 assert!(record.provider_session.is_none());
26925 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26926 assert_eq!(record.active_session.as_ref(), Some(&address));
26927 }
26928
26929 #[test]
26930 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26931 let secondary_root = temporary_workspace_root("identity-scope");
26932 std::fs::create_dir_all(&secondary_root).unwrap();
26933 let catalog = active_registry().unwrap();
26934 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26935 let primary = WorkspaceConfig::new(
26936 WorkspaceId::new("primary").unwrap(),
26937 std::env::current_dir().unwrap(),
26938 )
26939 .unwrap();
26940 let secondary = WorkspaceConfig::new(
26941 WorkspaceId::new("secondary").unwrap(),
26942 &secondary_root,
26943 )
26944 .unwrap();
26945 let shared = NodeShared::new(
26946 handle,
26947 "fixture-token".to_owned(),
26948 NodeId::new("node-1").unwrap(),
26949 vec![primary.clone(), secondary.clone()],
26950 vec![agent("claude")],
26951 );
26952 let identity = gate4agent_types::ProviderSessionIdentity {
26953 key: gate4agent_types::ProviderSessionKey::SessionId,
26954 id: "provider-session".to_owned(),
26955 transcript_path: None,
26956 };
26957 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26958 shared
26959 .insert_record(ManagedSessionRecord {
26960 record_id: dormant_id.clone(),
26961 display_name: "dormant".to_owned(),
26962 provider: agent("claude"),
26963 mode: SessionMode::Pty,
26964 state: ManagedSessionState::Dormant,
26965 workspace_id: primary.workspace_id,
26966 canonical_root: opaque_windows_path(primary.canonical_root),
26967 provider_session: Some(identity.clone()),
26968 active_session: None,
26969 environment_profile: None,
26970 bundle: None,
26971 context_id: None,
26972 context: None,
26973 exported_context: None,
26974 task_binding: None,
26975 created_at_unix_ms: 1,
26976 updated_at_unix_ms: 1,
26977 last_error: None,
26978 })
26979 .unwrap();
26980 let address = SessionAddress {
26981 workspace_id: secondary.workspace_id.clone(),
26982 session: SessionKey {
26983 instance_id: AgentInstanceId(91),
26984 generation: SessionGeneration(1),
26985 },
26986 };
26987 let current_id = SessionRecordId::new("sr-current").unwrap();
26988 shared
26989 .insert_record(ManagedSessionRecord {
26990 record_id: current_id.clone(),
26991 display_name: "current".to_owned(),
26992 provider: agent("claude"),
26993 mode: SessionMode::Pty,
26994 state: ManagedSessionState::IdentityPending,
26995 workspace_id: secondary.workspace_id,
26996 canonical_root: opaque_windows_path(secondary.canonical_root),
26997 provider_session: None,
26998 active_session: Some(address.clone()),
26999 environment_profile: None,
27000 bundle: None,
27001 context_id: None,
27002 context: None,
27003 exported_context: None,
27004 task_binding: None,
27005 created_at_unix_ms: 2,
27006 updated_at_unix_ms: 2,
27007 last_error: None,
27008 })
27009 .unwrap();
27010 shared.bind_managed_session(
27011 &address,
27012 current_id.clone(),
27013 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27014 None,
27015 );
27016 let event = ControlEvent {
27017 sequence: 1,
27018 command_id: None,
27019 instance_id: address.session.instance_id,
27020 generation: address.session.generation,
27021 event: ControlEventKind::ProviderEvent {
27022 sequence: 1,
27023 source: gate4agent_types::ProviderSource {
27024 family: gate4agent_types::AdapterFamily::PtySemantic,
27025 binding: gate4agent_types::AdapterBinding::new(
27026 gate4agent_types::AdapterId::new("claude").unwrap(),
27027 "fixture/v1",
27028 gate4agent_types::AdapterVerification::SyntheticFixture,
27029 )
27030 .unwrap(),
27031 },
27032 source_sequence: 1,
27033 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
27034 identity,
27035 },
27036 },
27037 };
27038 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
27039
27040 let binding = shared
27041 .session_bindings
27042 .lock()
27043 .unwrap_or_else(|poisoned| poisoned.into_inner())
27044 .get(&address.session.instance_id)
27045 .cloned()
27046 .unwrap();
27047 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
27048 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
27049 let current = shared.record(¤t_id).unwrap();
27050 assert_eq!(current.state, ManagedSessionState::Unavailable);
27051 assert_eq!(current.active_session.as_ref(), Some(&address));
27052 assert_eq!(
27053 current.last_error.as_deref(),
27054 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
27055 );
27056 std::fs::remove_dir_all(secondary_root).unwrap();
27057 }
27058
27059 #[tokio::test]
27060 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
27061 let root = temporary_workspace_root("semantic-provider-session");
27062 std::fs::create_dir_all(&root).unwrap();
27063 let state_path = root.join("state-v1.json");
27064 let catalog = active_registry().unwrap();
27065 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27066 let workspace = WorkspaceConfig::new(
27067 WorkspaceId::new("primary").unwrap(),
27068 &root,
27069 )
27070 .unwrap();
27071 let node_id = NodeId::new("node-semantic-identity").unwrap();
27072 let shared = NodeShared::new_with_incarnation(
27073 handle,
27074 "fixture-token".to_owned(),
27075 node_id.clone(),
27076 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27077 vec![workspace.clone()],
27078 vec![agent("claude")],
27079 ProviderRuntimeStatuses::default(),
27080 None,
27081 Vec::new(),
27082 Vec::new(),
27083 SpawnProfileRegistry::default(),
27084 None,
27085 None,
27086 None,
27087 Some(state_path.clone()),
27088 Vec::new(),
27089 Vec::new(),
27090 Vec::new(),
27091 Vec::new(),
27092 None,
27093 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27094 );
27095 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
27096 key: gate4agent_types::ProviderSessionKey::SessionId,
27097 id: "provider-session".to_owned(),
27098 transcript_path: None,
27099 };
27100 let durable_id = SessionRecordId::new("sr-durable").unwrap();
27101 shared
27102 .insert_record(ManagedSessionRecord {
27103 record_id: durable_id.clone(),
27104 display_name: "durable".to_owned(),
27105 provider: agent("claude"),
27106 mode: SessionMode::Pty,
27107 state: ManagedSessionState::Dormant,
27108 workspace_id: workspace.workspace_id.clone(),
27109 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
27110 provider_session: Some(persisted_identity.clone()),
27111 active_session: None,
27112 environment_profile: None,
27113 bundle: None,
27114 context_id: None,
27115 context: None,
27116 exported_context: None,
27117 task_binding: None,
27118 created_at_unix_ms: 1,
27119 updated_at_unix_ms: 1,
27120 last_error: None,
27121 })
27122 .unwrap();
27123 let address = SessionAddress {
27124 workspace_id: workspace.workspace_id.clone(),
27125 session: SessionKey {
27126 instance_id: AgentInstanceId(92),
27127 generation: SessionGeneration(2),
27128 },
27129 };
27130 let pending_id = SessionRecordId::new("sr-pending").unwrap();
27131 shared
27132 .insert_record(ManagedSessionRecord {
27133 record_id: pending_id.clone(),
27134 display_name: "pending".to_owned(),
27135 provider: agent("claude"),
27136 mode: SessionMode::Pty,
27137 state: ManagedSessionState::IdentityPending,
27138 workspace_id: workspace.workspace_id.clone(),
27139 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
27140 provider_session: None,
27141 active_session: Some(address.clone()),
27142 environment_profile: None,
27143 bundle: None,
27144 context_id: None,
27145 context: None,
27146 exported_context: None,
27147 task_binding: None,
27148 created_at_unix_ms: 2,
27149 updated_at_unix_ms: 2,
27150 last_error: None,
27151 })
27152 .unwrap();
27153 shared.bind_managed_session(
27154 &address,
27155 pending_id.clone(),
27156 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27157 None,
27158 );
27159 let observed_identity = gate4agent_types::ProviderSessionIdentity {
27160 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
27161 ..persisted_identity
27162 };
27163 let event = ControlEvent {
27164 sequence: 1,
27165 command_id: None,
27166 instance_id: address.session.instance_id,
27167 generation: address.session.generation,
27168 event: ControlEventKind::ProviderEvent {
27169 sequence: 1,
27170 source: gate4agent_types::ProviderSource {
27171 family: gate4agent_types::AdapterFamily::PtySemantic,
27172 binding: gate4agent_types::AdapterBinding::new(
27173 gate4agent_types::AdapterId::new("claude").unwrap(),
27174 "fixture/v1",
27175 gate4agent_types::AdapterVerification::SyntheticFixture,
27176 )
27177 .unwrap(),
27178 },
27179 source_sequence: 1,
27180 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
27181 identity: observed_identity.clone(),
27182 },
27183 },
27184 };
27185 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
27186
27187 let snapshot = shared.snapshot();
27188 assert_eq!(snapshot.session_records.len(), 1);
27189 let live = &snapshot.session_records[0];
27190 assert_eq!(live.record_id, durable_id);
27191 assert_eq!(live.state, ManagedSessionState::Live);
27192 assert_eq!(live.active_session.as_ref(), Some(&address));
27193 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
27194 let binding = shared
27195 .session_bindings
27196 .lock()
27197 .unwrap_or_else(|poisoned| poisoned.into_inner())
27198 .get(&address.session.instance_id)
27199 .cloned()
27200 .unwrap();
27201 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
27202
27203 let mut duplicate = live.clone();
27204 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
27205 duplicate.display_name = "duplicate".to_owned();
27206 duplicate.state = ManagedSessionState::Dormant;
27207 duplicate.active_session = None;
27208 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
27209 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
27210 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
27211 assert_eq!(shared.snapshot().session_records.len(), 1);
27212
27213 let terminal_size = gate4agent_types::TerminalSize {
27214 rows: 24,
27215 columns: 80,
27216 };
27217 let (first_busy, second_busy) = tokio::join!(
27218 shared.resume_session_record(&durable_id, terminal_size, None),
27219 shared.resume_session_record(&durable_id, terminal_size, None),
27220 );
27221 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
27222 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
27223 assert_eq!(busy.message, "session-record-busy");
27224 }
27225
27226 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
27227 assert_eq!(loaded.records.len(), 1);
27228 assert_eq!(loaded.records[0].record_id, durable_id);
27229 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
27230 assert_eq!(
27231 loaded.records[0]
27232 .provider_session
27233 .as_ref()
27234 .unwrap()
27235 .transcript_path,
27236 None,
27237 );
27238 let catalog = active_registry().unwrap();
27239 let (reloaded_handle, _reloaded_runtime) =
27240 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27241 let reloaded = NodeShared::new_with_incarnation(
27242 reloaded_handle,
27243 "fixture-token".to_owned(),
27244 node_id,
27245 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27246 vec![workspace],
27247 vec![agent("claude")],
27248 ProviderRuntimeStatuses::default(),
27249 None,
27250 Vec::new(),
27251 Vec::new(),
27252 SpawnProfileRegistry::default(),
27253 None,
27254 None,
27255 None,
27256 None,
27257 loaded.records,
27258 loaded.managed_worktrees,
27259 loaded.managed_worktree_tombstones,
27260 loaded.materializations,
27261 loaded.warning,
27262 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27263 );
27264 assert_eq!(reloaded.snapshot().session_records.len(), 1);
27265 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27266 std::fs::remove_dir_all(root).unwrap();
27267 }
27268
27269 #[tokio::test]
27270 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27271 let secondary_root = temporary_workspace_root("workspace-restore");
27272 let other_root = temporary_workspace_root("workspace-conflict");
27273 std::fs::create_dir_all(&secondary_root).unwrap();
27274 std::fs::create_dir_all(&other_root).unwrap();
27275 let catalog = active_registry().unwrap();
27276 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27277 let primary = WorkspaceConfig::new(
27278 WorkspaceId::new("primary").unwrap(),
27279 std::env::current_dir().unwrap(),
27280 )
27281 .unwrap();
27282 let secondary = WorkspaceConfig::new(
27283 WorkspaceId::new("secondary").unwrap(),
27284 &secondary_root,
27285 )
27286 .unwrap();
27287 let shared = NodeShared::new(
27288 handle,
27289 "fixture-token".to_owned(),
27290 NodeId::new("node-1").unwrap(),
27291 vec![primary, secondary.clone()],
27292 vec![agent("claude")],
27293 );
27294 let record_id = SessionRecordId::new("sr-workspace").unwrap();
27295 shared
27296 .insert_record(ManagedSessionRecord {
27297 record_id: record_id.clone(),
27298 display_name: "workspace record".to_owned(),
27299 provider: agent("claude"),
27300 mode: SessionMode::Pty,
27301 state: ManagedSessionState::Dormant,
27302 workspace_id: secondary.workspace_id.clone(),
27303 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27304 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27305 key: gate4agent_types::ProviderSessionKey::SessionId,
27306 id: "provider-session".to_owned(),
27307 transcript_path: None,
27308 }),
27309 active_session: None,
27310 environment_profile: None,
27311 bundle: None,
27312 context_id: None,
27313 context: None,
27314 exported_context: None,
27315 task_binding: None,
27316 created_at_unix_ms: 1,
27317 updated_at_unix_ms: 1,
27318 last_error: None,
27319 })
27320 .unwrap();
27321 shared.unregister_workspace(&secondary.workspace_id).unwrap();
27322 let unavailable = shared.record(&record_id).unwrap();
27323 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27324 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27325
27326 let conflict = shared
27327 .register_workspace(
27328 secondary.workspace_id.clone(),
27329 other_root.to_string_lossy().into_owned(),
27330 )
27331 .await
27332 .unwrap_err();
27333 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27334 shared
27335 .register_workspace(
27336 secondary.workspace_id,
27337 secondary_root.to_string_lossy().into_owned(),
27338 )
27339 .await
27340 .unwrap();
27341 let restored = shared.record(&record_id).unwrap();
27342 assert_eq!(restored.state, ManagedSessionState::Dormant);
27343 assert!(restored.last_error.is_none());
27344 std::fs::remove_dir_all(secondary_root).unwrap();
27345 std::fs::remove_dir_all(other_root).unwrap();
27346 }
27347
27348 #[test]
27349 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27350 let error = validate_workspace_request_root(
27351 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27352 )
27353 .unwrap_err();
27354 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27355 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27356 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27357 }
27358
27359 #[test]
27360 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27361 let catalog = active_registry().unwrap();
27362 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27363 let workspace = WorkspaceConfig::new(
27364 WorkspaceId::new("primary").unwrap(),
27365 std::env::current_dir().unwrap(),
27366 )
27367 .unwrap();
27368 let shared = NodeShared::new(
27369 handle,
27370 "fixture-token".to_owned(),
27371 NodeId::new("node-1").unwrap(),
27372 vec![workspace],
27373 vec![agent("claude")],
27374 );
27375 let publishers = 4_u64;
27376 let events_per_publisher = 256_u64;
27377 let start = std::sync::Barrier::new(publishers as usize + 1);
27378 let finished = AtomicU64::new(0);
27379
27380 std::thread::scope(|scope| {
27381 for _ in 0..publishers {
27382 scope.spawn(|| {
27383 start.wait();
27384 for _ in 0..events_per_publisher {
27385 shared.publish(NodeEvent::ControllerChanged { controller: None });
27386 }
27387 finished.fetch_add(1, Ordering::Release);
27388 });
27389 }
27390 start.wait();
27391 while finished.load(Ordering::Acquire) != publishers {
27392 assert_resync_history_is_linearized(shared.resync(0));
27393 std::thread::yield_now();
27394 }
27395 });
27396 let response = shared.resync(0);
27397 assert_resync_history_is_linearized(response.clone());
27398 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27399 unreachable!("resync helper returned another response");
27400 };
27401 assert_eq!(event_sequence, publishers * events_per_publisher);
27402 assert_eq!(events.len() as u64, event_sequence);
27403 }
27404
27405 fn assert_resync_history_is_linearized(response: NodeResponse) {
27406 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27407 unreachable!("resync helper returned another response");
27408 };
27409 assert!(events.windows(2).all(|pair| {
27410 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27411 }));
27412 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27413 }
27414
27415 #[test]
27416 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27417 assert_eq!(
27418 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27419 r"C:\repo\worktree",
27420 );
27421 assert_eq!(
27422 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27423 r"\\server\share\repo",
27424 );
27425 assert!(validate_workspace_root(
27426 &WorkspaceId::new("network").unwrap(),
27427 r"\\server\share\repo",
27428 )
27429 .is_err());
27430 let workspace = WorkspaceConfig::new(
27431 WorkspaceId::new("primary").unwrap(),
27432 std::env::current_dir().unwrap(),
27433 )
27434 .unwrap();
27435 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27436 }
27437
27438 #[test]
27439 fn resume_rebinds_only_on_the_expected_authorized_generation() {
27440 let catalog = active_registry().unwrap();
27441 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27442 let workspace_id = WorkspaceId::new("primary").unwrap();
27443 let workspace = WorkspaceConfig::new(
27444 workspace_id.clone(),
27445 std::env::current_dir().unwrap(),
27446 )
27447 .unwrap();
27448 let shared = NodeShared::new(
27449 handle,
27450 "fixture-token".to_owned(),
27451 NodeId::new("node-1").unwrap(),
27452 vec![workspace],
27453 vec![agent("claude")],
27454 );
27455 let instance_id = AgentInstanceId(17);
27456 let original = SessionAddress {
27457 workspace_id,
27458 session: SessionKey {
27459 instance_id,
27460 generation: SessionGeneration(3),
27461 },
27462 };
27463 shared.bind_session(
27464 instance_id,
27465 original.workspace_id.clone(),
27466 original.session.generation,
27467 );
27468 shared
27469 .arm_resume(
27470 &original,
27471 CommandId(41),
27472 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27473 )
27474 .unwrap();
27475
27476 shared.publish_control(ControlEvent {
27477 sequence: 1,
27478 command_id: None,
27479 instance_id,
27480 generation: SessionGeneration(4),
27481 event: ControlEventKind::Running { process_id: None },
27482 });
27483 assert_eq!(
27484 shared.address_for(instance_id, SessionGeneration(3)),
27485 Some(original.clone()),
27486 );
27487 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27488
27489 shared.publish_control(ControlEvent {
27490 sequence: 2,
27491 command_id: Some(CommandId(42)),
27492 instance_id,
27493 generation: SessionGeneration(3),
27494 event: ControlEventKind::CommandRejected {
27495 message: "unrelated command".to_owned(),
27496 },
27497 });
27498
27499 shared.publish_control(ControlEvent {
27500 sequence: 3,
27501 command_id: None,
27502 instance_id,
27503 generation: SessionGeneration(4),
27504 event: ControlEventKind::ResumeAuthorized {
27505 session: gate4agent_types::ResumeSessionSummary {
27506 key: gate4agent_types::ProviderSessionKey::SessionId,
27507 id: "fixture-session".to_owned(),
27508 },
27509 },
27510 });
27511 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27512 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27513 assert_eq!(rebound.workspace_id, original.workspace_id);
27514 let history = shared
27515 .history
27516 .lock()
27517 .unwrap_or_else(|poisoned| poisoned.into_inner());
27518 assert!(history.events.iter().any(|envelope| matches!(
27519 &envelope.event,
27520 NodeEvent::Control { address, event }
27521 if address == &rebound
27522 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27523 )));
27524 }
27525
27526 #[test]
27527 fn all_node_pty_prompts_use_bracketed_paste_framing() {
27528 for agent in ["claude", "codex", "kimi"] {
27529 assert_eq!(
27530 prompt_framing(&AgentId::new(agent).unwrap()),
27531 PromptFraming::BracketedPaste
27532 );
27533 }
27534 }
27535
27536 fn generous_walk_deadline() -> Instant {
27537 Instant::now() + Duration::from_secs(30)
27538 }
27539
27540 #[test]
27541 fn workspace_tree_is_relative_and_skips_heavy_directories() {
27542 let root = temporary_workspace_root("tree");
27543 std::fs::create_dir_all(root.join("src")).unwrap();
27544 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27545 std::fs::create_dir_all(root.join("target/debug")).unwrap();
27546 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27547 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27548 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27549 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27555 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27556 std::fs::create_dir_all(root.join("target-notes")).unwrap();
27560 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27561
27562 let (entries, truncated, _budget) =
27563 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27564 assert!(!truncated);
27565 assert!(entries.iter().any(|entry| {
27566 entry.relative_path.as_utf8() == Some("src")
27567 && entry.kind == WorkspaceEntryKind::Directory
27568 }));
27569 assert!(entries.iter().any(|entry| {
27570 entry.relative_path.as_utf8() == Some("src/lib.rs")
27571 && entry.kind == WorkspaceEntryKind::File
27572 }));
27573 assert!(entries.iter().any(|entry| {
27574 entry.relative_path.as_utf8() == Some("target-notes")
27575 && entry.kind == WorkspaceEntryKind::Directory
27576 }));
27577 assert!(entries.iter().any(|entry| {
27578 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27579 && entry.kind == WorkspaceEntryKind::File
27580 }));
27581 assert!(entries.iter().all(|entry| {
27582 let path = entry.relative_path.as_utf8().unwrap();
27583 !path.starts_with(".git")
27584 && !(path == "target" || path.starts_with("target/"))
27585 && !(path == "target-a2" || path.starts_with("target-a2/"))
27586 && !path.starts_with("node_modules")
27587 }));
27588
27589 std::fs::remove_dir_all(&root).unwrap();
27590 }
27591
27592 #[test]
27593 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27594 let root = temporary_workspace_root("tree-entry-cap");
27595 std::fs::create_dir_all(&root).unwrap();
27596 for index in 0..20 {
27597 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27598 }
27599
27600 let (entries, truncated, budget) =
27601 collect_workspace_entries(&root, generous_walk_deadline(), 5);
27602 assert!(truncated);
27603 assert!(budget.entry_cap_exceeded);
27604 assert!(!budget.time_budget_exceeded);
27605 assert!(entries.len() <= 5);
27606
27607 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27608 .expect("an entry-cap-truncated walk must report a truncation marker");
27609 assert!(inspection_truncation.walk_entry_cap_exceeded);
27610 assert!(!inspection_truncation.walk_time_budget_exceeded);
27611 assert!(!inspection_truncation.git_time_budget_exceeded);
27612 assert!(inspection_truncation.entries_visited >= 5);
27613
27614 std::fs::remove_dir_all(&root).unwrap();
27615 }
27616
27617 #[test]
27618 fn workspace_walk_within_budget_reports_no_truncation() {
27619 let root = temporary_workspace_root("tree-no-truncation");
27620 std::fs::create_dir_all(&root).unwrap();
27621 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27622
27623 let (_entries, truncated, budget) =
27624 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27625 assert!(!truncated);
27626 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27627
27628 std::fs::remove_dir_all(&root).unwrap();
27629 }
27630
27631 #[test]
27632 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27633 let root = temporary_workspace_root("tree-prefix-siblings");
27634 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27635 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27636 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27637 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27638
27639 let (entries, truncated, _budget) =
27640 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27641 assert!(!truncated);
27642 let paths = entries
27643 .iter()
27644 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27645 .collect::<Vec<_>>();
27646 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27647 assert_eq!(
27648 paths,
27649 vec![
27650 "gate4agent-c2".to_owned(),
27651 "gate4agent-c2-client".to_owned(),
27652 "gate4agent-c2-client/lib.rs".to_owned(),
27653 "gate4agent-c2/src".to_owned(),
27654 "gate4agent-c2/src/lib.rs".to_owned(),
27655 ],
27656 );
27657
27658 std::fs::remove_dir_all(&root).unwrap();
27659 }
27660
27661 #[test]
27662 fn git_parsers_cap_status_and_commit_results() {
27663 let mut status_output = Vec::new();
27664 for index in 0..=GIT_STATUS_MAX_ENTRIES {
27665 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27666 }
27667 let mut snapshot = git_snapshot_for_parser();
27668 parse_git_status(&status_output, &mut snapshot);
27669 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27670 assert!(snapshot.truncated);
27671 assert_eq!(snapshot.status[0].index_status, " ");
27672 assert_eq!(snapshot.status[0].worktree_status, "M");
27673
27674 snapshot.truncated = false;
27675 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27676 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27677 .collect::<String>();
27678 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27679 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27680 assert!(snapshot.truncated);
27681 }
27682
27683 #[test]
27684 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27685 let mut snapshot = git_snapshot_for_parser();
27686 parse_git_status(
27687 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27688 &mut snapshot,
27689 );
27690
27691 assert!(!snapshot.truncated);
27692 assert_eq!(snapshot.status.len(), 3);
27693 assert_eq!(snapshot.status[0].index_status, "R");
27694 assert_eq!(snapshot.status[0].worktree_status, " ");
27695 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27696 assert_eq!(
27697 snapshot.status[0]
27698 .previous_path
27699 .as_ref()
27700 .and_then(RepositoryPath::as_utf8),
27701 Some("src/previous name.rs"),
27702 );
27703 assert_eq!(snapshot.status[1].index_status, " ");
27704 assert_eq!(snapshot.status[1].worktree_status, "C");
27705 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27706 assert_eq!(
27707 snapshot.status[1]
27708 .previous_path
27709 .as_ref()
27710 .and_then(RepositoryPath::as_utf8),
27711 Some("src/original.rs"),
27712 );
27713 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27714 assert!(snapshot.status[2].previous_path.is_none());
27715 }
27716
27717 #[test]
27718 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27719 let mut snapshot = git_snapshot_for_parser();
27720 parse_git_status(
27721 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27722 &mut snapshot,
27723 );
27724
27725 assert!(snapshot.truncated);
27726 assert_eq!(snapshot.status.len(), 1);
27727 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27728 assert!(snapshot.status[0].previous_path.is_none());
27729 }
27730
27731 #[tokio::test]
27732 async fn observer_can_browse_host_directories_without_controller() {
27733 let root = temporary_workspace_root("host-directory-observer");
27734 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27735 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27736 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27737 let workspace_id = WorkspaceId::new("primary").unwrap();
27738 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27739 let catalog = active_registry().unwrap();
27740 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27741 let shared = NodeShared::new(
27742 handle,
27743 "fixture-token".to_owned(),
27744 NodeId::new("node-1").unwrap(),
27745 vec![workspace],
27746 vec![agent("claude")],
27747 );
27748 let request = NodeRequest::BrowseHostDirectories {
27749 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27750 after: None,
27751 };
27752 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27753 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27754 }));
27755 assert!(!request_uses_unnegotiated_capability(
27756 &request,
27757 &baseline_capabilities().unwrap(),
27758 ));
27759 assert!(request_uses_unnegotiated_capability(&request, &[]));
27760
27761 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27762 .await
27763 .unwrap();
27764 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27765 panic!("host directory browse returned another response");
27766 };
27767 assert_eq!(
27768 listing
27769 .entries
27770 .iter()
27771 .map(|entry| entry.display_name.as_str())
27772 .collect::<Vec<_>>(),
27773 vec!["directory-a", "directory-b"],
27774 );
27775 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27776 assert!(!listing.incomplete);
27777 std::fs::remove_dir_all(root).unwrap();
27778 }
27779
27780 #[tokio::test]
27781 async fn observer_can_inspect_only_a_registered_workspace() {
27782 let root = temporary_workspace_root("observer");
27783 std::fs::create_dir_all(root.join("src")).unwrap();
27784 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27785 let workspace_id = WorkspaceId::new("primary").unwrap();
27786 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27787 let catalog = active_registry().unwrap();
27788 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27789 let shared = NodeShared::new(
27790 handle,
27791 "fixture-token".to_owned(),
27792 NodeId::new("node-1").unwrap(),
27793 vec![workspace],
27794 vec![agent("claude")],
27795 );
27796
27797 let response = process_request_inner(
27798 &shared,
27799 77,
27800 ClientRole::Observer,
27801 NodeRequest::InspectWorkspace {
27802 workspace_id: workspace_id.clone(),
27803 },
27804 )
27805 .await
27806 .unwrap();
27807 let NodeResponse::WorkspaceInspected { inspection } = response else {
27808 panic!("workspace inspection returned another response");
27809 };
27810 assert_eq!(inspection.workspace_id, workspace_id);
27811 assert!(inspection.entries.iter().any(|entry| {
27812 entry.relative_path.as_utf8() == Some("src/main.rs")
27813 }));
27814
27815 let unknown = process_request_inner(
27816 &shared,
27817 77,
27818 ClientRole::Observer,
27819 NodeRequest::InspectWorkspace {
27820 workspace_id: WorkspaceId::new("unknown").unwrap(),
27821 },
27822 )
27823 .await
27824 .unwrap_err();
27825 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27826 std::fs::remove_dir_all(&root).unwrap();
27827 }
27828
27829 #[test]
27830 fn context_pack_source_accepts_only_running_or_successful_exit() {
27831 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27832 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27833 exit_code: Some(0),
27834 }));
27835 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27836 exit_code: Some(1),
27837 }));
27838 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27839 exit_code: None,
27840 }));
27841 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27842 message: "provider failed".to_owned(),
27843 }));
27844 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27845 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27846 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27847 }
27848
27849 #[test]
27850 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27851 let git = git_snapshot_for_parser();
27852 let first = ContextPackRepositoryObservation {
27853 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27854 git: git.clone(),
27855 files: Vec::new(),
27856 };
27857 let second = ContextPackRepositoryObservation {
27858 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27859 git,
27860 files: Vec::new(),
27861 };
27862
27863 let error = stable_context_pack_repository(first, second).unwrap_err();
27864
27865 assert_eq!(
27866 error.code,
27867 NodeFailureCode::ContextPackMaterializationFailed,
27868 );
27869 }
27870
27871 #[test]
27872 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27873 let git = git_snapshot_for_parser();
27874 let first = ContextPackRepositoryObservation {
27875 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27876 git: git.clone(),
27877 files: vec![ContextPackRepositoryFileSource::utf8(
27878 "README.md",
27879 "first\n".to_owned(),
27880 6,
27881 )],
27882 };
27883 let second = ContextPackRepositoryObservation {
27884 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27885 git,
27886 files: vec![ContextPackRepositoryFileSource::utf8(
27887 "README.md",
27888 "other\n".to_owned(),
27889 6,
27890 )],
27891 };
27892
27893 let error = stable_context_pack_repository(first, second).unwrap_err();
27894
27895 assert_eq!(
27896 error.code,
27897 NodeFailureCode::ContextPackMaterializationFailed,
27898 );
27899 }
27900
27901 #[tokio::test]
27902 async fn native_session_catalog_zero_active_session_fixture() {
27903 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27904 let history_root = temporary_workspace_root("native-catalog-history");
27905 let outside_root = temporary_workspace_root("native-catalog-outside");
27906 std::fs::create_dir_all(&workspace_root).unwrap();
27907 std::fs::create_dir_all(&history_root).unwrap();
27908 std::fs::create_dir_all(&outside_root).unwrap();
27909 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27910 [
27911 serde_json::json!({
27912 "type": "user",
27913 "sessionId": session_id,
27914 "cwd": cwd.to_str().unwrap(),
27915 "message": { "content": "raw user text must not cross the catalog wire" }
27916 }),
27917 serde_json::json!({
27918 "type": "assistant",
27919 "sessionId": session_id,
27920 "cwd": cwd.to_str().unwrap(),
27921 "message": { "model": "claude-sonnet", "content": "raw answer" }
27922 }),
27923 serde_json::json!({
27924 "type": "ai-title",
27925 "sessionId": session_id,
27926 "aiTitle": title
27927 }),
27928 ]
27929 .into_iter()
27930 .map(|record| record.to_string())
27931 .collect::<Vec<_>>()
27932 .join("\n")
27933 };
27934 std::fs::write(
27935 history_root.join("stable-session.jsonl"),
27936 transcript("stable-session", &workspace_root, "Workspace review"),
27937 )
27938 .unwrap();
27939 std::fs::write(
27940 history_root.join("outside-session.jsonl"),
27941 transcript("outside-session", &outside_root, "Outside review"),
27942 )
27943 .unwrap();
27944 let history = NativeHistoryConfig::new(vec![
27945 NativeHistoryRoot::new(
27946 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27947 HistorySourceLayout::SingleNdjson,
27948 history_root.clone(),
27949 )
27950 .unwrap(),
27951 ])
27952 .unwrap();
27953 let catalog = active_registry().unwrap();
27954 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27955 assert!(handle.snapshot().sessions.is_empty());
27956 let workspace_id = WorkspaceId::new("primary").unwrap();
27957 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27958 let mut shared = NodeShared::new(
27959 handle,
27960 "fixture-token".to_owned(),
27961 NodeId::new("node-native-catalog").unwrap(),
27962 vec![workspace],
27963 vec![agent("claude")],
27964 );
27965 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27966 NativeSessionCatalogAuthority::new(history),
27967 )));
27968 let route = NativeSessionCatalogRoute::workspace(
27969 workspace_id.clone(),
27970 agent("claude"),
27971 );
27972 let request = NodeRequest::CatalogNativeSessions {
27973 route: route.clone(),
27974 limit: 2,
27975 };
27976 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27977 .await
27978 .unwrap_err();
27979 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27980 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27981 .await
27982 .unwrap();
27983 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27984 panic!("native catalog returned a different response");
27985 };
27986 let summary = summary.expect("node must emit native session catalog summary metadata");
27987 assert_eq!(entries.len(), 1);
27988 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27989 assert_eq!(entries[0].external_group, None);
27990 assert_eq!(entries[0].record_id, None);
27991 assert_eq!(entries[0].title, None);
27992 assert_eq!(entries[0].model, None);
27993 assert_eq!(entries[0].message_count, 0);
27994 let encoded_entries = serde_json::to_string(&entries).unwrap();
27995 assert!(!encoded_entries.contains("stable-session"));
27996 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27997 let page_request = NodeRequest::PageNativeSessions {
27998 route: route.clone(),
27999 window: crate::protocol::NativeSessionCatalogWindow::Recent,
28000 catalog_revision: summary.catalog_revision,
28001 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
28002 after_selection_id: None,
28003 limit: 2,
28004 };
28005 let observer = process_request_inner(
28006 &shared,
28007 7,
28008 ClientRole::Observer,
28009 page_request.clone(),
28010 )
28011 .await
28012 .unwrap_err();
28013 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28014 let page_response = process_request_inner(
28015 &shared,
28016 7,
28017 ClientRole::Operator,
28018 page_request.clone(),
28019 )
28020 .await
28021 .unwrap();
28022 assert!(matches!(
28023 page_response,
28024 NodeResponse::NativeSessionsPaged { page, .. }
28025 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
28026 ));
28027 let stale = process_request_inner(
28028 &shared,
28029 7,
28030 ClientRole::Operator,
28031 NodeRequest::PageNativeSessions {
28032 route: route.clone(),
28033 window: crate::protocol::NativeSessionCatalogWindow::Recent,
28034 catalog_revision: summary.catalog_revision.wrapping_add(1),
28035 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
28036 after_selection_id: None,
28037 limit: 2,
28038 },
28039 )
28040 .await
28041 .unwrap_err();
28042 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
28043 let selection = NativeSessionSelection {
28044 route: route.clone(),
28045 catalog_revision: summary.catalog_revision,
28046 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
28047 selection_id: entries[0].selection_id.clone(),
28048 };
28049 let preview_response = process_request_inner(
28050 &shared,
28051 7,
28052 ClientRole::Operator,
28053 NodeRequest::PreviewNativeSession {
28054 selection: selection.clone(),
28055 message_limit: 1,
28056 },
28057 )
28058 .await
28059 .unwrap();
28060 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
28061 panic!("native preview returned a different response");
28062 };
28063 assert_eq!(preview.messages.len(), 1);
28064 assert!(preview.message_count_exact);
28065 assert_eq!(preview.messages[0].text, "raw answer");
28066 assert!(preview.truncated);
28067
28068 let index_request = NodeRequest::IndexNativeSession {
28069 selection: selection.clone(),
28070 display_name: "Saved Claude".to_owned(),
28071 };
28072 let observer = process_request_inner(
28073 &shared,
28074 7,
28075 ClientRole::Observer,
28076 index_request.clone(),
28077 )
28078 .await
28079 .unwrap_err();
28080 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28081 let without_controller = process_request_inner(
28082 &shared,
28083 7,
28084 ClientRole::Operator,
28085 index_request.clone(),
28086 )
28087 .await
28088 .unwrap_err();
28089 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
28090 let acquired = process_request_inner(
28091 &shared,
28092 7,
28093 ClientRole::Operator,
28094 NodeRequest::AcquireController { lease_ms: 5_000 },
28095 )
28096 .await
28097 .unwrap();
28098 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
28099 let indexed = process_request_inner(
28100 &shared,
28101 7,
28102 ClientRole::Operator,
28103 index_request,
28104 )
28105 .await
28106 .unwrap();
28107 assert!(indexed.native_session_index_contract_is_valid());
28108 let NodeResponse::NativeSessionIndexed {
28109 selection: echoed_selection,
28110 record,
28111 } = indexed else {
28112 panic!("native session index returned a different response");
28113 };
28114 assert_eq!(echoed_selection, selection);
28115 let recataloged = process_request_inner(
28116 &shared,
28117 7,
28118 ClientRole::Operator,
28119 NodeRequest::CatalogNativeSessions {
28120 route,
28121 limit: 2,
28122 },
28123 )
28124 .await
28125 .unwrap();
28126 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
28127 panic!("native catalog returned a different response after indexing");
28128 };
28129 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
28130
28131 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
28132 let external_catalog = process_request_inner(
28133 &shared,
28134 7,
28135 ClientRole::Operator,
28136 NodeRequest::CatalogNativeSessions {
28137 route: unregistered_route.clone(),
28138 limit: 2,
28139 },
28140 )
28141 .await
28142 .unwrap();
28143 let external_json = serde_json::to_string(&external_catalog).unwrap();
28144 assert!(!external_json.contains("outside-session"));
28145 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
28146 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
28147 assert!(!external_json.contains("\"session_id\""));
28148 assert!(!external_json.contains("\"cwd\""));
28149 assert!(!external_json.contains("\"path\""));
28150 let NodeResponse::NativeSessionsCataloged {
28151 route: echoed_route,
28152 entries: external_entries,
28153 summary: Some(external_summary),
28154 } = external_catalog
28155 else {
28156 panic!("unregistered native catalog returned a different response");
28157 };
28158 assert_eq!(echoed_route, unregistered_route);
28159 assert_eq!(external_entries.len(), 1);
28160 assert_eq!(external_entries[0].record_id, None);
28161 let external_group = external_entries[0]
28162 .external_group
28163 .as_ref()
28164 .expect("unregistered session must have an external group");
28165 assert_eq!(
28166 external_group.kind,
28167 gate4agent_types::NativeSessionExternalGroupKind::Project,
28168 );
28169 assert!(external_group.group_id.starts_with("external-"));
28170 assert_eq!(
28171 external_group.display_name,
28172 outside_root.file_name().unwrap().to_string_lossy(),
28173 );
28174 let external_selection = NativeSessionSelection {
28175 route: unregistered_route,
28176 catalog_revision: external_summary.catalog_revision,
28177 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
28178 selection_id: external_entries[0].selection_id.clone(),
28179 };
28180 let external_preview = process_request_inner(
28181 &shared,
28182 7,
28183 ClientRole::Operator,
28184 NodeRequest::PreviewNativeSession {
28185 selection: external_selection.clone(),
28186 message_limit: 1,
28187 },
28188 )
28189 .await
28190 .unwrap();
28191 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
28192 assert!(!external_preview_json.contains("outside-session"));
28193 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
28194 assert!(!external_preview_json.contains("\"session_id\""));
28195 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
28196 panic!("unregistered native preview returned a different response");
28197 };
28198 assert_eq!(preview.messages.len(), 1);
28199 let external_index = process_request_inner(
28200 &shared,
28201 7,
28202 ClientRole::Operator,
28203 NodeRequest::IndexNativeSession {
28204 selection: external_selection,
28205 display_name: "External Claude".to_owned(),
28206 },
28207 )
28208 .await
28209 .unwrap_err();
28210 assert_eq!(
28211 external_index.code,
28212 NodeFailureCode::WorkspaceRegistrationRequired,
28213 );
28214 let record_response = process_request_inner(
28215 &shared,
28216 7,
28217 ClientRole::Operator,
28218 NodeRequest::PreviewSessionRecord {
28219 record_id: record.record_id.clone(),
28220 message_limit: 2,
28221 },
28222 )
28223 .await
28224 .unwrap();
28225 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
28226 panic!("record preview returned a different response");
28227 };
28228 assert_eq!(record_id, record.record_id);
28229 assert_eq!(preview.messages.len(), 2);
28230 let encoded = serde_json::to_string(&preview).unwrap();
28231 assert!(!encoded.contains("stable-session"));
28232 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
28233 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
28234 panic!("record preview history summary returned a different response");
28235 };
28236 let summaries = events
28237 .iter()
28238 .filter_map(|envelope| match &envelope.event {
28239 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
28240 if record_id == &record.record_id =>
28241 {
28242 Some(*summary)
28243 }
28244 _ => None,
28245 })
28246 .collect::<Vec<_>>();
28247 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28248 let summary_json = serde_json::to_string(&summaries).unwrap();
28249 for private in [
28250 "stable-session",
28251 "raw user text must not cross the catalog wire",
28252 "raw answer",
28253 "Workspace review",
28254 "claude-sonnet",
28255 ] {
28256 assert!(!summary_json.contains(private), "{private}");
28257 }
28258 let identity = record.provider_session.clone().unwrap();
28259 assert_eq!(
28260 shared.revalidate_session_record_preview(&record, &identity),
28261 Ok(())
28262 );
28263 shared.remove_record_memory(&record.record_id);
28264 assert_eq!(
28265 shared
28266 .revalidate_session_record_preview(&record, &identity)
28267 .unwrap_err()
28268 .code,
28269 NodeFailureCode::UnknownSessionRecord
28270 );
28271 let released = process_request_inner(
28272 &shared,
28273 7,
28274 ClientRole::Operator,
28275 NodeRequest::ReleaseController,
28276 )
28277 .await
28278 .unwrap();
28279 assert!(matches!(released, NodeResponse::Controller { controller: None }));
28280 assert!(shared.controller_state().is_none());
28281 std::fs::remove_dir_all(workspace_root).unwrap();
28282 std::fs::remove_dir_all(history_root).unwrap();
28283 std::fs::remove_dir_all(outside_root).unwrap();
28284 }
28285
28286 #[tokio::test]
28287 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28288 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28289 std::fs::create_dir_all(&history_root).unwrap();
28290 let history = NativeHistoryConfig::new(vec![
28291 NativeHistoryRoot::new(
28292 gate4agent_types::AdapterId::new("claude-code").unwrap(),
28293 HistorySourceLayout::SingleNdjson,
28294 history_root.clone(),
28295 )
28296 .unwrap(),
28297 ])
28298 .unwrap();
28299 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28300 let held = catalog.lock().unwrap();
28301 let result = timeout(
28302 Duration::from_secs(1),
28303 run_native_session_catalog_operation(
28304 Arc::clone(&catalog),
28305 "native session catalog",
28306 |_| Ok::<(), std::convert::Infallible>(()),
28307 ),
28308 )
28309 .await
28310 .expect("concurrent native catalog request must not wait for the operation deadline")
28311 .unwrap_err();
28312 assert_eq!(result.code, NodeFailureCode::BackendBusy);
28313 drop(held);
28314 std::fs::remove_dir_all(history_root).unwrap();
28315 }
28316
28317 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28318 let unique = std::time::SystemTime::now()
28319 .duration_since(std::time::UNIX_EPOCH)
28320 .unwrap()
28321 .as_nanos();
28322 std::env::temp_dir().join(format!(
28323 "gate4agent-node-{label}-{}-{unique}",
28324 std::process::id(),
28325 ))
28326 }
28327
28328 fn git_snapshot_for_parser() -> GitSnapshot {
28329 GitSnapshot {
28330 is_repository: true,
28331 branch: Some("main".to_owned()),
28332 status: Vec::new(),
28333 recent_commits: Vec::new(),
28334 worktrees: Vec::new(),
28335 managed_worktree: None,
28336 truncated: false,
28337 diagnostic: None,
28338 }
28339 }
28340
28341 #[tokio::test]
28342 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28343 let root = temporary_workspace_root("managed-git-scope");
28344 std::fs::create_dir_all(&root).unwrap();
28345 run_test_git(&root, &["init"]);
28346 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28347 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28348 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28349 run_test_git(&root, &["add", "seed.txt"]);
28350 run_test_git(&root, &["commit", "-m", "seed"]);
28351 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28352
28353 let workspace_id = WorkspaceId::new("managed-a").unwrap();
28354 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28355 let catalog = active_registry().unwrap();
28356 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28357 let shared = NodeShared::new(
28358 handle,
28359 "fixture-token".to_owned(),
28360 NodeId::new("node-1").unwrap(),
28361 vec![workspace],
28362 vec![agent("claude")],
28363 );
28364 let mut lease = ManagedWorktreeLeaseRecord {
28365 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28366 source_workspace_id: WorkspaceId::new("primary").unwrap(),
28367 workspace_id: workspace_id.clone(),
28368 profile_id: WorktreeProfileId::new("default").unwrap(),
28369 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28370 target_root: root.to_string_lossy().into_owned(),
28371 branch: "gate4agent/a".to_owned(),
28372 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28373 expected_head: None,
28374 retention: ManagedWorktreeRetention::Retain,
28375 state: ManagedWorktreeLeaseState::InUse,
28376 session_holders: vec![ManagedWorktreeSessionHolder {
28377 incarnation_id: shared.incarnation_id,
28378 instance_id: AgentInstanceId(7),
28379 generation: SessionGeneration(1),
28380 }],
28381 record_holders: Vec::new(),
28382 cleanup_failure: None,
28383 created_at_unix_ms: 1,
28384 updated_at_unix_ms: 1,
28385 };
28386 *shared
28387 .managed_worktrees
28388 .lock()
28389 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28390 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28391
28392 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28393 let scope = inspection.git.managed_worktree.unwrap();
28394 assert_eq!(scope.lease_id, lease.lease_id);
28395 assert_eq!(scope.branch, "gate4agent/a");
28396 assert_eq!(scope.active_session_count, 1);
28397
28398 lease.branch = "gate4agent/b".to_owned();
28399 *shared
28400 .managed_worktrees
28401 .lock()
28402 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28403 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28404 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28405 assert!(mismatch.git.managed_worktree.is_none());
28406 std::fs::remove_dir_all(root).unwrap();
28407 }
28408
28409 #[tokio::test]
28410 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28411 let root = temporary_workspace_root("workspace-file-write-controller");
28412 std::fs::create_dir_all(root.join("src")).unwrap();
28413 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28414 let workspace_id = WorkspaceId::new("primary").unwrap();
28415 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28416 let catalog = active_registry().unwrap();
28417 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28418 let shared = NodeShared::new(
28419 handle,
28420 "fixture-token".to_owned(),
28421 NodeId::new("node-1").unwrap(),
28422 vec![workspace],
28423 vec![agent("claude")],
28424 );
28425 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28426 let expected_revision = workspace_file_revision(b"old\n");
28427 let request = NodeRequest::WriteWorkspaceFile {
28428 workspace_id: workspace_id.clone(),
28429 path: path.clone(),
28430 expected_revision: expected_revision.clone(),
28431 text: "new\n".to_owned(),
28432 };
28433 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28434 .await
28435 .unwrap_err();
28436 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28437 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28438 .await
28439 .unwrap_err();
28440 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28441 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28442 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28443 let NodeResponse::WorkspaceFileWritten { file } = response else {
28444 panic!("workspace file write returned another response");
28445 };
28446 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28447 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28448 let stale = process_request_inner(
28449 &shared,
28450 77,
28451 ClientRole::Operator,
28452 NodeRequest::WriteWorkspaceFile {
28453 workspace_id,
28454 path,
28455 expected_revision,
28456 text: "lost update\n".to_owned(),
28457 },
28458 )
28459 .await
28460 .unwrap_err();
28461 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28462 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28463 std::fs::remove_dir_all(root).unwrap();
28464 }
28465
28466 #[tokio::test]
28467 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28468 let root = temporary_workspace_root("workspace-entry-create-controller");
28469 std::fs::create_dir_all(root.join("src")).unwrap();
28470 let workspace_id = WorkspaceId::new("primary").unwrap();
28471 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28472 let catalog = active_registry().unwrap();
28473 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28474 let shared = NodeShared::new(
28475 handle,
28476 "fixture-token".to_owned(),
28477 NodeId::new("node-1").unwrap(),
28478 vec![workspace],
28479 vec![agent("claude")],
28480 );
28481 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28482 let request = NodeRequest::CreateWorkspaceFile {
28483 workspace_id: workspace_id.clone(),
28484 path: path.clone(),
28485 };
28486 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28487 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28488 }));
28489 assert!(!request_uses_unnegotiated_capability(
28490 &request,
28491 &baseline_capabilities().unwrap(),
28492 ));
28493 assert!(request_uses_unnegotiated_capability(&request, &[]));
28494 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28495 .await
28496 .unwrap_err();
28497 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28498 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28499 .await
28500 .unwrap_err();
28501 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28502 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28503
28504 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28505 .await
28506 .unwrap();
28507 let NodeResponse::WorkspaceFileCreated { file } = response else {
28508 panic!("workspace file create returned another response");
28509 };
28510 assert_eq!(file.workspace_id, workspace_id);
28511 assert_eq!(file.path, path);
28512 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28513 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28514
28515 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28516 .await
28517 .unwrap_err();
28518 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28519
28520 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28521 let response = process_request_inner(
28522 &shared,
28523 77,
28524 ClientRole::Operator,
28525 NodeRequest::CreateWorkspaceDirectory {
28526 workspace_id: workspace_id.clone(),
28527 path: directory_path.clone(),
28528 },
28529 )
28530 .await
28531 .unwrap();
28532 assert_eq!(
28533 response,
28534 NodeResponse::WorkspaceDirectoryCreated {
28535 workspace_id: workspace_id.clone(),
28536 entry: WorkspaceEntry {
28537 relative_path: directory_path,
28538 kind: WorkspaceEntryKind::Directory,
28539 },
28540 },
28541 );
28542 assert!(root.join("src/nested").is_dir());
28543
28544 let missing_parent = process_request_inner(
28545 &shared,
28546 77,
28547 ClientRole::Operator,
28548 NodeRequest::CreateWorkspaceFile {
28549 workspace_id: workspace_id.clone(),
28550 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28551 },
28552 )
28553 .await
28554 .unwrap_err();
28555 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28556 let non_directory_parent = process_request_inner(
28557 &shared,
28558 77,
28559 ClientRole::Operator,
28560 NodeRequest::CreateWorkspaceFile {
28561 workspace_id,
28562 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28563 },
28564 )
28565 .await
28566 .unwrap_err();
28567 assert_eq!(
28568 non_directory_parent.code,
28569 NodeFailureCode::RepositoryParentNotDirectory,
28570 );
28571 std::fs::remove_dir_all(root).unwrap();
28572 }
28573
28574 #[tokio::test]
28575 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28576 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28577 std::fs::create_dir_all(root.join("src")).unwrap();
28578 #[cfg(windows)]
28579 let root = std::fs::canonicalize(root).unwrap();
28580 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28581 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28582 let barrier = Arc::new(std::sync::Barrier::new(2));
28583 let done = Arc::new(AtomicBool::new(false));
28584 let task_commit_state = Arc::clone(&commit_state);
28585 let task_barrier = Arc::clone(&barrier);
28586 let task_done = Arc::clone(&done);
28587 let task_root = root.clone();
28588 let task = tokio::task::spawn_blocking(move || {
28589 task_barrier.wait();
28590 let result = create_workspace_file_on_disk(
28591 &task_root,
28592 &path,
28593 &task_commit_state,
28594 );
28595 task_done.store(true, Ordering::Release);
28596 result
28597 });
28598
28599 let error = settle_workspace_entry_create(
28600 task,
28601 Arc::clone(&commit_state),
28602 Duration::from_millis(10),
28603 "fixture deadline",
28604 "fixture task failed",
28605 )
28606 .await
28607 .unwrap_err();
28608 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28609 assert_eq!(
28610 commit_state.load(Ordering::Acquire),
28611 WORKSPACE_ENTRY_CREATE_CANCELED,
28612 );
28613
28614 barrier.wait();
28615 timeout(Duration::from_secs(1), async {
28616 while !done.load(Ordering::Acquire) {
28617 tokio::task::yield_now().await;
28618 }
28619 })
28620 .await
28621 .expect("canceled blocking create did not settle");
28622 assert!(!root.join("src/late.rs").exists());
28623 std::fs::remove_dir_all(root).unwrap();
28624 }
28625}
28626
28627#[cfg(test)]
28628mod provisional_spawn_tests {
28629 use super::*;
28630 use std::sync::Arc;
28631
28632 fn shared() -> NodeShared {
28633 let catalog = active_registry().unwrap();
28634 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28635 let workspace = WorkspaceConfig::new(
28636 WorkspaceId::new("primary").unwrap(),
28637 std::env::current_dir().unwrap(),
28638 )
28639 .unwrap()
28640 .with_worktree_service_mode(WorktreeServiceMode::Manual);
28641 NodeShared::new(
28642 handle,
28643 "fixture-token".to_owned(),
28644 NodeId::new("node-provisional-test").unwrap(),
28645 vec![workspace],
28646 vec![AgentId::new("claude").unwrap()],
28647 )
28648 }
28649
28650 #[test]
28653 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28654 let shared = shared();
28655 let ws = WorkspaceId::new("primary").unwrap();
28656 let mut ids = Vec::with_capacity(50);
28657 for i in 1u64..=50 {
28658 let id = AgentInstanceId(i);
28659 ids.push(id);
28660 let address = SessionAddress {
28661 workspace_id: ws.clone(),
28662 session: SessionKey {
28663 instance_id: id,
28664 generation: SessionGeneration(1),
28665 },
28666 };
28667 shared.mark_spawn_provisional(address);
28668 }
28669 assert_eq!(
28670 shared
28671 .provisional_spawns
28672 .lock()
28673 .unwrap_or_else(|p| p.into_inner())
28674 .len(),
28675 50
28676 );
28677 for id in ids.iter().step_by(2) {
28678 shared.sessionize_spawn(*id);
28679 }
28680 assert_eq!(
28681 shared
28682 .provisional_spawns
28683 .lock()
28684 .unwrap_or_else(|p| p.into_inner())
28685 .len(),
28686 25
28687 );
28688 assert!(shared.take_due_provisional_spawns().is_empty());
28689 {
28690 let mut map = shared
28691 .provisional_spawns
28692 .lock()
28693 .unwrap_or_else(|p| p.into_inner());
28694 let past = Instant::now() - Duration::from_secs(91);
28695 for (_, (_, at)) in map.iter_mut() {
28696 *at = past;
28697 }
28698 }
28699 let due = shared.take_due_provisional_spawns();
28700 assert_eq!(due.len(), 25);
28701 let due_ids: std::collections::BTreeSet<_> =
28702 due.iter().map(|a| a.session.instance_id.0).collect();
28703 let expected: std::collections::BTreeSet<_> =
28704 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28705 assert_eq!(due_ids, expected);
28706 assert!(shared
28707 .provisional_spawns
28708 .lock()
28709 .unwrap_or_else(|p| p.into_inner())
28710 .is_empty());
28711 }
28712
28713 #[test]
28716 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28717 let shared = Arc::new(shared());
28718 let ws = WorkspaceId::new("primary").unwrap();
28719 let mut handles = Vec::new();
28720 for i in 1u64..=20 {
28721 let shared = Arc::clone(&shared);
28722 let ws = ws.clone();
28723 handles.push(std::thread::spawn(move || {
28724 let id = AgentInstanceId(i);
28725 let address = SessionAddress {
28726 workspace_id: ws,
28727 session: SessionKey {
28728 instance_id: id,
28729 generation: SessionGeneration(1),
28730 },
28731 };
28732 shared.mark_spawn_provisional(address);
28733 id
28734 }));
28735 }
28736 let mut ids = Vec::new();
28737 for h in handles {
28738 ids.push(h.join().unwrap());
28739 }
28740 ids.sort_by_key(|id| id.0);
28741 assert_eq!(ids.len(), 20);
28742 ids.dedup();
28743 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28744 assert_eq!(
28745 shared
28746 .provisional_spawns
28747 .lock()
28748 .unwrap_or_else(|p| p.into_inner())
28749 .len(),
28750 20
28751 );
28752 shared.sessionize_spawn(AgentInstanceId(7));
28753 let left: Vec<_> = shared
28754 .provisional_spawns
28755 .lock()
28756 .unwrap_or_else(|p| p.into_inner())
28757 .keys()
28758 .map(|id| id.0)
28759 .collect();
28760 assert!(!left.contains(&7));
28761 assert_eq!(left.len(), 19);
28762 }
28763}
28764
28765
28766#[cfg(unix)]
28767extern "C" {
28768 #[link_name = "geteuid"]
28769 fn libc_geteuid() -> u32;
28770}
28771
28772#[cfg(test)]
28773mod call_home_link_tests {
28774 use super::*;
28775
28776 #[test]
28777 fn call_home_backoff_is_capped_and_monotonic() {
28778 let mut last = Duration::ZERO;
28779 for attempt in 1..20 {
28780 let next = call_home_backoff(attempt);
28781 assert!(next >= last);
28782 assert!(next <= Duration::from_secs(8));
28783 last = next;
28784 }
28785 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28786 }
28787
28788 #[tokio::test]
28789 async fn call_home_socket_gets_keepalive() {
28790 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28791 let addr = listener.local_addr().unwrap();
28792 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28793 let client = client.unwrap();
28794 enable_call_home_keepalive(&client).unwrap();
28795 let sock = socket2::SockRef::from(&client);
28796 assert!(sock.keepalive().unwrap());
28797 #[cfg(any(target_os = "linux", target_os = "macos"))]
28798 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28799 }
28800}
28801
28802fn provider_session_exists(provider: &str, id: &str) -> bool {
28804 if provider != "claude" { return false; }
28805 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28806 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28807 let name = format!("{id}.jsonl");
28808 homes.iter().any(|h| std::fs::read_dir(h.join(".claude/projects")).map_or(false, |rd| rd.flatten().any(|d| d.path().join(&name).is_file())))
28809}
28810
28811fn overlay_with_extra_args(
28813 provider: &AgentId,
28814 mode: SessionMode,
28815 overlay: Option<PreparedNativeLaunchOverlay>,
28816 args: Vec<OsString>,
28817) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28818 let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28819 Ok(match (overlay, args) {
28820 (prepared, args) if args.is_empty() => prepared,
28821 (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28822 (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28823 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28824 provider.clone(),
28825 match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28826 Vec::new(),
28827 args,
28828 ).map_err(|_| bad())?)),
28829 })
28830}
28831
28832fn provider_error_act(workspace: &str, instance: u64, provider: &str, via: &str, act: crate::provider_error::Action) {
28834 use crate::provider_error::Action;
28835 match act {
28836 Action::Retry { attempt, delay } => tracing::info!(target: "provider_retry", instance_id = instance, provider, via, attempt, max = crate::provider_error::MAX_ATTEMPTS, delay_ms = delay.as_millis() as u64, "provider error: retry scheduled"),
28837 Action::Limit { found } => {
28838 tracing::info!(target: "provider_retry", instance_id = instance, provider, via, "provider error: limit, no retry");
28839 if via == "acp" {
28840 crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::Limit { hint: found.hint });
28841 }
28842 }
28843 Action::Escalate { found, attempts } => {
28844 tracing::warn!(target: "provider_retry", instance_id = instance, provider, via, class = found.class.as_str(), code = ?found.code, attempts, "provider error: escalated");
28845 crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::ProviderError { class: found.class.as_str(), code: found.code, attempts, hint: found.hint });
28846 }
28847 }
28848}
28849
28850fn acp_tool_evidence(address: &SessionAddress, event: &ControlEvent) {
28852 let id = address.session.instance_id.0;
28853 match &event.event {
28854 ControlEventKind::Exited { .. } | ControlEventKind::Removed => crate::tool_evidence::forget_open(address.workspace_id.as_str(), id),
28855 ControlEventKind::ProviderEvent { event: e, .. } if crate::auto_permit::acp_provider(id).is_some() => crate::tool_evidence::observe(address.workspace_id.as_str(), id, e),
28856 _ => {}
28857 }
28858}
28859
28860fn trace_acp_turn(address: &SessionAddress, event: &ControlEvent) {
28863 let id = address.session.instance_id.0;
28864 let ControlEventKind::ProviderEvent { event: e, .. } = &event.event else { return };
28865 match e {
28866 ProviderEvent::TurnStarted { prompt } => tracing::info!(instance_id = id, prompt_chars = prompt.as_deref().map(str::len).unwrap_or(0), "acp turn started"),
28867 ProviderEvent::ToolStarted { name, .. } => tracing::info!(instance_id = id, tool = %name, "acp tool call started"),
28868 ProviderEvent::ToolCompleted { is_error, duration_ms, non_execution_kind, .. } => tracing::info!(instance_id = id, is_error, duration_ms = ?duration_ms, not_run = ?non_execution_kind, "acp tool call finished"),
28869 ProviderEvent::InteractionRequested { interaction_kind, tool_name, options, .. } => tracing::info!(instance_id = id, kind = ?interaction_kind, tool = %tool_name, options = options.len(), "acp permission/interaction requested"),
28870 ProviderEvent::TurnInterrupted => tracing::info!(instance_id = id, "acp turn interrupted"),
28871 ProviderEvent::SessionEnded { is_error, stop_reason, .. } => tracing::info!(instance_id = id, is_error, stop_reason = ?stop_reason, "acp turn ended"),
28872 ProviderEvent::Error { message } => tracing::warn!(instance_id = id, error = %message.chars().take(200).collect::<String>(), "acp provider error"),
28873 _ => {}
28874 }
28875}
28876
28877
28878#[cfg(all(test, windows))]
28879mod codex_restart_tests {
28880 use super::*;
28881
28882 fn shared(root: &Path, fail_launch: bool) -> (NodeShared, NativeRuntime, EventSubscription) {
28883 shared_with_acp(root, fail_launch, "unsupported")
28884 }
28885
28886 fn shared_with_acp(root: &Path, fail_launch: bool, acp_outcome: &str) -> (NodeShared, NativeRuntime, EventSubscription) {
28887 let provider = AgentId::new("codex").unwrap();
28888 let mut spec = builtin_registry().get(&provider).unwrap().clone();
28889 spec.launch.program = if fail_launch { "missing-codex-resume-fixture.exe" } else { "powershell.exe" }.into();
28890 spec.launch.fixed_args = vec![
28891 "-NoProfile".into(), "-NonInteractive".into(), "-Command".into(),
28892 r#"& { param([Parameter(ValueFromRemainingArguments=$true)][string[]]$launchArgs) [IO.File]::WriteAllLines((Join-Path (Get-Location) 'resume-argv.txt'), $launchArgs); while (-not (Test-Path -LiteralPath (Join-Path (Get-Location) 'resume-stop'))) { Start-Sleep -Milliseconds 100 } }"#.into(),
28893 ];
28894 spec.capabilities.transports.acp.as_mut().unwrap().launch_override = Some(gate4agent_types::LaunchSpec {
28896 program: "python".into(),
28897 fixed_args: vec!["-u".into(), "-c".into(), r#"
28898import json, sys
28899mode = sys.argv[1]
28900load = mode != 'unsupported'
28901for line in sys.stdin:
28902 q = json.loads(line)
28903 method = q.get('method')
28904 with open('resume-acp.jsonl', 'a', encoding='utf-8') as log:
28905 log.write(json.dumps(q) + '\n')
28906 result = {}
28907 if method == 'initialize': result = {'protocolVersion': 1, 'agentCapabilities': {'loadSession': load}}
28908 elif method == 'session/new': result = {'sessionId': 'temporary-thread'}
28909 elif method == 'session/load':
28910 assert q['params']['sessionId'] == '12345678-1234-4234-8234-123456789abc'
28911 if mode == 'refused':
28912 print(json.dumps({'jsonrpc':'2.0','id':q['id'],'error':{'code':-32000,'message':'fixture load refused'}}), flush=True)
28913 continue
28914 result = {'sessionId': q['params']['sessionId']}
28915 if method in ('session/new', 'session/load'): result['modes'] = {'currentModeId':'agent-full-access','availableModes':[{'id':'agent-full-access','name':'Full access'}]}
28916 if 'id' in q: print(json.dumps({'jsonrpc':'2.0','id':q['id'],'result':result}), flush=True)
28917"#.into(), acp_outcome.into()],
28918 });
28919 let catalog = AgentRegistry::new([spec]).unwrap();
28920 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28921 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
28922 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), root).unwrap();
28923 let mut shared = NodeShared::new(handle, "fixture-token".into(),
28924 NodeId::new("codex-restart-test").unwrap(), vec![workspace], vec![provider.clone()]);
28925 shared.state_path = Some(root.join("state.json"));
28926 shared.native_launch_profile_control = Some(runtime.native_launch_profile_control());
28927 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
28928 crate::protocol::ProviderRuntimeStatus::raw_passthrough(provider, None),
28929 ]).unwrap();
28930 let loaded = session_registry::load(shared.state_path.as_deref(), &shared.node_id).unwrap();
28931 for record in loaded.records { shared.insert_record(record).unwrap(); }
28932 (shared, runtime, events)
28933 }
28934
28935 fn isolated_test(name: &str) -> bool {
28936 if std::env::var_os("HATCHERY_CODEX_RESTART_FIXTURE").is_some() { return false; }
28937 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
28938 command.env_clear();
28939 for name in ["SystemRoot", "PATH", "TEMP", "TMP", "COMSPEC"] {
28940 if let Some(value) = std::env::var_os(name) { command.env(name, value); }
28941 }
28942 let status = command.args(["--exact", name, "--nocapture"])
28943 .env("HATCHERY_AUTO_PERMIT", "0")
28944 .env("HATCHERY_CODEX_RESTART_FIXTURE", "1")
28945 .status().unwrap();
28946 assert!(status.success());
28947 true
28948 }
28949
28950 async fn restart_case(acp: bool, fail_launch: bool, acp_outcome: &str) {
28951 let load = acp_outcome == "load";
28952 assert!(!crate::auto_permit::enabled("codex"));
28954 let root = std::env::temp_dir().join(format!("codex-restart-{}-{}", std::process::id(), crate::pty_resume::new_session_id()));
28955 std::fs::create_dir_all(&root).unwrap();
28956 let (seed, _, _) = shared(&root, fail_launch);
28957 let identity = ProviderSessionIdentity {
28958 key: gate4agent_types::ProviderSessionKey::SessionId,
28959 id: "12345678-1234-4234-8234-123456789abc".into(), transcript_path: None,
28960 };
28961 let record = seed.index_provider_session(WorkspaceId::new("primary").unwrap(),
28962 AgentId::new("codex").unwrap(), identity.clone(), "codex #7".into()).unwrap();
28963 if acp {
28964 seed.session_records.lock().unwrap().records.get_mut(&record.record_id).unwrap().mode = SessionMode::Acp;
28965 }
28966 seed.persist_state().unwrap();
28967 let journal = crate::pty_resume::path(&root);
28968 crate::pty_resume::upsert(&journal, crate::pty_resume::Entry {
28969 instance_id: 7, provider: "codex".into(), workspace_id: "primary".into(),
28970 provider_session_id: identity.id.clone(), acp, cols: 96, rows: 32,
28971 options: Some(gate4agent_types::SessionOptionSelection::new("gpt-5.5").with_value("effort", "high")),
28972 session_key: "fixture-session-key".into(), origin: Some("agent".into()),
28973 parent_session_id: Some("codex#2".into()), ..Default::default()
28974 });
28975 drop(seed);
28976 for restart in 0..2 {
28977 let _ = std::fs::remove_file(root.join("resume-stop"));
28978 let _ = std::fs::remove_file(root.join("resume-acp.jsonl"));
28979 let (node, mut runtime, events) = shared_with_acp(&root, fail_launch, acp_outcome);
28980 node.reserve_journalled_instance_ids();
28981 assert_eq!(node.next_instance_id.load(Ordering::Acquire), 8);
28982 let node = Arc::new(node);
28983 let scenario = async {
28984 node.restore_journalled_sessions().await;
28985 let entries = crate::pty_resume::load(&journal);
28986 assert_eq!(entries.len(), 1);
28987 assert_eq!(entries[0].instance_id, 7);
28988 assert_eq!(entries[0].provider_session_id, identity.id);
28989 assert_eq!(entries[0].session_key, "fixture-session-key");
28990 assert_eq!(entries[0].origin.as_deref(), Some("agent"));
28991 assert_eq!(entries[0].parent_session_id.as_deref(), Some("codex#2"));
28992 let current = node.record(&record.record_id).unwrap();
28993 assert_eq!(current.provider_session.as_ref(), Some(&identity));
28994 if acp && acp_outcome == "refused" && restart == 0 {
28995 let requests = std::fs::read_to_string(root.join("resume-acp.jsonl")).unwrap();
28996 assert!(requests.lines().any(|line| {
28997 let request: serde_json::Value = serde_json::from_str(line).unwrap();
28998 request["method"] == "session/load"
28999 && request["params"]["sessionId"] == identity.id
29000 }), "the fallback must follow an actual load refusal");
29001 }
29002 if fail_launch {
29003 assert!(current.active_session.is_none());
29004 sleep(Duration::from_secs(6)).await;
29006 assert_eq!(crate::pty_resume::load(&journal).len(), 1);
29007 assert!(!root.join("resume-argv.txt").exists());
29008 } else {
29009 assert_eq!(current.mode, if load { SessionMode::Acp } else { SessionMode::Pty });
29010 let active = current.active_session.unwrap();
29011 assert_eq!(active.session.instance_id.0, 7);
29012 if acp && !load && restart == 0 {
29013 assert!(active.session.generation.0 > 1,
29014 "fallback must bind the re-registered engine generation");
29015 }
29016 assert_eq!((entries[0].cols, entries[0].rows), (96, 32));
29017 assert_eq!(entries[0].acp, load);
29018 if load {
29019 let requests = std::fs::read_to_string(root.join("resume-acp.jsonl")).unwrap();
29020 let requests: Vec<serde_json::Value> = requests.lines()
29021 .map(|line| serde_json::from_str(line).unwrap()).collect();
29022 let loads: Vec<_> = requests.iter()
29023 .filter(|request| request["method"] == "session/load").collect();
29024 assert_eq!(loads.len(), 1, "resume must load the saved thread exactly once");
29025 assert_eq!(loads[0]["params"]["sessionId"], identity.id);
29026 assert!(!root.join("resume-argv.txt").exists());
29027 }
29028 if !load {
29029 let deadline = Instant::now() + Duration::from_secs(10);
29030 while !root.join("resume-argv.txt").exists() {
29031 assert!(Instant::now() < deadline, "native resume child did not write argv");
29032 sleep(Duration::from_millis(20)).await;
29033 }
29034 let argv = std::fs::read_to_string(root.join("resume-argv.txt")).unwrap();
29035 let args: Vec<_> = argv.lines().collect();
29036 assert!(args.windows(2).any(|pair| pair == ["resume", identity.id.as_str()]), "{args:?}");
29037 assert!(args.windows(2).any(|pair| pair == ["--model", "gpt-5.5"]));
29038 assert!(args.windows(2).any(|pair| pair == ["-c", "model_reasoning_effort=high"]));
29040 std::fs::remove_file(root.join("resume-argv.txt")).unwrap();
29041 }
29042 }
29043 std::fs::write(root.join("resume-stop"), b"stop").unwrap();
29044 node.begin_shutdown().await.unwrap();
29045 };
29046 tokio::time::timeout(Duration::from_secs(30), async {
29047 let (_, result) = tokio::join!(scenario,
29048 drive_runtime_until_shutdown(&mut runtime, events, Arc::clone(&node),
29049 Duration::from_millis(NativeRuntimeConfig::default().provider_shutdown_timeout_ms)));
29050 result.unwrap();
29051 }).await.unwrap_or_else(|error| {
29052 let history = node.history.lock().unwrap();
29053 panic!("restart fixture timed out: {error}; events: {:?}", history.events);
29054 });
29055 }
29056 std::fs::remove_dir_all(root).unwrap();
29057 }
29058
29059 #[tokio::test]
29060 async fn codex_restart_without_identity_keeps_the_unresolved_entry() {
29061 let root = std::env::temp_dir().join(format!("codex-unresolved-{}", crate::pty_resume::new_session_id()));
29062 std::fs::create_dir_all(&root).unwrap();
29063 let (node, _runtime, _) = shared(&root, false);
29064 let journal = crate::pty_resume::path(&root);
29065 crate::pty_resume::upsert(&journal, crate::pty_resume::Entry {
29066 instance_id: 7, provider: "codex".into(), workspace_id: "primary".into(),
29067 acp: true, ..Default::default()
29068 });
29069 node.restore_journalled_sessions().await;
29070 assert_eq!(crate::pty_resume::load(&journal).len(), 1);
29071 assert!(node.session_bindings.lock().unwrap().is_empty());
29072 assert!(node.session_records.lock().unwrap().records.is_empty());
29073 std::fs::remove_dir_all(root).unwrap();
29074 }
29075
29076 #[test]
29077 fn codex_identity_event_is_journalled_before_the_refresh_tick() {
29078 let root = std::env::temp_dir().join(format!("codex-identity-{}", crate::pty_resume::new_session_id()));
29079 std::fs::create_dir_all(&root).unwrap();
29080 let (node, _runtime, _) = shared(&root, false);
29081 let address = SessionAddress {
29082 workspace_id: WorkspaceId::new("primary").unwrap(),
29083 session: SessionKey { instance_id: AgentInstanceId(7), generation: SessionGeneration(1) },
29084 };
29085 node.bind_spawn_session(&address, AgentId::new("codex").unwrap(), SessionMode::Pty,
29086 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
29087 SpawnRecordPolicy::ProviderIdentityOnly, None).unwrap();
29088 let journal = crate::pty_resume::path(&root);
29089 crate::pty_resume::upsert(&journal, crate::pty_resume::Entry {
29090 instance_id: 7, provider: "codex".into(), ..Default::default()
29091 });
29092 for id in ["first-thread", "current-thread"] {
29093 node.publish_control(ControlEvent {
29094 sequence: 1, command_id: None, instance_id: AgentInstanceId(7), generation: SessionGeneration(1),
29095 event: ControlEventKind::ProviderEvent {
29096 sequence: 1,
29097 source: gate4agent_types::ProviderSource {
29098 family: AdapterFamily::PtySemantic,
29099 binding: AdapterBinding::new(
29100 gate4agent_types::AdapterId::new("codex").unwrap(),
29101 "fixture/v1", gate4agent_types::AdapterVerification::SyntheticFixture,
29102 ).unwrap(),
29103 },
29104 source_sequence: 1,
29105 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
29106 identity: ProviderSessionIdentity {
29107 key: gate4agent_types::ProviderSessionKey::SessionId,
29108 id: id.into(), transcript_path: None,
29109 },
29110 },
29111 },
29112 });
29113 assert_eq!(crate::pty_resume::load(&journal)[0].provider_session_id, id);
29114 }
29115 std::fs::remove_dir_all(root).unwrap();
29116 }
29117
29118 #[tokio::test]
29119 async fn codex_pty_restart_preserves_conversation_and_slot_twice() {
29120 if isolated_test("server::codex_restart_tests::codex_pty_restart_preserves_conversation_and_slot_twice") { return; }
29121 restart_case(false, false, "unsupported").await;
29122 }
29123
29124 #[tokio::test]
29125 async fn codex_acp_restart_resumes_same_thread_via_native_pty_twice() {
29126 if isolated_test("server::codex_restart_tests::codex_acp_restart_resumes_same_thread_via_native_pty_twice") { return; }
29127 restart_case(true, false, "unsupported").await;
29128 }
29129
29130 #[tokio::test]
29131 async fn codex_restart_failure_keeps_exact_journal_for_retry() {
29132 if isolated_test("server::codex_restart_tests::codex_restart_failure_keeps_exact_journal_for_retry") { return; }
29133 restart_case(true, true, "refused").await;
29134 }
29135 #[tokio::test]
29136 async fn codex_acp_restart_loads_same_thread_and_slot_twice() {
29137 if isolated_test("server::codex_restart_tests::codex_acp_restart_loads_same_thread_and_slot_twice") { return; }
29138 restart_case(true, false, "load").await;
29139 }
29140
29141 #[tokio::test]
29142 async fn codex_acp_load_refusal_falls_back_to_same_pty_thread() {
29143 if isolated_test("server::codex_restart_tests::codex_acp_load_refusal_falls_back_to_same_pty_thread") { return; }
29144 restart_case(true, false, "refused").await;
29145 }
29146
29147}