1use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, Instant};
6
7use tokio::spawn;
8use tokio::sync::{Semaphore, mpsc};
9use tokio::time::{sleep, timeout};
10use tokio_util::sync::CancellationToken;
11use tracing::{error, info, warn};
12use uuid::Uuid;
13
14#[cfg(feature = "prometheus")]
15use ironflow_core::metric_names::{
16 WORKER_ACTIVE, WORKER_LEASES_LOST_TOTAL, WORKER_POLLS_TOTAL, WORKER_QUEUE_DEPTH,
17};
18use ironflow_core::provider::AgentProvider;
19use ironflow_engine::engine::Engine;
20use ironflow_engine::handler::WorkflowHandler;
21use ironflow_engine::log_sender::LogReceiver;
22#[cfg(feature = "prometheus")]
23use ironflow_store::entities::RunFilter;
24use ironflow_store::entities::{LeaseRequest, RunStatus};
25use ironflow_store::error::StoreError;
26use ironflow_store::store::Store;
27#[cfg(feature = "prometheus")]
28use metrics::{counter, gauge};
29#[cfg(feature = "heartbeat")]
30use reqwest::Client;
31
32use crate::api_store::ApiRunStore;
33use crate::artifact_sink::ApiArtifactSink;
34use crate::error::WorkerError;
35use crate::log_pusher::LogPusher;
36
37const DEFAULT_CONCURRENCY: usize = 2;
38const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
39const DEFAULT_RUN_TIMEOUT: Duration = Duration::from_secs(30 * 60);
40const DEFAULT_MAX_CONSECUTIVE_PANICS: u32 = 3;
41const DEFAULT_PANIC_COOLDOWN: Duration = Duration::from_secs(5 * 60);
42const DEFAULT_LEASE_TTL: Duration = Duration::from_secs(90);
45const DEFAULT_LEASE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
47#[cfg(feature = "heartbeat")]
48const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
49
50pub struct WorkerBuilder {
74 api_url: String,
75 worker_token: String,
76 worker_id: String,
77 provider: Option<Arc<dyn AgentProvider>>,
78 handlers: Vec<Box<dyn WorkflowHandler>>,
79 concurrency: usize,
80 poll_interval: Duration,
81 run_timeout: Duration,
82 max_consecutive_panics: u32,
83 panic_cooldown: Duration,
84 lease_ttl: Duration,
85 lease_refresh_interval: Duration,
86 #[cfg(feature = "heartbeat")]
87 heartbeat_url: Option<String>,
88 #[cfg(feature = "heartbeat")]
89 heartbeat_interval: Duration,
90}
91
92impl WorkerBuilder {
93 pub fn new(api_url: &str, worker_token: &str) -> Self {
95 Self {
96 api_url: api_url.to_string(),
97 worker_token: worker_token.to_string(),
98 worker_id: format!("worker-{}", Uuid::now_v7()),
99 provider: None,
100 handlers: Vec::new(),
101 concurrency: DEFAULT_CONCURRENCY,
102 poll_interval: DEFAULT_POLL_INTERVAL,
103 run_timeout: DEFAULT_RUN_TIMEOUT,
104 max_consecutive_panics: DEFAULT_MAX_CONSECUTIVE_PANICS,
105 panic_cooldown: DEFAULT_PANIC_COOLDOWN,
106 lease_ttl: DEFAULT_LEASE_TTL,
107 lease_refresh_interval: DEFAULT_LEASE_REFRESH_INTERVAL,
108 #[cfg(feature = "heartbeat")]
109 heartbeat_url: None,
110 #[cfg(feature = "heartbeat")]
111 heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
112 }
113 }
114
115 pub fn provider(mut self, provider: Arc<dyn AgentProvider>) -> Self {
117 self.provider = Some(provider);
118 self
119 }
120
121 pub fn register(mut self, handler: impl WorkflowHandler + 'static) -> Self {
123 self.handlers.push(Box::new(handler));
124 self
125 }
126
127 pub fn concurrency(mut self, n: usize) -> Self {
129 self.concurrency = n;
130 self
131 }
132
133 pub fn poll_interval(mut self, interval: Duration) -> Self {
135 self.poll_interval = interval;
136 self
137 }
138
139 pub fn run_timeout(mut self, timeout: Duration) -> Self {
156 self.run_timeout = timeout;
157 self
158 }
159
160 pub fn max_consecutive_panics(mut self, n: u32) -> Self {
178 self.max_consecutive_panics = n;
179 self
180 }
181
182 pub fn panic_cooldown(mut self, cooldown: Duration) -> Self {
199 self.panic_cooldown = cooldown;
200 self
201 }
202
203 pub fn worker_id(mut self, worker_id: &str) -> Self {
220 self.worker_id = worker_id.to_string();
221 self
222 }
223
224 pub fn lease_ttl(mut self, ttl: Duration) -> Self {
242 self.lease_ttl = ttl;
243 self
244 }
245
246 pub fn lease_refresh_interval(mut self, interval: Duration) -> Self {
263 self.lease_refresh_interval = interval;
264 self
265 }
266
267 #[cfg(feature = "heartbeat")]
286 pub fn heartbeat_url(mut self, url: &str) -> Self {
287 self.heartbeat_url = Some(url.to_string());
288 self
289 }
290
291 #[cfg(feature = "heartbeat")]
309 pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
310 self.heartbeat_interval = interval;
311 self
312 }
313
314 pub fn build(self) -> Result<Worker, WorkerError> {
321 let provider = self
322 .provider
323 .ok_or_else(|| WorkerError::Internal("WorkerBuilder: provider is required".into()))?;
324
325 let store: Arc<dyn Store> = Arc::new(ApiRunStore::new(&self.api_url, &self.worker_token));
326
327 let mut engine = Engine::new(store, provider);
328 for handler in self.handlers {
329 engine
330 .register_boxed(handler)
331 .map_err(WorkerError::Engine)?;
332 }
333
334 let (log_sender, log_receiver) = ironflow_engine::log_sender::channel();
335 engine.set_log_sender(log_sender);
336
337 engine.set_artifact_sink(Arc::new(ApiArtifactSink::new(
341 &self.api_url,
342 &self.worker_token,
343 )));
344
345 #[cfg(feature = "heartbeat")]
346 let heartbeat_client = Client::builder()
347 .timeout(Duration::from_secs(5))
348 .build()
349 .expect("failed to build heartbeat HTTP client");
350
351 Ok(Worker {
352 engine: Arc::new(engine),
353 api_url: self.api_url,
354 worker_token: self.worker_token,
355 worker_id: self.worker_id,
356 log_receiver: Mutex::new(Some(log_receiver)),
357 concurrency: self.concurrency,
358 poll_interval: self.poll_interval,
359 run_timeout: self.run_timeout,
360 max_consecutive_panics: self.max_consecutive_panics,
361 panic_cooldown: self.panic_cooldown,
362 lease_ttl: self.lease_ttl,
363 lease_refresh_interval: self.lease_refresh_interval,
364 #[cfg(feature = "heartbeat")]
365 heartbeat_url: self.heartbeat_url,
366 #[cfg(feature = "heartbeat")]
367 heartbeat_interval: self.heartbeat_interval,
368 #[cfg(feature = "heartbeat")]
369 heartbeat_client,
370 })
371 }
372}
373
374pub struct Worker {
376 engine: Arc<Engine>,
377 api_url: String,
378 worker_token: String,
379 worker_id: String,
380 log_receiver: Mutex<Option<LogReceiver>>,
381 concurrency: usize,
382 poll_interval: Duration,
383 run_timeout: Duration,
384 max_consecutive_panics: u32,
385 panic_cooldown: Duration,
386 lease_ttl: Duration,
387 lease_refresh_interval: Duration,
388 #[cfg(feature = "heartbeat")]
389 heartbeat_url: Option<String>,
390 #[cfg(feature = "heartbeat")]
391 heartbeat_interval: Duration,
392 #[cfg(feature = "heartbeat")]
393 heartbeat_client: Client,
394}
395
396struct PoisonPillTracker {
398 max_consecutive: u32,
399 cooldown: Duration,
400 state: HashMap<String, (u32, Instant)>,
402}
403
404impl PoisonPillTracker {
405 fn new(max_consecutive: u32, cooldown: Duration) -> Self {
406 Self {
407 max_consecutive,
408 cooldown,
409 state: HashMap::new(),
410 }
411 }
412
413 fn record_panic(&mut self, workflow: &str) -> bool {
416 let entry = self
417 .state
418 .entry(workflow.to_string())
419 .or_insert((0, Instant::now()));
420 entry.0 += 1;
421 entry.1 = Instant::now();
422 entry.0 >= self.max_consecutive
423 }
424
425 fn record_success(&mut self, workflow: &str) {
427 self.state.remove(workflow);
428 }
429
430 fn is_blocked(&self, workflow: &str) -> bool {
432 self.state.get(workflow).is_some_and(|(count, last_panic)| {
433 *count >= self.max_consecutive && last_panic.elapsed() < self.cooldown
434 })
435 }
436}
437
438impl Worker {
439 pub async fn run(&self) -> Result<(), WorkerError> {
448 let semaphore = Arc::new(Semaphore::new(self.concurrency));
449 let shutdown = CancellationToken::new();
450 let mut idle_streak = 0u32;
451 let poison_tracker = Arc::new(Mutex::new(PoisonPillTracker::new(
452 self.max_consecutive_panics,
453 self.panic_cooldown,
454 )));
455 let (outcome_tx, mut outcome_rx) = mpsc::unbounded_channel::<RunOutcome>();
456
457 info!(
458 concurrency = self.concurrency,
459 poll_interval_ms = self.poll_interval.as_millis() as u64,
460 run_timeout_secs = self.run_timeout.as_secs(),
461 "worker started"
462 );
463
464 if let Some(receiver) = self.log_receiver.lock().expect("log_receiver lock").take() {
465 let pusher = LogPusher::new(&self.api_url, &self.worker_token);
466 spawn(pusher.run(receiver));
467 info!("log pusher started");
468 }
469
470 let shutdown_clone = shutdown.clone();
472 spawn(async move {
473 shutdown_signal().await;
474 info!("shutdown signal received, draining in-flight runs...");
475 shutdown_clone.cancel();
476 });
477
478 #[cfg(feature = "heartbeat")]
479 if let Some(ref url) = self.heartbeat_url {
480 let interval = self.heartbeat_interval;
481 let url = url.clone();
482 let client = self.heartbeat_client.clone();
483
484 spawn(async move {
485 let mut ticker = tokio::time::interval(interval);
486 ticker.tick().await;
488 loop {
489 ticker.tick().await;
490 match client.head(&url).send().await {
491 Ok(resp) if resp.status().is_success() => {
492 info!(url = %url, "heartbeat sent");
493 }
494 Ok(resp) => {
495 warn!(
496 url = %url,
497 status = %resp.status(),
498 "heartbeat ping returned non-success status"
499 );
500 }
501 Err(err) => {
502 warn!(
503 url = %url,
504 error = %err,
505 "heartbeat ping failed"
506 );
507 }
508 }
509 }
510 });
511 }
512
513 #[cfg(feature = "prometheus")]
514 let mut last_gauge_update = Instant::now();
515
516 while !shutdown.is_cancelled() {
517 while let Ok(outcome) = outcome_rx.try_recv() {
519 let mut tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
520 match outcome {
521 RunOutcome::Success(ref wf) => tracker.record_success(wf),
522 RunOutcome::LeaseLost(ref wf) => {
525 warn!(workflow = %wf, "run abandoned after losing its lease")
526 }
527 RunOutcome::Failed(ref wf) | RunOutcome::Timeout(ref wf) => {
528 if tracker.record_panic(wf) {
529 warn!(workflow = %wf, "workflow flagged as poison pill after consecutive failures");
530 }
531 }
532 RunOutcome::Panicked(ref wf) => {
533 if tracker.record_panic(wf) {
534 error!(workflow = %wf, "workflow flagged as poison pill after consecutive panics");
535 }
536 }
537 }
538 }
539
540 let run = self
541 .engine
542 .store()
543 .pick_next_pending(Some(LeaseRequest {
544 worker_id: self.worker_id.clone(),
545 ttl: self.lease_ttl,
546 }))
547 .await;
548
549 match run {
550 Ok(Some(run)) => {
551 #[cfg(feature = "prometheus")]
552 counter!(WORKER_POLLS_TOTAL, "result" => "hit").increment(1);
553
554 let is_blocked = {
556 let tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
557 tracker.is_blocked(&run.workflow_name)
558 };
559 if is_blocked {
560 warn!(
561 workflow = %run.workflow_name,
562 run_id = %run.id,
563 "skipping run: workflow flagged as poison pill, marking as failed"
564 );
565 if let Err(e) = self
566 .engine
567 .store()
568 .update_run_status(run.id, RunStatus::Failed)
569 .await
570 {
571 error!(run_id = %run.id, error = %e, "failed to mark poisoned run as failed");
572 }
573 continue;
574 }
575
576 let permit = semaphore
577 .clone()
578 .acquire_owned()
579 .await
580 .map_err(|_| WorkerError::Internal("semaphore closed".to_string()))?;
581
582 idle_streak = 0;
583 let engine = self.engine.clone();
584 let run_id = run.id;
585 let workflow = run.workflow_name.clone();
586 let workflow_for_watcher = workflow.clone();
587 let run_timeout = self.run_timeout;
588
589 info!(run_id = %run_id, workflow = %workflow, "executing run");
590
591 #[cfg(feature = "prometheus")]
592 gauge!(WORKER_ACTIVE).increment(1.0);
593
594 let lease_token = CancellationToken::new();
597 let refresher = spawn(refresh_lease(
598 self.engine.store().clone(),
599 run_id,
600 LeaseRequest {
601 worker_id: self.worker_id.clone(),
602 ttl: self.lease_ttl,
603 },
604 self.lease_refresh_interval,
605 lease_token.clone(),
606 ));
607
608 let handle = spawn(async move {
609 let _permit = permit;
610 let result = tokio::select! {
611 biased;
612 _ = lease_token.cancelled() => {
613 refresher.abort();
614 warn!(
617 run_id = %run_id,
618 workflow = %workflow,
619 "abandoning run: worker lease lost"
620 );
621 #[cfg(feature = "prometheus")]
622 counter!(WORKER_LEASES_LOST_TOTAL).increment(1);
623 return RunOutcome::LeaseLost(workflow);
625 }
626 result = timeout(run_timeout, engine.execute_handler_run(run_id)) => result,
627 };
628 refresher.abort();
629
630 match result {
631 Ok(Ok(_)) => {
632 info!(run_id = %run_id, workflow = %workflow, "run completed");
633 RunOutcome::Success(workflow)
634 }
635 Ok(Err(e)) => {
643 error!(run_id = %run_id, workflow = %workflow, error = %e, "run failed");
644 RunOutcome::Failed(workflow)
645 }
646 Err(_) => {
647 error!(
648 run_id = %run_id,
649 workflow = %workflow,
650 timeout_secs = run_timeout.as_secs(),
651 "run timed out"
652 );
653 let timeout_msg =
654 format!("run timed out after {}s", run_timeout.as_secs());
655 if let Err(e) = engine
657 .fail_or_schedule_retry(run_id, &timeout_msg, true, None, None)
658 .await
659 {
660 error!(run_id = %run_id, error = %e, "failed to record timed-out run");
661 }
662 RunOutcome::Timeout(workflow)
663 }
664 }
665 });
666
667 let watcher_engine = self.engine.clone();
669 let tx = outcome_tx.clone();
670 spawn(async move {
671 match handle.await {
672 Ok(outcome) => {
673 let _ = tx.send(outcome);
674 }
675 Err(e) => {
676 error!(run_id = %run_id, "spawned task panicked: {e}");
677 if let Err(store_err) = watcher_engine
681 .fail_or_schedule_retry(
682 run_id,
683 "parent run panicked",
684 true,
685 None,
686 None,
687 )
688 .await
689 {
690 error!(run_id = %run_id, error = %store_err, "failed to record panicked run");
691 }
692 let _ = tx.send(RunOutcome::Panicked(workflow_for_watcher));
693 }
694 }
695 #[cfg(feature = "prometheus")]
696 gauge!(WORKER_ACTIVE).decrement(1.0);
697 });
698 }
699 Ok(None) => {
700 #[cfg(feature = "prometheus")]
701 counter!(WORKER_POLLS_TOTAL, "result" => "miss").increment(1);
702
703 idle_streak += 1;
704 let backoff = if idle_streak > 10 {
705 self.poll_interval * 3
706 } else if idle_streak > 5 {
707 self.poll_interval * 2
708 } else {
709 self.poll_interval
710 };
711 sleep(backoff).await;
712 }
713 Err(e) => {
714 warn!(error = %e, "poll error");
715 sleep(self.poll_interval).await;
716 }
717 }
718
719 #[cfg(feature = "prometheus")]
720 if last_gauge_update.elapsed() >= Duration::from_secs(5) {
721 if let Ok(stats) = self
722 .engine
723 .store()
724 .get_stats(RunFilter {
725 status: Some(RunStatus::Pending),
726 ..RunFilter::default()
727 })
728 .await
729 {
730 gauge!(WORKER_QUEUE_DEPTH).set(stats.total_runs as f64);
731 }
732 last_gauge_update = Instant::now();
733 }
734 }
735
736 info!(
738 in_flight = self.concurrency - semaphore.available_permits(),
739 "waiting for in-flight runs to complete..."
740 );
741 let _ = semaphore
742 .acquire_many(self.concurrency as u32)
743 .await
744 .map_err(|_| WorkerError::Shutdown("semaphore closed during drain".to_string()))?;
745
746 info!("all in-flight runs completed, worker shut down");
747 Ok(())
748 }
749}
750
751enum RunOutcome {
753 Success(String),
755 Failed(String),
757 Timeout(String),
759 Panicked(String),
761 LeaseLost(String),
763}
764
765async fn refresh_lease(
771 store: Arc<dyn Store>,
772 run_id: uuid::Uuid,
773 lease: LeaseRequest,
774 refresh_interval: Duration,
775 lease_token: CancellationToken,
776) {
777 let ttl = lease.ttl;
778 let mut deadline = Instant::now() + ttl;
779
780 loop {
781 sleep(refresh_interval).await;
782
783 match store.renew_lease(run_id, lease.clone()).await {
784 Ok(_) => {
785 deadline = Instant::now() + ttl;
786 }
787 Err(StoreError::LeaseLost { held_by, .. }) => {
788 warn!(
789 run_id = %run_id,
790 held_by = held_by.as_deref().unwrap_or("unknown"),
791 "lease taken over by another worker"
792 );
793 lease_token.cancel();
794 return;
795 }
796 Err(err) if Instant::now() >= deadline => {
797 warn!(
800 run_id = %run_id,
801 error = %err,
802 ttl_secs = ttl.as_secs(),
803 "lease could not be refreshed before it expired"
804 );
805 lease_token.cancel();
806 return;
807 }
808 Err(err) => {
809 warn!(run_id = %run_id, error = %err, "lease refresh failed, retrying");
810 }
811 }
812 }
813}
814
815async fn shutdown_signal() {
817 use tokio::signal;
818
819 let ctrl_c = async {
820 signal::ctrl_c()
821 .await
822 .expect("failed to install Ctrl+C handler");
823 };
824
825 #[cfg(unix)]
826 let terminate = async {
827 use tokio::signal::unix::{SignalKind, signal};
828
829 signal(SignalKind::terminate())
830 .expect("failed to install SIGTERM handler")
831 .recv()
832 .await;
833 };
834
835 #[cfg(not(unix))]
836 let terminate = {
837 use std::future::pending;
838 pending::<()>()
839 };
840
841 tokio::select! {
842 () = ctrl_c => {},
843 () = terminate => {},
844 }
845}
846
847#[cfg(test)]
848mod tests {
849 use super::*;
850 use ironflow_core::providers::claude::ClaudeCodeProvider;
851
852 #[test]
853 fn builder_new_creates_default_config() {
854 let builder = WorkerBuilder::new("http://localhost:3000", "my-token");
855 assert_eq!(builder.api_url, "http://localhost:3000");
856 assert_eq!(builder.worker_token, "my-token");
857 assert_eq!(builder.concurrency, DEFAULT_CONCURRENCY);
858 assert_eq!(builder.poll_interval, DEFAULT_POLL_INTERVAL);
859 assert_eq!(builder.run_timeout, DEFAULT_RUN_TIMEOUT);
860 assert_eq!(
861 builder.max_consecutive_panics,
862 DEFAULT_MAX_CONSECUTIVE_PANICS
863 );
864 assert_eq!(builder.panic_cooldown, DEFAULT_PANIC_COOLDOWN);
865 assert!(builder.provider.is_none());
866 }
867
868 #[test]
869 fn builder_with_trailing_slash_normalized() {
870 let builder = WorkerBuilder::new("http://localhost:3000/", "token");
871 assert_eq!(builder.api_url, "http://localhost:3000/");
872 }
873
874 #[test]
875 fn builder_provider_sets_provider() {
876 let provider = Arc::new(ClaudeCodeProvider::new());
877 let builder =
878 WorkerBuilder::new("http://localhost:3000", "token").provider(provider.clone());
879 assert!(builder.provider.is_some());
880 }
881
882 #[test]
883 fn builder_concurrency_sets_concurrency() {
884 let builder = WorkerBuilder::new("http://localhost:3000", "token").concurrency(8);
885 assert_eq!(builder.concurrency, 8);
886 }
887
888 #[test]
889 fn builder_concurrency_zero_accepted() {
890 let provider = Arc::new(ClaudeCodeProvider::new());
891 let builder = WorkerBuilder::new("http://localhost:3000", "token")
892 .provider(provider)
893 .concurrency(0);
894 assert_eq!(builder.concurrency, 0);
895 }
896
897 #[test]
898 fn builder_poll_interval_sets_interval() {
899 let interval = Duration::from_secs(5);
900 let builder = WorkerBuilder::new("http://localhost:3000", "token").poll_interval(interval);
901 assert_eq!(builder.poll_interval, interval);
902 }
903
904 #[test]
905 fn builder_run_timeout_sets_timeout() {
906 let dur = Duration::from_secs(120);
907 let builder = WorkerBuilder::new("http://localhost:3000", "token").run_timeout(dur);
908 assert_eq!(builder.run_timeout, dur);
909 }
910
911 #[test]
912 fn builder_max_consecutive_panics_sets_value() {
913 let builder =
914 WorkerBuilder::new("http://localhost:3000", "token").max_consecutive_panics(10);
915 assert_eq!(builder.max_consecutive_panics, 10);
916 }
917
918 #[test]
919 fn builder_panic_cooldown_sets_value() {
920 let dur = Duration::from_secs(600);
921 let builder = WorkerBuilder::new("http://localhost:3000", "token").panic_cooldown(dur);
922 assert_eq!(builder.panic_cooldown, dur);
923 }
924
925 #[test]
926 fn builder_defaults_lease_settings() {
927 let builder = WorkerBuilder::new("http://localhost:3000", "token");
928 assert_eq!(builder.lease_ttl, DEFAULT_LEASE_TTL);
929 assert_eq!(
930 builder.lease_refresh_interval,
931 DEFAULT_LEASE_REFRESH_INTERVAL
932 );
933 assert!(builder.worker_id.starts_with("worker-"));
934 }
935
936 #[test]
937 fn builder_generates_a_distinct_worker_id_per_instance() {
938 let a = WorkerBuilder::new("http://localhost:3000", "token");
939 let b = WorkerBuilder::new("http://localhost:3000", "token");
940 assert_ne!(a.worker_id, b.worker_id);
941 }
942
943 #[test]
944 fn builder_worker_id_overrides_default() {
945 let builder = WorkerBuilder::new("http://localhost:3000", "token").worker_id("worker-eu-1");
946 assert_eq!(builder.worker_id, "worker-eu-1");
947 }
948
949 #[test]
950 fn builder_lease_ttl_sets_value() {
951 let dur = Duration::from_secs(120);
952 let builder = WorkerBuilder::new("http://localhost:3000", "token").lease_ttl(dur);
953 assert_eq!(builder.lease_ttl, dur);
954 }
955
956 #[test]
957 fn builder_lease_refresh_interval_sets_value() {
958 let dur = Duration::from_secs(5);
959 let builder =
960 WorkerBuilder::new("http://localhost:3000", "token").lease_refresh_interval(dur);
961 assert_eq!(builder.lease_refresh_interval, dur);
962 }
963
964 #[test]
965 fn builder_build_without_provider_fails() {
966 let builder = WorkerBuilder::new("http://localhost:3000", "token");
967 let result = builder.build();
968 assert!(result.is_err());
969 match result {
970 Err(WorkerError::Internal(msg)) => {
971 assert!(msg.contains("provider is required"));
972 }
973 _ => panic!("expected Internal error about missing provider"),
974 }
975 }
976
977 #[test]
978 fn builder_build_with_provider_succeeds() {
979 let provider = Arc::new(ClaudeCodeProvider::new());
980 let builder = WorkerBuilder::new("http://localhost:3000", "token").provider(provider);
981 let result = builder.build();
982 assert!(result.is_ok());
983 }
984
985 #[test]
986 fn builder_build_creates_worker_with_correct_concurrency() {
987 let provider = Arc::new(ClaudeCodeProvider::new());
988 let builder = WorkerBuilder::new("http://localhost:3000", "token")
989 .provider(provider)
990 .concurrency(16);
991 let worker = builder.build().unwrap();
992 assert_eq!(worker.concurrency, 16);
993 }
994
995 #[test]
996 fn builder_build_creates_worker_with_correct_interval() {
997 let provider = Arc::new(ClaudeCodeProvider::new());
998 let interval = Duration::from_secs(10);
999 let builder = WorkerBuilder::new("http://localhost:3000", "token")
1000 .provider(provider)
1001 .poll_interval(interval);
1002 let worker = builder.build().unwrap();
1003 assert_eq!(worker.poll_interval, interval);
1004 }
1005
1006 #[test]
1007 fn builder_build_preserves_timeout() {
1008 let provider = Arc::new(ClaudeCodeProvider::new());
1009 let dur = Duration::from_secs(300);
1010 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1011 .provider(provider)
1012 .run_timeout(dur)
1013 .build()
1014 .unwrap();
1015 assert_eq!(worker.run_timeout, dur);
1016 }
1017
1018 #[test]
1019 fn builder_build_preserves_poison_pill_config() {
1020 let provider = Arc::new(ClaudeCodeProvider::new());
1021 let cooldown = Duration::from_secs(120);
1022 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1023 .provider(provider)
1024 .max_consecutive_panics(7)
1025 .panic_cooldown(cooldown)
1026 .build()
1027 .unwrap();
1028 assert_eq!(worker.max_consecutive_panics, 7);
1029 assert_eq!(worker.panic_cooldown, cooldown);
1030 }
1031
1032 #[test]
1033 fn builder_chaining_works() {
1034 let provider = Arc::new(ClaudeCodeProvider::new());
1035 let result = WorkerBuilder::new("http://localhost:3000", "token")
1036 .provider(provider)
1037 .concurrency(4)
1038 .poll_interval(Duration::from_secs(3))
1039 .run_timeout(Duration::from_secs(600))
1040 .max_consecutive_panics(5)
1041 .panic_cooldown(Duration::from_secs(120))
1042 .build();
1043 assert!(result.is_ok());
1044 let worker = result.unwrap();
1045 assert_eq!(worker.concurrency, 4);
1046 assert_eq!(worker.poll_interval, Duration::from_secs(3));
1047 assert_eq!(worker.run_timeout, Duration::from_secs(600));
1048 assert_eq!(worker.max_consecutive_panics, 5);
1049 assert_eq!(worker.panic_cooldown, Duration::from_secs(120));
1050 }
1051
1052 #[test]
1053 fn builder_empty_api_url_accepted() {
1054 let provider = Arc::new(ClaudeCodeProvider::new());
1055 let builder = WorkerBuilder::new("", "token").provider(provider);
1056 let result = builder.build();
1057 assert!(result.is_ok());
1058 }
1059
1060 #[test]
1061 fn builder_empty_token_accepted() {
1062 let provider = Arc::new(ClaudeCodeProvider::new());
1063 let builder = WorkerBuilder::new("http://localhost:3000", "").provider(provider);
1064 let result = builder.build();
1065 assert!(result.is_ok());
1066 }
1067
1068 #[cfg(feature = "heartbeat")]
1069 #[test]
1070 fn builder_heartbeat_defaults() {
1071 let builder = WorkerBuilder::new("http://localhost:3000", "token");
1072 assert!(builder.heartbeat_url.is_none());
1073 assert_eq!(builder.heartbeat_interval, DEFAULT_HEARTBEAT_INTERVAL);
1074 }
1075
1076 #[cfg(feature = "heartbeat")]
1077 #[test]
1078 fn builder_heartbeat_url_sets_url() {
1079 let builder = WorkerBuilder::new("http://localhost:3000", "token")
1080 .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc");
1081 assert_eq!(
1082 builder.heartbeat_url.as_deref(),
1083 Some("https://uptime.betterstack.com/api/v1/heartbeat/abc")
1084 );
1085 }
1086
1087 #[cfg(feature = "heartbeat")]
1088 #[test]
1089 fn builder_heartbeat_custom_interval() {
1090 let interval = Duration::from_secs(10);
1091 let builder =
1092 WorkerBuilder::new("http://localhost:3000", "token").heartbeat_interval(interval);
1093 assert_eq!(builder.heartbeat_interval, interval);
1094 }
1095
1096 #[cfg(feature = "heartbeat")]
1097 #[test]
1098 fn builder_build_preserves_heartbeat_config() {
1099 let provider = Arc::new(ClaudeCodeProvider::new());
1100 let interval = Duration::from_secs(15);
1101 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1102 .provider(provider)
1103 .heartbeat_url("https://example.com/heartbeat")
1104 .heartbeat_interval(interval)
1105 .build()
1106 .unwrap();
1107 assert_eq!(
1108 worker.heartbeat_url.as_deref(),
1109 Some("https://example.com/heartbeat")
1110 );
1111 assert_eq!(worker.heartbeat_interval, interval);
1112 }
1113
1114 #[cfg(feature = "heartbeat")]
1115 #[test]
1116 fn builder_build_without_heartbeat_url_has_none() {
1117 let provider = Arc::new(ClaudeCodeProvider::new());
1118 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1119 .provider(provider)
1120 .build()
1121 .unwrap();
1122 assert!(worker.heartbeat_url.is_none());
1123 }
1124
1125 #[test]
1128 fn poison_tracker_not_blocked_initially() {
1129 let tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1130 assert!(!tracker.is_blocked("my-workflow"));
1131 }
1132
1133 #[test]
1134 fn poison_tracker_blocked_after_max_panics() {
1135 let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1136 assert!(!tracker.record_panic("wf"));
1137 assert!(!tracker.record_panic("wf"));
1138 assert!(tracker.record_panic("wf"));
1139 assert!(tracker.is_blocked("wf"));
1140 }
1141
1142 #[test]
1143 fn poison_tracker_success_resets_count() {
1144 let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1145 tracker.record_panic("wf");
1146 tracker.record_panic("wf");
1147 tracker.record_success("wf");
1148 assert!(!tracker.is_blocked("wf"));
1149 assert!(!tracker.record_panic("wf"));
1151 }
1152
1153 #[test]
1154 fn poison_tracker_independent_per_workflow() {
1155 let mut tracker = PoisonPillTracker::new(2, Duration::from_secs(300));
1156 tracker.record_panic("wf-a");
1157 tracker.record_panic("wf-a");
1158 assert!(tracker.is_blocked("wf-a"));
1159 assert!(!tracker.is_blocked("wf-b"));
1160 }
1161
1162 #[test]
1163 fn poison_tracker_unblocks_after_cooldown() {
1164 let mut tracker = PoisonPillTracker::new(2, Duration::from_millis(0));
1165 tracker.record_panic("wf");
1166 tracker.record_panic("wf");
1167 assert!(!tracker.is_blocked("wf"));
1169 }
1170}