Skip to main content

a3s_code_core/evaluation/
supervision.rs

1//! Host-policy boundary supervision for auxiliary evaluations.
2
3use 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    /// Dispatch identities are retained for the lifetime of this supervisor.
197    /// This prevents concurrent/replayed observations from creating a second
198    /// auxiliary run after the first admission has succeeded. Hosts should
199    /// scope a supervisor to the corresponding Session/Run lifetime.
200    dispatched: HashSet<(ExecutionTargetV1, u64, String)>,
201    /// Reservations made while evidence is being read or an auxiliary
202    /// service is admitting a run. This is separate from `dispatched` so a
203    /// cancelled/failed admission can be retried without creating a replay
204    /// hole.
205    admitting: HashSet<(ExecutionTargetV1, u64, String)>,
206    /// Claims that have been admitted in the durable ledger and still need a
207    /// terminal completion receipt. The typed claim keeps canonical identity,
208    /// the legacy request digest, and owner together so a stale watcher can
209    /// never remove a newer takeover claim.
210    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        // A cooldown is charged only after evidence and auxiliary admission
227        // succeed. Failed/cancelled admission can therefore retry the exact
228        // fact immediately, while later facts still observe the cooldown.
229        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        // Admission state is protected by a short, synchronous lock and does
247        // not perform I/O. Releasing synchronously makes cancellation cleanup
248        // deterministic: an immediately replayed observation cannot race a
249        // deferred task that has not yet removed the reservation.
250        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
276/// Connects an append-only fact journal to a host policy, evidence reader, and
277/// auxiliary-run service.  The policy is the only component that decides when
278/// an evaluation is useful; Core only enforces identity, bounds, and cleanup.
279pub 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    /// Construct a supervisor whose dispatch claims survive process restart.
311    /// The ledger is still only a replay/lease mechanism; result persistence
312    /// and host authorization remain separate concerns.
313    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    /// Cancel all admitted auxiliary work and release this supervisor's
357    /// in-memory admission history. Auxiliary services still own their
358    /// terminal snapshots; the parent cancellation token makes cooperative
359    /// executors settle without blocking the caller.
360    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    /// Return the number of auxiliary runs currently admitted but not yet
397    /// terminal. This is an observation only and never grants admission.
398    pub async fn pending_count(&self) -> usize {
399        lock_state(&self.state).in_flight.values().sum()
400    }
401
402    /// Record one runtime event and, if the host policy selects the matching
403    /// boundary, dispatch one isolated auxiliary run.  The returned handle is
404    /// advisory: the parent execution is never implicitly blocked by the
405    /// auxiliary result.
406    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                // A journal replay and a first append can race before either
455                // caller reaches this state lock. The dispatch key is the
456                // supervisor's admission identity, so an already reserved or
457                // committed key must never create a second evaluator.
458                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            // Reserve the slot before reading evidence or spawning the
477            // executor. Two events can arrive concurrently; reserving here
478            // keeps max_pending a real admission bound rather than a
479            // best-effort post-dispatch counter.
480            *state.in_flight.entry(key.clone()).or_default() += 1;
481            // Reserve before the async evidence read so two concurrent
482            // observations of the same fact cannot both pass admission.
483            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                // `interval` ticks immediately once; the initial claim already
611                // has a full lease, so the first renewal can wait one period.
612                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                                    // Shutdown or a newer supervisor has
631                                    // fenced this watcher out. It may still
632                                    // settle its local handle, but must not
633                                    // publish a stale completion receipt.
634                                    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;