Skip to main content

a3s_code_core/
agent_protocol_harness.rs

1//! Code-owned multi-session kernel for the native `a3s code harness` process.
2//!
3//! The executable supplies HTTP and health transport. This kernel owns only
4//! admission into existing `Agent`/`AgentSession` state and deliberately has
5//! no parallel run store, scheduler, event journal, or checkpoint authority.
6
7use 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
28/// Finite default number of conversation sessions retained by one Harness.
29pub const AGENT_PROTOCOL_HARNESS_MAX_SESSIONS: usize = 1_024;
30
31/// Stable failures returned by the Code-owned multi-session Harness kernel.
32#[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/// Failures specific to one Harness-visible portable-checkpoint admission and
68/// its exact logical recovery.
69#[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
148/// Release-bound, multi-session kernel used by the sole native Harness.
149///
150/// Each entry is an [`AgentProtocolHost`] over one ordinary [`AgentSession`](crate::AgentSession).
151/// The map only retains those Code-owned sessions for conversation reuse; it
152/// never mirrors their runs or events. Miss admission is serialized so two
153/// concurrent commands cannot construct the same session twice, while work on
154/// already admitted sessions remains concurrent.
155pub 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    /// Admit one release into the native Harness compatibility surface.
181    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    /// Apply common options to every Code session created by this Harness.
209    ///
210    /// A caller-provided session ID is ignored. The exact protocol identity is
211    /// authoritative, and auto-save is always enabled when a store is present.
212    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    /// Override the finite retained-session limit.
220    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    /// Route an exact command into its Code-owned conversation session.
252    pub async fn execute(
253        &self,
254        command: &AgentProtocolCommandV1,
255    ) -> Result<AgentProtocolCommandReceiptV1, AgentProtocolHarnessError> {
256        command.validate()?;
257        // A normal recovery must load an existing persisted Session. Creating
258        // a fresh Session before discovering that its checkpoint is missing
259        // would publish an unusable ghost conversation. Portable checkpoint
260        // recovery has its own exact admission API below and may construct an
261        // unpublished Session from the supplied checkpoint.
262        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    /// Validate, restore, execute, and publish one portable checkpoint as one
268    /// Harness-visible admission.
269    ///
270    /// The complete descriptor is matched before payload decode. For a missing
271    /// Session, Code builds an unpublished Session directly from the semantic
272    /// snapshot and starts the target Run from the logical value decoded from
273    /// those same canonical bytes. Only successful admission enters the
274    /// Harness session map; no split SessionStore writes are required first.
275    /// External store revision fencing remains the embedding host's boundary.
276    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    /// Restore a portable checkpoint whose source Run used a non-empty scoped
286    /// capability generation.
287    ///
288    /// The batch is consumed only for a missing Session and must reconstruct
289    /// the checkpoint's exact historical generation. Existing Sessions are
290    /// never rolled backward by this API.
291    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    /// Route a bounded event query into the same authoritative Code session.
476    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    /// Route an immutable change-set query into the same authoritative run.
486    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    /// Stop admission and close every Code-owned session and Agent resource.
496    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}