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 api_store = ApiRunStore::new(&self.api_url, &self.worker_token);
494        let store: Arc<dyn Store> = Arc::new(api_store.clone());
495        let strategy = self
496            .account_strategy
497            .unwrap_or_else(|| Arc::new(LeastUtilized));
498        let provider: Arc<dyn AgentProvider> = Arc::new(
499            AccountAwareProvider::new(provider, store.clone())
500                .with_strategy(strategy)
501                .with_max_capacity_wait(self.max_capacity_wait),
502        );
503
504        // The worker's store cannot deliver a signal: the API does it.
505        let mut engine = Engine::new(store, provider).with_signal_sender(Arc::new(api_store));
506        if let Some(decision_provider) = self.decision_provider {
507            engine = engine.with_decision_provider(decision_provider);
508        }
509        for handler in self.handlers {
510            engine
511                .register_boxed(handler)
512                .map_err(WorkerError::Engine)?;
513        }
514        let capabilities = WorkerCapabilities::new(registered_workflows(&engine), tags.clone());
515        engine.set_worker_tags(tags);
516
517        let (log_sender, log_receiver) = ironflow_engine::log_sender::channel();
518        engine.set_log_sender(log_sender);
519
520        // Artifact bytes travel through the API: the worker holds no storage
521        // credential. When the API has no backend configured, a step that
522        // declares artifacts fails with a clear message and the rest runs on.
523        engine.set_artifact_sink(Arc::new(ApiArtifactSink::new(
524            &self.api_url,
525            &self.worker_token,
526        )));
527
528        #[cfg(feature = "heartbeat")]
529        let heartbeat_client = Client::builder()
530            .timeout(Duration::from_secs(5))
531            .build()
532            .expect("failed to build heartbeat HTTP client");
533
534        Ok(Worker {
535            engine: Arc::new(engine),
536            api_url: self.api_url,
537            worker_token: self.worker_token,
538            worker_id: self.worker_id,
539            capabilities,
540            log_receiver: Mutex::new(Some(log_receiver)),
541            concurrency: self.concurrency,
542            poll_interval: self.poll_interval,
543            run_timeout: self.run_timeout,
544            max_consecutive_panics: self.max_consecutive_panics,
545            panic_cooldown: self.panic_cooldown,
546            lease_ttl: self.lease_ttl,
547            lease_refresh_interval: self.lease_refresh_interval,
548            #[cfg(feature = "heartbeat")]
549            heartbeat_url: self.heartbeat_url,
550            #[cfg(feature = "heartbeat")]
551            heartbeat_interval: self.heartbeat_interval,
552            #[cfg(feature = "heartbeat")]
553            heartbeat_client,
554            #[cfg(feature = "prometheus")]
555            metrics_addr: self.metrics_addr,
556        })
557    }
558}
559
560/// Background worker that polls the API and executes workflows.
561pub struct Worker {
562    engine: Arc<Engine>,
563    api_url: String,
564    worker_token: String,
565    worker_id: String,
566    capabilities: WorkerCapabilities,
567    log_receiver: Mutex<Option<LogReceiver>>,
568    concurrency: usize,
569    poll_interval: Duration,
570    run_timeout: Duration,
571    max_consecutive_panics: u32,
572    panic_cooldown: Duration,
573    lease_ttl: Duration,
574    lease_refresh_interval: Duration,
575    #[cfg(feature = "heartbeat")]
576    heartbeat_url: Option<String>,
577    #[cfg(feature = "heartbeat")]
578    heartbeat_interval: Duration,
579    #[cfg(feature = "heartbeat")]
580    heartbeat_client: Client,
581    #[cfg(feature = "prometheus")]
582    metrics_addr: Option<SocketAddr>,
583}
584
585/// Tracks consecutive failures per workflow for poison pill detection.
586struct PoisonPillTracker {
587    max_consecutive: u32,
588    cooldown: Duration,
589    /// Maps workflow name to (consecutive panic count, last panic time).
590    state: HashMap<String, (u32, Instant)>,
591}
592
593impl PoisonPillTracker {
594    fn new(max_consecutive: u32, cooldown: Duration) -> Self {
595        Self {
596            max_consecutive,
597            cooldown,
598            state: HashMap::new(),
599        }
600    }
601
602    /// Record a panic for a workflow. Returns `true` if the workflow is now
603    /// considered a poison pill.
604    fn record_panic(&mut self, workflow: &str) -> bool {
605        let entry = self
606            .state
607            .entry(workflow.to_string())
608            .or_insert((0, Instant::now()));
609        entry.0 += 1;
610        entry.1 = Instant::now();
611        entry.0 >= self.max_consecutive
612    }
613
614    /// Record a successful execution, resetting the panic counter.
615    fn record_success(&mut self, workflow: &str) {
616        self.state.remove(workflow);
617    }
618
619    /// Returns `true` if the workflow is currently blocked as a poison pill.
620    fn is_blocked(&self, workflow: &str) -> bool {
621        self.state.get(workflow).is_some_and(|(count, last_panic)| {
622            *count >= self.max_consecutive && last_panic.elapsed() < self.cooldown
623        })
624    }
625}
626
627impl Worker {
628    /// What this worker asks the API for: the workflows it registered,
629    /// sorted, and the tags it carries, sorted and deduplicated.
630    ///
631    /// # Examples
632    ///
633    /// ```no_run
634    /// use std::sync::Arc;
635    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
636    /// use ironflow_worker::WorkerBuilder;
637    ///
638    /// # fn example() -> Result<(), ironflow_worker::WorkerError> {
639    /// let worker = WorkerBuilder::new("http://localhost:3000", "token")
640    ///     .provider(Arc::new(ClaudeCodeProvider::new()))
641    ///     .tags(["gpu"])
642    ///     .build()?;
643    /// assert_eq!(worker.capabilities().tags, vec!["gpu".to_string()]);
644    /// # Ok(())
645    /// # }
646    /// ```
647    pub fn capabilities(&self) -> &WorkerCapabilities {
648        &self.capabilities
649    }
650
651    /// Run the worker loop until a shutdown signal (SIGTERM/SIGINT) is received.
652    ///
653    /// On shutdown, the worker stops picking new runs and waits for all
654    /// in-flight executions to complete before returning.
655    ///
656    /// # Errors
657    ///
658    /// Returns [`WorkerError`] if the polling loop encounters an unrecoverable error.
659    /// With the `prometheus` feature and a
660    /// [`metrics_addr`](WorkerBuilder::metrics_addr), returns
661    /// [`WorkerError::Internal`] before polling if the exporter cannot be built
662    /// (including when the address cannot be bound, for instance because it is
663    /// already in use) or a metrics recorder is already installed in the process.
664    pub async fn run(&self) -> Result<(), WorkerError> {
665        let semaphore = Arc::new(Semaphore::new(self.concurrency));
666        let shutdown = CancellationToken::new();
667        let mut idle_streak = 0u32;
668        let poison_tracker = Arc::new(Mutex::new(PoisonPillTracker::new(
669            self.max_consecutive_panics,
670            self.panic_cooldown,
671        )));
672        let (outcome_tx, mut outcome_rx) = mpsc::unbounded_channel::<RunOutcome>();
673
674        info!(
675            concurrency = self.concurrency,
676            poll_interval_ms = self.poll_interval.as_millis() as u64,
677            run_timeout_secs = self.run_timeout.as_secs(),
678            "worker started"
679        );
680
681        #[cfg(feature = "prometheus")]
682        if let Some(addr) = self.metrics_addr {
683            let (recorder, exporter) = PrometheusBuilder::new()
684                .with_http_listener(addr)
685                .build()
686                .map_err(|e| {
687                    WorkerError::Internal(format!(
688                        "cannot build the Prometheus exporter on {addr}: {e}"
689                    ))
690                })?;
691            set_global_recorder(recorder).map_err(|e| {
692                WorkerError::Internal(format!("a metrics recorder is already installed: {e}"))
693            })?;
694            spawn(async move {
695                if let Err(e) = exporter.await {
696                    error!(error = ?e, "prometheus exporter stopped");
697                }
698            });
699            info!(%addr, "prometheus metrics endpoint listening");
700        }
701
702        if let Some(receiver) = self.log_receiver.lock().expect("log_receiver lock").take() {
703            let pusher = LogPusher::new(&self.api_url, &self.worker_token);
704            spawn(pusher.run(receiver));
705            info!("log pusher started");
706        }
707
708        // Spawn shutdown signal handler
709        let shutdown_clone = shutdown.clone();
710        spawn(async move {
711            shutdown_signal().await;
712            info!("shutdown signal received, draining in-flight runs...");
713            shutdown_clone.cancel();
714        });
715
716        #[cfg(feature = "heartbeat")]
717        if let Some(ref url) = self.heartbeat_url {
718            let interval = self.heartbeat_interval;
719            let url = url.clone();
720            let client = self.heartbeat_client.clone();
721
722            spawn(async move {
723                let mut ticker = tokio::time::interval(interval);
724                // skip the first immediate tick
725                ticker.tick().await;
726                loop {
727                    ticker.tick().await;
728                    match client.head(&url).send().await {
729                        Ok(resp) if resp.status().is_success() => {
730                            info!(url = %url, "heartbeat sent");
731                        }
732                        Ok(resp) => {
733                            warn!(
734                                url = %url,
735                                status = %resp.status(),
736                                "heartbeat ping returned non-success status"
737                            );
738                        }
739                        Err(err) => {
740                            warn!(
741                                url = %url,
742                                error = %err,
743                                "heartbeat ping failed"
744                            );
745                        }
746                    }
747                }
748            });
749        }
750
751        #[cfg(feature = "prometheus")]
752        let mut queue_depth =
753            QueueDepthGauge::new(&self.api_url, &self.worker_token, &self.capabilities);
754
755        while !shutdown.is_cancelled() {
756            // Drain outcome channel to update poison pill tracker
757            while let Ok(outcome) = outcome_rx.try_recv() {
758                let mut tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
759                match outcome {
760                    RunOutcome::Success(ref wf) => tracker.record_success(wf),
761                    // Losing a lease says nothing about the workflow itself:
762                    // it must not count towards the poison pill threshold.
763                    RunOutcome::LeaseLost(ref wf) => {
764                        warn!(workflow = %wf, "run abandoned after losing its lease")
765                    }
766                    RunOutcome::Failed(ref wf) | RunOutcome::Timeout(ref wf) => {
767                        if tracker.record_panic(wf) {
768                            warn!(workflow = %wf, "workflow flagged as poison pill after consecutive failures");
769                        }
770                    }
771                    RunOutcome::Panicked(ref wf) => {
772                        if tracker.record_panic(wf) {
773                            error!(workflow = %wf, "workflow flagged as poison pill after consecutive panics");
774                        }
775                    }
776                }
777            }
778
779            // Claim a run only once a slot is free: a claimed run is `Running`
780            // under a lease that nobody refreshes until it starts executing.
781            let Some(permit) = acquire_slot(&semaphore, &shutdown).await? else {
782                break;
783            };
784
785            let run = self
786                .engine
787                .store()
788                .pick_next_pending_for(
789                    Some(LeaseRequest {
790                        worker_id: self.worker_id.clone(),
791                        ttl: self.lease_ttl,
792                    }),
793                    Some(self.capabilities.clone()),
794                )
795                .await;
796
797            match run {
798                Ok(Some(run)) => {
799                    #[cfg(feature = "prometheus")]
800                    counter!(WORKER_POLLS_TOTAL, "result" => "hit").increment(1);
801
802                    // Poison pill check: skip workflows that keep failing
803                    let is_blocked = {
804                        let tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
805                        tracker.is_blocked(&run.workflow_name)
806                    };
807                    if is_blocked {
808                        warn!(
809                            workflow = %run.workflow_name,
810                            run_id = %run.id,
811                            "skipping run: workflow flagged as poison pill, marking as failed"
812                        );
813                        if let Err(e) = self
814                            .engine
815                            .store()
816                            .update_run_status(run.id, RunStatus::Failed)
817                            .await
818                        {
819                            error!(run_id = %run.id, error = %e, "failed to mark poisoned run as failed");
820                        }
821                        drop(permit);
822                        continue;
823                    }
824
825                    idle_streak = 0;
826                    let engine = self.engine.clone();
827                    let run_id = run.id;
828                    let workflow = run.workflow_name.clone();
829                    let workflow_for_watcher = workflow.clone();
830                    let run_timeout = self.run_timeout;
831
832                    info!(run_id = %run_id, workflow = %workflow, "executing run");
833
834                    #[cfg(feature = "prometheus")]
835                    gauge!(WORKER_ACTIVE).increment(1.0);
836
837                    // Keep the lease alive for as long as this run executes, and
838                    // abandon the run as soon as the lease is lost.
839                    let lease_token = CancellationToken::new();
840                    let refresher = spawn(refresh_lease(
841                        self.engine.store().clone(),
842                        run_id,
843                        LeaseRequest {
844                            worker_id: self.worker_id.clone(),
845                            ttl: self.lease_ttl,
846                        },
847                        self.lease_refresh_interval,
848                        lease_token.clone(),
849                    ));
850
851                    let handle = spawn(async move {
852                        let _permit = permit;
853                        let result = select! {
854                            biased;
855                            _ = lease_token.cancelled() => {
856                                refresher.abort();
857                                // Dropping the execution future here cancels it;
858                                // child processes die with it (kill_on_drop).
859                                warn!(
860                                    run_id = %run_id,
861                                    workflow = %workflow,
862                                    "abandoning run: worker lease lost"
863                                );
864                                #[cfg(feature = "prometheus")]
865                                counter!(WORKER_LEASES_LOST_TOTAL).increment(1);
866                                // No status write: the run belongs to someone else now.
867                                return RunOutcome::LeaseLost(workflow);
868                            }
869                            result = timeout(run_timeout, engine.execute_handler_run(run_id)) => result,
870                        };
871                        refresher.abort();
872
873                        match result {
874                            Ok(Ok(_)) => {
875                                info!(run_id = %run_id, workflow = %workflow, "run completed");
876                                RunOutcome::Success(workflow)
877                            }
878                            // `execute_handler_run` already routed this failure
879                            // through `fail_or_schedule_retry` (or, for a budget
880                            // refusal, straight to Cancelled) and cleaned up the
881                            // orphaned steps, so the run is either terminal or
882                            // waiting for its retry. Writing Failed here again
883                            // would cancel an armed retry and would be rejected
884                            // by the FSM for a cancelled run.
885                            Ok(Err(e)) => {
886                                error!(run_id = %run_id, workflow = %workflow, error = %e, "run failed");
887                                RunOutcome::Failed(workflow)
888                            }
889                            Err(_) => {
890                                error!(
891                                    run_id = %run_id,
892                                    workflow = %workflow,
893                                    timeout_secs = run_timeout.as_secs(),
894                                    "run timed out"
895                                );
896                                let timeout_msg =
897                                    format!("run timed out after {}s", run_timeout.as_secs());
898                                // A timeout is transient by nature: worth replaying.
899                                if let Err(e) = engine
900                                    .fail_or_schedule_retry(run_id, &timeout_msg, true, None, None)
901                                    .await
902                                {
903                                    error!(run_id = %run_id, error = %e, "failed to record timed-out run");
904                                }
905                                RunOutcome::Timeout(workflow)
906                            }
907                        }
908                    });
909
910                    // Spawn a watcher to catch panics and report outcomes
911                    let watcher_engine = self.engine.clone();
912                    let tx = outcome_tx.clone();
913                    spawn(async move {
914                        match handle.await {
915                            Ok(outcome) => {
916                                let _ = tx.send(outcome);
917                            }
918                            Err(e) => {
919                                error!(run_id = %run_id, "spawned task panicked: {e}");
920                                // Retryable: a panic can be transient, and the
921                                // poison pill tracker stops a workflow that keeps
922                                // panicking across runs.
923                                if let Err(store_err) = watcher_engine
924                                    .fail_or_schedule_retry(
925                                        run_id,
926                                        "parent run panicked",
927                                        true,
928                                        None,
929                                        None,
930                                    )
931                                    .await
932                                {
933                                    error!(run_id = %run_id, error = %store_err, "failed to record panicked run");
934                                }
935                                let _ = tx.send(RunOutcome::Panicked(workflow_for_watcher));
936                            }
937                        }
938                        #[cfg(feature = "prometheus")]
939                        gauge!(WORKER_ACTIVE).decrement(1.0);
940                    });
941                }
942                Ok(None) => {
943                    drop(permit);
944                    #[cfg(feature = "prometheus")]
945                    counter!(WORKER_POLLS_TOTAL, "result" => "miss").increment(1);
946
947                    idle_streak += 1;
948                    let backoff = if idle_streak > 10 {
949                        self.poll_interval * 3
950                    } else if idle_streak > 5 {
951                        self.poll_interval * 2
952                    } else {
953                        self.poll_interval
954                    };
955                    sleep(backoff).await;
956                }
957                Err(e) => {
958                    drop(permit);
959                    warn!(error = %e, "poll error");
960                    sleep(self.poll_interval).await;
961                }
962            }
963
964            #[cfg(feature = "prometheus")]
965            queue_depth.refresh_if_due().await;
966        }
967
968        // Graceful drain: wait for all in-flight tasks to release their permits
969        info!(
970            in_flight = self.concurrency - semaphore.available_permits(),
971            "waiting for in-flight runs to complete..."
972        );
973        let _ = semaphore
974            .acquire_many(self.concurrency as u32)
975            .await
976            .map_err(|_| WorkerError::Shutdown("semaphore closed during drain".to_string()))?;
977
978        info!("all in-flight runs completed, worker shut down");
979        Ok(())
980    }
981}
982
983/// Sorted names of the workflows registered on `engine`, sent to the API so
984/// the worker is only handed runs it can execute.
985///
986/// The names travel as one comma-separated query parameter: if one contains a
987/// comma it cannot be sent, so `None` is returned (any workflow) with a
988/// warning, and tags alone route the runs.
989fn registered_workflows(engine: &Engine) -> Option<Vec<String>> {
990    let mut names: Vec<String> = engine
991        .handler_names()
992        .into_iter()
993        .map(str::to_string)
994        .collect();
995    if let Some(name) = names.iter().find(|name| name.contains(',')) {
996        warn!(
997            workflow = %name,
998            "workflow name contains a comma: the worker will not filter runs by workflow"
999        );
1000        return None;
1001    }
1002    names.sort();
1003    Some(names)
1004}
1005
1006/// Outcome of a single run execution, used for poison pill tracking.
1007enum RunOutcome {
1008    /// Run completed successfully.
1009    Success(String),
1010    /// Run failed with an error (engine returned Err).
1011    Failed(String),
1012    /// Run exceeded its timeout.
1013    Timeout(String),
1014    /// Run panicked (task JoinError).
1015    Panicked(String),
1016    /// Run was abandoned because this worker lost its lease: another worker
1017    /// took it over, or an operator paused the run.
1018    LeaseLost(String),
1019}
1020
1021/// Wait for a free execution slot, or give up as soon as shutdown is requested.
1022///
1023/// Returns `Ok(None)` on shutdown, even when a slot is free, so no run is
1024/// claimed once the worker is draining.
1025///
1026/// # Errors
1027///
1028/// Returns [`WorkerError::Internal`] if the semaphore has been closed.
1029async fn acquire_slot(
1030    semaphore: &Arc<Semaphore>,
1031    shutdown: &CancellationToken,
1032) -> Result<Option<OwnedSemaphorePermit>, WorkerError> {
1033    select! {
1034        biased;
1035        _ = shutdown.cancelled() => Ok(None),
1036        permit = semaphore.clone().acquire_owned() => permit
1037            .map(Some)
1038            .map_err(|e| WorkerError::Internal(format!("semaphore closed: {e}"))),
1039    }
1040}
1041
1042/// Keep a run's lease alive until the run finishes or the lease is lost.
1043///
1044/// Cancels `lease_token` when the API hands the run to another worker, or when
1045/// refreshes keep failing for longer than the lease TTL — in both cases this
1046/// worker must stop executing the run rather than risk a double execution.
1047///
1048/// A sub-workflow child run is executed by resuming its root run, which takes
1049/// the child's lease over (see [`Engine::execute_handler_run`]). When the
1050/// child's lease is lost, the refresher follows it to the root: if the root's
1051/// lease renews for this worker, the root is refreshed from then on instead
1052/// of abandoning the execution.
1053async fn refresh_lease(
1054    store: Arc<dyn Store>,
1055    run_id: Uuid,
1056    lease: LeaseRequest,
1057    refresh_interval: Duration,
1058    lease_token: CancellationToken,
1059) {
1060    let ttl = lease.ttl;
1061    let mut deadline = Instant::now() + ttl;
1062    let mut target = run_id;
1063
1064    loop {
1065        sleep(refresh_interval).await;
1066
1067        match store.renew_lease(target, lease.clone()).await {
1068            Ok(_) => {
1069                deadline = Instant::now() + ttl;
1070            }
1071            Err(StoreError::LeaseLost { held_by, .. }) => {
1072                let followed = if target == run_id {
1073                    follow_lease_to_root(store.as_ref(), run_id, &lease).await
1074                } else {
1075                    None
1076                };
1077                if let Some(root) = followed {
1078                    info!(
1079                        run_id = %run_id,
1080                        root_run_id = %root,
1081                        "lease followed the run to its root"
1082                    );
1083                    target = root;
1084                    deadline = Instant::now() + ttl;
1085                    continue;
1086                }
1087                warn!(
1088                    run_id = %target,
1089                    held_by = held_by.as_deref().unwrap_or("unknown"),
1090                    "lease lost: taken over by another worker, or the run was paused"
1091                );
1092                lease_token.cancel();
1093                return;
1094            }
1095            Err(err) if Instant::now() >= deadline => {
1096                // The API has been unreachable longer than the lease lasts:
1097                // another worker may already have picked the run up.
1098                warn!(
1099                    run_id = %target,
1100                    error = %err,
1101                    ttl_secs = ttl.as_secs(),
1102                    "lease could not be refreshed before it expired"
1103                );
1104                lease_token.cancel();
1105                return;
1106            }
1107            Err(err) => {
1108                warn!(run_id = %target, error = %err, "lease refresh failed, retrying");
1109            }
1110        }
1111    }
1112}
1113
1114/// The root run that took the lease of `run_id` over, if any.
1115///
1116/// `Some` only when `run_id` is a sub-workflow child run and the lease of its
1117/// root renews for this worker, i.e. the engine handed the child's lease to
1118/// the root it resumed. Any failure yields `None`: the caller then treats the
1119/// lease as lost.
1120async fn follow_lease_to_root(
1121    store: &dyn Store,
1122    run_id: Uuid,
1123    lease: &LeaseRequest,
1124) -> Option<Uuid> {
1125    let run = match store.get_run(run_id).await {
1126        Ok(run) => run?,
1127        Err(err) => {
1128            warn!(run_id = %run_id, error = %err, "could not load run to follow its lease");
1129            return None;
1130        }
1131    };
1132    let root = chain_root(&run)?;
1133    match store.renew_lease(root, lease.clone()).await {
1134        Ok(_) => Some(root),
1135        Err(err) => {
1136            warn!(
1137                run_id = %run_id,
1138                root_run_id = %root,
1139                error = %err,
1140                "lease could not follow the run to its root"
1141            );
1142            None
1143        }
1144    }
1145}
1146
1147/// Wait for SIGTERM or SIGINT (Ctrl+C).
1148async fn shutdown_signal() {
1149    use tokio::signal;
1150
1151    let ctrl_c = async {
1152        signal::ctrl_c()
1153            .await
1154            .expect("failed to install Ctrl+C handler");
1155    };
1156
1157    #[cfg(unix)]
1158    let terminate = async {
1159        use tokio::signal::unix::{SignalKind, signal};
1160
1161        signal(SignalKind::terminate())
1162            .expect("failed to install SIGTERM handler")
1163            .recv()
1164            .await;
1165    };
1166
1167    #[cfg(not(unix))]
1168    let terminate = {
1169        use std::future::pending;
1170        pending::<()>()
1171    };
1172
1173    select! {
1174        () = ctrl_c => {},
1175        () = terminate => {},
1176    }
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181    use super::*;
1182
1183    use ironflow_core::account_strategy::Priority;
1184    use ironflow_core::providers::claude::ClaudeCodeProvider;
1185    use ironflow_core::providers::record_replay_decision::RecordReplayDecisionProvider;
1186    use ironflow_engine::context::WorkflowContext;
1187    use ironflow_engine::handler::HandlerFuture;
1188    use ironflow_store::entities::{MAX_WORKER_TAGS, WorkerTagError};
1189
1190    #[test]
1191    fn builder_new_creates_default_config() {
1192        let builder = WorkerBuilder::new("http://localhost:3000", "my-token");
1193        assert_eq!(builder.api_url, "http://localhost:3000");
1194        assert_eq!(builder.worker_token, "my-token");
1195        assert_eq!(builder.concurrency, DEFAULT_CONCURRENCY);
1196        assert_eq!(builder.poll_interval, DEFAULT_POLL_INTERVAL);
1197        assert_eq!(builder.run_timeout, DEFAULT_RUN_TIMEOUT);
1198        assert_eq!(
1199            builder.max_consecutive_panics,
1200            DEFAULT_MAX_CONSECUTIVE_PANICS
1201        );
1202        assert_eq!(builder.panic_cooldown, DEFAULT_PANIC_COOLDOWN);
1203        assert!(builder.provider.is_none());
1204    }
1205
1206    #[test]
1207    fn builder_with_trailing_slash_normalized() {
1208        let builder = WorkerBuilder::new("http://localhost:3000/", "token");
1209        assert_eq!(builder.api_url, "http://localhost:3000/");
1210    }
1211
1212    #[test]
1213    fn builder_provider_sets_provider() {
1214        let provider = Arc::new(ClaudeCodeProvider::new());
1215        let builder =
1216            WorkerBuilder::new("http://localhost:3000", "token").provider(provider.clone());
1217        assert!(builder.provider.is_some());
1218    }
1219
1220    #[test]
1221    fn builder_account_strategy_sets_strategy() {
1222        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1223        assert!(builder.account_strategy.is_none());
1224        let builder = builder.account_strategy(Arc::new(Priority));
1225        assert_eq!(
1226            builder.account_strategy.as_ref().map(|s| s.name()),
1227            Some("priority")
1228        );
1229    }
1230
1231    #[test]
1232    fn builder_max_capacity_wait_defaults_to_six_hours_and_can_be_set() {
1233        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1234        assert_eq!(builder.max_capacity_wait, DEFAULT_MAX_CAPACITY_WAIT);
1235        assert_eq!(builder.max_capacity_wait, Duration::from_secs(6 * 3600));
1236        let builder = builder.max_capacity_wait(Duration::ZERO);
1237        assert_eq!(builder.max_capacity_wait, Duration::ZERO);
1238    }
1239
1240    #[test]
1241    fn builder_decision_provider_defaults_to_none() {
1242        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1243        assert!(builder.decision_provider.is_none());
1244    }
1245
1246    #[test]
1247    fn builder_decision_provider_sets_provider() {
1248        let builder = WorkerBuilder::new("http://localhost:3000", "token").decision_provider(
1249            Arc::new(RecordReplayDecisionProvider::replay("tests/fixtures")),
1250        );
1251        assert!(builder.decision_provider.is_some());
1252    }
1253
1254    #[test]
1255    fn builder_concurrency_sets_concurrency() {
1256        let builder = WorkerBuilder::new("http://localhost:3000", "token").concurrency(8);
1257        assert_eq!(builder.concurrency, 8);
1258    }
1259
1260    #[test]
1261    fn builder_concurrency_zero_accepted() {
1262        let provider = Arc::new(ClaudeCodeProvider::new());
1263        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1264            .provider(provider)
1265            .concurrency(0);
1266        assert_eq!(builder.concurrency, 0);
1267    }
1268
1269    #[test]
1270    fn builder_poll_interval_sets_interval() {
1271        let interval = Duration::from_secs(5);
1272        let builder = WorkerBuilder::new("http://localhost:3000", "token").poll_interval(interval);
1273        assert_eq!(builder.poll_interval, interval);
1274    }
1275
1276    #[test]
1277    fn builder_run_timeout_sets_timeout() {
1278        let dur = Duration::from_secs(120);
1279        let builder = WorkerBuilder::new("http://localhost:3000", "token").run_timeout(dur);
1280        assert_eq!(builder.run_timeout, dur);
1281    }
1282
1283    #[test]
1284    fn builder_max_consecutive_panics_sets_value() {
1285        let builder =
1286            WorkerBuilder::new("http://localhost:3000", "token").max_consecutive_panics(10);
1287        assert_eq!(builder.max_consecutive_panics, 10);
1288    }
1289
1290    #[test]
1291    fn builder_panic_cooldown_sets_value() {
1292        let dur = Duration::from_secs(600);
1293        let builder = WorkerBuilder::new("http://localhost:3000", "token").panic_cooldown(dur);
1294        assert_eq!(builder.panic_cooldown, dur);
1295    }
1296
1297    #[test]
1298    fn builder_defaults_lease_settings() {
1299        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1300        assert_eq!(builder.lease_ttl, DEFAULT_LEASE_TTL);
1301        assert_eq!(
1302            builder.lease_refresh_interval,
1303            DEFAULT_LEASE_REFRESH_INTERVAL
1304        );
1305        assert!(builder.worker_id.starts_with("worker-"));
1306    }
1307
1308    #[test]
1309    fn builder_generates_a_distinct_worker_id_per_instance() {
1310        let a = WorkerBuilder::new("http://localhost:3000", "token");
1311        let b = WorkerBuilder::new("http://localhost:3000", "token");
1312        assert_ne!(a.worker_id, b.worker_id);
1313    }
1314
1315    #[test]
1316    fn builder_worker_id_overrides_default() {
1317        let builder = WorkerBuilder::new("http://localhost:3000", "token").worker_id("worker-eu-1");
1318        assert_eq!(builder.worker_id, "worker-eu-1");
1319    }
1320
1321    #[test]
1322    fn builder_lease_ttl_sets_value() {
1323        let dur = Duration::from_secs(120);
1324        let builder = WorkerBuilder::new("http://localhost:3000", "token").lease_ttl(dur);
1325        assert_eq!(builder.lease_ttl, dur);
1326    }
1327
1328    #[test]
1329    fn builder_lease_refresh_interval_sets_value() {
1330        let dur = Duration::from_secs(5);
1331        let builder =
1332            WorkerBuilder::new("http://localhost:3000", "token").lease_refresh_interval(dur);
1333        assert_eq!(builder.lease_refresh_interval, dur);
1334    }
1335
1336    #[test]
1337    fn builder_build_without_provider_fails() {
1338        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1339        let result = builder.build();
1340        assert!(result.is_err());
1341        match result {
1342            Err(WorkerError::Internal(msg)) => {
1343                assert!(msg.contains("provider is required"));
1344            }
1345            _ => panic!("expected Internal error about missing provider"),
1346        }
1347    }
1348
1349    #[test]
1350    fn builder_build_with_provider_succeeds() {
1351        let provider = Arc::new(ClaudeCodeProvider::new());
1352        let builder = WorkerBuilder::new("http://localhost:3000", "token").provider(provider);
1353        let result = builder.build();
1354        assert!(result.is_ok());
1355    }
1356
1357    struct Named(&'static str);
1358
1359    impl WorkflowHandler for Named {
1360        fn name(&self) -> &str {
1361            self.0
1362        }
1363
1364        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1365            Box::pin(async { Ok(()) })
1366        }
1367    }
1368
1369    fn tagged_builder() -> WorkerBuilder {
1370        WorkerBuilder::new("http://localhost:3000", "token")
1371            .provider(Arc::new(ClaudeCodeProvider::new()))
1372    }
1373
1374    #[test]
1375    fn builder_tags_default_to_empty() {
1376        let worker = tagged_builder().build().unwrap();
1377        assert!(worker.capabilities().tags.is_empty());
1378        let engine_tags = worker.engine.worker_tags();
1379        assert!(
1380            engine_tags.is_some_and(<[String]>::is_empty),
1381            "{engine_tags:?}"
1382        );
1383    }
1384
1385    #[test]
1386    fn builder_tags_are_normalized_and_given_to_the_engine() {
1387        let worker = tagged_builder()
1388            .tags(["region:eu", " gpu ", "gpu"])
1389            .build()
1390            .unwrap();
1391        let expected = vec!["gpu".to_string(), "region:eu".to_string()];
1392        assert_eq!(worker.capabilities().tags, expected);
1393        assert_eq!(worker.engine.worker_tags(), Some(expected.as_slice()));
1394    }
1395
1396    #[test]
1397    fn builder_tags_extend_previous_ones() {
1398        let worker = tagged_builder()
1399            .tags(["gpu"])
1400            .tags(vec!["arm".to_string()])
1401            .build()
1402            .unwrap();
1403        assert_eq!(
1404            worker.capabilities().tags,
1405            vec!["arm".to_string(), "gpu".to_string()]
1406        );
1407    }
1408
1409    #[test]
1410    fn builder_tags_capabilities_list_registered_workflows_sorted() {
1411        let worker = tagged_builder()
1412            .register(Named("deploy"))
1413            .register(Named("build"))
1414            .build()
1415            .unwrap();
1416        assert_eq!(
1417            worker.capabilities().workflows,
1418            Some(vec!["build".to_string(), "deploy".to_string()])
1419        );
1420    }
1421
1422    #[test]
1423    fn builder_tags_capabilities_without_handlers_take_no_workflow() {
1424        let worker = tagged_builder().build().unwrap();
1425        assert_eq!(worker.capabilities().workflows, Some(Vec::new()));
1426    }
1427
1428    #[test]
1429    fn builder_tags_workflow_name_with_comma_disables_workflow_filter() {
1430        let worker = tagged_builder()
1431            .register(Named("deploy"))
1432            .register(Named("build,test"))
1433            .tags(["gpu"])
1434            .build()
1435            .unwrap();
1436        assert_eq!(worker.capabilities().workflows, None);
1437        assert_eq!(worker.capabilities().tags, vec!["gpu".to_string()]);
1438    }
1439
1440    #[test]
1441    fn builder_rejects_invalid_tags() {
1442        let invalid: [&[&str]; 3] = [&["bad tag"], &[""], &["gpu", "  "]];
1443        for tags in invalid {
1444            let result = tagged_builder().tags(tags.iter().copied()).build();
1445            let Err(WorkerError::Engine(EngineError::InvalidWorkerTag(_))) = result else {
1446                panic!("tags {tags:?} must be rejected");
1447            };
1448        }
1449    }
1450
1451    #[test]
1452    fn builder_rejects_too_many_tags() {
1453        let tags = (0..=MAX_WORKER_TAGS).map(|i| format!("tag-{i}"));
1454        let result = tagged_builder().tags(tags).build();
1455        let Err(WorkerError::Engine(EngineError::InvalidWorkerTag(err))) = result else {
1456            panic!("too many tags must be rejected");
1457        };
1458        assert!(matches!(err, WorkerTagError::TooMany { .. }), "{err:?}");
1459    }
1460
1461    #[test]
1462    fn builder_build_creates_worker_with_correct_concurrency() {
1463        let provider = Arc::new(ClaudeCodeProvider::new());
1464        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1465            .provider(provider)
1466            .concurrency(16);
1467        let worker = builder.build().unwrap();
1468        assert_eq!(worker.concurrency, 16);
1469    }
1470
1471    #[test]
1472    fn builder_build_creates_worker_with_correct_interval() {
1473        let provider = Arc::new(ClaudeCodeProvider::new());
1474        let interval = Duration::from_secs(10);
1475        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1476            .provider(provider)
1477            .poll_interval(interval);
1478        let worker = builder.build().unwrap();
1479        assert_eq!(worker.poll_interval, interval);
1480    }
1481
1482    #[test]
1483    fn builder_build_preserves_timeout() {
1484        let provider = Arc::new(ClaudeCodeProvider::new());
1485        let dur = Duration::from_secs(300);
1486        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1487            .provider(provider)
1488            .run_timeout(dur)
1489            .build()
1490            .unwrap();
1491        assert_eq!(worker.run_timeout, dur);
1492    }
1493
1494    #[test]
1495    fn builder_build_preserves_poison_pill_config() {
1496        let provider = Arc::new(ClaudeCodeProvider::new());
1497        let cooldown = Duration::from_secs(120);
1498        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1499            .provider(provider)
1500            .max_consecutive_panics(7)
1501            .panic_cooldown(cooldown)
1502            .build()
1503            .unwrap();
1504        assert_eq!(worker.max_consecutive_panics, 7);
1505        assert_eq!(worker.panic_cooldown, cooldown);
1506    }
1507
1508    #[test]
1509    fn builder_chaining_works() {
1510        let provider = Arc::new(ClaudeCodeProvider::new());
1511        let result = WorkerBuilder::new("http://localhost:3000", "token")
1512            .provider(provider)
1513            .concurrency(4)
1514            .poll_interval(Duration::from_secs(3))
1515            .run_timeout(Duration::from_secs(600))
1516            .max_consecutive_panics(5)
1517            .panic_cooldown(Duration::from_secs(120))
1518            .build();
1519        assert!(result.is_ok());
1520        let worker = result.unwrap();
1521        assert_eq!(worker.concurrency, 4);
1522        assert_eq!(worker.poll_interval, Duration::from_secs(3));
1523        assert_eq!(worker.run_timeout, Duration::from_secs(600));
1524        assert_eq!(worker.max_consecutive_panics, 5);
1525        assert_eq!(worker.panic_cooldown, Duration::from_secs(120));
1526    }
1527
1528    #[test]
1529    fn builder_empty_api_url_accepted() {
1530        let provider = Arc::new(ClaudeCodeProvider::new());
1531        let builder = WorkerBuilder::new("", "token").provider(provider);
1532        let result = builder.build();
1533        assert!(result.is_ok());
1534    }
1535
1536    #[test]
1537    fn builder_empty_token_accepted() {
1538        let provider = Arc::new(ClaudeCodeProvider::new());
1539        let builder = WorkerBuilder::new("http://localhost:3000", "").provider(provider);
1540        let result = builder.build();
1541        assert!(result.is_ok());
1542    }
1543
1544    #[cfg(feature = "prometheus")]
1545    #[test]
1546    fn builder_metrics_addr_is_stored_and_build_succeeds() {
1547        let addr: SocketAddr = "127.0.0.1:9100".parse().unwrap();
1548        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1549            .provider(Arc::new(ClaudeCodeProvider::new()))
1550            .metrics_addr(addr);
1551        assert_eq!(builder.metrics_addr, Some(addr));
1552        assert!(builder.build().is_ok());
1553    }
1554
1555    #[cfg(feature = "heartbeat")]
1556    #[test]
1557    fn builder_heartbeat_defaults() {
1558        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1559        assert!(builder.heartbeat_url.is_none());
1560        assert_eq!(builder.heartbeat_interval, DEFAULT_HEARTBEAT_INTERVAL);
1561    }
1562
1563    #[cfg(feature = "heartbeat")]
1564    #[test]
1565    fn builder_heartbeat_url_sets_url() {
1566        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1567            .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc");
1568        assert_eq!(
1569            builder.heartbeat_url.as_deref(),
1570            Some("https://uptime.betterstack.com/api/v1/heartbeat/abc")
1571        );
1572    }
1573
1574    #[cfg(feature = "heartbeat")]
1575    #[test]
1576    fn builder_heartbeat_custom_interval() {
1577        let interval = Duration::from_secs(10);
1578        let builder =
1579            WorkerBuilder::new("http://localhost:3000", "token").heartbeat_interval(interval);
1580        assert_eq!(builder.heartbeat_interval, interval);
1581    }
1582
1583    #[cfg(feature = "heartbeat")]
1584    #[test]
1585    fn builder_build_preserves_heartbeat_config() {
1586        let provider = Arc::new(ClaudeCodeProvider::new());
1587        let interval = Duration::from_secs(15);
1588        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1589            .provider(provider)
1590            .heartbeat_url("https://example.com/heartbeat")
1591            .heartbeat_interval(interval)
1592            .build()
1593            .unwrap();
1594        assert_eq!(
1595            worker.heartbeat_url.as_deref(),
1596            Some("https://example.com/heartbeat")
1597        );
1598        assert_eq!(worker.heartbeat_interval, interval);
1599    }
1600
1601    #[cfg(feature = "heartbeat")]
1602    #[test]
1603    fn builder_build_without_heartbeat_url_has_none() {
1604        let provider = Arc::new(ClaudeCodeProvider::new());
1605        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1606            .provider(provider)
1607            .build()
1608            .unwrap();
1609        assert!(worker.heartbeat_url.is_none());
1610    }
1611
1612    // --- PoisonPillTracker tests ---
1613
1614    #[test]
1615    fn poison_tracker_not_blocked_initially() {
1616        let tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1617        assert!(!tracker.is_blocked("my-workflow"));
1618    }
1619
1620    #[test]
1621    fn poison_tracker_blocked_after_max_panics() {
1622        let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1623        assert!(!tracker.record_panic("wf"));
1624        assert!(!tracker.record_panic("wf"));
1625        assert!(tracker.record_panic("wf"));
1626        assert!(tracker.is_blocked("wf"));
1627    }
1628
1629    #[test]
1630    fn poison_tracker_success_resets_count() {
1631        let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1632        tracker.record_panic("wf");
1633        tracker.record_panic("wf");
1634        tracker.record_success("wf");
1635        assert!(!tracker.is_blocked("wf"));
1636        // After reset, need 3 more panics to block
1637        assert!(!tracker.record_panic("wf"));
1638    }
1639
1640    #[test]
1641    fn poison_tracker_independent_per_workflow() {
1642        let mut tracker = PoisonPillTracker::new(2, Duration::from_secs(300));
1643        tracker.record_panic("wf-a");
1644        tracker.record_panic("wf-a");
1645        assert!(tracker.is_blocked("wf-a"));
1646        assert!(!tracker.is_blocked("wf-b"));
1647    }
1648
1649    #[test]
1650    fn poison_tracker_unblocks_after_cooldown() {
1651        let mut tracker = PoisonPillTracker::new(2, Duration::from_millis(0));
1652        tracker.record_panic("wf");
1653        tracker.record_panic("wf");
1654        // Cooldown is 0ms, should immediately unblock
1655        assert!(!tracker.is_blocked("wf"));
1656    }
1657
1658    #[tokio::test]
1659    async fn acquire_slot_returns_a_permit_when_a_slot_is_free() {
1660        let semaphore = Arc::new(Semaphore::new(1));
1661        let shutdown = CancellationToken::new();
1662
1663        let permit = acquire_slot(&semaphore, &shutdown)
1664            .await
1665            .expect("semaphore open");
1666
1667        assert!(permit.is_some());
1668        assert_eq!(semaphore.available_permits(), 0);
1669        drop(permit);
1670        assert_eq!(semaphore.available_permits(), 1);
1671    }
1672
1673    #[tokio::test]
1674    async fn acquire_slot_gives_up_on_shutdown_while_all_slots_are_busy() {
1675        let semaphore = Arc::new(Semaphore::new(1));
1676        let _busy = semaphore.clone().acquire_owned().await.expect("permit");
1677        let shutdown = CancellationToken::new();
1678
1679        let cancel = shutdown.clone();
1680        spawn(async move {
1681            sleep(Duration::from_millis(20)).await;
1682            cancel.cancel();
1683        });
1684
1685        let permit = timeout(Duration::from_secs(5), acquire_slot(&semaphore, &shutdown))
1686            .await
1687            .expect("waiting for a slot must stop on shutdown")
1688            .expect("semaphore open");
1689
1690        assert!(permit.is_none());
1691    }
1692
1693    #[tokio::test]
1694    async fn acquire_slot_takes_no_permit_once_shutdown_is_requested() {
1695        let semaphore = Arc::new(Semaphore::new(1));
1696        let shutdown = CancellationToken::new();
1697        shutdown.cancel();
1698
1699        let permit = acquire_slot(&semaphore, &shutdown)
1700            .await
1701            .expect("semaphore open");
1702
1703        assert!(permit.is_none());
1704        assert_eq!(semaphore.available_permits(), 1);
1705    }
1706
1707    #[tokio::test]
1708    async fn acquire_slot_fails_when_the_semaphore_is_closed() {
1709        let semaphore = Arc::new(Semaphore::new(1));
1710        semaphore.close();
1711        let shutdown = CancellationToken::new();
1712
1713        let result = acquire_slot(&semaphore, &shutdown).await;
1714
1715        assert!(matches!(result, Err(WorkerError::Internal(_))));
1716    }
1717}