1use af_context::{RunId, SubjectId, TenantId};
4use std::collections::HashMap;
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::{ActionIntent, CapabilityPin, ControlEpochs, LifecyclePolicy};
15
16#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
20pub struct Wakeup {
21 pub id: String,
23 pub kind: String,
25 pub payload: Value,
27}
28
29#[derive(Debug, Clone, PartialEq)]
31pub struct WorkItem {
32 pub id: String,
34 pub run_id: RunId,
36 pub tenant_id: TenantId,
38 pub subject_id: SubjectId,
40 pub spec_id: String,
42 pub definition_id: String,
44 pub workflow_revision: u64,
46 pub workflow_revision_digest: String,
48 pub execution_profile_id: String,
50 pub execution_profile_revision: u64,
52 pub execution_profile_digest: String,
54 pub kernel_abi_version: String,
56 pub capability_pins: Vec<CapabilityPin>,
58 pub lifecycle: LifecyclePolicy,
60 pub scheduled_at: DateTime<Utc>,
63 pub claimed_at: DateTime<Utc>,
65 pub config: Value,
67 pub state_version: i64,
69 pub control_epochs: ControlEpochs,
71 pub cancel_requested: bool,
73 pub lease_version: i64,
75 #[doc = "Due timers and trigger deliveries, oldest first."]
76 pub wakeups: Vec<Wakeup>,
77}
78
79impl WorkItem {
80 fn validate_pins(&self) -> Result<(), String> {
81 for (name, value) in [
82 ("definition_id", self.definition_id.as_str()),
83 (
84 "workflow_revision_digest",
85 self.workflow_revision_digest.as_str(),
86 ),
87 ("execution_profile_id", self.execution_profile_id.as_str()),
88 (
89 "execution_profile_digest",
90 self.execution_profile_digest.as_str(),
91 ),
92 ("kernel_abi_version", self.kernel_abi_version.as_str()),
93 ] {
94 if value.trim().is_empty() {
95 return Err(format!("work item is missing pinned {name}"));
96 }
97 }
98 if self.workflow_revision == 0 || self.execution_profile_revision == 0 {
99 return Err("work item revision pins must be positive".into());
100 }
101 uuid::Uuid::parse_str(&self.run_id)
102 .map_err(|error| format!("work item has invalid run_id: {error}"))?;
103 if self.state_version < 0
104 || self.lease_version <= 0
105 || self.control_epochs.tenant < 0
106 || self.control_epochs.instance < 0
107 {
108 return Err("work item state, lease and control pins must be current".into());
109 }
110 Ok(())
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum WorkDisposition {
117 Continue {
119 delay_secs: i64,
121 },
122 Reschedule {
124 at: DateTime<Utc>,
126 },
127 Complete,
129 Failed {
131 error: String,
133 retry_at: Option<DateTime<Utc>>,
135 },
136}
137
138#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
140pub struct EvaluationOutcome {
141 pub triggered: bool,
143 pub matched: bool,
145 pub succeeded: bool,
147 pub action_terminal: bool,
149}
150
151#[derive(Debug, Clone, PartialEq)]
155pub struct WorkflowTransitionCommand {
156 pub delivery_key: String,
158 pub delivery_digest: String,
160 pub event_type: String,
162 pub event_digest: String,
164 pub event_payload: Value,
166 pub next_state: Value,
168 pub action_intents: Vec<ActionIntent>,
170 pub outcome: EvaluationOutcome,
172 pub disposition: WorkDisposition,
174}
175
176impl WorkflowTransitionCommand {
177 pub fn failure(item: &WorkItem, error: impl Into<String>, retry_at: DateTime<Utc>) -> Self {
179 let error = error.into();
180 Self {
181 delivery_key: format!("supervisor:{}:{}", item.id, item.state_version),
182 delivery_digest: format!("failure:{}:{}", item.lease_version, error),
183 event_type: "workflow.evaluation_failed".into(),
184 event_digest: format!("failure:{}:{}", item.state_version, error),
185 event_payload: serde_json::json!({"error": error.clone()}),
186 next_state: item.config.clone(),
187 action_intents: Vec::new(),
188 outcome: EvaluationOutcome::default(),
189 disposition: WorkDisposition::Failed {
190 error,
191 retry_at: Some(retry_at),
192 },
193 }
194 }
195
196 fn validate_for(&self, item: &WorkItem) -> Result<(), String> {
197 item.validate_pins()?;
198 for (name, value) in [
199 ("delivery_key", self.delivery_key.as_str()),
200 ("delivery_digest", self.delivery_digest.as_str()),
201 ("event_type", self.event_type.as_str()),
202 ("event_digest", self.event_digest.as_str()),
203 ] {
204 if value.trim().is_empty() {
205 return Err(format!("workflow command is missing {name}"));
206 }
207 }
208 if item.cancel_requested && !self.action_intents.is_empty() {
209 return Err("cancelled work cannot prepare external actions".into());
210 }
211 for intent in &self.action_intents {
212 intent
213 .validate_prepared()
214 .map_err(|error| error.to_string())?;
215 if intent.tenant_id != item.tenant_id || intent.instance_id != item.id {
216 return Err("action intent escapes claimed work scope".into());
217 }
218 if intent.run_id != item.run_id {
219 return Err("action intent escapes claimed workflow run".into());
220 }
221 if intent.control_epochs.tenant != item.control_epochs.tenant
226 || intent.control_epochs.instance != item.control_epochs.instance
227 || intent.lease_epoch != item.lease_version
228 {
229 return Err("action intent uses stale control or lease pins".into());
230 }
231 if !item
232 .capability_pins
233 .iter()
234 .any(|pin| pin == &intent.capability)
235 {
236 return Err(format!(
237 "action capability '{}' is not pinned by the workflow revision",
238 intent.capability.id
239 ));
240 }
241 }
242 Ok(())
243 }
244}
245
246#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
248pub struct SupervisorStats {
249 pub claimed: usize,
251 pub failed: usize,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct SupervisorSettings {
258 pub worker_id: String,
260 pub lease_secs: i64,
262 pub requeue_delay_secs: i64,
264 pub claim_batch: i64,
266 pub concurrency: usize,
268}
269
270impl SupervisorSettings {
271 fn concurrency(&self) -> usize {
272 self.concurrency.max(1)
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
280pub enum SupervisorError {
281 #[error("work queue: {0}")]
283 Queue(String),
284 #[error("driver '{driver}': {reason}")]
286 Driver {
287 driver: String,
289 reason: String,
291 },
292 #[error("context: {0}")]
294 Context(String),
295 #[error("workflow state commit failed: {0}")]
297 Commit(String),
298}
299
300#[async_trait]
302pub trait WorkQueue: Send + Sync {
303 async fn claim_due(
305 &self,
306 spec_ids: &[String],
307 worker_id: &str,
308 lease_secs: i64,
309 batch: i64,
310 ) -> Result<Vec<WorkItem>, SupervisorError>;
311
312 async fn renew(
314 &self,
315 tenant_id: &str,
316 id: &str,
317 worker_id: &str,
318 lease_version: i64,
319 lease_secs: i64,
320 ) -> Result<(), SupervisorError>;
321 async fn commit_command(
323 &self,
324 item: &WorkItem,
325 command: &WorkflowTransitionCommand,
326 ) -> Result<(), SupervisorError>;
327}
328
329#[derive(Default)]
332pub struct MemoryWorkQueue {
333 items: std::sync::Mutex<Vec<WorkItem>>,
334 committed: std::sync::Mutex<Vec<(WorkItem, WorkflowTransitionCommand)>>,
335 renewals: std::sync::Mutex<Vec<(String, i64)>>,
336}
337
338impl MemoryWorkQueue {
339 pub fn new(items: Vec<WorkItem>) -> Self {
341 Self {
342 items: std::sync::Mutex::new(items),
343 ..Self::default()
344 }
345 }
346
347 pub fn push(&self, item: WorkItem) {
349 self.items
350 .lock()
351 .unwrap_or_else(std::sync::PoisonError::into_inner)
352 .push(item);
353 }
354
355 pub fn committed(&self) -> Vec<(WorkItem, WorkflowTransitionCommand)> {
357 self.committed
358 .lock()
359 .unwrap_or_else(std::sync::PoisonError::into_inner)
360 .clone()
361 }
362
363 pub fn renewals(&self) -> Vec<(String, i64)> {
365 self.renewals
366 .lock()
367 .unwrap_or_else(std::sync::PoisonError::into_inner)
368 .clone()
369 }
370}
371
372#[async_trait]
373impl WorkQueue for MemoryWorkQueue {
374 async fn claim_due(
375 &self,
376 spec_ids: &[String],
377 _worker_id: &str,
378 _lease_secs: i64,
379 batch: i64,
380 ) -> Result<Vec<WorkItem>, SupervisorError> {
381 let mut items = self
382 .items
383 .lock()
384 .unwrap_or_else(std::sync::PoisonError::into_inner);
385 let mut claimed = Vec::new();
386 let mut index = 0;
387 while index < items.len() && claimed.len() < batch.max(0) as usize {
388 if spec_ids.contains(&items[index].spec_id) {
389 claimed.push(items.remove(index));
390 } else {
391 index += 1;
392 }
393 }
394 Ok(claimed)
395 }
396
397 async fn renew(
398 &self,
399 _tenant_id: &str,
400 id: &str,
401 _worker_id: &str,
402 lease_version: i64,
403 _lease_secs: i64,
404 ) -> Result<(), SupervisorError> {
405 self.renewals
406 .lock()
407 .unwrap_or_else(std::sync::PoisonError::into_inner)
408 .push((id.to_owned(), lease_version));
409 Ok(())
410 }
411
412 async fn commit_command(
413 &self,
414 item: &WorkItem,
415 command: &WorkflowTransitionCommand,
416 ) -> Result<(), SupervisorError> {
417 self.committed
418 .lock()
419 .unwrap_or_else(std::sync::PoisonError::into_inner)
420 .push((item.clone(), command.clone()));
421 Ok(())
422 }
423}
424
425#[async_trait]
427pub trait WorkflowDriver<Context>: Send + Sync
428where
429 Context: Send + Sync,
430{
431 fn name(&self) -> &'static str;
433 fn spec_ids(&self) -> Vec<&str>;
435 fn validate_specs(&self) -> Result<(), String>;
438 async fn evaluate(
442 &self,
443 context: &Context,
444 item: &WorkItem,
445 ) -> Result<WorkflowTransitionCommand, String>;
446}
447
448pub struct DriverRegistry<Context: Send + Sync> {
450 drivers: Vec<Arc<dyn WorkflowDriver<Context>>>,
451 by_spec: HashMap<String, Arc<dyn WorkflowDriver<Context>>>,
452}
453
454impl<Context: Send + Sync> Default for DriverRegistry<Context> {
455 fn default() -> Self {
456 Self {
457 drivers: Vec::new(),
458 by_spec: HashMap::new(),
459 }
460 }
461}
462
463impl<Context: Send + Sync> DriverRegistry<Context> {
464 pub fn new() -> Self {
466 Self::default()
467 }
468
469 pub fn register(
472 &mut self,
473 driver: Arc<dyn WorkflowDriver<Context>>,
474 ) -> Result<&mut Self, SupervisorError> {
475 for spec_id in driver.spec_ids() {
476 if let Some(existing) = self.by_spec.get(spec_id) {
477 return Err(SupervisorError::Driver {
478 driver: driver.name().to_owned(),
479 reason: format!(
480 "spec '{spec_id}' is already claimed by '{}'",
481 existing.name()
482 ),
483 });
484 }
485 self.by_spec.insert(spec_id.to_owned(), driver.clone());
486 }
487 self.drivers.push(driver);
488 Ok(self)
489 }
490
491 pub fn spec_ids(&self) -> Vec<String> {
493 let mut ids = self.by_spec.keys().cloned().collect::<Vec<_>>();
494 ids.sort();
495 ids
496 }
497
498 pub fn for_spec(&self, spec_id: &str) -> Option<&Arc<dyn WorkflowDriver<Context>>> {
500 self.by_spec.get(spec_id)
501 }
502
503 pub fn names(&self) -> Vec<&'static str> {
505 self.drivers.iter().map(|driver| driver.name()).collect()
506 }
507
508 pub fn is_empty(&self) -> bool {
510 self.drivers.is_empty()
511 }
512
513 pub fn validate_all(&self) -> Result<(), SupervisorError> {
515 for driver in &self.drivers {
516 driver
517 .validate_specs()
518 .map_err(|reason| SupervisorError::Driver {
519 driver: driver.name().to_owned(),
520 reason,
521 })?;
522 }
523 Ok(())
524 }
525}
526
527pub async fn run_due_pass<Context, Queue, BuildContext, BuildFuture>(
530 queue: &Queue,
531 registry: &DriverRegistry<Context>,
532 settings: &SupervisorSettings,
533 build_context: BuildContext,
534) -> Result<SupervisorStats, SupervisorError>
535where
536 Context: Send + Sync + 'static,
537 Queue: WorkQueue,
538 BuildContext: FnOnce() -> BuildFuture,
539 BuildFuture: Future<Output = Result<Context, String>> + Send,
540{
541 let claim_limit = settings
542 .claim_batch
543 .clamp(1, i64::try_from(settings.concurrency()).unwrap_or(i64::MAX));
544 let items = queue
545 .claim_due(
546 ®istry.spec_ids(),
547 &settings.worker_id,
548 settings.lease_secs,
549 claim_limit,
550 )
551 .await?;
552 if items.is_empty() {
553 return Ok(SupervisorStats::default());
554 }
555
556 let context = match build_context().await {
557 Ok(context) => Arc::new(context),
558 Err(error) => {
559 let mut cleanup_errors = Vec::new();
560 for item in &items {
561 let command = WorkflowTransitionCommand::failure(
562 item,
563 error.clone(),
564 Utc::now() + chrono::Duration::seconds(settings.requeue_delay_secs),
565 );
566 if let Err(cleanup) = queue.commit_command(item, &command).await {
567 cleanup_errors.push(format!("{} commit failure: {cleanup}", item.id));
568 }
569 }
570 if !cleanup_errors.is_empty() {
571 return Err(SupervisorError::Context(format!(
572 "{error}; claimed work cleanup failed: {}",
573 cleanup_errors.join(", ")
574 )));
575 }
576 return Err(SupervisorError::Context(error));
577 }
578 };
579
580 let claimed = items.len();
581 let mut failed = 0;
582 let mut persistence_errors = Vec::new();
583 let mut work = items.into_iter();
584 let mut tasks = FuturesUnordered::new();
585
586 let spawn_next = |tasks: &mut FuturesUnordered<_>, work: &mut std::vec::IntoIter<WorkItem>| {
587 let Some(item) = work.next() else {
588 return false;
589 };
590 let driver = registry.for_spec(&item.spec_id).cloned();
591 let context = context.clone();
592 let renewal_period = std::time::Duration::from_millis(
593 (settings.lease_secs.clamp(1, 3600) as u64 * 1000 / 3).max(1),
594 );
595 tasks.push(async move {
596 let evaluation_item = item.clone();
597 let evaluation = async {
598 evaluation_item.validate_pins()?;
599 match driver {
600 Some(driver) => AssertUnwindSafe(driver.evaluate(&context, &evaluation_item))
601 .catch_unwind()
602 .await
603 .map_err(|_| "driver panicked".to_string())
604 .and_then(|result| result),
605 None => Err(format!(
606 "no driver registered for spec '{}'",
607 evaluation_item.spec_id
608 )),
609 }
610 };
611 tokio::pin!(evaluation);
612 let mut renewal = tokio::time::interval_at(
613 tokio::time::Instant::now() + renewal_period,
614 renewal_period,
615 );
616 renewal.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
617 let result = loop {
618 tokio::select! {
619 result = &mut evaluation => break result,
620 _ = renewal.tick() => {
621 if let Err(error) = queue
622 .renew(
623 &item.tenant_id,
624 &item.id,
625 &settings.worker_id,
626 item.lease_version,
627 settings.lease_secs,
628 )
629 .await
630 {
631 break Err(format!("lease renewal failed: {error}"));
632 }
633 }
634 }
635 };
636 (item, result)
637 });
638 true
639 };
640
641 for _ in 0..settings.concurrency() {
642 if !spawn_next(&mut tasks, &mut work) {
643 break;
644 }
645 }
646
647 while let Some((item, result)) = tasks.next().await {
648 let command = match result {
649 Ok(command) => match command.validate_for(&item) {
650 Ok(()) => command,
651 Err(error) => {
652 failed += 1;
653 WorkflowTransitionCommand::failure(
654 &item,
655 error,
656 Utc::now() + chrono::Duration::seconds(settings.requeue_delay_secs),
657 )
658 }
659 },
660 Err(error) => {
661 failed += 1;
662 WorkflowTransitionCommand::failure(
663 &item,
664 error,
665 Utc::now() + chrono::Duration::seconds(settings.requeue_delay_secs),
666 )
667 }
668 };
669 if let Err(error) = queue.commit_command(&item, &command).await {
670 failed += usize::from(!matches!(
671 command.disposition,
672 WorkDisposition::Failed { .. }
673 ));
674 persistence_errors.push(format!("{} command commit: {error}", item.id));
675 }
676 spawn_next(&mut tasks, &mut work);
677 }
678
679 if persistence_errors.is_empty() {
680 Ok(SupervisorStats { claimed, failed })
681 } else {
682 Err(SupervisorError::Commit(persistence_errors.join(", ")))
683 }
684}
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689 use std::sync::atomic::{AtomicBool, Ordering};
690 use std::sync::Mutex;
691
692 struct Context;
693 struct Driver {
694 valid: bool,
695 }
696
697 #[async_trait]
698 impl WorkflowDriver<Context> for Driver {
699 fn name(&self) -> &'static str {
700 "driver"
701 }
702 fn spec_ids(&self) -> Vec<&str> {
703 vec!["spec"]
704 }
705 fn validate_specs(&self) -> Result<(), String> {
706 self.valid.then_some(()).ok_or_else(|| "invalid".into())
707 }
708 async fn evaluate(
709 &self,
710 _context: &Context,
711 item: &WorkItem,
712 ) -> Result<WorkflowTransitionCommand, String> {
713 if let Some(milliseconds) = item.config["sleep_ms"].as_u64() {
714 tokio::time::sleep(std::time::Duration::from_millis(milliseconds)).await;
715 }
716 if item.config["fail"] == true {
717 Err("planned".into())
718 } else if item.config["reschedule"] == true {
719 Ok(command(
720 item,
721 WorkDisposition::Reschedule { at: Utc::now() },
722 serde_json::json!({"next": true}),
723 ))
724 } else if item.config["stop"] == true {
725 Ok(command(
726 item,
727 WorkDisposition::Complete,
728 item.config.clone(),
729 ))
730 } else {
731 Ok(command(
732 item,
733 WorkDisposition::Continue { delay_secs: 5 },
734 item.config.clone(),
735 ))
736 }
737 }
738 }
739
740 fn command(
741 item: &WorkItem,
742 disposition: WorkDisposition,
743 next_state: Value,
744 ) -> WorkflowTransitionCommand {
745 WorkflowTransitionCommand {
746 delivery_key: format!("test:{}:{}", item.id, item.state_version),
747 delivery_digest: "delivery-digest".into(),
748 event_type: "workflow.test".into(),
749 event_digest: "event-digest".into(),
750 event_payload: Value::Null,
751 next_state,
752 action_intents: Vec::new(),
753 outcome: EvaluationOutcome::default(),
754 disposition,
755 }
756 }
757
758 #[derive(Default)]
759 struct Queue {
760 items: Mutex<Vec<WorkItem>>,
761 committed: Mutex<Vec<(String, WorkDisposition)>>,
762 renewals: Mutex<Vec<(String, String, i64)>>,
763 renewal_error: Mutex<Option<String>>,
764 finalization_error: Mutex<Option<String>>,
765 claim_limits: Mutex<Vec<i64>>,
766 }
767
768 #[async_trait]
769 impl WorkQueue for Queue {
770 async fn claim_due(
771 &self,
772 _spec_ids: &[String],
773 _worker_id: &str,
774 _lease_secs: i64,
775 batch: i64,
776 ) -> Result<Vec<WorkItem>, SupervisorError> {
777 self.claim_limits.lock().unwrap().push(batch);
778 let mut items = self.items.lock().unwrap();
779 let take = items.len().min(batch as usize);
780 Ok(items.drain(..take).collect())
781 }
782
783 async fn renew(
784 &self,
785 _tenant_id: &str,
786 id: &str,
787 worker_id: &str,
788 lease_version: i64,
789 _lease_secs: i64,
790 ) -> Result<(), SupervisorError> {
791 self.renewals.lock().unwrap().push((
792 id.to_string(),
793 worker_id.to_string(),
794 lease_version,
795 ));
796 match self.renewal_error.lock().unwrap().clone() {
797 Some(error) => Err(SupervisorError::Queue(error)),
798 None => Ok(()),
799 }
800 }
801
802 async fn commit_command(
803 &self,
804 item: &WorkItem,
805 command: &WorkflowTransitionCommand,
806 ) -> Result<(), SupervisorError> {
807 self.committed
808 .lock()
809 .unwrap()
810 .push((item.id.clone(), command.disposition.clone()));
811 match self.finalization_error.lock().unwrap().clone() {
812 Some(error) => Err(SupervisorError::Queue(error)),
813 None => Ok(()),
814 }
815 }
816 }
817
818 fn item(id: &str, fail: bool) -> WorkItem {
819 WorkItem {
820 id: id.into(),
821 run_id: uuid::Uuid::new_v4().to_string().parse().unwrap(),
822 tenant_id: "tenant".parse().unwrap(),
823 subject_id: "subject".parse().unwrap(),
824 spec_id: "spec".into(),
825 definition_id: "definition".into(),
826 workflow_revision: 1,
827 workflow_revision_digest: "workflow-digest".into(),
828 execution_profile_id: "profile".into(),
829 execution_profile_revision: 1,
830 execution_profile_digest: "profile-digest".into(),
831 kernel_abi_version: "1".into(),
832 capability_pins: Vec::new(),
833 lifecycle: LifecyclePolicy::run_once(),
834 scheduled_at: Utc::now(),
835 claimed_at: Utc::now(),
836 config: serde_json::json!({"fail": fail}),
837 state_version: 0,
838 control_epochs: ControlEpochs::default(),
839 cancel_requested: false,
840 lease_version: 1,
841 wakeups: Vec::new(),
842 }
843 }
844
845 fn stopped_item(id: &str) -> WorkItem {
846 WorkItem {
847 config: serde_json::json!({"stop": true}),
848 ..item(id, false)
849 }
850 }
851
852 fn settings() -> SupervisorSettings {
853 SupervisorSettings {
854 worker_id: "worker".into(),
855 lease_secs: 60,
856 requeue_delay_secs: 5,
857 claim_batch: 10,
858 concurrency: 3,
859 }
860 }
861
862 struct MustNotEvaluate(Arc<AtomicBool>);
863
864 #[async_trait]
865 impl WorkflowDriver<Context> for MustNotEvaluate {
866 fn name(&self) -> &'static str {
867 "must-not-evaluate"
868 }
869
870 fn spec_ids(&self) -> Vec<&str> {
871 vec!["spec"]
872 }
873
874 fn validate_specs(&self) -> Result<(), String> {
875 Ok(())
876 }
877
878 async fn evaluate(
879 &self,
880 _context: &Context,
881 item: &WorkItem,
882 ) -> Result<WorkflowTransitionCommand, String> {
883 self.0.store(true, Ordering::SeqCst);
884 Ok(command(
885 item,
886 WorkDisposition::Complete,
887 item.config.clone(),
888 ))
889 }
890 }
891
892 #[tokio::test]
893 async fn missing_revision_pin_fails_before_driver_evaluation() {
894 let mut stale = item("stale", false);
895 stale.workflow_revision_digest.clear();
896 let queue = Queue {
897 items: Mutex::new(vec![stale]),
898 ..Default::default()
899 };
900 let called = Arc::new(AtomicBool::new(false));
901 let mut registry = DriverRegistry::new();
902 registry
903 .register(Arc::new(MustNotEvaluate(called.clone())))
904 .unwrap();
905
906 let stats = run_due_pass(&queue, ®istry, &settings(), || async { Ok(Context) })
907 .await
908 .unwrap();
909
910 assert_eq!(stats.failed, 1);
911 assert!(!called.load(Ordering::SeqCst));
912 assert!(matches!(
913 queue.committed.lock().unwrap().as_slice(),
914 [(id, WorkDisposition::Failed { error, .. })]
915 if id == "stale" && error.contains("workflow_revision_digest")
916 ));
917 }
918
919 #[tokio::test]
920 async fn due_pass_releases_success_and_marks_failures() {
921 let queue = Queue {
922 items: Mutex::new(vec![
923 item("ok", false),
924 stopped_item("done"),
925 item("bad", true),
926 ]),
927 ..Default::default()
928 };
929 let mut registry = DriverRegistry::new();
930 registry.register(Arc::new(Driver { valid: true })).unwrap();
931 let stats = run_due_pass(&queue, ®istry, &settings(), || async { Ok(Context) })
932 .await
933 .unwrap();
934 assert_eq!(
935 stats,
936 SupervisorStats {
937 claimed: 3,
938 failed: 1
939 }
940 );
941 let committed = queue.committed.lock().unwrap();
942 assert_eq!(committed.len(), 3);
943 assert!(committed
944 .iter()
945 .any(|(id, disposition)| id == "done" && disposition == &WorkDisposition::Complete));
946 assert!(committed.iter().any(|(id, disposition)| id == "bad"
947 && matches!(disposition, WorkDisposition::Failed { .. })));
948 }
949
950 #[tokio::test]
951 async fn context_failure_releases_every_claim() {
952 let queue = Queue {
953 items: Mutex::new(vec![item("one", false), item("two", false)]),
954 ..Default::default()
955 };
956 let mut registry = DriverRegistry::new();
957 registry.register(Arc::new(Driver { valid: true })).unwrap();
958 let error = run_due_pass(&queue, ®istry, &settings(), || async {
959 Err::<Context, _>("context failed".into())
960 })
961 .await
962 .unwrap_err();
963 assert_eq!(error, SupervisorError::Context("context failed".into()));
964 assert_eq!(queue.committed.lock().unwrap().len(), 2);
965 assert!(queue
966 .committed
967 .lock()
968 .unwrap()
969 .iter()
970 .all(|(_, disposition)| matches!(disposition, WorkDisposition::Failed { .. })));
971 }
972
973 #[tokio::test]
974 async fn due_pass_atomically_reschedules_driver_state() {
975 let queue = Queue {
976 items: Mutex::new(vec![WorkItem {
977 config: serde_json::json!({"reschedule": true}),
978 ..item("recurring", false)
979 }]),
980 ..Default::default()
981 };
982 let mut registry = DriverRegistry::new();
983 registry.register(Arc::new(Driver { valid: true })).unwrap();
984 run_due_pass(&queue, ®istry, &settings(), || async { Ok(Context) })
985 .await
986 .unwrap();
987 assert!(matches!(
988 queue.committed.lock().unwrap().as_slice(),
989 [(id, WorkDisposition::Reschedule { .. })] if id == "recurring"
990 ));
991 }
992
993 #[tokio::test]
994 async fn finalization_failure_is_not_reported_as_success() {
995 let queue = Queue {
996 items: Mutex::new(vec![item("stale", false)]),
997 finalization_error: Mutex::new(Some("stale lease".into())),
998 ..Default::default()
999 };
1000 let mut registry = DriverRegistry::new();
1001 registry.register(Arc::new(Driver { valid: true })).unwrap();
1002
1003 let error = run_due_pass(&queue, ®istry, &settings(), || async { Ok(Context) })
1004 .await
1005 .unwrap_err();
1006
1007 assert!(
1008 matches!(&error, SupervisorError::Commit(reason) if reason.contains("stale lease"))
1009 );
1010 assert!(error.to_string().contains("state commit failed"));
1011 }
1012
1013 #[tokio::test]
1014 async fn due_pass_claims_only_work_that_can_start() {
1015 let queue = Queue {
1016 items: Mutex::new(vec![
1017 item("one", false),
1018 item("two", false),
1019 item("queued", false),
1020 ]),
1021 ..Default::default()
1022 };
1023 let mut registry = DriverRegistry::new();
1024 registry.register(Arc::new(Driver { valid: true })).unwrap();
1025 let mut settings = settings();
1026 settings.concurrency = 2;
1027
1028 let stats = run_due_pass(&queue, ®istry, &settings, || async { Ok(Context) })
1029 .await
1030 .unwrap();
1031
1032 assert_eq!(stats.claimed, 2);
1033 assert_eq!(queue.claim_limits.lock().unwrap().as_slice(), [2]);
1034 assert_eq!(queue.items.lock().unwrap().len(), 1);
1035 }
1036
1037 #[tokio::test(start_paused = true)]
1038 async fn long_evaluate_renews_its_lease() {
1039 let queue = Queue {
1040 items: Mutex::new(vec![WorkItem {
1041 config: serde_json::json!({"sleep_ms": 3500}),
1042 ..item("slow", false)
1043 }]),
1044 ..Default::default()
1045 };
1046 let mut registry = DriverRegistry::new();
1047 registry.register(Arc::new(Driver { valid: true })).unwrap();
1048 let mut settings = settings();
1049 settings.lease_secs = 3;
1050 settings.concurrency = 1;
1051
1052 let stats = run_due_pass(&queue, ®istry, &settings, || async { Ok(Context) })
1053 .await
1054 .unwrap();
1055
1056 assert_eq!(stats.failed, 0);
1057 let renewals = queue.renewals.lock().unwrap();
1058 assert_eq!(renewals.len(), 3);
1059 assert!(renewals
1060 .iter()
1061 .all(|renewal| renewal == &("slow".into(), "worker".into(), 1)));
1062 assert!(matches!(
1063 queue.committed.lock().unwrap().as_slice(),
1064 [(id, WorkDisposition::Continue { .. })] if id == "slow"
1065 ));
1066 }
1067
1068 #[tokio::test(start_paused = true)]
1069 async fn expired_lease_stops_evaluation() {
1070 let queue = Queue {
1071 items: Mutex::new(vec![WorkItem {
1072 config: serde_json::json!({"sleep_ms": 5000}),
1073 ..item("expired", false)
1074 }]),
1075 renewal_error: Mutex::new(Some("lease expired".into())),
1076 ..Default::default()
1077 };
1078 let mut registry = DriverRegistry::new();
1079 registry.register(Arc::new(Driver { valid: true })).unwrap();
1080 let mut settings = settings();
1081 settings.lease_secs = 3;
1082 settings.concurrency = 1;
1083
1084 let stats = run_due_pass(&queue, ®istry, &settings, || async { Ok(Context) })
1085 .await
1086 .unwrap();
1087
1088 assert_eq!(stats.failed, 1);
1089 assert_eq!(queue.renewals.lock().unwrap().len(), 1);
1090 assert!(matches!(
1091 queue.committed.lock().unwrap().as_slice(),
1092 [(id, WorkDisposition::Failed { .. })] if id == "expired"
1093 ));
1094 }
1095
1096 #[test]
1097 fn registry_fails_duplicate_ownership_and_invalid_specs() {
1098 let mut registry = DriverRegistry::new();
1099 assert!(registry.is_empty());
1100 registry
1101 .register(Arc::new(Driver { valid: false }))
1102 .unwrap();
1103 assert_eq!(registry.spec_ids(), ["spec"]);
1104 assert_eq!(registry.names(), ["driver"]);
1105 assert!(registry.for_spec("spec").is_some());
1106 assert_eq!(
1107 registry.validate_all().unwrap_err(),
1108 SupervisorError::Driver {
1109 driver: "driver".into(),
1110 reason: "invalid".into()
1111 }
1112 );
1113 assert!(matches!(
1114 registry.register(Arc::new(Driver { valid: true })),
1115 Err(SupervisorError::Driver { reason, .. }) if reason.contains("already claimed")
1116 ));
1117 }
1118
1119 #[tokio::test]
1120 async fn memory_work_queue_serves_consumers_without_a_database() {
1121 let queue = MemoryWorkQueue::new(vec![item("first", false)]);
1122 queue.push(item("other-spec", false));
1123 {
1124 let mut items = queue.items.lock().unwrap();
1125 items[1].spec_id = "unknown".into();
1126 }
1127 let mut registry = DriverRegistry::new();
1128 registry.register(Arc::new(Driver { valid: true })).unwrap();
1129 let stats = run_due_pass(&queue, ®istry, &settings(), || async { Ok(Context) })
1130 .await
1131 .unwrap();
1132 assert_eq!(
1133 stats,
1134 SupervisorStats {
1135 claimed: 1,
1136 failed: 0
1137 },
1138 "only items for registered specs are claimed"
1139 );
1140 let committed = queue.committed();
1141 assert_eq!(committed.len(), 1);
1142 assert_eq!(committed[0].0.id, "first");
1143 assert_eq!(
1144 committed[0].1.disposition,
1145 WorkDisposition::Continue { delay_secs: 5 }
1146 );
1147 assert!(queue.renewals().is_empty());
1148 assert_eq!(
1149 run_due_pass(&queue, ®istry, &settings(), || async { Ok(Context) })
1150 .await
1151 .unwrap()
1152 .claimed,
1153 0,
1154 "claimed items leave the queue"
1155 );
1156 }
1157
1158 #[test]
1159 fn commands_are_validated_against_the_claimed_work() {
1160 let work = item("scoped", false);
1161 let mut blank = command(&work, WorkDisposition::Complete, Value::Null);
1162 blank.event_type.clear();
1163 assert!(blank
1164 .validate_for(&work)
1165 .unwrap_err()
1166 .contains("missing event_type"));
1167
1168 let mut cancelled = item("cancelled", false);
1169 cancelled.cancel_requested = true;
1170 let mut with_intent = command(&cancelled, WorkDisposition::Complete, Value::Null);
1171 with_intent.action_intents.push(ActionIntent {
1172 id: "intent".into(),
1173 tenant_id: cancelled.tenant_id.clone(),
1174 instance_id: cancelled.id.clone().parse().unwrap(),
1175 run_id: cancelled.run_id.clone(),
1176 capability: CapabilityPin {
1177 id: "cap".into(),
1178 contract_version: "1".into(),
1179 content_digest: "digest".into(),
1180 },
1181 idempotency_key: "key".into(),
1182 state: crate::ActionState::Prepared,
1183 input: Value::Null,
1184 effect: crate::Effect::ExternalWrite,
1185 retry_class: crate::IdempotencyMode::Native,
1186 control_epochs: ControlEpochs::default(),
1187 resource_scope_id: String::new(),
1188 lease_epoch: cancelled.lease_version,
1189 action_epoch: cancelled.state_version,
1190 deadline: None,
1191 reservation: None,
1192 created_at: Utc::now(),
1193 });
1194 assert!(with_intent
1195 .validate_for(&cancelled)
1196 .unwrap_err()
1197 .contains("cancelled work"));
1198
1199 let mut escaped = with_intent.clone();
1200 escaped.action_intents[0].tenant_id = "someone-else".parse().unwrap();
1201 let scoped = item("scoped", false);
1202 let mut escaped_command = command(&scoped, WorkDisposition::Complete, Value::Null);
1203 escaped_command.action_intents = escaped.action_intents;
1204 assert!(escaped_command
1205 .validate_for(&scoped)
1206 .unwrap_err()
1207 .contains("escapes claimed work scope"));
1208 }
1209}