Skip to main content

ironflow_worker/
worker.rs

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