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 let workspace = workspace.into();
191 if workspace.trim().is_empty() {
192 return Err(AgentProtocolHarnessError::Workspace(
193 "workspace path must not be empty".into(),
194 ));
195 }
196 Ok(Self {
197 manifest: Arc::new(manifest),
198 agent,
199 workspace,
200 session_options: SessionOptions::new(),
201 max_sessions: AGENT_PROTOCOL_HARNESS_MAX_SESSIONS,
202 sessions: RwLock::new(HashMap::new()),
203 admission: Mutex::new(()),
204 closed: AtomicBool::new(false),
205 })
206 }
207
208 pub fn with_session_options(mut self, options: SessionOptions) -> Self {
213 self.session_options = options;
214 self.session_options.session_id = None;
215 self.session_options.auto_save = true;
216 self
217 }
218
219 pub fn with_max_sessions(
221 mut self,
222 max_sessions: usize,
223 ) -> Result<Self, AgentProtocolHarnessError> {
224 if max_sessions == 0 {
225 return Err(AgentProtocolHarnessError::SessionCapacity);
226 }
227 self.max_sessions = max_sessions;
228 Ok(self)
229 }
230
231 pub fn manifest(&self) -> &AgentReleaseManifest {
232 &self.manifest
233 }
234
235 pub fn agent_release_identity(&self) -> &str {
236 self.manifest.artifact().digest()
237 }
238
239 pub fn max_sessions(&self) -> usize {
240 self.max_sessions
241 }
242
243 pub fn is_closed(&self) -> bool {
244 self.closed.load(Ordering::Acquire)
245 }
246
247 pub async fn session_count(&self) -> usize {
248 self.sessions.read().await.len()
249 }
250
251 pub async fn execute(
253 &self,
254 command: &AgentProtocolCommandV1,
255 ) -> Result<AgentProtocolCommandReceiptV1, AgentProtocolHarnessError> {
256 command.validate()?;
257 let create_if_missing = matches!(command, AgentProtocolCommandV1::Start { .. });
263 let host = self.host_for(command.identity(), create_if_missing).await?;
264 host.execute(command).await.map_err(Into::into)
265 }
266
267 pub async fn execute_checkpoint_recovery(
277 &self,
278 request: &AgentProtocolRunRecoverExactV1,
279 checkpoint: SessionCheckpointExportV1,
280 ) -> Result<AgentProtocolCommandReceiptV1, AgentProtocolCheckpointRecoveryError> {
281 self.execute_checkpoint_recovery_inner(request, checkpoint, None)
282 .await
283 }
284
285 pub async fn execute_checkpoint_recovery_with_capability_batch(
292 &self,
293 request: &AgentProtocolRunRecoverExactV1,
294 checkpoint: SessionCheckpointExportV1,
295 capability_batch: crate::capability::SessionCapabilityBatch,
296 ) -> Result<AgentProtocolCommandReceiptV1, AgentProtocolCheckpointRecoveryError> {
297 self.execute_checkpoint_recovery_inner(request, checkpoint, Some(capability_batch))
298 .await
299 }
300
301 async fn execute_checkpoint_recovery_inner(
302 &self,
303 request: &AgentProtocolRunRecoverExactV1,
304 checkpoint: SessionCheckpointExportV1,
305 mut capability_batch: Option<crate::capability::SessionCapabilityBatch>,
306 ) -> Result<AgentProtocolCommandReceiptV1, AgentProtocolCheckpointRecoveryError> {
307 request
308 .validate()
309 .map_err(AgentProtocolHarnessError::from)?;
310 if request.identity.agent_release_identity != self.manifest.artifact().digest() {
311 return Err(
312 AgentProtocolHarnessError::from(AgentProtocolHostError::ReleaseMismatch).into(),
313 );
314 }
315 if request.checkpoint != *checkpoint.descriptor() {
316 return Err(SessionCheckpointError::ContentDrift(
317 "recovery request descriptor does not match the supplied portable checkpoint"
318 .into(),
319 )
320 .into());
321 }
322 let payload = checkpoint.into_open()?;
323 let (mut snapshot, logical_resume) = payload.into_parts();
324 let logical_resume = logical_resume.ok_or_else(|| {
325 SessionCheckpointError::InvalidPayload(
326 "exact recovery requires a logical-resume component".into(),
327 )
328 })?;
329
330 let _admission = self.admission.lock().await;
331 if self.is_closed() {
332 return Err(AgentProtocolHarnessError::Closed.into());
333 }
334 if let Some(host) = self
335 .sessions
336 .read()
337 .await
338 .get(&request.identity.session_id)
339 .map(|entry| Arc::clone(&entry.host))
340 {
341 if capability_batch.is_some() {
342 return Err(AgentProtocolCheckpointRecoveryError::SessionAlreadyActive);
343 }
344 if host
345 .session()
346 .run_snapshot(&request.identity.run_id)
347 .await
348 .is_none()
349 {
350 return Err(AgentProtocolCheckpointRecoveryError::SessionAlreadyActive);
351 }
352 return host
353 .execute_exact_recovery_from_checkpoint(request, logical_resume)
354 .await
355 .map_err(Into::into);
356 }
357 if self.sessions.read().await.len() >= self.max_sessions {
358 return Err(AgentProtocolHarnessError::SessionCapacity.into());
359 }
360
361 let options = self
362 .session_options
363 .clone()
364 .with_session_id(&request.identity.session_id)
365 .with_auto_save(true);
366 if let Some(persisted) = self
367 .agent
368 .load_protocol_session_snapshot_async(&request.identity.session_id, &options)
369 .await
370 .map_err(AgentProtocolHarnessError::from)?
371 {
372 let target_already_persisted = persisted
373 .run_records
374 .iter()
375 .any(|record| record.snapshot.id == request.identity.run_id);
376 if target_already_persisted {
377 snapshot = persisted;
378 } else {
379 request.checkpoint.snapshot.validate_for(&persisted)?;
380 }
381 }
382
383 let workspace = HarnessSessionWorkspace::prepare(PathBuf::from(&self.workspace)).await?;
384 let session = self
385 .agent
386 .restore_protocol_checkpoint_session_async(
387 snapshot,
388 workspace.path().to_string_lossy().into_owned(),
389 options,
390 )
391 .await
392 .map_err(AgentProtocolHarnessError::from)?;
393 match (&logical_resume.capability_binding, capability_batch.take()) {
394 (Some(expected), batch) => match session.ensure_recovery_capability_binding(expected) {
395 Ok(()) if batch.is_none() => {}
396 Ok(()) => {
397 session.close().await;
398 return Err(SessionCheckpointError::InvalidPayload(
399 "a recovery capability batch was supplied even though the restored Session already matches the checkpoint"
400 .into(),
401 )
402 .into());
403 }
404 Err(crate::capability::RunCapabilityBindingError::ContentDrift { .. }) => {
405 let Some(batch) = batch else {
406 session.close().await;
407 return Err(SessionCheckpointError::ContentDrift(
408 "the portable checkpoint requires a scoped capability generation that was not reconstructed by the host"
409 .into(),
410 )
411 .into());
412 };
413 if let Err(error) = session
414 .bootstrap_recovery_capability_batch(
415 expected,
416 batch,
417 tokio_util::sync::CancellationToken::new(),
418 )
419 .await
420 {
421 session.close().await;
422 return Err(AgentProtocolHarnessError::Code(error.into()).into());
423 }
424 }
425 Err(error) => {
426 session.close().await;
427 return Err(SessionCheckpointError::InvalidPayload(format!(
428 "the portable checkpoint capability binding is invalid: {error}"
429 ))
430 .into());
431 }
432 },
433 (None, Some(_)) => {
434 session.close().await;
435 return Err(SessionCheckpointError::InvalidPayload(
436 "a recovery capability batch cannot accompany a legacy unbound checkpoint"
437 .into(),
438 )
439 .into());
440 }
441 (None, None) => {}
442 }
443 let session = Arc::new(session);
444 let host = match AgentProtocolHost::from_manifest(&self.manifest, Arc::clone(&session)) {
445 Ok(host) => Arc::new(host),
446 Err(error) => {
447 session.close().await;
448 return Err(AgentProtocolHarnessError::from(error).into());
449 }
450 };
451 let receipt = match host
452 .execute_exact_recovery_from_checkpoint(request, logical_resume)
453 .await
454 {
455 Ok(receipt) => receipt,
456 Err(error) => {
457 host.session().close().await;
458 return Err(error.into());
459 }
460 };
461 if self.is_closed() {
462 host.session().close().await;
463 return Err(AgentProtocolHarnessError::Closed.into());
464 }
465 self.sessions.write().await.insert(
466 request.identity.session_id.clone(),
467 Arc::new(HarnessSessionEntry {
468 host,
469 _workspace: workspace,
470 }),
471 );
472 Ok(receipt)
473 }
474
475 pub async fn event_page(
477 &self,
478 request: &AgentProtocolEventPageRequestV1,
479 ) -> Result<AgentProtocolEventPageV1, AgentProtocolHarnessError> {
480 request.validate()?;
481 let host = self.host_for(&request.identity, false).await?;
482 host.event_page_for(request).await.map_err(Into::into)
483 }
484
485 pub async fn change_set(
487 &self,
488 request: &AgentProtocolChangeSetRequestV1,
489 ) -> Result<AgentProtocolChangeSetV1, AgentProtocolHarnessError> {
490 request.validate()?;
491 let host = self.host_for(&request.identity, false).await?;
492 host.change_set_for(request).await.map_err(Into::into)
493 }
494
495 pub async fn close(&self) {
497 if self.closed.swap(true, Ordering::AcqRel) {
498 return;
499 }
500 let _admission = self.admission.lock().await;
501 self.agent.close().await;
502 self.sessions.write().await.clear();
503 }
504
505 async fn host_for(
506 &self,
507 identity: &AgentProtocolRunIdentityV1,
508 create_if_missing: bool,
509 ) -> Result<Arc<AgentProtocolHost>, AgentProtocolHarnessError> {
510 identity.validate()?;
511 if identity.agent_release_identity != self.manifest.artifact().digest() {
512 return Err(AgentProtocolHostError::ReleaseMismatch.into());
513 }
514 if self.is_closed() {
515 return Err(AgentProtocolHarnessError::Closed);
516 }
517 if let Some(host) = self
518 .sessions
519 .read()
520 .await
521 .get(&identity.session_id)
522 .map(|entry| Arc::clone(&entry.host))
523 {
524 return Ok(host);
525 }
526
527 let _admission = self.admission.lock().await;
528 if self.is_closed() {
529 return Err(AgentProtocolHarnessError::Closed);
530 }
531 if let Some(host) = self
532 .sessions
533 .read()
534 .await
535 .get(&identity.session_id)
536 .map(|entry| Arc::clone(&entry.host))
537 {
538 return Ok(host);
539 }
540 if self.sessions.read().await.len() >= self.max_sessions {
541 return Err(AgentProtocolHarnessError::SessionCapacity);
542 }
543
544 let workspace = HarnessSessionWorkspace::prepare(PathBuf::from(&self.workspace)).await?;
545 let options = self
546 .session_options
547 .clone()
548 .with_session_id(&identity.session_id)
549 .with_auto_save(true);
550 let session = self
551 .agent
552 .open_protocol_session_async(
553 workspace.path().to_string_lossy().into_owned(),
554 options,
555 create_if_missing,
556 )
557 .await?
558 .ok_or(AgentProtocolHarnessError::SessionNotFound)?;
559 let host = Arc::new(AgentProtocolHost::from_manifest(
560 &self.manifest,
561 Arc::new(session),
562 )?);
563 self.sessions.write().await.insert(
564 identity.session_id.clone(),
565 Arc::new(HarnessSessionEntry {
566 host: Arc::clone(&host),
567 _workspace: workspace,
568 }),
569 );
570 Ok(host)
571 }
572}