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        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    /// Apply common options to every Code session created by this Harness.
203    ///
204    /// A caller-provided session ID is ignored. The exact protocol identity is
205    /// authoritative, and auto-save is always enabled when a store is present.
206    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    /// Override the finite retained-session limit.
214    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    /// Route an exact command into its Code-owned conversation session.
246    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    /// Validate, restore, execute, and publish one portable checkpoint as one
260    /// Harness-visible admission.
261    ///
262    /// The complete descriptor is matched before payload decode. For a missing
263    /// Session, Code builds an unpublished Session directly from the semantic
264    /// snapshot and starts the target Run from the logical value decoded from
265    /// those same canonical bytes. Only successful admission enters the
266    /// Harness session map; no split SessionStore writes are required first.
267    /// External store revision fencing remains the embedding host's boundary.
268    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    /// Restore a portable checkpoint whose source Run used a non-empty scoped
278    /// capability generation.
279    ///
280    /// The batch is consumed only for a missing Session and must reconstruct
281    /// the checkpoint's exact historical generation. Existing Sessions are
282    /// never rolled backward by this API.
283    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    /// Route a bounded event query into the same authoritative Code session.
468    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    /// Route an immutable change-set query into the same authoritative run.
478    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    /// Stop admission and close every Code-owned session and Agent resource.
488    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}