aion_integration_cli/session.rs
1//! [`CliSession`] — a live plain-stdout CLI agent run behind the [`AgentSession`] seam.
2//!
3//! This is the **observability-only** case (case (b), §3A.1): the agent has no structured control
4//! channel at all. One background **pump task** owns the child's stdout, reads it line by line,
5//! demuxes each line into a neutral [`ActivityEvent`] ([`crate::demux`]), and forwards it to the
6//! [`AgentSession::events`] stream. When stdout closes (end of run) it captures the terminal result
7//! (the final stdout line, how the child ended, and the last stop reason the agent announced) and
8//! hands it to [`AgentSession::wait_result`].
9//!
10//! The capability set is **always empty**: [`AgentSession::capabilities`] returns
11//! [`InterventionCapabilities::none`] and [`AgentSession::intervene`] rejects **every** command with
12//! [`HarnessError::CapabilityNotSupported`]. There is no control channel to route a command onto —
13//! the empty advertisement is a first-class tier, not a degenerate one, and the server never routes
14//! a command to a session that advertises nothing.
15
16use std::sync::Arc;
17
18use aion_core::{
19 ActivityEvent, AgentOutcome, InterventionCapabilities, InterventionCommand, Payload, StopKind,
20};
21use aion_integrations::contract::AgentSession;
22use aion_integrations::error::HarnessError;
23use async_trait::async_trait;
24use futures::stream::BoxStream;
25use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader, Lines};
26use tokio::sync::{Mutex, mpsc, oneshot};
27
28use crate::demux::{self, EventIdentity};
29
30/// How the child process ended, kept honest by naming all three endings.
31///
32/// A flattened `Option<i32>` conflated two endings that must not share a fate:
33/// `None` used to mean BOTH "no child at all" (the in-memory reader path) and
34/// "terminated by a signal" — and a signal-killed agent fell through the
35/// non-zero-exit refusal into a result claiming the agent finished its turn.
36enum ChildEnd {
37 /// No child process exists — the session runs over a bare reader (tests,
38 /// in-memory drivers). End of stream is the only ending observable.
39 NoChild,
40 /// The child exited on its own, with this exit code.
41 Exited(i32),
42 /// The child was terminated by a signal: it did not end by its own choice.
43 Signalled,
44 /// The child's exit status could not be read at all (an OS-level `wait`
45 /// failure) — an ending that cannot honestly be classified as any of the
46 /// other three, carried so it refuses loudly instead of guessing.
47 StatusUnreadable {
48 /// The wait error as the OS reported it.
49 detail: String,
50 },
51}
52
53/// The terminal outcome the pump captures when stdout closes: the final line,
54/// how the child ended, and the last stop reason the agent itself announced.
55struct TerminalOutcome {
56 /// The last non-empty stdout line the agent printed, if any — its "final answer".
57 final_line: Option<String>,
58 /// How the child process ended.
59 end: ChildEnd,
60 /// The reason from the LAST `{"type":"stop",...}` line the demux produced,
61 /// if the run announced one — the agent's own account of why it stopped,
62 /// retained so the outcome record reports it instead of inventing one.
63 announced_stop: Option<StopKind>,
64}
65
66/// A live plain-stdout CLI agent run for one activity attempt.
67///
68/// Generic over the child's stdout read half so tests can drive it over an in-memory reader; in
69/// production it is the child process's stdout. The child handle is held so it is reaped on drop.
70pub struct CliSession {
71 capabilities: InterventionCapabilities,
72 events: Option<mpsc::UnboundedReceiver<ActivityEvent>>,
73 result: Option<oneshot::Receiver<TerminalOutcome>>,
74 pump: Option<tokio::task::JoinHandle<()>>,
75 /// The attempt's `(workflow, run, activity, attempt)` identity — stamped onto every demuxed
76 /// event by the pump, and the material for the outcome record's derived `session_id` (a
77 /// one-shot process has no persisted session, so the identity quadruple IS the conversation's
78 /// honest handle).
79 identity: EventIdentity,
80 /// The spawned child, reaped when the session is dropped. `None` for the in-memory test path.
81 child: Arc<Mutex<Option<tokio::process::Child>>>,
82}
83
84impl CliSession {
85 /// Builds a session over a child's stdout read half and starts the reader pump.
86 ///
87 /// `identity` stamps the run key onto every demuxed event. `child` (when present) is awaited by
88 /// the pump for the exit status and reaped on drop. The pump starts here so events stream from
89 /// the moment the session exists.
90 pub(crate) fn start<R>(
91 stdout: R,
92 identity: EventIdentity,
93 child: Option<tokio::process::Child>,
94 ) -> Self
95 where
96 R: AsyncRead + Unpin + Send + 'static,
97 {
98 let (event_tx, event_rx) = mpsc::unbounded_channel();
99 let (result_tx, result_rx) = oneshot::channel();
100 let child = Arc::new(Mutex::new(child));
101
102 let pump = tokio::spawn(pump_loop(
103 BufReader::new(stdout).lines(),
104 identity.clone(),
105 event_tx,
106 result_tx,
107 Arc::clone(&child),
108 ));
109
110 Self {
111 capabilities: InterventionCapabilities::none(),
112 events: Some(event_rx),
113 result: Some(result_rx),
114 pump: Some(pump),
115 identity,
116 child,
117 }
118 }
119}
120
121/// The reader pump: demuxes every stdout line into an event until stdout closes, then captures the
122/// terminal outcome (final line + exit status) and sends it to the result waiter.
123///
124/// A send failure on the event channel means the consumer dropped the stream; the pump keeps
125/// reading so the terminal result is still captured, it just stops forwarding events.
126async fn pump_loop<R>(
127 mut lines: Lines<BufReader<R>>,
128 identity: EventIdentity,
129 event_tx: mpsc::UnboundedSender<ActivityEvent>,
130 result_tx: oneshot::Sender<TerminalOutcome>,
131 child: Arc<Mutex<Option<tokio::process::Child>>>,
132) where
133 R: AsyncRead + Unpin + Send,
134{
135 let mut worker_seq: u64 = 0;
136 let mut final_line: Option<String> = None;
137 let mut announced_stop: Option<StopKind> = None;
138 loop {
139 match lines.next_line().await {
140 Ok(Some(line)) => {
141 if !line.trim().is_empty() {
142 final_line = Some(line.clone());
143 }
144 let event = demux::line_to_event(&identity, worker_seq, &line);
145 worker_seq = worker_seq.saturating_add(1);
146 // The agent's own stop announcement is part of the terminal
147 // truth, not just transcript colour: the LAST one is retained
148 // so the outcome record can report the reason the demux
149 // already produced instead of inventing `end_turn`.
150 if let aion_core::ActivityEventKind::Stop { reason } = &event.kind {
151 announced_stop = Some(reason.clone());
152 }
153 let _ = event_tx.send(event);
154 }
155 // End of stdout == end of run: capture the terminal outcome and stop.
156 Ok(None) => break,
157 Err(error) => {
158 tracing::debug!(%error, "cli adapter: reader pump ended on a stdout read error");
159 break;
160 }
161 }
162 }
163
164 let end = wait_child_end(&child).await;
165 // A signal death is an ending the agent could not announce, so the pump
166 // announces it: a synthesized terminal Stop event puts the detail
167 // ("terminated by signal") on the transcript — the one channel whose
168 // `StopKind::Other` carries a reason verbatim, where the outcome record's
169 // canonical vocabulary collapses it to `"other"`.
170 if matches!(end, ChildEnd::Signalled) {
171 let stop = demux::synthesized_stop_event(
172 &identity,
173 worker_seq,
174 StopKind::Other {
175 reason: "terminated by signal".to_owned(),
176 },
177 );
178 let _ = event_tx.send(stop);
179 }
180 // A dropped result receiver (the caller gave up on wait_result) is benign.
181 let _ = result_tx.send(TerminalOutcome {
182 final_line,
183 end,
184 announced_stop,
185 });
186}
187
188/// Awaits the child's exit and reports how it ended — exit code, signal
189/// termination, or no child at all (the in-memory reader path).
190async fn wait_child_end(child: &Arc<Mutex<Option<tokio::process::Child>>>) -> ChildEnd {
191 let mut guard = child.lock().await;
192 let Some(child) = guard.as_mut() else {
193 return ChildEnd::NoChild;
194 };
195 match child.wait().await {
196 Ok(status) => match status.code() {
197 Some(code) => ChildEnd::Exited(code),
198 // On Unix an exit status without a code is a signal termination;
199 // it must never be mistaken for a clean exit.
200 None => ChildEnd::Signalled,
201 },
202 Err(error) => ChildEnd::StatusUnreadable {
203 detail: error.to_string(),
204 },
205 }
206}
207
208#[async_trait]
209impl AgentSession for CliSession {
210 fn capabilities(&self) -> &InterventionCapabilities {
211 // Always the empty set: observability-only, no control channel.
212 &self.capabilities
213 }
214
215 fn events(&mut self) -> BoxStream<'static, ActivityEvent> {
216 // The stream is taken once; a second call yields an empty stream rather than panicking.
217 match self.events.take() {
218 Some(receiver) => Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(
219 receiver,
220 )),
221 None => Box::pin(futures::stream::empty()),
222 }
223 }
224
225 async fn intervene(&self, cmd: InterventionCommand) -> Result<(), HarnessError> {
226 // The empty capability set rejects EVERY command: there is no control channel at all. This
227 // is the first-class observability-only rejection, not an internal failure.
228 Err(HarnessError::capability_not_supported(format!(
229 "{:?}",
230 cmd.kind.primitive()
231 )))
232 }
233
234 async fn wait_result(mut self) -> Result<Payload, HarnessError> {
235 let receiver = self
236 .result
237 .take()
238 .ok_or_else(|| HarnessError::protocol("run result already awaited"))?;
239 let outcome = receiver.await.map_err(|_recv| {
240 HarnessError::transport("cli agent result channel closed before the run finished")
241 })?;
242 terminal_outcome_into_payload(outcome, &self.identity)
243 }
244}
245
246/// Interprets the captured terminal outcome as the replay-authoritative [`Payload`].
247///
248/// A non-zero exit code is an application-level failure ([`HarnessError::Harness`]), and an
249/// unreadable exit status refuses rather than guesses. Every other ending yields the canonical
250/// [`AgentOutcome`] record — the same JSON result shape the worker captures from EVERY harness.
251/// `text` is the final stdout line (the agent's "answer"); a run that printed nothing yields the
252/// honest empty string, never an invented answer. `stop_reason` is the run's own account, through
253/// the crate's demux vocabulary:
254///
255/// - the LAST `{"type":"stop","reason":...}` line the agent printed, when it printed one — the
256/// reason the demux already produced, never re-derived;
257/// - [`StopKind::EndTurn`] ONLY when a cleanly-ended run announced no stop line at all
258/// (end-of-stdout with a zero exit, or the in-memory no-child path);
259/// - a signal-killed child is [`StopKind::Other`] (`terminated by signal`): the completion policy
260/// is unchanged — such a run completed before this and still does — but the record stops
261/// claiming the agent chose to end its turn, and it overrides any earlier announced stop
262/// because a stop line followed by a signal death was a mid-run announcement, not the ending.
263///
264/// `session_id` is derived from the attempt's identity
265/// (`{workflow_id}:{run_id}:{activity_id}:{attempt}`): a cli agent is a one-shot process with no
266/// persisted session, so the conversation the record names IS that one invocation, and the
267/// identity quadruple is its honest handle. Per the record's contract the id is meaningful only
268/// to the harness that minted it — this harness has no load path, so the handle identifies the
269/// conversation and promises nothing. The harness's `final_line` is already the final agent
270/// message, so it honestly fills both `text` and `final_message`; there is no earlier-answer
271/// accumulation in this adapter.
272fn terminal_outcome_into_payload(
273 outcome: TerminalOutcome,
274 identity: &EventIdentity,
275) -> Result<Payload, HarnessError> {
276 let TerminalOutcome {
277 final_line,
278 end,
279 announced_stop,
280 } = outcome;
281 let stop = match end {
282 ChildEnd::Exited(code) if code != 0 => {
283 return Err(HarnessError::harness(format!(
284 "cli agent exited with a non-zero status: {code}"
285 )));
286 }
287 ChildEnd::StatusUnreadable { detail } => {
288 return Err(HarnessError::transport(format!(
289 "the cli agent's exit status could not be read, so the run has no honest \
290 terminal classification: {detail}"
291 )));
292 }
293 ChildEnd::Signalled => StopKind::Other {
294 reason: "terminated by signal".to_owned(),
295 },
296 ChildEnd::Exited(_) | ChildEnd::NoChild => announced_stop.unwrap_or(StopKind::EndTurn),
297 };
298 let session_id = format!(
299 "{}:{}:{}:{}",
300 identity.workflow_id, identity.run_id, identity.activity_id, identity.attempt
301 );
302 let final_line = final_line.unwrap_or_default();
303 AgentOutcome::new(final_line.clone(), final_line, &stop, session_id)
304 .into_payload()
305 .map_err(|source| {
306 HarnessError::protocol(format!("cli agent result is not encodable: {source}"))
307 })
308}
309
310impl Drop for CliSession {
311 fn drop(&mut self) {
312 // Abort the pump and best-effort kill the child so a dropped session leaks neither a task
313 // nor a process.
314 if let Some(pump) = self.pump.take() {
315 pump.abort();
316 }
317 if let Ok(mut guard) = self.child.try_lock()
318 && let Some(child) = guard.as_mut()
319 {
320 let _ = child.start_kill();
321 }
322 }
323}
324
325#[cfg(test)]
326#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
327mod tests;