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
14use ironflow_core::decision::DecisionProvider;
15#[cfg(feature = "prometheus")]
16use ironflow_core::metric_names::{
17 WORKER_ACTIVE, WORKER_LEASES_LOST_TOTAL, WORKER_POLLS_TOTAL, WORKER_QUEUE_DEPTH,
18};
19use ironflow_core::provider::AgentProvider;
20use ironflow_engine::engine::Engine;
21use ironflow_engine::handler::WorkflowHandler;
22use ironflow_engine::log_sender::LogReceiver;
23#[cfg(feature = "prometheus")]
24use ironflow_store::entities::RunFilter;
25use ironflow_store::entities::{LeaseRequest, RunStatus};
26use ironflow_store::error::StoreError;
27use ironflow_store::store::Store;
28#[cfg(feature = "prometheus")]
29use metrics::{counter, gauge};
30#[cfg(feature = "heartbeat")]
31use reqwest::Client;
32
33use crate::api_store::ApiRunStore;
34use crate::artifact_sink::ApiArtifactSink;
35use crate::error::WorkerError;
36use crate::log_pusher::LogPusher;
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 handlers: Vec<Box<dyn WorkflowHandler>>,
81 concurrency: usize,
82 poll_interval: Duration,
83 run_timeout: Duration,
84 max_consecutive_panics: u32,
85 panic_cooldown: Duration,
86 lease_ttl: Duration,
87 lease_refresh_interval: Duration,
88 #[cfg(feature = "heartbeat")]
89 heartbeat_url: Option<String>,
90 #[cfg(feature = "heartbeat")]
91 heartbeat_interval: Duration,
92}
93
94impl WorkerBuilder {
95 pub fn new(api_url: &str, worker_token: &str) -> Self {
97 Self {
98 api_url: api_url.to_string(),
99 worker_token: worker_token.to_string(),
100 worker_id: format!("worker-{}", Uuid::now_v7()),
101 provider: None,
102 decision_provider: None,
103 handlers: Vec::new(),
104 concurrency: DEFAULT_CONCURRENCY,
105 poll_interval: DEFAULT_POLL_INTERVAL,
106 run_timeout: DEFAULT_RUN_TIMEOUT,
107 max_consecutive_panics: DEFAULT_MAX_CONSECUTIVE_PANICS,
108 panic_cooldown: DEFAULT_PANIC_COOLDOWN,
109 lease_ttl: DEFAULT_LEASE_TTL,
110 lease_refresh_interval: DEFAULT_LEASE_REFRESH_INTERVAL,
111 #[cfg(feature = "heartbeat")]
112 heartbeat_url: None,
113 #[cfg(feature = "heartbeat")]
114 heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
115 }
116 }
117
118 pub fn provider(mut self, provider: Arc<dyn AgentProvider>) -> Self {
120 self.provider = Some(provider);
121 self
122 }
123
124 pub fn decision_provider(mut self, provider: Arc<dyn DecisionProvider>) -> Self {
150 self.decision_provider = Some(provider);
151 self
152 }
153
154 pub fn register(mut self, handler: impl WorkflowHandler + 'static) -> Self {
156 self.handlers.push(Box::new(handler));
157 self
158 }
159
160 pub fn concurrency(mut self, n: usize) -> Self {
162 self.concurrency = n;
163 self
164 }
165
166 pub fn poll_interval(mut self, interval: Duration) -> Self {
168 self.poll_interval = interval;
169 self
170 }
171
172 pub fn run_timeout(mut self, timeout: Duration) -> Self {
189 self.run_timeout = timeout;
190 self
191 }
192
193 pub fn max_consecutive_panics(mut self, n: u32) -> Self {
211 self.max_consecutive_panics = n;
212 self
213 }
214
215 pub fn panic_cooldown(mut self, cooldown: Duration) -> Self {
232 self.panic_cooldown = cooldown;
233 self
234 }
235
236 pub fn worker_id(mut self, worker_id: &str) -> Self {
253 self.worker_id = worker_id.to_string();
254 self
255 }
256
257 pub fn lease_ttl(mut self, ttl: Duration) -> Self {
275 self.lease_ttl = ttl;
276 self
277 }
278
279 pub fn lease_refresh_interval(mut self, interval: Duration) -> Self {
296 self.lease_refresh_interval = interval;
297 self
298 }
299
300 #[cfg(feature = "heartbeat")]
319 pub fn heartbeat_url(mut self, url: &str) -> Self {
320 self.heartbeat_url = Some(url.to_string());
321 self
322 }
323
324 #[cfg(feature = "heartbeat")]
342 pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
343 self.heartbeat_interval = interval;
344 self
345 }
346
347 pub fn build(self) -> Result<Worker, WorkerError> {
354 let provider = self
355 .provider
356 .ok_or_else(|| WorkerError::Internal("WorkerBuilder: provider is required".into()))?;
357
358 let store: Arc<dyn Store> = Arc::new(ApiRunStore::new(&self.api_url, &self.worker_token));
359
360 let mut engine = Engine::new(store, provider);
361 if let Some(decision_provider) = self.decision_provider {
362 engine = engine.with_decision_provider(decision_provider);
363 }
364 for handler in self.handlers {
365 engine
366 .register_boxed(handler)
367 .map_err(WorkerError::Engine)?;
368 }
369
370 let (log_sender, log_receiver) = ironflow_engine::log_sender::channel();
371 engine.set_log_sender(log_sender);
372
373 engine.set_artifact_sink(Arc::new(ApiArtifactSink::new(
377 &self.api_url,
378 &self.worker_token,
379 )));
380
381 #[cfg(feature = "heartbeat")]
382 let heartbeat_client = Client::builder()
383 .timeout(Duration::from_secs(5))
384 .build()
385 .expect("failed to build heartbeat HTTP client");
386
387 Ok(Worker {
388 engine: Arc::new(engine),
389 api_url: self.api_url,
390 worker_token: self.worker_token,
391 worker_id: self.worker_id,
392 log_receiver: Mutex::new(Some(log_receiver)),
393 concurrency: self.concurrency,
394 poll_interval: self.poll_interval,
395 run_timeout: self.run_timeout,
396 max_consecutive_panics: self.max_consecutive_panics,
397 panic_cooldown: self.panic_cooldown,
398 lease_ttl: self.lease_ttl,
399 lease_refresh_interval: self.lease_refresh_interval,
400 #[cfg(feature = "heartbeat")]
401 heartbeat_url: self.heartbeat_url,
402 #[cfg(feature = "heartbeat")]
403 heartbeat_interval: self.heartbeat_interval,
404 #[cfg(feature = "heartbeat")]
405 heartbeat_client,
406 })
407 }
408}
409
410pub struct Worker {
412 engine: Arc<Engine>,
413 api_url: String,
414 worker_token: String,
415 worker_id: String,
416 log_receiver: Mutex<Option<LogReceiver>>,
417 concurrency: usize,
418 poll_interval: Duration,
419 run_timeout: Duration,
420 max_consecutive_panics: u32,
421 panic_cooldown: Duration,
422 lease_ttl: Duration,
423 lease_refresh_interval: Duration,
424 #[cfg(feature = "heartbeat")]
425 heartbeat_url: Option<String>,
426 #[cfg(feature = "heartbeat")]
427 heartbeat_interval: Duration,
428 #[cfg(feature = "heartbeat")]
429 heartbeat_client: Client,
430}
431
432struct PoisonPillTracker {
434 max_consecutive: u32,
435 cooldown: Duration,
436 state: HashMap<String, (u32, Instant)>,
438}
439
440impl PoisonPillTracker {
441 fn new(max_consecutive: u32, cooldown: Duration) -> Self {
442 Self {
443 max_consecutive,
444 cooldown,
445 state: HashMap::new(),
446 }
447 }
448
449 fn record_panic(&mut self, workflow: &str) -> bool {
452 let entry = self
453 .state
454 .entry(workflow.to_string())
455 .or_insert((0, Instant::now()));
456 entry.0 += 1;
457 entry.1 = Instant::now();
458 entry.0 >= self.max_consecutive
459 }
460
461 fn record_success(&mut self, workflow: &str) {
463 self.state.remove(workflow);
464 }
465
466 fn is_blocked(&self, workflow: &str) -> bool {
468 self.state.get(workflow).is_some_and(|(count, last_panic)| {
469 *count >= self.max_consecutive && last_panic.elapsed() < self.cooldown
470 })
471 }
472}
473
474impl Worker {
475 pub async fn run(&self) -> Result<(), WorkerError> {
484 let semaphore = Arc::new(Semaphore::new(self.concurrency));
485 let shutdown = CancellationToken::new();
486 let mut idle_streak = 0u32;
487 let poison_tracker = Arc::new(Mutex::new(PoisonPillTracker::new(
488 self.max_consecutive_panics,
489 self.panic_cooldown,
490 )));
491 let (outcome_tx, mut outcome_rx) = mpsc::unbounded_channel::<RunOutcome>();
492
493 info!(
494 concurrency = self.concurrency,
495 poll_interval_ms = self.poll_interval.as_millis() as u64,
496 run_timeout_secs = self.run_timeout.as_secs(),
497 "worker started"
498 );
499
500 if let Some(receiver) = self.log_receiver.lock().expect("log_receiver lock").take() {
501 let pusher = LogPusher::new(&self.api_url, &self.worker_token);
502 spawn(pusher.run(receiver));
503 info!("log pusher started");
504 }
505
506 let shutdown_clone = shutdown.clone();
508 spawn(async move {
509 shutdown_signal().await;
510 info!("shutdown signal received, draining in-flight runs...");
511 shutdown_clone.cancel();
512 });
513
514 #[cfg(feature = "heartbeat")]
515 if let Some(ref url) = self.heartbeat_url {
516 let interval = self.heartbeat_interval;
517 let url = url.clone();
518 let client = self.heartbeat_client.clone();
519
520 spawn(async move {
521 let mut ticker = tokio::time::interval(interval);
522 ticker.tick().await;
524 loop {
525 ticker.tick().await;
526 match client.head(&url).send().await {
527 Ok(resp) if resp.status().is_success() => {
528 info!(url = %url, "heartbeat sent");
529 }
530 Ok(resp) => {
531 warn!(
532 url = %url,
533 status = %resp.status(),
534 "heartbeat ping returned non-success status"
535 );
536 }
537 Err(err) => {
538 warn!(
539 url = %url,
540 error = %err,
541 "heartbeat ping failed"
542 );
543 }
544 }
545 }
546 });
547 }
548
549 #[cfg(feature = "prometheus")]
550 let mut last_gauge_update = Instant::now();
551
552 while !shutdown.is_cancelled() {
553 while let Ok(outcome) = outcome_rx.try_recv() {
555 let mut tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
556 match outcome {
557 RunOutcome::Success(ref wf) => tracker.record_success(wf),
558 RunOutcome::LeaseLost(ref wf) => {
561 warn!(workflow = %wf, "run abandoned after losing its lease")
562 }
563 RunOutcome::Failed(ref wf) | RunOutcome::Timeout(ref wf) => {
564 if tracker.record_panic(wf) {
565 warn!(workflow = %wf, "workflow flagged as poison pill after consecutive failures");
566 }
567 }
568 RunOutcome::Panicked(ref wf) => {
569 if tracker.record_panic(wf) {
570 error!(workflow = %wf, "workflow flagged as poison pill after consecutive panics");
571 }
572 }
573 }
574 }
575
576 let run = self
577 .engine
578 .store()
579 .pick_next_pending(Some(LeaseRequest {
580 worker_id: self.worker_id.clone(),
581 ttl: self.lease_ttl,
582 }))
583 .await;
584
585 match run {
586 Ok(Some(run)) => {
587 #[cfg(feature = "prometheus")]
588 counter!(WORKER_POLLS_TOTAL, "result" => "hit").increment(1);
589
590 let is_blocked = {
592 let tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
593 tracker.is_blocked(&run.workflow_name)
594 };
595 if is_blocked {
596 warn!(
597 workflow = %run.workflow_name,
598 run_id = %run.id,
599 "skipping run: workflow flagged as poison pill, marking as failed"
600 );
601 if let Err(e) = self
602 .engine
603 .store()
604 .update_run_status(run.id, RunStatus::Failed)
605 .await
606 {
607 error!(run_id = %run.id, error = %e, "failed to mark poisoned run as failed");
608 }
609 continue;
610 }
611
612 let permit = semaphore
613 .clone()
614 .acquire_owned()
615 .await
616 .map_err(|_| WorkerError::Internal("semaphore closed".to_string()))?;
617
618 idle_streak = 0;
619 let engine = self.engine.clone();
620 let run_id = run.id;
621 let workflow = run.workflow_name.clone();
622 let workflow_for_watcher = workflow.clone();
623 let run_timeout = self.run_timeout;
624
625 info!(run_id = %run_id, workflow = %workflow, "executing run");
626
627 #[cfg(feature = "prometheus")]
628 gauge!(WORKER_ACTIVE).increment(1.0);
629
630 let lease_token = CancellationToken::new();
633 let refresher = spawn(refresh_lease(
634 self.engine.store().clone(),
635 run_id,
636 LeaseRequest {
637 worker_id: self.worker_id.clone(),
638 ttl: self.lease_ttl,
639 },
640 self.lease_refresh_interval,
641 lease_token.clone(),
642 ));
643
644 let handle = spawn(async move {
645 let _permit = permit;
646 let result = tokio::select! {
647 biased;
648 _ = lease_token.cancelled() => {
649 refresher.abort();
650 warn!(
653 run_id = %run_id,
654 workflow = %workflow,
655 "abandoning run: worker lease lost"
656 );
657 #[cfg(feature = "prometheus")]
658 counter!(WORKER_LEASES_LOST_TOTAL).increment(1);
659 return RunOutcome::LeaseLost(workflow);
661 }
662 result = timeout(run_timeout, engine.execute_handler_run(run_id)) => result,
663 };
664 refresher.abort();
665
666 match result {
667 Ok(Ok(_)) => {
668 info!(run_id = %run_id, workflow = %workflow, "run completed");
669 RunOutcome::Success(workflow)
670 }
671 Ok(Err(e)) => {
679 error!(run_id = %run_id, workflow = %workflow, error = %e, "run failed");
680 RunOutcome::Failed(workflow)
681 }
682 Err(_) => {
683 error!(
684 run_id = %run_id,
685 workflow = %workflow,
686 timeout_secs = run_timeout.as_secs(),
687 "run timed out"
688 );
689 let timeout_msg =
690 format!("run timed out after {}s", run_timeout.as_secs());
691 if let Err(e) = engine
693 .fail_or_schedule_retry(run_id, &timeout_msg, true, None, None)
694 .await
695 {
696 error!(run_id = %run_id, error = %e, "failed to record timed-out run");
697 }
698 RunOutcome::Timeout(workflow)
699 }
700 }
701 });
702
703 let watcher_engine = self.engine.clone();
705 let tx = outcome_tx.clone();
706 spawn(async move {
707 match handle.await {
708 Ok(outcome) => {
709 let _ = tx.send(outcome);
710 }
711 Err(e) => {
712 error!(run_id = %run_id, "spawned task panicked: {e}");
713 if let Err(store_err) = watcher_engine
717 .fail_or_schedule_retry(
718 run_id,
719 "parent run panicked",
720 true,
721 None,
722 None,
723 )
724 .await
725 {
726 error!(run_id = %run_id, error = %store_err, "failed to record panicked run");
727 }
728 let _ = tx.send(RunOutcome::Panicked(workflow_for_watcher));
729 }
730 }
731 #[cfg(feature = "prometheus")]
732 gauge!(WORKER_ACTIVE).decrement(1.0);
733 });
734 }
735 Ok(None) => {
736 #[cfg(feature = "prometheus")]
737 counter!(WORKER_POLLS_TOTAL, "result" => "miss").increment(1);
738
739 idle_streak += 1;
740 let backoff = if idle_streak > 10 {
741 self.poll_interval * 3
742 } else if idle_streak > 5 {
743 self.poll_interval * 2
744 } else {
745 self.poll_interval
746 };
747 sleep(backoff).await;
748 }
749 Err(e) => {
750 warn!(error = %e, "poll error");
751 sleep(self.poll_interval).await;
752 }
753 }
754
755 #[cfg(feature = "prometheus")]
756 if last_gauge_update.elapsed() >= Duration::from_secs(5) {
757 if let Ok(stats) = self
758 .engine
759 .store()
760 .get_stats(RunFilter {
761 status: Some(RunStatus::Pending),
762 ..RunFilter::default()
763 })
764 .await
765 {
766 gauge!(WORKER_QUEUE_DEPTH).set(stats.total_runs as f64);
767 }
768 last_gauge_update = Instant::now();
769 }
770 }
771
772 info!(
774 in_flight = self.concurrency - semaphore.available_permits(),
775 "waiting for in-flight runs to complete..."
776 );
777 let _ = semaphore
778 .acquire_many(self.concurrency as u32)
779 .await
780 .map_err(|_| WorkerError::Shutdown("semaphore closed during drain".to_string()))?;
781
782 info!("all in-flight runs completed, worker shut down");
783 Ok(())
784 }
785}
786
787enum RunOutcome {
789 Success(String),
791 Failed(String),
793 Timeout(String),
795 Panicked(String),
797 LeaseLost(String),
799}
800
801async fn refresh_lease(
807 store: Arc<dyn Store>,
808 run_id: uuid::Uuid,
809 lease: LeaseRequest,
810 refresh_interval: Duration,
811 lease_token: CancellationToken,
812) {
813 let ttl = lease.ttl;
814 let mut deadline = Instant::now() + ttl;
815
816 loop {
817 sleep(refresh_interval).await;
818
819 match store.renew_lease(run_id, lease.clone()).await {
820 Ok(_) => {
821 deadline = Instant::now() + ttl;
822 }
823 Err(StoreError::LeaseLost { held_by, .. }) => {
824 warn!(
825 run_id = %run_id,
826 held_by = held_by.as_deref().unwrap_or("unknown"),
827 "lease taken over by another worker"
828 );
829 lease_token.cancel();
830 return;
831 }
832 Err(err) if Instant::now() >= deadline => {
833 warn!(
836 run_id = %run_id,
837 error = %err,
838 ttl_secs = ttl.as_secs(),
839 "lease could not be refreshed before it expired"
840 );
841 lease_token.cancel();
842 return;
843 }
844 Err(err) => {
845 warn!(run_id = %run_id, error = %err, "lease refresh failed, retrying");
846 }
847 }
848 }
849}
850
851async fn shutdown_signal() {
853 use tokio::signal;
854
855 let ctrl_c = async {
856 signal::ctrl_c()
857 .await
858 .expect("failed to install Ctrl+C handler");
859 };
860
861 #[cfg(unix)]
862 let terminate = async {
863 use tokio::signal::unix::{SignalKind, signal};
864
865 signal(SignalKind::terminate())
866 .expect("failed to install SIGTERM handler")
867 .recv()
868 .await;
869 };
870
871 #[cfg(not(unix))]
872 let terminate = {
873 use std::future::pending;
874 pending::<()>()
875 };
876
877 tokio::select! {
878 () = ctrl_c => {},
879 () = terminate => {},
880 }
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886 use ironflow_core::providers::claude::ClaudeCodeProvider;
887 use ironflow_core::providers::record_replay_decision::RecordReplayDecisionProvider;
888
889 #[test]
890 fn builder_new_creates_default_config() {
891 let builder = WorkerBuilder::new("http://localhost:3000", "my-token");
892 assert_eq!(builder.api_url, "http://localhost:3000");
893 assert_eq!(builder.worker_token, "my-token");
894 assert_eq!(builder.concurrency, DEFAULT_CONCURRENCY);
895 assert_eq!(builder.poll_interval, DEFAULT_POLL_INTERVAL);
896 assert_eq!(builder.run_timeout, DEFAULT_RUN_TIMEOUT);
897 assert_eq!(
898 builder.max_consecutive_panics,
899 DEFAULT_MAX_CONSECUTIVE_PANICS
900 );
901 assert_eq!(builder.panic_cooldown, DEFAULT_PANIC_COOLDOWN);
902 assert!(builder.provider.is_none());
903 }
904
905 #[test]
906 fn builder_with_trailing_slash_normalized() {
907 let builder = WorkerBuilder::new("http://localhost:3000/", "token");
908 assert_eq!(builder.api_url, "http://localhost:3000/");
909 }
910
911 #[test]
912 fn builder_provider_sets_provider() {
913 let provider = Arc::new(ClaudeCodeProvider::new());
914 let builder =
915 WorkerBuilder::new("http://localhost:3000", "token").provider(provider.clone());
916 assert!(builder.provider.is_some());
917 }
918
919 #[test]
920 fn builder_decision_provider_defaults_to_none() {
921 let builder = WorkerBuilder::new("http://localhost:3000", "token");
922 assert!(builder.decision_provider.is_none());
923 }
924
925 #[test]
926 fn builder_decision_provider_sets_provider() {
927 let builder = WorkerBuilder::new("http://localhost:3000", "token").decision_provider(
928 Arc::new(RecordReplayDecisionProvider::replay("tests/fixtures")),
929 );
930 assert!(builder.decision_provider.is_some());
931 }
932
933 #[test]
934 fn builder_concurrency_sets_concurrency() {
935 let builder = WorkerBuilder::new("http://localhost:3000", "token").concurrency(8);
936 assert_eq!(builder.concurrency, 8);
937 }
938
939 #[test]
940 fn builder_concurrency_zero_accepted() {
941 let provider = Arc::new(ClaudeCodeProvider::new());
942 let builder = WorkerBuilder::new("http://localhost:3000", "token")
943 .provider(provider)
944 .concurrency(0);
945 assert_eq!(builder.concurrency, 0);
946 }
947
948 #[test]
949 fn builder_poll_interval_sets_interval() {
950 let interval = Duration::from_secs(5);
951 let builder = WorkerBuilder::new("http://localhost:3000", "token").poll_interval(interval);
952 assert_eq!(builder.poll_interval, interval);
953 }
954
955 #[test]
956 fn builder_run_timeout_sets_timeout() {
957 let dur = Duration::from_secs(120);
958 let builder = WorkerBuilder::new("http://localhost:3000", "token").run_timeout(dur);
959 assert_eq!(builder.run_timeout, dur);
960 }
961
962 #[test]
963 fn builder_max_consecutive_panics_sets_value() {
964 let builder =
965 WorkerBuilder::new("http://localhost:3000", "token").max_consecutive_panics(10);
966 assert_eq!(builder.max_consecutive_panics, 10);
967 }
968
969 #[test]
970 fn builder_panic_cooldown_sets_value() {
971 let dur = Duration::from_secs(600);
972 let builder = WorkerBuilder::new("http://localhost:3000", "token").panic_cooldown(dur);
973 assert_eq!(builder.panic_cooldown, dur);
974 }
975
976 #[test]
977 fn builder_defaults_lease_settings() {
978 let builder = WorkerBuilder::new("http://localhost:3000", "token");
979 assert_eq!(builder.lease_ttl, DEFAULT_LEASE_TTL);
980 assert_eq!(
981 builder.lease_refresh_interval,
982 DEFAULT_LEASE_REFRESH_INTERVAL
983 );
984 assert!(builder.worker_id.starts_with("worker-"));
985 }
986
987 #[test]
988 fn builder_generates_a_distinct_worker_id_per_instance() {
989 let a = WorkerBuilder::new("http://localhost:3000", "token");
990 let b = WorkerBuilder::new("http://localhost:3000", "token");
991 assert_ne!(a.worker_id, b.worker_id);
992 }
993
994 #[test]
995 fn builder_worker_id_overrides_default() {
996 let builder = WorkerBuilder::new("http://localhost:3000", "token").worker_id("worker-eu-1");
997 assert_eq!(builder.worker_id, "worker-eu-1");
998 }
999
1000 #[test]
1001 fn builder_lease_ttl_sets_value() {
1002 let dur = Duration::from_secs(120);
1003 let builder = WorkerBuilder::new("http://localhost:3000", "token").lease_ttl(dur);
1004 assert_eq!(builder.lease_ttl, dur);
1005 }
1006
1007 #[test]
1008 fn builder_lease_refresh_interval_sets_value() {
1009 let dur = Duration::from_secs(5);
1010 let builder =
1011 WorkerBuilder::new("http://localhost:3000", "token").lease_refresh_interval(dur);
1012 assert_eq!(builder.lease_refresh_interval, dur);
1013 }
1014
1015 #[test]
1016 fn builder_build_without_provider_fails() {
1017 let builder = WorkerBuilder::new("http://localhost:3000", "token");
1018 let result = builder.build();
1019 assert!(result.is_err());
1020 match result {
1021 Err(WorkerError::Internal(msg)) => {
1022 assert!(msg.contains("provider is required"));
1023 }
1024 _ => panic!("expected Internal error about missing provider"),
1025 }
1026 }
1027
1028 #[test]
1029 fn builder_build_with_provider_succeeds() {
1030 let provider = Arc::new(ClaudeCodeProvider::new());
1031 let builder = WorkerBuilder::new("http://localhost:3000", "token").provider(provider);
1032 let result = builder.build();
1033 assert!(result.is_ok());
1034 }
1035
1036 #[test]
1037 fn builder_build_creates_worker_with_correct_concurrency() {
1038 let provider = Arc::new(ClaudeCodeProvider::new());
1039 let builder = WorkerBuilder::new("http://localhost:3000", "token")
1040 .provider(provider)
1041 .concurrency(16);
1042 let worker = builder.build().unwrap();
1043 assert_eq!(worker.concurrency, 16);
1044 }
1045
1046 #[test]
1047 fn builder_build_creates_worker_with_correct_interval() {
1048 let provider = Arc::new(ClaudeCodeProvider::new());
1049 let interval = Duration::from_secs(10);
1050 let builder = WorkerBuilder::new("http://localhost:3000", "token")
1051 .provider(provider)
1052 .poll_interval(interval);
1053 let worker = builder.build().unwrap();
1054 assert_eq!(worker.poll_interval, interval);
1055 }
1056
1057 #[test]
1058 fn builder_build_preserves_timeout() {
1059 let provider = Arc::new(ClaudeCodeProvider::new());
1060 let dur = Duration::from_secs(300);
1061 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1062 .provider(provider)
1063 .run_timeout(dur)
1064 .build()
1065 .unwrap();
1066 assert_eq!(worker.run_timeout, dur);
1067 }
1068
1069 #[test]
1070 fn builder_build_preserves_poison_pill_config() {
1071 let provider = Arc::new(ClaudeCodeProvider::new());
1072 let cooldown = Duration::from_secs(120);
1073 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1074 .provider(provider)
1075 .max_consecutive_panics(7)
1076 .panic_cooldown(cooldown)
1077 .build()
1078 .unwrap();
1079 assert_eq!(worker.max_consecutive_panics, 7);
1080 assert_eq!(worker.panic_cooldown, cooldown);
1081 }
1082
1083 #[test]
1084 fn builder_chaining_works() {
1085 let provider = Arc::new(ClaudeCodeProvider::new());
1086 let result = WorkerBuilder::new("http://localhost:3000", "token")
1087 .provider(provider)
1088 .concurrency(4)
1089 .poll_interval(Duration::from_secs(3))
1090 .run_timeout(Duration::from_secs(600))
1091 .max_consecutive_panics(5)
1092 .panic_cooldown(Duration::from_secs(120))
1093 .build();
1094 assert!(result.is_ok());
1095 let worker = result.unwrap();
1096 assert_eq!(worker.concurrency, 4);
1097 assert_eq!(worker.poll_interval, Duration::from_secs(3));
1098 assert_eq!(worker.run_timeout, Duration::from_secs(600));
1099 assert_eq!(worker.max_consecutive_panics, 5);
1100 assert_eq!(worker.panic_cooldown, Duration::from_secs(120));
1101 }
1102
1103 #[test]
1104 fn builder_empty_api_url_accepted() {
1105 let provider = Arc::new(ClaudeCodeProvider::new());
1106 let builder = WorkerBuilder::new("", "token").provider(provider);
1107 let result = builder.build();
1108 assert!(result.is_ok());
1109 }
1110
1111 #[test]
1112 fn builder_empty_token_accepted() {
1113 let provider = Arc::new(ClaudeCodeProvider::new());
1114 let builder = WorkerBuilder::new("http://localhost:3000", "").provider(provider);
1115 let result = builder.build();
1116 assert!(result.is_ok());
1117 }
1118
1119 #[cfg(feature = "heartbeat")]
1120 #[test]
1121 fn builder_heartbeat_defaults() {
1122 let builder = WorkerBuilder::new("http://localhost:3000", "token");
1123 assert!(builder.heartbeat_url.is_none());
1124 assert_eq!(builder.heartbeat_interval, DEFAULT_HEARTBEAT_INTERVAL);
1125 }
1126
1127 #[cfg(feature = "heartbeat")]
1128 #[test]
1129 fn builder_heartbeat_url_sets_url() {
1130 let builder = WorkerBuilder::new("http://localhost:3000", "token")
1131 .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc");
1132 assert_eq!(
1133 builder.heartbeat_url.as_deref(),
1134 Some("https://uptime.betterstack.com/api/v1/heartbeat/abc")
1135 );
1136 }
1137
1138 #[cfg(feature = "heartbeat")]
1139 #[test]
1140 fn builder_heartbeat_custom_interval() {
1141 let interval = Duration::from_secs(10);
1142 let builder =
1143 WorkerBuilder::new("http://localhost:3000", "token").heartbeat_interval(interval);
1144 assert_eq!(builder.heartbeat_interval, interval);
1145 }
1146
1147 #[cfg(feature = "heartbeat")]
1148 #[test]
1149 fn builder_build_preserves_heartbeat_config() {
1150 let provider = Arc::new(ClaudeCodeProvider::new());
1151 let interval = Duration::from_secs(15);
1152 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1153 .provider(provider)
1154 .heartbeat_url("https://example.com/heartbeat")
1155 .heartbeat_interval(interval)
1156 .build()
1157 .unwrap();
1158 assert_eq!(
1159 worker.heartbeat_url.as_deref(),
1160 Some("https://example.com/heartbeat")
1161 );
1162 assert_eq!(worker.heartbeat_interval, interval);
1163 }
1164
1165 #[cfg(feature = "heartbeat")]
1166 #[test]
1167 fn builder_build_without_heartbeat_url_has_none() {
1168 let provider = Arc::new(ClaudeCodeProvider::new());
1169 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1170 .provider(provider)
1171 .build()
1172 .unwrap();
1173 assert!(worker.heartbeat_url.is_none());
1174 }
1175
1176 #[test]
1179 fn poison_tracker_not_blocked_initially() {
1180 let tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1181 assert!(!tracker.is_blocked("my-workflow"));
1182 }
1183
1184 #[test]
1185 fn poison_tracker_blocked_after_max_panics() {
1186 let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1187 assert!(!tracker.record_panic("wf"));
1188 assert!(!tracker.record_panic("wf"));
1189 assert!(tracker.record_panic("wf"));
1190 assert!(tracker.is_blocked("wf"));
1191 }
1192
1193 #[test]
1194 fn poison_tracker_success_resets_count() {
1195 let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1196 tracker.record_panic("wf");
1197 tracker.record_panic("wf");
1198 tracker.record_success("wf");
1199 assert!(!tracker.is_blocked("wf"));
1200 assert!(!tracker.record_panic("wf"));
1202 }
1203
1204 #[test]
1205 fn poison_tracker_independent_per_workflow() {
1206 let mut tracker = PoisonPillTracker::new(2, Duration::from_secs(300));
1207 tracker.record_panic("wf-a");
1208 tracker.record_panic("wf-a");
1209 assert!(tracker.is_blocked("wf-a"));
1210 assert!(!tracker.is_blocked("wf-b"));
1211 }
1212
1213 #[test]
1214 fn poison_tracker_unblocks_after_cooldown() {
1215 let mut tracker = PoisonPillTracker::new(2, Duration::from_millis(0));
1216 tracker.record_panic("wf");
1217 tracker.record_panic("wf");
1218 assert!(!tracker.is_blocked("wf"));
1220 }
1221}