Skip to main content

af_workflow/
supervisor.rs

1//! Database-agnostic scheduler for claimed long-running workflow instances.
2
3use af_context::{RunId, SubjectId, TenantId};
4use std::collections::{HashMap, VecDeque};
5use std::future::Future;
6use std::panic::AssertUnwindSafe;
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use chrono::{DateTime, Utc};
11use futures::{stream::FuturesUnordered, FutureExt, StreamExt};
12use serde_json::Value;
13
14use crate::{
15    ActionIntent, CapabilityPin, ControlEpochs, LifecyclePolicy, NodeRegistry, SpecDriver,
16    WorkflowRevision,
17};
18
19/// A durable fact that became due for a claimed instance: an expired timer or
20/// a pending trigger delivery. The driver reads it; the queue marks it consumed
21/// in the same transaction that commits the driver's command.
22#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
23pub struct Wakeup {
24    /// Stable identifier of this record.
25    pub id: String,
26    /// Discriminator naming the variant of this record.
27    pub kind: String,
28    /// Target branch; absent only for legacy single-branch work or system timers.
29    #[serde(default)]
30    pub branch_id: Option<af_context::BranchId>,
31    /// Structured payload.
32    pub payload: Value,
33}
34
35/// One claimed instance with everything a driver needs to evaluate it.
36#[derive(Debug, Clone, PartialEq)]
37pub struct WorkItem {
38    /// Stable identifier of this record.
39    pub id: String,
40    /// Run this record belongs to.
41    pub run_id: RunId,
42    /// Tenant that owns this record.
43    pub tenant_id: TenantId,
44    /// Subject (user or service principal) acting on or owning this record.
45    pub subject_id: SubjectId,
46    /// Workflow spec identifier.
47    pub spec_id: String,
48    /// Workflow definition this record belongs to.
49    pub definition_id: String,
50    /// Pinned workflow revision.
51    pub workflow_revision: u64,
52    /// Digest of the pinned revision.
53    pub workflow_revision_digest: String,
54    /// Pinned execution profile.
55    pub execution_profile_id: String,
56    /// Pinned execution profile revision.
57    pub execution_profile_revision: u64,
58    /// Digest of the pinned profile.
59    pub execution_profile_digest: String,
60    /// Kernel ABI the revision pinned.
61    pub kernel_abi_version: String,
62    /// Capabilities the revision may dispatch.
63    pub capability_pins: Vec<CapabilityPin>,
64    /// Completion, timeout and catch-up policy pinned by the revision.
65    pub lifecycle: LifecyclePolicy,
66    /// Database timestamps captured by the claim transaction. Scheduling logic
67    /// never consults an application-host clock.
68    pub scheduled_at: DateTime<Utc>,
69    /// Immutable database creation time, anchoring each initial branch schedule.
70    pub created_at: DateTime<Utc>,
71    /// Database time of the claim.
72    pub claimed_at: DateTime<Utc>,
73    /// Configuration object validated against the declared schema.
74    pub config: Value,
75    /// CAS version the command must be committed against.
76    pub state_version: i64,
77    /// Control epochs at claim time.
78    pub control_epochs: ControlEpochs,
79    /// Whether cancellation was requested; the terminal commit honours it.
80    pub cancel_requested: bool,
81    /// Fencing token bumped on every lease acquisition; stale holders cannot write.
82    pub lease_version: i64,
83    #[doc = "Due timers and trigger deliveries, oldest first."]
84    pub wakeups: Vec<Wakeup>,
85}
86
87impl WorkItem {
88    /// Whether this claim only needs cancellation or a trusted terminal timer to converge.
89    pub fn is_converging(&self) -> bool {
90        self.cancel_requested
91            || self
92                .wakeups
93                .iter()
94                .any(|wakeup| wakeup.kind == "timer" && wakeup.payload["kind"] == "terminal_action")
95    }
96
97    fn validate_pins(&self) -> Result<(), String> {
98        for (name, value) in [
99            ("definition_id", self.definition_id.as_str()),
100            (
101                "workflow_revision_digest",
102                self.workflow_revision_digest.as_str(),
103            ),
104            ("execution_profile_id", self.execution_profile_id.as_str()),
105            (
106                "execution_profile_digest",
107                self.execution_profile_digest.as_str(),
108            ),
109            ("kernel_abi_version", self.kernel_abi_version.as_str()),
110        ] {
111            if value.trim().is_empty() {
112                return Err(format!("work item is missing pinned {name}"));
113            }
114        }
115        if self.workflow_revision == 0 || self.execution_profile_revision == 0 {
116            return Err("work item revision pins must be positive".into());
117        }
118        uuid::Uuid::parse_str(&self.run_id)
119            .map_err(|error| format!("work item has invalid run_id: {error}"))?;
120        if self.state_version < 0
121            || self.lease_version <= 0
122            || self.control_epochs.tenant < 0
123            || self.control_epochs.instance < 0
124        {
125            return Err("work item state, lease and control pins must be current".into());
126        }
127        Ok(())
128    }
129}
130
131/// What the queue does with the instance after committing a command.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum WorkDisposition {
134    /// Evaluate again after a delay.
135    Continue {
136        /// Seconds until the next evaluation.
137        delay_secs: i64,
138    },
139    /// Evaluate again at an exact time.
140    Reschedule {
141        /// Next evaluation time.
142        at: DateTime<Utc>,
143    },
144    /// The instance is finished.
145    Complete,
146    /// The evaluation failed.
147    Failed {
148        /// Failure reason.
149        error: String,
150        /// When to retry; `None` fails the instance permanently.
151        retry_at: Option<DateTime<Utc>>,
152    },
153}
154
155/// Completion-policy accounting for one evaluation.
156#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
157pub struct EvaluationOutcome {
158    /// The evaluation consumed a trigger or schedule occurrence.
159    pub triggered: bool,
160    /// At least one event passed a material step.
161    pub matched: bool,
162    /// The graph reached a sink or retained an event through its final step.
163    pub succeeded: bool,
164    /// The evaluation observed a terminal action outcome.
165    pub action_terminal: bool,
166}
167
168/// The only result a workflow driver may return. The queue commits the state,
169/// event, intents and scheduling disposition atomically before any provider is
170/// allowed to dispatch an external effect.
171#[derive(Debug, Clone, PartialEq)]
172pub struct WorkflowTransitionCommand {
173    /// Idempotency key of this transition.
174    pub delivery_key: String,
175    /// Content digest; a reused key with a different digest is a conflict.
176    pub delivery_digest: String,
177    /// Stable machine-readable event type.
178    pub event_type: String,
179    /// Digest of the authoritative event.
180    pub event_digest: String,
181    /// Authoritative event payload.
182    pub event_payload: Value,
183    /// New authoritative state.
184    pub next_state: Value,
185    /// Intents to persist in the same transaction.
186    pub action_intents: Vec<ActionIntent>,
187    /// Completion-policy accounting for this transition.
188    pub outcome: EvaluationOutcome,
189    /// Claimed wakeups actually consumed by this transition; failed evaluations consume none.
190    pub consumed_wakeups: Vec<String>,
191    /// Scheduling disposition.
192    pub disposition: WorkDisposition,
193}
194
195impl WorkflowTransitionCommand {
196    /// Command recording an evaluation failure and retrying at `retry_at`.
197    pub fn failure(item: &WorkItem, error: impl Into<String>, retry_at: DateTime<Utc>) -> Self {
198        let error = error.into();
199        Self {
200            delivery_key: format!("supervisor:{}:{}", item.id, item.state_version),
201            delivery_digest: format!("failure:{}:{}", item.lease_version, error),
202            event_type: "workflow.evaluation_failed".into(),
203            event_digest: format!("failure:{}:{}", item.state_version, error),
204            event_payload: serde_json::json!({"error": error.clone()}),
205            next_state: item.config.clone(),
206            action_intents: Vec::new(),
207            outcome: EvaluationOutcome::default(),
208            consumed_wakeups: Vec::new(),
209            disposition: WorkDisposition::Failed {
210                error,
211                retry_at: Some(retry_at),
212            },
213        }
214    }
215
216    /// Validate pins, effects and acknowledgements against the claimed work.
217    pub fn validate_for(&self, item: &WorkItem) -> Result<(), String> {
218        item.validate_pins()?;
219        for (name, value) in [
220            ("delivery_key", self.delivery_key.as_str()),
221            ("delivery_digest", self.delivery_digest.as_str()),
222            ("event_type", self.event_type.as_str()),
223            ("event_digest", self.event_digest.as_str()),
224        ] {
225            if value.trim().is_empty() {
226                return Err(format!("workflow command is missing {name}"));
227            }
228        }
229        if item.cancel_requested && !self.action_intents.is_empty() {
230            return Err("cancelled work cannot prepare external actions".into());
231        }
232        let consumed: std::collections::BTreeSet<_> = self.consumed_wakeups.iter().collect();
233        if consumed.len() != self.consumed_wakeups.len()
234            || consumed
235                .iter()
236                .any(|id| !item.wakeups.iter().any(|wakeup| &wakeup.id == *id))
237            || (!consumed.is_empty()
238                && matches!(self.disposition, WorkDisposition::Failed { .. })
239                && !self.outcome.triggered)
240        {
241            return Err("workflow command acknowledges unconsumed or unclaimed work".into());
242        }
243        for intent in &self.action_intents {
244            intent
245                .validate_prepared()
246                .map_err(|error| error.to_string())?;
247            if intent.tenant_id != item.tenant_id || intent.instance_id != item.id {
248                return Err("action intent escapes claimed work scope".into());
249            }
250            if intent.run_id != item.run_id {
251                return Err("action intent escapes claimed workflow run".into());
252            }
253            // Resource-scope epochs are owned by the store: the queue does not
254            // know which resource an intent targets, so it only pins what it
255            // claimed (tenant + instance) and the commit transaction proves the
256            // resource epoch against `workflow_control_epochs`.
257            if intent.control_epochs.tenant != item.control_epochs.tenant
258                || intent.control_epochs.instance != item.control_epochs.instance
259                || intent.lease_epoch != item.lease_version
260            {
261                return Err("action intent uses stale control or lease pins".into());
262            }
263            if !item
264                .capability_pins
265                .iter()
266                .any(|pin| pin == &intent.capability)
267            {
268                return Err(format!(
269                    "action capability '{}' is not pinned by the workflow revision",
270                    intent.capability.id
271                ));
272            }
273        }
274        Ok(())
275    }
276}
277
278/// Result of one pass.
279#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
280pub struct SupervisorStats {
281    /// Items claimed.
282    pub claimed: usize,
283    /// Items whose evaluation failed.
284    pub failed: usize,
285}
286
287/// Pass configuration.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct SupervisorSettings {
290    /// Identity of the worker holding the lease.
291    pub worker_id: String,
292    /// Lease duration per claim.
293    pub lease_secs: i64,
294    /// Retry delay after a failed evaluation.
295    pub requeue_delay_secs: i64,
296    /// Maximum items per pass.
297    pub claim_batch: i64,
298    /// Concurrent evaluations.
299    pub concurrency: usize,
300}
301
302impl SupervisorSettings {
303    fn concurrency(&self) -> usize {
304        self.concurrency.max(1)
305    }
306}
307
308/// Failure at the supervisor boundary. Driver evaluation reasons stay `String`
309/// because they are product-authored text recorded verbatim in the failure
310/// event; everything the kernel itself decides is typed here.
311#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
312pub enum SupervisorError {
313    /// The durable work queue could not claim, renew or commit.
314    #[error("work queue: {0}")]
315    Queue(String),
316    /// A driver's spec set failed validation or two drivers claim one spec.
317    #[error("driver '{driver}': {reason}")]
318    Driver {
319        /// Driver name.
320        driver: String,
321        /// Why.
322        reason: String,
323    },
324    /// The per-pass context factory failed; claimed work was requeued.
325    #[error("context: {0}")]
326    Context(String),
327    /// One or more transition commits failed after evaluation.
328    #[error("workflow state commit failed: {0}")]
329    Commit(String),
330}
331
332/// Durable queue the supervisor claims from and commits to.
333#[async_trait]
334pub trait WorkQueue: Send + Sync {
335    /// Claim due instances for `spec_ids`; an empty slice selects all specs.
336    async fn claim_due(
337        &self,
338        spec_ids: &[String],
339        worker_id: &str,
340        lease_secs: i64,
341        batch: i64,
342    ) -> Result<Vec<WorkItem>, SupervisorError>;
343
344    /// Read the immutable revision of an owned, currently leased instance.
345    /// Stores without revision persistence reject dynamic spec execution.
346    async fn load_revision(&self, _item: &WorkItem) -> Result<WorkflowRevision, SupervisorError> {
347        Err(SupervisorError::Queue(
348            "published revision loading is unavailable".into(),
349        ))
350    }
351
352    /// Recheck current owner admission before any graph/read or custom-driver evaluation.
353    /// Queues without an authority source fail closed. Terminal system observations
354    /// and cancellation still enter their existing convergence paths.
355    async fn admit_evaluation(&self, _item: &WorkItem) -> Result<(), SupervisorError> {
356        Err(SupervisorError::Queue(
357            "workflow evaluation admission is unavailable".into(),
358        ))
359    }
360
361    /// Renew a claim's lease.
362    async fn renew(
363        &self,
364        tenant_id: &str,
365        id: &str,
366        worker_id: &str,
367        lease_version: i64,
368        lease_secs: i64,
369    ) -> Result<(), SupervisorError>;
370    /// Commit a driver command atomically.
371    async fn commit_command(
372        &self,
373        item: &WorkItem,
374        command: &WorkflowTransitionCommand,
375    ) -> Result<(), SupervisorError>;
376}
377
378/// In-memory [`WorkQueue`] for consumer unit tests: hands out queued items in
379/// order and records every committed command. No leases, no durability.
380#[derive(Default)]
381pub struct MemoryWorkQueue {
382    items: std::sync::Mutex<Vec<WorkItem>>,
383    committed: std::sync::Mutex<Vec<(WorkItem, WorkflowTransitionCommand)>>,
384    renewals: std::sync::Mutex<Vec<(String, i64)>>,
385}
386
387impl MemoryWorkQueue {
388    /// Queue pre-loaded with `items`.
389    pub fn new(items: Vec<WorkItem>) -> Self {
390        Self {
391            items: std::sync::Mutex::new(items),
392            ..Self::default()
393        }
394    }
395
396    /// Enqueue an item.
397    pub fn push(&self, item: WorkItem) {
398        self.items
399            .lock()
400            .unwrap_or_else(std::sync::PoisonError::into_inner)
401            .push(item);
402    }
403
404    /// Every `(item, command)` pair committed so far, in commit order.
405    pub fn committed(&self) -> Vec<(WorkItem, WorkflowTransitionCommand)> {
406        self.committed
407            .lock()
408            .unwrap_or_else(std::sync::PoisonError::into_inner)
409            .clone()
410    }
411
412    /// `(instance id, lease version)` for every renewal.
413    pub fn renewals(&self) -> Vec<(String, i64)> {
414        self.renewals
415            .lock()
416            .unwrap_or_else(std::sync::PoisonError::into_inner)
417            .clone()
418    }
419}
420
421#[async_trait]
422impl WorkQueue for MemoryWorkQueue {
423    async fn admit_evaluation(&self, _: &WorkItem) -> Result<(), SupervisorError> {
424        Ok(())
425    }
426    async fn claim_due(
427        &self,
428        spec_ids: &[String],
429        _worker_id: &str,
430        _lease_secs: i64,
431        batch: i64,
432    ) -> Result<Vec<WorkItem>, SupervisorError> {
433        let mut items = self
434            .items
435            .lock()
436            .unwrap_or_else(std::sync::PoisonError::into_inner);
437        let mut claimed = Vec::new();
438        let mut index = 0;
439        while index < items.len() && claimed.len() < batch.max(0) as usize {
440            if spec_ids.is_empty() || spec_ids.contains(&items[index].spec_id) {
441                claimed.push(items.remove(index));
442            } else {
443                index += 1;
444            }
445        }
446        Ok(claimed)
447    }
448
449    async fn renew(
450        &self,
451        _tenant_id: &str,
452        id: &str,
453        _worker_id: &str,
454        lease_version: i64,
455        _lease_secs: i64,
456    ) -> Result<(), SupervisorError> {
457        self.renewals
458            .lock()
459            .unwrap_or_else(std::sync::PoisonError::into_inner)
460            .push((id.to_owned(), lease_version));
461        Ok(())
462    }
463
464    async fn commit_command(
465        &self,
466        item: &WorkItem,
467        command: &WorkflowTransitionCommand,
468    ) -> Result<(), SupervisorError> {
469        self.committed
470            .lock()
471            .unwrap_or_else(std::sync::PoisonError::into_inner)
472            .push((item.clone(), command.clone()));
473        Ok(())
474    }
475}
476
477/// Deterministic evaluator for the specs it owns.
478#[async_trait]
479pub trait WorkflowDriver<Context>: Send + Sync
480where
481    Context: Send + Sync,
482{
483    /// Stable driver name.
484    fn name(&self) -> &'static str;
485    /// Specs this driver owns.
486    fn spec_ids(&self) -> Vec<&str>;
487    /// Reject a spec set the driver cannot serve; the reason is reported as
488    /// [`SupervisorError::Driver`].
489    fn validate_specs(&self) -> Result<(), String>;
490    /// Explicitly admit the exact revision and ABI a custom driver implements.
491    /// The default refuses execution rather than silently accepting new revisions.
492    fn supports_revision(&self, _item: &WorkItem) -> bool {
493        false
494    }
495    /// Evaluate deterministic workflow state and return one durable command.
496    /// External effects must be represented as prepared `ActionIntent`s and
497    /// dispatched later by a registered provider.
498    async fn evaluate(
499        &self,
500        context: &Context,
501        item: &WorkItem,
502    ) -> Result<WorkflowTransitionCommand, String>;
503}
504
505/// Drivers keyed by the specs they own.
506pub struct DriverRegistry<Context: Send + Sync> {
507    drivers: Vec<Arc<dyn WorkflowDriver<Context>>>,
508    by_spec: HashMap<String, Arc<dyn WorkflowDriver<Context>>>,
509    nodes: Option<Arc<NodeRegistry>>,
510    startup_specs: Vec<String>,
511    compiled: tokio::sync::Mutex<VecDeque<CompiledRevision<Context>>>,
512}
513
514struct CompiledRevision<Context: Send + Sync> {
515    scope: Option<(TenantId, af_context::WorkflowDefinitionId)>,
516    key: (String, String),
517    content: Value,
518    driver: Arc<dyn WorkflowDriver<Context>>,
519}
520
521const COMPILED_REVISION_LIMIT: usize = 256;
522
523impl<Context: Send + Sync> Default for DriverRegistry<Context> {
524    fn default() -> Self {
525        Self {
526            drivers: Vec::new(),
527            by_spec: HashMap::new(),
528            nodes: None,
529            startup_specs: Vec::new(),
530            compiled: Default::default(),
531        }
532    }
533}
534
535impl<Context: Send + Sync> DriverRegistry<Context> {
536    /// Empty registry.
537    pub fn new() -> Self {
538        Self::default()
539    }
540
541    /// Enable lazy execution of published specs using this immutable registry snapshot.
542    pub fn with_node_registry(mut self, nodes: NodeRegistry) -> Self {
543        self.compiled.get_mut().clear();
544        self.startup_specs.clear();
545        self.nodes = Some(Arc::new(nodes));
546        self
547    }
548
549    /// Precompile a shipped graph. Execution still requires its published revision.
550    pub fn prewarm_spec(&mut self, spec: &crate::Spec) -> Result<(), String> {
551        let nodes = self.nodes.as_ref().ok_or("node registry is unavailable")?;
552        let driver = SpecDriver::new(spec, nodes).map_err(|error| error.to_string())?;
553        let compiled = self.compiled.get_mut();
554        if compiled.len() == COMPILED_REVISION_LIMIT {
555            compiled.pop_front();
556        }
557        compiled.push_back(CompiledRevision {
558            scope: None,
559            key: (spec.spec_id.clone(), String::new()),
560            content: serde_json::to_value(spec).map_err(|error| error.to_string())?,
561            driver: Arc::new(driver),
562        });
563        if !self.startup_specs.contains(&spec.spec_id) {
564            self.startup_specs.push(spec.spec_id.clone());
565        }
566        Ok(())
567    }
568
569    /// Validate and compile the exact published graph, caching at most 256 revisions.
570    /// Callers must load the revision through the owner-scoped durable queue first.
571    pub async fn resolve_revision(
572        &self,
573        item: &WorkItem,
574        revision: WorkflowRevision,
575    ) -> Result<Arc<dyn WorkflowDriver<Context>>, String>
576    where
577        Context: 'static,
578    {
579        item.validate_pins()?;
580        revision.validate().map_err(|error| error.to_string())?;
581        if revision.definition_id != item.definition_id
582            || revision.revision != item.workflow_revision
583            || revision.content_digest != item.workflow_revision_digest
584            || revision.spec.spec_id != item.spec_id
585            || revision.kernel_abi_version != item.kernel_abi_version
586            || revision.capabilities != item.capability_pins
587        {
588            return Err("published revision does not match the claimed pins".into());
589        }
590        if !matches!(revision.kernel_abi_version.as_str(), "1" | "kernel-abi-1") {
591            return Err("unsupported workflow kernel ABI".into());
592        }
593        let nodes = self
594            .nodes
595            .as_ref()
596            .ok_or("dynamic spec execution is unavailable")?;
597        let builtins = NodeRegistry::with_builtins();
598        for (expression, version) in &revision.expression_versions {
599            let supported = nodes.capability_manifests().any(|manifest| {
600                manifest.id == *expression && manifest.contract_version == *version
601            }) || (builtins.is_step(expression) && version == "1");
602            if !supported {
603                return Err("unsupported expression version".into());
604            }
605        }
606        for pin in &revision.capabilities {
607            let manifest = nodes
608                .capability_by_pin(pin)
609                .ok_or("pinned capability is unavailable")?;
610            debug_assert_eq!(manifest.id, pin.id);
611        }
612        let selected_nodes = nodes
613            .for_capability_pins(&revision.capabilities)
614            .map_err(|error| error.to_string())?;
615        for node in revision
616            .spec
617            .branches
618            .iter()
619            .flat_map(|branch| &branch.nodes)
620        {
621            if let Some(manifest) = selected_nodes.capability(&node.node_type) {
622                if !revision.capabilities.iter().any(|pin| {
623                    pin.id == manifest.id
624                        && pin.contract_version == manifest.contract_version
625                        && pin.content_digest == manifest.content_digest
626                }) {
627                    return Err("graph capability lacks an exact revision pin".into());
628                }
629            } else if nodes
630                .capability_manifests()
631                .any(|manifest| manifest.id == node.node_type)
632            {
633                return Err("graph capability lacks an exact revision pin".into());
634            } else if !builtins.is_step(&node.node_type) && !builtins.is_ingress(&node.node_type) {
635                return Err("product graph node requires a capability manifest".into());
636            }
637        }
638        let content = serde_json::json!({
639            "spec": revision.spec, "abi": revision.kernel_abi_version,
640            "capabilities": revision.capabilities,
641            "expressions": revision.expression_versions,
642            "dependencies": revision.dependency_set_digest,
643        });
644        let key = (item.spec_id.clone(), item.workflow_revision_digest.clone());
645        let scope = Some((
646            item.tenant_id.clone(),
647            item.definition_id
648                .parse()
649                .map_err(|_| "invalid workflow definition identity")?,
650        ));
651        let mut compiled = self.compiled.lock().await;
652        if let Some(entry) = compiled
653            .iter()
654            .find(|entry| entry.key == key && entry.scope == scope)
655        {
656            if entry.content != content {
657                return Err("revision digest refers to different compiled content".into());
658            }
659            return Ok(entry.driver.clone());
660        }
661        if let Some(index) = compiled.iter().position(|entry| {
662            entry.key.0 == item.spec_id
663                && entry.key.1.is_empty()
664                && entry.content == content["spec"]
665        }) {
666            let mut entry = compiled.remove(index).expect("located cache entry exists");
667            entry.scope = scope;
668            entry.key = key;
669            entry.content = content;
670            let driver = entry.driver.clone();
671            compiled.push_back(entry);
672            return Ok(driver);
673        }
674        let nodes = selected_nodes;
675        // ponytail: cache misses compile serially per registry; parallelize only if
676        // measured publish bursts require it. Compilation never blocks the executor.
677        let driver: Arc<dyn WorkflowDriver<Context>> = Arc::new(
678            tokio::task::spawn_blocking(move || {
679                SpecDriver::new(&revision.spec, &nodes).map_err(|error| error.to_string())
680            })
681            .await
682            .map_err(|_| "spec compiler panicked")??,
683        );
684        if compiled.len() == COMPILED_REVISION_LIMIT {
685            compiled.pop_front();
686        }
687        compiled.push_back(CompiledRevision {
688            scope,
689            key,
690            content,
691            driver: driver.clone(),
692        });
693        Ok(driver)
694    }
695
696    async fn resolve<Queue: WorkQueue>(
697        &self,
698        queue: &Queue,
699        item: &WorkItem,
700    ) -> Result<Arc<dyn WorkflowDriver<Context>>, String>
701    where
702        Context: 'static,
703    {
704        if let Some(driver) = self.by_spec.get(&item.spec_id) {
705            if !driver.supports_revision(item) {
706                return Err("custom driver does not support the claimed revision".into());
707            }
708            return Ok(driver.clone());
709        }
710        let revision = queue
711            .load_revision(item)
712            .await
713            .map_err(|error| error.to_string())?;
714        self.resolve_revision(item, revision).await
715    }
716
717    /// Register a driver. Duplicate spec ownership is a startup error because
718    /// dispatch must never depend on registration order.
719    pub fn register(
720        &mut self,
721        driver: Arc<dyn WorkflowDriver<Context>>,
722    ) -> Result<&mut Self, SupervisorError> {
723        for spec_id in driver.spec_ids() {
724            if let Some(existing) = self.by_spec.get(spec_id) {
725                return Err(SupervisorError::Driver {
726                    driver: driver.name().to_owned(),
727                    reason: format!(
728                        "spec '{spec_id}' is already claimed by '{}'",
729                        existing.name()
730                    ),
731                });
732            }
733            self.by_spec.insert(spec_id.to_owned(), driver.clone());
734        }
735        self.drivers.push(driver);
736        Ok(self)
737    }
738
739    /// Every owned spec id, sorted.
740    pub fn spec_ids(&self) -> Vec<String> {
741        let mut ids = self.by_spec.keys().cloned().collect::<Vec<_>>();
742        ids.extend(self.startup_specs.iter().cloned());
743        ids.sort();
744        ids.dedup();
745        ids
746    }
747
748    /// Driver owning `spec_id`.
749    pub fn for_spec(&self, spec_id: &str) -> Option<&Arc<dyn WorkflowDriver<Context>>> {
750        self.by_spec.get(spec_id)
751    }
752
753    /// Registered driver names.
754    pub fn names(&self) -> Vec<&'static str> {
755        self.drivers.iter().map(|driver| driver.name()).collect()
756    }
757
758    /// Whether no driver is registered or prewarmed; published graphs may still resolve lazily.
759    pub fn is_empty(&self) -> bool {
760        self.drivers.is_empty() && self.startup_specs.is_empty()
761    }
762
763    /// Validate every driver's spec set.
764    pub fn validate_all(&self) -> Result<(), SupervisorError> {
765        for driver in &self.drivers {
766            driver
767                .validate_specs()
768                .map_err(|reason| SupervisorError::Driver {
769                    driver: driver.name().to_owned(),
770                    reason,
771                })?;
772        }
773        Ok(())
774    }
775}
776
777/// One supervisor pass: claim due work, evaluate concurrently under renewed leases, commit every command.
778/// A failing context factory requeues every claimed item with the failure.
779pub async fn run_due_pass<Context, Queue, BuildContext, BuildFuture>(
780    queue: &Queue,
781    registry: &DriverRegistry<Context>,
782    settings: &SupervisorSettings,
783    build_context: BuildContext,
784) -> Result<SupervisorStats, SupervisorError>
785where
786    Context: Send + Sync + 'static,
787    Queue: WorkQueue,
788    BuildContext: FnOnce() -> BuildFuture,
789    BuildFuture: Future<Output = Result<Context, String>> + Send,
790{
791    if registry.is_empty() && registry.nodes.is_none() {
792        return Ok(SupervisorStats::default());
793    }
794    let claim_limit = settings
795        .claim_batch
796        .clamp(1, i64::try_from(settings.concurrency()).unwrap_or(i64::MAX));
797    let items = queue
798        .claim_due(
799            &if registry.nodes.is_some() {
800                Vec::new()
801            } else {
802                registry.spec_ids()
803            },
804            &settings.worker_id,
805            settings.lease_secs,
806            claim_limit,
807        )
808        .await?;
809    if items.is_empty() {
810        return Ok(SupervisorStats::default());
811    }
812
813    let context = match build_context().await {
814        Ok(context) => Arc::new(context),
815        Err(error) => {
816            let mut cleanup_errors = Vec::new();
817            for item in &items {
818                let command = WorkflowTransitionCommand::failure(
819                    item,
820                    error.clone(),
821                    Utc::now() + chrono::Duration::seconds(settings.requeue_delay_secs),
822                );
823                if let Err(cleanup) = queue.commit_command(item, &command).await {
824                    cleanup_errors.push(format!("{} commit failure: {cleanup}", item.id));
825                }
826            }
827            if !cleanup_errors.is_empty() {
828                return Err(SupervisorError::Context(format!(
829                    "{error}; claimed work cleanup failed: {}",
830                    cleanup_errors.join(", ")
831                )));
832            }
833            return Err(SupervisorError::Context(error));
834        }
835    };
836
837    let claimed = items.len();
838    let mut failed = 0;
839    let mut persistence_errors = Vec::new();
840    let mut work = items.into_iter();
841    let mut tasks = FuturesUnordered::new();
842
843    let spawn_next = |tasks: &mut FuturesUnordered<_>, work: &mut std::vec::IntoIter<WorkItem>| {
844        let Some(item) = work.next() else {
845            return false;
846        };
847        let context = context.clone();
848        let renewal_period = std::time::Duration::from_millis(
849            (settings.lease_secs.clamp(1, 3600) as u64 * 1000 / 3).max(1),
850        );
851        tasks.push(async move {
852            let evaluation_item = item.clone();
853            let evaluation = async {
854                evaluation_item.validate_pins()?;
855                if !evaluation_item.is_converging() {
856                    queue
857                        .admit_evaluation(&evaluation_item)
858                        .await
859                        .map_err(|error| error.to_string())?;
860                }
861                let driver = registry.resolve(queue, &evaluation_item).await?;
862                AssertUnwindSafe(driver.evaluate(&context, &evaluation_item))
863                    .catch_unwind()
864                    .await
865                    .map_err(|_| "driver panicked".to_string())
866                    .and_then(|result| result)
867            };
868            tokio::pin!(evaluation);
869            let mut renewal = tokio::time::interval_at(
870                tokio::time::Instant::now() + renewal_period,
871                renewal_period,
872            );
873            renewal.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
874            let result = loop {
875                tokio::select! {
876                    result = &mut evaluation => break result,
877                    _ = renewal.tick() => {
878                        if let Err(error) = queue
879                            .renew(
880                                &item.tenant_id,
881                                &item.id,
882                                &settings.worker_id,
883                                item.lease_version,
884                                settings.lease_secs,
885                            )
886                            .await
887                        {
888                            break Err(format!("lease renewal failed: {error}"));
889                        }
890                    }
891                }
892            };
893            (item, result)
894        });
895        true
896    };
897
898    for _ in 0..settings.concurrency() {
899        if !spawn_next(&mut tasks, &mut work) {
900            break;
901        }
902    }
903
904    while let Some((item, result)) = tasks.next().await {
905        let command = match result {
906            Ok(command) => match command.validate_for(&item) {
907                Ok(()) => command,
908                Err(error) => {
909                    failed += 1;
910                    WorkflowTransitionCommand::failure(
911                        &item,
912                        error,
913                        Utc::now() + chrono::Duration::seconds(settings.requeue_delay_secs),
914                    )
915                }
916            },
917            Err(error) => {
918                failed += 1;
919                WorkflowTransitionCommand::failure(
920                    &item,
921                    error,
922                    Utc::now() + chrono::Duration::seconds(settings.requeue_delay_secs),
923                )
924            }
925        };
926        if let Err(error) = queue.commit_command(&item, &command).await {
927            failed += usize::from(!matches!(
928                command.disposition,
929                WorkDisposition::Failed { .. }
930            ));
931            persistence_errors.push(format!("{} command commit: {error}", item.id));
932        }
933        spawn_next(&mut tasks, &mut work);
934    }
935
936    if persistence_errors.is_empty() {
937        Ok(SupervisorStats { claimed, failed })
938    } else {
939        Err(SupervisorError::Commit(persistence_errors.join(", ")))
940    }
941}
942
943#[cfg(test)]
944mod tests {
945    use super::*;
946    use std::sync::atomic::{AtomicBool, Ordering};
947    use std::sync::Mutex;
948
949    struct Context;
950    struct Driver {
951        valid: bool,
952    }
953
954    #[async_trait]
955    impl WorkflowDriver<Context> for Driver {
956        fn name(&self) -> &'static str {
957            "driver"
958        }
959        fn spec_ids(&self) -> Vec<&str> {
960            vec!["spec"]
961        }
962        fn validate_specs(&self) -> Result<(), String> {
963            self.valid.then_some(()).ok_or_else(|| "invalid".into())
964        }
965        fn supports_revision(&self, item: &WorkItem) -> bool {
966            item.workflow_revision_digest == "workflow-digest" && item.kernel_abi_version == "1"
967        }
968        async fn evaluate(
969            &self,
970            _context: &Context,
971            item: &WorkItem,
972        ) -> Result<WorkflowTransitionCommand, String> {
973            if let Some(milliseconds) = item.config["sleep_ms"].as_u64() {
974                tokio::time::sleep(std::time::Duration::from_millis(milliseconds)).await;
975            }
976            if item.config["fail"] == true {
977                Err("planned".into())
978            } else if item.config["reschedule"] == true {
979                Ok(command(
980                    item,
981                    WorkDisposition::Reschedule { at: Utc::now() },
982                    serde_json::json!({"next": true}),
983                ))
984            } else if item.config["stop"] == true {
985                Ok(command(
986                    item,
987                    WorkDisposition::Complete,
988                    item.config.clone(),
989                ))
990            } else {
991                Ok(command(
992                    item,
993                    WorkDisposition::Continue { delay_secs: 5 },
994                    item.config.clone(),
995                ))
996            }
997        }
998    }
999
1000    fn command(
1001        item: &WorkItem,
1002        disposition: WorkDisposition,
1003        next_state: Value,
1004    ) -> WorkflowTransitionCommand {
1005        WorkflowTransitionCommand {
1006            consumed_wakeups: item
1007                .wakeups
1008                .iter()
1009                .map(|wakeup| wakeup.id.clone())
1010                .collect(),
1011            delivery_key: format!("test:{}:{}", item.id, item.state_version),
1012            delivery_digest: "delivery-digest".into(),
1013            event_type: "workflow.test".into(),
1014            event_digest: "event-digest".into(),
1015            event_payload: Value::Null,
1016            next_state,
1017            action_intents: Vec::new(),
1018            outcome: EvaluationOutcome::default(),
1019            disposition,
1020        }
1021    }
1022
1023    #[derive(Default)]
1024    struct Queue {
1025        items: Mutex<Vec<WorkItem>>,
1026        committed: Mutex<Vec<(String, WorkDisposition)>>,
1027        renewals: Mutex<Vec<(String, String, i64)>>,
1028        renewal_error: Mutex<Option<String>>,
1029        finalization_error: Mutex<Option<String>>,
1030        claim_limits: Mutex<Vec<i64>>,
1031    }
1032
1033    #[async_trait]
1034    impl WorkQueue for Queue {
1035        async fn admit_evaluation(&self, _: &WorkItem) -> Result<(), SupervisorError> {
1036            Ok(())
1037        }
1038        async fn claim_due(
1039            &self,
1040            _spec_ids: &[String],
1041            _worker_id: &str,
1042            _lease_secs: i64,
1043            batch: i64,
1044        ) -> Result<Vec<WorkItem>, SupervisorError> {
1045            self.claim_limits.lock().unwrap().push(batch);
1046            let mut items = self.items.lock().unwrap();
1047            let take = items.len().min(batch as usize);
1048            Ok(items.drain(..take).collect())
1049        }
1050
1051        async fn renew(
1052            &self,
1053            _tenant_id: &str,
1054            id: &str,
1055            worker_id: &str,
1056            lease_version: i64,
1057            _lease_secs: i64,
1058        ) -> Result<(), SupervisorError> {
1059            self.renewals.lock().unwrap().push((
1060                id.to_string(),
1061                worker_id.to_string(),
1062                lease_version,
1063            ));
1064            match self.renewal_error.lock().unwrap().clone() {
1065                Some(error) => Err(SupervisorError::Queue(error)),
1066                None => Ok(()),
1067            }
1068        }
1069
1070        async fn commit_command(
1071            &self,
1072            item: &WorkItem,
1073            command: &WorkflowTransitionCommand,
1074        ) -> Result<(), SupervisorError> {
1075            self.committed
1076                .lock()
1077                .unwrap()
1078                .push((item.id.clone(), command.disposition.clone()));
1079            match self.finalization_error.lock().unwrap().clone() {
1080                Some(error) => Err(SupervisorError::Queue(error)),
1081                None => Ok(()),
1082            }
1083        }
1084    }
1085
1086    fn item(id: &str, fail: bool) -> WorkItem {
1087        WorkItem {
1088            id: id.into(),
1089            run_id: uuid::Uuid::new_v4().to_string().parse().unwrap(),
1090            tenant_id: "tenant".parse().unwrap(),
1091            subject_id: "subject".parse().unwrap(),
1092            spec_id: "spec".into(),
1093            definition_id: "definition".into(),
1094            workflow_revision: 1,
1095            workflow_revision_digest: "workflow-digest".into(),
1096            execution_profile_id: "profile".into(),
1097            execution_profile_revision: 1,
1098            execution_profile_digest: "profile-digest".into(),
1099            kernel_abi_version: "1".into(),
1100            capability_pins: Vec::new(),
1101            lifecycle: LifecyclePolicy::run_once(),
1102            scheduled_at: Utc::now(),
1103            created_at: Utc::now(),
1104            claimed_at: Utc::now(),
1105            config: serde_json::json!({"fail": fail}),
1106            state_version: 0,
1107            control_epochs: ControlEpochs::default(),
1108            cancel_requested: false,
1109            lease_version: 1,
1110            wakeups: Vec::new(),
1111        }
1112    }
1113
1114    fn stopped_item(id: &str) -> WorkItem {
1115        WorkItem {
1116            config: serde_json::json!({"stop": true}),
1117            ..item(id, false)
1118        }
1119    }
1120
1121    fn settings() -> SupervisorSettings {
1122        SupervisorSettings {
1123            worker_id: "worker".into(),
1124            lease_secs: 60,
1125            requeue_delay_secs: 5,
1126            claim_batch: 10,
1127            concurrency: 3,
1128        }
1129    }
1130
1131    struct MustNotEvaluate(Arc<AtomicBool>);
1132
1133    #[async_trait]
1134    impl WorkflowDriver<Context> for MustNotEvaluate {
1135        fn name(&self) -> &'static str {
1136            "must-not-evaluate"
1137        }
1138
1139        fn spec_ids(&self) -> Vec<&str> {
1140            vec!["spec"]
1141        }
1142
1143        fn validate_specs(&self) -> Result<(), String> {
1144            Ok(())
1145        }
1146
1147        async fn evaluate(
1148            &self,
1149            _context: &Context,
1150            item: &WorkItem,
1151        ) -> Result<WorkflowTransitionCommand, String> {
1152            self.0.store(true, Ordering::SeqCst);
1153            Ok(command(
1154                item,
1155                WorkDisposition::Complete,
1156                item.config.clone(),
1157            ))
1158        }
1159    }
1160
1161    #[tokio::test]
1162    async fn missing_revision_pin_fails_before_driver_evaluation() {
1163        let mut stale = item("stale", false);
1164        stale.workflow_revision_digest.clear();
1165        let queue = Queue {
1166            items: Mutex::new(vec![stale]),
1167            ..Default::default()
1168        };
1169        let called = Arc::new(AtomicBool::new(false));
1170        let mut registry = DriverRegistry::new();
1171        registry
1172            .register(Arc::new(MustNotEvaluate(called.clone())))
1173            .unwrap();
1174
1175        let stats = run_due_pass(&queue, &registry, &settings(), || async { Ok(Context) })
1176            .await
1177            .unwrap();
1178
1179        assert_eq!(stats.failed, 1);
1180        assert!(!called.load(Ordering::SeqCst));
1181        assert!(matches!(
1182            queue.committed.lock().unwrap().as_slice(),
1183            [(id, WorkDisposition::Failed { error, .. })]
1184                if id == "stale" && error.contains("workflow_revision_digest")
1185        ));
1186    }
1187
1188    #[tokio::test]
1189    async fn due_pass_releases_success_and_marks_failures() {
1190        let queue = Queue {
1191            items: Mutex::new(vec![
1192                item("ok", false),
1193                stopped_item("done"),
1194                item("bad", true),
1195            ]),
1196            ..Default::default()
1197        };
1198        let mut registry = DriverRegistry::new();
1199        registry.register(Arc::new(Driver { valid: true })).unwrap();
1200        let stats = run_due_pass(&queue, &registry, &settings(), || async { Ok(Context) })
1201            .await
1202            .unwrap();
1203        assert_eq!(
1204            stats,
1205            SupervisorStats {
1206                claimed: 3,
1207                failed: 1
1208            }
1209        );
1210        let committed = queue.committed.lock().unwrap();
1211        assert_eq!(committed.len(), 3);
1212        assert!(committed
1213            .iter()
1214            .any(|(id, disposition)| id == "done" && disposition == &WorkDisposition::Complete));
1215        assert!(committed.iter().any(|(id, disposition)| id == "bad"
1216            && matches!(disposition, WorkDisposition::Failed { .. })));
1217    }
1218
1219    #[tokio::test]
1220    async fn context_failure_releases_every_claim() {
1221        let queue = Queue {
1222            items: Mutex::new(vec![item("one", false), item("two", false)]),
1223            ..Default::default()
1224        };
1225        let mut registry = DriverRegistry::new();
1226        registry.register(Arc::new(Driver { valid: true })).unwrap();
1227        let error = run_due_pass(&queue, &registry, &settings(), || async {
1228            Err::<Context, _>("context failed".into())
1229        })
1230        .await
1231        .unwrap_err();
1232        assert_eq!(error, SupervisorError::Context("context failed".into()));
1233        assert_eq!(queue.committed.lock().unwrap().len(), 2);
1234        assert!(queue
1235            .committed
1236            .lock()
1237            .unwrap()
1238            .iter()
1239            .all(|(_, disposition)| matches!(disposition, WorkDisposition::Failed { .. })));
1240    }
1241
1242    #[tokio::test]
1243    async fn due_pass_atomically_reschedules_driver_state() {
1244        let queue = Queue {
1245            items: Mutex::new(vec![WorkItem {
1246                config: serde_json::json!({"reschedule": true}),
1247                ..item("recurring", false)
1248            }]),
1249            ..Default::default()
1250        };
1251        let mut registry = DriverRegistry::new();
1252        registry.register(Arc::new(Driver { valid: true })).unwrap();
1253        run_due_pass(&queue, &registry, &settings(), || async { Ok(Context) })
1254            .await
1255            .unwrap();
1256        assert!(matches!(
1257            queue.committed.lock().unwrap().as_slice(),
1258            [(id, WorkDisposition::Reschedule { .. })] if id == "recurring"
1259        ));
1260    }
1261
1262    #[tokio::test]
1263    async fn finalization_failure_is_not_reported_as_success() {
1264        let queue = Queue {
1265            items: Mutex::new(vec![item("stale", false)]),
1266            finalization_error: Mutex::new(Some("stale lease".into())),
1267            ..Default::default()
1268        };
1269        let mut registry = DriverRegistry::new();
1270        registry.register(Arc::new(Driver { valid: true })).unwrap();
1271
1272        let error = run_due_pass(&queue, &registry, &settings(), || async { Ok(Context) })
1273            .await
1274            .unwrap_err();
1275
1276        assert!(
1277            matches!(&error, SupervisorError::Commit(reason) if reason.contains("stale lease"))
1278        );
1279        assert!(error.to_string().contains("state commit failed"));
1280    }
1281
1282    #[tokio::test]
1283    async fn due_pass_claims_only_work_that_can_start() {
1284        let queue = Queue {
1285            items: Mutex::new(vec![
1286                item("one", false),
1287                item("two", false),
1288                item("queued", false),
1289            ]),
1290            ..Default::default()
1291        };
1292        let mut registry = DriverRegistry::new();
1293        registry.register(Arc::new(Driver { valid: true })).unwrap();
1294        let mut settings = settings();
1295        settings.concurrency = 2;
1296
1297        let stats = run_due_pass(&queue, &registry, &settings, || async { Ok(Context) })
1298            .await
1299            .unwrap();
1300
1301        assert_eq!(stats.claimed, 2);
1302        assert_eq!(queue.claim_limits.lock().unwrap().as_slice(), [2]);
1303        assert_eq!(queue.items.lock().unwrap().len(), 1);
1304    }
1305
1306    #[tokio::test(start_paused = true)]
1307    async fn long_evaluate_renews_its_lease() {
1308        let queue = Queue {
1309            items: Mutex::new(vec![WorkItem {
1310                config: serde_json::json!({"sleep_ms": 3500}),
1311                ..item("slow", false)
1312            }]),
1313            ..Default::default()
1314        };
1315        let mut registry = DriverRegistry::new();
1316        registry.register(Arc::new(Driver { valid: true })).unwrap();
1317        let mut settings = settings();
1318        settings.lease_secs = 3;
1319        settings.concurrency = 1;
1320
1321        let stats = run_due_pass(&queue, &registry, &settings, || async { Ok(Context) })
1322            .await
1323            .unwrap();
1324
1325        assert_eq!(stats.failed, 0);
1326        let renewals = queue.renewals.lock().unwrap();
1327        assert_eq!(renewals.len(), 3);
1328        assert!(renewals
1329            .iter()
1330            .all(|renewal| renewal == &("slow".into(), "worker".into(), 1)));
1331        assert!(matches!(
1332            queue.committed.lock().unwrap().as_slice(),
1333            [(id, WorkDisposition::Continue { .. })] if id == "slow"
1334        ));
1335    }
1336
1337    #[tokio::test(start_paused = true)]
1338    async fn expired_lease_stops_evaluation() {
1339        let queue = Queue {
1340            items: Mutex::new(vec![WorkItem {
1341                config: serde_json::json!({"sleep_ms": 5000}),
1342                ..item("expired", false)
1343            }]),
1344            renewal_error: Mutex::new(Some("lease expired".into())),
1345            ..Default::default()
1346        };
1347        let mut registry = DriverRegistry::new();
1348        registry.register(Arc::new(Driver { valid: true })).unwrap();
1349        let mut settings = settings();
1350        settings.lease_secs = 3;
1351        settings.concurrency = 1;
1352
1353        let stats = run_due_pass(&queue, &registry, &settings, || async { Ok(Context) })
1354            .await
1355            .unwrap();
1356
1357        assert_eq!(stats.failed, 1);
1358        assert_eq!(queue.renewals.lock().unwrap().len(), 1);
1359        assert!(matches!(
1360            queue.committed.lock().unwrap().as_slice(),
1361            [(id, WorkDisposition::Failed { .. })] if id == "expired"
1362        ));
1363    }
1364
1365    #[test]
1366    fn registry_fails_duplicate_ownership_and_invalid_specs() {
1367        let mut registry = DriverRegistry::new();
1368        assert!(registry.is_empty());
1369        registry
1370            .register(Arc::new(Driver { valid: false }))
1371            .unwrap();
1372        assert_eq!(registry.spec_ids(), ["spec"]);
1373        assert_eq!(registry.names(), ["driver"]);
1374        assert!(registry.for_spec("spec").is_some());
1375        assert_eq!(
1376            registry.validate_all().unwrap_err(),
1377            SupervisorError::Driver {
1378                driver: "driver".into(),
1379                reason: "invalid".into()
1380            }
1381        );
1382        assert!(matches!(
1383            registry.register(Arc::new(Driver { valid: true })),
1384            Err(SupervisorError::Driver { reason, .. }) if reason.contains("already claimed")
1385        ));
1386    }
1387
1388    #[tokio::test]
1389    async fn memory_work_queue_serves_consumers_without_a_database() {
1390        let queue = MemoryWorkQueue::new(vec![item("first", false)]);
1391        queue.push(item("other-spec", false));
1392        {
1393            let mut items = queue.items.lock().unwrap();
1394            items[1].spec_id = "unknown".into();
1395        }
1396        let mut registry = DriverRegistry::new();
1397        registry.register(Arc::new(Driver { valid: true })).unwrap();
1398        let stats = run_due_pass(&queue, &registry, &settings(), || async { Ok(Context) })
1399            .await
1400            .unwrap();
1401        assert_eq!(
1402            stats,
1403            SupervisorStats {
1404                claimed: 1,
1405                failed: 0
1406            },
1407            "only items for registered specs are claimed"
1408        );
1409        let committed = queue.committed();
1410        assert_eq!(committed.len(), 1);
1411        assert_eq!(committed[0].0.id, "first");
1412        assert_eq!(
1413            committed[0].1.disposition,
1414            WorkDisposition::Continue { delay_secs: 5 }
1415        );
1416        assert!(queue.renewals().is_empty());
1417        assert_eq!(
1418            run_due_pass(&queue, &registry, &settings(), || async { Ok(Context) })
1419                .await
1420                .unwrap()
1421                .claimed,
1422            0,
1423            "claimed items leave the queue"
1424        );
1425    }
1426
1427    #[test]
1428    fn commands_are_validated_against_the_claimed_work() {
1429        let work = item("scoped", false);
1430        let mut blank = command(&work, WorkDisposition::Complete, Value::Null);
1431        blank.event_type.clear();
1432        assert!(blank
1433            .validate_for(&work)
1434            .unwrap_err()
1435            .contains("missing event_type"));
1436
1437        let mut cancelled = item("cancelled", false);
1438        cancelled.cancel_requested = true;
1439        let mut with_intent = command(&cancelled, WorkDisposition::Complete, Value::Null);
1440        with_intent.action_intents.push(ActionIntent {
1441            id: "intent".into(),
1442            tenant_id: cancelled.tenant_id.clone(),
1443            instance_id: cancelled.id.clone().parse().unwrap(),
1444            run_id: cancelled.run_id.clone(),
1445            capability: CapabilityPin {
1446                id: "cap".into(),
1447                contract_version: "1".into(),
1448                content_digest: "digest".into(),
1449            },
1450            idempotency_key: "key".into(),
1451            state: crate::ActionState::Prepared,
1452            input: Value::Null,
1453            effect: crate::Effect::ExternalWrite,
1454            retry_class: crate::IdempotencyMode::Native,
1455            control_epochs: ControlEpochs::default(),
1456            resource_scope_id: String::new(),
1457            lease_epoch: cancelled.lease_version,
1458            action_epoch: cancelled.state_version,
1459            deadline: None,
1460            reservation: None,
1461            created_at: Utc::now(),
1462        });
1463        assert!(with_intent
1464            .validate_for(&cancelled)
1465            .unwrap_err()
1466            .contains("cancelled work"));
1467
1468        let mut escaped = with_intent.clone();
1469        escaped.action_intents[0].tenant_id = "someone-else".parse().unwrap();
1470        let scoped = item("scoped", false);
1471        let mut escaped_command = command(&scoped, WorkDisposition::Complete, Value::Null);
1472        escaped_command.action_intents = escaped.action_intents;
1473        assert!(escaped_command
1474            .validate_for(&scoped)
1475            .unwrap_err()
1476            .contains("escapes claimed work scope"));
1477    }
1478}