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