agent_sdk_core/application/
run_handle.rs1use std::{
7 collections::BTreeMap,
8 sync::{Arc, Mutex},
9 time::Duration,
10};
11
12use crate::{
13 domain::{AgentError, AgentId, RunId},
14 event::{EventCursor, EventDeliverySemantics, EventKind},
15 event_bus::AgentEventStream,
16 journal::{JournalCursor, JournalRecord, JournalRecordPayload},
17 run::{RunResult, RunStatus},
18 subscription::RunSubscriptionSource,
19};
20
21#[derive(Clone)]
22pub struct RunHandle {
25 run_id: RunId,
26 control: Arc<dyn RunControlStore>,
27 subscriptions: Arc<dyn RunSubscriptionSource>,
28}
29
30impl RunHandle {
31 pub fn new(
35 run_id: RunId,
36 control: Arc<dyn RunControlStore>,
37 subscriptions: Arc<dyn RunSubscriptionSource>,
38 ) -> Self {
39 Self {
40 run_id,
41 control,
42 subscriptions,
43 }
44 }
45
46 pub fn run_id(&self) -> &RunId {
49 &self.run_id
50 }
51
52 pub fn wait(&self) -> Result<RunResult, AgentError> {
56 self.consistent_terminal_result()?.ok_or_else(|| {
57 AgentError::contract_violation(
58 "run is not terminal until journal, handle status, and terminal event agree",
59 )
60 })
61 }
62
63 pub fn wait_with_timeout(&self, _timeout: Duration) -> Result<Option<RunResult>, AgentError> {
67 self.consistent_terminal_result()
68 }
69
70 pub fn status(&self) -> Result<RunStatus, AgentError> {
73 self.control.status(&self.run_id)
74 }
75
76 pub fn stream_from(&self, cursor: Option<EventCursor>) -> Result<AgentEventStream, AgentError> {
80 self.subscriptions
81 .subscribe_run(self.run_id.clone(), cursor)
82 }
83
84 pub fn stream_from_journal(
88 &self,
89 cursor: JournalCursor,
90 ) -> Result<AgentEventStream, AgentError> {
91 self.subscriptions
92 .replay_run_from_cursor(self.run_id.clone(), cursor)
93 }
94
95 pub fn cancel(&self) -> Result<(), AgentError> {
99 self.control.request_cancel(&self.run_id)
100 }
101
102 fn consistent_terminal_result(&self) -> Result<Option<RunResult>, AgentError> {
103 let Some(result) = self.control.terminal_result(&self.run_id)? else {
104 return Ok(None);
105 };
106 if !result.status.is_terminal() {
107 return Err(AgentError::contract_violation(
108 "terminal result carried non-terminal run status",
109 ));
110 }
111
112 let Some(frame) = self.subscriptions.latest_terminal_event(&self.run_id)? else {
113 return Ok(None);
114 };
115 let Some(event_status) = status_from_terminal_event_kind(&frame.event.envelope.event_kind)
116 else {
117 return Ok(None);
118 };
119 if event_status != result.status {
120 return Err(AgentError::contract_violation(
121 "terminal event status does not match sealed journal result",
122 ));
123 }
124 if !matches!(
125 frame.event.envelope.delivery_semantics,
126 EventDeliverySemantics::JournalBacked | EventDeliverySemantics::DerivedReplay
127 ) || frame.event.envelope.journal_cursor.is_none()
128 {
129 return Err(AgentError::contract_violation(
130 "terminal event must be journal-backed or derived from a journal cursor",
131 ));
132 }
133
134 Ok(Some(result))
135 }
136}
137
138impl core::fmt::Debug for RunHandle {
139 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
140 formatter
141 .debug_struct("RunHandle")
142 .field("run_id", &self.run_id)
143 .finish_non_exhaustive()
144 }
145}
146
147pub trait RunControlStore: Send + Sync {
152 fn status(&self, run_id: &RunId) -> Result<RunStatus, AgentError>;
155 fn terminal_result(&self, run_id: &RunId) -> Result<Option<RunResult>, AgentError>;
158 fn request_cancel(&self, run_id: &RunId) -> Result<(), AgentError>;
162}
163
164#[derive(Clone, Debug, Default)]
165pub struct InMemoryRunControlStore {
168 records: Arc<Mutex<BTreeMap<RunId, RunControlRecord>>>,
169}
170
171impl InMemoryRunControlStore {
172 pub fn register_run(&self, run_id: RunId, agent_id: AgentId) -> Result<(), AgentError> {
175 self.records
176 .lock()
177 .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?
178 .entry(run_id)
179 .or_insert_with(|| RunControlRecord::new(agent_id));
180 Ok(())
181 }
182
183 pub fn mark_visible_output_complete(
186 &self,
187 run_id: &RunId,
188 output: impl Into<String>,
189 ) -> Result<(), AgentError> {
190 let mut records = self
191 .records
192 .lock()
193 .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?;
194 let record = records
195 .get_mut(run_id)
196 .ok_or_else(|| AgentError::contract_violation("run is not registered"))?;
197 record.visible_output = Some(output.into());
198 Ok(())
199 }
200
201 pub fn seal_terminal_result_from_journal(
205 &self,
206 record: &JournalRecord,
207 output: impl Into<String>,
208 ) -> Result<RunResult, AgentError> {
209 let status = match &record.payload {
210 JournalRecordPayload::TerminalResult(marker) => {
211 RunStatus::from_terminal_str(&marker.terminal_status).ok_or_else(|| {
212 AgentError::contract_violation("journal terminal status is not recognized")
213 })?
214 }
215 _ => {
216 return Err(AgentError::contract_violation(
217 "journal record is not a terminal result marker",
218 ));
219 }
220 };
221
222 let result = RunResult::new(record.run_id.clone(), status.clone(), output);
223 let mut records = self
224 .records
225 .lock()
226 .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?;
227 let stored = records
228 .entry(record.run_id.clone())
229 .or_insert_with(|| RunControlRecord::new(record.agent_id.clone()));
230
231 if let Some(existing) = stored.final_result.as_ref() {
232 if existing != &result {
233 return Err(AgentError::contract_violation(
234 "journal terminal result conflicts with existing handle result",
235 ));
236 }
237 return Ok(existing.clone());
238 }
239
240 stored.status = status.clone();
241 stored.journal_terminal_status = Some(status);
242 stored.final_result = Some(result.clone());
243 Ok(result)
244 }
245
246 pub fn cancel_request_count(&self, run_id: &RunId) -> Result<usize, AgentError> {
249 Ok(self
250 .records
251 .lock()
252 .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?
253 .get(run_id)
254 .map(|record| record.cancel_request_count)
255 .unwrap_or(0))
256 }
257
258 pub fn visible_output(&self, run_id: &RunId) -> Result<Option<String>, AgentError> {
262 Ok(self
263 .records
264 .lock()
265 .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?
266 .get(run_id)
267 .and_then(|record| record.visible_output.clone()))
268 }
269}
270
271impl RunControlStore for InMemoryRunControlStore {
272 fn status(&self, run_id: &RunId) -> Result<RunStatus, AgentError> {
273 Ok(self
274 .records
275 .lock()
276 .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?
277 .get(run_id)
278 .map(|record| record.status.clone())
279 .unwrap_or(RunStatus::Pending))
280 }
281
282 fn terminal_result(&self, run_id: &RunId) -> Result<Option<RunResult>, AgentError> {
283 let records = self
284 .records
285 .lock()
286 .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?;
287 let Some(record) = records.get(run_id) else {
288 return Ok(None);
289 };
290 let Some(result) = record.final_result.clone() else {
291 return Ok(None);
292 };
293 if record.status != result.status
294 || record.journal_terminal_status.as_ref() != Some(&result.status)
295 {
296 return Err(AgentError::contract_violation(
297 "handle status and journal terminal record disagree",
298 ));
299 }
300 Ok(Some(result))
301 }
302
303 fn request_cancel(&self, run_id: &RunId) -> Result<(), AgentError> {
304 let mut records = self
305 .records
306 .lock()
307 .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?;
308 let record = records
309 .get_mut(run_id)
310 .ok_or_else(|| AgentError::contract_violation("run is not registered"))?;
311 if record.status.is_terminal() || record.cancel_requested {
312 return Ok(());
313 }
314 record.cancel_requested = true;
315 record.cancel_request_count += 1;
316 record.status = RunStatus::Cancelling;
317 Ok(())
318 }
319}
320
321#[derive(Clone, Debug)]
322struct RunControlRecord {
323 #[allow(dead_code)]
324 agent_id: AgentId,
325 status: RunStatus,
326 visible_output: Option<String>,
327 journal_terminal_status: Option<RunStatus>,
328 final_result: Option<RunResult>,
329 cancel_requested: bool,
330 cancel_request_count: usize,
331}
332
333impl RunControlRecord {
334 fn new(agent_id: AgentId) -> Self {
335 Self {
336 agent_id,
337 status: RunStatus::Running,
338 visible_output: None,
339 journal_terminal_status: None,
340 final_result: None,
341 cancel_requested: false,
342 cancel_request_count: 0,
343 }
344 }
345}
346
347fn status_from_terminal_event_kind(kind: &EventKind) -> Option<RunStatus> {
348 match kind {
349 EventKind::RunCompleted => Some(RunStatus::Completed),
350 EventKind::RunFailed => Some(RunStatus::Failed),
351 EventKind::RunCancelled => Some(RunStatus::Cancelled),
352 _ => None,
353 }
354}