1use async_trait::async_trait;
5use everruns_provider::{
6 LlmResponseStream, LlmStreamEvent,
7 error::{AgentLoopError, Result},
8 native_async::{Delivery, NativeAsyncCheckpoint, NativeToolCall, PendingCallState},
9};
10use futures::{StreamExt, stream::FuturesUnordered};
11use std::{collections::HashMap, sync::Arc};
12use tokio::sync::{Mutex, Semaphore};
13
14#[async_trait]
17pub trait NativeAsyncJournal: Send + Sync {
18 async fn load(&self) -> Result<NativeAsyncCheckpoint>;
19 async fn save(&self, checkpoint: &NativeAsyncCheckpoint) -> Result<()>;
21 async fn heartbeat(&self) -> Result<()> {
23 Ok(())
24 }
25 async fn release(&self) -> Result<()> {
26 Ok(())
27 }
28}
29
30#[derive(Debug, Clone)]
31pub struct NativeCallPolicy {
32 pub allow_async: bool,
33 pub replay_safe: bool,
34 pub concurrency_class: Option<String>,
35}
36
37#[async_trait]
41pub trait NativeAsyncExecutor: Send + Sync + 'static {
42 async fn authorize(&self, call: &NativeToolCall) -> Result<NativeCallPolicy>;
43 async fn execute(&self, call: NativeToolCall) -> Result<String>;
44}
45
46struct OwnedJob {
49 id: String,
50 handle: tokio::task::JoinHandle<(String, Result<String>)>,
51}
52impl Drop for OwnedJob {
53 fn drop(&mut self) {
54 self.handle.abort();
55 }
56}
57impl std::future::Future for OwnedJob {
58 type Output = (String, Result<String>);
59 fn poll(
60 mut self: std::pin::Pin<&mut Self>,
61 context: &mut std::task::Context<'_>,
62 ) -> std::task::Poll<Self::Output> {
63 match std::pin::Pin::new(&mut self.handle).poll(context) {
64 std::task::Poll::Ready(Ok(result)) => std::task::Poll::Ready(result),
65 std::task::Poll::Ready(Err(_)) => {
66 std::task::Poll::Ready((self.id.clone(), Err(AgentLoopError::Cancelled)))
67 }
68 std::task::Poll::Pending => std::task::Poll::Pending,
69 }
70 }
71}
72
73pub struct NativeAsyncCoordinator {
74 journal: Box<dyn NativeAsyncJournal>,
75 executor: Arc<dyn NativeAsyncExecutor>,
76 checkpoint: NativeAsyncCheckpoint,
77 jobs: FuturesUnordered<OwnedJob>,
78 permits: Arc<Semaphore>,
79 classes: HashMap<String, Arc<Mutex<()>>>,
80 serialize_all: bool,
81 poisoned: bool,
82 last_heartbeat: tokio::time::Instant,
83}
84
85impl NativeAsyncCoordinator {
86 pub async fn open(
90 journal: Box<dyn NativeAsyncJournal>,
91 executor: Arc<dyn NativeAsyncExecutor>,
92 max_concurrency: usize,
93 parallel_tool_calls: bool,
94 ) -> Result<Self> {
95 let mut checkpoint = journal.load().await?;
96 checkpoint.recover()?;
97 journal.save(&checkpoint).await?;
98 let mut this = Self {
99 journal,
100 executor,
101 checkpoint,
102 jobs: FuturesUnordered::new(),
103 permits: Arc::new(Semaphore::new(max_concurrency.max(1))),
104 classes: HashMap::new(),
105 serialize_all: !parallel_tool_calls,
106 poisoned: false,
107 last_heartbeat: tokio::time::Instant::now(),
108 };
109 let queued: Vec<_> = this
110 .checkpoint
111 .order
112 .iter()
113 .filter_map(|id| this.checkpoint.calls.get(id))
114 .filter(|pending| pending.state == PendingCallState::Queued)
115 .map(|pending| pending.call.clone())
116 .collect();
117 for call in queued {
118 let policy = this.executor.authorize(&call).await?;
119 this.launch(call, policy).await?;
120 }
121 Ok(this)
122 }
123
124 pub fn checkpoint(&self) -> &NativeAsyncCheckpoint {
125 &self.checkpoint
126 }
127
128 fn healthy(&self) -> Result<()> {
129 if self.poisoned {
130 Err(AgentLoopError::store(
131 "native coordinator lost durable state; reopen under a fresh ownership fence",
132 ))
133 } else {
134 Ok(())
135 }
136 }
137
138 async fn save(&mut self) -> Result<()> {
139 match self.journal.save(&self.checkpoint).await {
140 Ok(()) => {
141 self.last_heartbeat = tokio::time::Instant::now();
142 Ok(())
143 }
144 Err(error) => {
145 self.poisoned = true;
146 self.jobs.clear();
147 Err(error)
148 }
149 }
150 }
151
152 async fn heartbeat(&mut self) -> Result<()> {
153 if let Err(error) = self.journal.heartbeat().await {
154 self.poisoned = true;
155 self.jobs.clear();
156 return Err(error);
157 }
158 self.last_heartbeat = tokio::time::Instant::now();
159 Ok(())
160 }
161
162 pub async fn persist_host_outcome(&mut self, outcome: serde_json::Value) -> Result<()> {
163 self.healthy()?;
164 if !self.checkpoint.can_complete() {
165 return Err(AgentLoopError::store("native outputs remain pending"));
166 }
167 self.checkpoint.host_outcome = Some(outcome);
168 self.save().await
169 }
170
171 pub async fn release(&mut self) -> Result<()> {
172 self.healthy()?;
173 if !self.checkpoint.can_complete() {
174 return Err(AgentLoopError::store(
175 "cannot release native conversation with pending outputs",
176 ));
177 }
178 self.journal.release().await?;
179 self.poisoned = true;
180 Ok(())
181 }
182
183 async fn launch(&mut self, call: NativeToolCall, policy: NativeCallPolicy) -> Result<()> {
184 if call.is_async() && !policy.allow_async {
185 return Err(AgentLoopError::config(
186 "tool is not authorized for native asynchronous execution",
187 ));
188 }
189 if self
190 .checkpoint
191 .calls
192 .get(call.id())
193 .is_some_and(|pending| pending.replay_safe != policy.replay_safe)
194 {
195 return Err(AgentLoopError::config(
196 "native replay policy changed; explicit reconciliation required",
197 ));
198 }
199 self.checkpoint.start(call.id())?;
200 self.save().await?;
201 let class = if self.serialize_all {
202 Some(String::new())
203 } else {
204 policy.concurrency_class
205 };
206 let lock = class.map(|class| {
207 self.classes
208 .entry(class)
209 .or_insert_with(|| Arc::new(Mutex::new(())))
210 .clone()
211 });
212 let permits = self.permits.clone();
213 let executor = self.executor.clone();
214 let call_id = call.id().to_owned();
215 let handle = tokio::spawn(async move {
216 let _class_guard = match lock {
217 Some(lock) => Some(lock.lock_owned().await),
218 None => None,
219 };
220 let _permit = permits
221 .acquire()
222 .await
223 .expect("coordinator never closes permits");
224 let id = call.id().to_owned();
225 (id, executor.execute(call).await)
226 });
227 self.jobs.push(OwnedJob {
228 id: call_id,
229 handle,
230 });
231 Ok(())
232 }
233
234 pub async fn register(&mut self, call: NativeToolCall) -> Result<()> {
235 self.healthy()?;
236 let policy = self.executor.authorize(&call).await?;
237 if call.is_async() && !policy.allow_async {
238 return Err(AgentLoopError::config(
239 "tool is not authorized for native asynchronous execution",
240 ));
241 }
242 if !self.checkpoint.register(call.clone(), policy.replay_safe)? {
243 return Ok(());
244 }
245 self.save().await?;
246 if self.checkpoint.response_in_flight && !call.is_async() {
248 return Ok(());
249 }
250 self.launch(call, policy).await
251 }
252
253 async fn launch_synchronous_calls(&mut self) -> Result<()> {
254 let queued: Vec<_> = self
255 .checkpoint
256 .order
257 .iter()
258 .filter_map(|id| self.checkpoint.calls.get(id))
259 .filter(|pending| !pending.call.is_async() && pending.state == PendingCallState::Queued)
260 .map(|pending| pending.call.clone())
261 .collect();
262 for call in queued {
263 let policy = self.executor.authorize(&call).await?;
264 self.launch(call, policy).await?;
265 }
266 Ok(())
267 }
268
269 async fn settle(&mut self, id: String, result: Result<String>) -> Result<()> {
270 let output = result
272 .unwrap_or_else(|error| serde_json::json!({"error":error.to_string()}).to_string());
273 self.checkpoint.settle(&id, output)?;
274 self.save().await
275 }
276
277 pub async fn begin_transcript_response(&mut self, message_id: String) -> Result<()> {
278 self.healthy()?;
279 if self.checkpoint.transcript_message_id.is_some() {
280 return Err(AgentLoopError::store("native transcript is not committed"));
281 }
282 self.checkpoint.transcript_message_id = Some(message_id);
283 self.begin_response().await
284 }
285
286 pub async fn stage_transcript_result(&mut self, result: serde_json::Value) -> Result<()> {
287 self.healthy()?;
288 if self.checkpoint.response_in_flight || self.checkpoint.transcript_message_id.is_none() {
289 return Err(AgentLoopError::store(
290 "native response is not ready for transcript commit",
291 ));
292 }
293 if self.checkpoint.host_responses.len() + 1 != self.checkpoint.completed_responses as usize
294 {
295 return Err(AgentLoopError::store(
296 "native response summary count mismatch",
297 ));
298 }
299 self.checkpoint.host_responses.push(result);
300 self.save().await
301 }
302
303 pub async fn transcript_committed(&mut self, message_id: &str) -> Result<()> {
304 self.healthy()?;
305 if self.checkpoint.response_in_flight
306 || self.checkpoint.transcript_message_id.as_deref() != Some(message_id)
307 {
308 return Err(AgentLoopError::store("native transcript boundary mismatch"));
309 }
310 self.checkpoint.transcript_message_id = None;
311 self.save().await
312 }
313
314 pub async fn begin_response(&mut self) -> Result<()> {
316 self.healthy()?;
317 if self.checkpoint.response_in_flight {
318 return Err(AgentLoopError::store(
319 "prior native response requires reconciliation",
320 ));
321 }
322 self.checkpoint.response_in_flight = true;
323 self.save().await
324 }
325
326 pub async fn next_response_event(
329 &mut self,
330 stream: &mut LlmResponseStream,
331 ) -> Result<LlmStreamEvent> {
332 self.healthy()?;
333 let result = self.next_response_event_inner(stream).await;
334 if result.is_err() {
335 self.poisoned = true;
336 self.jobs.clear();
337 }
338 result
339 }
340
341 async fn next_response_event_inner(
342 &mut self,
343 stream: &mut LlmResponseStream,
344 ) -> Result<LlmStreamEvent> {
345 if !self.checkpoint.response_in_flight {
346 return Err(AgentLoopError::store(
347 "native response has no persisted request intent",
348 ));
349 }
350 loop {
351 tokio::select! {
352 _ = tokio::time::sleep_until(self.last_heartbeat + std::time::Duration::from_secs(10)) => self.heartbeat().await?,
353 completed = self.jobs.next(), if !self.jobs.is_empty() => {
354 let (id, result) = completed.expect("nonempty jobs");
355 self.settle(id, result).await?;
356 }
357 event = stream.next() => {
358 let event = event.ok_or_else(|| AgentLoopError::llm("native response stream ended before completion"))??;
359 match &event {
360 LlmStreamEvent::NativeToolCall(call) => self.register(call.clone()).await?,
361 LlmStreamEvent::ToolCalls(calls) => for call in calls {
362 self.register(NativeToolCall::Function { call_id: call.id.clone(), name: call.name.clone(), arguments: serde_json::to_string(&call.arguments).map_err(|error| AgentLoopError::config(error.to_string()))?, asynchronous: false }).await?;
363 },
364 LlmStreamEvent::Done(metadata) => {
365 let id = metadata.response_id.clone().ok_or_else(|| AgentLoopError::llm("native response omitted response ID"))?;
366 if metadata.finish_reason.as_deref().is_some_and(|reason| !matches!(reason, "stop" | "tool_calls" | "end_turn")) { return Err(AgentLoopError::llm("native response did not finish successfully")); }
367 if self.checkpoint.delivery.is_some() { self.checkpoint.acknowledge_delivery(id)?; } else { self.checkpoint.response_completed(id)?; }
368 self.checkpoint.response_in_flight = false;
369 self.save().await?;
370 self.launch_synchronous_calls().await?;
371 }
372 LlmStreamEvent::Error(error) => return Err(AgentLoopError::llm(error.to_string())),
373 _ => {}
374 }
375 return Ok(event);
376 }
377 }
378 }
379 }
380
381 pub async fn pump(
386 &mut self,
387 stream: LlmResponseStream,
388 mut observe: impl FnMut(LlmStreamEvent),
389 ) -> Result<()> {
390 self.healthy()?;
391 if self.checkpoint.response_in_flight {
392 return Err(AgentLoopError::store(
393 "prior native response requires reconciliation",
394 ));
395 }
396 let result = self.pump_inner(stream, &mut observe).await;
397 if result.is_err() {
398 self.poisoned = true;
399 self.jobs.clear();
400 }
401 result
402 }
403
404 async fn pump_inner(
405 &mut self,
406 mut stream: LlmResponseStream,
407 mut observe: impl FnMut(LlmStreamEvent),
408 ) -> Result<()> {
409 self.begin_response().await?;
410 loop {
411 let event = self.next_response_event(&mut stream).await?;
412 match event {
413 LlmStreamEvent::NativeToolCall(_) | LlmStreamEvent::ToolCalls(_) => {}
414 LlmStreamEvent::Done(_) => {
415 observe(event);
416 return Ok(());
417 }
418 other => observe(other),
419 }
420 }
421 }
422
423 pub async fn wait_next(&mut self) -> Result<bool> {
425 self.healthy()?;
426 let mut lease_tick = tokio::time::interval(std::time::Duration::from_secs(10));
427 loop {
428 tokio::select! {
429 _ = lease_tick.tick() => self.heartbeat().await?,
430 completed = self.jobs.next() => {
431 if let Some((id, result)) = completed {
432 self.settle(id, result).await?;
433 return Ok(true);
434 }
435 return Ok(false);
436 }
437 }
438 }
439 }
440
441 pub async fn prepare_delivery(&mut self) -> Result<Option<Delivery>> {
444 self.healthy()?;
445 while self.checkpoint.calls.values().any(|pending| {
446 !pending.call.is_async()
447 && matches!(
448 pending.state,
449 PendingCallState::Queued | PendingCallState::Running
450 )
451 }) {
452 if !self.wait_next().await? {
453 return Err(AgentLoopError::store("synchronous call has no running job"));
454 }
455 }
456 let delivery = self.checkpoint.prepare_delivery()?.cloned();
457 self.save().await?;
458 Ok(delivery)
459 }
460
461 pub async fn cancel(&mut self) -> Result<()> {
464 self.healthy()?;
465 for job in self.jobs.iter() {
466 job.handle.abort();
467 }
468 while let Some((id, result)) = self.jobs.next().await {
469 if !matches!(result, Err(AgentLoopError::Cancelled)) {
470 let output = result.unwrap_or_else(|error| {
471 serde_json::json!({"error":error.to_string()}).to_string()
472 });
473 self.checkpoint.settle(&id, output)?;
474 }
475 }
476 self.checkpoint.cancel();
477 self.save().await
478 }
479}
480
481impl NativeAsyncCoordinator {
482 pub async fn run<Request, RequestFuture>(
486 &mut self,
487 max_responses: usize,
488 mut request: Request,
489 mut observe: impl FnMut(LlmStreamEvent),
490 ) -> Result<()>
491 where
492 Request: FnMut(Option<Delivery>, Option<String>) -> RequestFuture,
493 RequestFuture: std::future::Future<Output = Result<LlmResponseStream>>,
494 {
495 self.healthy()?;
496 for _ in 0..max_responses {
497 let mut delivery = self.prepare_delivery().await?;
498 if delivery.is_none()
501 && self.checkpoint.latest_response_id.is_some()
502 && !self.jobs.is_empty()
503 {
504 self.wait_next().await?;
505 delivery = self.prepare_delivery().await?;
506 }
507 let response = request(delivery, self.checkpoint.latest_response_id.clone());
508 tokio::pin!(response);
509 let mut lease_tick = tokio::time::interval(std::time::Duration::from_secs(10));
510 let stream = loop {
511 tokio::select! {
512 result = &mut response => break result?,
513 _ = lease_tick.tick() => self.heartbeat().await?,
514 }
515 };
516 self.pump(stream, &mut observe).await?;
517 if self.checkpoint.can_complete() {
518 return Ok(());
519 }
520 if !self
521 .checkpoint
522 .calls
523 .values()
524 .any(|pending| matches!(pending.state, PendingCallState::Ready { .. }))
525 {
526 self.wait_next().await?;
527 }
528 }
529 Err(AgentLoopError::config(
530 "native async response limit reached; pending work remains checkpointed",
531 ))
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use everruns_provider::LlmCompletionMetadata;
539 use std::{
540 sync::{
541 Mutex as StdMutex,
542 atomic::{AtomicUsize, Ordering},
543 },
544 time::Duration,
545 };
546
547 #[derive(Clone, Default)]
548 struct MemoryJournal(Arc<StdMutex<NativeAsyncCheckpoint>>);
549 #[async_trait]
550 impl NativeAsyncJournal for MemoryJournal {
551 async fn load(&self) -> Result<NativeAsyncCheckpoint> {
552 Ok(self.0.lock().unwrap().clone())
553 }
554 async fn save(&self, checkpoint: &NativeAsyncCheckpoint) -> Result<()> {
555 *self.0.lock().unwrap() = checkpoint.clone();
556 Ok(())
557 }
558 }
559 #[derive(Default)]
560 struct Executor {
561 started: AtomicUsize,
562 active: AtomicUsize,
563 maximum: AtomicUsize,
564 }
565 #[async_trait]
566 impl NativeAsyncExecutor for Executor {
567 async fn authorize(&self, call: &NativeToolCall) -> Result<NativeCallPolicy> {
568 if call.name() == "forbidden" {
569 return Err(AgentLoopError::tool("not authorized"));
570 }
571 Ok(NativeCallPolicy {
572 allow_async: true,
573 replay_safe: true,
574 concurrency_class: None,
575 })
576 }
577 async fn execute(&self, call: NativeToolCall) -> Result<String> {
578 self.started.fetch_add(1, Ordering::SeqCst);
579 let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
580 self.maximum.fetch_max(active, Ordering::SeqCst);
581 struct Active<'a>(&'a AtomicUsize);
582 impl Drop for Active<'_> {
583 fn drop(&mut self) {
584 self.0.fetch_sub(1, Ordering::SeqCst);
585 }
586 }
587 let _active = Active(&self.active);
588 tokio::time::sleep(Duration::from_millis(if call.id() == "slow" {
589 100
590 } else {
591 1
592 }))
593 .await;
594 Ok(call.id().into())
595 }
596 }
597 fn call(id: &str, asynchronous: bool) -> NativeToolCall {
598 NativeToolCall::Function {
599 call_id: id.into(),
600 name: "lookup".into(),
601 arguments: "{}".into(),
602 asynchronous,
603 }
604 }
605 fn done(id: &str) -> LlmStreamEvent {
606 LlmStreamEvent::Done(Box::new({
607 let mut metadata = LlmCompletionMetadata::default();
608 metadata.response_id = Some(id.into());
609 metadata
610 }))
611 }
612 fn stream(events: Vec<LlmStreamEvent>) -> LlmResponseStream {
613 Box::pin(futures::stream::iter(events.into_iter().map(Ok)))
614 }
615
616 #[tokio::test]
617 async fn synchronous_calls_wait_for_successful_response_completion() {
618 for rejected in [false, true] {
619 let executor = Arc::new(Executor::default());
620 let mut coordinator = NativeAsyncCoordinator::open(
621 Box::new(MemoryJournal::default()),
622 executor.clone(),
623 2,
624 true,
625 )
626 .await
627 .unwrap();
628 coordinator.begin_response().await.unwrap();
629 let mut terminal = done("response");
630 if rejected && let LlmStreamEvent::Done(metadata) = &mut terminal {
631 metadata.finish_reason = Some("length".into());
632 }
633 let mut response = stream(vec![
634 LlmStreamEvent::NativeToolCall(call("sync", false)),
635 terminal,
636 ]);
637 coordinator
638 .next_response_event(&mut response)
639 .await
640 .unwrap();
641 assert_eq!(
642 coordinator.checkpoint().calls["sync"].state,
643 PendingCallState::Queued,
644 "synchronous calls must not gain early execution from native opt-in"
645 );
646 let result = coordinator.next_response_event(&mut response).await;
647 if rejected {
648 assert!(result.is_err());
649 assert!(
650 coordinator.checkpoint().clone().recover().is_err(),
651 "recovery must not execute rejected calls"
652 );
653 assert_eq!(executor.started.load(Ordering::SeqCst), 0);
654 } else {
655 result.unwrap();
656 assert!(coordinator.wait_next().await.unwrap());
657 assert_eq!(executor.started.load(Ordering::SeqCst), 1);
658 }
659 }
660 }
661
662 #[tokio::test]
663 async fn early_dispatch_mixed_calls_out_of_order_and_independent_work() {
664 let journal = MemoryJournal::default();
665 let executor = Arc::new(Executor::default());
666 let mut coordinator =
667 NativeAsyncCoordinator::open(Box::new(journal.clone()), executor.clone(), 2, true)
668 .await
669 .unwrap();
670 let (sender, receiver) = futures::channel::mpsc::unbounded();
671 sender
672 .unbounded_send(Ok(LlmStreamEvent::NativeToolCall(call("slow", true))))
673 .unwrap();
674 let observed = executor.clone();
675 let producer = async move {
676 while observed.started.load(Ordering::SeqCst) == 0 {
678 tokio::task::yield_now().await;
679 }
680 sender
681 .unbounded_send(Ok(LlmStreamEvent::NativeToolCall(call("fast", true))))
682 .unwrap();
683 sender
684 .unbounded_send(Ok(LlmStreamEvent::TextDelta("independent answer".into())))
685 .unwrap();
686 sender.unbounded_send(Ok(done("launch"))).unwrap();
687 };
688 let mut text = String::new();
689 let (result, _) = tokio::join!(
690 coordinator.pump(Box::pin(receiver), |event| {
691 if let LlmStreamEvent::TextDelta(delta) = event {
692 text.push_str(&delta);
693 }
694 }),
695 producer
696 );
697 result.unwrap();
698 assert_eq!(text, "independent answer");
699 assert!(!coordinator.checkpoint().can_complete());
700 coordinator.wait_next().await.unwrap();
701 coordinator
702 .pump(stream(vec![done("independent_followup")]), |_| {})
703 .await
704 .unwrap();
705 let delivery = coordinator.prepare_delivery().await.unwrap().unwrap();
706 assert_eq!(delivery.previous_response_id, "independent_followup");
707 assert_eq!(delivery.call_ids, vec!["fast"]);
708 coordinator
709 .pump(
710 stream(vec![
711 LlmStreamEvent::NativeToolCall(call("sync", false)),
712 done("fast_receipt"),
713 ]),
714 |_| {},
715 )
716 .await
717 .unwrap();
718 let next = coordinator.prepare_delivery().await.unwrap().unwrap();
719 assert!(next.call_ids.contains(&"sync".to_string()));
720 coordinator
721 .pump(stream(vec![done("sync_receipt")]), |_| {})
722 .await
723 .unwrap();
724 while coordinator.wait_next().await.unwrap() {}
725 coordinator.prepare_delivery().await.unwrap();
726 coordinator
727 .pump(stream(vec![done("final")]), |_| {})
728 .await
729 .unwrap();
730 assert!(coordinator.checkpoint().can_complete());
731 assert!(executor.maximum.load(Ordering::SeqCst) <= 2);
732 }
733
734 #[tokio::test]
735 async fn restart_cancellation_duplicate_calls_and_serial_limit() {
736 let journal = MemoryJournal::default();
737 let executor = Arc::new(Executor::default());
738 let mut coordinator =
739 NativeAsyncCoordinator::open(Box::new(journal.clone()), executor.clone(), 8, false)
740 .await
741 .unwrap();
742 coordinator.register(call("slow", true)).await.unwrap();
743 coordinator.register(call("slow", true)).await.unwrap();
744 coordinator.register(call("fast", true)).await.unwrap();
745 coordinator
746 .pump(stream(vec![done("before_restart")]), |_| {})
747 .await
748 .unwrap();
749 drop(coordinator);
750 let mut recovered =
751 NativeAsyncCoordinator::open(Box::new(journal), executor.clone(), 8, false)
752 .await
753 .unwrap();
754 while recovered.wait_next().await.unwrap() {}
755 assert!(executor.maximum.load(Ordering::SeqCst) <= 1);
756 let delivery = recovered.prepare_delivery().await.unwrap().unwrap();
757 assert_eq!(delivery.call_ids.len(), 2);
758 recovered
759 .pump(stream(vec![done("receipt")]), |_| {})
760 .await
761 .unwrap();
762 recovered.register(call("cancel", true)).await.unwrap();
763 recovered.cancel().await.unwrap();
764 assert!(!recovered.wait_next().await.unwrap());
765 assert!(!recovered.checkpoint().can_complete());
766 assert!(
767 recovered.prepare_delivery().await.unwrap().unwrap().input[0]["output"]
768 .as_str()
769 .unwrap()
770 .contains("cancelled")
771 );
772 }
773
774 #[tokio::test]
775 async fn failed_journal_write_prevents_dispatch() {
776 struct FailsAfterOpen(AtomicUsize);
777 #[async_trait]
778 impl NativeAsyncJournal for FailsAfterOpen {
779 async fn load(&self) -> Result<NativeAsyncCheckpoint> {
780 Ok(NativeAsyncCheckpoint::default())
781 }
782 async fn save(&self, _: &NativeAsyncCheckpoint) -> Result<()> {
783 if self.0.fetch_add(1, Ordering::SeqCst) == 0 {
784 Ok(())
785 } else {
786 Err(AgentLoopError::store("disk unavailable"))
787 }
788 }
789 }
790 let executor = Arc::new(Executor::default());
791 let mut coordinator = NativeAsyncCoordinator::open(
792 Box::new(FailsAfterOpen(AtomicUsize::new(0))),
793 executor.clone(),
794 1,
795 true,
796 )
797 .await
798 .unwrap();
799 assert!(
800 coordinator
801 .register(call("must_not_run", true))
802 .await
803 .is_err()
804 );
805 assert_eq!(executor.started.load(Ordering::SeqCst), 0);
806 assert!(coordinator.wait_next().await.is_err());
807 assert!(
808 coordinator
809 .register(call("must_not_run", true))
810 .await
811 .is_err()
812 );
813 }
814
815 #[tokio::test]
816 async fn authorization_and_incomplete_stream_do_not_silently_finish() {
817 let journal = MemoryJournal::default();
818 let executor = Arc::new(Executor::default());
819 let mut coordinator =
820 NativeAsyncCoordinator::open(Box::new(journal.clone()), executor.clone(), 1, true)
821 .await
822 .unwrap();
823 let forbidden = NativeToolCall::Function {
824 call_id: "bad".into(),
825 name: "forbidden".into(),
826 arguments: "{}".into(),
827 asynchronous: true,
828 };
829 assert!(coordinator.register(forbidden).await.is_err());
830 assert!(coordinator.checkpoint().calls.is_empty());
831 assert!(
832 coordinator
833 .pump(
834 stream(vec![LlmStreamEvent::NativeToolCall(call("pending", true))]),
835 |_| {}
836 )
837 .await
838 .is_err()
839 );
840 drop(coordinator);
841 assert!(
842 NativeAsyncCoordinator::open(Box::new(journal), executor, 1, true)
843 .await
844 .is_err()
845 );
846 }
847 #[tokio::test]
848 async fn native_journal_and_jobs_share_transport_without_deadlocking() {
849 struct Journal {
850 memory: MemoryJournal,
851 transport: Arc<Mutex<()>>,
852 }
853 #[async_trait]
854 impl NativeAsyncJournal for Journal {
855 async fn load(&self) -> Result<NativeAsyncCheckpoint> {
856 self.memory.load().await
857 }
858 async fn save(&self, state: &NativeAsyncCheckpoint) -> Result<()> {
859 let _transport = self.transport.lock().await;
860 self.memory.save(state).await
861 }
862 }
863 struct Tool {
864 transport: Arc<Mutex<()>>,
865 started: Arc<tokio::sync::Notify>,
866 }
867 #[async_trait]
868 impl NativeAsyncExecutor for Tool {
869 async fn authorize(&self, _: &NativeToolCall) -> Result<NativeCallPolicy> {
870 Ok(NativeCallPolicy {
871 allow_async: true,
872 replay_safe: true,
873 concurrency_class: None,
874 })
875 }
876 async fn execute(&self, call: NativeToolCall) -> Result<String> {
877 let _transport = self.transport.lock().await;
878 self.started.notify_one();
879 tokio::time::sleep(Duration::from_millis(20)).await;
880 Ok(call.id().into())
881 }
882 }
883 let transport = Arc::new(Mutex::new(()));
884 let started = Arc::new(tokio::sync::Notify::new());
885 let journal = Journal {
886 memory: MemoryJournal::default(),
887 transport: transport.clone(),
888 };
889 let tool = Arc::new(Tool {
890 transport,
891 started: started.clone(),
892 });
893 let mut coordinator = NativeAsyncCoordinator::open(Box::new(journal), tool, 2, true)
894 .await
895 .unwrap();
896 let (sender, receiver) = futures::channel::mpsc::unbounded();
897 sender
898 .unbounded_send(Ok(LlmStreamEvent::NativeToolCall(call("first", true))))
899 .unwrap();
900 let sender_task = tokio::spawn(async move {
901 started.notified().await;
902 sender
903 .unbounded_send(Ok(LlmStreamEvent::NativeToolCall(call("second", true))))
904 .unwrap();
905 sender.unbounded_send(Ok(done("response"))).unwrap();
906 });
907 tokio::time::timeout(
908 Duration::from_secs(1),
909 coordinator.pump(Box::pin(receiver), |_| {}),
910 )
911 .await
912 .expect("journal writes must not stop the job that owns their transport")
913 .unwrap();
914 sender_task.await.unwrap();
915 while coordinator.wait_next().await.unwrap() {}
916 assert!(
917 coordinator
918 .checkpoint()
919 .calls
920 .values()
921 .all(|pending| matches!(pending.state, PendingCallState::Ready { .. }))
922 );
923 }
924}