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