Skip to main content

ironflow_worker/
worker.rs

1//! Worker -- polls the API for pending runs and executes them.
2
3use 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
39/// Builder for configuring and creating a [`Worker`].
40///
41/// # Examples
42///
43/// ```no_run
44/// use std::sync::Arc;
45/// use std::time::Duration;
46/// use ironflow_worker::WorkerBuilder;
47/// use ironflow_core::providers::claude::ClaudeCodeProvider;
48///
49/// # async fn example() -> Result<(), ironflow_worker::WorkerError> {
50/// let worker = WorkerBuilder::new("http://localhost:3000", "my-token")
51///     .provider(Arc::new(ClaudeCodeProvider::new()))
52///     .concurrency(4)
53///     .poll_interval(Duration::from_secs(2))
54///     .run_timeout(Duration::from_secs(600))
55///     .max_consecutive_panics(5)
56///     .build()?;
57///
58/// worker.run().await?;
59/// # Ok(())
60/// # }
61/// ```
62pub 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    /// Create a new builder targeting the given API server.
80    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    /// Set the agent provider for AI operations.
99    pub fn provider(mut self, provider: Arc<dyn AgentProvider>) -> Self {
100        self.provider = Some(provider);
101        self
102    }
103
104    /// Register a workflow handler.
105    pub fn register(mut self, handler: impl WorkflowHandler + 'static) -> Self {
106        self.handlers.push(Box::new(handler));
107        self
108    }
109
110    /// Set the maximum number of concurrent workflow executions.
111    pub fn concurrency(mut self, n: usize) -> Self {
112        self.concurrency = n;
113        self
114    }
115
116    /// Set the interval between polls for new runs.
117    pub fn poll_interval(mut self, interval: Duration) -> Self {
118        self.poll_interval = interval;
119        self
120    }
121
122    /// Set the maximum execution time per run.
123    ///
124    /// If a run exceeds this duration, it is cancelled and marked as `Failed`
125    /// with a timeout error. Defaults to 30 minutes.
126    ///
127    /// # Examples
128    ///
129    /// ```no_run
130    /// use std::time::Duration;
131    /// use ironflow_worker::WorkerBuilder;
132    ///
133    /// # fn example() {
134    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
135    ///     .run_timeout(Duration::from_secs(600));
136    /// # }
137    /// ```
138    pub fn run_timeout(mut self, timeout: Duration) -> Self {
139        self.run_timeout = timeout;
140        self
141    }
142
143    /// Set the maximum number of consecutive panics per workflow before
144    /// the worker stops picking runs for that workflow (poison pill guard).
145    ///
146    /// When a workflow panics `max_consecutive_panics` times in a row without
147    /// a single success, the worker skips it for a cooldown period (see
148    /// [`panic_cooldown`](Self::panic_cooldown)). Defaults to 3.
149    ///
150    /// # Examples
151    ///
152    /// ```no_run
153    /// use ironflow_worker::WorkerBuilder;
154    ///
155    /// # fn example() {
156    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
157    ///     .max_consecutive_panics(5);
158    /// # }
159    /// ```
160    pub fn max_consecutive_panics(mut self, n: u32) -> Self {
161        self.max_consecutive_panics = n;
162        self
163    }
164
165    /// Set the cooldown duration after a workflow is flagged as a poison pill.
166    ///
167    /// After `max_consecutive_panics` is reached, runs for that workflow are
168    /// skipped until this duration elapses. Defaults to 5 minutes.
169    ///
170    /// # Examples
171    ///
172    /// ```no_run
173    /// use std::time::Duration;
174    /// use ironflow_worker::WorkerBuilder;
175    ///
176    /// # fn example() {
177    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
178    ///     .panic_cooldown(Duration::from_secs(600));
179    /// # }
180    /// ```
181    pub fn panic_cooldown(mut self, cooldown: Duration) -> Self {
182        self.panic_cooldown = cooldown;
183        self
184    }
185
186    /// Set the heartbeat URL (dead man's switch).
187    ///
188    /// The worker pings this URL at every heartbeat interval with an HTTP
189    /// HEAD request. Compatible with BetterStack Heartbeats, Cronitor,
190    /// Healthchecks.io, or any dead man's switch service.
191    ///
192    /// If not set, no heartbeat is emitted even when the feature is enabled.
193    ///
194    /// # Examples
195    ///
196    /// ```no_run
197    /// use ironflow_worker::WorkerBuilder;
198    ///
199    /// # fn example() {
200    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
201    ///     .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc123");
202    /// # }
203    /// ```
204    #[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    /// Set the heartbeat interval.
211    ///
212    /// Controls how often the worker pings the [`heartbeat_url`](Self::heartbeat_url).
213    /// Defaults to 30 seconds.
214    ///
215    /// # Examples
216    ///
217    /// ```no_run
218    /// use std::time::Duration;
219    /// use ironflow_worker::WorkerBuilder;
220    ///
221    /// # fn example() {
222    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
223    ///     .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc123")
224    ///     .heartbeat_interval(Duration::from_secs(60));
225    /// # }
226    /// ```
227    #[cfg(feature = "heartbeat")]
228    pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
229        self.heartbeat_interval = interval;
230        self
231    }
232
233    /// Build the worker.
234    ///
235    /// # Errors
236    ///
237    /// Returns [`WorkerError::Internal`] if no provider has been set.
238    /// Returns [`WorkerError::Engine`] if a handler registration fails.
239    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
282/// Background worker that polls the API and executes workflows.
283pub 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
301/// Tracks consecutive failures per workflow for poison pill detection.
302struct PoisonPillTracker {
303    max_consecutive: u32,
304    cooldown: Duration,
305    /// Maps workflow name to (consecutive panic count, last panic time).
306    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    /// Record a panic for a workflow. Returns `true` if the workflow is now
319    /// considered a poison pill.
320    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    /// Record a successful execution, resetting the panic counter.
331    fn record_success(&mut self, workflow: &str) {
332        self.state.remove(workflow);
333    }
334
335    /// Returns `true` if the workflow is currently blocked as a poison pill.
336    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    /// Run the worker loop until a shutdown signal (SIGTERM/SIGINT) is received.
345    ///
346    /// On shutdown, the worker stops picking new runs and waits for all
347    /// in-flight executions to complete before returning.
348    ///
349    /// # Errors
350    ///
351    /// Returns [`WorkerError`] if the polling loop encounters an unrecoverable error.
352    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        // Spawn shutdown signal handler
376        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                // skip the first immediate tick
392                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            // Drain outcome channel to update poison pill tracker
420            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                    // Poison pill check: skip workflows that keep failing
445                    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                                // A budget refusal has already moved the run to
497                                // Cancelled inside the engine. Forcing Failed
498                                // here would be rejected by the FSM and would
499                                // erase the reason it stopped.
500                                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                    // Spawn a watcher to catch panics and report outcomes
566                    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        // Graceful drain: wait for all in-flight tasks to release their permits
617        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
631/// Outcome of a single run execution, used for poison pill tracking.
632enum RunOutcome {
633    /// Run completed successfully.
634    Success(String),
635    /// Run failed with an error (engine returned Err).
636    Failed(String),
637    /// Run exceeded its timeout.
638    Timeout(String),
639    /// Run panicked (task JoinError).
640    Panicked(String),
641}
642
643/// Wait for SIGTERM or SIGINT (Ctrl+C).
644async 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    // --- PoisonPillTracker tests ---
915
916    #[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        // After reset, need 3 more panics to block
939        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        // Cooldown is 0ms, should immediately unblock
957        assert!(!tracker.is_blocked("wf"));
958    }
959}