1use super::auxiliary_run::{
4 AuxiliaryCapabilityProfileV1, AuxiliaryModeV1, AuxiliaryRunError, AuxiliaryRunHandle,
5 AuxiliaryRunOutputV1, AuxiliaryRunService, AuxiliaryRunSpecV1,
6};
7use super::dispatch_ledger::{
8 EvaluationDispatchClaimOutcome, EvaluationDispatchLedger, EVALUATION_DISPATCH_LEASE_GRACE_MS,
9 EVALUATION_DISPATCH_MIN_LEASE_MS,
10};
11use super::evidence::{
12 EvidenceContentModeV1, EvidenceLimitsV1, EvidenceReadRequestV1, EvidenceReader,
13};
14use super::identity::{digest_json, ExecutionFrameV1, ExecutionTargetV1};
15use super::journal::{ExecutionFactJournal, ExecutionFactV1, JournalError};
16use crate::execution_identity::{
17 ExecutionClaimV1, ExecutionResultOutcomeV1, ExecutionResultReceiptV1,
18};
19use crate::run::RunEventRecord;
20use serde::{Deserialize, Serialize};
21use std::collections::{HashMap, HashSet};
22use std::sync::{Arc, Mutex, MutexGuard};
23use std::time::Duration;
24use tokio_util::sync::CancellationToken;
25
26pub const EVALUATION_PLAN_SCHEMA_V1: &str = "a3s.code.evaluation-plan.v1";
27pub const EVALUATION_MAX_PENDING: usize = 1024;
28pub const EVALUATION_MAX_COOLDOWN_MS: u64 = 24 * 60 * 60 * 1000;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum EvaluationBoundaryV1 {
33 EveryEvent,
34 TurnEnd,
35 RunTerminal,
36}
37
38impl EvaluationBoundaryV1 {
39 pub fn matches(self, fact: &ExecutionFactV1) -> bool {
40 match self {
41 Self::EveryEvent => true,
42 Self::TurnEnd => fact.event_type == "turn_end",
43 Self::RunTerminal => matches!(
44 fact.event_type.as_str(),
45 "agent_end" | "error" | "run_control_applied" | "persistence_failed"
46 ),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct EvaluationPlanV1 {
54 pub schema: String,
55 pub boundary: EvaluationBoundaryV1,
56 pub purpose: String,
57 pub instruction: String,
58 pub mode: AuxiliaryModeV1,
59 pub capabilities: AuxiliaryCapabilityProfileV1,
60 pub parent_ceiling: Option<AuxiliaryCapabilityProfileV1>,
61 pub limits: EvidenceLimitsV1,
62 pub content_mode: EvidenceContentModeV1,
63 pub include_prompt: bool,
64 pub include_terminal_text: bool,
65 pub include_artifact_content: bool,
66 pub max_pending: usize,
67 pub cooldown_ms: u64,
68 pub max_steps: u32,
69 pub timeout_ms: Option<u64>,
70 pub output_schema: Option<serde_json::Value>,
71}
72
73impl EvaluationPlanV1 {
74 pub fn new(
75 boundary: EvaluationBoundaryV1,
76 purpose: impl Into<String>,
77 instruction: impl Into<String>,
78 ) -> Self {
79 Self {
80 schema: EVALUATION_PLAN_SCHEMA_V1.to_string(),
81 boundary,
82 purpose: purpose.into(),
83 instruction: instruction.into(),
84 mode: AuxiliaryModeV1::Detached,
85 capabilities: AuxiliaryCapabilityProfileV1::tool_free(),
86 parent_ceiling: None,
87 limits: EvidenceLimitsV1::default(),
88 content_mode: EvidenceContentModeV1::DigestOnly,
89 include_prompt: false,
90 include_terminal_text: false,
91 include_artifact_content: false,
92 max_pending: 1,
93 cooldown_ms: 0,
94 max_steps: 1,
95 timeout_ms: None,
96 output_schema: None,
97 }
98 }
99
100 pub fn with_cooldown_ms(mut self, cooldown_ms: u64) -> Self {
101 self.cooldown_ms = cooldown_ms;
102 self
103 }
104
105 pub fn validate(&self) -> Result<(), SupervisorError> {
106 if self.schema != EVALUATION_PLAN_SCHEMA_V1 {
107 return Err(SupervisorError::InvalidPlan("schema"));
108 }
109 if self.purpose.is_empty() || self.purpose.len() > 256 || self.purpose.contains('\0') {
110 return Err(SupervisorError::InvalidPlan("purpose"));
111 }
112 if self.instruction.is_empty() || self.instruction.len() > 128 * 1024 {
113 return Err(SupervisorError::InvalidPlan("instruction"));
114 }
115 if self.max_pending == 0
116 || self.max_pending > EVALUATION_MAX_PENDING
117 || self.max_steps == 0
118 || self.max_steps > super::auxiliary_run::AUXILIARY_MAX_STEPS
119 {
120 return Err(SupervisorError::InvalidPlan("limits"));
121 }
122 if self.cooldown_ms > EVALUATION_MAX_COOLDOWN_MS {
123 return Err(SupervisorError::InvalidPlan("cooldown_ms"));
124 }
125 self.limits
126 .validate()
127 .map_err(|_| SupervisorError::InvalidPlan("evidence_limits"))?;
128 self.capabilities
129 .validate()
130 .map_err(|_| SupervisorError::InvalidPlan("capabilities"))?;
131 if let Some(ceiling) = self.parent_ceiling {
132 ceiling
133 .validate()
134 .map_err(|_| SupervisorError::InvalidPlan("parent_ceiling"))?;
135 if !self.capabilities.is_within(ceiling) {
136 return Err(SupervisorError::CapabilityEscalation);
137 }
138 }
139 if self
140 .timeout_ms
141 .is_some_and(|timeout| timeout == 0 || timeout > 24 * 60 * 60 * 1000)
142 {
143 return Err(SupervisorError::InvalidPlan("timeout_ms"));
144 }
145 if let Some(schema) = &self.output_schema {
146 let encoded = serde_json::to_vec(schema)
147 .map_err(|_| SupervisorError::InvalidPlan("output_schema"))?;
148 if encoded.len() > 128 * 1024
149 || jsonschema::draft202012::options().build(schema).is_err()
150 {
151 return Err(SupervisorError::InvalidPlan("output_schema"));
152 }
153 }
154 Ok(())
155 }
156}
157
158pub trait EvaluationPolicy: Send + Sync {
159 fn plan(&self, fact: &ExecutionFactV1) -> Option<EvaluationPlanV1>;
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum EvaluationDispatchOutcome {
164 Ignored,
165 Suppressed,
166 Dispatched,
167}
168
169#[derive(Debug, Clone)]
170pub struct EvaluationDispatch {
171 pub outcome: EvaluationDispatchOutcome,
172 pub fact: ExecutionFactV1,
173 pub handle: Option<AuxiliaryRunHandle>,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
177pub enum SupervisorError {
178 #[error("evaluation plan field `{0}` is invalid")]
179 InvalidPlan(&'static str),
180 #[error("evaluation plan would exceed its parent capability ceiling")]
181 CapabilityEscalation,
182 #[error("execution fact error: {0}")]
183 Journal(#[from] JournalError),
184 #[error("evidence read failed: {0}")]
185 Evidence(String),
186 #[error("auxiliary run failed to dispatch: {0}")]
187 Auxiliary(#[from] AuxiliaryRunError),
188 #[error("evaluation dispatch ledger failed: {0}")]
189 DispatchLedger(String),
190}
191
192#[derive(Default)]
193struct SupervisorState {
194 in_flight: HashMap<(ExecutionTargetV1, String), usize>,
195 last_dispatch_ms: HashMap<(ExecutionTargetV1, String), u64>,
196 dispatched: HashSet<(ExecutionTargetV1, u64, String)>,
201 admitting: HashSet<(ExecutionTargetV1, u64, String)>,
206 ledger_claims: HashMap<String, crate::execution_identity::ExecutionClaimV1>,
211}
212
213struct DispatchReservation {
214 state: Arc<Mutex<SupervisorState>>,
215 pending_key: (ExecutionTargetV1, String),
216 dispatch_key: (ExecutionTargetV1, u64, String),
217 dispatch_at_ms: u64,
218 finished: bool,
219}
220
221impl DispatchReservation {
222 fn commit(&mut self) {
223 let mut state = lock_state(&self.state);
224 state.admitting.remove(&self.dispatch_key);
225 state.dispatched.insert(self.dispatch_key.clone());
226 state
230 .last_dispatch_ms
231 .insert(self.pending_key.clone(), self.dispatch_at_ms);
232 self.finished = true;
233 }
234
235 fn release(&mut self) {
236 release_state(&self.state, &self.pending_key, &self.dispatch_key);
237 self.finished = true;
238 }
239}
240
241impl Drop for DispatchReservation {
242 fn drop(&mut self) {
243 if self.finished {
244 return;
245 }
246 release_state(&self.state, &self.pending_key, &self.dispatch_key);
251 }
252}
253
254fn lock_state<'a>(state: &'a Arc<Mutex<SupervisorState>>) -> MutexGuard<'a, SupervisorState> {
255 state
256 .lock()
257 .unwrap_or_else(|poisoned| poisoned.into_inner())
258}
259
260fn release_state(
261 state: &Arc<Mutex<SupervisorState>>,
262 pending_key: &(ExecutionTargetV1, String),
263 dispatch_key: &(ExecutionTargetV1, u64, String),
264) {
265 let mut state = lock_state(state);
266 if let Some(pending) = state.in_flight.get_mut(pending_key) {
267 *pending = pending.saturating_sub(1);
268 if *pending == 0 {
269 state.in_flight.remove(pending_key);
270 }
271 }
272 state.admitting.remove(dispatch_key);
273 state.dispatched.remove(dispatch_key);
274}
275
276pub struct EvaluationSupervisor {
280 journal: Arc<dyn ExecutionFactJournal>,
281 reader: Arc<dyn EvidenceReader>,
282 auxiliary: Arc<dyn AuxiliaryRunService>,
283 policy: Arc<dyn EvaluationPolicy>,
284 cancellation: CancellationToken,
285 state: Arc<Mutex<SupervisorState>>,
286 dispatch_ledger: Option<Arc<dyn EvaluationDispatchLedger>>,
287 owner_id: String,
288}
289
290impl std::fmt::Debug for EvaluationSupervisor {
291 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 formatter
293 .debug_struct("EvaluationSupervisor")
294 .field("cancelled", &self.cancellation.is_cancelled())
295 .field("durable_dispatch_ledger", &self.dispatch_ledger.is_some())
296 .finish()
297 }
298}
299
300impl EvaluationSupervisor {
301 pub fn new(
302 journal: Arc<dyn ExecutionFactJournal>,
303 reader: Arc<dyn EvidenceReader>,
304 auxiliary: Arc<dyn AuxiliaryRunService>,
305 policy: Arc<dyn EvaluationPolicy>,
306 ) -> Self {
307 Self::with_optional_dispatch_ledger(journal, reader, auxiliary, policy, None)
308 }
309
310 pub fn with_dispatch_ledger(
314 journal: Arc<dyn ExecutionFactJournal>,
315 reader: Arc<dyn EvidenceReader>,
316 auxiliary: Arc<dyn AuxiliaryRunService>,
317 policy: Arc<dyn EvaluationPolicy>,
318 dispatch_ledger: Arc<dyn EvaluationDispatchLedger>,
319 ) -> Self {
320 Self::with_optional_dispatch_ledger(
321 journal,
322 reader,
323 auxiliary,
324 policy,
325 Some(dispatch_ledger),
326 )
327 }
328
329 fn with_optional_dispatch_ledger(
330 journal: Arc<dyn ExecutionFactJournal>,
331 reader: Arc<dyn EvidenceReader>,
332 auxiliary: Arc<dyn AuxiliaryRunService>,
333 policy: Arc<dyn EvaluationPolicy>,
334 dispatch_ledger: Option<Arc<dyn EvaluationDispatchLedger>>,
335 ) -> Self {
336 Self {
337 journal,
338 reader,
339 auxiliary,
340 policy,
341 cancellation: CancellationToken::new(),
342 state: Arc::new(Mutex::new(SupervisorState::default())),
343 dispatch_ledger,
344 owner_id: format!("evaluation-supervisor-{}", uuid::Uuid::new_v4()),
345 }
346 }
347
348 pub fn cancellation(&self) -> CancellationToken {
349 self.cancellation.clone()
350 }
351
352 pub fn cancel(&self) {
353 self.cancellation.cancel();
354 }
355
356 pub async fn shutdown(&self) {
361 self.cancel();
362 let claims = {
363 let mut state = lock_state(&self.state);
364 let claims = state
365 .ledger_claims
366 .drain()
367 .map(|(_, claim)| claim)
368 .collect::<Vec<_>>();
369 state.in_flight.clear();
370 state.last_dispatch_ms.clear();
371 state.admitting.clear();
372 state.dispatched.clear();
373 claims
374 };
375 if let Some(ledger) = &self.dispatch_ledger {
376 for claim in claims {
377 if let Err(error) = ledger
378 .release_with_identity(
379 claim.record_id(),
380 claim.ledger_key(),
381 claim.identity(),
382 claim.owner_id(),
383 )
384 .await
385 {
386 tracing::warn!(
387 dispatch_id = %claim.record_id(),
388 error = %error,
389 "failed to release evaluation dispatch claim during shutdown"
390 );
391 }
392 }
393 }
394 }
395
396 pub async fn pending_count(&self) -> usize {
399 lock_state(&self.state).in_flight.values().sum()
400 }
401
402 pub async fn observe_event(
407 &self,
408 frame: ExecutionFrameV1,
409 record: &RunEventRecord,
410 ) -> Result<EvaluationDispatch, SupervisorError> {
411 let fact = super::journal::ExecutionFactV1::from_run_event(frame, record)?;
412 self.journal.append(fact.clone())?;
413 let Some(plan) = self.policy.plan(&fact) else {
414 return Ok(EvaluationDispatch {
415 outcome: EvaluationDispatchOutcome::Ignored,
416 fact,
417 handle: None,
418 });
419 };
420 plan.validate()?;
421 if !plan.boundary.matches(&fact) {
422 return Ok(EvaluationDispatch {
423 outcome: EvaluationDispatchOutcome::Ignored,
424 fact,
425 handle: None,
426 });
427 }
428 let key = (fact.frame.target.clone(), plan.purpose.clone());
429 let dispatch_key = (
430 fact.frame.target.clone(),
431 fact.sequence,
432 plan.purpose.clone(),
433 );
434 let dispatch_id = deterministic_auxiliary_id(&fact, &plan.purpose)?;
435 let request_digest = dispatch_request_digest(&fact, &plan)?;
436 let execution_identity =
437 dispatch_execution_identity(&fact, &plan, &dispatch_id, &request_digest)?;
438 let claim = crate::execution_identity::ExecutionClaimV1::new(
439 execution_identity,
440 &dispatch_id,
441 &request_digest,
442 &self.owner_id,
443 )
444 .map_err(|error| SupervisorError::DispatchLedger(error.to_string()))?;
445 tracing::trace!(
446 dispatch_id = dispatch_id.as_str(),
447 identity = claim.identity().key(),
448 "Evaluation dispatch execution identity bound to claim ledger"
449 );
450 let now = now_ms();
451 {
452 let mut state = lock_state(&self.state);
453 if state.dispatched.contains(&dispatch_key) || state.admitting.contains(&dispatch_key) {
454 return Ok(EvaluationDispatch {
459 outcome: EvaluationDispatchOutcome::Ignored,
460 fact,
461 handle: None,
462 });
463 }
464 let pending = state.in_flight.get(&key).copied().unwrap_or(0);
465 let last = state.last_dispatch_ms.get(&key).copied();
466 if pending >= plan.max_pending
467 || last.is_some_and(|last| now.saturating_sub(last) < plan.cooldown_ms)
468 || self.cancellation.is_cancelled()
469 {
470 return Ok(EvaluationDispatch {
471 outcome: EvaluationDispatchOutcome::Suppressed,
472 fact,
473 handle: None,
474 });
475 }
476 *state.in_flight.entry(key.clone()).or_default() += 1;
481 state.admitting.insert(dispatch_key.clone());
484 }
485 let mut reservation = DispatchReservation {
486 state: Arc::clone(&self.state),
487 pending_key: key.clone(),
488 dispatch_key: dispatch_key.clone(),
489 dispatch_at_ms: now,
490 finished: false,
491 };
492
493 let mut ledger_claimed = false;
494 if let Some(ledger) = &self.dispatch_ledger {
495 let lease_ms = dispatch_lease_ms(plan.timeout_ms);
496 let claim_outcome = ledger
497 .claim_with_identity(
498 claim.record_id(),
499 claim.ledger_key(),
500 claim.identity(),
501 claim.owner_id(),
502 now,
503 lease_ms,
504 )
505 .await
506 .map_err(|error| SupervisorError::DispatchLedger(error.to_string()))?;
507 match claim_outcome {
508 EvaluationDispatchClaimOutcome::Claimed { .. } => {
509 lock_state(&self.state)
510 .ledger_claims
511 .insert(dispatch_id.clone(), claim.clone());
512 ledger_claimed = true;
513 }
514 EvaluationDispatchClaimOutcome::Completed => {
515 reservation.release();
516 return Ok(EvaluationDispatch {
517 outcome: EvaluationDispatchOutcome::Ignored,
518 fact,
519 handle: None,
520 });
521 }
522 EvaluationDispatchClaimOutcome::Busy { .. } => {
523 reservation.release();
524 return Ok(EvaluationDispatch {
525 outcome: EvaluationDispatchOutcome::Suppressed,
526 fact,
527 handle: None,
528 });
529 }
530 EvaluationDispatchClaimOutcome::Conflict => {
531 reservation.release();
532 return Err(SupervisorError::DispatchLedger(
533 "dispatch identity conflicts with a different request".to_string(),
534 ));
535 }
536 }
537 }
538
539 let request = EvidenceReadRequestV1 {
540 target: fact.frame.target.clone(),
541 after_sequence: None,
542 limits: plan.limits,
543 content_mode: plan.content_mode,
544 include_prompt: plan.include_prompt,
545 include_terminal_text: plan.include_terminal_text,
546 include_artifact_content: plan.include_artifact_content,
547 };
548 let evidence = match self.reader.read(request).await {
549 Ok(evidence) => evidence,
550 Err(error) => {
551 if ledger_claimed {
552 self.release_dispatch_claim(&claim).await;
553 }
554 reservation.release();
555 return Err(SupervisorError::Evidence(error.to_string()));
556 }
557 };
558 if self.cancellation.is_cancelled() {
559 if ledger_claimed {
560 self.release_dispatch_claim(&claim).await;
561 }
562 reservation.release();
563 return Ok(EvaluationDispatch {
564 outcome: EvaluationDispatchOutcome::Suppressed,
565 fact,
566 handle: None,
567 });
568 }
569 let mut spec = AuxiliaryRunSpecV1::new(
570 fact.frame.clone(),
571 plan.purpose.clone(),
572 plan.instruction,
573 evidence.snapshot_digest.clone(),
574 )
575 .with_mode(plan.mode)
576 .with_capabilities(plan.capabilities);
577 if let Some(ceiling) = plan.parent_ceiling {
578 spec = spec.with_parent_ceiling(ceiling);
579 }
580 spec.max_steps = plan.max_steps;
581 spec.timeout_ms = plan.timeout_ms;
582 spec.output_schema = plan.output_schema;
583 spec.id = dispatch_id.clone();
584 let evidence_digest = evidence.snapshot_digest.clone();
585 let handle = match self
586 .auxiliary
587 .spawn(spec, evidence, Some(self.cancellation.child_token()))
588 .await
589 {
590 Ok(handle) => handle,
591 Err(error) => {
592 if ledger_claimed {
593 self.release_dispatch_claim(&claim).await;
594 }
595 reservation.release();
596 return Err(error.into());
597 }
598 };
599 reservation.commit();
600 let state = Arc::clone(&self.state);
601 let watcher = handle.clone();
602 let ledger = self.dispatch_ledger.clone();
603 let watcher_claim = claim.clone();
604 let lease_ms = dispatch_lease_ms(plan.timeout_ms);
605 tokio::spawn(async move {
606 let mut claim_owned = ledger.is_some();
607 if let Some(ledger) = &ledger {
608 let heartbeat_ms = (lease_ms / 3).max(1);
609 let mut heartbeat = tokio::time::interval(Duration::from_millis(heartbeat_ms));
610 heartbeat.tick().await;
613 let result = loop {
614 tokio::select! {
615 result = watcher.wait() => break result,
616 _ = heartbeat.tick() => {
617 match ledger
618 .renew_with_identity(
619 watcher_claim.record_id(),
620 watcher_claim.ledger_key(),
621 watcher_claim.identity(),
622 watcher_claim.owner_id(),
623 now_ms(),
624 lease_ms,
625 )
626 .await
627 {
628 Ok(true) => {}
629 Ok(false) => {
630 claim_owned = false;
635 }
636 Err(error) => {
637 tracing::warn!(
638 dispatch_id = %watcher_claim.record_id(),
639 error = %error,
640 "evaluation dispatch lease renewal failed"
641 );
642 }
643 }
644 }
645 }
646 if !claim_owned {
647 break watcher.wait().await;
648 }
649 };
650 if claim_owned {
651 match result_receipt(&watcher_claim, &evidence_digest, &result) {
652 Ok(receipt) => {
653 if let Err(error) = ledger
654 .complete_with_receipt(
655 watcher_claim.record_id(),
656 watcher_claim.ledger_key(),
657 watcher_claim.identity(),
658 watcher_claim.owner_id(),
659 &receipt,
660 now_ms(),
661 )
662 .await
663 {
664 tracing::warn!(
665 dispatch_id = %watcher_claim.record_id(),
666 error = %error,
667 "failed to persist evaluation result receipt"
668 );
669 }
670 }
671 Err(error) => {
672 tracing::warn!(
673 dispatch_id = %watcher_claim.record_id(),
674 error = %error,
675 "failed to build evaluation result receipt"
676 );
677 }
678 }
679 }
680 } else {
681 let _ = watcher.wait().await;
682 }
683 let mut state = lock_state(&state);
684 if let Some(pending) = state.in_flight.get_mut(&key) {
685 *pending = pending.saturating_sub(1);
686 if *pending == 0 {
687 state.in_flight.remove(&key);
688 }
689 }
690 if state
691 .ledger_claims
692 .get(watcher_claim.record_id())
693 .is_some_and(|claim| claim.ledger_key() == watcher_claim.ledger_key())
694 {
695 state.ledger_claims.remove(watcher_claim.record_id());
696 }
697 });
698 Ok(EvaluationDispatch {
699 outcome: EvaluationDispatchOutcome::Dispatched,
700 fact,
701 handle: Some(handle),
702 })
703 }
704
705 async fn release_dispatch_claim(&self, claim: &crate::execution_identity::ExecutionClaimV1) {
706 if let Some(ledger) = &self.dispatch_ledger {
707 if let Err(error) = ledger
708 .release_with_identity(
709 claim.record_id(),
710 claim.ledger_key(),
711 claim.identity(),
712 claim.owner_id(),
713 )
714 .await
715 {
716 tracing::warn!(
717 dispatch_id = %claim.record_id(),
718 error = %error,
719 "failed to release evaluation dispatch claim"
720 );
721 }
722 }
723 let mut state = lock_state(&self.state);
724 if state
725 .ledger_claims
726 .get(claim.record_id())
727 .is_some_and(|current| current.ledger_key() == claim.ledger_key())
728 {
729 state.ledger_claims.remove(claim.record_id());
730 }
731 }
732}
733
734fn deterministic_auxiliary_id(
735 fact: &ExecutionFactV1,
736 purpose: &str,
737) -> Result<String, SupervisorError> {
738 let identity = serde_json::json!({
739 "target": fact.frame.target.clone(),
740 "sequence": fact.sequence,
741 "purpose": purpose,
742 "fact_digest": fact.fact_digest.clone(),
743 });
744 let digest = digest_json("a3s.code.evaluation-dispatch.v1", &identity)
745 .map_err(|error| SupervisorError::Evidence(error.to_string()))?;
746 Ok(format!("aux-{digest}"))
747}
748
749fn dispatch_request_digest(
750 fact: &ExecutionFactV1,
751 plan: &EvaluationPlanV1,
752) -> Result<String, SupervisorError> {
753 let plan_digest = digest_json("a3s.code.evaluation-plan.identity.v1", plan)
754 .map_err(|error| SupervisorError::Evidence(error.to_string()))?;
755 digest_json(
756 "a3s.code.evaluation-dispatch.request.v1",
757 &serde_json::json!({
758 "fact_digest": &fact.fact_digest,
759 "purpose": &plan.purpose,
760 "plan_digest": plan_digest,
761 }),
762 )
763 .map_err(|error| SupervisorError::Evidence(error.to_string()))
764}
765
766fn dispatch_execution_identity(
767 fact: &ExecutionFactV1,
768 plan: &EvaluationPlanV1,
769 dispatch_id: &str,
770 request_digest: &str,
771) -> Result<crate::execution_identity::ExecutionIdentityV1, SupervisorError> {
772 crate::execution_identity::ExecutionIdentityV1::derive(
773 crate::execution_identity::EVALUATION_DISPATCH_IDENTITY_DOMAIN_V1,
774 &serde_json::json!({
775 "target": &fact.frame.target,
776 "sequence": fact.sequence,
777 "purpose": &plan.purpose,
778 "fact_digest": &fact.fact_digest,
779 "dispatch_id": dispatch_id,
780 "request_digest": request_digest,
781 }),
782 )
783 .map_err(|error| SupervisorError::Evidence(error.to_string()))
784}
785
786fn result_receipt(
787 claim: &ExecutionClaimV1,
788 evidence_digest: &str,
789 result: &Result<AuxiliaryRunOutputV1, AuxiliaryRunError>,
790) -> Result<ExecutionResultReceiptV1, crate::execution_identity::ExecutionIdentityError> {
791 match result {
792 Ok(output) => claim.result_receipt(
793 evidence_digest,
794 ExecutionResultOutcomeV1::Succeeded,
795 Some(output.output_digest.clone()),
796 output.output_bytes,
797 ),
798 Err(AuxiliaryRunError::Cancelled) => claim.result_receipt(
799 evidence_digest,
800 ExecutionResultOutcomeV1::Cancelled,
801 None,
802 0,
803 ),
804 Err(AuxiliaryRunError::TimedOut) => {
805 claim.result_receipt(evidence_digest, ExecutionResultOutcomeV1::TimedOut, None, 0)
806 }
807 Err(_) => claim.result_receipt(evidence_digest, ExecutionResultOutcomeV1::Failed, None, 0),
808 }
809}
810
811fn dispatch_lease_ms(timeout_ms: Option<u64>) -> u64 {
812 timeout_ms
813 .unwrap_or(EVALUATION_DISPATCH_MIN_LEASE_MS)
814 .saturating_add(EVALUATION_DISPATCH_LEASE_GRACE_MS)
815 .max(EVALUATION_DISPATCH_MIN_LEASE_MS)
816}
817
818fn now_ms() -> u64 {
819 std::time::SystemTime::now()
820 .duration_since(std::time::UNIX_EPOCH)
821 .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
822 .unwrap_or(0)
823}
824
825#[cfg(test)]
826#[path = "supervision_tests.rs"]
827mod tests;