Skip to main content

ironflow_worker/
worker.rs

1//! Worker -- polls the API for pending runs and executes them.
2
3use std::collections::HashMap;
4#[cfg(feature = "prometheus")]
5use std::net::SocketAddr;
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, Instant};
8
9use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc};
10use tokio::time::{sleep, timeout};
11use tokio::{select, spawn};
12use tokio_util::sync::CancellationToken;
13use tracing::{error, info, warn};
14use uuid::Uuid;
15
16use ironflow_core::account_strategy::{AccountStrategy, LeastUtilized};
17use ironflow_core::decision::DecisionProvider;
18#[cfg(feature = "prometheus")]
19use ironflow_core::metric_names::{WORKER_ACTIVE, WORKER_LEASES_LOST_TOTAL, WORKER_POLLS_TOTAL};
20use ironflow_core::provider::AgentProvider;
21use ironflow_engine::accounts::{AccountAwareProvider, DEFAULT_MAX_CAPACITY_WAIT};
22use ironflow_engine::engine::{Engine, chain_root};
23use ironflow_engine::error::EngineError;
24use ironflow_engine::handler::WorkflowHandler;
25use ironflow_engine::log_sender::LogReceiver;
26use ironflow_store::entities::{
27    LeaseRequest, RunStatus, WorkerCapabilities, normalize_worker_tags, validate_worker_tags,
28};
29use ironflow_store::error::StoreError;
30use ironflow_store::store::Store;
31#[cfg(feature = "prometheus")]
32use metrics::{counter, gauge, set_global_recorder};
33#[cfg(feature = "prometheus")]
34use metrics_exporter_prometheus::PrometheusBuilder;
35#[cfg(feature = "heartbeat")]
36use reqwest::Client;
37
38use crate::api_store::ApiRunStore;
39use crate::artifact_sink::ApiArtifactSink;
40use crate::error::WorkerError;
41use crate::log_pusher::LogPusher;
42#[cfg(feature = "prometheus")]
43use crate::queue_depth::QueueDepthGauge;
44
45const DEFAULT_CONCURRENCY: usize = 2;
46const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
47const DEFAULT_RUN_TIMEOUT: Duration = Duration::from_secs(30 * 60);
48const DEFAULT_MAX_CONSECUTIVE_PANICS: u32 = 3;
49const DEFAULT_PANIC_COOLDOWN: Duration = Duration::from_secs(5 * 60);
50/// Lease duration requested when picking a run: three missed refreshes before
51/// the run becomes recoverable by the reaper.
52const DEFAULT_LEASE_TTL: Duration = Duration::from_secs(90);
53/// How often the lease of a running run is refreshed.
54const DEFAULT_LEASE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
55#[cfg(feature = "heartbeat")]
56const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
57
58/// Builder for configuring and creating a [`Worker`].
59///
60/// # Examples
61///
62/// ```no_run
63/// use std::sync::Arc;
64/// use std::time::Duration;
65/// use ironflow_worker::WorkerBuilder;
66/// use ironflow_core::providers::claude::ClaudeCodeProvider;
67///
68/// # async fn example() -> Result<(), ironflow_worker::WorkerError> {
69/// let worker = WorkerBuilder::new("http://localhost:3000", "my-token")
70///     .provider(Arc::new(ClaudeCodeProvider::new()))
71///     .concurrency(4)
72///     .poll_interval(Duration::from_secs(2))
73///     .run_timeout(Duration::from_secs(600))
74///     .max_consecutive_panics(5)
75///     .build()?;
76///
77/// worker.run().await?;
78/// # Ok(())
79/// # }
80/// ```
81pub struct WorkerBuilder {
82    api_url: String,
83    worker_token: String,
84    worker_id: String,
85    provider: Option<Arc<dyn AgentProvider>>,
86    decision_provider: Option<Arc<dyn DecisionProvider>>,
87    account_strategy: Option<Arc<dyn AccountStrategy>>,
88    max_capacity_wait: Duration,
89    handlers: Vec<Box<dyn WorkflowHandler>>,
90    concurrency: usize,
91    poll_interval: Duration,
92    run_timeout: Duration,
93    max_consecutive_panics: u32,
94    panic_cooldown: Duration,
95    lease_ttl: Duration,
96    tags: Vec<String>,
97    lease_refresh_interval: Duration,
98    #[cfg(feature = "heartbeat")]
99    heartbeat_url: Option<String>,
100    #[cfg(feature = "heartbeat")]
101    heartbeat_interval: Duration,
102    #[cfg(feature = "prometheus")]
103    metrics_addr: Option<SocketAddr>,
104}
105
106impl WorkerBuilder {
107    /// Create a new builder targeting the given API server.
108    pub fn new(api_url: &str, worker_token: &str) -> Self {
109        Self {
110            api_url: api_url.to_string(),
111            worker_token: worker_token.to_string(),
112            worker_id: format!("worker-{}", Uuid::now_v7()),
113            provider: None,
114            decision_provider: None,
115            account_strategy: None,
116            max_capacity_wait: DEFAULT_MAX_CAPACITY_WAIT,
117            handlers: Vec::new(),
118            concurrency: DEFAULT_CONCURRENCY,
119            poll_interval: DEFAULT_POLL_INTERVAL,
120            run_timeout: DEFAULT_RUN_TIMEOUT,
121            max_consecutive_panics: DEFAULT_MAX_CONSECUTIVE_PANICS,
122            panic_cooldown: DEFAULT_PANIC_COOLDOWN,
123            lease_ttl: DEFAULT_LEASE_TTL,
124            lease_refresh_interval: DEFAULT_LEASE_REFRESH_INTERVAL,
125            tags: Vec::new(),
126            #[cfg(feature = "heartbeat")]
127            heartbeat_url: None,
128            #[cfg(feature = "heartbeat")]
129            heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
130            #[cfg(feature = "prometheus")]
131            metrics_addr: None,
132        }
133    }
134
135    /// Set the agent provider for AI operations.
136    pub fn provider(mut self, provider: Arc<dyn AgentProvider>) -> Self {
137        self.provider = Some(provider);
138        self
139    }
140
141    /// Set the [`DecisionProvider`] backend for `ctx.decision(...)` steps.
142    ///
143    /// Every run executed by this worker gets it, like
144    /// [`Engine::with_decision_provider`]. Optional: without it, a workflow that
145    /// reaches a decision step fails with
146    /// [`EngineError::NoDecisionProvider`](ironflow_engine::error::EngineError::NoDecisionProvider).
147    /// A decision step already completed in the store is replayed without
148    /// calling the provider again.
149    ///
150    /// # Examples
151    ///
152    /// ```no_run
153    /// use std::sync::Arc;
154    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
155    /// use ironflow_core::providers::record_replay_decision::RecordReplayDecisionProvider;
156    /// use ironflow_worker::WorkerBuilder;
157    ///
158    /// # fn example() -> Result<(), ironflow_worker::WorkerError> {
159    /// let worker = WorkerBuilder::new("http://localhost:3000", "token")
160    ///     .provider(Arc::new(ClaudeCodeProvider::new()))
161    ///     .decision_provider(Arc::new(RecordReplayDecisionProvider::replay("tests/fixtures")))
162    ///     .build()?;
163    /// # Ok(())
164    /// # }
165    /// ```
166    pub fn decision_provider(mut self, provider: Arc<dyn DecisionProvider>) -> Self {
167        self.decision_provider = Some(provider);
168        self
169    }
170
171    /// Set the strategy picking a Provider Account for each agent step.
172    ///
173    /// Defaults to [`LeastUtilized`]. Only used when Provider Accounts of the
174    /// provider's kind exist; otherwise agent steps run with the worker
175    /// environment.
176    ///
177    /// # Examples
178    ///
179    /// ```no_run
180    /// use std::sync::Arc;
181    /// use ironflow_core::account_strategy::Priority;
182    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
183    /// use ironflow_worker::WorkerBuilder;
184    ///
185    /// # fn example() -> Result<(), ironflow_worker::WorkerError> {
186    /// let worker = WorkerBuilder::new("http://localhost:3000", "token")
187    ///     .provider(Arc::new(ClaudeCodeProvider::new()))
188    ///     .account_strategy(Arc::new(Priority))
189    ///     .build()?;
190    /// # Ok(())
191    /// # }
192    /// ```
193    pub fn account_strategy(mut self, strategy: Arc<dyn AccountStrategy>) -> Self {
194        self.account_strategy = Some(strategy);
195        self
196    }
197
198    /// Set how long an agent step may wait for Provider Account capacity.
199    ///
200    /// When every targeted account is rate limited, the run sleeps until the
201    /// earliest reset if it falls within this bound, and fails with
202    /// `AgentError::NoCapacity` otherwise. A step's own
203    /// `AgentConfig::max_capacity_wait` takes precedence. Defaults to
204    /// [`DEFAULT_MAX_CAPACITY_WAIT`] (6 hours); [`Duration::ZERO`] fails fast.
205    ///
206    /// # Examples
207    ///
208    /// ```no_run
209    /// use std::sync::Arc;
210    /// use std::time::Duration;
211    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
212    /// use ironflow_worker::WorkerBuilder;
213    ///
214    /// # fn example() -> Result<(), ironflow_worker::WorkerError> {
215    /// let worker = WorkerBuilder::new("http://localhost:3000", "token")
216    ///     .provider(Arc::new(ClaudeCodeProvider::new()))
217    ///     .max_capacity_wait(Duration::ZERO)
218    ///     .build()?;
219    /// # Ok(())
220    /// # }
221    /// ```
222    pub fn max_capacity_wait(mut self, wait: Duration) -> Self {
223        self.max_capacity_wait = wait;
224        self
225    }
226
227    /// Register a workflow handler.
228    pub fn register(mut self, handler: impl WorkflowHandler + 'static) -> Self {
229        self.handlers.push(Box::new(handler));
230        self
231    }
232
233    /// Set the maximum number of concurrent workflow executions.
234    pub fn concurrency(mut self, n: usize) -> Self {
235        self.concurrency = n;
236        self
237    }
238
239    /// Set the interval between polls for new runs.
240    pub fn poll_interval(mut self, interval: Duration) -> Self {
241        self.poll_interval = interval;
242        self
243    }
244
245    /// Set the maximum execution time per run.
246    ///
247    /// If a run exceeds this duration, it is cancelled and marked as `Failed`
248    /// with a timeout error. Defaults to 30 minutes.
249    ///
250    /// # Examples
251    ///
252    /// ```no_run
253    /// use std::time::Duration;
254    /// use ironflow_worker::WorkerBuilder;
255    ///
256    /// # fn example() {
257    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
258    ///     .run_timeout(Duration::from_secs(600));
259    /// # }
260    /// ```
261    pub fn run_timeout(mut self, timeout: Duration) -> Self {
262        self.run_timeout = timeout;
263        self
264    }
265
266    /// Set the maximum number of consecutive panics per workflow before
267    /// the worker stops picking runs for that workflow (poison pill guard).
268    ///
269    /// When a workflow panics `max_consecutive_panics` times in a row without
270    /// a single success, the worker skips it for a cooldown period (see
271    /// [`panic_cooldown`](Self::panic_cooldown)). Defaults to 3.
272    ///
273    /// # Examples
274    ///
275    /// ```no_run
276    /// use ironflow_worker::WorkerBuilder;
277    ///
278    /// # fn example() {
279    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
280    ///     .max_consecutive_panics(5);
281    /// # }
282    /// ```
283    pub fn max_consecutive_panics(mut self, n: u32) -> Self {
284        self.max_consecutive_panics = n;
285        self
286    }
287
288    /// Set the cooldown duration after a workflow is flagged as a poison pill.
289    ///
290    /// After `max_consecutive_panics` is reached, runs for that workflow are
291    /// skipped until this duration elapses. Defaults to 5 minutes.
292    ///
293    /// # Examples
294    ///
295    /// ```no_run
296    /// use std::time::Duration;
297    /// use ironflow_worker::WorkerBuilder;
298    ///
299    /// # fn example() {
300    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
301    ///     .panic_cooldown(Duration::from_secs(600));
302    /// # }
303    /// ```
304    pub fn panic_cooldown(mut self, cooldown: Duration) -> Self {
305        self.panic_cooldown = cooldown;
306        self
307    }
308
309    /// Set the identifier this worker uses to claim runs.
310    ///
311    /// Defaults to `worker-<uuid>`, regenerated at every start so two processes
312    /// never share an identity. Override it only if you have a stable, unique
313    /// name per process.
314    ///
315    /// # Examples
316    ///
317    /// ```no_run
318    /// use ironflow_worker::WorkerBuilder;
319    ///
320    /// # fn example() {
321    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
322    ///     .worker_id("worker-eu-west-1a");
323    /// # }
324    /// ```
325    pub fn worker_id(mut self, worker_id: &str) -> Self {
326        self.worker_id = worker_id.to_string();
327        self
328    }
329
330    /// Declare tags this worker carries, adding to any set before.
331    ///
332    /// The worker only picks runs whose required tags (from
333    /// [`WorkflowHandler::required_worker_tags`] and the run's own
334    /// `worker_tags`) are all in this list, and only runs of the workflows it
335    /// registered. A run requiring a tag no worker carries stays pending.
336    /// Defaults to no tag: the worker only takes runs that require none.
337    ///
338    /// Each tag is 1 to 64 bytes of ASCII letters, digits and `- _ . : / =`,
339    /// at most 32 tags; [`build`](Self::build) rejects anything else.
340    /// Surrounding whitespace and duplicates are dropped.
341    ///
342    /// # Examples
343    ///
344    /// ```no_run
345    /// use ironflow_worker::WorkerBuilder;
346    ///
347    /// # fn example() {
348    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
349    ///     .tags(["gpu", "region:eu"]);
350    /// # }
351    /// ```
352    pub fn tags<I, S>(mut self, tags: I) -> Self
353    where
354        I: IntoIterator<Item = S>,
355        S: Into<String>,
356    {
357        self.tags.extend(tags.into_iter().map(Into::into));
358        self
359    }
360
361    /// Set how long a lease stays valid without a refresh.
362    ///
363    /// Once it expires, the API reaper requeues the run for another worker.
364    /// Keep it at a few times [`lease_refresh_interval`](Self::lease_refresh_interval)
365    /// so a transient network blip does not hand the run over. Defaults to 90 s.
366    ///
367    /// # Examples
368    ///
369    /// ```no_run
370    /// use std::time::Duration;
371    /// use ironflow_worker::WorkerBuilder;
372    ///
373    /// # fn example() {
374    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
375    ///     .lease_ttl(Duration::from_secs(120));
376    /// # }
377    /// ```
378    pub fn lease_ttl(mut self, ttl: Duration) -> Self {
379        self.lease_ttl = ttl;
380        self
381    }
382
383    /// Set how often the lease of a running run is refreshed.
384    ///
385    /// Defaults to 30 seconds. Must stay well below
386    /// [`lease_ttl`](Self::lease_ttl).
387    ///
388    /// # Examples
389    ///
390    /// ```no_run
391    /// use std::time::Duration;
392    /// use ironflow_worker::WorkerBuilder;
393    ///
394    /// # fn example() {
395    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
396    ///     .lease_refresh_interval(Duration::from_secs(10));
397    /// # }
398    /// ```
399    pub fn lease_refresh_interval(mut self, interval: Duration) -> Self {
400        self.lease_refresh_interval = interval;
401        self
402    }
403
404    /// Serve the worker metrics on `addr` in the Prometheus text format.
405    ///
406    /// When [`Worker::run`] starts, it installs the process-global metrics
407    /// recorder and serves the metrics on `GET /metrics` from a background
408    /// task. [`Worker::run`] fails if a recorder is already installed in the
409    /// process. Without this option nothing listens and the global recorder
410    /// is left untouched. The library does not read any environment variable:
411    /// the address is the caller's to provide.
412    ///
413    /// # Examples
414    ///
415    /// ```no_run
416    /// use std::net::{AddrParseError, SocketAddr};
417    /// use ironflow_worker::WorkerBuilder;
418    ///
419    /// # fn example() -> Result<(), AddrParseError> {
420    /// let addr: SocketAddr = "0.0.0.0:9100".parse()?;
421    /// let builder = WorkerBuilder::new("http://localhost:3000", "token").metrics_addr(addr);
422    /// # Ok(())
423    /// # }
424    /// ```
425    #[cfg(feature = "prometheus")]
426    pub fn metrics_addr(mut self, addr: SocketAddr) -> Self {
427        self.metrics_addr = Some(addr);
428        self
429    }
430
431    /// Set the heartbeat URL (dead man's switch).
432    ///
433    /// The worker pings this URL at every heartbeat interval with an HTTP
434    /// HEAD request. Compatible with BetterStack Heartbeats, Cronitor,
435    /// Healthchecks.io, or any dead man's switch service.
436    ///
437    /// If not set, no heartbeat is emitted even when the feature is enabled.
438    ///
439    /// # Examples
440    ///
441    /// ```no_run
442    /// use ironflow_worker::WorkerBuilder;
443    ///
444    /// # fn example() {
445    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
446    ///     .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc123");
447    /// # }
448    /// ```
449    #[cfg(feature = "heartbeat")]
450    pub fn heartbeat_url(mut self, url: &str) -> Self {
451        self.heartbeat_url = Some(url.to_string());
452        self
453    }
454
455    /// Set the heartbeat interval.
456    ///
457    /// Controls how often the worker pings the [`heartbeat_url`](Self::heartbeat_url).
458    /// Defaults to 30 seconds.
459    ///
460    /// # Examples
461    ///
462    /// ```no_run
463    /// use std::time::Duration;
464    /// use ironflow_worker::WorkerBuilder;
465    ///
466    /// # fn example() {
467    /// let builder = WorkerBuilder::new("http://localhost:3000", "token")
468    ///     .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc123")
469    ///     .heartbeat_interval(Duration::from_secs(60));
470    /// # }
471    /// ```
472    #[cfg(feature = "heartbeat")]
473    pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
474        self.heartbeat_interval = interval;
475        self
476    }
477
478    /// Build the worker.
479    ///
480    /// # Errors
481    ///
482    /// Returns [`WorkerError::Internal`] if no provider has been set.
483    /// Returns [`WorkerError::Engine`] if a handler registration fails, or
484    /// wrapping [`EngineError::InvalidWorkerTag`] if a tag set with
485    /// [`tags`](Self::tags) is invalid.
486    pub fn build(self) -> Result<Worker, WorkerError> {
487        let provider = self
488            .provider
489            .ok_or_else(|| WorkerError::Internal("WorkerBuilder: provider is required".into()))?;
490        validate_worker_tags(&self.tags).map_err(EngineError::InvalidWorkerTag)?;
491        let tags = normalize_worker_tags(self.tags);
492
493        let store: Arc<dyn Store> = Arc::new(ApiRunStore::new(&self.api_url, &self.worker_token));
494        let strategy = self
495            .account_strategy
496            .unwrap_or_else(|| Arc::new(LeastUtilized));
497        let provider: Arc<dyn AgentProvider> = Arc::new(
498            AccountAwareProvider::new(provider, store.clone())
499                .with_strategy(strategy)
500                .with_max_capacity_wait(self.max_capacity_wait),
501        );
502
503        let mut engine = Engine::new(store, provider);
504        if let Some(decision_provider) = self.decision_provider {
505            engine = engine.with_decision_provider(decision_provider);
506        }
507        for handler in self.handlers {
508            engine
509                .register_boxed(handler)
510                .map_err(WorkerError::Engine)?;
511        }
512        let capabilities = WorkerCapabilities::new(registered_workflows(&engine), tags.clone());
513        engine.set_worker_tags(tags);
514
515        let (log_sender, log_receiver) = ironflow_engine::log_sender::channel();
516        engine.set_log_sender(log_sender);
517
518        // Artifact bytes travel through the API: the worker holds no storage
519        // credential. When the API has no backend configured, a step that
520        // declares artifacts fails with a clear message and the rest runs on.
521        engine.set_artifact_sink(Arc::new(ApiArtifactSink::new(
522            &self.api_url,
523            &self.worker_token,
524        )));
525
526        #[cfg(feature = "heartbeat")]
527        let heartbeat_client = Client::builder()
528            .timeout(Duration::from_secs(5))
529            .build()
530            .expect("failed to build heartbeat HTTP client");
531
532        Ok(Worker {
533            engine: Arc::new(engine),
534            api_url: self.api_url,
535            worker_token: self.worker_token,
536            worker_id: self.worker_id,
537            capabilities,
538            log_receiver: Mutex::new(Some(log_receiver)),
539            concurrency: self.concurrency,
540            poll_interval: self.poll_interval,
541            run_timeout: self.run_timeout,
542            max_consecutive_panics: self.max_consecutive_panics,
543            panic_cooldown: self.panic_cooldown,
544            lease_ttl: self.lease_ttl,
545            lease_refresh_interval: self.lease_refresh_interval,
546            #[cfg(feature = "heartbeat")]
547            heartbeat_url: self.heartbeat_url,
548            #[cfg(feature = "heartbeat")]
549            heartbeat_interval: self.heartbeat_interval,
550            #[cfg(feature = "heartbeat")]
551            heartbeat_client,
552            #[cfg(feature = "prometheus")]
553            metrics_addr: self.metrics_addr,
554        })
555    }
556}
557
558/// Background worker that polls the API and executes workflows.
559pub struct Worker {
560    engine: Arc<Engine>,
561    api_url: String,
562    worker_token: String,
563    worker_id: String,
564    capabilities: WorkerCapabilities,
565    log_receiver: Mutex<Option<LogReceiver>>,
566    concurrency: usize,
567    poll_interval: Duration,
568    run_timeout: Duration,
569    max_consecutive_panics: u32,
570    panic_cooldown: Duration,
571    lease_ttl: Duration,
572    lease_refresh_interval: Duration,
573    #[cfg(feature = "heartbeat")]
574    heartbeat_url: Option<String>,
575    #[cfg(feature = "heartbeat")]
576    heartbeat_interval: Duration,
577    #[cfg(feature = "heartbeat")]
578    heartbeat_client: Client,
579    #[cfg(feature = "prometheus")]
580    metrics_addr: Option<SocketAddr>,
581}
582
583/// Tracks consecutive failures per workflow for poison pill detection.
584struct PoisonPillTracker {
585    max_consecutive: u32,
586    cooldown: Duration,
587    /// Maps workflow name to (consecutive panic count, last panic time).
588    state: HashMap<String, (u32, Instant)>,
589}
590
591impl PoisonPillTracker {
592    fn new(max_consecutive: u32, cooldown: Duration) -> Self {
593        Self {
594            max_consecutive,
595            cooldown,
596            state: HashMap::new(),
597        }
598    }
599
600    /// Record a panic for a workflow. Returns `true` if the workflow is now
601    /// considered a poison pill.
602    fn record_panic(&mut self, workflow: &str) -> bool {
603        let entry = self
604            .state
605            .entry(workflow.to_string())
606            .or_insert((0, Instant::now()));
607        entry.0 += 1;
608        entry.1 = Instant::now();
609        entry.0 >= self.max_consecutive
610    }
611
612    /// Record a successful execution, resetting the panic counter.
613    fn record_success(&mut self, workflow: &str) {
614        self.state.remove(workflow);
615    }
616
617    /// Returns `true` if the workflow is currently blocked as a poison pill.
618    fn is_blocked(&self, workflow: &str) -> bool {
619        self.state.get(workflow).is_some_and(|(count, last_panic)| {
620            *count >= self.max_consecutive && last_panic.elapsed() < self.cooldown
621        })
622    }
623}
624
625impl Worker {
626    /// What this worker asks the API for: the workflows it registered,
627    /// sorted, and the tags it carries, sorted and deduplicated.
628    ///
629    /// # Examples
630    ///
631    /// ```no_run
632    /// use std::sync::Arc;
633    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
634    /// use ironflow_worker::WorkerBuilder;
635    ///
636    /// # fn example() -> Result<(), ironflow_worker::WorkerError> {
637    /// let worker = WorkerBuilder::new("http://localhost:3000", "token")
638    ///     .provider(Arc::new(ClaudeCodeProvider::new()))
639    ///     .tags(["gpu"])
640    ///     .build()?;
641    /// assert_eq!(worker.capabilities().tags, vec!["gpu".to_string()]);
642    /// # Ok(())
643    /// # }
644    /// ```
645    pub fn capabilities(&self) -> &WorkerCapabilities {
646        &self.capabilities
647    }
648
649    /// Run the worker loop until a shutdown signal (SIGTERM/SIGINT) is received.
650    ///
651    /// On shutdown, the worker stops picking new runs and waits for all
652    /// in-flight executions to complete before returning.
653    ///
654    /// # Errors
655    ///
656    /// Returns [`WorkerError`] if the polling loop encounters an unrecoverable error.
657    /// With the `prometheus` feature and a
658    /// [`metrics_addr`](WorkerBuilder::metrics_addr), returns
659    /// [`WorkerError::Internal`] before polling if the exporter cannot be built
660    /// (including when the address cannot be bound, for instance because it is
661    /// already in use) or a metrics recorder is already installed in the process.
662    pub async fn run(&self) -> Result<(), WorkerError> {
663        let semaphore = Arc::new(Semaphore::new(self.concurrency));
664        let shutdown = CancellationToken::new();
665        let mut idle_streak = 0u32;
666        let poison_tracker = Arc::new(Mutex::new(PoisonPillTracker::new(
667            self.max_consecutive_panics,
668            self.panic_cooldown,
669        )));
670        let (outcome_tx, mut outcome_rx) = mpsc::unbounded_channel::<RunOutcome>();
671
672        info!(
673            concurrency = self.concurrency,
674            poll_interval_ms = self.poll_interval.as_millis() as u64,
675            run_timeout_secs = self.run_timeout.as_secs(),
676            "worker started"
677        );
678
679        #[cfg(feature = "prometheus")]
680        if let Some(addr) = self.metrics_addr {
681            let (recorder, exporter) = PrometheusBuilder::new()
682                .with_http_listener(addr)
683                .build()
684                .map_err(|e| {
685                    WorkerError::Internal(format!(
686                        "cannot build the Prometheus exporter on {addr}: {e}"
687                    ))
688                })?;
689            set_global_recorder(recorder).map_err(|e| {
690                WorkerError::Internal(format!("a metrics recorder is already installed: {e}"))
691            })?;
692            spawn(async move {
693                if let Err(e) = exporter.await {
694                    error!(error = ?e, "prometheus exporter stopped");
695                }
696            });
697            info!(%addr, "prometheus metrics endpoint listening");
698        }
699
700        if let Some(receiver) = self.log_receiver.lock().expect("log_receiver lock").take() {
701            let pusher = LogPusher::new(&self.api_url, &self.worker_token);
702            spawn(pusher.run(receiver));
703            info!("log pusher started");
704        }
705
706        // Spawn shutdown signal handler
707        let shutdown_clone = shutdown.clone();
708        spawn(async move {
709            shutdown_signal().await;
710            info!("shutdown signal received, draining in-flight runs...");
711            shutdown_clone.cancel();
712        });
713
714        #[cfg(feature = "heartbeat")]
715        if let Some(ref url) = self.heartbeat_url {
716            let interval = self.heartbeat_interval;
717            let url = url.clone();
718            let client = self.heartbeat_client.clone();
719
720            spawn(async move {
721                let mut ticker = tokio::time::interval(interval);
722                // skip the first immediate tick
723                ticker.tick().await;
724                loop {
725                    ticker.tick().await;
726                    match client.head(&url).send().await {
727                        Ok(resp) if resp.status().is_success() => {
728                            info!(url = %url, "heartbeat sent");
729                        }
730                        Ok(resp) => {
731                            warn!(
732                                url = %url,
733                                status = %resp.status(),
734                                "heartbeat ping returned non-success status"
735                            );
736                        }
737                        Err(err) => {
738                            warn!(
739                                url = %url,
740                                error = %err,
741                                "heartbeat ping failed"
742                            );
743                        }
744                    }
745                }
746            });
747        }
748
749        #[cfg(feature = "prometheus")]
750        let mut queue_depth =
751            QueueDepthGauge::new(&self.api_url, &self.worker_token, &self.capabilities);
752
753        while !shutdown.is_cancelled() {
754            // Drain outcome channel to update poison pill tracker
755            while let Ok(outcome) = outcome_rx.try_recv() {
756                let mut tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
757                match outcome {
758                    RunOutcome::Success(ref wf) => tracker.record_success(wf),
759                    // Losing a lease says nothing about the workflow itself:
760                    // it must not count towards the poison pill threshold.
761                    RunOutcome::LeaseLost(ref wf) => {
762                        warn!(workflow = %wf, "run abandoned after losing its lease")
763                    }
764                    RunOutcome::Failed(ref wf) | RunOutcome::Timeout(ref wf) => {
765                        if tracker.record_panic(wf) {
766                            warn!(workflow = %wf, "workflow flagged as poison pill after consecutive failures");
767                        }
768                    }
769                    RunOutcome::Panicked(ref wf) => {
770                        if tracker.record_panic(wf) {
771                            error!(workflow = %wf, "workflow flagged as poison pill after consecutive panics");
772                        }
773                    }
774                }
775            }
776
777            // Claim a run only once a slot is free: a claimed run is `Running`
778            // under a lease that nobody refreshes until it starts executing.
779            let Some(permit) = acquire_slot(&semaphore, &shutdown).await? else {
780                break;
781            };
782
783            let run = self
784                .engine
785                .store()
786                .pick_next_pending_for(
787                    Some(LeaseRequest {
788                        worker_id: self.worker_id.clone(),
789                        ttl: self.lease_ttl,
790                    }),
791                    Some(self.capabilities.clone()),
792                )
793                .await;
794
795            match run {
796                Ok(Some(run)) => {
797                    #[cfg(feature = "prometheus")]
798                    counter!(WORKER_POLLS_TOTAL, "result" => "hit").increment(1);
799
800                    // Poison pill check: skip workflows that keep failing
801                    let is_blocked = {
802                        let tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
803                        tracker.is_blocked(&run.workflow_name)
804                    };
805                    if is_blocked {
806                        warn!(
807                            workflow = %run.workflow_name,
808                            run_id = %run.id,
809                            "skipping run: workflow flagged as poison pill, marking as failed"
810                        );
811                        if let Err(e) = self
812                            .engine
813                            .store()
814                            .update_run_status(run.id, RunStatus::Failed)
815                            .await
816                        {
817                            error!(run_id = %run.id, error = %e, "failed to mark poisoned run as failed");
818                        }
819                        drop(permit);
820                        continue;
821                    }
822
823                    idle_streak = 0;
824                    let engine = self.engine.clone();
825                    let run_id = run.id;
826                    let workflow = run.workflow_name.clone();
827                    let workflow_for_watcher = workflow.clone();
828                    let run_timeout = self.run_timeout;
829
830                    info!(run_id = %run_id, workflow = %workflow, "executing run");
831
832                    #[cfg(feature = "prometheus")]
833                    gauge!(WORKER_ACTIVE).increment(1.0);
834
835                    // Keep the lease alive for as long as this run executes, and
836                    // abandon the run as soon as the lease is lost.
837                    let lease_token = CancellationToken::new();
838                    let refresher = spawn(refresh_lease(
839                        self.engine.store().clone(),
840                        run_id,
841                        LeaseRequest {
842                            worker_id: self.worker_id.clone(),
843                            ttl: self.lease_ttl,
844                        },
845                        self.lease_refresh_interval,
846                        lease_token.clone(),
847                    ));
848
849                    let handle = spawn(async move {
850                        let _permit = permit;
851                        let result = select! {
852                            biased;
853                            _ = lease_token.cancelled() => {
854                                refresher.abort();
855                                // Dropping the execution future here cancels it;
856                                // child processes die with it (kill_on_drop).
857                                warn!(
858                                    run_id = %run_id,
859                                    workflow = %workflow,
860                                    "abandoning run: worker lease lost"
861                                );
862                                #[cfg(feature = "prometheus")]
863                                counter!(WORKER_LEASES_LOST_TOTAL).increment(1);
864                                // No status write: the run belongs to someone else now.
865                                return RunOutcome::LeaseLost(workflow);
866                            }
867                            result = timeout(run_timeout, engine.execute_handler_run(run_id)) => result,
868                        };
869                        refresher.abort();
870
871                        match result {
872                            Ok(Ok(_)) => {
873                                info!(run_id = %run_id, workflow = %workflow, "run completed");
874                                RunOutcome::Success(workflow)
875                            }
876                            // `execute_handler_run` already routed this failure
877                            // through `fail_or_schedule_retry` (or, for a budget
878                            // refusal, straight to Cancelled) and cleaned up the
879                            // orphaned steps, so the run is either terminal or
880                            // waiting for its retry. Writing Failed here again
881                            // would cancel an armed retry and would be rejected
882                            // by the FSM for a cancelled run.
883                            Ok(Err(e)) => {
884                                error!(run_id = %run_id, workflow = %workflow, error = %e, "run failed");
885                                RunOutcome::Failed(workflow)
886                            }
887                            Err(_) => {
888                                error!(
889                                    run_id = %run_id,
890                                    workflow = %workflow,
891                                    timeout_secs = run_timeout.as_secs(),
892                                    "run timed out"
893                                );
894                                let timeout_msg =
895                                    format!("run timed out after {}s", run_timeout.as_secs());
896                                // A timeout is transient by nature: worth replaying.
897                                if let Err(e) = engine
898                                    .fail_or_schedule_retry(run_id, &timeout_msg, true, None, None)
899                                    .await
900                                {
901                                    error!(run_id = %run_id, error = %e, "failed to record timed-out run");
902                                }
903                                RunOutcome::Timeout(workflow)
904                            }
905                        }
906                    });
907
908                    // Spawn a watcher to catch panics and report outcomes
909                    let watcher_engine = self.engine.clone();
910                    let tx = outcome_tx.clone();
911                    spawn(async move {
912                        match handle.await {
913                            Ok(outcome) => {
914                                let _ = tx.send(outcome);
915                            }
916                            Err(e) => {
917                                error!(run_id = %run_id, "spawned task panicked: {e}");
918                                // Retryable: a panic can be transient, and the
919                                // poison pill tracker stops a workflow that keeps
920                                // panicking across runs.
921                                if let Err(store_err) = watcher_engine
922                                    .fail_or_schedule_retry(
923                                        run_id,
924                                        "parent run panicked",
925                                        true,
926                                        None,
927                                        None,
928                                    )
929                                    .await
930                                {
931                                    error!(run_id = %run_id, error = %store_err, "failed to record panicked run");
932                                }
933                                let _ = tx.send(RunOutcome::Panicked(workflow_for_watcher));
934                            }
935                        }
936                        #[cfg(feature = "prometheus")]
937                        gauge!(WORKER_ACTIVE).decrement(1.0);
938                    });
939                }
940                Ok(None) => {
941                    drop(permit);
942                    #[cfg(feature = "prometheus")]
943                    counter!(WORKER_POLLS_TOTAL, "result" => "miss").increment(1);
944
945                    idle_streak += 1;
946                    let backoff = if idle_streak > 10 {
947                        self.poll_interval * 3
948                    } else if idle_streak > 5 {
949                        self.poll_interval * 2
950                    } else {
951                        self.poll_interval
952                    };
953                    sleep(backoff).await;
954                }
955                Err(e) => {
956                    drop(permit);
957                    warn!(error = %e, "poll error");
958                    sleep(self.poll_interval).await;
959                }
960            }
961
962            #[cfg(feature = "prometheus")]
963            queue_depth.refresh_if_due().await;
964        }
965
966        // Graceful drain: wait for all in-flight tasks to release their permits
967        info!(
968            in_flight = self.concurrency - semaphore.available_permits(),
969            "waiting for in-flight runs to complete..."
970        );
971        let _ = semaphore
972            .acquire_many(self.concurrency as u32)
973            .await
974            .map_err(|_| WorkerError::Shutdown("semaphore closed during drain".to_string()))?;
975
976        info!("all in-flight runs completed, worker shut down");
977        Ok(())
978    }
979}
980
981/// Sorted names of the workflows registered on `engine`, sent to the API so
982/// the worker is only handed runs it can execute.
983///
984/// The names travel as one comma-separated query parameter: if one contains a
985/// comma it cannot be sent, so `None` is returned (any workflow) with a
986/// warning, and tags alone route the runs.
987fn registered_workflows(engine: &Engine) -> Option<Vec<String>> {
988    let mut names: Vec<String> = engine
989        .handler_names()
990        .into_iter()
991        .map(str::to_string)
992        .collect();
993    if let Some(name) = names.iter().find(|name| name.contains(',')) {
994        warn!(
995            workflow = %name,
996            "workflow name contains a comma: the worker will not filter runs by workflow"
997        );
998        return None;
999    }
1000    names.sort();
1001    Some(names)
1002}
1003
1004/// Outcome of a single run execution, used for poison pill tracking.
1005enum RunOutcome {
1006    /// Run completed successfully.
1007    Success(String),
1008    /// Run failed with an error (engine returned Err).
1009    Failed(String),
1010    /// Run exceeded its timeout.
1011    Timeout(String),
1012    /// Run panicked (task JoinError).
1013    Panicked(String),
1014    /// Run was abandoned because this worker lost its lease: another worker
1015    /// took it over, or an operator paused the run.
1016    LeaseLost(String),
1017}
1018
1019/// Wait for a free execution slot, or give up as soon as shutdown is requested.
1020///
1021/// Returns `Ok(None)` on shutdown, even when a slot is free, so no run is
1022/// claimed once the worker is draining.
1023///
1024/// # Errors
1025///
1026/// Returns [`WorkerError::Internal`] if the semaphore has been closed.
1027async fn acquire_slot(
1028    semaphore: &Arc<Semaphore>,
1029    shutdown: &CancellationToken,
1030) -> Result<Option<OwnedSemaphorePermit>, WorkerError> {
1031    select! {
1032        biased;
1033        _ = shutdown.cancelled() => Ok(None),
1034        permit = semaphore.clone().acquire_owned() => permit
1035            .map(Some)
1036            .map_err(|e| WorkerError::Internal(format!("semaphore closed: {e}"))),
1037    }
1038}
1039
1040/// Keep a run's lease alive until the run finishes or the lease is lost.
1041///
1042/// Cancels `lease_token` when the API hands the run to another worker, or when
1043/// refreshes keep failing for longer than the lease TTL — in both cases this
1044/// worker must stop executing the run rather than risk a double execution.
1045///
1046/// A sub-workflow child run is executed by resuming its root run, which takes
1047/// the child's lease over (see [`Engine::execute_handler_run`]). When the
1048/// child's lease is lost, the refresher follows it to the root: if the root's
1049/// lease renews for this worker, the root is refreshed from then on instead
1050/// of abandoning the execution.
1051async fn refresh_lease(
1052    store: Arc<dyn Store>,
1053    run_id: Uuid,
1054    lease: LeaseRequest,
1055    refresh_interval: Duration,
1056    lease_token: CancellationToken,
1057) {
1058    let ttl = lease.ttl;
1059    let mut deadline = Instant::now() + ttl;
1060    let mut target = run_id;
1061
1062    loop {
1063        sleep(refresh_interval).await;
1064
1065        match store.renew_lease(target, lease.clone()).await {
1066            Ok(_) => {
1067                deadline = Instant::now() + ttl;
1068            }
1069            Err(StoreError::LeaseLost { held_by, .. }) => {
1070                let followed = if target == run_id {
1071                    follow_lease_to_root(store.as_ref(), run_id, &lease).await
1072                } else {
1073                    None
1074                };
1075                if let Some(root) = followed {
1076                    info!(
1077                        run_id = %run_id,
1078                        root_run_id = %root,
1079                        "lease followed the run to its root"
1080                    );
1081                    target = root;
1082                    deadline = Instant::now() + ttl;
1083                    continue;
1084                }
1085                warn!(
1086                    run_id = %target,
1087                    held_by = held_by.as_deref().unwrap_or("unknown"),
1088                    "lease lost: taken over by another worker, or the run was paused"
1089                );
1090                lease_token.cancel();
1091                return;
1092            }
1093            Err(err) if Instant::now() >= deadline => {
1094                // The API has been unreachable longer than the lease lasts:
1095                // another worker may already have picked the run up.
1096                warn!(
1097                    run_id = %target,
1098                    error = %err,
1099                    ttl_secs = ttl.as_secs(),
1100                    "lease could not be refreshed before it expired"
1101                );
1102                lease_token.cancel();
1103                return;
1104            }
1105            Err(err) => {
1106                warn!(run_id = %target, error = %err, "lease refresh failed, retrying");
1107            }
1108        }
1109    }
1110}
1111
1112/// The root run that took the lease of `run_id` over, if any.
1113///
1114/// `Some` only when `run_id` is a sub-workflow child run and the lease of its
1115/// root renews for this worker, i.e. the engine handed the child's lease to
1116/// the root it resumed. Any failure yields `None`: the caller then treats the
1117/// lease as lost.
1118async fn follow_lease_to_root(
1119    store: &dyn Store,
1120    run_id: Uuid,
1121    lease: &LeaseRequest,
1122) -> Option<Uuid> {
1123    let run = match store.get_run(run_id).await {
1124        Ok(run) => run?,
1125        Err(err) => {
1126            warn!(run_id = %run_id, error = %err, "could not load run to follow its lease");
1127            return None;
1128        }
1129    };
1130    let root = chain_root(&run)?;
1131    match store.renew_lease(root, lease.clone()).await {
1132        Ok(_) => Some(root),
1133        Err(err) => {
1134            warn!(
1135                run_id = %run_id,
1136                root_run_id = %root,
1137                error = %err,
1138                "lease could not follow the run to its root"
1139            );
1140            None
1141        }
1142    }
1143}
1144
1145/// Wait for SIGTERM or SIGINT (Ctrl+C).
1146async fn shutdown_signal() {
1147    use tokio::signal;
1148
1149    let ctrl_c = async {
1150        signal::ctrl_c()
1151            .await
1152            .expect("failed to install Ctrl+C handler");
1153    };
1154
1155    #[cfg(unix)]
1156    let terminate = async {
1157        use tokio::signal::unix::{SignalKind, signal};
1158
1159        signal(SignalKind::terminate())
1160            .expect("failed to install SIGTERM handler")
1161            .recv()
1162            .await;
1163    };
1164
1165    #[cfg(not(unix))]
1166    let terminate = {
1167        use std::future::pending;
1168        pending::<()>()
1169    };
1170
1171    select! {
1172        () = ctrl_c => {},
1173        () = terminate => {},
1174    }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179    use super::*;
1180
1181    use ironflow_core::account_strategy::Priority;
1182    use ironflow_core::providers::claude::ClaudeCodeProvider;
1183    use ironflow_core::providers::record_replay_decision::RecordReplayDecisionProvider;
1184    use ironflow_engine::context::WorkflowContext;
1185    use ironflow_engine::handler::HandlerFuture;
1186    use ironflow_store::entities::{MAX_WORKER_TAGS, WorkerTagError};
1187
1188    #[test]
1189    fn builder_new_creates_default_config() {
1190        let builder = WorkerBuilder::new("http://localhost:3000", "my-token");
1191        assert_eq!(builder.api_url, "http://localhost:3000");
1192        assert_eq!(builder.worker_token, "my-token");
1193        assert_eq!(builder.concurrency, DEFAULT_CONCURRENCY);
1194        assert_eq!(builder.poll_interval, DEFAULT_POLL_INTERVAL);
1195        assert_eq!(builder.run_timeout, DEFAULT_RUN_TIMEOUT);
1196        assert_eq!(
1197            builder.max_consecutive_panics,
1198            DEFAULT_MAX_CONSECUTIVE_PANICS
1199        );
1200        assert_eq!(builder.panic_cooldown, DEFAULT_PANIC_COOLDOWN);
1201        assert!(builder.provider.is_none());
1202    }
1203
1204    #[test]
1205    fn builder_with_trailing_slash_normalized() {
1206        let builder = WorkerBuilder::new("http://localhost:3000/", "token");
1207        assert_eq!(builder.api_url, "http://localhost:3000/");
1208    }
1209
1210    #[test]
1211    fn builder_provider_sets_provider() {
1212        let provider = Arc::new(ClaudeCodeProvider::new());
1213        let builder =
1214            WorkerBuilder::new("http://localhost:3000", "token").provider(provider.clone());
1215        assert!(builder.provider.is_some());
1216    }
1217
1218    #[test]
1219    fn builder_account_strategy_sets_strategy() {
1220        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1221        assert!(builder.account_strategy.is_none());
1222        let builder = builder.account_strategy(Arc::new(Priority));
1223        assert_eq!(
1224            builder.account_strategy.as_ref().map(|s| s.name()),
1225            Some("priority")
1226        );
1227    }
1228
1229    #[test]
1230    fn builder_max_capacity_wait_defaults_to_six_hours_and_can_be_set() {
1231        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1232        assert_eq!(builder.max_capacity_wait, DEFAULT_MAX_CAPACITY_WAIT);
1233        assert_eq!(builder.max_capacity_wait, Duration::from_secs(6 * 3600));
1234        let builder = builder.max_capacity_wait(Duration::ZERO);
1235        assert_eq!(builder.max_capacity_wait, Duration::ZERO);
1236    }
1237
1238    #[test]
1239    fn builder_decision_provider_defaults_to_none() {
1240        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1241        assert!(builder.decision_provider.is_none());
1242    }
1243
1244    #[test]
1245    fn builder_decision_provider_sets_provider() {
1246        let builder = WorkerBuilder::new("http://localhost:3000", "token").decision_provider(
1247            Arc::new(RecordReplayDecisionProvider::replay("tests/fixtures")),
1248        );
1249        assert!(builder.decision_provider.is_some());
1250    }
1251
1252    #[test]
1253    fn builder_concurrency_sets_concurrency() {
1254        let builder = WorkerBuilder::new("http://localhost:3000", "token").concurrency(8);
1255        assert_eq!(builder.concurrency, 8);
1256    }
1257
1258    #[test]
1259    fn builder_concurrency_zero_accepted() {
1260        let provider = Arc::new(ClaudeCodeProvider::new());
1261        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1262            .provider(provider)
1263            .concurrency(0);
1264        assert_eq!(builder.concurrency, 0);
1265    }
1266
1267    #[test]
1268    fn builder_poll_interval_sets_interval() {
1269        let interval = Duration::from_secs(5);
1270        let builder = WorkerBuilder::new("http://localhost:3000", "token").poll_interval(interval);
1271        assert_eq!(builder.poll_interval, interval);
1272    }
1273
1274    #[test]
1275    fn builder_run_timeout_sets_timeout() {
1276        let dur = Duration::from_secs(120);
1277        let builder = WorkerBuilder::new("http://localhost:3000", "token").run_timeout(dur);
1278        assert_eq!(builder.run_timeout, dur);
1279    }
1280
1281    #[test]
1282    fn builder_max_consecutive_panics_sets_value() {
1283        let builder =
1284            WorkerBuilder::new("http://localhost:3000", "token").max_consecutive_panics(10);
1285        assert_eq!(builder.max_consecutive_panics, 10);
1286    }
1287
1288    #[test]
1289    fn builder_panic_cooldown_sets_value() {
1290        let dur = Duration::from_secs(600);
1291        let builder = WorkerBuilder::new("http://localhost:3000", "token").panic_cooldown(dur);
1292        assert_eq!(builder.panic_cooldown, dur);
1293    }
1294
1295    #[test]
1296    fn builder_defaults_lease_settings() {
1297        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1298        assert_eq!(builder.lease_ttl, DEFAULT_LEASE_TTL);
1299        assert_eq!(
1300            builder.lease_refresh_interval,
1301            DEFAULT_LEASE_REFRESH_INTERVAL
1302        );
1303        assert!(builder.worker_id.starts_with("worker-"));
1304    }
1305
1306    #[test]
1307    fn builder_generates_a_distinct_worker_id_per_instance() {
1308        let a = WorkerBuilder::new("http://localhost:3000", "token");
1309        let b = WorkerBuilder::new("http://localhost:3000", "token");
1310        assert_ne!(a.worker_id, b.worker_id);
1311    }
1312
1313    #[test]
1314    fn builder_worker_id_overrides_default() {
1315        let builder = WorkerBuilder::new("http://localhost:3000", "token").worker_id("worker-eu-1");
1316        assert_eq!(builder.worker_id, "worker-eu-1");
1317    }
1318
1319    #[test]
1320    fn builder_lease_ttl_sets_value() {
1321        let dur = Duration::from_secs(120);
1322        let builder = WorkerBuilder::new("http://localhost:3000", "token").lease_ttl(dur);
1323        assert_eq!(builder.lease_ttl, dur);
1324    }
1325
1326    #[test]
1327    fn builder_lease_refresh_interval_sets_value() {
1328        let dur = Duration::from_secs(5);
1329        let builder =
1330            WorkerBuilder::new("http://localhost:3000", "token").lease_refresh_interval(dur);
1331        assert_eq!(builder.lease_refresh_interval, dur);
1332    }
1333
1334    #[test]
1335    fn builder_build_without_provider_fails() {
1336        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1337        let result = builder.build();
1338        assert!(result.is_err());
1339        match result {
1340            Err(WorkerError::Internal(msg)) => {
1341                assert!(msg.contains("provider is required"));
1342            }
1343            _ => panic!("expected Internal error about missing provider"),
1344        }
1345    }
1346
1347    #[test]
1348    fn builder_build_with_provider_succeeds() {
1349        let provider = Arc::new(ClaudeCodeProvider::new());
1350        let builder = WorkerBuilder::new("http://localhost:3000", "token").provider(provider);
1351        let result = builder.build();
1352        assert!(result.is_ok());
1353    }
1354
1355    struct Named(&'static str);
1356
1357    impl WorkflowHandler for Named {
1358        fn name(&self) -> &str {
1359            self.0
1360        }
1361
1362        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1363            Box::pin(async { Ok(()) })
1364        }
1365    }
1366
1367    fn tagged_builder() -> WorkerBuilder {
1368        WorkerBuilder::new("http://localhost:3000", "token")
1369            .provider(Arc::new(ClaudeCodeProvider::new()))
1370    }
1371
1372    #[test]
1373    fn builder_tags_default_to_empty() {
1374        let worker = tagged_builder().build().unwrap();
1375        assert!(worker.capabilities().tags.is_empty());
1376        let engine_tags = worker.engine.worker_tags();
1377        assert!(
1378            engine_tags.is_some_and(<[String]>::is_empty),
1379            "{engine_tags:?}"
1380        );
1381    }
1382
1383    #[test]
1384    fn builder_tags_are_normalized_and_given_to_the_engine() {
1385        let worker = tagged_builder()
1386            .tags(["region:eu", " gpu ", "gpu"])
1387            .build()
1388            .unwrap();
1389        let expected = vec!["gpu".to_string(), "region:eu".to_string()];
1390        assert_eq!(worker.capabilities().tags, expected);
1391        assert_eq!(worker.engine.worker_tags(), Some(expected.as_slice()));
1392    }
1393
1394    #[test]
1395    fn builder_tags_extend_previous_ones() {
1396        let worker = tagged_builder()
1397            .tags(["gpu"])
1398            .tags(vec!["arm".to_string()])
1399            .build()
1400            .unwrap();
1401        assert_eq!(
1402            worker.capabilities().tags,
1403            vec!["arm".to_string(), "gpu".to_string()]
1404        );
1405    }
1406
1407    #[test]
1408    fn builder_tags_capabilities_list_registered_workflows_sorted() {
1409        let worker = tagged_builder()
1410            .register(Named("deploy"))
1411            .register(Named("build"))
1412            .build()
1413            .unwrap();
1414        assert_eq!(
1415            worker.capabilities().workflows,
1416            Some(vec!["build".to_string(), "deploy".to_string()])
1417        );
1418    }
1419
1420    #[test]
1421    fn builder_tags_capabilities_without_handlers_take_no_workflow() {
1422        let worker = tagged_builder().build().unwrap();
1423        assert_eq!(worker.capabilities().workflows, Some(Vec::new()));
1424    }
1425
1426    #[test]
1427    fn builder_tags_workflow_name_with_comma_disables_workflow_filter() {
1428        let worker = tagged_builder()
1429            .register(Named("deploy"))
1430            .register(Named("build,test"))
1431            .tags(["gpu"])
1432            .build()
1433            .unwrap();
1434        assert_eq!(worker.capabilities().workflows, None);
1435        assert_eq!(worker.capabilities().tags, vec!["gpu".to_string()]);
1436    }
1437
1438    #[test]
1439    fn builder_rejects_invalid_tags() {
1440        let invalid: [&[&str]; 3] = [&["bad tag"], &[""], &["gpu", "  "]];
1441        for tags in invalid {
1442            let result = tagged_builder().tags(tags.iter().copied()).build();
1443            let Err(WorkerError::Engine(EngineError::InvalidWorkerTag(_))) = result else {
1444                panic!("tags {tags:?} must be rejected");
1445            };
1446        }
1447    }
1448
1449    #[test]
1450    fn builder_rejects_too_many_tags() {
1451        let tags = (0..=MAX_WORKER_TAGS).map(|i| format!("tag-{i}"));
1452        let result = tagged_builder().tags(tags).build();
1453        let Err(WorkerError::Engine(EngineError::InvalidWorkerTag(err))) = result else {
1454            panic!("too many tags must be rejected");
1455        };
1456        assert!(matches!(err, WorkerTagError::TooMany { .. }), "{err:?}");
1457    }
1458
1459    #[test]
1460    fn builder_build_creates_worker_with_correct_concurrency() {
1461        let provider = Arc::new(ClaudeCodeProvider::new());
1462        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1463            .provider(provider)
1464            .concurrency(16);
1465        let worker = builder.build().unwrap();
1466        assert_eq!(worker.concurrency, 16);
1467    }
1468
1469    #[test]
1470    fn builder_build_creates_worker_with_correct_interval() {
1471        let provider = Arc::new(ClaudeCodeProvider::new());
1472        let interval = Duration::from_secs(10);
1473        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1474            .provider(provider)
1475            .poll_interval(interval);
1476        let worker = builder.build().unwrap();
1477        assert_eq!(worker.poll_interval, interval);
1478    }
1479
1480    #[test]
1481    fn builder_build_preserves_timeout() {
1482        let provider = Arc::new(ClaudeCodeProvider::new());
1483        let dur = Duration::from_secs(300);
1484        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1485            .provider(provider)
1486            .run_timeout(dur)
1487            .build()
1488            .unwrap();
1489        assert_eq!(worker.run_timeout, dur);
1490    }
1491
1492    #[test]
1493    fn builder_build_preserves_poison_pill_config() {
1494        let provider = Arc::new(ClaudeCodeProvider::new());
1495        let cooldown = Duration::from_secs(120);
1496        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1497            .provider(provider)
1498            .max_consecutive_panics(7)
1499            .panic_cooldown(cooldown)
1500            .build()
1501            .unwrap();
1502        assert_eq!(worker.max_consecutive_panics, 7);
1503        assert_eq!(worker.panic_cooldown, cooldown);
1504    }
1505
1506    #[test]
1507    fn builder_chaining_works() {
1508        let provider = Arc::new(ClaudeCodeProvider::new());
1509        let result = WorkerBuilder::new("http://localhost:3000", "token")
1510            .provider(provider)
1511            .concurrency(4)
1512            .poll_interval(Duration::from_secs(3))
1513            .run_timeout(Duration::from_secs(600))
1514            .max_consecutive_panics(5)
1515            .panic_cooldown(Duration::from_secs(120))
1516            .build();
1517        assert!(result.is_ok());
1518        let worker = result.unwrap();
1519        assert_eq!(worker.concurrency, 4);
1520        assert_eq!(worker.poll_interval, Duration::from_secs(3));
1521        assert_eq!(worker.run_timeout, Duration::from_secs(600));
1522        assert_eq!(worker.max_consecutive_panics, 5);
1523        assert_eq!(worker.panic_cooldown, Duration::from_secs(120));
1524    }
1525
1526    #[test]
1527    fn builder_empty_api_url_accepted() {
1528        let provider = Arc::new(ClaudeCodeProvider::new());
1529        let builder = WorkerBuilder::new("", "token").provider(provider);
1530        let result = builder.build();
1531        assert!(result.is_ok());
1532    }
1533
1534    #[test]
1535    fn builder_empty_token_accepted() {
1536        let provider = Arc::new(ClaudeCodeProvider::new());
1537        let builder = WorkerBuilder::new("http://localhost:3000", "").provider(provider);
1538        let result = builder.build();
1539        assert!(result.is_ok());
1540    }
1541
1542    #[cfg(feature = "prometheus")]
1543    #[test]
1544    fn builder_metrics_addr_is_stored_and_build_succeeds() {
1545        let addr: SocketAddr = "127.0.0.1:9100".parse().unwrap();
1546        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1547            .provider(Arc::new(ClaudeCodeProvider::new()))
1548            .metrics_addr(addr);
1549        assert_eq!(builder.metrics_addr, Some(addr));
1550        assert!(builder.build().is_ok());
1551    }
1552
1553    #[cfg(feature = "heartbeat")]
1554    #[test]
1555    fn builder_heartbeat_defaults() {
1556        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1557        assert!(builder.heartbeat_url.is_none());
1558        assert_eq!(builder.heartbeat_interval, DEFAULT_HEARTBEAT_INTERVAL);
1559    }
1560
1561    #[cfg(feature = "heartbeat")]
1562    #[test]
1563    fn builder_heartbeat_url_sets_url() {
1564        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1565            .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc");
1566        assert_eq!(
1567            builder.heartbeat_url.as_deref(),
1568            Some("https://uptime.betterstack.com/api/v1/heartbeat/abc")
1569        );
1570    }
1571
1572    #[cfg(feature = "heartbeat")]
1573    #[test]
1574    fn builder_heartbeat_custom_interval() {
1575        let interval = Duration::from_secs(10);
1576        let builder =
1577            WorkerBuilder::new("http://localhost:3000", "token").heartbeat_interval(interval);
1578        assert_eq!(builder.heartbeat_interval, interval);
1579    }
1580
1581    #[cfg(feature = "heartbeat")]
1582    #[test]
1583    fn builder_build_preserves_heartbeat_config() {
1584        let provider = Arc::new(ClaudeCodeProvider::new());
1585        let interval = Duration::from_secs(15);
1586        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1587            .provider(provider)
1588            .heartbeat_url("https://example.com/heartbeat")
1589            .heartbeat_interval(interval)
1590            .build()
1591            .unwrap();
1592        assert_eq!(
1593            worker.heartbeat_url.as_deref(),
1594            Some("https://example.com/heartbeat")
1595        );
1596        assert_eq!(worker.heartbeat_interval, interval);
1597    }
1598
1599    #[cfg(feature = "heartbeat")]
1600    #[test]
1601    fn builder_build_without_heartbeat_url_has_none() {
1602        let provider = Arc::new(ClaudeCodeProvider::new());
1603        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1604            .provider(provider)
1605            .build()
1606            .unwrap();
1607        assert!(worker.heartbeat_url.is_none());
1608    }
1609
1610    // --- PoisonPillTracker tests ---
1611
1612    #[test]
1613    fn poison_tracker_not_blocked_initially() {
1614        let tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1615        assert!(!tracker.is_blocked("my-workflow"));
1616    }
1617
1618    #[test]
1619    fn poison_tracker_blocked_after_max_panics() {
1620        let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1621        assert!(!tracker.record_panic("wf"));
1622        assert!(!tracker.record_panic("wf"));
1623        assert!(tracker.record_panic("wf"));
1624        assert!(tracker.is_blocked("wf"));
1625    }
1626
1627    #[test]
1628    fn poison_tracker_success_resets_count() {
1629        let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1630        tracker.record_panic("wf");
1631        tracker.record_panic("wf");
1632        tracker.record_success("wf");
1633        assert!(!tracker.is_blocked("wf"));
1634        // After reset, need 3 more panics to block
1635        assert!(!tracker.record_panic("wf"));
1636    }
1637
1638    #[test]
1639    fn poison_tracker_independent_per_workflow() {
1640        let mut tracker = PoisonPillTracker::new(2, Duration::from_secs(300));
1641        tracker.record_panic("wf-a");
1642        tracker.record_panic("wf-a");
1643        assert!(tracker.is_blocked("wf-a"));
1644        assert!(!tracker.is_blocked("wf-b"));
1645    }
1646
1647    #[test]
1648    fn poison_tracker_unblocks_after_cooldown() {
1649        let mut tracker = PoisonPillTracker::new(2, Duration::from_millis(0));
1650        tracker.record_panic("wf");
1651        tracker.record_panic("wf");
1652        // Cooldown is 0ms, should immediately unblock
1653        assert!(!tracker.is_blocked("wf"));
1654    }
1655
1656    #[tokio::test]
1657    async fn acquire_slot_returns_a_permit_when_a_slot_is_free() {
1658        let semaphore = Arc::new(Semaphore::new(1));
1659        let shutdown = CancellationToken::new();
1660
1661        let permit = acquire_slot(&semaphore, &shutdown)
1662            .await
1663            .expect("semaphore open");
1664
1665        assert!(permit.is_some());
1666        assert_eq!(semaphore.available_permits(), 0);
1667        drop(permit);
1668        assert_eq!(semaphore.available_permits(), 1);
1669    }
1670
1671    #[tokio::test]
1672    async fn acquire_slot_gives_up_on_shutdown_while_all_slots_are_busy() {
1673        let semaphore = Arc::new(Semaphore::new(1));
1674        let _busy = semaphore.clone().acquire_owned().await.expect("permit");
1675        let shutdown = CancellationToken::new();
1676
1677        let cancel = shutdown.clone();
1678        spawn(async move {
1679            sleep(Duration::from_millis(20)).await;
1680            cancel.cancel();
1681        });
1682
1683        let permit = timeout(Duration::from_secs(5), acquire_slot(&semaphore, &shutdown))
1684            .await
1685            .expect("waiting for a slot must stop on shutdown")
1686            .expect("semaphore open");
1687
1688        assert!(permit.is_none());
1689    }
1690
1691    #[tokio::test]
1692    async fn acquire_slot_takes_no_permit_once_shutdown_is_requested() {
1693        let semaphore = Arc::new(Semaphore::new(1));
1694        let shutdown = CancellationToken::new();
1695        shutdown.cancel();
1696
1697        let permit = acquire_slot(&semaphore, &shutdown)
1698            .await
1699            .expect("semaphore open");
1700
1701        assert!(permit.is_none());
1702        assert_eq!(semaphore.available_permits(), 1);
1703    }
1704
1705    #[tokio::test]
1706    async fn acquire_slot_fails_when_the_semaphore_is_closed() {
1707        let semaphore = Arc::new(Semaphore::new(1));
1708        semaphore.close();
1709        let shutdown = CancellationToken::new();
1710
1711        let result = acquire_slot(&semaphore, &shutdown).await;
1712
1713        assert!(matches!(result, Err(WorkerError::Internal(_))));
1714    }
1715}