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};
12use uuid::Uuid;
13
14#[cfg(feature = "prometheus")]
15use ironflow_core::metric_names::{
16    WORKER_ACTIVE, WORKER_LEASES_LOST_TOTAL, WORKER_POLLS_TOTAL, WORKER_QUEUE_DEPTH,
17};
18use ironflow_core::provider::AgentProvider;
19use ironflow_engine::engine::Engine;
20use ironflow_engine::handler::WorkflowHandler;
21use ironflow_engine::log_sender::LogReceiver;
22#[cfg(feature = "prometheus")]
23use ironflow_store::entities::RunFilter;
24use ironflow_store::entities::{LeaseRequest, RunStatus};
25use ironflow_store::error::StoreError;
26use ironflow_store::store::Store;
27#[cfg(feature = "prometheus")]
28use metrics::{counter, gauge};
29#[cfg(feature = "heartbeat")]
30use reqwest::Client;
31
32use crate::api_store::ApiRunStore;
33use crate::artifact_sink::ApiArtifactSink;
34use crate::error::WorkerError;
35use crate::log_pusher::LogPusher;
36
37const DEFAULT_CONCURRENCY: usize = 2;
38const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
39const DEFAULT_RUN_TIMEOUT: Duration = Duration::from_secs(30 * 60);
40const DEFAULT_MAX_CONSECUTIVE_PANICS: u32 = 3;
41const DEFAULT_PANIC_COOLDOWN: Duration = Duration::from_secs(5 * 60);
42/// Lease duration requested when picking a run: three missed refreshes before
43/// the run becomes recoverable by the reaper.
44const DEFAULT_LEASE_TTL: Duration = Duration::from_secs(90);
45/// How often the lease of a running run is refreshed.
46const DEFAULT_LEASE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
47#[cfg(feature = "heartbeat")]
48const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
49
50/// Builder for configuring and creating a [`Worker`].
51///
52/// # Examples
53///
54/// ```no_run
55/// use std::sync::Arc;
56/// use std::time::Duration;
57/// use ironflow_worker::WorkerBuilder;
58/// use ironflow_core::providers::claude::ClaudeCodeProvider;
59///
60/// # async fn example() -> Result<(), ironflow_worker::WorkerError> {
61/// let worker = WorkerBuilder::new("http://localhost:3000", "my-token")
62///     .provider(Arc::new(ClaudeCodeProvider::new()))
63///     .concurrency(4)
64///     .poll_interval(Duration::from_secs(2))
65///     .run_timeout(Duration::from_secs(600))
66///     .max_consecutive_panics(5)
67///     .build()?;
68///
69/// worker.run().await?;
70/// # Ok(())
71/// # }
72/// ```
73pub struct WorkerBuilder {
74    api_url: String,
75    worker_token: String,
76    worker_id: String,
77    provider: Option<Arc<dyn AgentProvider>>,
78    handlers: Vec<Box<dyn WorkflowHandler>>,
79    concurrency: usize,
80    poll_interval: Duration,
81    run_timeout: Duration,
82    max_consecutive_panics: u32,
83    panic_cooldown: Duration,
84    lease_ttl: Duration,
85    lease_refresh_interval: Duration,
86    #[cfg(feature = "heartbeat")]
87    heartbeat_url: Option<String>,
88    #[cfg(feature = "heartbeat")]
89    heartbeat_interval: Duration,
90}
91
92impl WorkerBuilder {
93    /// Create a new builder targeting the given API server.
94    pub fn new(api_url: &str, worker_token: &str) -> Self {
95        Self {
96            api_url: api_url.to_string(),
97            worker_token: worker_token.to_string(),
98            worker_id: format!("worker-{}", Uuid::now_v7()),
99            provider: None,
100            handlers: Vec::new(),
101            concurrency: DEFAULT_CONCURRENCY,
102            poll_interval: DEFAULT_POLL_INTERVAL,
103            run_timeout: DEFAULT_RUN_TIMEOUT,
104            max_consecutive_panics: DEFAULT_MAX_CONSECUTIVE_PANICS,
105            panic_cooldown: DEFAULT_PANIC_COOLDOWN,
106            lease_ttl: DEFAULT_LEASE_TTL,
107            lease_refresh_interval: DEFAULT_LEASE_REFRESH_INTERVAL,
108            #[cfg(feature = "heartbeat")]
109            heartbeat_url: None,
110            #[cfg(feature = "heartbeat")]
111            heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
112        }
113    }
114
115    /// Set the agent provider for AI operations.
116    pub fn provider(mut self, provider: Arc<dyn AgentProvider>) -> Self {
117        self.provider = Some(provider);
118        self
119    }
120
121    /// Register a workflow handler.
122    pub fn register(mut self, handler: impl WorkflowHandler + 'static) -> Self {
123        self.handlers.push(Box::new(handler));
124        self
125    }
126
127    /// Set the maximum number of concurrent workflow executions.
128    pub fn concurrency(mut self, n: usize) -> Self {
129        self.concurrency = n;
130        self
131    }
132
133    /// Set the interval between polls for new runs.
134    pub fn poll_interval(mut self, interval: Duration) -> Self {
135        self.poll_interval = interval;
136        self
137    }
138
139    /// Set the maximum execution time per run.
140    ///
141    /// If a run exceeds this duration, it is cancelled and marked as `Failed`
142    /// with a timeout error. Defaults to 30 minutes.
143    ///
144    /// # Examples
145    ///
146    /// ```no_run
147    /// use std::time::Duration;
148    /// use ironflow_worker::WorkerBuilder;
149    ///
150    /// # fn example() {
151    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
152    ///     .run_timeout(Duration::from_secs(600));
153    /// # }
154    /// ```
155    pub fn run_timeout(mut self, timeout: Duration) -> Self {
156        self.run_timeout = timeout;
157        self
158    }
159
160    /// Set the maximum number of consecutive panics per workflow before
161    /// the worker stops picking runs for that workflow (poison pill guard).
162    ///
163    /// When a workflow panics `max_consecutive_panics` times in a row without
164    /// a single success, the worker skips it for a cooldown period (see
165    /// [`panic_cooldown`](Self::panic_cooldown)). Defaults to 3.
166    ///
167    /// # Examples
168    ///
169    /// ```no_run
170    /// use ironflow_worker::WorkerBuilder;
171    ///
172    /// # fn example() {
173    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
174    ///     .max_consecutive_panics(5);
175    /// # }
176    /// ```
177    pub fn max_consecutive_panics(mut self, n: u32) -> Self {
178        self.max_consecutive_panics = n;
179        self
180    }
181
182    /// Set the cooldown duration after a workflow is flagged as a poison pill.
183    ///
184    /// After `max_consecutive_panics` is reached, runs for that workflow are
185    /// skipped until this duration elapses. Defaults to 5 minutes.
186    ///
187    /// # Examples
188    ///
189    /// ```no_run
190    /// use std::time::Duration;
191    /// use ironflow_worker::WorkerBuilder;
192    ///
193    /// # fn example() {
194    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
195    ///     .panic_cooldown(Duration::from_secs(600));
196    /// # }
197    /// ```
198    pub fn panic_cooldown(mut self, cooldown: Duration) -> Self {
199        self.panic_cooldown = cooldown;
200        self
201    }
202
203    /// Set the identifier this worker uses to claim runs.
204    ///
205    /// Defaults to `worker-<uuid>`, regenerated at every start so two processes
206    /// never share an identity. Override it only if you have a stable, unique
207    /// name per process.
208    ///
209    /// # Examples
210    ///
211    /// ```no_run
212    /// use ironflow_worker::WorkerBuilder;
213    ///
214    /// # fn example() {
215    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
216    ///     .worker_id("worker-eu-west-1a");
217    /// # }
218    /// ```
219    pub fn worker_id(mut self, worker_id: &str) -> Self {
220        self.worker_id = worker_id.to_string();
221        self
222    }
223
224    /// Set how long a lease stays valid without a refresh.
225    ///
226    /// Once it expires, the API reaper requeues the run for another worker.
227    /// Keep it at a few times [`lease_refresh_interval`](Self::lease_refresh_interval)
228    /// so a transient network blip does not hand the run over. Defaults to 90 s.
229    ///
230    /// # Examples
231    ///
232    /// ```no_run
233    /// use std::time::Duration;
234    /// use ironflow_worker::WorkerBuilder;
235    ///
236    /// # fn example() {
237    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
238    ///     .lease_ttl(Duration::from_secs(120));
239    /// # }
240    /// ```
241    pub fn lease_ttl(mut self, ttl: Duration) -> Self {
242        self.lease_ttl = ttl;
243        self
244    }
245
246    /// Set how often the lease of a running run is refreshed.
247    ///
248    /// Defaults to 30 seconds. Must stay well below
249    /// [`lease_ttl`](Self::lease_ttl).
250    ///
251    /// # Examples
252    ///
253    /// ```no_run
254    /// use std::time::Duration;
255    /// use ironflow_worker::WorkerBuilder;
256    ///
257    /// # fn example() {
258    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
259    ///     .lease_refresh_interval(Duration::from_secs(10));
260    /// # }
261    /// ```
262    pub fn lease_refresh_interval(mut self, interval: Duration) -> Self {
263        self.lease_refresh_interval = interval;
264        self
265    }
266
267    /// Set the heartbeat URL (dead man's switch).
268    ///
269    /// The worker pings this URL at every heartbeat interval with an HTTP
270    /// HEAD request. Compatible with BetterStack Heartbeats, Cronitor,
271    /// Healthchecks.io, or any dead man's switch service.
272    ///
273    /// If not set, no heartbeat is emitted even when the feature is enabled.
274    ///
275    /// # Examples
276    ///
277    /// ```no_run
278    /// use ironflow_worker::WorkerBuilder;
279    ///
280    /// # fn example() {
281    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
282    ///     .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc123");
283    /// # }
284    /// ```
285    #[cfg(feature = "heartbeat")]
286    pub fn heartbeat_url(mut self, url: &str) -> Self {
287        self.heartbeat_url = Some(url.to_string());
288        self
289    }
290
291    /// Set the heartbeat interval.
292    ///
293    /// Controls how often the worker pings the [`heartbeat_url`](Self::heartbeat_url).
294    /// Defaults to 30 seconds.
295    ///
296    /// # Examples
297    ///
298    /// ```no_run
299    /// use std::time::Duration;
300    /// use ironflow_worker::WorkerBuilder;
301    ///
302    /// # fn example() {
303    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
304    ///     .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc123")
305    ///     .heartbeat_interval(Duration::from_secs(60));
306    /// # }
307    /// ```
308    #[cfg(feature = "heartbeat")]
309    pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
310        self.heartbeat_interval = interval;
311        self
312    }
313
314    /// Build the worker.
315    ///
316    /// # Errors
317    ///
318    /// Returns [`WorkerError::Internal`] if no provider has been set.
319    /// Returns [`WorkerError::Engine`] if a handler registration fails.
320    pub fn build(self) -> Result<Worker, WorkerError> {
321        let provider = self
322            .provider
323            .ok_or_else(|| WorkerError::Internal("WorkerBuilder: provider is required".into()))?;
324
325        let store: Arc<dyn Store> = Arc::new(ApiRunStore::new(&self.api_url, &self.worker_token));
326
327        let mut engine = Engine::new(store, provider);
328        for handler in self.handlers {
329            engine
330                .register_boxed(handler)
331                .map_err(WorkerError::Engine)?;
332        }
333
334        let (log_sender, log_receiver) = ironflow_engine::log_sender::channel();
335        engine.set_log_sender(log_sender);
336
337        // Artifact bytes travel through the API: the worker holds no storage
338        // credential. When the API has no backend configured, a step that
339        // declares artifacts fails with a clear message and the rest runs on.
340        engine.set_artifact_sink(Arc::new(ApiArtifactSink::new(
341            &self.api_url,
342            &self.worker_token,
343        )));
344
345        #[cfg(feature = "heartbeat")]
346        let heartbeat_client = Client::builder()
347            .timeout(Duration::from_secs(5))
348            .build()
349            .expect("failed to build heartbeat HTTP client");
350
351        Ok(Worker {
352            engine: Arc::new(engine),
353            api_url: self.api_url,
354            worker_token: self.worker_token,
355            worker_id: self.worker_id,
356            log_receiver: Mutex::new(Some(log_receiver)),
357            concurrency: self.concurrency,
358            poll_interval: self.poll_interval,
359            run_timeout: self.run_timeout,
360            max_consecutive_panics: self.max_consecutive_panics,
361            panic_cooldown: self.panic_cooldown,
362            lease_ttl: self.lease_ttl,
363            lease_refresh_interval: self.lease_refresh_interval,
364            #[cfg(feature = "heartbeat")]
365            heartbeat_url: self.heartbeat_url,
366            #[cfg(feature = "heartbeat")]
367            heartbeat_interval: self.heartbeat_interval,
368            #[cfg(feature = "heartbeat")]
369            heartbeat_client,
370        })
371    }
372}
373
374/// Background worker that polls the API and executes workflows.
375pub struct Worker {
376    engine: Arc<Engine>,
377    api_url: String,
378    worker_token: String,
379    worker_id: String,
380    log_receiver: Mutex<Option<LogReceiver>>,
381    concurrency: usize,
382    poll_interval: Duration,
383    run_timeout: Duration,
384    max_consecutive_panics: u32,
385    panic_cooldown: Duration,
386    lease_ttl: Duration,
387    lease_refresh_interval: Duration,
388    #[cfg(feature = "heartbeat")]
389    heartbeat_url: Option<String>,
390    #[cfg(feature = "heartbeat")]
391    heartbeat_interval: Duration,
392    #[cfg(feature = "heartbeat")]
393    heartbeat_client: Client,
394}
395
396/// Tracks consecutive failures per workflow for poison pill detection.
397struct PoisonPillTracker {
398    max_consecutive: u32,
399    cooldown: Duration,
400    /// Maps workflow name to (consecutive panic count, last panic time).
401    state: HashMap<String, (u32, Instant)>,
402}
403
404impl PoisonPillTracker {
405    fn new(max_consecutive: u32, cooldown: Duration) -> Self {
406        Self {
407            max_consecutive,
408            cooldown,
409            state: HashMap::new(),
410        }
411    }
412
413    /// Record a panic for a workflow. Returns `true` if the workflow is now
414    /// considered a poison pill.
415    fn record_panic(&mut self, workflow: &str) -> bool {
416        let entry = self
417            .state
418            .entry(workflow.to_string())
419            .or_insert((0, Instant::now()));
420        entry.0 += 1;
421        entry.1 = Instant::now();
422        entry.0 >= self.max_consecutive
423    }
424
425    /// Record a successful execution, resetting the panic counter.
426    fn record_success(&mut self, workflow: &str) {
427        self.state.remove(workflow);
428    }
429
430    /// Returns `true` if the workflow is currently blocked as a poison pill.
431    fn is_blocked(&self, workflow: &str) -> bool {
432        self.state.get(workflow).is_some_and(|(count, last_panic)| {
433            *count >= self.max_consecutive && last_panic.elapsed() < self.cooldown
434        })
435    }
436}
437
438impl Worker {
439    /// Run the worker loop until a shutdown signal (SIGTERM/SIGINT) is received.
440    ///
441    /// On shutdown, the worker stops picking new runs and waits for all
442    /// in-flight executions to complete before returning.
443    ///
444    /// # Errors
445    ///
446    /// Returns [`WorkerError`] if the polling loop encounters an unrecoverable error.
447    pub async fn run(&self) -> Result<(), WorkerError> {
448        let semaphore = Arc::new(Semaphore::new(self.concurrency));
449        let shutdown = CancellationToken::new();
450        let mut idle_streak = 0u32;
451        let poison_tracker = Arc::new(Mutex::new(PoisonPillTracker::new(
452            self.max_consecutive_panics,
453            self.panic_cooldown,
454        )));
455        let (outcome_tx, mut outcome_rx) = mpsc::unbounded_channel::<RunOutcome>();
456
457        info!(
458            concurrency = self.concurrency,
459            poll_interval_ms = self.poll_interval.as_millis() as u64,
460            run_timeout_secs = self.run_timeout.as_secs(),
461            "worker started"
462        );
463
464        if let Some(receiver) = self.log_receiver.lock().expect("log_receiver lock").take() {
465            let pusher = LogPusher::new(&self.api_url, &self.worker_token);
466            spawn(pusher.run(receiver));
467            info!("log pusher started");
468        }
469
470        // Spawn shutdown signal handler
471        let shutdown_clone = shutdown.clone();
472        spawn(async move {
473            shutdown_signal().await;
474            info!("shutdown signal received, draining in-flight runs...");
475            shutdown_clone.cancel();
476        });
477
478        #[cfg(feature = "heartbeat")]
479        if let Some(ref url) = self.heartbeat_url {
480            let interval = self.heartbeat_interval;
481            let url = url.clone();
482            let client = self.heartbeat_client.clone();
483
484            spawn(async move {
485                let mut ticker = tokio::time::interval(interval);
486                // skip the first immediate tick
487                ticker.tick().await;
488                loop {
489                    ticker.tick().await;
490                    match client.head(&url).send().await {
491                        Ok(resp) if resp.status().is_success() => {
492                            info!(url = %url, "heartbeat sent");
493                        }
494                        Ok(resp) => {
495                            warn!(
496                                url = %url,
497                                status = %resp.status(),
498                                "heartbeat ping returned non-success status"
499                            );
500                        }
501                        Err(err) => {
502                            warn!(
503                                url = %url,
504                                error = %err,
505                                "heartbeat ping failed"
506                            );
507                        }
508                    }
509                }
510            });
511        }
512
513        #[cfg(feature = "prometheus")]
514        let mut last_gauge_update = Instant::now();
515
516        while !shutdown.is_cancelled() {
517            // Drain outcome channel to update poison pill tracker
518            while let Ok(outcome) = outcome_rx.try_recv() {
519                let mut tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
520                match outcome {
521                    RunOutcome::Success(ref wf) => tracker.record_success(wf),
522                    // Losing a lease says nothing about the workflow itself:
523                    // it must not count towards the poison pill threshold.
524                    RunOutcome::LeaseLost(ref wf) => {
525                        warn!(workflow = %wf, "run abandoned after losing its lease")
526                    }
527                    RunOutcome::Failed(ref wf) | RunOutcome::Timeout(ref wf) => {
528                        if tracker.record_panic(wf) {
529                            warn!(workflow = %wf, "workflow flagged as poison pill after consecutive failures");
530                        }
531                    }
532                    RunOutcome::Panicked(ref wf) => {
533                        if tracker.record_panic(wf) {
534                            error!(workflow = %wf, "workflow flagged as poison pill after consecutive panics");
535                        }
536                    }
537                }
538            }
539
540            let run = self
541                .engine
542                .store()
543                .pick_next_pending(Some(LeaseRequest {
544                    worker_id: self.worker_id.clone(),
545                    ttl: self.lease_ttl,
546                }))
547                .await;
548
549            match run {
550                Ok(Some(run)) => {
551                    #[cfg(feature = "prometheus")]
552                    counter!(WORKER_POLLS_TOTAL, "result" => "hit").increment(1);
553
554                    // Poison pill check: skip workflows that keep failing
555                    let is_blocked = {
556                        let tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
557                        tracker.is_blocked(&run.workflow_name)
558                    };
559                    if is_blocked {
560                        warn!(
561                            workflow = %run.workflow_name,
562                            run_id = %run.id,
563                            "skipping run: workflow flagged as poison pill, marking as failed"
564                        );
565                        if let Err(e) = self
566                            .engine
567                            .store()
568                            .update_run_status(run.id, RunStatus::Failed)
569                            .await
570                        {
571                            error!(run_id = %run.id, error = %e, "failed to mark poisoned run as failed");
572                        }
573                        continue;
574                    }
575
576                    let permit = semaphore
577                        .clone()
578                        .acquire_owned()
579                        .await
580                        .map_err(|_| WorkerError::Internal("semaphore closed".to_string()))?;
581
582                    idle_streak = 0;
583                    let engine = self.engine.clone();
584                    let run_id = run.id;
585                    let workflow = run.workflow_name.clone();
586                    let workflow_for_watcher = workflow.clone();
587                    let run_timeout = self.run_timeout;
588
589                    info!(run_id = %run_id, workflow = %workflow, "executing run");
590
591                    #[cfg(feature = "prometheus")]
592                    gauge!(WORKER_ACTIVE).increment(1.0);
593
594                    // Keep the lease alive for as long as this run executes, and
595                    // abandon the run as soon as the lease is lost.
596                    let lease_token = CancellationToken::new();
597                    let refresher = spawn(refresh_lease(
598                        self.engine.store().clone(),
599                        run_id,
600                        LeaseRequest {
601                            worker_id: self.worker_id.clone(),
602                            ttl: self.lease_ttl,
603                        },
604                        self.lease_refresh_interval,
605                        lease_token.clone(),
606                    ));
607
608                    let handle = spawn(async move {
609                        let _permit = permit;
610                        let result = tokio::select! {
611                            biased;
612                            _ = lease_token.cancelled() => {
613                                refresher.abort();
614                                // Dropping the execution future here cancels it;
615                                // child processes die with it (kill_on_drop).
616                                warn!(
617                                    run_id = %run_id,
618                                    workflow = %workflow,
619                                    "abandoning run: worker lease lost"
620                                );
621                                #[cfg(feature = "prometheus")]
622                                counter!(WORKER_LEASES_LOST_TOTAL).increment(1);
623                                // No status write: the run belongs to someone else now.
624                                return RunOutcome::LeaseLost(workflow);
625                            }
626                            result = timeout(run_timeout, engine.execute_handler_run(run_id)) => result,
627                        };
628                        refresher.abort();
629
630                        match result {
631                            Ok(Ok(_)) => {
632                                info!(run_id = %run_id, workflow = %workflow, "run completed");
633                                RunOutcome::Success(workflow)
634                            }
635                            // `execute_handler_run` already routed this failure
636                            // through `fail_or_schedule_retry` (or, for a budget
637                            // refusal, straight to Cancelled) and cleaned up the
638                            // orphaned steps, so the run is either terminal or
639                            // waiting for its retry. Writing Failed here again
640                            // would cancel an armed retry and would be rejected
641                            // by the FSM for a cancelled run.
642                            Ok(Err(e)) => {
643                                error!(run_id = %run_id, workflow = %workflow, error = %e, "run failed");
644                                RunOutcome::Failed(workflow)
645                            }
646                            Err(_) => {
647                                error!(
648                                    run_id = %run_id,
649                                    workflow = %workflow,
650                                    timeout_secs = run_timeout.as_secs(),
651                                    "run timed out"
652                                );
653                                let timeout_msg =
654                                    format!("run timed out after {}s", run_timeout.as_secs());
655                                // A timeout is transient by nature: worth replaying.
656                                if let Err(e) = engine
657                                    .fail_or_schedule_retry(run_id, &timeout_msg, true, None, None)
658                                    .await
659                                {
660                                    error!(run_id = %run_id, error = %e, "failed to record timed-out run");
661                                }
662                                RunOutcome::Timeout(workflow)
663                            }
664                        }
665                    });
666
667                    // Spawn a watcher to catch panics and report outcomes
668                    let watcher_engine = self.engine.clone();
669                    let tx = outcome_tx.clone();
670                    spawn(async move {
671                        match handle.await {
672                            Ok(outcome) => {
673                                let _ = tx.send(outcome);
674                            }
675                            Err(e) => {
676                                error!(run_id = %run_id, "spawned task panicked: {e}");
677                                // Retryable: a panic can be transient, and the
678                                // poison pill tracker stops a workflow that keeps
679                                // panicking across runs.
680                                if let Err(store_err) = watcher_engine
681                                    .fail_or_schedule_retry(
682                                        run_id,
683                                        "parent run panicked",
684                                        true,
685                                        None,
686                                        None,
687                                    )
688                                    .await
689                                {
690                                    error!(run_id = %run_id, error = %store_err, "failed to record panicked run");
691                                }
692                                let _ = tx.send(RunOutcome::Panicked(workflow_for_watcher));
693                            }
694                        }
695                        #[cfg(feature = "prometheus")]
696                        gauge!(WORKER_ACTIVE).decrement(1.0);
697                    });
698                }
699                Ok(None) => {
700                    #[cfg(feature = "prometheus")]
701                    counter!(WORKER_POLLS_TOTAL, "result" => "miss").increment(1);
702
703                    idle_streak += 1;
704                    let backoff = if idle_streak > 10 {
705                        self.poll_interval * 3
706                    } else if idle_streak > 5 {
707                        self.poll_interval * 2
708                    } else {
709                        self.poll_interval
710                    };
711                    sleep(backoff).await;
712                }
713                Err(e) => {
714                    warn!(error = %e, "poll error");
715                    sleep(self.poll_interval).await;
716                }
717            }
718
719            #[cfg(feature = "prometheus")]
720            if last_gauge_update.elapsed() >= Duration::from_secs(5) {
721                if let Ok(stats) = self
722                    .engine
723                    .store()
724                    .get_stats(RunFilter {
725                        status: Some(RunStatus::Pending),
726                        ..RunFilter::default()
727                    })
728                    .await
729                {
730                    gauge!(WORKER_QUEUE_DEPTH).set(stats.total_runs as f64);
731                }
732                last_gauge_update = Instant::now();
733            }
734        }
735
736        // Graceful drain: wait for all in-flight tasks to release their permits
737        info!(
738            in_flight = self.concurrency - semaphore.available_permits(),
739            "waiting for in-flight runs to complete..."
740        );
741        let _ = semaphore
742            .acquire_many(self.concurrency as u32)
743            .await
744            .map_err(|_| WorkerError::Shutdown("semaphore closed during drain".to_string()))?;
745
746        info!("all in-flight runs completed, worker shut down");
747        Ok(())
748    }
749}
750
751/// Outcome of a single run execution, used for poison pill tracking.
752enum RunOutcome {
753    /// Run completed successfully.
754    Success(String),
755    /// Run failed with an error (engine returned Err).
756    Failed(String),
757    /// Run exceeded its timeout.
758    Timeout(String),
759    /// Run panicked (task JoinError).
760    Panicked(String),
761    /// Run was abandoned because this worker lost its lease.
762    LeaseLost(String),
763}
764
765/// Keep a run's lease alive until the run finishes or the lease is lost.
766///
767/// Cancels `lease_token` when the API hands the run to another worker, or when
768/// refreshes keep failing for longer than the lease TTL — in both cases this
769/// worker must stop executing the run rather than risk a double execution.
770async fn refresh_lease(
771    store: Arc<dyn Store>,
772    run_id: uuid::Uuid,
773    lease: LeaseRequest,
774    refresh_interval: Duration,
775    lease_token: CancellationToken,
776) {
777    let ttl = lease.ttl;
778    let mut deadline = Instant::now() + ttl;
779
780    loop {
781        sleep(refresh_interval).await;
782
783        match store.renew_lease(run_id, lease.clone()).await {
784            Ok(_) => {
785                deadline = Instant::now() + ttl;
786            }
787            Err(StoreError::LeaseLost { held_by, .. }) => {
788                warn!(
789                    run_id = %run_id,
790                    held_by = held_by.as_deref().unwrap_or("unknown"),
791                    "lease taken over by another worker"
792                );
793                lease_token.cancel();
794                return;
795            }
796            Err(err) if Instant::now() >= deadline => {
797                // The API has been unreachable longer than the lease lasts:
798                // another worker may already have picked the run up.
799                warn!(
800                    run_id = %run_id,
801                    error = %err,
802                    ttl_secs = ttl.as_secs(),
803                    "lease could not be refreshed before it expired"
804                );
805                lease_token.cancel();
806                return;
807            }
808            Err(err) => {
809                warn!(run_id = %run_id, error = %err, "lease refresh failed, retrying");
810            }
811        }
812    }
813}
814
815/// Wait for SIGTERM or SIGINT (Ctrl+C).
816async fn shutdown_signal() {
817    use tokio::signal;
818
819    let ctrl_c = async {
820        signal::ctrl_c()
821            .await
822            .expect("failed to install Ctrl+C handler");
823    };
824
825    #[cfg(unix)]
826    let terminate = async {
827        use tokio::signal::unix::{SignalKind, signal};
828
829        signal(SignalKind::terminate())
830            .expect("failed to install SIGTERM handler")
831            .recv()
832            .await;
833    };
834
835    #[cfg(not(unix))]
836    let terminate = {
837        use std::future::pending;
838        pending::<()>()
839    };
840
841    tokio::select! {
842        () = ctrl_c => {},
843        () = terminate => {},
844    }
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850    use ironflow_core::providers::claude::ClaudeCodeProvider;
851
852    #[test]
853    fn builder_new_creates_default_config() {
854        let builder = WorkerBuilder::new("http://localhost:3000", "my-token");
855        assert_eq!(builder.api_url, "http://localhost:3000");
856        assert_eq!(builder.worker_token, "my-token");
857        assert_eq!(builder.concurrency, DEFAULT_CONCURRENCY);
858        assert_eq!(builder.poll_interval, DEFAULT_POLL_INTERVAL);
859        assert_eq!(builder.run_timeout, DEFAULT_RUN_TIMEOUT);
860        assert_eq!(
861            builder.max_consecutive_panics,
862            DEFAULT_MAX_CONSECUTIVE_PANICS
863        );
864        assert_eq!(builder.panic_cooldown, DEFAULT_PANIC_COOLDOWN);
865        assert!(builder.provider.is_none());
866    }
867
868    #[test]
869    fn builder_with_trailing_slash_normalized() {
870        let builder = WorkerBuilder::new("http://localhost:3000/", "token");
871        assert_eq!(builder.api_url, "http://localhost:3000/");
872    }
873
874    #[test]
875    fn builder_provider_sets_provider() {
876        let provider = Arc::new(ClaudeCodeProvider::new());
877        let builder =
878            WorkerBuilder::new("http://localhost:3000", "token").provider(provider.clone());
879        assert!(builder.provider.is_some());
880    }
881
882    #[test]
883    fn builder_concurrency_sets_concurrency() {
884        let builder = WorkerBuilder::new("http://localhost:3000", "token").concurrency(8);
885        assert_eq!(builder.concurrency, 8);
886    }
887
888    #[test]
889    fn builder_concurrency_zero_accepted() {
890        let provider = Arc::new(ClaudeCodeProvider::new());
891        let builder = WorkerBuilder::new("http://localhost:3000", "token")
892            .provider(provider)
893            .concurrency(0);
894        assert_eq!(builder.concurrency, 0);
895    }
896
897    #[test]
898    fn builder_poll_interval_sets_interval() {
899        let interval = Duration::from_secs(5);
900        let builder = WorkerBuilder::new("http://localhost:3000", "token").poll_interval(interval);
901        assert_eq!(builder.poll_interval, interval);
902    }
903
904    #[test]
905    fn builder_run_timeout_sets_timeout() {
906        let dur = Duration::from_secs(120);
907        let builder = WorkerBuilder::new("http://localhost:3000", "token").run_timeout(dur);
908        assert_eq!(builder.run_timeout, dur);
909    }
910
911    #[test]
912    fn builder_max_consecutive_panics_sets_value() {
913        let builder =
914            WorkerBuilder::new("http://localhost:3000", "token").max_consecutive_panics(10);
915        assert_eq!(builder.max_consecutive_panics, 10);
916    }
917
918    #[test]
919    fn builder_panic_cooldown_sets_value() {
920        let dur = Duration::from_secs(600);
921        let builder = WorkerBuilder::new("http://localhost:3000", "token").panic_cooldown(dur);
922        assert_eq!(builder.panic_cooldown, dur);
923    }
924
925    #[test]
926    fn builder_defaults_lease_settings() {
927        let builder = WorkerBuilder::new("http://localhost:3000", "token");
928        assert_eq!(builder.lease_ttl, DEFAULT_LEASE_TTL);
929        assert_eq!(
930            builder.lease_refresh_interval,
931            DEFAULT_LEASE_REFRESH_INTERVAL
932        );
933        assert!(builder.worker_id.starts_with("worker-"));
934    }
935
936    #[test]
937    fn builder_generates_a_distinct_worker_id_per_instance() {
938        let a = WorkerBuilder::new("http://localhost:3000", "token");
939        let b = WorkerBuilder::new("http://localhost:3000", "token");
940        assert_ne!(a.worker_id, b.worker_id);
941    }
942
943    #[test]
944    fn builder_worker_id_overrides_default() {
945        let builder = WorkerBuilder::new("http://localhost:3000", "token").worker_id("worker-eu-1");
946        assert_eq!(builder.worker_id, "worker-eu-1");
947    }
948
949    #[test]
950    fn builder_lease_ttl_sets_value() {
951        let dur = Duration::from_secs(120);
952        let builder = WorkerBuilder::new("http://localhost:3000", "token").lease_ttl(dur);
953        assert_eq!(builder.lease_ttl, dur);
954    }
955
956    #[test]
957    fn builder_lease_refresh_interval_sets_value() {
958        let dur = Duration::from_secs(5);
959        let builder =
960            WorkerBuilder::new("http://localhost:3000", "token").lease_refresh_interval(dur);
961        assert_eq!(builder.lease_refresh_interval, dur);
962    }
963
964    #[test]
965    fn builder_build_without_provider_fails() {
966        let builder = WorkerBuilder::new("http://localhost:3000", "token");
967        let result = builder.build();
968        assert!(result.is_err());
969        match result {
970            Err(WorkerError::Internal(msg)) => {
971                assert!(msg.contains("provider is required"));
972            }
973            _ => panic!("expected Internal error about missing provider"),
974        }
975    }
976
977    #[test]
978    fn builder_build_with_provider_succeeds() {
979        let provider = Arc::new(ClaudeCodeProvider::new());
980        let builder = WorkerBuilder::new("http://localhost:3000", "token").provider(provider);
981        let result = builder.build();
982        assert!(result.is_ok());
983    }
984
985    #[test]
986    fn builder_build_creates_worker_with_correct_concurrency() {
987        let provider = Arc::new(ClaudeCodeProvider::new());
988        let builder = WorkerBuilder::new("http://localhost:3000", "token")
989            .provider(provider)
990            .concurrency(16);
991        let worker = builder.build().unwrap();
992        assert_eq!(worker.concurrency, 16);
993    }
994
995    #[test]
996    fn builder_build_creates_worker_with_correct_interval() {
997        let provider = Arc::new(ClaudeCodeProvider::new());
998        let interval = Duration::from_secs(10);
999        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1000            .provider(provider)
1001            .poll_interval(interval);
1002        let worker = builder.build().unwrap();
1003        assert_eq!(worker.poll_interval, interval);
1004    }
1005
1006    #[test]
1007    fn builder_build_preserves_timeout() {
1008        let provider = Arc::new(ClaudeCodeProvider::new());
1009        let dur = Duration::from_secs(300);
1010        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1011            .provider(provider)
1012            .run_timeout(dur)
1013            .build()
1014            .unwrap();
1015        assert_eq!(worker.run_timeout, dur);
1016    }
1017
1018    #[test]
1019    fn builder_build_preserves_poison_pill_config() {
1020        let provider = Arc::new(ClaudeCodeProvider::new());
1021        let cooldown = Duration::from_secs(120);
1022        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1023            .provider(provider)
1024            .max_consecutive_panics(7)
1025            .panic_cooldown(cooldown)
1026            .build()
1027            .unwrap();
1028        assert_eq!(worker.max_consecutive_panics, 7);
1029        assert_eq!(worker.panic_cooldown, cooldown);
1030    }
1031
1032    #[test]
1033    fn builder_chaining_works() {
1034        let provider = Arc::new(ClaudeCodeProvider::new());
1035        let result = WorkerBuilder::new("http://localhost:3000", "token")
1036            .provider(provider)
1037            .concurrency(4)
1038            .poll_interval(Duration::from_secs(3))
1039            .run_timeout(Duration::from_secs(600))
1040            .max_consecutive_panics(5)
1041            .panic_cooldown(Duration::from_secs(120))
1042            .build();
1043        assert!(result.is_ok());
1044        let worker = result.unwrap();
1045        assert_eq!(worker.concurrency, 4);
1046        assert_eq!(worker.poll_interval, Duration::from_secs(3));
1047        assert_eq!(worker.run_timeout, Duration::from_secs(600));
1048        assert_eq!(worker.max_consecutive_panics, 5);
1049        assert_eq!(worker.panic_cooldown, Duration::from_secs(120));
1050    }
1051
1052    #[test]
1053    fn builder_empty_api_url_accepted() {
1054        let provider = Arc::new(ClaudeCodeProvider::new());
1055        let builder = WorkerBuilder::new("", "token").provider(provider);
1056        let result = builder.build();
1057        assert!(result.is_ok());
1058    }
1059
1060    #[test]
1061    fn builder_empty_token_accepted() {
1062        let provider = Arc::new(ClaudeCodeProvider::new());
1063        let builder = WorkerBuilder::new("http://localhost:3000", "").provider(provider);
1064        let result = builder.build();
1065        assert!(result.is_ok());
1066    }
1067
1068    #[cfg(feature = "heartbeat")]
1069    #[test]
1070    fn builder_heartbeat_defaults() {
1071        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1072        assert!(builder.heartbeat_url.is_none());
1073        assert_eq!(builder.heartbeat_interval, DEFAULT_HEARTBEAT_INTERVAL);
1074    }
1075
1076    #[cfg(feature = "heartbeat")]
1077    #[test]
1078    fn builder_heartbeat_url_sets_url() {
1079        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1080            .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc");
1081        assert_eq!(
1082            builder.heartbeat_url.as_deref(),
1083            Some("https://uptime.betterstack.com/api/v1/heartbeat/abc")
1084        );
1085    }
1086
1087    #[cfg(feature = "heartbeat")]
1088    #[test]
1089    fn builder_heartbeat_custom_interval() {
1090        let interval = Duration::from_secs(10);
1091        let builder =
1092            WorkerBuilder::new("http://localhost:3000", "token").heartbeat_interval(interval);
1093        assert_eq!(builder.heartbeat_interval, interval);
1094    }
1095
1096    #[cfg(feature = "heartbeat")]
1097    #[test]
1098    fn builder_build_preserves_heartbeat_config() {
1099        let provider = Arc::new(ClaudeCodeProvider::new());
1100        let interval = Duration::from_secs(15);
1101        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1102            .provider(provider)
1103            .heartbeat_url("https://example.com/heartbeat")
1104            .heartbeat_interval(interval)
1105            .build()
1106            .unwrap();
1107        assert_eq!(
1108            worker.heartbeat_url.as_deref(),
1109            Some("https://example.com/heartbeat")
1110        );
1111        assert_eq!(worker.heartbeat_interval, interval);
1112    }
1113
1114    #[cfg(feature = "heartbeat")]
1115    #[test]
1116    fn builder_build_without_heartbeat_url_has_none() {
1117        let provider = Arc::new(ClaudeCodeProvider::new());
1118        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1119            .provider(provider)
1120            .build()
1121            .unwrap();
1122        assert!(worker.heartbeat_url.is_none());
1123    }
1124
1125    // --- PoisonPillTracker tests ---
1126
1127    #[test]
1128    fn poison_tracker_not_blocked_initially() {
1129        let tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1130        assert!(!tracker.is_blocked("my-workflow"));
1131    }
1132
1133    #[test]
1134    fn poison_tracker_blocked_after_max_panics() {
1135        let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1136        assert!(!tracker.record_panic("wf"));
1137        assert!(!tracker.record_panic("wf"));
1138        assert!(tracker.record_panic("wf"));
1139        assert!(tracker.is_blocked("wf"));
1140    }
1141
1142    #[test]
1143    fn poison_tracker_success_resets_count() {
1144        let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1145        tracker.record_panic("wf");
1146        tracker.record_panic("wf");
1147        tracker.record_success("wf");
1148        assert!(!tracker.is_blocked("wf"));
1149        // After reset, need 3 more panics to block
1150        assert!(!tracker.record_panic("wf"));
1151    }
1152
1153    #[test]
1154    fn poison_tracker_independent_per_workflow() {
1155        let mut tracker = PoisonPillTracker::new(2, Duration::from_secs(300));
1156        tracker.record_panic("wf-a");
1157        tracker.record_panic("wf-a");
1158        assert!(tracker.is_blocked("wf-a"));
1159        assert!(!tracker.is_blocked("wf-b"));
1160    }
1161
1162    #[test]
1163    fn poison_tracker_unblocks_after_cooldown() {
1164        let mut tracker = PoisonPillTracker::new(2, Duration::from_millis(0));
1165        tracker.record_panic("wf");
1166        tracker.record_panic("wf");
1167        // Cooldown is 0ms, should immediately unblock
1168        assert!(!tracker.is_blocked("wf"));
1169    }
1170}