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 recovery semantics.
6
7use crate::agent_api::{Agent, SessionOptions};
8use crate::agent_protocol::{
9    AgentProtocolChangeSetRequestV1, AgentProtocolChangeSetV1, AgentProtocolCommandReceiptV1,
10    AgentProtocolCommandV1, AgentProtocolError, AgentProtocolEventPageRequestV1,
11    AgentProtocolEventPageV1, AgentProtocolRunIdentityV1,
12};
13use crate::agent_protocol_host::{AgentProtocolHost, AgentProtocolHostError};
14use crate::error::CodeError;
15use crate::release::{
16    agent_harness_compatibility_v1, AgentReleaseError, AgentReleaseManifest, AGENT_PROTOCOL_V1,
17};
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::Arc;
22use thiserror::Error;
23use tokio::sync::{Mutex, RwLock};
24
25/// Finite default number of conversation sessions retained by one Harness.
26pub const AGENT_PROTOCOL_HARNESS_MAX_SESSIONS: usize = 1_024;
27
28/// Stable failures returned by the Code-owned multi-session Harness kernel.
29#[derive(Debug, Error)]
30pub enum AgentProtocolHarnessError {
31    #[error(transparent)]
32    Protocol(#[from] AgentProtocolError),
33    #[error(transparent)]
34    Release(#[from] AgentReleaseError),
35    #[error(transparent)]
36    Host(#[from] AgentProtocolHostError),
37    #[error(transparent)]
38    Code(#[from] CodeError),
39    #[error("A3S Code Harness session was not found")]
40    SessionNotFound,
41    #[error("A3S Code Harness session capacity is exhausted")]
42    SessionCapacity,
43    #[error("A3S Code Harness is draining or stopped")]
44    Closed,
45    #[error("A3S Code Harness workspace isolation failed: {0}")]
46    Workspace(String),
47}
48
49impl AgentProtocolHarnessError {
50    pub const fn code(&self) -> &'static str {
51        match self {
52            Self::Protocol(error) => error.code(),
53            Self::Release(error) => error.code(),
54            Self::Host(error) => error.code(),
55            Self::Code(error) => error.code(),
56            Self::SessionNotFound => "a3s.code.agent_protocol.session_not_found",
57            Self::SessionCapacity => "a3s.code.agent_protocol.session_capacity",
58            Self::Closed => "a3s.code.agent_protocol.harness_closed",
59            Self::Workspace(_) => "a3s.code.agent_protocol.workspace_isolation",
60        }
61    }
62}
63
64struct HarnessSessionEntry {
65    host: Arc<AgentProtocolHost>,
66    _workspace: HarnessSessionWorkspace,
67}
68
69enum HarnessSessionWorkspace {
70    Shared(PathBuf),
71    Isolated {
72        source: PathBuf,
73        path: PathBuf,
74        _temporary_root: tempfile::TempDir,
75    },
76}
77
78impl HarnessSessionWorkspace {
79    async fn prepare(source: PathBuf) -> Result<Self, AgentProtocolHarnessError> {
80        tokio::task::spawn_blocking(move || {
81            if !crate::git::is_git_repo(&source) {
82                return Ok(Self::Shared(source));
83            }
84            let temporary_root = tempfile::Builder::new()
85                .prefix("a3s-code-harness-session-")
86                .tempdir()
87                .map_err(|error| AgentProtocolHarnessError::Workspace(error.to_string()))?;
88            let path = temporary_root.path().join("workspace");
89            crate::git::create_isolated_worktree(&source, &path)
90                .map_err(|error| AgentProtocolHarnessError::Workspace(error.to_string()))?;
91            Ok(Self::Isolated {
92                source,
93                path,
94                _temporary_root: temporary_root,
95            })
96        })
97        .await
98        .map_err(|error| AgentProtocolHarnessError::Workspace(error.to_string()))?
99    }
100
101    fn path(&self) -> &Path {
102        match self {
103            Self::Shared(path) | Self::Isolated { path, .. } => path,
104        }
105    }
106}
107
108impl Drop for HarnessSessionWorkspace {
109    fn drop(&mut self) {
110        if let Self::Isolated { source, path, .. } = self {
111            if let Err(error) = crate::git::remove_isolated_worktree(source, path) {
112                tracing::warn!(%error, workspace = %path.display(), "could not remove Agent Harness session worktree");
113            }
114        }
115    }
116}
117
118/// Release-bound, multi-session kernel used by the sole native Harness.
119///
120/// Each entry is an [`AgentProtocolHost`] over one ordinary [`AgentSession`](crate::AgentSession).
121/// The map only retains those Code-owned sessions for conversation reuse; it
122/// never mirrors their runs or events. Miss admission is serialized so two
123/// concurrent commands cannot construct the same session twice, while work on
124/// already admitted sessions remains concurrent.
125pub struct AgentProtocolHarness {
126    manifest: Arc<AgentReleaseManifest>,
127    agent: Arc<Agent>,
128    workspace: String,
129    session_options: SessionOptions,
130    max_sessions: usize,
131    sessions: RwLock<HashMap<String, Arc<HarnessSessionEntry>>>,
132    admission: Mutex<()>,
133    closed: AtomicBool,
134}
135
136impl std::fmt::Debug for AgentProtocolHarness {
137    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        formatter
139            .debug_struct("AgentProtocolHarness")
140            .field("agent_release_identity", &self.manifest.artifact().digest())
141            .field("manifest_identity", &self.manifest.identity())
142            .field("workspace", &self.workspace)
143            .field("max_sessions", &self.max_sessions)
144            .field("closed", &self.closed.load(Ordering::Acquire))
145            .finish_non_exhaustive()
146    }
147}
148
149impl AgentProtocolHarness {
150    /// Admit one release into the native Harness compatibility surface.
151    pub fn new(
152        manifest: AgentReleaseManifest,
153        agent: Arc<Agent>,
154        workspace: impl Into<String>,
155    ) -> Result<Self, AgentProtocolHarnessError> {
156        manifest.verify_compatibility(&agent_harness_compatibility_v1())?;
157        if manifest.protocol() != AGENT_PROTOCOL_V1 {
158            return Err(AgentProtocolHostError::ReleaseProtocolMismatch.into());
159        }
160        Ok(Self {
161            manifest: Arc::new(manifest),
162            agent,
163            workspace: workspace.into(),
164            session_options: SessionOptions::new(),
165            max_sessions: AGENT_PROTOCOL_HARNESS_MAX_SESSIONS,
166            sessions: RwLock::new(HashMap::new()),
167            admission: Mutex::new(()),
168            closed: AtomicBool::new(false),
169        })
170    }
171
172    /// Apply common options to every Code session created by this Harness.
173    ///
174    /// A caller-provided session ID is ignored. The exact protocol identity is
175    /// authoritative, and auto-save is always enabled when a store is present.
176    pub fn with_session_options(mut self, options: SessionOptions) -> Self {
177        self.session_options = options;
178        self.session_options.session_id = None;
179        self.session_options.auto_save = true;
180        self
181    }
182
183    /// Override the finite retained-session limit.
184    pub fn with_max_sessions(
185        mut self,
186        max_sessions: usize,
187    ) -> Result<Self, AgentProtocolHarnessError> {
188        if max_sessions == 0 {
189            return Err(AgentProtocolHarnessError::SessionCapacity);
190        }
191        self.max_sessions = max_sessions;
192        Ok(self)
193    }
194
195    pub fn manifest(&self) -> &AgentReleaseManifest {
196        &self.manifest
197    }
198
199    pub fn agent_release_identity(&self) -> &str {
200        self.manifest.artifact().digest()
201    }
202
203    pub fn max_sessions(&self) -> usize {
204        self.max_sessions
205    }
206
207    pub fn is_closed(&self) -> bool {
208        self.closed.load(Ordering::Acquire)
209    }
210
211    pub async fn session_count(&self) -> usize {
212        self.sessions.read().await.len()
213    }
214
215    /// Route an exact command into its Code-owned conversation session.
216    pub async fn execute(
217        &self,
218        command: &AgentProtocolCommandV1,
219    ) -> Result<AgentProtocolCommandReceiptV1, AgentProtocolHarnessError> {
220        command.validate()?;
221        let create_if_missing = matches!(
222            command,
223            AgentProtocolCommandV1::Start { .. } | AgentProtocolCommandV1::Recover { .. }
224        );
225        let host = self.host_for(command.identity(), create_if_missing).await?;
226        host.execute(command).await.map_err(Into::into)
227    }
228
229    /// Route a bounded event query into the same authoritative Code session.
230    pub async fn event_page(
231        &self,
232        request: &AgentProtocolEventPageRequestV1,
233    ) -> Result<AgentProtocolEventPageV1, AgentProtocolHarnessError> {
234        request.validate()?;
235        let host = self.host_for(&request.identity, false).await?;
236        host.event_page_for(request).await.map_err(Into::into)
237    }
238
239    /// Route an immutable change-set query into the same authoritative run.
240    pub async fn change_set(
241        &self,
242        request: &AgentProtocolChangeSetRequestV1,
243    ) -> Result<AgentProtocolChangeSetV1, AgentProtocolHarnessError> {
244        request.validate()?;
245        let host = self.host_for(&request.identity, false).await?;
246        host.change_set_for(request).await.map_err(Into::into)
247    }
248
249    /// Stop admission and close every Code-owned session and Agent resource.
250    pub async fn close(&self) {
251        if self.closed.swap(true, Ordering::AcqRel) {
252            return;
253        }
254        let _admission = self.admission.lock().await;
255        self.agent.close().await;
256        self.sessions.write().await.clear();
257    }
258
259    async fn host_for(
260        &self,
261        identity: &AgentProtocolRunIdentityV1,
262        create_if_missing: bool,
263    ) -> Result<Arc<AgentProtocolHost>, AgentProtocolHarnessError> {
264        identity.validate()?;
265        if identity.agent_release_identity != self.manifest.artifact().digest() {
266            return Err(AgentProtocolHostError::ReleaseMismatch.into());
267        }
268        if self.is_closed() {
269            return Err(AgentProtocolHarnessError::Closed);
270        }
271        if let Some(host) = self
272            .sessions
273            .read()
274            .await
275            .get(&identity.session_id)
276            .map(|entry| Arc::clone(&entry.host))
277        {
278            return Ok(host);
279        }
280
281        let _admission = self.admission.lock().await;
282        if self.is_closed() {
283            return Err(AgentProtocolHarnessError::Closed);
284        }
285        if let Some(host) = self
286            .sessions
287            .read()
288            .await
289            .get(&identity.session_id)
290            .map(|entry| Arc::clone(&entry.host))
291        {
292            return Ok(host);
293        }
294        if self.sessions.read().await.len() >= self.max_sessions {
295            return Err(AgentProtocolHarnessError::SessionCapacity);
296        }
297
298        let workspace = HarnessSessionWorkspace::prepare(PathBuf::from(&self.workspace)).await?;
299        let options = self
300            .session_options
301            .clone()
302            .with_session_id(&identity.session_id)
303            .with_auto_save(true);
304        let session = self
305            .agent
306            .open_protocol_session_async(
307                workspace.path().to_string_lossy().into_owned(),
308                options,
309                create_if_missing,
310            )
311            .await?
312            .ok_or(AgentProtocolHarnessError::SessionNotFound)?;
313        let host = Arc::new(AgentProtocolHost::from_manifest(
314            &self.manifest,
315            Arc::new(session),
316        )?);
317        self.sessions.write().await.insert(
318            identity.session_id.clone(),
319            Arc::new(HarnessSessionEntry {
320                host: Arc::clone(&host),
321                _workspace: workspace,
322            }),
323        );
324        Ok(host)
325    }
326}