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