1use std::{
4 collections::BTreeMap,
5 sync::{
6 Arc,
7 atomic::{AtomicBool, Ordering},
8 },
9 time::{Duration, Instant},
10};
11
12use async_trait::async_trait;
13use runtime_api_contract::{
14 CreateExecutionRequest, CreateExecutionResponse, EventPage, EventPayload, ExecutionFailure,
15 ExecutionOptions, ExecutionOutcome, ExecutionState, ExecutionView,
16 ModelGenerationOptions as ApiModelGenerationOptions, ModelToolChoice as ApiModelToolChoice,
17 RuntimeInput, SubmitInputRequest,
18};
19use runtime_kernel_api::{
20 AgentDefinitionResolver, ContextLimits, KernelEvent, KernelEventSink, KernelFailure,
21 KernelLimits, KernelSpec, RuntimeKernel,
22};
23use runtime_link_api::{
24 ExecutionJournal, JournalDelegation, JournalError, JournalReservation, LinkError,
25 NewJournalExecution, RuntimeLink,
26};
27use runtime_ports::{
28 CommitDisposition, ExecutionDelegationLease, ExecutionSessionFactory, InteractionRequest,
29 InteractionResponse, InteractionSession, ModelGenerationOptions, ModelToolChoice,
30 OperationContext, PortFailure, PortFailureKind, ResolvedExecutionContext,
31 RuntimeInstanceResolver, SessionScope, SubagentOutcome, SubagentRequest, SubagentSession,
32 TraceSession,
33};
34use runtime_types::{ExecutionId, OperationId, RequestAuthority};
35use sha2::{Digest, Sha256};
36use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore};
37use tokio_util::{sync::CancellationToken, task::TaskTracker};
38
39#[derive(Clone)]
40pub struct ExecutionCoordinator {
41 journal: Arc<dyn ExecutionJournal>,
42 instance_resolver: Arc<dyn RuntimeInstanceResolver>,
43 session_factory: Arc<dyn ExecutionSessionFactory>,
44 definition_resolver: Arc<dyn AgentDefinitionResolver>,
45 kernel: Arc<dyn RuntimeKernel>,
46 context_limits: ContextLimits,
47 capacity: Arc<Semaphore>,
48 cancellations: Arc<Mutex<BTreeMap<ExecutionId, CancellationToken>>>,
49 authorities: Arc<Mutex<BTreeMap<ExecutionId, RequestAuthority>>>,
50 accepting: Arc<AtomicBool>,
51 tasks: TaskTracker,
52 recovery_tasks: TaskTracker,
53 recovery_stop: CancellationToken,
54 recovery_started: Arc<AtomicBool>,
55 worker_id: Arc<str>,
56}
57
58impl ExecutionCoordinator {
59 pub fn new(
60 journal: Arc<dyn ExecutionJournal>,
61 instance_resolver: Arc<dyn RuntimeInstanceResolver>,
62 session_factory: Arc<dyn ExecutionSessionFactory>,
63 definition_resolver: Arc<dyn AgentDefinitionResolver>,
64 kernel: Arc<dyn RuntimeKernel>,
65 max_concurrent_executions: usize,
66 ) -> Result<Self, LinkError> {
67 Self::new_with_context_limits(
68 journal,
69 instance_resolver,
70 session_factory,
71 definition_resolver,
72 kernel,
73 max_concurrent_executions,
74 ContextLimits::default(),
75 )
76 }
77
78 #[allow(clippy::too_many_arguments)]
79 pub fn new_with_context_limits(
80 journal: Arc<dyn ExecutionJournal>,
81 instance_resolver: Arc<dyn RuntimeInstanceResolver>,
82 session_factory: Arc<dyn ExecutionSessionFactory>,
83 definition_resolver: Arc<dyn AgentDefinitionResolver>,
84 kernel: Arc<dyn RuntimeKernel>,
85 max_concurrent_executions: usize,
86 context_limits: ContextLimits,
87 ) -> Result<Self, LinkError> {
88 if max_concurrent_executions == 0 {
89 return Err(LinkError::Invalid(
90 "max_concurrent_executions must be positive".into(),
91 ));
92 }
93 Ok(Self {
94 journal,
95 instance_resolver,
96 session_factory,
97 definition_resolver,
98 kernel,
99 context_limits,
100 capacity: Arc::new(Semaphore::new(max_concurrent_executions)),
101 cancellations: Arc::new(Mutex::new(BTreeMap::new())),
102 authorities: Arc::new(Mutex::new(BTreeMap::new())),
103 accepting: Arc::new(AtomicBool::new(true)),
104 tasks: TaskTracker::new(),
105 recovery_tasks: TaskTracker::new(),
106 recovery_stop: CancellationToken::new(),
107 recovery_started: Arc::new(AtomicBool::new(false)),
108 worker_id: format!("runtime-worker-{}", ExecutionId::random().as_str()).into(),
109 })
110 }
111
112 pub async fn recover(&self, limit: usize) -> Result<usize, LinkError> {
116 let now = chrono::Utc::now().timestamp_millis();
117 let records = self
118 .journal
119 .recoverable(now, limit)
120 .await
121 .map_err(map_journal)?;
122 let mut scheduled = 0;
123 for record in records {
124 let Ok(permit) = self.capacity.clone().try_acquire_owned() else {
125 break;
126 };
127 scheduled += 1;
128 if record.view.state == ExecutionState::Queued && record.delegation.is_some() {
129 self.tasks
130 .spawn(self.clone().run(record.view.id.clone(), permit));
131 } else {
132 self.tasks.spawn(
133 self.clone()
134 .recover_interrupted(record.view.id.clone(), permit),
135 );
136 }
137 }
138 Ok(scheduled)
139 }
140
141 pub fn start_recovery_loop(
144 &self,
145 interval: Duration,
146 batch_size: usize,
147 ) -> Result<(), LinkError> {
148 if interval.is_zero() || batch_size == 0 || batch_size > 10_000 {
149 return Err(LinkError::Invalid(
150 "recovery interval and batch size must be bounded and positive".into(),
151 ));
152 }
153 if self
154 .recovery_started
155 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
156 .is_err()
157 {
158 return Ok(());
159 }
160 let coordinator = self.clone();
161 let stop = self.recovery_stop.clone();
162 self.recovery_tasks.spawn(async move {
163 let mut ticker = tokio::time::interval(interval);
164 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
165 ticker.tick().await;
168 loop {
169 tokio::select! {
170 _ = stop.cancelled() => return,
171 _ = ticker.tick() => {
172 if !coordinator.accepting.load(Ordering::SeqCst) {
173 return;
174 }
175 if let Err(error) = coordinator.recover(batch_size).await {
176 tracing::warn!(%error, "periodic execution recovery scan failed");
177 }
178 }
179 }
180 }
181 });
182 Ok(())
183 }
184
185 pub fn begin_shutdown(&self) {
188 self.accepting.store(false, Ordering::SeqCst);
189 self.recovery_stop.cancel();
190 self.recovery_tasks.close();
191 self.tasks.close();
192 }
193
194 pub async fn shutdown(&self, grace: Duration) -> bool {
197 self.begin_shutdown();
198 if tokio::time::timeout(grace, async {
199 tokio::join!(self.recovery_tasks.wait(), self.tasks.wait());
200 })
201 .await
202 .is_ok()
203 {
204 return true;
205 }
206 for cancellation in self.cancellations.lock().await.values() {
207 cancellation.cancel();
208 }
209 tokio::time::timeout(Duration::from_secs(5), async {
210 tokio::join!(self.recovery_tasks.wait(), self.tasks.wait());
211 })
212 .await
213 .is_ok()
214 }
215
216 pub fn active_executions(&self) -> usize {
217 self.tasks.len()
218 }
219
220 async fn run(self, execution_id: ExecutionId, permit: OwnedSemaphorePermit) {
221 let _permit = permit;
222 let now = chrono::Utc::now().timestamp_millis();
223 let expires = now.saturating_add(30_000);
224 if self
225 .journal
226 .claim(&execution_id, &self.worker_id, now, expires)
227 .await
228 .is_err()
229 {
230 return;
231 }
232 if let Err(error) = self.run_with_claim_heartbeat(&execution_id).await {
233 tracing::error!(execution_id=%execution_id, code=%error.code, message=%error.message, "execution failed");
234 if error.code == "WORKER_CLAIM_LOST" {
235 self.cancellations.lock().await.remove(&execution_id);
239 self.authorities.lock().await.remove(&execution_id);
240 return;
241 }
242 if error.commit == CommitDisposition::Unknown {
243 let payload = EventPayload::Warning {
244 code: "COMMIT_DISPOSITION_UNKNOWN".into(),
245 message: format!(
246 "{}: {}; recovery must resolve the committed operation before terminal state",
247 error.code, error.message
248 ),
249 };
250 let state = self
251 .journal
252 .get(&execution_id)
253 .await
254 .ok()
255 .map(|record| record.view.state);
256 let preserved = match state {
257 Some(ExecutionState::Finalizing) => self
258 .journal
259 .append_event(&execution_id, payload)
260 .await
261 .is_ok(),
262 Some(ExecutionState::Running) => self
263 .journal
264 .transition_with_event(
265 &execution_id,
266 &[ExecutionState::Running],
267 ExecutionState::Finalizing,
268 None,
269 None,
270 payload,
271 )
272 .await
273 .is_ok(),
274 _ => false,
275 };
276 if preserved {
277 self.cancellations.lock().await.remove(&execution_id);
278 self.authorities.lock().await.remove(&execution_id);
279 return;
280 }
281 }
282 if error.code == "CANCELED"
283 && self
284 .journal
285 .get(&execution_id)
286 .await
287 .is_ok_and(|record| record.view.state == ExecutionState::Finalizing)
288 {
289 self.cancellations.lock().await.remove(&execution_id);
290 self.authorities.lock().await.remove(&execution_id);
291 return;
292 }
293 let _ = self
294 .finalize_failure(
295 &execution_id,
296 ExecutionFailure {
297 code: error.code,
298 message: error.message,
299 retryable: error.retryable,
300 },
301 )
302 .await;
303 }
304 self.cleanup_delegation(&execution_id).await;
305 self.cancellations.lock().await.remove(&execution_id);
306 self.authorities.lock().await.remove(&execution_id);
307 }
308
309 async fn run_with_claim_heartbeat(
310 &self,
311 execution_id: &ExecutionId,
312 ) -> Result<(), KernelFailure> {
313 let mut execution = Box::pin(self.run_inner(execution_id));
314 let mut heartbeat = tokio::time::interval(Duration::from_secs(10));
315 heartbeat.tick().await;
318 loop {
319 tokio::select! {
320 result = &mut execution => return result,
321 _ = heartbeat.tick() => {
322 let now = chrono::Utc::now().timestamp_millis();
323 if self
324 .journal
325 .claim(
326 execution_id,
327 &self.worker_id,
328 now,
329 now.saturating_add(30_000),
330 )
331 .await
332 .is_err()
333 {
334 if let Some(cancellation) = self.cancellations.lock().await.get(execution_id) {
335 cancellation.cancel();
336 }
337 let _ = tokio::time::timeout(Duration::from_secs(5), &mut execution).await;
342 return Err(KernelFailure {
343 code: "WORKER_CLAIM_LOST".into(),
344 message: "worker could not renew its durable execution claim".into(),
345 retryable: true,
346 commit: CommitDisposition::Unknown,
347 });
348 }
349 }
350 }
351 }
352 }
353
354 async fn recover_interrupted(self, execution_id: ExecutionId, permit: OwnedSemaphorePermit) {
355 let _permit = permit;
356 let now = chrono::Utc::now().timestamp_millis();
357 if self
358 .journal
359 .claim(
360 &execution_id,
361 &self.worker_id,
362 now,
363 now.saturating_add(60_000),
364 )
365 .await
366 .is_err()
367 {
368 return;
369 }
370 let Ok(mut record) = self.journal.get(&execution_id).await else {
371 return;
372 };
373 if record.view.state.is_terminal() {
374 self.cleanup_delegation(&execution_id).await;
375 return;
376 }
377 if matches!(
378 record.view.state,
379 ExecutionState::Running | ExecutionState::WaitingForInput
380 ) {
381 let Ok(mutation) = self
382 .journal
383 .transition_with_event(
384 &execution_id,
385 &[ExecutionState::Running, ExecutionState::WaitingForInput],
386 ExecutionState::Finalizing,
387 None,
388 None,
389 EventPayload::Warning {
390 code: "EXECUTION_INTERRUPTED".into(),
391 message: "the prior worker stopped; recovery will not replay uncheckpointed side effects".into(),
392 },
393 )
394 .await
395 else {
396 return;
397 };
398 record = mutation.execution;
399 }
400 if record.view.state == ExecutionState::Queued {
401 let _ = self
402 .journal
403 .transition_with_event(
404 &execution_id,
405 &[ExecutionState::Queued],
406 ExecutionState::Failed,
407 None,
408 Some(ExecutionFailure {
409 code: "DELEGATION_NOT_DURABLE".into(),
410 message: "queued execution lost caller authority before delegation attach"
411 .into(),
412 retryable: true,
413 }),
414 EventPayload::ExecutionFailed {
415 code: "DELEGATION_NOT_DURABLE".into(),
416 message: "queued execution cannot be recovered without a delegation".into(),
417 },
418 )
419 .await;
420 return;
421 }
422 if let Some(stored) = record
423 .delegation
424 .clone()
425 .filter(|delegation| !delegation.cleanup_complete)
426 {
427 let delegation = delegation_from_journal(stored);
428 if self
429 .revoke_for_finalization(&execution_id, &record.caller, &delegation)
430 .await
431 .is_err()
432 {
433 return;
434 }
435 }
436 let _ = self
437 .journal
438 .transition_with_event(
439 &execution_id,
440 &[ExecutionState::Finalizing],
441 ExecutionState::Failed,
442 None,
443 Some(ExecutionFailure {
444 code: "EXECUTION_INTERRUPTED".into(),
445 message: "execution was interrupted before a durable resumable checkpoint"
446 .into(),
447 retryable: true,
448 }),
449 EventPayload::ExecutionFailed {
450 code: "EXECUTION_INTERRUPTED".into(),
451 message: "execution was safely finalized after worker recovery".into(),
452 },
453 )
454 .await;
455 }
456
457 async fn run_inner(&self, execution_id: &ExecutionId) -> Result<(), KernelFailure> {
458 let record = self
459 .journal
460 .get(execution_id)
461 .await
462 .map_err(journal_kernel_error)?;
463 let cancellation = CancellationToken::new();
464 self.cancellations
465 .lock()
466 .await
467 .insert(execution_id.clone(), cancellation.clone());
468 let remaining_ms = record
469 .view
470 .created_at_ms
471 .saturating_add((record.request.options.deadline_seconds * 1000) as i64)
472 .saturating_sub(chrono::Utc::now().timestamp_millis())
473 .max(1) as u64;
474 let operation = OperationContext {
475 id: OperationId::new(format!("{execution_id}:run"))
476 .map_err(|error| KernelFailure::new("OPERATION_ID_INVALID", error.to_string()))?,
477 execution_id: execution_id.clone(),
478 deadline: Instant::now() + Duration::from_millis(remaining_ms),
479 cancellation,
480 };
481 let resolved = self
482 .instance_resolver
483 .resolve(
484 &operation,
485 &record.caller,
486 &record.request.runtime_instance_id,
487 )
488 .await
489 .map_err(port_kernel_error)?;
490 let resolved = apply_request_routing(resolved, &record.request);
491 let definition = self
492 .definition_resolver
493 .resolve(&operation, &resolved)
494 .await?;
495 let scope = SessionScope {
496 execution_id: execution_id.clone(),
497 conversation_id: record.request.conversation_id.clone(),
498 resolved: resolved.clone(),
499 };
500 let mut delegation = match record.delegation {
501 Some(delegation) if !delegation.cleanup_complete => delegation_from_journal(delegation),
502 Some(_) => {
503 return Err(KernelFailure::new(
504 "DELEGATION_ALREADY_CLEANED",
505 "execution delegation was already revoked",
506 ));
507 }
508 None => {
509 let authority = self
510 .authorities
511 .lock()
512 .await
513 .get(execution_id)
514 .cloned()
515 .ok_or_else(|| KernelFailure::new(
516 "EXECUTION_AUTHORITY_MISSING",
517 "queued execution has no durable delegation and caller authority is unavailable",
518 ))?;
519 let delegation = self
520 .session_factory
521 .establish_delegation(&operation, &authority, &scope)
522 .await
523 .map_err(port_kernel_error)?;
524 self.journal
525 .attach_delegation(execution_id, journal_delegation(&delegation))
526 .await
527 .map_err(journal_kernel_error)?;
528 delegation
529 }
530 };
531 let now_seconds = chrono::Utc::now().timestamp().max(0) as u64;
532 if delegation.expires_at_seconds
533 <= now_seconds
534 .saturating_add(operation.remaining().map_err(port_kernel_error)?.as_secs())
535 .saturating_add(30)
536 {
537 delegation = self
538 .session_factory
539 .renew_delegation(&operation, &record.caller, &delegation)
540 .await
541 .map_err(port_kernel_error)?;
542 self.journal
543 .attach_delegation(execution_id, journal_delegation(&delegation))
544 .await
545 .map_err(journal_kernel_error)?;
546 }
547 self.journal
548 .transition(
549 execution_id,
550 &[ExecutionState::Queued],
551 ExecutionState::Running,
552 )
553 .await
554 .map_err(journal_kernel_error)?;
555 self.journal
556 .append_event(execution_id, EventPayload::ExecutionStarted)
557 .await
558 .map_err(journal_kernel_error)?;
559 let mut sessions = self
560 .session_factory
561 .create(&operation, &record.caller, &delegation, &scope)
562 .await
563 .map_err(port_kernel_error)?;
564 sessions.interaction = Arc::new(JournaledInteractionSession {
565 execution_id: execution_id.clone(),
566 journal: self.journal.clone(),
567 inner: sessions.interaction.clone(),
568 });
569 sessions.subagent = Arc::new(ChildExecutionSubagentSession {
570 coordinator: self.clone(),
571 authority: self.authorities.lock().await.get(execution_id).cloned(),
572 parent_execution_id: execution_id.clone(),
573 runtime_instance_id: record.request.runtime_instance_id.clone(),
574 workspace_id: record.request.workspace_id.clone(),
575 model: record.request.model.clone(),
576 generation: record.request.generation.clone(),
577 });
578 let prompt = record
579 .request
580 .input
581 .user_text()
582 .ok_or_else(|| {
583 KernelFailure::new(
584 "INITIAL_INPUT_INVALID",
585 "execution must start with at least one user message",
586 )
587 })?
588 .to_string();
589 let request_messages = request_messages(&record.request.input)?;
590 let sink: Arc<dyn KernelEventSink> = Arc::new(JournalKernelEventSink {
591 execution_id: execution_id.clone(),
592 journal: self.journal.clone(),
593 trace: sessions.trace.clone(),
594 operation: operation.clone(),
595 });
596 let outcome = self
597 .kernel
598 .execute(
599 operation,
600 KernelSpec {
601 execution_id: execution_id.clone(),
602 conversation_id: record.request.conversation_id.clone(),
603 user_prompt: prompt,
604 request_messages,
605 model: resolved.model,
606 generation: model_generation_options(&record.request.generation),
607 definition,
608 granted_capabilities: record.caller.capabilities.iter().cloned().collect(),
609 limits: self.kernel_limits(&record.request.options, &record.request.generation),
610 },
611 sessions,
612 sink,
613 )
614 .await?;
615 let public = ExecutionOutcome {
616 answer: outcome.answer.clone(),
617 model_turns: outcome.model_turns,
618 tool_calls: outcome.tool_calls,
619 input_tokens: outcome.usage.input_tokens,
620 output_tokens: outcome.usage.output_tokens,
621 };
622 self.journal
623 .transition(
624 execution_id,
625 &[ExecutionState::Running],
626 ExecutionState::Finalizing,
627 )
628 .await
629 .map_err(journal_kernel_error)?;
630 self.revoke_for_finalization(execution_id, &record.caller, &delegation)
631 .await?;
632 self.journal
633 .transition_with_event(
634 execution_id,
635 &[ExecutionState::Finalizing],
636 ExecutionState::Completed,
637 Some(public),
638 None,
639 EventPayload::ExecutionCompleted {
640 answer: outcome.answer,
641 },
642 )
643 .await
644 .map_err(journal_kernel_error)?;
645 Ok(())
646 }
647
648 async fn revoke_for_finalization(
649 &self,
650 execution_id: &ExecutionId,
651 caller: &runtime_types::CallerScope,
652 delegation: &ExecutionDelegationLease,
653 ) -> Result<(), KernelFailure> {
654 let operation = OperationContext {
655 id: OperationId::new(format!("{execution_id}:cleanup"))
656 .unwrap_or_else(|_| OperationId::random()),
657 execution_id: execution_id.clone(),
658 deadline: Instant::now() + Duration::from_secs(5),
659 cancellation: CancellationToken::new(),
660 };
661 self.session_factory
662 .revoke_delegation(&operation, caller, delegation)
663 .await
664 .map_err(|error| {
665 let mut failure = port_kernel_error(error);
666 failure.commit = CommitDisposition::Unknown;
667 failure
668 })?;
669 self.journal
670 .complete_delegation_cleanup(execution_id, &delegation.lease_ref)
671 .await
672 .map_err(journal_kernel_error)?;
673 Ok(())
674 }
675
676 async fn cleanup_delegation(&self, execution_id: &ExecutionId) {
677 let Ok(record) = self.journal.get(execution_id).await else {
678 return;
679 };
680 let Some(stored) = record
681 .delegation
682 .filter(|delegation| !delegation.cleanup_complete)
683 else {
684 return;
685 };
686 if !record.view.state.is_terminal() {
687 return;
688 }
689 let delegation = delegation_from_journal(stored);
690 let operation = OperationContext {
691 id: OperationId::new(format!("{execution_id}:cleanup"))
692 .unwrap_or_else(|_| OperationId::random()),
693 execution_id: execution_id.clone(),
694 deadline: Instant::now() + Duration::from_secs(5),
695 cancellation: CancellationToken::new(),
696 };
697 match self
698 .session_factory
699 .revoke_delegation(&operation, &record.caller, &delegation)
700 .await
701 {
702 Ok(()) => {
703 let _ = self
704 .journal
705 .complete_delegation_cleanup(execution_id, &delegation.lease_ref)
706 .await;
707 }
708 Err(error) => tracing::warn!(
709 execution_id = %execution_id,
710 code = %error.code,
711 "delegation cleanup deferred to recovery"
712 ),
713 }
714 }
715
716 async fn finalize_failure(
717 &self,
718 execution_id: &ExecutionId,
719 failure: ExecutionFailure,
720 ) -> Result<ExecutionView, LinkError> {
721 let mut record = self.journal.get(execution_id).await.map_err(map_journal)?;
722 if record.view.state.is_terminal() {
723 return Ok(record.view);
724 }
725 if record.view.state != ExecutionState::Finalizing {
726 record = self
727 .journal
728 .transition(
729 execution_id,
730 &[
731 ExecutionState::Queued,
732 ExecutionState::Running,
733 ExecutionState::WaitingForInput,
734 ],
735 ExecutionState::Finalizing,
736 )
737 .await
738 .map_err(map_journal)?;
739 }
740 if let Some(stored) = record
741 .delegation
742 .clone()
743 .filter(|delegation| !delegation.cleanup_complete)
744 {
745 self.revoke_for_finalization(
746 execution_id,
747 &record.caller,
748 &delegation_from_journal(stored),
749 )
750 .await
751 .map_err(|error| {
752 LinkError::Unavailable(format!("{}: {}", error.code, error.message))
753 })?;
754 }
755 let event = EventPayload::ExecutionFailed {
756 code: failure.code.clone(),
757 message: failure.message.clone(),
758 };
759 self.journal
760 .transition_with_event(
761 execution_id,
762 &[ExecutionState::Finalizing],
763 ExecutionState::Failed,
764 None,
765 Some(failure),
766 event,
767 )
768 .await
769 .map(|mutation| mutation.execution.view)
770 .map_err(map_journal)
771 }
772
773 async fn fail_queued(
774 &self,
775 execution_id: &ExecutionId,
776 code: &str,
777 message: &str,
778 retryable: bool,
779 ) -> Result<ExecutionView, LinkError> {
780 self.finalize_failure(
781 execution_id,
782 ExecutionFailure {
783 code: code.into(),
784 message: message.into(),
785 retryable,
786 },
787 )
788 .await
789 }
790
791 async fn prepare_delegation(
792 &self,
793 record: &runtime_link_api::JournalExecution,
794 authority: &RequestAuthority,
795 ) -> Result<runtime_link_api::JournalExecution, LinkError> {
796 if record.delegation.is_some() {
797 return Ok(record.clone());
798 }
799 let remaining_ms = record
800 .view
801 .created_at_ms
802 .saturating_add((record.request.options.deadline_seconds as i64) * 1000)
803 .saturating_sub(chrono::Utc::now().timestamp_millis())
804 .max(1) as u64;
805 let operation = OperationContext {
806 id: OperationId::new(format!("{}:delegate", record.view.id))
807 .map_err(|error| LinkError::Internal(error.to_string()))?,
808 execution_id: record.view.id.clone(),
809 deadline: Instant::now() + Duration::from_millis(remaining_ms),
810 cancellation: CancellationToken::new(),
811 };
812 let resolved = self
813 .instance_resolver
814 .resolve(
815 &operation,
816 &record.caller,
817 &record.request.runtime_instance_id,
818 )
819 .await
820 .map_err(map_port)?;
821 let resolved = apply_request_routing(resolved, &record.request);
822 let scope = SessionScope {
823 execution_id: record.view.id.clone(),
824 conversation_id: record.request.conversation_id.clone(),
825 resolved,
826 };
827 let delegation = self
828 .session_factory
829 .establish_delegation(&operation, authority, &scope)
830 .await
831 .map_err(map_port)?;
832 self.journal
833 .attach_delegation(&record.view.id, journal_delegation(&delegation))
834 .await
835 .map_err(map_journal)
836 }
837}
838
839#[async_trait]
840impl RuntimeLink for ExecutionCoordinator {
841 async fn create_execution(
842 &self,
843 authority: &RequestAuthority,
844 idempotency_key: &str,
845 request: CreateExecutionRequest,
846 ) -> Result<CreateExecutionResponse, LinkError> {
847 authority
848 .caller
849 .validate()
850 .map_err(|error| LinkError::Invalid(error.to_string()))?;
851 validate_create(idempotency_key, &request)?;
852 if matches!(
853 &request.input,
854 RuntimeInput::Messages { messages }
855 if messages.len() >= self.context_limits.max_messages
856 ) {
857 return Err(LinkError::Invalid(format!(
858 "request messages must contain fewer than {} items",
859 self.context_limits.max_messages
860 )));
861 }
862 if !self.accepting.load(Ordering::SeqCst) {
863 return Err(LinkError::Unavailable("runtime is shutting down".into()));
864 }
865 let now = chrono::Utc::now().timestamp_millis();
866 let view = ExecutionView {
867 id: ExecutionId::random(),
868 runtime_instance_id: request.runtime_instance_id.clone(),
869 conversation_id: request.conversation_id.clone(),
870 workspace_id: request.workspace_id.clone(),
871 model: request.model.clone(),
872 metadata: request.metadata.clone(),
873 state: ExecutionState::Queued,
874 outcome: None,
875 failure: None,
876 created_at_ms: now,
877 updated_at_ms: now,
878 };
879 match self
880 .journal
881 .reserve(NewJournalExecution {
882 idempotency_key: idempotency_key.into(),
883 caller: authority.caller.clone(),
884 request,
885 view,
886 })
887 .await
888 .map_err(map_journal)?
889 {
890 JournalReservation::Existing(record) => {
891 ensure_owner(&record.caller, authority)?;
892 let record = if record.view.state == ExecutionState::Queued {
893 let prepared = self.prepare_delegation(&record, authority).await?;
894 if let Ok(permit) = self.capacity.clone().try_acquire_owned() {
895 self.authorities
896 .lock()
897 .await
898 .insert(prepared.view.id.clone(), authority.clone());
899 self.tasks
900 .spawn(self.clone().run(prepared.view.id.clone(), permit));
901 }
902 prepared
903 } else {
904 record
905 };
906 Ok(CreateExecutionResponse {
907 execution: record.view,
908 replayed: true,
909 })
910 }
911 JournalReservation::Created(record) => {
912 self.journal
913 .append_event(&record.view.id, EventPayload::ExecutionQueued)
914 .await
915 .map_err(map_journal)?;
916 let record = self.prepare_delegation(&record, authority).await?;
917 let execution = if !self.accepting.load(Ordering::SeqCst) {
918 let failed = self
919 .fail_queued(
920 &record.view.id,
921 "RUNTIME_SHUTTING_DOWN",
922 "runtime stopped accepting work before dispatch",
923 true,
924 )
925 .await?;
926 self.cleanup_delegation(&record.view.id).await;
927 failed
928 } else if let Ok(permit) = self.capacity.clone().try_acquire_owned() {
929 self.authorities
930 .lock()
931 .await
932 .insert(record.view.id.clone(), authority.clone());
933 let execution = record.view.clone();
934 self.tasks
935 .spawn(self.clone().run(execution.id.clone(), permit));
936 execution
937 } else {
938 let failed = self
939 .fail_queued(
940 &record.view.id,
941 "CAPACITY_EXCEEDED",
942 "runtime execution capacity is exhausted",
943 true,
944 )
945 .await?;
946 self.cleanup_delegation(&record.view.id).await;
947 failed
948 };
949 Ok(CreateExecutionResponse {
950 execution,
951 replayed: false,
952 })
953 }
954 }
955 }
956
957 async fn execution(
958 &self,
959 authority: &RequestAuthority,
960 id: &ExecutionId,
961 ) -> Result<ExecutionView, LinkError> {
962 let record = self.journal.get(id).await.map_err(map_journal)?;
963 ensure_owner(&record.caller, authority)?;
964 Ok(record.view)
965 }
966
967 async fn events(
968 &self,
969 authority: &RequestAuthority,
970 id: &ExecutionId,
971 after: Option<u64>,
972 limit: usize,
973 ) -> Result<EventPage, LinkError> {
974 if limit == 0 || limit > 1000 {
975 return Err(LinkError::Invalid(
976 "event limit must be within 1..=1000".into(),
977 ));
978 }
979 ensure_owner(
980 &self.journal.get(id).await.map_err(map_journal)?.caller,
981 authority,
982 )?;
983 let items = self
984 .journal
985 .events(id, after, limit)
986 .await
987 .map_err(map_journal)?;
988 let next_after = items.last().map(|event| event.sequence);
989 Ok(EventPage { items, next_after })
990 }
991
992 async fn submit_input(
993 &self,
994 authority: &RequestAuthority,
995 id: &ExecutionId,
996 request: SubmitInputRequest,
997 ) -> Result<ExecutionView, LinkError> {
998 let record = self.journal.get(id).await.map_err(map_journal)?;
999 ensure_owner(&record.caller, authority)?;
1000 if record.view.state != ExecutionState::WaitingForInput {
1001 return Err(LinkError::Conflict(
1002 "execution is not waiting for input".into(),
1003 ));
1004 }
1005 let (request_id, text) = match request.input {
1006 RuntimeInput::ElicitationResponse { request_id, text } => (request_id, text),
1007 RuntimeInput::ToolApproval {
1008 request_id,
1009 approved,
1010 } => (
1011 request_id,
1012 if approved {
1013 "approved".into()
1014 } else {
1015 "denied".into()
1016 },
1017 ),
1018 RuntimeInput::UserMessage { .. } | RuntimeInput::Messages { .. } => {
1019 return Err(LinkError::Invalid(
1020 "waiting input requires tool_approval or elicitation_response".into(),
1021 ));
1022 }
1023 };
1024 if request_id.is_empty() || request_id.len() > 256 || text.len() > 1024 * 1024 {
1025 return Err(LinkError::Invalid(
1026 "interaction request ID must be 1..=256 bytes and response at most 1 MiB".into(),
1027 ));
1028 }
1029 let operation = OperationContext {
1030 id: request.operation_id.clone(),
1031 execution_id: id.clone(),
1032 deadline: Instant::now() + Duration::from_secs(30),
1033 cancellation: CancellationToken::new(),
1034 };
1035 let mutation = self
1036 .journal
1037 .commit_interaction_input(id, &request.operation_id, &request_id, &text)
1038 .await
1039 .map_err(map_journal)?;
1040 if let Err(error) = self
1041 .session_factory
1042 .submit_input(
1043 &operation,
1044 &record.caller,
1045 id,
1046 &request.operation_id,
1047 &request_id,
1048 InteractionResponse { text },
1049 )
1050 .await
1051 {
1052 return Err(map_port(error));
1055 }
1056 Ok(mutation.execution.view)
1057 }
1058
1059 async fn cancel(
1060 &self,
1061 authority: &RequestAuthority,
1062 id: &ExecutionId,
1063 ) -> Result<ExecutionView, LinkError> {
1064 let record = self.journal.get(id).await.map_err(map_journal)?;
1065 ensure_owner(&record.caller, authority)?;
1066 if record.view.state.is_terminal() {
1067 return Ok(record.view);
1068 }
1069 if let Some(token) = self.cancellations.lock().await.get(id) {
1070 token.cancel();
1071 }
1072 let record = if record.view.state == ExecutionState::Finalizing {
1073 record
1074 } else {
1075 self.journal
1076 .transition_with_event(
1077 id,
1078 &[
1079 ExecutionState::Queued,
1080 ExecutionState::Running,
1081 ExecutionState::WaitingForInput,
1082 ],
1083 ExecutionState::Finalizing,
1084 None,
1085 None,
1086 EventPayload::Warning {
1087 code: "CANCELLATION_REQUESTED".into(),
1088 message: "execution cancellation entered cleanup".into(),
1089 },
1090 )
1091 .await
1092 .map_err(map_journal)?
1093 .execution
1094 };
1095 if let Some(stored) = record
1096 .delegation
1097 .clone()
1098 .filter(|delegation| !delegation.cleanup_complete)
1099 {
1100 self.revoke_for_finalization(id, &record.caller, &delegation_from_journal(stored))
1101 .await
1102 .map_err(|error| {
1103 LinkError::Unavailable(format!("{}: {}", error.code, error.message))
1104 })?;
1105 }
1106 let updated = self
1107 .journal
1108 .transition_with_event(
1109 id,
1110 &[ExecutionState::Finalizing],
1111 ExecutionState::Canceled,
1112 None,
1113 None,
1114 EventPayload::ExecutionCanceled,
1115 )
1116 .await
1117 .map_err(map_journal)?;
1118 Ok(updated.execution.view)
1119 }
1120}
1121
1122fn journal_delegation(delegation: &ExecutionDelegationLease) -> JournalDelegation {
1123 JournalDelegation {
1124 lease_ref: delegation.lease_ref.clone(),
1125 expires_at_seconds: delegation.expires_at_seconds,
1126 revision: delegation.revision,
1127 cleanup_complete: false,
1128 }
1129}
1130
1131fn delegation_from_journal(delegation: JournalDelegation) -> ExecutionDelegationLease {
1132 ExecutionDelegationLease {
1133 lease_ref: delegation.lease_ref,
1134 expires_at_seconds: delegation.expires_at_seconds,
1135 revision: delegation.revision,
1136 }
1137}
1138
1139struct JournalKernelEventSink {
1140 execution_id: ExecutionId,
1141 journal: Arc<dyn ExecutionJournal>,
1142 trace: Arc<dyn TraceSession>,
1143 operation: OperationContext,
1144}
1145#[async_trait]
1146impl KernelEventSink for JournalKernelEventSink {
1147 async fn emit(&self, event: KernelEvent) -> Result<(), KernelFailure> {
1148 let payload = match event {
1149 KernelEvent::Started => return Ok(()),
1152 KernelEvent::ModelStarted {
1153 turn,
1154 invocation_id,
1155 } => EventPayload::ModelStarted {
1156 turn,
1157 invocation_id,
1158 },
1159 KernelEvent::ModelCompleted {
1160 turn,
1161 invocation_id,
1162 finish,
1163 usage,
1164 } => EventPayload::ModelCompleted {
1165 turn,
1166 invocation_id,
1167 finish_reason: match finish {
1168 runtime_ports::ModelFinish::Stop => "stop",
1169 runtime_ports::ModelFinish::ToolCalls => "tool_calls",
1170 runtime_ports::ModelFinish::Length => "length",
1171 runtime_ports::ModelFinish::ContentFilter => "content_filter",
1172 runtime_ports::ModelFinish::Other => "other",
1173 }
1174 .into(),
1175 input_tokens: usage.input_tokens,
1176 output_tokens: usage.output_tokens,
1177 },
1178 KernelEvent::ToolStarted { call_id, name } => {
1179 EventPayload::ToolStarted { call_id, name }
1180 }
1181 KernelEvent::ToolCompleted {
1182 call_id,
1183 name,
1184 failed,
1185 } => EventPayload::ToolCompleted {
1186 call_id,
1187 name,
1188 failed,
1189 },
1190 KernelEvent::Warning { code, message } => EventPayload::Warning { code, message },
1191 };
1192 let event_type = match &payload {
1193 EventPayload::ModelStarted { .. } => "runtime.model.started",
1194 EventPayload::ModelCompleted { .. } => "runtime.model.completed",
1195 EventPayload::ToolStarted { .. } => "runtime.tool.started",
1196 EventPayload::ToolCompleted { .. } => "runtime.tool.completed",
1197 EventPayload::Warning { .. } => "runtime.warning",
1198 _ => "runtime.event",
1199 };
1200 let trace_payload = serde_json::to_value(&payload)
1201 .map_err(|error| KernelFailure::new("EVENT_SERIALIZATION_FAILED", error.to_string()))?;
1202 self.journal
1203 .append_event(&self.execution_id, payload)
1204 .await
1205 .map_err(journal_kernel_error)?;
1206 if let Err(error) = self
1209 .trace
1210 .append(&self.operation, event_type, trace_payload)
1211 .await
1212 {
1213 tracing::warn!(
1214 execution_id = %self.execution_id,
1215 code = %error.code,
1216 "trace projection failed after journal commit"
1217 );
1218 }
1219 Ok(())
1220 }
1221}
1222
1223struct JournaledInteractionSession {
1224 execution_id: ExecutionId,
1225 journal: Arc<dyn ExecutionJournal>,
1226 inner: Arc<dyn InteractionSession>,
1227}
1228
1229struct ChildExecutionSubagentSession {
1230 coordinator: ExecutionCoordinator,
1231 authority: Option<RequestAuthority>,
1232 parent_execution_id: ExecutionId,
1233 runtime_instance_id: runtime_types::RuntimeInstanceId,
1234 workspace_id: Option<runtime_types::WorkspaceId>,
1235 model: Option<String>,
1236 generation: ApiModelGenerationOptions,
1237}
1238
1239#[async_trait]
1240impl SubagentSession for ChildExecutionSubagentSession {
1241 async fn execute(
1242 &self,
1243 operation: &OperationContext,
1244 request: SubagentRequest,
1245 ) -> Result<SubagentOutcome, PortFailure> {
1246 let remaining = operation.remaining()?;
1247 let authority = self.authority.as_ref().ok_or_else(|| {
1248 PortFailure::new(
1249 PortFailureKind::Unavailable,
1250 "SUBAGENT_AUTHORITY_UNAVAILABLE",
1251 "recovered execution cannot widen its delegation with a new child execution",
1252 )
1253 })?;
1254 if request.prompt.trim().is_empty() || !(1..=256).contains(&request.max_model_turns) {
1255 return Err(PortFailure::new(
1256 PortFailureKind::Invalid,
1257 "SUBAGENT_REQUEST_INVALID",
1258 "subagent prompt and max_model_turns within 1..=256 are required",
1259 ));
1260 }
1261 let mut digest = Sha256::new();
1262 digest.update(self.parent_execution_id.as_str());
1263 digest.update(b":");
1264 digest.update(request.request_id.as_bytes());
1265 let idempotency_key = format!("subagent:{:x}", digest.finalize());
1266 let deadline_seconds = remaining.as_secs().saturating_add(1).clamp(1, 3600);
1267 let child = self
1268 .coordinator
1269 .create_execution(
1270 authority,
1271 &idempotency_key,
1272 CreateExecutionRequest {
1273 runtime_instance_id: self.runtime_instance_id.clone(),
1274 conversation_id: None,
1275 input: RuntimeInput::UserMessage {
1276 text: request.prompt,
1277 },
1278 workspace_id: self.workspace_id.clone(),
1279 model: self.model.clone(),
1280 instructions: None,
1281 metadata: BTreeMap::new(),
1282 generation: self.generation.clone(),
1283 options: ExecutionOptions {
1284 deadline_seconds,
1285 max_model_turns: request.max_model_turns,
1286 max_tool_calls: request.max_model_turns.saturating_mul(8).clamp(1, 2048),
1287 },
1288 },
1289 )
1290 .await
1291 .map_err(link_port_error)?
1292 .execution;
1293 loop {
1294 if operation.cancellation.is_cancelled() {
1295 let _ = self.coordinator.cancel(authority, &child.id).await;
1296 return Err(PortFailure::canceled());
1297 }
1298 operation.remaining()?;
1299 let view = self
1300 .coordinator
1301 .execution(authority, &child.id)
1302 .await
1303 .map_err(link_port_error)?;
1304 match view.state {
1305 ExecutionState::Completed => {
1306 return Ok(SubagentOutcome {
1307 answer: view
1308 .outcome
1309 .map(|outcome| outcome.answer)
1310 .unwrap_or_default(),
1311 });
1312 }
1313 ExecutionState::Failed => {
1314 let failure = view.failure.unwrap_or(ExecutionFailure {
1315 code: "SUBAGENT_FAILED".into(),
1316 message: "subagent failed without a failure payload".into(),
1317 retryable: false,
1318 });
1319 let mut error = PortFailure::new(
1320 PortFailureKind::Unavailable,
1321 failure.code,
1322 failure.message,
1323 );
1324 error.retryable = failure.retryable;
1325 return Err(error);
1326 }
1327 ExecutionState::Canceled => return Err(PortFailure::canceled()),
1328 ExecutionState::WaitingForInput => {
1329 let _ = self.coordinator.cancel(authority, &child.id).await;
1330 return Err(PortFailure::new(
1331 PortFailureKind::Conflict,
1332 "SUBAGENT_INTERACTION_UNSUPPORTED",
1333 "a nested execution cannot request direct user input",
1334 ));
1335 }
1336 ExecutionState::Queued | ExecutionState::Running | ExecutionState::Finalizing => {}
1337 }
1338 tokio::select! {
1339 _ = operation.cancellation.cancelled() => {
1340 let _ = self.coordinator.cancel(authority, &child.id).await;
1341 return Err(PortFailure::canceled());
1342 }
1343 _ = tokio::time::sleep(Duration::from_millis(25)) => {}
1344 }
1345 }
1346 }
1347}
1348#[async_trait]
1349impl InteractionSession for JournaledInteractionSession {
1350 async fn prepare(
1351 &self,
1352 _: &OperationContext,
1353 _: InteractionRequest,
1354 ) -> Result<(), PortFailure> {
1355 Err(PortFailure::new(
1356 PortFailureKind::Internal,
1357 "INTERACTION_WRAPPER_PROTOCOL",
1358 "journaled interaction must use request()",
1359 ))
1360 }
1361
1362 async fn wait(
1363 &self,
1364 _: &OperationContext,
1365 _: &str,
1366 ) -> Result<InteractionResponse, PortFailure> {
1367 Err(PortFailure::new(
1368 PortFailureKind::Internal,
1369 "INTERACTION_WRAPPER_PROTOCOL",
1370 "journaled interaction must use request()",
1371 ))
1372 }
1373
1374 async fn request(
1375 &self,
1376 operation: &OperationContext,
1377 request: InteractionRequest,
1378 ) -> Result<InteractionResponse, PortFailure> {
1379 self.inner.prepare(operation, request.clone()).await?;
1380 self.journal
1381 .begin_interaction(&self.execution_id, &request.request_id, &request.prompt)
1382 .await
1383 .map_err(journal_port_error)?;
1384 let result = self.inner.wait(operation, &request.request_id).await;
1385 match result {
1386 Ok(response) => {
1387 self.journal
1388 .complete_interaction(&self.execution_id, &request.request_id)
1389 .await
1390 .map_err(journal_port_error)?;
1391 Ok(response)
1392 }
1393 Err(error) => Err(error),
1394 }
1395 }
1396}
1397
1398fn validate_create(key: &str, request: &CreateExecutionRequest) -> Result<(), LinkError> {
1399 if key.is_empty() || key.len() > 256 {
1400 return Err(LinkError::Invalid(
1401 "Idempotency-Key must contain 1..=256 bytes".into(),
1402 ));
1403 }
1404 request
1405 .options
1406 .validate()
1407 .map_err(|message| LinkError::Invalid(message.into()))?;
1408 request
1409 .generation
1410 .validate()
1411 .map_err(|message| LinkError::Invalid(message.into()))?;
1412 if request
1413 .model
1414 .as_ref()
1415 .is_some_and(|model| model.trim().is_empty())
1416 {
1417 return Err(LinkError::Invalid("model must not be empty".into()));
1418 }
1419 if request
1420 .instructions
1421 .as_ref()
1422 .is_some_and(|instructions| instructions.len() > 256 * 1024)
1423 {
1424 return Err(LinkError::Invalid(
1425 "instructions must not exceed 256 KiB".into(),
1426 ));
1427 }
1428 let user_metadata_count = request
1429 .metadata
1430 .keys()
1431 .filter(|key| key.as_str() != "requestId")
1432 .count();
1433 if user_metadata_count > 16
1434 || request
1435 .metadata
1436 .iter()
1437 .any(|(key, value)| key.is_empty() || key.len() > 64 || value.len() > 512)
1438 {
1439 return Err(LinkError::Invalid(
1440 "metadata supports at most 16 entries with 1..=64 byte keys and values up to 512 bytes"
1441 .into(),
1442 ));
1443 }
1444 match &request.input {
1445 RuntimeInput::UserMessage { text }
1446 if !text.trim().is_empty() && text.len() <= 1024 * 1024 =>
1447 {
1448 Ok(())
1449 }
1450 RuntimeInput::Messages { messages }
1451 if !messages.is_empty()
1452 && messages.len() <= 256
1453 && messages.iter().any(|message| {
1454 message.role == runtime_api_contract::RuntimeMessageRole::User
1455 && !message.text.trim().is_empty()
1456 })
1457 && messages
1458 .iter()
1459 .all(|message| !message.text.trim().is_empty() && message.text.len() <= 1024 * 1024) =>
1460 {
1461 Ok(())
1462 }
1463 _ => Err(LinkError::Invalid(
1464 "initial input must contain 1..=256 non-empty text messages of at most 1 MiB each and at least one user role".into(),
1465 )),
1466 }
1467}
1468
1469fn request_messages(
1470 input: &RuntimeInput,
1471) -> Result<Vec<runtime_ports::ConversationMessage>, KernelFailure> {
1472 use runtime_api_contract::RuntimeMessageRole;
1473 use runtime_ports::ModelRole;
1474
1475 match input {
1476 RuntimeInput::UserMessage { text } => Ok(vec![runtime_ports::ConversationMessage {
1477 role: ModelRole::User,
1478 text: text.clone(),
1479 }]),
1480 RuntimeInput::Messages { messages } => Ok(messages
1481 .iter()
1482 .map(|message| runtime_ports::ConversationMessage {
1483 role: match message.role {
1484 RuntimeMessageRole::System | RuntimeMessageRole::Developer => ModelRole::System,
1485 RuntimeMessageRole::User => ModelRole::User,
1486 RuntimeMessageRole::Assistant => ModelRole::Assistant,
1487 },
1488 text: message.text.clone(),
1489 })
1490 .collect()),
1491 RuntimeInput::ToolApproval { .. } | RuntimeInput::ElicitationResponse { .. } => {
1492 Err(KernelFailure::new(
1493 "INITIAL_INPUT_INVALID",
1494 "execution must start with messages",
1495 ))
1496 }
1497 }
1498}
1499
1500fn apply_request_routing(
1505 mut resolved: ResolvedExecutionContext,
1506 request: &CreateExecutionRequest,
1507) -> ResolvedExecutionContext {
1508 if let Some(workspace_id) = &request.workspace_id {
1509 resolved.workspace_id = workspace_id.clone();
1510 }
1511 if let Some(model) = &request.model {
1512 resolved.model = model.clone();
1513 }
1514 if let Some(instructions) = &request.instructions {
1515 resolved
1516 .metadata
1517 .entry("instructions".into())
1518 .and_modify(|current| {
1519 current.push_str("\n\n");
1520 current.push_str(instructions);
1521 })
1522 .or_insert_with(|| instructions.clone());
1523 }
1524 for (key, value) in &request.metadata {
1525 resolved
1526 .metadata
1527 .insert(format!("task.{key}"), value.clone());
1528 }
1529 resolved
1530}
1531
1532fn ensure_owner(
1533 caller: &runtime_types::CallerScope,
1534 authority: &RequestAuthority,
1535) -> Result<(), LinkError> {
1536 if caller.subject == authority.caller.subject
1537 && caller.tenant_id == authority.caller.tenant_id
1538 && caller.project_id == authority.caller.project_id
1539 {
1540 Ok(())
1541 } else {
1542 Err(LinkError::Forbidden)
1543 }
1544}
1545
1546impl ExecutionCoordinator {
1547 fn kernel_limits(
1548 &self,
1549 options: &ExecutionOptions,
1550 generation: &ApiModelGenerationOptions,
1551 ) -> KernelLimits {
1552 let mut context = self.context_limits.clone();
1553 if let Some(max_output_tokens) = generation.max_output_tokens {
1554 context.reserved_output_tokens = max_output_tokens;
1555 }
1556 KernelLimits {
1557 max_model_turns: options.max_model_turns,
1558 max_tool_calls: options.max_tool_calls,
1559 context,
1560 max_tool_output_bytes: 256 * 1024,
1561 }
1562 }
1563}
1564
1565fn model_generation_options(options: &ApiModelGenerationOptions) -> ModelGenerationOptions {
1566 ModelGenerationOptions {
1567 max_output_tokens: options.max_output_tokens,
1568 temperature: options.temperature,
1569 stop_sequences: options.stop_sequences.clone(),
1570 response_format: options.response_format.clone(),
1571 tool_choice: match &options.tool_choice {
1572 ApiModelToolChoice::Auto => ModelToolChoice::Auto,
1573 ApiModelToolChoice::None => ModelToolChoice::None,
1574 },
1575 }
1576}
1577fn map_journal(error: JournalError) -> LinkError {
1578 match error {
1579 JournalError::NotFound => LinkError::NotFound,
1580 JournalError::Conflict => LinkError::Conflict("journal compare-and-set failed".into()),
1581 JournalError::Unavailable(message) => LinkError::Unavailable(message),
1582 }
1583}
1584fn map_port(error: PortFailure) -> LinkError {
1585 LinkError::Unavailable(format!("{}: {}", error.code, error.message))
1586}
1587fn link_port_error(error: LinkError) -> PortFailure {
1588 match error {
1589 LinkError::Invalid(message) => {
1590 PortFailure::new(PortFailureKind::Invalid, "SUBAGENT_INVALID", message)
1591 }
1592 LinkError::NotFound => PortFailure::new(
1593 PortFailureKind::NotFound,
1594 "SUBAGENT_NOT_FOUND",
1595 "nested execution was not found",
1596 ),
1597 LinkError::Forbidden => PortFailure::new(
1598 PortFailureKind::Forbidden,
1599 "SUBAGENT_FORBIDDEN",
1600 "nested execution is forbidden",
1601 ),
1602 LinkError::Conflict(message) => {
1603 PortFailure::new(PortFailureKind::Conflict, "SUBAGENT_CONFLICT", message)
1604 }
1605 LinkError::Overloaded => PortFailure::new(
1606 PortFailureKind::Unavailable,
1607 "SUBAGENT_OVERLOADED",
1608 "nested execution capacity is exhausted",
1609 ),
1610 LinkError::Unavailable(message) => PortFailure::new(
1611 PortFailureKind::Unavailable,
1612 "SUBAGENT_UNAVAILABLE",
1613 message,
1614 ),
1615 LinkError::Internal(message) => {
1616 PortFailure::new(PortFailureKind::Internal, "SUBAGENT_INTERNAL", message)
1617 }
1618 }
1619}
1620fn journal_kernel_error(error: JournalError) -> KernelFailure {
1621 KernelFailure::new("JOURNAL_FAILURE", error.to_string())
1622}
1623fn port_kernel_error(error: PortFailure) -> KernelFailure {
1624 KernelFailure {
1625 code: error.code,
1626 message: error.message,
1627 retryable: error.retryable,
1628 commit: error.commit,
1629 }
1630}
1631fn journal_port_error(error: JournalError) -> PortFailure {
1632 PortFailure::new(
1633 runtime_ports::PortFailureKind::Unavailable,
1634 "JOURNAL_FAILURE",
1635 error.to_string(),
1636 )
1637}
1638
1639#[cfg(test)]
1640mod tests {
1641 use std::{collections::BTreeMap, sync::Arc};
1642
1643 use agent_runtime_code_agent::definition as code_agent_definition;
1644 use agent_runtime_testkit::{ScriptedModelSession, sessions};
1645 use runtime_adapter_execution_journal::MemoryExecutionJournal;
1646 use runtime_agent_definition_loader::DefinitionCatalogResolver;
1647 use runtime_api_contract::{
1648 CreateExecutionRequest, ExecutionOptions, ExecutionState, RuntimeInput,
1649 };
1650 use runtime_kernel::AgentKernel;
1651 use runtime_link_api::RuntimeLink;
1652 use runtime_ports::{
1653 CommitDisposition, ExecutionSessions, InteractionResponse, ModelContent, ModelFinish,
1654 ModelResponse, PortFailureKind, ResolvedExecutionContext, TokenUsage,
1655 };
1656 use runtime_types::{
1657 CallerScope, CredentialHandle, DelegationLeaseRef, RuntimeInstanceId, WorkspaceId,
1658 };
1659 use serde_json::json;
1660
1661 use super::*;
1662
1663 struct FixtureResolver;
1664 #[async_trait]
1665 impl RuntimeInstanceResolver for FixtureResolver {
1666 async fn resolve(
1667 &self,
1668 _: &OperationContext,
1669 _: &CallerScope,
1670 id: &RuntimeInstanceId,
1671 ) -> Result<ResolvedExecutionContext, PortFailure> {
1672 Ok(ResolvedExecutionContext {
1673 runtime_instance_id: id.clone(),
1674 agent_id: "agt_test".into(),
1675 runtime_type: "code".into(),
1676 model: "fixture".into(),
1677 workspace_id: WorkspaceId::new("workspace-1").unwrap(),
1678 definition_id: "runtime-code-agent".into(),
1679 definition_version: code_agent_definition()
1680 .expect("valid Code Agent definition")
1681 .manifest
1682 .version
1683 .clone(),
1684 definition_digest: None,
1685 metadata: BTreeMap::new(),
1686 })
1687 }
1688 }
1689
1690 fn code_agent_resolver() -> Arc<dyn AgentDefinitionResolver> {
1691 let definition = code_agent_definition().expect("valid Code Agent definition");
1692 Arc::new(
1693 DefinitionCatalogResolver::new([definition])
1694 .expect("single Code Agent definition catalog"),
1695 )
1696 }
1697
1698 struct FixtureFactory {
1699 model: Arc<dyn runtime_ports::ModelSession>,
1700 }
1701
1702 struct HangingModel;
1703 #[async_trait]
1704 impl runtime_ports::ModelSession for HangingModel {
1705 async fn invoke(
1706 &self,
1707 operation: &OperationContext,
1708 _: runtime_ports::ModelRequest,
1709 ) -> Result<ModelResponse, PortFailure> {
1710 operation.cancellation.cancelled().await;
1711 Err(PortFailure::canceled())
1712 }
1713 }
1714
1715 struct CommitUnknownModel;
1716 #[async_trait]
1717 impl runtime_ports::ModelSession for CommitUnknownModel {
1718 async fn invoke(
1719 &self,
1720 _: &OperationContext,
1721 _: runtime_ports::ModelRequest,
1722 ) -> Result<ModelResponse, PortFailure> {
1723 let mut failure = PortFailure::new(
1724 PortFailureKind::Unavailable,
1725 "MODEL_TRANSPORT_UNKNOWN",
1726 "model transport failed after dispatch",
1727 );
1728 failure.commit = CommitDisposition::Unknown;
1729 Err(failure)
1730 }
1731 }
1732 #[async_trait]
1733 impl ExecutionSessionFactory for FixtureFactory {
1734 async fn establish_delegation(
1735 &self,
1736 _: &OperationContext,
1737 _: &RequestAuthority,
1738 _: &SessionScope,
1739 ) -> Result<ExecutionDelegationLease, PortFailure> {
1740 Ok(ExecutionDelegationLease {
1741 lease_ref: DelegationLeaseRef::new("edl_fixture").unwrap(),
1742 expires_at_seconds: u64::MAX,
1743 revision: 1,
1744 })
1745 }
1746
1747 async fn create(
1748 &self,
1749 _: &OperationContext,
1750 _: &CallerScope,
1751 _: &ExecutionDelegationLease,
1752 _: &SessionScope,
1753 ) -> Result<ExecutionSessions, PortFailure> {
1754 Ok(sessions(self.model.clone()))
1755 }
1756 async fn submit_input(
1757 &self,
1758 _: &OperationContext,
1759 _: &CallerScope,
1760 _: &ExecutionId,
1761 _: &OperationId,
1762 _: &str,
1763 _: InteractionResponse,
1764 ) -> Result<(), PortFailure> {
1765 Err(PortFailure::new(
1766 PortFailureKind::Conflict,
1767 "NO_INTERACTION",
1768 "fixture has no pending interaction",
1769 ))
1770 }
1771
1772 async fn renew_delegation(
1773 &self,
1774 _: &OperationContext,
1775 _: &CallerScope,
1776 delegation: &ExecutionDelegationLease,
1777 ) -> Result<ExecutionDelegationLease, PortFailure> {
1778 Ok(delegation.clone())
1779 }
1780
1781 async fn revoke_delegation(
1782 &self,
1783 _: &OperationContext,
1784 _: &CallerScope,
1785 _: &ExecutionDelegationLease,
1786 ) -> Result<(), PortFailure> {
1787 Ok(())
1788 }
1789 }
1790
1791 #[tokio::test]
1792 async fn create_runs_code_distribution_to_durable_terminal_state() {
1793 let model = Arc::new(ScriptedModelSession::new(vec![ModelResponse {
1794 output: vec![ModelContent::ToolUse {
1795 id: "call-1".into(),
1796 name: "runtime.complete".into(),
1797 arguments: json!({"answer":"complete"}),
1798 }],
1799 finish: ModelFinish::ToolCalls,
1800 usage: TokenUsage::default(),
1801 }]));
1802 let coordinator = ExecutionCoordinator::new(
1803 Arc::new(MemoryExecutionJournal::new(100)),
1804 Arc::new(FixtureResolver),
1805 Arc::new(FixtureFactory { model }),
1806 code_agent_resolver(),
1807 Arc::new(AgentKernel),
1808 2,
1809 )
1810 .unwrap();
1811 let authority = RequestAuthority {
1812 caller: CallerScope {
1813 subject: "user-1".into(),
1814 tenant_id: "tenant-1".into(),
1815 project_id: "project-1".into(),
1816 capabilities: Vec::new(),
1817 },
1818 credential: CredentialHandle::new("test-token").unwrap(),
1819 };
1820 let created = coordinator
1821 .create_execution(
1822 &authority,
1823 "test-key",
1824 CreateExecutionRequest {
1825 runtime_instance_id: RuntimeInstanceId::new("runtime-1").unwrap(),
1826 conversation_id: None,
1827 input: RuntimeInput::UserMessage {
1828 text: "do it".into(),
1829 },
1830 workspace_id: None,
1831 model: None,
1832 instructions: None,
1833 metadata: BTreeMap::new(),
1834 generation: ApiModelGenerationOptions::default(),
1835 options: ExecutionOptions::default(),
1836 },
1837 )
1838 .await
1839 .unwrap();
1840 let mut view = created.execution;
1841 for _ in 0..100 {
1842 view = coordinator.execution(&authority, &view.id).await.unwrap();
1843 if view.state.is_terminal() {
1844 break;
1845 }
1846 tokio::time::sleep(Duration::from_millis(10)).await;
1847 }
1848 assert_eq!(view.state, ExecutionState::Completed, "{view:?}");
1849 assert_eq!(view.outcome.unwrap().answer, "complete");
1850 let events = coordinator
1851 .events(&authority, &view.id, None, 100)
1852 .await
1853 .unwrap();
1854 assert!(matches!(
1855 events.items.first().unwrap().payload,
1856 EventPayload::ExecutionQueued
1857 ));
1858 assert!(matches!(
1859 events.items.last().unwrap().payload,
1860 EventPayload::ExecutionCompleted { .. }
1861 ));
1862 }
1863
1864 #[tokio::test]
1865 async fn recovery_resumes_queued_execution_from_durable_delegation_without_caller_token() {
1866 let model = Arc::new(ScriptedModelSession::new(vec![ModelResponse {
1867 output: vec![ModelContent::ToolUse {
1868 id: "call-recovered".into(),
1869 name: "runtime.complete".into(),
1870 arguments: json!({"answer":"recovered"}),
1871 }],
1872 finish: ModelFinish::ToolCalls,
1873 usage: TokenUsage::default(),
1874 }]));
1875 let journal = Arc::new(MemoryExecutionJournal::new(100));
1876 let request = CreateExecutionRequest {
1877 runtime_instance_id: RuntimeInstanceId::new("runtime-1").unwrap(),
1878 conversation_id: None,
1879 input: RuntimeInput::UserMessage {
1880 text: "resume".into(),
1881 },
1882 workspace_id: None,
1883 model: None,
1884 instructions: None,
1885 metadata: BTreeMap::new(),
1886 generation: ApiModelGenerationOptions::default(),
1887 options: ExecutionOptions::default(),
1888 };
1889 let now = chrono::Utc::now().timestamp_millis();
1890 let id = ExecutionId::random();
1891 journal
1892 .reserve(NewJournalExecution {
1893 idempotency_key: "recovery-key".into(),
1894 caller: CallerScope {
1895 subject: "user-1".into(),
1896 tenant_id: "tenant-1".into(),
1897 project_id: "project-1".into(),
1898 capabilities: Vec::new(),
1899 },
1900 request: request.clone(),
1901 view: runtime_api_contract::ExecutionView {
1902 id: id.clone(),
1903 runtime_instance_id: request.runtime_instance_id.clone(),
1904 conversation_id: None,
1905 workspace_id: request.workspace_id.clone(),
1906 model: request.model.clone(),
1907 metadata: request.metadata.clone(),
1908 state: ExecutionState::Queued,
1909 outcome: None,
1910 failure: None,
1911 created_at_ms: now,
1912 updated_at_ms: now,
1913 },
1914 })
1915 .await
1916 .unwrap();
1917 journal
1918 .attach_delegation(
1919 &id,
1920 JournalDelegation {
1921 lease_ref: DelegationLeaseRef::new("edl_recovered").unwrap(),
1922 expires_at_seconds: u64::MAX,
1923 revision: 1,
1924 cleanup_complete: false,
1925 },
1926 )
1927 .await
1928 .unwrap();
1929 let coordinator = ExecutionCoordinator::new(
1930 journal.clone(),
1931 Arc::new(FixtureResolver),
1932 Arc::new(FixtureFactory { model }),
1933 code_agent_resolver(),
1934 Arc::new(AgentKernel),
1935 2,
1936 )
1937 .unwrap();
1938 assert_eq!(coordinator.recover(10).await.unwrap(), 1);
1939 for _ in 0..100 {
1940 let record = journal.get(&id).await.unwrap();
1941 if record.view.state.is_terminal() {
1942 assert_eq!(record.view.state, ExecutionState::Completed);
1943 assert_eq!(record.view.outcome.unwrap().answer, "recovered");
1944 assert!(record.delegation.unwrap().cleanup_complete);
1945 return;
1946 }
1947 tokio::time::sleep(Duration::from_millis(10)).await;
1948 }
1949 panic!("recovered execution did not reach terminal state");
1950 }
1951
1952 #[tokio::test]
1953 async fn periodic_recovery_picks_up_claim_that_expires_after_startup_scan() {
1954 let model = Arc::new(ScriptedModelSession::new(vec![ModelResponse {
1955 output: vec![ModelContent::ToolUse {
1956 id: "call-delayed-recovery".into(),
1957 name: "runtime.complete".into(),
1958 arguments: json!({"answer":"delayed recovery"}),
1959 }],
1960 finish: ModelFinish::ToolCalls,
1961 usage: TokenUsage::default(),
1962 }]));
1963 let journal = Arc::new(MemoryExecutionJournal::new(100));
1964 let request = request("resume after claim expiry");
1965 let now = chrono::Utc::now().timestamp_millis();
1966 let id = ExecutionId::random();
1967 journal
1968 .reserve(NewJournalExecution {
1969 idempotency_key: "delayed-recovery-key".into(),
1970 caller: authority().caller,
1971 request: request.clone(),
1972 view: ExecutionView {
1973 id: id.clone(),
1974 runtime_instance_id: request.runtime_instance_id.clone(),
1975 conversation_id: None,
1976 workspace_id: request.workspace_id.clone(),
1977 model: request.model.clone(),
1978 metadata: request.metadata.clone(),
1979 state: ExecutionState::Queued,
1980 outcome: None,
1981 failure: None,
1982 created_at_ms: now,
1983 updated_at_ms: now,
1984 },
1985 })
1986 .await
1987 .unwrap();
1988 journal
1989 .attach_delegation(
1990 &id,
1991 JournalDelegation {
1992 lease_ref: DelegationLeaseRef::new("edl_delayed_recovery").unwrap(),
1993 expires_at_seconds: u64::MAX,
1994 revision: 1,
1995 cleanup_complete: false,
1996 },
1997 )
1998 .await
1999 .unwrap();
2000 journal
2001 .claim(&id, "dead-worker", now, now + 50)
2002 .await
2003 .unwrap();
2004 let coordinator = ExecutionCoordinator::new(
2005 journal.clone(),
2006 Arc::new(FixtureResolver),
2007 Arc::new(FixtureFactory { model }),
2008 code_agent_resolver(),
2009 Arc::new(AgentKernel),
2010 1,
2011 )
2012 .unwrap();
2013 assert_eq!(coordinator.recover(10).await.unwrap(), 0);
2014 coordinator
2015 .start_recovery_loop(Duration::from_millis(10), 10)
2016 .unwrap();
2017 for _ in 0..100 {
2018 let record = journal.get(&id).await.unwrap();
2019 if record.view.state == ExecutionState::Completed {
2020 assert_eq!(record.view.outcome.unwrap().answer, "delayed recovery");
2021 assert!(coordinator.shutdown(Duration::from_secs(1)).await);
2022 return;
2023 }
2024 tokio::time::sleep(Duration::from_millis(10)).await;
2025 }
2026 coordinator.begin_shutdown();
2027 panic!("periodic recovery did not pick up the expired claim");
2028 }
2029
2030 #[tokio::test]
2031 async fn shutdown_cancels_registered_execution_tasks_after_grace() {
2032 let journal = Arc::new(MemoryExecutionJournal::new(100));
2033 let coordinator = ExecutionCoordinator::new(
2034 journal.clone(),
2035 Arc::new(FixtureResolver),
2036 Arc::new(FixtureFactory {
2037 model: Arc::new(HangingModel),
2038 }),
2039 code_agent_resolver(),
2040 Arc::new(AgentKernel),
2041 1,
2042 )
2043 .unwrap();
2044 let authority = authority();
2045 let created = coordinator
2046 .create_execution(&authority, "shutdown", request("wait"))
2047 .await
2048 .unwrap()
2049 .execution;
2050 for _ in 0..100 {
2051 if coordinator
2052 .execution(&authority, &created.id)
2053 .await
2054 .unwrap()
2055 .state
2056 == ExecutionState::Running
2057 {
2058 break;
2059 }
2060 tokio::time::sleep(Duration::from_millis(5)).await;
2061 }
2062 assert!(coordinator.shutdown(Duration::from_millis(1)).await);
2063 assert_eq!(coordinator.active_executions(), 0);
2064 let terminal = coordinator
2065 .execution(&authority, &created.id)
2066 .await
2067 .unwrap();
2068 assert_eq!(terminal.state, ExecutionState::Failed);
2069 assert_eq!(terminal.failure.unwrap().code, "CANCELED");
2070 assert!(
2071 journal
2072 .get(&created.id)
2073 .await
2074 .unwrap()
2075 .delegation
2076 .unwrap()
2077 .cleanup_complete,
2078 "Failed must imply durable delegation cleanup"
2079 );
2080 }
2081
2082 #[tokio::test]
2083 async fn commit_unknown_stays_finalizing_for_recovery() {
2084 let coordinator = ExecutionCoordinator::new(
2085 Arc::new(MemoryExecutionJournal::new(100)),
2086 Arc::new(FixtureResolver),
2087 Arc::new(FixtureFactory {
2088 model: Arc::new(CommitUnknownModel),
2089 }),
2090 code_agent_resolver(),
2091 Arc::new(AgentKernel),
2092 1,
2093 )
2094 .unwrap();
2095 let authority = authority();
2096 let execution = coordinator
2097 .create_execution(&authority, "commit-unknown", request("invoke"))
2098 .await
2099 .unwrap()
2100 .execution;
2101 for _ in 0..100 {
2102 let view = coordinator
2103 .execution(&authority, &execution.id)
2104 .await
2105 .unwrap();
2106 if view.state == ExecutionState::Finalizing && coordinator.active_executions() == 0 {
2107 break;
2108 }
2109 tokio::time::sleep(Duration::from_millis(5)).await;
2110 }
2111 let view = coordinator
2112 .execution(&authority, &execution.id)
2113 .await
2114 .unwrap();
2115 assert_eq!(view.state, ExecutionState::Finalizing);
2116 assert!(view.failure.is_none());
2117 let events = coordinator
2118 .events(&authority, &execution.id, None, 100)
2119 .await
2120 .unwrap();
2121 assert!(matches!(
2122 events.items.last().unwrap().payload,
2123 EventPayload::Warning { ref code, .. } if code == "COMMIT_DISPOSITION_UNKNOWN"
2124 ));
2125 }
2126
2127 fn authority() -> RequestAuthority {
2128 RequestAuthority {
2129 caller: CallerScope {
2130 subject: "user-1".into(),
2131 tenant_id: "tenant-1".into(),
2132 project_id: "project-1".into(),
2133 capabilities: Vec::new(),
2134 },
2135 credential: CredentialHandle::new("test-token").unwrap(),
2136 }
2137 }
2138
2139 fn request(prompt: &str) -> CreateExecutionRequest {
2140 CreateExecutionRequest {
2141 runtime_instance_id: RuntimeInstanceId::new("runtime-1").unwrap(),
2142 conversation_id: None,
2143 input: RuntimeInput::UserMessage {
2144 text: prompt.into(),
2145 },
2146 workspace_id: None,
2147 model: None,
2148 instructions: None,
2149 metadata: BTreeMap::new(),
2150 generation: ApiModelGenerationOptions::default(),
2151 options: ExecutionOptions::default(),
2152 }
2153 }
2154
2155 #[test]
2156 fn task_routing_overrides_workspace_model_and_augments_instructions() {
2157 let mut execution = request("inspect");
2158 execution.workspace_id = Some(WorkspaceId::new("workspace-2").unwrap());
2159 execution.model = Some("model-2".into());
2160 execution.instructions = Some("request instruction".into());
2161 execution
2162 .metadata
2163 .insert("traceId".into(), "trace-1".into());
2164 let mut metadata = BTreeMap::new();
2165 metadata.insert("instructions".into(), "binding instruction".into());
2166 let resolved = apply_request_routing(
2167 ResolvedExecutionContext {
2168 runtime_instance_id: RuntimeInstanceId::new("runtime-1").unwrap(),
2169 agent_id: "agt_test".into(),
2170 runtime_type: "test".into(),
2171 model: "model-1".into(),
2172 workspace_id: WorkspaceId::new("workspace-1").unwrap(),
2173 definition_id: "definition-1".into(),
2174 definition_version: "1".into(),
2175 definition_digest: None,
2176 metadata,
2177 },
2178 &execution,
2179 );
2180 assert_eq!(resolved.workspace_id.as_str(), "workspace-2");
2181 assert_eq!(resolved.model, "model-2");
2182 assert_eq!(
2183 resolved.metadata["instructions"],
2184 "binding instruction\n\nrequest instruction"
2185 );
2186 assert_eq!(resolved.metadata["task.traceId"], "trace-1");
2187 }
2188}