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::{agent_harness_compatibility_v1, AgentReleaseError, AgentReleaseManifest};
18use crate::session_checkpoint::{SessionCheckpointError, SessionCheckpointExportV1};
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::Arc;
23use thiserror::Error;
24use tokio::sync::{Mutex, RwLock};
25
26pub const AGENT_PROTOCOL_HARNESS_MAX_SESSIONS: usize = 1_024;
28
29static TEST_FORCE_INVALID_RECOVERY_BINDING: AtomicBool = AtomicBool::new(false);
32
33pub fn force_invalid_recovery_binding_for_test() {
35 TEST_FORCE_INVALID_RECOVERY_BINDING.store(true, Ordering::SeqCst);
36}
37
38#[derive(Debug, Error)]
40pub enum AgentProtocolHarnessError {
41 #[error(transparent)]
42 Protocol(#[from] AgentProtocolError),
43 #[error(transparent)]
44 Release(#[from] AgentReleaseError),
45 #[error(transparent)]
46 Host(#[from] AgentProtocolHostError),
47 #[error(transparent)]
48 Code(#[from] CodeError),
49 #[error("A3S Code Harness session was not found")]
50 SessionNotFound,
51 #[error("A3S Code Harness session capacity is exhausted")]
52 SessionCapacity,
53 #[error("A3S Code Harness is draining or stopped")]
54 Closed,
55 #[error("A3S Code Harness workspace isolation failed: {0}")]
56 Workspace(String),
57}
58
59impl AgentProtocolHarnessError {
60 pub const fn code(&self) -> &'static str {
61 match self {
62 Self::Protocol(error) => error.code(),
63 Self::Release(error) => error.code(),
64 Self::Host(error) => error.code(),
65 Self::Code(error) => error.code(),
66 Self::SessionNotFound => "a3s.code.agent_protocol.session_not_found",
67 Self::SessionCapacity => "a3s.code.agent_protocol.session_capacity",
68 Self::Closed => "a3s.code.agent_protocol.harness_closed",
69 Self::Workspace(_) => "a3s.code.agent_protocol.workspace_isolation",
70 }
71 }
72}
73
74#[derive(Debug, Error)]
77pub enum AgentProtocolCheckpointRecoveryError {
78 #[error(transparent)]
79 Harness(#[from] AgentProtocolHarnessError),
80 #[error(transparent)]
81 Exact(#[from] AgentProtocolExactRecoveryError),
82 #[error(transparent)]
83 Checkpoint(#[from] SessionCheckpointError),
84 #[error("A3S Code Harness session is already active without the exact target Run")]
85 SessionAlreadyActive,
86}
87
88impl AgentProtocolCheckpointRecoveryError {
89 pub const fn code(&self) -> &'static str {
90 match self {
91 Self::Harness(error) => error.code(),
92 Self::Exact(error) => error.code(),
93 Self::Checkpoint(error) => error.code(),
94 Self::SessionAlreadyActive => {
95 "a3s.code.agent_protocol.checkpoint_session_already_active"
96 }
97 }
98 }
99}
100
101struct HarnessSessionEntry {
102 host: Arc<AgentProtocolHost>,
103 _workspace: HarnessSessionWorkspace,
104}
105
106enum HarnessSessionWorkspace {
107 Shared(PathBuf),
108 Isolated {
109 source: PathBuf,
110 path: PathBuf,
111 _temporary_root: tempfile::TempDir,
112 },
113}
114
115impl HarnessSessionWorkspace {
116 async fn prepare(source: PathBuf) -> Result<Self, AgentProtocolHarnessError> {
117 tokio::task::spawn_blocking(move || {
118 if !crate::git::is_git_repo(&source) {
119 return Ok(Self::Shared(source));
120 }
121 let temporary_root = tempfile::Builder::new()
122 .prefix("a3s-code-harness-session-")
123 .tempdir()
124 .map_err(|error| AgentProtocolHarnessError::Workspace(error.to_string()))?;
125 let path = temporary_root.path().join("workspace");
126 crate::git::create_isolated_worktree(&source, &path)
127 .map_err(|error| AgentProtocolHarnessError::Workspace(error.to_string()))?;
128 Ok(Self::Isolated {
129 source,
130 path,
131 _temporary_root: temporary_root,
132 })
133 })
134 .await
135 .map_err(|error| AgentProtocolHarnessError::Workspace(error.to_string()))?
136 }
137
138 fn path(&self) -> &Path {
139 match self {
140 Self::Shared(path) | Self::Isolated { path, .. } => path,
141 }
142 }
143}
144
145impl Drop for HarnessSessionWorkspace {
146 fn drop(&mut self) {
147 if let Self::Isolated { source, path, .. } = self {
148 if let Err(error) = crate::git::remove_isolated_worktree(source, path) {
149 tracing::warn!(%error, workspace = %path.display(), "could not remove Agent Harness session worktree");
150 }
151 }
152 }
153}
154
155pub struct AgentProtocolHarness {
163 manifest: Arc<AgentReleaseManifest>,
164 agent: Arc<Agent>,
165 workspace: String,
166 session_options: SessionOptions,
167 max_sessions: usize,
168 sessions: RwLock<HashMap<String, Arc<HarnessSessionEntry>>>,
169 admission: Mutex<()>,
170 closed: AtomicBool,
171}
172
173impl std::fmt::Debug for AgentProtocolHarness {
174 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 formatter
176 .debug_struct("AgentProtocolHarness")
177 .field("agent_release_identity", &self.manifest.artifact().digest())
178 .field("manifest_identity", &self.manifest.identity())
179 .field("workspace", &self.workspace)
180 .field("max_sessions", &self.max_sessions)
181 .field("closed", &self.closed.load(Ordering::Acquire))
182 .finish_non_exhaustive()
183 }
184}
185
186impl AgentProtocolHarness {
187 pub fn new(
189 manifest: AgentReleaseManifest,
190 agent: Arc<Agent>,
191 workspace: impl Into<String>,
192 ) -> Result<Self, AgentProtocolHarnessError> {
193 manifest.verify_compatibility(&agent_harness_compatibility_v1())?;
196 let workspace = workspace.into();
197 if workspace.trim().is_empty() {
198 return Err(AgentProtocolHarnessError::Workspace(
199 "workspace path must not be empty".into(),
200 ));
201 }
202 Ok(Self {
203 manifest: Arc::new(manifest),
204 agent,
205 workspace,
206 session_options: SessionOptions::new(),
207 max_sessions: AGENT_PROTOCOL_HARNESS_MAX_SESSIONS,
208 sessions: RwLock::new(HashMap::new()),
209 admission: Mutex::new(()),
210 closed: AtomicBool::new(false),
211 })
212 }
213
214 pub fn with_session_options(mut self, options: SessionOptions) -> Self {
219 self.session_options = options;
220 self.session_options.session_id = None;
221 self.session_options.auto_save = true;
222 self
223 }
224
225 pub fn with_max_sessions(
227 mut self,
228 max_sessions: usize,
229 ) -> Result<Self, AgentProtocolHarnessError> {
230 if max_sessions == 0 {
231 return Err(AgentProtocolHarnessError::SessionCapacity);
232 }
233 self.max_sessions = max_sessions;
234 Ok(self)
235 }
236
237 pub fn manifest(&self) -> &AgentReleaseManifest {
238 &self.manifest
239 }
240
241 pub fn agent_release_identity(&self) -> &str {
242 self.manifest.artifact().digest()
243 }
244
245 pub fn max_sessions(&self) -> usize {
246 self.max_sessions
247 }
248
249 pub fn is_closed(&self) -> bool {
250 self.closed.load(Ordering::Acquire)
251 }
252
253 pub async fn session_count(&self) -> usize {
254 self.sessions.read().await.len()
255 }
256
257 pub async fn execute(
259 &self,
260 command: &AgentProtocolCommandV1,
261 ) -> Result<AgentProtocolCommandReceiptV1, AgentProtocolHarnessError> {
262 command.validate()?;
263 let create_if_missing = matches!(command, AgentProtocolCommandV1::Start { .. });
269 let host = self.host_for(command.identity(), create_if_missing).await?;
270 match host.execute(command).await {
272 Ok(receipt) => Ok(receipt),
273 Err(error) => Err(map_host_execute_error(error, self.is_closed())),
274 }
275 }
276
277 pub async fn execute_checkpoint_recovery(
287 &self,
288 request: &AgentProtocolRunRecoverExactV1,
289 checkpoint: SessionCheckpointExportV1,
290 ) -> Result<AgentProtocolCommandReceiptV1, AgentProtocolCheckpointRecoveryError> {
291 self.execute_checkpoint_recovery_inner(request, checkpoint, None)
292 .await
293 }
294
295 pub async fn execute_checkpoint_recovery_with_capability_batch(
302 &self,
303 request: &AgentProtocolRunRecoverExactV1,
304 checkpoint: SessionCheckpointExportV1,
305 capability_batch: crate::capability::SessionCapabilityBatch,
306 ) -> Result<AgentProtocolCommandReceiptV1, AgentProtocolCheckpointRecoveryError> {
307 self.execute_checkpoint_recovery_inner(request, checkpoint, Some(capability_batch))
308 .await
309 }
310
311 async fn execute_checkpoint_recovery_inner(
312 &self,
313 request: &AgentProtocolRunRecoverExactV1,
314 checkpoint: SessionCheckpointExportV1,
315 mut capability_batch: Option<crate::capability::SessionCapabilityBatch>,
316 ) -> Result<AgentProtocolCommandReceiptV1, AgentProtocolCheckpointRecoveryError> {
317 request
318 .validate()
319 .map_err(AgentProtocolHarnessError::from)?;
320 if request.identity.agent_release_identity != self.manifest.artifact().digest() {
321 return Err(
322 AgentProtocolHarnessError::from(AgentProtocolHostError::ReleaseMismatch).into(),
323 );
324 }
325 if request.checkpoint != *checkpoint.descriptor() {
326 return Err(SessionCheckpointError::ContentDrift(
327 "recovery request descriptor does not match the supplied portable checkpoint"
328 .into(),
329 )
330 .into());
331 }
332 let payload = checkpoint.into_open()?;
333 let (mut snapshot, logical_resume) = payload.into_exact_recovery_parts()?;
334
335 let _admission = self.admission.lock().await;
336 if self.is_closed() {
337 return Err(AgentProtocolHarnessError::Closed.into());
338 }
339 if let Some(host) = self
340 .sessions
341 .read()
342 .await
343 .get(&request.identity.session_id)
344 .map(|entry| Arc::clone(&entry.host))
345 {
346 if capability_batch.is_some() {
347 return Err(AgentProtocolCheckpointRecoveryError::SessionAlreadyActive);
348 }
349 if host
350 .session()
351 .run_snapshot(&request.identity.run_id)
352 .await
353 .is_none()
354 {
355 return Err(AgentProtocolCheckpointRecoveryError::SessionAlreadyActive);
356 }
357 return host
358 .execute_exact_recovery_from_checkpoint(request, logical_resume)
359 .await
360 .map_err(Into::into);
361 }
362 if self.sessions.read().await.len() >= self.max_sessions {
363 return Err(AgentProtocolHarnessError::SessionCapacity.into());
364 }
365
366 let options = self
367 .session_options
368 .clone()
369 .with_session_id(&request.identity.session_id)
370 .with_auto_save(true);
371 if let Some(persisted) = self
372 .agent
373 .load_protocol_session_snapshot_async(&request.identity.session_id, &options)
374 .await
375 .map_err(AgentProtocolHarnessError::from)?
376 {
377 let target_already_persisted = persisted
378 .run_records
379 .iter()
380 .any(|record| record.snapshot.id == request.identity.run_id);
381 if target_already_persisted {
382 snapshot = persisted;
383 } else {
384 request.checkpoint.snapshot.validate_for(&persisted)?;
385 }
386 }
387
388 let workspace = HarnessSessionWorkspace::prepare(PathBuf::from(&self.workspace)).await?;
389 let session = self
390 .agent
391 .restore_protocol_checkpoint_session_async(
392 snapshot,
393 workspace.path().to_string_lossy().into_owned(),
394 options,
395 )
396 .await
397 .map_err(AgentProtocolHarnessError::from)?;
398 match (&logical_resume.capability_binding, capability_batch.take()) {
399 (Some(expected), batch) => {
400 let binding_result =
401 if TEST_FORCE_INVALID_RECOVERY_BINDING.swap(false, Ordering::SeqCst) {
402 Err(crate::capability::RunCapabilityBindingError::InvalidField {
403 field: "schema",
404 message: "forced invalid recovery binding".into(),
405 })
406 } else {
407 session.ensure_recovery_capability_binding(expected)
408 };
409 match binding_result {
410 Ok(()) if batch.is_none() => {}
411 Ok(()) => {
412 session.close().await;
413 return Err(SessionCheckpointError::InvalidPayload(
414 "a recovery capability batch was supplied even though the restored Session already matches the checkpoint"
415 .into(),
416 )
417 .into());
418 }
419 Err(crate::capability::RunCapabilityBindingError::ContentDrift { .. }) => {
420 let Some(batch) = batch else {
421 session.close().await;
422 return Err(SessionCheckpointError::ContentDrift(
423 "the portable checkpoint requires a scoped capability generation that was not reconstructed by the host"
424 .into(),
425 )
426 .into());
427 };
428 if let Err(error) = session
429 .bootstrap_recovery_capability_batch(
430 expected,
431 batch,
432 tokio_util::sync::CancellationToken::new(),
433 )
434 .await
435 {
436 session.close().await;
437 return Err(AgentProtocolHarnessError::Code(error.into()).into());
438 }
439 }
440 Err(_error) => {
441 session.close().await;
442 return Err(SessionCheckpointError::InvalidPayload(
443 "the portable checkpoint capability binding is invalid".into(),
444 )
445 .into());
446 }
447 }
448 }
449 (None, Some(_)) => {
450 session.close().await;
451 return Err(SessionCheckpointError::InvalidPayload(
452 "a recovery capability batch cannot accompany a legacy unbound checkpoint"
453 .into(),
454 )
455 .into());
456 }
457 (None, None) => {}
458 }
459 let session = Arc::new(session);
460 let host = Arc::new(AgentProtocolHost::from_verified_manifest(
463 &self.manifest,
464 Arc::clone(&session),
465 ));
466 let receipt = match host
467 .execute_exact_recovery_from_checkpoint(request, logical_resume)
468 .await
469 {
470 Ok(receipt) => receipt,
471 Err(error) => {
472 host.session().close().await;
473 return Err(error.into());
474 }
475 };
476 if self.is_closed() {
477 host.session().close().await;
478 return Err(AgentProtocolHarnessError::Closed.into());
479 }
480 self.sessions.write().await.insert(
481 request.identity.session_id.clone(),
482 Arc::new(HarnessSessionEntry {
483 host,
484 _workspace: workspace,
485 }),
486 );
487 Ok(receipt)
488 }
489
490 pub async fn event_page(
492 &self,
493 request: &AgentProtocolEventPageRequestV1,
494 ) -> Result<AgentProtocolEventPageV1, AgentProtocolHarnessError> {
495 request.validate()?;
496 let host = self.host_for(&request.identity, false).await?;
497 host.event_page_for(request).await.map_err(Into::into)
498 }
499
500 pub async fn change_set(
502 &self,
503 request: &AgentProtocolChangeSetRequestV1,
504 ) -> Result<AgentProtocolChangeSetV1, AgentProtocolHarnessError> {
505 request.validate()?;
506 let host = self.host_for(&request.identity, false).await?;
507 host.change_set_for(request).await.map_err(Into::into)
508 }
509
510 pub async fn close(&self) {
512 if self.closed.swap(true, Ordering::AcqRel) {
513 return;
514 }
515 let _admission = self.admission.lock().await;
516 self.agent.close().await;
517 self.sessions.write().await.clear();
518 }
519
520 async fn host_for(
521 &self,
522 identity: &AgentProtocolRunIdentityV1,
523 create_if_missing: bool,
524 ) -> Result<Arc<AgentProtocolHost>, AgentProtocolHarnessError> {
525 identity.validate()?;
526 if identity.agent_release_identity != self.manifest.artifact().digest() {
527 return Err(AgentProtocolHostError::ReleaseMismatch.into());
528 }
529 if self.is_closed() {
530 return Err(AgentProtocolHarnessError::Closed);
531 }
532 if let Some(host) = self
533 .sessions
534 .read()
535 .await
536 .get(&identity.session_id)
537 .map(|entry| Arc::clone(&entry.host))
538 {
539 return Ok(host);
540 }
541
542 let _admission = self.admission.lock().await;
543 if self.is_closed() {
544 return Err(AgentProtocolHarnessError::Closed);
545 }
546 if let Some(host) = self
547 .sessions
548 .read()
549 .await
550 .get(&identity.session_id)
551 .map(|entry| Arc::clone(&entry.host))
552 {
553 return Ok(host);
554 }
555 if self.sessions.read().await.len() >= self.max_sessions {
556 return Err(AgentProtocolHarnessError::SessionCapacity);
557 }
558
559 let workspace = HarnessSessionWorkspace::prepare(PathBuf::from(&self.workspace)).await?;
560 let options = self
561 .session_options
562 .clone()
563 .with_session_id(&identity.session_id)
564 .with_auto_save(true);
565 let session = self
566 .agent
567 .open_protocol_session_async(
568 workspace.path().to_string_lossy().into_owned(),
569 options,
570 create_if_missing,
571 )
572 .await?
573 .ok_or(AgentProtocolHarnessError::SessionNotFound)?;
574 if self.is_closed() {
575 session.close().await;
576 return Err(AgentProtocolHarnessError::Closed);
577 }
578 let host = Arc::new(AgentProtocolHost::from_verified_manifest(
579 &self.manifest,
580 Arc::new(session),
581 ));
582 self.sessions.write().await.insert(
583 identity.session_id.clone(),
584 Arc::new(HarnessSessionEntry {
585 host: Arc::clone(&host),
586 _workspace: workspace,
587 }),
588 );
589 Ok(host)
590 }
591}
592
593fn map_host_execute_error(
594 error: AgentProtocolHostError,
595 harness_closed: bool,
596) -> AgentProtocolHarnessError {
597 match error {
598 AgentProtocolHostError::Code(CodeError::SessionClosed { .. }) if harness_closed => {
599 AgentProtocolHarnessError::Closed
600 }
601 other => other.into(),
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use crate::error::CodeError;
609 use crate::release::AgentReleaseError;
610 use crate::session_checkpoint::SessionCheckpointError;
611
612 #[test]
613 fn harness_error_codes_are_stable() {
614 let errors = [
615 AgentProtocolHarnessError::Protocol(AgentProtocolError::Encoding),
616 AgentProtocolHarnessError::Release(AgentReleaseError::UnsupportedContract),
617 AgentProtocolHarnessError::Host(AgentProtocolHostError::RunNotFound),
618 AgentProtocolHarnessError::Code(CodeError::TaskSchedulerClosed),
619 AgentProtocolHarnessError::SessionNotFound,
620 AgentProtocolHarnessError::SessionCapacity,
621 AgentProtocolHarnessError::Closed,
622 AgentProtocolHarnessError::Workspace("x".into()),
623 ];
624 assert_eq!(errors[0].code(), AgentProtocolError::Encoding.code());
625 assert_eq!(
626 errors[1].code(),
627 AgentReleaseError::UnsupportedContract.code()
628 );
629 assert_eq!(errors[2].code(), AgentProtocolHostError::RunNotFound.code());
630 assert_eq!(errors[3].code(), CodeError::TaskSchedulerClosed.code());
631 assert_eq!(
632 errors[4].code(),
633 "a3s.code.agent_protocol.session_not_found"
634 );
635 assert_eq!(errors[5].code(), "a3s.code.agent_protocol.session_capacity");
636 assert_eq!(errors[6].code(), "a3s.code.agent_protocol.harness_closed");
637 assert_eq!(
638 errors[7].code(),
639 "a3s.code.agent_protocol.workspace_isolation"
640 );
641 for error in &errors {
642 assert!(!error.to_string().is_empty());
643 assert!(!format!("{error:?}").is_empty());
644 }
645 }
646
647 #[test]
648 fn session_closed_during_harness_drain_maps_to_closed() {
649 let closed = map_host_execute_error(
650 AgentProtocolHostError::Code(CodeError::SessionClosed {
651 session_id: "s".into(),
652 }),
653 true,
654 );
655 assert!(matches!(closed, AgentProtocolHarnessError::Closed));
656
657 let open = map_host_execute_error(
658 AgentProtocolHostError::Code(CodeError::SessionClosed {
659 session_id: "s".into(),
660 }),
661 false,
662 );
663 assert!(matches!(
664 open,
665 AgentProtocolHarnessError::Host(AgentProtocolHostError::Code(
666 CodeError::SessionClosed { .. }
667 ))
668 ));
669
670 let other = map_host_execute_error(AgentProtocolHostError::RunNotFound, true);
671 assert!(matches!(
672 other,
673 AgentProtocolHarnessError::Host(AgentProtocolHostError::RunNotFound)
674 ));
675 }
676
677 #[test]
678 fn checkpoint_recovery_error_codes_are_stable() {
679 let errors = [
680 AgentProtocolCheckpointRecoveryError::Harness(AgentProtocolHarnessError::Closed),
681 AgentProtocolCheckpointRecoveryError::Exact(AgentProtocolExactRecoveryError::Host(
682 AgentProtocolHostError::RunNotFound,
683 )),
684 AgentProtocolCheckpointRecoveryError::Checkpoint(
685 SessionCheckpointError::InvalidPayload("x".into()),
686 ),
687 AgentProtocolCheckpointRecoveryError::SessionAlreadyActive,
688 ];
689 assert_eq!(errors[0].code(), AgentProtocolHarnessError::Closed.code());
690 assert_eq!(errors[1].code(), AgentProtocolHostError::RunNotFound.code());
691 assert_eq!(
692 errors[2].code(),
693 SessionCheckpointError::InvalidPayload("x".into()).code()
694 );
695 assert_eq!(
696 errors[3].code(),
697 "a3s.code.agent_protocol.checkpoint_session_already_active"
698 );
699 for error in &errors {
700 assert!(!error.to_string().is_empty());
701 assert!(!format!("{error:?}").is_empty());
702 }
703 }
704}