1use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, Instant};
6
7use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc};
8use tokio::time::{sleep, timeout};
9use tokio::{select, spawn};
10use tokio_util::sync::CancellationToken;
11use tracing::{error, info, warn};
12use uuid::Uuid;
13
14use ironflow_core::account_strategy::{AccountStrategy, LeastUtilized};
15use ironflow_core::decision::DecisionProvider;
16#[cfg(feature = "prometheus")]
17use ironflow_core::metric_names::{WORKER_ACTIVE, WORKER_LEASES_LOST_TOTAL, WORKER_POLLS_TOTAL};
18use ironflow_core::provider::AgentProvider;
19use ironflow_engine::accounts::AccountAwareProvider;
20use ironflow_engine::engine::Engine;
21use ironflow_engine::handler::WorkflowHandler;
22use ironflow_engine::log_sender::LogReceiver;
23use ironflow_store::entities::{LeaseRequest, RunStatus};
24use ironflow_store::error::StoreError;
25use ironflow_store::store::Store;
26#[cfg(feature = "prometheus")]
27use metrics::{counter, gauge};
28#[cfg(feature = "heartbeat")]
29use reqwest::Client;
30
31use crate::api_store::ApiRunStore;
32use crate::artifact_sink::ApiArtifactSink;
33use crate::error::WorkerError;
34use crate::log_pusher::LogPusher;
35#[cfg(feature = "prometheus")]
36use crate::queue_depth::QueueDepthGauge;
37
38const DEFAULT_CONCURRENCY: usize = 2;
39const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
40const DEFAULT_RUN_TIMEOUT: Duration = Duration::from_secs(30 * 60);
41const DEFAULT_MAX_CONSECUTIVE_PANICS: u32 = 3;
42const DEFAULT_PANIC_COOLDOWN: Duration = Duration::from_secs(5 * 60);
43const DEFAULT_LEASE_TTL: Duration = Duration::from_secs(90);
46const DEFAULT_LEASE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
48#[cfg(feature = "heartbeat")]
49const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
50
51pub struct WorkerBuilder {
75 api_url: String,
76 worker_token: String,
77 worker_id: String,
78 provider: Option<Arc<dyn AgentProvider>>,
79 decision_provider: Option<Arc<dyn DecisionProvider>>,
80 account_strategy: Option<Arc<dyn AccountStrategy>>,
81 handlers: Vec<Box<dyn WorkflowHandler>>,
82 concurrency: usize,
83 poll_interval: Duration,
84 run_timeout: Duration,
85 max_consecutive_panics: u32,
86 panic_cooldown: Duration,
87 lease_ttl: Duration,
88 lease_refresh_interval: Duration,
89 #[cfg(feature = "heartbeat")]
90 heartbeat_url: Option<String>,
91 #[cfg(feature = "heartbeat")]
92 heartbeat_interval: Duration,
93}
94
95impl WorkerBuilder {
96 pub fn new(api_url: &str, worker_token: &str) -> Self {
98 Self {
99 api_url: api_url.to_string(),
100 worker_token: worker_token.to_string(),
101 worker_id: format!("worker-{}", Uuid::now_v7()),
102 provider: None,
103 decision_provider: None,
104 account_strategy: None,
105 handlers: Vec::new(),
106 concurrency: DEFAULT_CONCURRENCY,
107 poll_interval: DEFAULT_POLL_INTERVAL,
108 run_timeout: DEFAULT_RUN_TIMEOUT,
109 max_consecutive_panics: DEFAULT_MAX_CONSECUTIVE_PANICS,
110 panic_cooldown: DEFAULT_PANIC_COOLDOWN,
111 lease_ttl: DEFAULT_LEASE_TTL,
112 lease_refresh_interval: DEFAULT_LEASE_REFRESH_INTERVAL,
113 #[cfg(feature = "heartbeat")]
114 heartbeat_url: None,
115 #[cfg(feature = "heartbeat")]
116 heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
117 }
118 }
119
120 pub fn provider(mut self, provider: Arc<dyn AgentProvider>) -> Self {
122 self.provider = Some(provider);
123 self
124 }
125
126 pub fn decision_provider(mut self, provider: Arc<dyn DecisionProvider>) -> Self {
152 self.decision_provider = Some(provider);
153 self
154 }
155
156 pub fn account_strategy(mut self, strategy: Arc<dyn AccountStrategy>) -> Self {
179 self.account_strategy = Some(strategy);
180 self
181 }
182
183 pub fn register(mut self, handler: impl WorkflowHandler + 'static) -> Self {
185 self.handlers.push(Box::new(handler));
186 self
187 }
188
189 pub fn concurrency(mut self, n: usize) -> Self {
191 self.concurrency = n;
192 self
193 }
194
195 pub fn poll_interval(mut self, interval: Duration) -> Self {
197 self.poll_interval = interval;
198 self
199 }
200
201 pub fn run_timeout(mut self, timeout: Duration) -> Self {
218 self.run_timeout = timeout;
219 self
220 }
221
222 pub fn max_consecutive_panics(mut self, n: u32) -> Self {
240 self.max_consecutive_panics = n;
241 self
242 }
243
244 pub fn panic_cooldown(mut self, cooldown: Duration) -> Self {
261 self.panic_cooldown = cooldown;
262 self
263 }
264
265 pub fn worker_id(mut self, worker_id: &str) -> Self {
282 self.worker_id = worker_id.to_string();
283 self
284 }
285
286 pub fn lease_ttl(mut self, ttl: Duration) -> Self {
304 self.lease_ttl = ttl;
305 self
306 }
307
308 pub fn lease_refresh_interval(mut self, interval: Duration) -> Self {
325 self.lease_refresh_interval = interval;
326 self
327 }
328
329 #[cfg(feature = "heartbeat")]
348 pub fn heartbeat_url(mut self, url: &str) -> Self {
349 self.heartbeat_url = Some(url.to_string());
350 self
351 }
352
353 #[cfg(feature = "heartbeat")]
371 pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
372 self.heartbeat_interval = interval;
373 self
374 }
375
376 pub fn build(self) -> Result<Worker, WorkerError> {
383 let provider = self
384 .provider
385 .ok_or_else(|| WorkerError::Internal("WorkerBuilder: provider is required".into()))?;
386
387 let store: Arc<dyn Store> = Arc::new(ApiRunStore::new(&self.api_url, &self.worker_token));
388 let strategy = self
389 .account_strategy
390 .unwrap_or_else(|| Arc::new(LeastUtilized));
391 let provider: Arc<dyn AgentProvider> =
392 Arc::new(AccountAwareProvider::new(provider, store.clone()).with_strategy(strategy));
393
394 let mut engine = Engine::new(store, provider);
395 if let Some(decision_provider) = self.decision_provider {
396 engine = engine.with_decision_provider(decision_provider);
397 }
398 for handler in self.handlers {
399 engine
400 .register_boxed(handler)
401 .map_err(WorkerError::Engine)?;
402 }
403
404 let (log_sender, log_receiver) = ironflow_engine::log_sender::channel();
405 engine.set_log_sender(log_sender);
406
407 engine.set_artifact_sink(Arc::new(ApiArtifactSink::new(
411 &self.api_url,
412 &self.worker_token,
413 )));
414
415 #[cfg(feature = "heartbeat")]
416 let heartbeat_client = Client::builder()
417 .timeout(Duration::from_secs(5))
418 .build()
419 .expect("failed to build heartbeat HTTP client");
420
421 Ok(Worker {
422 engine: Arc::new(engine),
423 api_url: self.api_url,
424 worker_token: self.worker_token,
425 worker_id: self.worker_id,
426 log_receiver: Mutex::new(Some(log_receiver)),
427 concurrency: self.concurrency,
428 poll_interval: self.poll_interval,
429 run_timeout: self.run_timeout,
430 max_consecutive_panics: self.max_consecutive_panics,
431 panic_cooldown: self.panic_cooldown,
432 lease_ttl: self.lease_ttl,
433 lease_refresh_interval: self.lease_refresh_interval,
434 #[cfg(feature = "heartbeat")]
435 heartbeat_url: self.heartbeat_url,
436 #[cfg(feature = "heartbeat")]
437 heartbeat_interval: self.heartbeat_interval,
438 #[cfg(feature = "heartbeat")]
439 heartbeat_client,
440 })
441 }
442}
443
444pub struct Worker {
446 engine: Arc<Engine>,
447 api_url: String,
448 worker_token: String,
449 worker_id: String,
450 log_receiver: Mutex<Option<LogReceiver>>,
451 concurrency: usize,
452 poll_interval: Duration,
453 run_timeout: Duration,
454 max_consecutive_panics: u32,
455 panic_cooldown: Duration,
456 lease_ttl: Duration,
457 lease_refresh_interval: Duration,
458 #[cfg(feature = "heartbeat")]
459 heartbeat_url: Option<String>,
460 #[cfg(feature = "heartbeat")]
461 heartbeat_interval: Duration,
462 #[cfg(feature = "heartbeat")]
463 heartbeat_client: Client,
464}
465
466struct PoisonPillTracker {
468 max_consecutive: u32,
469 cooldown: Duration,
470 state: HashMap<String, (u32, Instant)>,
472}
473
474impl PoisonPillTracker {
475 fn new(max_consecutive: u32, cooldown: Duration) -> Self {
476 Self {
477 max_consecutive,
478 cooldown,
479 state: HashMap::new(),
480 }
481 }
482
483 fn record_panic(&mut self, workflow: &str) -> bool {
486 let entry = self
487 .state
488 .entry(workflow.to_string())
489 .or_insert((0, Instant::now()));
490 entry.0 += 1;
491 entry.1 = Instant::now();
492 entry.0 >= self.max_consecutive
493 }
494
495 fn record_success(&mut self, workflow: &str) {
497 self.state.remove(workflow);
498 }
499
500 fn is_blocked(&self, workflow: &str) -> bool {
502 self.state.get(workflow).is_some_and(|(count, last_panic)| {
503 *count >= self.max_consecutive && last_panic.elapsed() < self.cooldown
504 })
505 }
506}
507
508impl Worker {
509 pub async fn run(&self) -> Result<(), WorkerError> {
518 let semaphore = Arc::new(Semaphore::new(self.concurrency));
519 let shutdown = CancellationToken::new();
520 let mut idle_streak = 0u32;
521 let poison_tracker = Arc::new(Mutex::new(PoisonPillTracker::new(
522 self.max_consecutive_panics,
523 self.panic_cooldown,
524 )));
525 let (outcome_tx, mut outcome_rx) = mpsc::unbounded_channel::<RunOutcome>();
526
527 info!(
528 concurrency = self.concurrency,
529 poll_interval_ms = self.poll_interval.as_millis() as u64,
530 run_timeout_secs = self.run_timeout.as_secs(),
531 "worker started"
532 );
533
534 if let Some(receiver) = self.log_receiver.lock().expect("log_receiver lock").take() {
535 let pusher = LogPusher::new(&self.api_url, &self.worker_token);
536 spawn(pusher.run(receiver));
537 info!("log pusher started");
538 }
539
540 let shutdown_clone = shutdown.clone();
542 spawn(async move {
543 shutdown_signal().await;
544 info!("shutdown signal received, draining in-flight runs...");
545 shutdown_clone.cancel();
546 });
547
548 #[cfg(feature = "heartbeat")]
549 if let Some(ref url) = self.heartbeat_url {
550 let interval = self.heartbeat_interval;
551 let url = url.clone();
552 let client = self.heartbeat_client.clone();
553
554 spawn(async move {
555 let mut ticker = tokio::time::interval(interval);
556 ticker.tick().await;
558 loop {
559 ticker.tick().await;
560 match client.head(&url).send().await {
561 Ok(resp) if resp.status().is_success() => {
562 info!(url = %url, "heartbeat sent");
563 }
564 Ok(resp) => {
565 warn!(
566 url = %url,
567 status = %resp.status(),
568 "heartbeat ping returned non-success status"
569 );
570 }
571 Err(err) => {
572 warn!(
573 url = %url,
574 error = %err,
575 "heartbeat ping failed"
576 );
577 }
578 }
579 }
580 });
581 }
582
583 #[cfg(feature = "prometheus")]
584 let mut queue_depth = QueueDepthGauge::new(&self.api_url, &self.worker_token);
585
586 while !shutdown.is_cancelled() {
587 while let Ok(outcome) = outcome_rx.try_recv() {
589 let mut tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
590 match outcome {
591 RunOutcome::Success(ref wf) => tracker.record_success(wf),
592 RunOutcome::LeaseLost(ref wf) => {
595 warn!(workflow = %wf, "run abandoned after losing its lease")
596 }
597 RunOutcome::Failed(ref wf) | RunOutcome::Timeout(ref wf) => {
598 if tracker.record_panic(wf) {
599 warn!(workflow = %wf, "workflow flagged as poison pill after consecutive failures");
600 }
601 }
602 RunOutcome::Panicked(ref wf) => {
603 if tracker.record_panic(wf) {
604 error!(workflow = %wf, "workflow flagged as poison pill after consecutive panics");
605 }
606 }
607 }
608 }
609
610 let Some(permit) = acquire_slot(&semaphore, &shutdown).await? else {
613 break;
614 };
615
616 let run = self
617 .engine
618 .store()
619 .pick_next_pending(Some(LeaseRequest {
620 worker_id: self.worker_id.clone(),
621 ttl: self.lease_ttl,
622 }))
623 .await;
624
625 match run {
626 Ok(Some(run)) => {
627 #[cfg(feature = "prometheus")]
628 counter!(WORKER_POLLS_TOTAL, "result" => "hit").increment(1);
629
630 let is_blocked = {
632 let tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
633 tracker.is_blocked(&run.workflow_name)
634 };
635 if is_blocked {
636 warn!(
637 workflow = %run.workflow_name,
638 run_id = %run.id,
639 "skipping run: workflow flagged as poison pill, marking as failed"
640 );
641 if let Err(e) = self
642 .engine
643 .store()
644 .update_run_status(run.id, RunStatus::Failed)
645 .await
646 {
647 error!(run_id = %run.id, error = %e, "failed to mark poisoned run as failed");
648 }
649 drop(permit);
650 continue;
651 }
652
653 idle_streak = 0;
654 let engine = self.engine.clone();
655 let run_id = run.id;
656 let workflow = run.workflow_name.clone();
657 let workflow_for_watcher = workflow.clone();
658 let run_timeout = self.run_timeout;
659
660 info!(run_id = %run_id, workflow = %workflow, "executing run");
661
662 #[cfg(feature = "prometheus")]
663 gauge!(WORKER_ACTIVE).increment(1.0);
664
665 let lease_token = CancellationToken::new();
668 let refresher = spawn(refresh_lease(
669 self.engine.store().clone(),
670 run_id,
671 LeaseRequest {
672 worker_id: self.worker_id.clone(),
673 ttl: self.lease_ttl,
674 },
675 self.lease_refresh_interval,
676 lease_token.clone(),
677 ));
678
679 let handle = spawn(async move {
680 let _permit = permit;
681 let result = select! {
682 biased;
683 _ = lease_token.cancelled() => {
684 refresher.abort();
685 warn!(
688 run_id = %run_id,
689 workflow = %workflow,
690 "abandoning run: worker lease lost"
691 );
692 #[cfg(feature = "prometheus")]
693 counter!(WORKER_LEASES_LOST_TOTAL).increment(1);
694 return RunOutcome::LeaseLost(workflow);
696 }
697 result = timeout(run_timeout, engine.execute_handler_run(run_id)) => result,
698 };
699 refresher.abort();
700
701 match result {
702 Ok(Ok(_)) => {
703 info!(run_id = %run_id, workflow = %workflow, "run completed");
704 RunOutcome::Success(workflow)
705 }
706 Ok(Err(e)) => {
714 error!(run_id = %run_id, workflow = %workflow, error = %e, "run failed");
715 RunOutcome::Failed(workflow)
716 }
717 Err(_) => {
718 error!(
719 run_id = %run_id,
720 workflow = %workflow,
721 timeout_secs = run_timeout.as_secs(),
722 "run timed out"
723 );
724 let timeout_msg =
725 format!("run timed out after {}s", run_timeout.as_secs());
726 if let Err(e) = engine
728 .fail_or_schedule_retry(run_id, &timeout_msg, true, None, None)
729 .await
730 {
731 error!(run_id = %run_id, error = %e, "failed to record timed-out run");
732 }
733 RunOutcome::Timeout(workflow)
734 }
735 }
736 });
737
738 let watcher_engine = self.engine.clone();
740 let tx = outcome_tx.clone();
741 spawn(async move {
742 match handle.await {
743 Ok(outcome) => {
744 let _ = tx.send(outcome);
745 }
746 Err(e) => {
747 error!(run_id = %run_id, "spawned task panicked: {e}");
748 if let Err(store_err) = watcher_engine
752 .fail_or_schedule_retry(
753 run_id,
754 "parent run panicked",
755 true,
756 None,
757 None,
758 )
759 .await
760 {
761 error!(run_id = %run_id, error = %store_err, "failed to record panicked run");
762 }
763 let _ = tx.send(RunOutcome::Panicked(workflow_for_watcher));
764 }
765 }
766 #[cfg(feature = "prometheus")]
767 gauge!(WORKER_ACTIVE).decrement(1.0);
768 });
769 }
770 Ok(None) => {
771 drop(permit);
772 #[cfg(feature = "prometheus")]
773 counter!(WORKER_POLLS_TOTAL, "result" => "miss").increment(1);
774
775 idle_streak += 1;
776 let backoff = if idle_streak > 10 {
777 self.poll_interval * 3
778 } else if idle_streak > 5 {
779 self.poll_interval * 2
780 } else {
781 self.poll_interval
782 };
783 sleep(backoff).await;
784 }
785 Err(e) => {
786 drop(permit);
787 warn!(error = %e, "poll error");
788 sleep(self.poll_interval).await;
789 }
790 }
791
792 #[cfg(feature = "prometheus")]
793 queue_depth.refresh_if_due().await;
794 }
795
796 info!(
798 in_flight = self.concurrency - semaphore.available_permits(),
799 "waiting for in-flight runs to complete..."
800 );
801 let _ = semaphore
802 .acquire_many(self.concurrency as u32)
803 .await
804 .map_err(|_| WorkerError::Shutdown("semaphore closed during drain".to_string()))?;
805
806 info!("all in-flight runs completed, worker shut down");
807 Ok(())
808 }
809}
810
811enum RunOutcome {
813 Success(String),
815 Failed(String),
817 Timeout(String),
819 Panicked(String),
821 LeaseLost(String),
823}
824
825async fn acquire_slot(
834 semaphore: &Arc<Semaphore>,
835 shutdown: &CancellationToken,
836) -> Result<Option<OwnedSemaphorePermit>, WorkerError> {
837 select! {
838 biased;
839 _ = shutdown.cancelled() => Ok(None),
840 permit = semaphore.clone().acquire_owned() => permit
841 .map(Some)
842 .map_err(|e| WorkerError::Internal(format!("semaphore closed: {e}"))),
843 }
844}
845
846async fn refresh_lease(
852 store: Arc<dyn Store>,
853 run_id: uuid::Uuid,
854 lease: LeaseRequest,
855 refresh_interval: Duration,
856 lease_token: CancellationToken,
857) {
858 let ttl = lease.ttl;
859 let mut deadline = Instant::now() + ttl;
860
861 loop {
862 sleep(refresh_interval).await;
863
864 match store.renew_lease(run_id, lease.clone()).await {
865 Ok(_) => {
866 deadline = Instant::now() + ttl;
867 }
868 Err(StoreError::LeaseLost { held_by, .. }) => {
869 warn!(
870 run_id = %run_id,
871 held_by = held_by.as_deref().unwrap_or("unknown"),
872 "lease taken over by another worker"
873 );
874 lease_token.cancel();
875 return;
876 }
877 Err(err) if Instant::now() >= deadline => {
878 warn!(
881 run_id = %run_id,
882 error = %err,
883 ttl_secs = ttl.as_secs(),
884 "lease could not be refreshed before it expired"
885 );
886 lease_token.cancel();
887 return;
888 }
889 Err(err) => {
890 warn!(run_id = %run_id, error = %err, "lease refresh failed, retrying");
891 }
892 }
893 }
894}
895
896async fn shutdown_signal() {
898 use tokio::signal;
899
900 let ctrl_c = async {
901 signal::ctrl_c()
902 .await
903 .expect("failed to install Ctrl+C handler");
904 };
905
906 #[cfg(unix)]
907 let terminate = async {
908 use tokio::signal::unix::{SignalKind, signal};
909
910 signal(SignalKind::terminate())
911 .expect("failed to install SIGTERM handler")
912 .recv()
913 .await;
914 };
915
916 #[cfg(not(unix))]
917 let terminate = {
918 use std::future::pending;
919 pending::<()>()
920 };
921
922 select! {
923 () = ctrl_c => {},
924 () = terminate => {},
925 }
926}
927
928#[cfg(test)]
929mod tests {
930 use super::*;
931 use ironflow_core::account_strategy::Priority;
932 use ironflow_core::providers::claude::ClaudeCodeProvider;
933 use ironflow_core::providers::record_replay_decision::RecordReplayDecisionProvider;
934
935 #[test]
936 fn builder_new_creates_default_config() {
937 let builder = WorkerBuilder::new("http://localhost:3000", "my-token");
938 assert_eq!(builder.api_url, "http://localhost:3000");
939 assert_eq!(builder.worker_token, "my-token");
940 assert_eq!(builder.concurrency, DEFAULT_CONCURRENCY);
941 assert_eq!(builder.poll_interval, DEFAULT_POLL_INTERVAL);
942 assert_eq!(builder.run_timeout, DEFAULT_RUN_TIMEOUT);
943 assert_eq!(
944 builder.max_consecutive_panics,
945 DEFAULT_MAX_CONSECUTIVE_PANICS
946 );
947 assert_eq!(builder.panic_cooldown, DEFAULT_PANIC_COOLDOWN);
948 assert!(builder.provider.is_none());
949 }
950
951 #[test]
952 fn builder_with_trailing_slash_normalized() {
953 let builder = WorkerBuilder::new("http://localhost:3000/", "token");
954 assert_eq!(builder.api_url, "http://localhost:3000/");
955 }
956
957 #[test]
958 fn builder_provider_sets_provider() {
959 let provider = Arc::new(ClaudeCodeProvider::new());
960 let builder =
961 WorkerBuilder::new("http://localhost:3000", "token").provider(provider.clone());
962 assert!(builder.provider.is_some());
963 }
964
965 #[test]
966 fn builder_account_strategy_sets_strategy() {
967 let builder = WorkerBuilder::new("http://localhost:3000", "token");
968 assert!(builder.account_strategy.is_none());
969 let builder = builder.account_strategy(Arc::new(Priority));
970 assert_eq!(
971 builder.account_strategy.as_ref().map(|s| s.name()),
972 Some("priority")
973 );
974 }
975
976 #[test]
977 fn builder_decision_provider_defaults_to_none() {
978 let builder = WorkerBuilder::new("http://localhost:3000", "token");
979 assert!(builder.decision_provider.is_none());
980 }
981
982 #[test]
983 fn builder_decision_provider_sets_provider() {
984 let builder = WorkerBuilder::new("http://localhost:3000", "token").decision_provider(
985 Arc::new(RecordReplayDecisionProvider::replay("tests/fixtures")),
986 );
987 assert!(builder.decision_provider.is_some());
988 }
989
990 #[test]
991 fn builder_concurrency_sets_concurrency() {
992 let builder = WorkerBuilder::new("http://localhost:3000", "token").concurrency(8);
993 assert_eq!(builder.concurrency, 8);
994 }
995
996 #[test]
997 fn builder_concurrency_zero_accepted() {
998 let provider = Arc::new(ClaudeCodeProvider::new());
999 let builder = WorkerBuilder::new("http://localhost:3000", "token")
1000 .provider(provider)
1001 .concurrency(0);
1002 assert_eq!(builder.concurrency, 0);
1003 }
1004
1005 #[test]
1006 fn builder_poll_interval_sets_interval() {
1007 let interval = Duration::from_secs(5);
1008 let builder = WorkerBuilder::new("http://localhost:3000", "token").poll_interval(interval);
1009 assert_eq!(builder.poll_interval, interval);
1010 }
1011
1012 #[test]
1013 fn builder_run_timeout_sets_timeout() {
1014 let dur = Duration::from_secs(120);
1015 let builder = WorkerBuilder::new("http://localhost:3000", "token").run_timeout(dur);
1016 assert_eq!(builder.run_timeout, dur);
1017 }
1018
1019 #[test]
1020 fn builder_max_consecutive_panics_sets_value() {
1021 let builder =
1022 WorkerBuilder::new("http://localhost:3000", "token").max_consecutive_panics(10);
1023 assert_eq!(builder.max_consecutive_panics, 10);
1024 }
1025
1026 #[test]
1027 fn builder_panic_cooldown_sets_value() {
1028 let dur = Duration::from_secs(600);
1029 let builder = WorkerBuilder::new("http://localhost:3000", "token").panic_cooldown(dur);
1030 assert_eq!(builder.panic_cooldown, dur);
1031 }
1032
1033 #[test]
1034 fn builder_defaults_lease_settings() {
1035 let builder = WorkerBuilder::new("http://localhost:3000", "token");
1036 assert_eq!(builder.lease_ttl, DEFAULT_LEASE_TTL);
1037 assert_eq!(
1038 builder.lease_refresh_interval,
1039 DEFAULT_LEASE_REFRESH_INTERVAL
1040 );
1041 assert!(builder.worker_id.starts_with("worker-"));
1042 }
1043
1044 #[test]
1045 fn builder_generates_a_distinct_worker_id_per_instance() {
1046 let a = WorkerBuilder::new("http://localhost:3000", "token");
1047 let b = WorkerBuilder::new("http://localhost:3000", "token");
1048 assert_ne!(a.worker_id, b.worker_id);
1049 }
1050
1051 #[test]
1052 fn builder_worker_id_overrides_default() {
1053 let builder = WorkerBuilder::new("http://localhost:3000", "token").worker_id("worker-eu-1");
1054 assert_eq!(builder.worker_id, "worker-eu-1");
1055 }
1056
1057 #[test]
1058 fn builder_lease_ttl_sets_value() {
1059 let dur = Duration::from_secs(120);
1060 let builder = WorkerBuilder::new("http://localhost:3000", "token").lease_ttl(dur);
1061 assert_eq!(builder.lease_ttl, dur);
1062 }
1063
1064 #[test]
1065 fn builder_lease_refresh_interval_sets_value() {
1066 let dur = Duration::from_secs(5);
1067 let builder =
1068 WorkerBuilder::new("http://localhost:3000", "token").lease_refresh_interval(dur);
1069 assert_eq!(builder.lease_refresh_interval, dur);
1070 }
1071
1072 #[test]
1073 fn builder_build_without_provider_fails() {
1074 let builder = WorkerBuilder::new("http://localhost:3000", "token");
1075 let result = builder.build();
1076 assert!(result.is_err());
1077 match result {
1078 Err(WorkerError::Internal(msg)) => {
1079 assert!(msg.contains("provider is required"));
1080 }
1081 _ => panic!("expected Internal error about missing provider"),
1082 }
1083 }
1084
1085 #[test]
1086 fn builder_build_with_provider_succeeds() {
1087 let provider = Arc::new(ClaudeCodeProvider::new());
1088 let builder = WorkerBuilder::new("http://localhost:3000", "token").provider(provider);
1089 let result = builder.build();
1090 assert!(result.is_ok());
1091 }
1092
1093 #[test]
1094 fn builder_build_creates_worker_with_correct_concurrency() {
1095 let provider = Arc::new(ClaudeCodeProvider::new());
1096 let builder = WorkerBuilder::new("http://localhost:3000", "token")
1097 .provider(provider)
1098 .concurrency(16);
1099 let worker = builder.build().unwrap();
1100 assert_eq!(worker.concurrency, 16);
1101 }
1102
1103 #[test]
1104 fn builder_build_creates_worker_with_correct_interval() {
1105 let provider = Arc::new(ClaudeCodeProvider::new());
1106 let interval = Duration::from_secs(10);
1107 let builder = WorkerBuilder::new("http://localhost:3000", "token")
1108 .provider(provider)
1109 .poll_interval(interval);
1110 let worker = builder.build().unwrap();
1111 assert_eq!(worker.poll_interval, interval);
1112 }
1113
1114 #[test]
1115 fn builder_build_preserves_timeout() {
1116 let provider = Arc::new(ClaudeCodeProvider::new());
1117 let dur = Duration::from_secs(300);
1118 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1119 .provider(provider)
1120 .run_timeout(dur)
1121 .build()
1122 .unwrap();
1123 assert_eq!(worker.run_timeout, dur);
1124 }
1125
1126 #[test]
1127 fn builder_build_preserves_poison_pill_config() {
1128 let provider = Arc::new(ClaudeCodeProvider::new());
1129 let cooldown = Duration::from_secs(120);
1130 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1131 .provider(provider)
1132 .max_consecutive_panics(7)
1133 .panic_cooldown(cooldown)
1134 .build()
1135 .unwrap();
1136 assert_eq!(worker.max_consecutive_panics, 7);
1137 assert_eq!(worker.panic_cooldown, cooldown);
1138 }
1139
1140 #[test]
1141 fn builder_chaining_works() {
1142 let provider = Arc::new(ClaudeCodeProvider::new());
1143 let result = WorkerBuilder::new("http://localhost:3000", "token")
1144 .provider(provider)
1145 .concurrency(4)
1146 .poll_interval(Duration::from_secs(3))
1147 .run_timeout(Duration::from_secs(600))
1148 .max_consecutive_panics(5)
1149 .panic_cooldown(Duration::from_secs(120))
1150 .build();
1151 assert!(result.is_ok());
1152 let worker = result.unwrap();
1153 assert_eq!(worker.concurrency, 4);
1154 assert_eq!(worker.poll_interval, Duration::from_secs(3));
1155 assert_eq!(worker.run_timeout, Duration::from_secs(600));
1156 assert_eq!(worker.max_consecutive_panics, 5);
1157 assert_eq!(worker.panic_cooldown, Duration::from_secs(120));
1158 }
1159
1160 #[test]
1161 fn builder_empty_api_url_accepted() {
1162 let provider = Arc::new(ClaudeCodeProvider::new());
1163 let builder = WorkerBuilder::new("", "token").provider(provider);
1164 let result = builder.build();
1165 assert!(result.is_ok());
1166 }
1167
1168 #[test]
1169 fn builder_empty_token_accepted() {
1170 let provider = Arc::new(ClaudeCodeProvider::new());
1171 let builder = WorkerBuilder::new("http://localhost:3000", "").provider(provider);
1172 let result = builder.build();
1173 assert!(result.is_ok());
1174 }
1175
1176 #[cfg(feature = "heartbeat")]
1177 #[test]
1178 fn builder_heartbeat_defaults() {
1179 let builder = WorkerBuilder::new("http://localhost:3000", "token");
1180 assert!(builder.heartbeat_url.is_none());
1181 assert_eq!(builder.heartbeat_interval, DEFAULT_HEARTBEAT_INTERVAL);
1182 }
1183
1184 #[cfg(feature = "heartbeat")]
1185 #[test]
1186 fn builder_heartbeat_url_sets_url() {
1187 let builder = WorkerBuilder::new("http://localhost:3000", "token")
1188 .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc");
1189 assert_eq!(
1190 builder.heartbeat_url.as_deref(),
1191 Some("https://uptime.betterstack.com/api/v1/heartbeat/abc")
1192 );
1193 }
1194
1195 #[cfg(feature = "heartbeat")]
1196 #[test]
1197 fn builder_heartbeat_custom_interval() {
1198 let interval = Duration::from_secs(10);
1199 let builder =
1200 WorkerBuilder::new("http://localhost:3000", "token").heartbeat_interval(interval);
1201 assert_eq!(builder.heartbeat_interval, interval);
1202 }
1203
1204 #[cfg(feature = "heartbeat")]
1205 #[test]
1206 fn builder_build_preserves_heartbeat_config() {
1207 let provider = Arc::new(ClaudeCodeProvider::new());
1208 let interval = Duration::from_secs(15);
1209 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1210 .provider(provider)
1211 .heartbeat_url("https://example.com/heartbeat")
1212 .heartbeat_interval(interval)
1213 .build()
1214 .unwrap();
1215 assert_eq!(
1216 worker.heartbeat_url.as_deref(),
1217 Some("https://example.com/heartbeat")
1218 );
1219 assert_eq!(worker.heartbeat_interval, interval);
1220 }
1221
1222 #[cfg(feature = "heartbeat")]
1223 #[test]
1224 fn builder_build_without_heartbeat_url_has_none() {
1225 let provider = Arc::new(ClaudeCodeProvider::new());
1226 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1227 .provider(provider)
1228 .build()
1229 .unwrap();
1230 assert!(worker.heartbeat_url.is_none());
1231 }
1232
1233 #[test]
1236 fn poison_tracker_not_blocked_initially() {
1237 let tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1238 assert!(!tracker.is_blocked("my-workflow"));
1239 }
1240
1241 #[test]
1242 fn poison_tracker_blocked_after_max_panics() {
1243 let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1244 assert!(!tracker.record_panic("wf"));
1245 assert!(!tracker.record_panic("wf"));
1246 assert!(tracker.record_panic("wf"));
1247 assert!(tracker.is_blocked("wf"));
1248 }
1249
1250 #[test]
1251 fn poison_tracker_success_resets_count() {
1252 let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1253 tracker.record_panic("wf");
1254 tracker.record_panic("wf");
1255 tracker.record_success("wf");
1256 assert!(!tracker.is_blocked("wf"));
1257 assert!(!tracker.record_panic("wf"));
1259 }
1260
1261 #[test]
1262 fn poison_tracker_independent_per_workflow() {
1263 let mut tracker = PoisonPillTracker::new(2, Duration::from_secs(300));
1264 tracker.record_panic("wf-a");
1265 tracker.record_panic("wf-a");
1266 assert!(tracker.is_blocked("wf-a"));
1267 assert!(!tracker.is_blocked("wf-b"));
1268 }
1269
1270 #[test]
1271 fn poison_tracker_unblocks_after_cooldown() {
1272 let mut tracker = PoisonPillTracker::new(2, Duration::from_millis(0));
1273 tracker.record_panic("wf");
1274 tracker.record_panic("wf");
1275 assert!(!tracker.is_blocked("wf"));
1277 }
1278
1279 #[tokio::test]
1280 async fn acquire_slot_returns_a_permit_when_a_slot_is_free() {
1281 let semaphore = Arc::new(Semaphore::new(1));
1282 let shutdown = CancellationToken::new();
1283
1284 let permit = acquire_slot(&semaphore, &shutdown)
1285 .await
1286 .expect("semaphore open");
1287
1288 assert!(permit.is_some());
1289 assert_eq!(semaphore.available_permits(), 0);
1290 drop(permit);
1291 assert_eq!(semaphore.available_permits(), 1);
1292 }
1293
1294 #[tokio::test]
1295 async fn acquire_slot_gives_up_on_shutdown_while_all_slots_are_busy() {
1296 let semaphore = Arc::new(Semaphore::new(1));
1297 let _busy = semaphore.clone().acquire_owned().await.expect("permit");
1298 let shutdown = CancellationToken::new();
1299
1300 let cancel = shutdown.clone();
1301 spawn(async move {
1302 sleep(Duration::from_millis(20)).await;
1303 cancel.cancel();
1304 });
1305
1306 let permit = timeout(Duration::from_secs(5), acquire_slot(&semaphore, &shutdown))
1307 .await
1308 .expect("waiting for a slot must stop on shutdown")
1309 .expect("semaphore open");
1310
1311 assert!(permit.is_none());
1312 }
1313
1314 #[tokio::test]
1315 async fn acquire_slot_takes_no_permit_once_shutdown_is_requested() {
1316 let semaphore = Arc::new(Semaphore::new(1));
1317 let shutdown = CancellationToken::new();
1318 shutdown.cancel();
1319
1320 let permit = acquire_slot(&semaphore, &shutdown)
1321 .await
1322 .expect("semaphore open");
1323
1324 assert!(permit.is_none());
1325 assert_eq!(semaphore.available_permits(), 1);
1326 }
1327
1328 #[tokio::test]
1329 async fn acquire_slot_fails_when_the_semaphore_is_closed() {
1330 let semaphore = Arc::new(Semaphore::new(1));
1331 semaphore.close();
1332 let shutdown = CancellationToken::new();
1333
1334 let result = acquire_slot(&semaphore, &shutdown).await;
1335
1336 assert!(matches!(result, Err(WorkerError::Internal(_))));
1337 }
1338}