1use crate::agent_api::{Agent, SessionOptions};
8use crate::agent_protocol::{
9 AgentProtocolChangeSetRequestV1, AgentProtocolChangeSetV1, AgentProtocolCommandReceiptV1,
10 AgentProtocolCommandV1, AgentProtocolError, AgentProtocolEventPageRequestV1,
11 AgentProtocolEventPageV1, AgentProtocolRunIdentityV1, AgentProtocolRunRecoverExactV1,
12};
13use crate::agent_protocol_host::{
14 AgentProtocolExactRecoveryError, AgentProtocolHost, AgentProtocolHostError,
15};
16use crate::error::CodeError;
17use crate::release::{
18 agent_harness_compatibility_v1, AgentReleaseError, AgentReleaseManifest, AGENT_PROTOCOL_V1,
19};
20use crate::session_checkpoint::{SessionCheckpointError, SessionCheckpointExportV1};
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::Arc;
25use thiserror::Error;
26use tokio::sync::{Mutex, RwLock};
27
28pub const AGENT_PROTOCOL_HARNESS_MAX_SESSIONS: usize = 1_024;
30
31#[derive(Debug, Error)]
33pub enum AgentProtocolHarnessError {
34 #[error(transparent)]
35 Protocol(#[from] AgentProtocolError),
36 #[error(transparent)]
37 Release(#[from] AgentReleaseError),
38 #[error(transparent)]
39 Host(#[from] AgentProtocolHostError),
40 #[error(transparent)]
41 Code(#[from] CodeError),
42 #[error("A3S Code Harness session was not found")]
43 SessionNotFound,
44 #[error("A3S Code Harness session capacity is exhausted")]
45 SessionCapacity,
46 #[error("A3S Code Harness is draining or stopped")]
47 Closed,
48 #[error("A3S Code Harness workspace isolation failed: {0}")]
49 Workspace(String),
50}
51
52impl AgentProtocolHarnessError {
53 pub const fn code(&self) -> &'static str {
54 match self {
55 Self::Protocol(error) => error.code(),
56 Self::Release(error) => error.code(),
57 Self::Host(error) => error.code(),
58 Self::Code(error) => error.code(),
59 Self::SessionNotFound => "a3s.code.agent_protocol.session_not_found",
60 Self::SessionCapacity => "a3s.code.agent_protocol.session_capacity",
61 Self::Closed => "a3s.code.agent_protocol.harness_closed",
62 Self::Workspace(_) => "a3s.code.agent_protocol.workspace_isolation",
63 }
64 }
65}
66
67#[derive(Debug, Error)]
70pub enum AgentProtocolCheckpointRecoveryError {
71 #[error(transparent)]
72 Harness(#[from] AgentProtocolHarnessError),
73 #[error(transparent)]
74 Exact(#[from] AgentProtocolExactRecoveryError),
75 #[error(transparent)]
76 Checkpoint(#[from] SessionCheckpointError),
77 #[error("A3S Code Harness session is already active without the exact target Run")]
78 SessionAlreadyActive,
79}
80
81impl AgentProtocolCheckpointRecoveryError {
82 pub const fn code(&self) -> &'static str {
83 match self {
84 Self::Harness(error) => error.code(),
85 Self::Exact(error) => error.code(),
86 Self::Checkpoint(error) => error.code(),
87 Self::SessionAlreadyActive => {
88 "a3s.code.agent_protocol.checkpoint_session_already_active"
89 }
90 }
91 }
92}
93
94struct HarnessSessionEntry {
95 host: Arc<AgentProtocolHost>,
96 _workspace: HarnessSessionWorkspace,
97}
98
99enum HarnessSessionWorkspace {
100 Shared(PathBuf),
101 Isolated {
102 source: PathBuf,
103 path: PathBuf,
104 _temporary_root: tempfile::TempDir,
105 },
106}
107
108impl HarnessSessionWorkspace {
109 async fn prepare(source: PathBuf) -> Result<Self, AgentProtocolHarnessError> {
110 tokio::task::spawn_blocking(move || {
111 if !crate::git::is_git_repo(&source) {
112 return Ok(Self::Shared(source));
113 }
114 let temporary_root = tempfile::Builder::new()
115 .prefix("a3s-code-harness-session-")
116 .tempdir()
117 .map_err(|error| AgentProtocolHarnessError::Workspace(error.to_string()))?;
118 let path = temporary_root.path().join("workspace");
119 crate::git::create_isolated_worktree(&source, &path)
120 .map_err(|error| AgentProtocolHarnessError::Workspace(error.to_string()))?;
121 Ok(Self::Isolated {
122 source,
123 path,
124 _temporary_root: temporary_root,
125 })
126 })
127 .await
128 .map_err(|error| AgentProtocolHarnessError::Workspace(error.to_string()))?
129 }
130
131 fn path(&self) -> &Path {
132 match self {
133 Self::Shared(path) | Self::Isolated { path, .. } => path,
134 }
135 }
136}
137
138impl Drop for HarnessSessionWorkspace {
139 fn drop(&mut self) {
140 if let Self::Isolated { source, path, .. } = self {
141 if let Err(error) = crate::git::remove_isolated_worktree(source, path) {
142 tracing::warn!(%error, workspace = %path.display(), "could not remove Agent Harness session worktree");
143 }
144 }
145 }
146}
147
148pub struct AgentProtocolHarness {
156 manifest: Arc<AgentReleaseManifest>,
157 agent: Arc<Agent>,
158 workspace: String,
159 session_options: SessionOptions,
160 max_sessions: usize,
161 sessions: RwLock<HashMap<String, Arc<HarnessSessionEntry>>>,
162 admission: Mutex<()>,
163 closed: AtomicBool,
164}
165
166impl std::fmt::Debug for AgentProtocolHarness {
167 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 formatter
169 .debug_struct("AgentProtocolHarness")
170 .field("agent_release_identity", &self.manifest.artifact().digest())
171 .field("manifest_identity", &self.manifest.identity())
172 .field("workspace", &self.workspace)
173 .field("max_sessions", &self.max_sessions)
174 .field("closed", &self.closed.load(Ordering::Acquire))
175 .finish_non_exhaustive()
176 }
177}
178
179impl AgentProtocolHarness {
180 pub fn new(
182 manifest: AgentReleaseManifest,
183 agent: Arc<Agent>,
184 workspace: impl Into<String>,
185 ) -> Result<Self, AgentProtocolHarnessError> {
186 manifest.verify_compatibility(&agent_harness_compatibility_v1())?;
187 if manifest.protocol() != AGENT_PROTOCOL_V1 {
188 return Err(AgentProtocolHostError::ReleaseProtocolMismatch.into());
189 }
190 Ok(Self {
191 manifest: Arc::new(manifest),
192 agent,
193 workspace: workspace.into(),
194 session_options: SessionOptions::new(),
195 max_sessions: AGENT_PROTOCOL_HARNESS_MAX_SESSIONS,
196 sessions: RwLock::new(HashMap::new()),
197 admission: Mutex::new(()),
198 closed: AtomicBool::new(false),
199 })
200 }
201
202 pub fn with_session_options(mut self, options: SessionOptions) -> Self {
207 self.session_options = options;
208 self.session_options.session_id = None;
209 self.session_options.auto_save = true;
210 self
211 }
212
213 pub fn with_max_sessions(
215 mut self,
216 max_sessions: usize,
217 ) -> Result<Self, AgentProtocolHarnessError> {
218 if max_sessions == 0 {
219 return Err(AgentProtocolHarnessError::SessionCapacity);
220 }
221 self.max_sessions = max_sessions;
222 Ok(self)
223 }
224
225 pub fn manifest(&self) -> &AgentReleaseManifest {
226 &self.manifest
227 }
228
229 pub fn agent_release_identity(&self) -> &str {
230 self.manifest.artifact().digest()
231 }
232
233 pub fn max_sessions(&self) -> usize {
234 self.max_sessions
235 }
236
237 pub fn is_closed(&self) -> bool {
238 self.closed.load(Ordering::Acquire)
239 }
240
241 pub async fn session_count(&self) -> usize {
242 self.sessions.read().await.len()
243 }
244
245 pub async fn execute(
247 &self,
248 command: &AgentProtocolCommandV1,
249 ) -> Result<AgentProtocolCommandReceiptV1, AgentProtocolHarnessError> {
250 command.validate()?;
251 let create_if_missing = matches!(
252 command,
253 AgentProtocolCommandV1::Start { .. } | AgentProtocolCommandV1::Recover { .. }
254 );
255 let host = self.host_for(command.identity(), create_if_missing).await?;
256 host.execute(command).await.map_err(Into::into)
257 }
258
259 pub async fn execute_checkpoint_recovery(
269 &self,
270 request: &AgentProtocolRunRecoverExactV1,
271 checkpoint: SessionCheckpointExportV1,
272 ) -> Result<AgentProtocolCommandReceiptV1, AgentProtocolCheckpointRecoveryError> {
273 self.execute_checkpoint_recovery_inner(request, checkpoint, None)
274 .await
275 }
276
277 pub async fn execute_checkpoint_recovery_with_capability_batch(
284 &self,
285 request: &AgentProtocolRunRecoverExactV1,
286 checkpoint: SessionCheckpointExportV1,
287 capability_batch: crate::capability::SessionCapabilityBatch,
288 ) -> Result<AgentProtocolCommandReceiptV1, AgentProtocolCheckpointRecoveryError> {
289 self.execute_checkpoint_recovery_inner(request, checkpoint, Some(capability_batch))
290 .await
291 }
292
293 async fn execute_checkpoint_recovery_inner(
294 &self,
295 request: &AgentProtocolRunRecoverExactV1,
296 checkpoint: SessionCheckpointExportV1,
297 mut capability_batch: Option<crate::capability::SessionCapabilityBatch>,
298 ) -> Result<AgentProtocolCommandReceiptV1, AgentProtocolCheckpointRecoveryError> {
299 request
300 .validate()
301 .map_err(AgentProtocolHarnessError::from)?;
302 if request.identity.agent_release_identity != self.manifest.artifact().digest() {
303 return Err(
304 AgentProtocolHarnessError::from(AgentProtocolHostError::ReleaseMismatch).into(),
305 );
306 }
307 if request.checkpoint != *checkpoint.descriptor() {
308 return Err(SessionCheckpointError::ContentDrift(
309 "recovery request descriptor does not match the supplied portable checkpoint"
310 .into(),
311 )
312 .into());
313 }
314 let payload = checkpoint.into_open()?;
315 let (mut snapshot, logical_resume) = payload.into_parts();
316 let logical_resume = logical_resume.ok_or_else(|| {
317 SessionCheckpointError::InvalidPayload(
318 "exact recovery requires a logical-resume component".into(),
319 )
320 })?;
321
322 let _admission = self.admission.lock().await;
323 if self.is_closed() {
324 return Err(AgentProtocolHarnessError::Closed.into());
325 }
326 if let Some(host) = self
327 .sessions
328 .read()
329 .await
330 .get(&request.identity.session_id)
331 .map(|entry| Arc::clone(&entry.host))
332 {
333 if capability_batch.is_some() {
334 return Err(AgentProtocolCheckpointRecoveryError::SessionAlreadyActive);
335 }
336 if host
337 .session()
338 .run_snapshot(&request.identity.run_id)
339 .await
340 .is_none()
341 {
342 return Err(AgentProtocolCheckpointRecoveryError::SessionAlreadyActive);
343 }
344 return host
345 .execute_exact_recovery_from_checkpoint(request, logical_resume)
346 .await
347 .map_err(Into::into);
348 }
349 if self.sessions.read().await.len() >= self.max_sessions {
350 return Err(AgentProtocolHarnessError::SessionCapacity.into());
351 }
352
353 let options = self
354 .session_options
355 .clone()
356 .with_session_id(&request.identity.session_id)
357 .with_auto_save(true);
358 if let Some(persisted) = self
359 .agent
360 .load_protocol_session_snapshot_async(&request.identity.session_id, &options)
361 .await
362 .map_err(AgentProtocolHarnessError::from)?
363 {
364 let target_already_persisted = persisted
365 .run_records
366 .iter()
367 .any(|record| record.snapshot.id == request.identity.run_id);
368 if target_already_persisted {
369 snapshot = persisted;
370 } else {
371 request.checkpoint.snapshot.validate_for(&persisted)?;
372 }
373 }
374
375 let workspace = HarnessSessionWorkspace::prepare(PathBuf::from(&self.workspace)).await?;
376 let session = self
377 .agent
378 .restore_protocol_checkpoint_session_async(
379 snapshot,
380 workspace.path().to_string_lossy().into_owned(),
381 options,
382 )
383 .await
384 .map_err(AgentProtocolHarnessError::from)?;
385 match (&logical_resume.capability_binding, capability_batch.take()) {
386 (Some(expected), batch) => match session.ensure_recovery_capability_binding(expected) {
387 Ok(()) if batch.is_none() => {}
388 Ok(()) => {
389 session.close().await;
390 return Err(SessionCheckpointError::InvalidPayload(
391 "a recovery capability batch was supplied even though the restored Session already matches the checkpoint"
392 .into(),
393 )
394 .into());
395 }
396 Err(crate::capability::RunCapabilityBindingError::ContentDrift { .. }) => {
397 let Some(batch) = batch else {
398 session.close().await;
399 return Err(SessionCheckpointError::ContentDrift(
400 "the portable checkpoint requires a scoped capability generation that was not reconstructed by the host"
401 .into(),
402 )
403 .into());
404 };
405 if let Err(error) = session
406 .bootstrap_recovery_capability_batch(
407 expected,
408 batch,
409 tokio_util::sync::CancellationToken::new(),
410 )
411 .await
412 {
413 session.close().await;
414 return Err(AgentProtocolHarnessError::Code(error.into()).into());
415 }
416 }
417 Err(error) => {
418 session.close().await;
419 return Err(SessionCheckpointError::InvalidPayload(format!(
420 "the portable checkpoint capability binding is invalid: {error}"
421 ))
422 .into());
423 }
424 },
425 (None, Some(_)) => {
426 session.close().await;
427 return Err(SessionCheckpointError::InvalidPayload(
428 "a recovery capability batch cannot accompany a legacy unbound checkpoint"
429 .into(),
430 )
431 .into());
432 }
433 (None, None) => {}
434 }
435 let session = Arc::new(session);
436 let host = match AgentProtocolHost::from_manifest(&self.manifest, Arc::clone(&session)) {
437 Ok(host) => Arc::new(host),
438 Err(error) => {
439 session.close().await;
440 return Err(AgentProtocolHarnessError::from(error).into());
441 }
442 };
443 let receipt = match host
444 .execute_exact_recovery_from_checkpoint(request, logical_resume)
445 .await
446 {
447 Ok(receipt) => receipt,
448 Err(error) => {
449 host.session().close().await;
450 return Err(error.into());
451 }
452 };
453 if self.is_closed() {
454 host.session().close().await;
455 return Err(AgentProtocolHarnessError::Closed.into());
456 }
457 self.sessions.write().await.insert(
458 request.identity.session_id.clone(),
459 Arc::new(HarnessSessionEntry {
460 host,
461 _workspace: workspace,
462 }),
463 );
464 Ok(receipt)
465 }
466
467 pub async fn event_page(
469 &self,
470 request: &AgentProtocolEventPageRequestV1,
471 ) -> Result<AgentProtocolEventPageV1, AgentProtocolHarnessError> {
472 request.validate()?;
473 let host = self.host_for(&request.identity, false).await?;
474 host.event_page_for(request).await.map_err(Into::into)
475 }
476
477 pub async fn change_set(
479 &self,
480 request: &AgentProtocolChangeSetRequestV1,
481 ) -> Result<AgentProtocolChangeSetV1, AgentProtocolHarnessError> {
482 request.validate()?;
483 let host = self.host_for(&request.identity, false).await?;
484 host.change_set_for(request).await.map_err(Into::into)
485 }
486
487 pub async fn close(&self) {
489 if self.closed.swap(true, Ordering::AcqRel) {
490 return;
491 }
492 let _admission = self.admission.lock().await;
493 self.agent.close().await;
494 self.sessions.write().await.clear();
495 }
496
497 async fn host_for(
498 &self,
499 identity: &AgentProtocolRunIdentityV1,
500 create_if_missing: bool,
501 ) -> Result<Arc<AgentProtocolHost>, AgentProtocolHarnessError> {
502 identity.validate()?;
503 if identity.agent_release_identity != self.manifest.artifact().digest() {
504 return Err(AgentProtocolHostError::ReleaseMismatch.into());
505 }
506 if self.is_closed() {
507 return Err(AgentProtocolHarnessError::Closed);
508 }
509 if let Some(host) = self
510 .sessions
511 .read()
512 .await
513 .get(&identity.session_id)
514 .map(|entry| Arc::clone(&entry.host))
515 {
516 return Ok(host);
517 }
518
519 let _admission = self.admission.lock().await;
520 if self.is_closed() {
521 return Err(AgentProtocolHarnessError::Closed);
522 }
523 if let Some(host) = self
524 .sessions
525 .read()
526 .await
527 .get(&identity.session_id)
528 .map(|entry| Arc::clone(&entry.host))
529 {
530 return Ok(host);
531 }
532 if self.sessions.read().await.len() >= self.max_sessions {
533 return Err(AgentProtocolHarnessError::SessionCapacity);
534 }
535
536 let workspace = HarnessSessionWorkspace::prepare(PathBuf::from(&self.workspace)).await?;
537 let options = self
538 .session_options
539 .clone()
540 .with_session_id(&identity.session_id)
541 .with_auto_save(true);
542 let session = self
543 .agent
544 .open_protocol_session_async(
545 workspace.path().to_string_lossy().into_owned(),
546 options,
547 create_if_missing,
548 )
549 .await?
550 .ok_or(AgentProtocolHarnessError::SessionNotFound)?;
551 let host = Arc::new(AgentProtocolHost::from_manifest(
552 &self.manifest,
553 Arc::new(session),
554 )?);
555 self.sessions.write().await.insert(
556 identity.session_id.clone(),
557 Arc::new(HarnessSessionEntry {
558 host: Arc::clone(&host),
559 _workspace: workspace,
560 }),
561 );
562 Ok(host)
563 }
564}