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};
12
13#[cfg(feature = "prometheus")]
14use ironflow_core::metric_names::{WORKER_ACTIVE, WORKER_POLLS_TOTAL};
15use ironflow_core::provider::AgentProvider;
16use ironflow_engine::engine::Engine;
17use ironflow_engine::error::EngineError;
18use ironflow_engine::handler::WorkflowHandler;
19use ironflow_engine::log_sender::LogReceiver;
20use ironflow_store::entities::{RunStatus, RunUpdate};
21use ironflow_store::store::Store;
22#[cfg(feature = "prometheus")]
23use metrics::{counter, gauge};
24#[cfg(feature = "heartbeat")]
25use reqwest::Client;
26
27use crate::api_store::ApiRunStore;
28use crate::error::WorkerError;
29use crate::log_pusher::LogPusher;
30
31const DEFAULT_CONCURRENCY: usize = 2;
32const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
33const DEFAULT_RUN_TIMEOUT: Duration = Duration::from_secs(30 * 60);
34const DEFAULT_MAX_CONSECUTIVE_PANICS: u32 = 3;
35const DEFAULT_PANIC_COOLDOWN: Duration = Duration::from_secs(5 * 60);
36#[cfg(feature = "heartbeat")]
37const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
38
39pub struct WorkerBuilder {
63 api_url: String,
64 worker_token: String,
65 provider: Option<Arc<dyn AgentProvider>>,
66 handlers: Vec<Box<dyn WorkflowHandler>>,
67 concurrency: usize,
68 poll_interval: Duration,
69 run_timeout: Duration,
70 max_consecutive_panics: u32,
71 panic_cooldown: Duration,
72 #[cfg(feature = "heartbeat")]
73 heartbeat_url: Option<String>,
74 #[cfg(feature = "heartbeat")]
75 heartbeat_interval: Duration,
76}
77
78impl WorkerBuilder {
79 pub fn new(api_url: &str, worker_token: &str) -> Self {
81 Self {
82 api_url: api_url.to_string(),
83 worker_token: worker_token.to_string(),
84 provider: None,
85 handlers: Vec::new(),
86 concurrency: DEFAULT_CONCURRENCY,
87 poll_interval: DEFAULT_POLL_INTERVAL,
88 run_timeout: DEFAULT_RUN_TIMEOUT,
89 max_consecutive_panics: DEFAULT_MAX_CONSECUTIVE_PANICS,
90 panic_cooldown: DEFAULT_PANIC_COOLDOWN,
91 #[cfg(feature = "heartbeat")]
92 heartbeat_url: None,
93 #[cfg(feature = "heartbeat")]
94 heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
95 }
96 }
97
98 pub fn provider(mut self, provider: Arc<dyn AgentProvider>) -> Self {
100 self.provider = Some(provider);
101 self
102 }
103
104 pub fn register(mut self, handler: impl WorkflowHandler + 'static) -> Self {
106 self.handlers.push(Box::new(handler));
107 self
108 }
109
110 pub fn concurrency(mut self, n: usize) -> Self {
112 self.concurrency = n;
113 self
114 }
115
116 pub fn poll_interval(mut self, interval: Duration) -> Self {
118 self.poll_interval = interval;
119 self
120 }
121
122 pub fn run_timeout(mut self, timeout: Duration) -> Self {
139 self.run_timeout = timeout;
140 self
141 }
142
143 pub fn max_consecutive_panics(mut self, n: u32) -> Self {
161 self.max_consecutive_panics = n;
162 self
163 }
164
165 pub fn panic_cooldown(mut self, cooldown: Duration) -> Self {
182 self.panic_cooldown = cooldown;
183 self
184 }
185
186 #[cfg(feature = "heartbeat")]
205 pub fn heartbeat_url(mut self, url: &str) -> Self {
206 self.heartbeat_url = Some(url.to_string());
207 self
208 }
209
210 #[cfg(feature = "heartbeat")]
228 pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
229 self.heartbeat_interval = interval;
230 self
231 }
232
233 pub fn build(self) -> Result<Worker, WorkerError> {
240 let provider = self
241 .provider
242 .ok_or_else(|| WorkerError::Internal("WorkerBuilder: provider is required".into()))?;
243
244 let store: Arc<dyn Store> = Arc::new(ApiRunStore::new(&self.api_url, &self.worker_token));
245
246 let mut engine = Engine::new(store, provider);
247 for handler in self.handlers {
248 engine
249 .register_boxed(handler)
250 .map_err(WorkerError::Engine)?;
251 }
252
253 let (log_sender, log_receiver) = ironflow_engine::log_sender::channel();
254 engine.set_log_sender(log_sender);
255
256 #[cfg(feature = "heartbeat")]
257 let heartbeat_client = Client::builder()
258 .timeout(Duration::from_secs(5))
259 .build()
260 .expect("failed to build heartbeat HTTP client");
261
262 Ok(Worker {
263 engine: Arc::new(engine),
264 api_url: self.api_url,
265 worker_token: self.worker_token,
266 log_receiver: Mutex::new(Some(log_receiver)),
267 concurrency: self.concurrency,
268 poll_interval: self.poll_interval,
269 run_timeout: self.run_timeout,
270 max_consecutive_panics: self.max_consecutive_panics,
271 panic_cooldown: self.panic_cooldown,
272 #[cfg(feature = "heartbeat")]
273 heartbeat_url: self.heartbeat_url,
274 #[cfg(feature = "heartbeat")]
275 heartbeat_interval: self.heartbeat_interval,
276 #[cfg(feature = "heartbeat")]
277 heartbeat_client,
278 })
279 }
280}
281
282pub struct Worker {
284 engine: Arc<Engine>,
285 api_url: String,
286 worker_token: String,
287 log_receiver: Mutex<Option<LogReceiver>>,
288 concurrency: usize,
289 poll_interval: Duration,
290 run_timeout: Duration,
291 max_consecutive_panics: u32,
292 panic_cooldown: Duration,
293 #[cfg(feature = "heartbeat")]
294 heartbeat_url: Option<String>,
295 #[cfg(feature = "heartbeat")]
296 heartbeat_interval: Duration,
297 #[cfg(feature = "heartbeat")]
298 heartbeat_client: Client,
299}
300
301struct PoisonPillTracker {
303 max_consecutive: u32,
304 cooldown: Duration,
305 state: HashMap<String, (u32, Instant)>,
307}
308
309impl PoisonPillTracker {
310 fn new(max_consecutive: u32, cooldown: Duration) -> Self {
311 Self {
312 max_consecutive,
313 cooldown,
314 state: HashMap::new(),
315 }
316 }
317
318 fn record_panic(&mut self, workflow: &str) -> bool {
321 let entry = self
322 .state
323 .entry(workflow.to_string())
324 .or_insert((0, Instant::now()));
325 entry.0 += 1;
326 entry.1 = Instant::now();
327 entry.0 >= self.max_consecutive
328 }
329
330 fn record_success(&mut self, workflow: &str) {
332 self.state.remove(workflow);
333 }
334
335 fn is_blocked(&self, workflow: &str) -> bool {
337 self.state.get(workflow).is_some_and(|(count, last_panic)| {
338 *count >= self.max_consecutive && last_panic.elapsed() < self.cooldown
339 })
340 }
341}
342
343impl Worker {
344 pub async fn run(&self) -> Result<(), WorkerError> {
353 let semaphore = Arc::new(Semaphore::new(self.concurrency));
354 let shutdown = CancellationToken::new();
355 let mut idle_streak = 0u32;
356 let poison_tracker = Arc::new(Mutex::new(PoisonPillTracker::new(
357 self.max_consecutive_panics,
358 self.panic_cooldown,
359 )));
360 let (outcome_tx, mut outcome_rx) = mpsc::unbounded_channel::<RunOutcome>();
361
362 info!(
363 concurrency = self.concurrency,
364 poll_interval_ms = self.poll_interval.as_millis() as u64,
365 run_timeout_secs = self.run_timeout.as_secs(),
366 "worker started"
367 );
368
369 if let Some(receiver) = self.log_receiver.lock().expect("log_receiver lock").take() {
370 let pusher = LogPusher::new(&self.api_url, &self.worker_token);
371 spawn(pusher.run(receiver));
372 info!("log pusher started");
373 }
374
375 let shutdown_clone = shutdown.clone();
377 spawn(async move {
378 shutdown_signal().await;
379 info!("shutdown signal received, draining in-flight runs...");
380 shutdown_clone.cancel();
381 });
382
383 #[cfg(feature = "heartbeat")]
384 if let Some(ref url) = self.heartbeat_url {
385 let interval = self.heartbeat_interval;
386 let url = url.clone();
387 let client = self.heartbeat_client.clone();
388
389 spawn(async move {
390 let mut ticker = tokio::time::interval(interval);
391 ticker.tick().await;
393 loop {
394 ticker.tick().await;
395 match client.head(&url).send().await {
396 Ok(resp) if resp.status().is_success() => {
397 info!(url = %url, "heartbeat sent");
398 }
399 Ok(resp) => {
400 warn!(
401 url = %url,
402 status = %resp.status(),
403 "heartbeat ping returned non-success status"
404 );
405 }
406 Err(err) => {
407 warn!(
408 url = %url,
409 error = %err,
410 "heartbeat ping failed"
411 );
412 }
413 }
414 }
415 });
416 }
417
418 while !shutdown.is_cancelled() {
419 while let Ok(outcome) = outcome_rx.try_recv() {
421 let mut tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
422 match outcome {
423 RunOutcome::Success(ref wf) => tracker.record_success(wf),
424 RunOutcome::Failed(ref wf) | RunOutcome::Timeout(ref wf) => {
425 if tracker.record_panic(wf) {
426 warn!(workflow = %wf, "workflow flagged as poison pill after consecutive failures");
427 }
428 }
429 RunOutcome::Panicked(ref wf) => {
430 if tracker.record_panic(wf) {
431 error!(workflow = %wf, "workflow flagged as poison pill after consecutive panics");
432 }
433 }
434 }
435 }
436
437 let run = self.engine.store().pick_next_pending().await;
438
439 match run {
440 Ok(Some(run)) => {
441 #[cfg(feature = "prometheus")]
442 counter!(WORKER_POLLS_TOTAL, "result" => "hit").increment(1);
443
444 let is_blocked = {
446 let tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
447 tracker.is_blocked(&run.workflow_name)
448 };
449 if is_blocked {
450 warn!(
451 workflow = %run.workflow_name,
452 run_id = %run.id,
453 "skipping run: workflow flagged as poison pill, marking as failed"
454 );
455 if let Err(e) = self
456 .engine
457 .store()
458 .update_run_status(run.id, RunStatus::Failed)
459 .await
460 {
461 error!(run_id = %run.id, error = %e, "failed to mark poisoned run as failed");
462 }
463 continue;
464 }
465
466 let permit = semaphore
467 .clone()
468 .acquire_owned()
469 .await
470 .map_err(|_| WorkerError::Internal("semaphore closed".to_string()))?;
471
472 idle_streak = 0;
473 let engine = self.engine.clone();
474 let run_id = run.id;
475 let workflow = run.workflow_name.clone();
476 let workflow_for_watcher = workflow.clone();
477 let run_timeout = self.run_timeout;
478
479 info!(run_id = %run_id, workflow = %workflow, "executing run");
480
481 #[cfg(feature = "prometheus")]
482 gauge!(WORKER_ACTIVE).increment(1.0);
483
484 let handle = spawn(async move {
485 let _permit = permit;
486 let result = timeout(run_timeout, engine.execute_handler_run(run_id)).await;
487
488 match result {
489 Ok(Ok(_)) => {
490 info!(run_id = %run_id, workflow = %workflow, "run completed");
491 RunOutcome::Success(workflow)
492 }
493 Ok(Err(e)) => {
494 error!(run_id = %run_id, workflow = %workflow, error = %e, "run failed");
495
496 let budget_exceeded =
501 matches!(e, EngineError::RunBudgetExceeded { .. });
502
503 if !budget_exceeded
504 && let Err(store_err) = engine
505 .store()
506 .update_run(
507 run_id,
508 RunUpdate {
509 status: Some(RunStatus::Failed),
510 error: Some(e.to_string()),
511 ..RunUpdate::default()
512 },
513 )
514 .await
515 {
516 error!(run_id = %run_id, error = %store_err, "failed to mark run as failed");
517 }
518
519 let cleanup_reason = if budget_exceeded {
520 "parent run stopped: cost cap reached"
521 } else {
522 "parent run failed"
523 };
524 if let Err(cleanup_err) =
525 engine.fail_orphaned_steps(run_id, cleanup_reason).await
526 {
527 error!(run_id = %run_id, error = %cleanup_err, "failed to cleanup orphaned steps");
528 }
529 RunOutcome::Failed(workflow)
530 }
531 Err(_) => {
532 error!(
533 run_id = %run_id,
534 workflow = %workflow,
535 timeout_secs = run_timeout.as_secs(),
536 "run timed out"
537 );
538 let timeout_msg =
539 format!("run timed out after {}s", run_timeout.as_secs());
540 if let Err(e) = engine
541 .store()
542 .update_run(
543 run_id,
544 RunUpdate {
545 status: Some(RunStatus::Failed),
546 error: Some(timeout_msg),
547 ..RunUpdate::default()
548 },
549 )
550 .await
551 {
552 error!(run_id = %run_id, error = %e, "failed to mark timed-out run as failed");
553 }
554 if let Err(e) = engine
555 .fail_orphaned_steps(run_id, "parent run timed out")
556 .await
557 {
558 error!(run_id = %run_id, error = %e, "failed to cleanup orphaned steps after timeout");
559 }
560 RunOutcome::Timeout(workflow)
561 }
562 }
563 });
564
565 let watcher_engine = self.engine.clone();
567 let tx = outcome_tx.clone();
568 spawn(async move {
569 match handle.await {
570 Ok(outcome) => {
571 let _ = tx.send(outcome);
572 }
573 Err(e) => {
574 error!(run_id = %run_id, "spawned task panicked: {e}");
575 if let Err(store_err) = watcher_engine
576 .store()
577 .update_run_status(run_id, RunStatus::Failed)
578 .await
579 {
580 error!(run_id = %run_id, error = %store_err, "failed to mark panicked run as failed");
581 }
582 if let Err(cleanup_err) = watcher_engine
583 .fail_orphaned_steps(run_id, "parent run panicked")
584 .await
585 {
586 error!(run_id = %run_id, error = %cleanup_err, "failed to cleanup orphaned steps after panic");
587 }
588 let _ = tx.send(RunOutcome::Panicked(workflow_for_watcher));
589 }
590 }
591 #[cfg(feature = "prometheus")]
592 gauge!(WORKER_ACTIVE).decrement(1.0);
593 });
594 }
595 Ok(None) => {
596 #[cfg(feature = "prometheus")]
597 counter!(WORKER_POLLS_TOTAL, "result" => "miss").increment(1);
598
599 idle_streak += 1;
600 let backoff = if idle_streak > 10 {
601 self.poll_interval * 3
602 } else if idle_streak > 5 {
603 self.poll_interval * 2
604 } else {
605 self.poll_interval
606 };
607 sleep(backoff).await;
608 }
609 Err(e) => {
610 warn!(error = %e, "poll error");
611 sleep(self.poll_interval).await;
612 }
613 }
614 }
615
616 info!(
618 in_flight = self.concurrency - semaphore.available_permits(),
619 "waiting for in-flight runs to complete..."
620 );
621 let _ = semaphore
622 .acquire_many(self.concurrency as u32)
623 .await
624 .map_err(|_| WorkerError::Shutdown("semaphore closed during drain".to_string()))?;
625
626 info!("all in-flight runs completed, worker shut down");
627 Ok(())
628 }
629}
630
631enum RunOutcome {
633 Success(String),
635 Failed(String),
637 Timeout(String),
639 Panicked(String),
641}
642
643async fn shutdown_signal() {
645 use tokio::signal;
646
647 let ctrl_c = async {
648 signal::ctrl_c()
649 .await
650 .expect("failed to install Ctrl+C handler");
651 };
652
653 #[cfg(unix)]
654 let terminate = async {
655 use tokio::signal::unix::{SignalKind, signal};
656
657 signal(SignalKind::terminate())
658 .expect("failed to install SIGTERM handler")
659 .recv()
660 .await;
661 };
662
663 #[cfg(not(unix))]
664 let terminate = {
665 use std::future::pending;
666 pending::<()>()
667 };
668
669 tokio::select! {
670 () = ctrl_c => {},
671 () = terminate => {},
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678 use ironflow_core::providers::claude::ClaudeCodeProvider;
679
680 #[test]
681 fn builder_new_creates_default_config() {
682 let builder = WorkerBuilder::new("http://localhost:3000", "my-token");
683 assert_eq!(builder.api_url, "http://localhost:3000");
684 assert_eq!(builder.worker_token, "my-token");
685 assert_eq!(builder.concurrency, DEFAULT_CONCURRENCY);
686 assert_eq!(builder.poll_interval, DEFAULT_POLL_INTERVAL);
687 assert_eq!(builder.run_timeout, DEFAULT_RUN_TIMEOUT);
688 assert_eq!(
689 builder.max_consecutive_panics,
690 DEFAULT_MAX_CONSECUTIVE_PANICS
691 );
692 assert_eq!(builder.panic_cooldown, DEFAULT_PANIC_COOLDOWN);
693 assert!(builder.provider.is_none());
694 }
695
696 #[test]
697 fn builder_with_trailing_slash_normalized() {
698 let builder = WorkerBuilder::new("http://localhost:3000/", "token");
699 assert_eq!(builder.api_url, "http://localhost:3000/");
700 }
701
702 #[test]
703 fn builder_provider_sets_provider() {
704 let provider = Arc::new(ClaudeCodeProvider::new());
705 let builder =
706 WorkerBuilder::new("http://localhost:3000", "token").provider(provider.clone());
707 assert!(builder.provider.is_some());
708 }
709
710 #[test]
711 fn builder_concurrency_sets_concurrency() {
712 let builder = WorkerBuilder::new("http://localhost:3000", "token").concurrency(8);
713 assert_eq!(builder.concurrency, 8);
714 }
715
716 #[test]
717 fn builder_concurrency_zero_accepted() {
718 let provider = Arc::new(ClaudeCodeProvider::new());
719 let builder = WorkerBuilder::new("http://localhost:3000", "token")
720 .provider(provider)
721 .concurrency(0);
722 assert_eq!(builder.concurrency, 0);
723 }
724
725 #[test]
726 fn builder_poll_interval_sets_interval() {
727 let interval = Duration::from_secs(5);
728 let builder = WorkerBuilder::new("http://localhost:3000", "token").poll_interval(interval);
729 assert_eq!(builder.poll_interval, interval);
730 }
731
732 #[test]
733 fn builder_run_timeout_sets_timeout() {
734 let dur = Duration::from_secs(120);
735 let builder = WorkerBuilder::new("http://localhost:3000", "token").run_timeout(dur);
736 assert_eq!(builder.run_timeout, dur);
737 }
738
739 #[test]
740 fn builder_max_consecutive_panics_sets_value() {
741 let builder =
742 WorkerBuilder::new("http://localhost:3000", "token").max_consecutive_panics(10);
743 assert_eq!(builder.max_consecutive_panics, 10);
744 }
745
746 #[test]
747 fn builder_panic_cooldown_sets_value() {
748 let dur = Duration::from_secs(600);
749 let builder = WorkerBuilder::new("http://localhost:3000", "token").panic_cooldown(dur);
750 assert_eq!(builder.panic_cooldown, dur);
751 }
752
753 #[test]
754 fn builder_build_without_provider_fails() {
755 let builder = WorkerBuilder::new("http://localhost:3000", "token");
756 let result = builder.build();
757 assert!(result.is_err());
758 match result {
759 Err(WorkerError::Internal(msg)) => {
760 assert!(msg.contains("provider is required"));
761 }
762 _ => panic!("expected Internal error about missing provider"),
763 }
764 }
765
766 #[test]
767 fn builder_build_with_provider_succeeds() {
768 let provider = Arc::new(ClaudeCodeProvider::new());
769 let builder = WorkerBuilder::new("http://localhost:3000", "token").provider(provider);
770 let result = builder.build();
771 assert!(result.is_ok());
772 }
773
774 #[test]
775 fn builder_build_creates_worker_with_correct_concurrency() {
776 let provider = Arc::new(ClaudeCodeProvider::new());
777 let builder = WorkerBuilder::new("http://localhost:3000", "token")
778 .provider(provider)
779 .concurrency(16);
780 let worker = builder.build().unwrap();
781 assert_eq!(worker.concurrency, 16);
782 }
783
784 #[test]
785 fn builder_build_creates_worker_with_correct_interval() {
786 let provider = Arc::new(ClaudeCodeProvider::new());
787 let interval = Duration::from_secs(10);
788 let builder = WorkerBuilder::new("http://localhost:3000", "token")
789 .provider(provider)
790 .poll_interval(interval);
791 let worker = builder.build().unwrap();
792 assert_eq!(worker.poll_interval, interval);
793 }
794
795 #[test]
796 fn builder_build_preserves_timeout() {
797 let provider = Arc::new(ClaudeCodeProvider::new());
798 let dur = Duration::from_secs(300);
799 let worker = WorkerBuilder::new("http://localhost:3000", "token")
800 .provider(provider)
801 .run_timeout(dur)
802 .build()
803 .unwrap();
804 assert_eq!(worker.run_timeout, dur);
805 }
806
807 #[test]
808 fn builder_build_preserves_poison_pill_config() {
809 let provider = Arc::new(ClaudeCodeProvider::new());
810 let cooldown = Duration::from_secs(120);
811 let worker = WorkerBuilder::new("http://localhost:3000", "token")
812 .provider(provider)
813 .max_consecutive_panics(7)
814 .panic_cooldown(cooldown)
815 .build()
816 .unwrap();
817 assert_eq!(worker.max_consecutive_panics, 7);
818 assert_eq!(worker.panic_cooldown, cooldown);
819 }
820
821 #[test]
822 fn builder_chaining_works() {
823 let provider = Arc::new(ClaudeCodeProvider::new());
824 let result = WorkerBuilder::new("http://localhost:3000", "token")
825 .provider(provider)
826 .concurrency(4)
827 .poll_interval(Duration::from_secs(3))
828 .run_timeout(Duration::from_secs(600))
829 .max_consecutive_panics(5)
830 .panic_cooldown(Duration::from_secs(120))
831 .build();
832 assert!(result.is_ok());
833 let worker = result.unwrap();
834 assert_eq!(worker.concurrency, 4);
835 assert_eq!(worker.poll_interval, Duration::from_secs(3));
836 assert_eq!(worker.run_timeout, Duration::from_secs(600));
837 assert_eq!(worker.max_consecutive_panics, 5);
838 assert_eq!(worker.panic_cooldown, Duration::from_secs(120));
839 }
840
841 #[test]
842 fn builder_empty_api_url_accepted() {
843 let provider = Arc::new(ClaudeCodeProvider::new());
844 let builder = WorkerBuilder::new("", "token").provider(provider);
845 let result = builder.build();
846 assert!(result.is_ok());
847 }
848
849 #[test]
850 fn builder_empty_token_accepted() {
851 let provider = Arc::new(ClaudeCodeProvider::new());
852 let builder = WorkerBuilder::new("http://localhost:3000", "").provider(provider);
853 let result = builder.build();
854 assert!(result.is_ok());
855 }
856
857 #[cfg(feature = "heartbeat")]
858 #[test]
859 fn builder_heartbeat_defaults() {
860 let builder = WorkerBuilder::new("http://localhost:3000", "token");
861 assert!(builder.heartbeat_url.is_none());
862 assert_eq!(builder.heartbeat_interval, DEFAULT_HEARTBEAT_INTERVAL);
863 }
864
865 #[cfg(feature = "heartbeat")]
866 #[test]
867 fn builder_heartbeat_url_sets_url() {
868 let builder = WorkerBuilder::new("http://localhost:3000", "token")
869 .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc");
870 assert_eq!(
871 builder.heartbeat_url.as_deref(),
872 Some("https://uptime.betterstack.com/api/v1/heartbeat/abc")
873 );
874 }
875
876 #[cfg(feature = "heartbeat")]
877 #[test]
878 fn builder_heartbeat_custom_interval() {
879 let interval = Duration::from_secs(10);
880 let builder =
881 WorkerBuilder::new("http://localhost:3000", "token").heartbeat_interval(interval);
882 assert_eq!(builder.heartbeat_interval, interval);
883 }
884
885 #[cfg(feature = "heartbeat")]
886 #[test]
887 fn builder_build_preserves_heartbeat_config() {
888 let provider = Arc::new(ClaudeCodeProvider::new());
889 let interval = Duration::from_secs(15);
890 let worker = WorkerBuilder::new("http://localhost:3000", "token")
891 .provider(provider)
892 .heartbeat_url("https://example.com/heartbeat")
893 .heartbeat_interval(interval)
894 .build()
895 .unwrap();
896 assert_eq!(
897 worker.heartbeat_url.as_deref(),
898 Some("https://example.com/heartbeat")
899 );
900 assert_eq!(worker.heartbeat_interval, interval);
901 }
902
903 #[cfg(feature = "heartbeat")]
904 #[test]
905 fn builder_build_without_heartbeat_url_has_none() {
906 let provider = Arc::new(ClaudeCodeProvider::new());
907 let worker = WorkerBuilder::new("http://localhost:3000", "token")
908 .provider(provider)
909 .build()
910 .unwrap();
911 assert!(worker.heartbeat_url.is_none());
912 }
913
914 #[test]
917 fn poison_tracker_not_blocked_initially() {
918 let tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
919 assert!(!tracker.is_blocked("my-workflow"));
920 }
921
922 #[test]
923 fn poison_tracker_blocked_after_max_panics() {
924 let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
925 assert!(!tracker.record_panic("wf"));
926 assert!(!tracker.record_panic("wf"));
927 assert!(tracker.record_panic("wf"));
928 assert!(tracker.is_blocked("wf"));
929 }
930
931 #[test]
932 fn poison_tracker_success_resets_count() {
933 let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
934 tracker.record_panic("wf");
935 tracker.record_panic("wf");
936 tracker.record_success("wf");
937 assert!(!tracker.is_blocked("wf"));
938 assert!(!tracker.record_panic("wf"));
940 }
941
942 #[test]
943 fn poison_tracker_independent_per_workflow() {
944 let mut tracker = PoisonPillTracker::new(2, Duration::from_secs(300));
945 tracker.record_panic("wf-a");
946 tracker.record_panic("wf-a");
947 assert!(tracker.is_blocked("wf-a"));
948 assert!(!tracker.is_blocked("wf-b"));
949 }
950
951 #[test]
952 fn poison_tracker_unblocks_after_cooldown() {
953 let mut tracker = PoisonPillTracker::new(2, Duration::from_millis(0));
954 tracker.record_panic("wf");
955 tracker.record_panic("wf");
956 assert!(!tracker.is_blocked("wf"));
958 }
959}