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.
950    LeaseLost(String),
951}
952
953/// Wait for a free execution slot, or give up as soon as shutdown is requested.
954///
955/// Returns `Ok(None)` on shutdown, even when a slot is free, so no run is
956/// claimed once the worker is draining.
957///
958/// # Errors
959///
960/// Returns [`WorkerError::Internal`] if the semaphore has been closed.
961async fn acquire_slot(
962    semaphore: &Arc<Semaphore>,
963    shutdown: &CancellationToken,
964) -> Result<Option<OwnedSemaphorePermit>, WorkerError> {
965    select! {
966        biased;
967        _ = shutdown.cancelled() => Ok(None),
968        permit = semaphore.clone().acquire_owned() => permit
969            .map(Some)
970            .map_err(|e| WorkerError::Internal(format!("semaphore closed: {e}"))),
971    }
972}
973
974/// Keep a run's lease alive until the run finishes or the lease is lost.
975///
976/// Cancels `lease_token` when the API hands the run to another worker, or when
977/// refreshes keep failing for longer than the lease TTL — in both cases this
978/// worker must stop executing the run rather than risk a double execution.
979///
980/// A sub-workflow child run is executed by resuming its root run, which takes
981/// the child's lease over (see [`Engine::execute_handler_run`]). When the
982/// child's lease is lost, the refresher follows it to the root: if the root's
983/// lease renews for this worker, the root is refreshed from then on instead
984/// of abandoning the execution.
985async fn refresh_lease(
986    store: Arc<dyn Store>,
987    run_id: Uuid,
988    lease: LeaseRequest,
989    refresh_interval: Duration,
990    lease_token: CancellationToken,
991) {
992    let ttl = lease.ttl;
993    let mut deadline = Instant::now() + ttl;
994    let mut target = run_id;
995
996    loop {
997        sleep(refresh_interval).await;
998
999        match store.renew_lease(target, lease.clone()).await {
1000            Ok(_) => {
1001                deadline = Instant::now() + ttl;
1002            }
1003            Err(StoreError::LeaseLost { held_by, .. }) => {
1004                let followed = if target == run_id {
1005                    follow_lease_to_root(store.as_ref(), run_id, &lease).await
1006                } else {
1007                    None
1008                };
1009                if let Some(root) = followed {
1010                    info!(
1011                        run_id = %run_id,
1012                        root_run_id = %root,
1013                        "lease followed the run to its root"
1014                    );
1015                    target = root;
1016                    deadline = Instant::now() + ttl;
1017                    continue;
1018                }
1019                warn!(
1020                    run_id = %target,
1021                    held_by = held_by.as_deref().unwrap_or("unknown"),
1022                    "lease taken over by another worker"
1023                );
1024                lease_token.cancel();
1025                return;
1026            }
1027            Err(err) if Instant::now() >= deadline => {
1028                // The API has been unreachable longer than the lease lasts:
1029                // another worker may already have picked the run up.
1030                warn!(
1031                    run_id = %target,
1032                    error = %err,
1033                    ttl_secs = ttl.as_secs(),
1034                    "lease could not be refreshed before it expired"
1035                );
1036                lease_token.cancel();
1037                return;
1038            }
1039            Err(err) => {
1040                warn!(run_id = %target, error = %err, "lease refresh failed, retrying");
1041            }
1042        }
1043    }
1044}
1045
1046/// The root run that took the lease of `run_id` over, if any.
1047///
1048/// `Some` only when `run_id` is a sub-workflow child run and the lease of its
1049/// root renews for this worker, i.e. the engine handed the child's lease to
1050/// the root it resumed. Any failure yields `None`: the caller then treats the
1051/// lease as lost.
1052async fn follow_lease_to_root(
1053    store: &dyn Store,
1054    run_id: Uuid,
1055    lease: &LeaseRequest,
1056) -> Option<Uuid> {
1057    let run = match store.get_run(run_id).await {
1058        Ok(run) => run?,
1059        Err(err) => {
1060            warn!(run_id = %run_id, error = %err, "could not load run to follow its lease");
1061            return None;
1062        }
1063    };
1064    let root = chain_root(&run)?;
1065    match store.renew_lease(root, lease.clone()).await {
1066        Ok(_) => Some(root),
1067        Err(err) => {
1068            warn!(
1069                run_id = %run_id,
1070                root_run_id = %root,
1071                error = %err,
1072                "lease could not follow the run to its root"
1073            );
1074            None
1075        }
1076    }
1077}
1078
1079/// Wait for SIGTERM or SIGINT (Ctrl+C).
1080async fn shutdown_signal() {
1081    use tokio::signal;
1082
1083    let ctrl_c = async {
1084        signal::ctrl_c()
1085            .await
1086            .expect("failed to install Ctrl+C handler");
1087    };
1088
1089    #[cfg(unix)]
1090    let terminate = async {
1091        use tokio::signal::unix::{SignalKind, signal};
1092
1093        signal(SignalKind::terminate())
1094            .expect("failed to install SIGTERM handler")
1095            .recv()
1096            .await;
1097    };
1098
1099    #[cfg(not(unix))]
1100    let terminate = {
1101        use std::future::pending;
1102        pending::<()>()
1103    };
1104
1105    select! {
1106        () = ctrl_c => {},
1107        () = terminate => {},
1108    }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114
1115    use ironflow_core::account_strategy::Priority;
1116    use ironflow_core::providers::claude::ClaudeCodeProvider;
1117    use ironflow_core::providers::record_replay_decision::RecordReplayDecisionProvider;
1118    use ironflow_engine::context::WorkflowContext;
1119    use ironflow_engine::handler::HandlerFuture;
1120    use ironflow_store::entities::{MAX_WORKER_TAGS, WorkerTagError};
1121
1122    #[test]
1123    fn builder_new_creates_default_config() {
1124        let builder = WorkerBuilder::new("http://localhost:3000", "my-token");
1125        assert_eq!(builder.api_url, "http://localhost:3000");
1126        assert_eq!(builder.worker_token, "my-token");
1127        assert_eq!(builder.concurrency, DEFAULT_CONCURRENCY);
1128        assert_eq!(builder.poll_interval, DEFAULT_POLL_INTERVAL);
1129        assert_eq!(builder.run_timeout, DEFAULT_RUN_TIMEOUT);
1130        assert_eq!(
1131            builder.max_consecutive_panics,
1132            DEFAULT_MAX_CONSECUTIVE_PANICS
1133        );
1134        assert_eq!(builder.panic_cooldown, DEFAULT_PANIC_COOLDOWN);
1135        assert!(builder.provider.is_none());
1136    }
1137
1138    #[test]
1139    fn builder_with_trailing_slash_normalized() {
1140        let builder = WorkerBuilder::new("http://localhost:3000/", "token");
1141        assert_eq!(builder.api_url, "http://localhost:3000/");
1142    }
1143
1144    #[test]
1145    fn builder_provider_sets_provider() {
1146        let provider = Arc::new(ClaudeCodeProvider::new());
1147        let builder =
1148            WorkerBuilder::new("http://localhost:3000", "token").provider(provider.clone());
1149        assert!(builder.provider.is_some());
1150    }
1151
1152    #[test]
1153    fn builder_account_strategy_sets_strategy() {
1154        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1155        assert!(builder.account_strategy.is_none());
1156        let builder = builder.account_strategy(Arc::new(Priority));
1157        assert_eq!(
1158            builder.account_strategy.as_ref().map(|s| s.name()),
1159            Some("priority")
1160        );
1161    }
1162
1163    #[test]
1164    fn builder_max_capacity_wait_defaults_to_six_hours_and_can_be_set() {
1165        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1166        assert_eq!(builder.max_capacity_wait, DEFAULT_MAX_CAPACITY_WAIT);
1167        assert_eq!(builder.max_capacity_wait, Duration::from_secs(6 * 3600));
1168        let builder = builder.max_capacity_wait(Duration::ZERO);
1169        assert_eq!(builder.max_capacity_wait, Duration::ZERO);
1170    }
1171
1172    #[test]
1173    fn builder_decision_provider_defaults_to_none() {
1174        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1175        assert!(builder.decision_provider.is_none());
1176    }
1177
1178    #[test]
1179    fn builder_decision_provider_sets_provider() {
1180        let builder = WorkerBuilder::new("http://localhost:3000", "token").decision_provider(
1181            Arc::new(RecordReplayDecisionProvider::replay("tests/fixtures")),
1182        );
1183        assert!(builder.decision_provider.is_some());
1184    }
1185
1186    #[test]
1187    fn builder_concurrency_sets_concurrency() {
1188        let builder = WorkerBuilder::new("http://localhost:3000", "token").concurrency(8);
1189        assert_eq!(builder.concurrency, 8);
1190    }
1191
1192    #[test]
1193    fn builder_concurrency_zero_accepted() {
1194        let provider = Arc::new(ClaudeCodeProvider::new());
1195        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1196            .provider(provider)
1197            .concurrency(0);
1198        assert_eq!(builder.concurrency, 0);
1199    }
1200
1201    #[test]
1202    fn builder_poll_interval_sets_interval() {
1203        let interval = Duration::from_secs(5);
1204        let builder = WorkerBuilder::new("http://localhost:3000", "token").poll_interval(interval);
1205        assert_eq!(builder.poll_interval, interval);
1206    }
1207
1208    #[test]
1209    fn builder_run_timeout_sets_timeout() {
1210        let dur = Duration::from_secs(120);
1211        let builder = WorkerBuilder::new("http://localhost:3000", "token").run_timeout(dur);
1212        assert_eq!(builder.run_timeout, dur);
1213    }
1214
1215    #[test]
1216    fn builder_max_consecutive_panics_sets_value() {
1217        let builder =
1218            WorkerBuilder::new("http://localhost:3000", "token").max_consecutive_panics(10);
1219        assert_eq!(builder.max_consecutive_panics, 10);
1220    }
1221
1222    #[test]
1223    fn builder_panic_cooldown_sets_value() {
1224        let dur = Duration::from_secs(600);
1225        let builder = WorkerBuilder::new("http://localhost:3000", "token").panic_cooldown(dur);
1226        assert_eq!(builder.panic_cooldown, dur);
1227    }
1228
1229    #[test]
1230    fn builder_defaults_lease_settings() {
1231        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1232        assert_eq!(builder.lease_ttl, DEFAULT_LEASE_TTL);
1233        assert_eq!(
1234            builder.lease_refresh_interval,
1235            DEFAULT_LEASE_REFRESH_INTERVAL
1236        );
1237        assert!(builder.worker_id.starts_with("worker-"));
1238    }
1239
1240    #[test]
1241    fn builder_generates_a_distinct_worker_id_per_instance() {
1242        let a = WorkerBuilder::new("http://localhost:3000", "token");
1243        let b = WorkerBuilder::new("http://localhost:3000", "token");
1244        assert_ne!(a.worker_id, b.worker_id);
1245    }
1246
1247    #[test]
1248    fn builder_worker_id_overrides_default() {
1249        let builder = WorkerBuilder::new("http://localhost:3000", "token").worker_id("worker-eu-1");
1250        assert_eq!(builder.worker_id, "worker-eu-1");
1251    }
1252
1253    #[test]
1254    fn builder_lease_ttl_sets_value() {
1255        let dur = Duration::from_secs(120);
1256        let builder = WorkerBuilder::new("http://localhost:3000", "token").lease_ttl(dur);
1257        assert_eq!(builder.lease_ttl, dur);
1258    }
1259
1260    #[test]
1261    fn builder_lease_refresh_interval_sets_value() {
1262        let dur = Duration::from_secs(5);
1263        let builder =
1264            WorkerBuilder::new("http://localhost:3000", "token").lease_refresh_interval(dur);
1265        assert_eq!(builder.lease_refresh_interval, dur);
1266    }
1267
1268    #[test]
1269    fn builder_build_without_provider_fails() {
1270        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1271        let result = builder.build();
1272        assert!(result.is_err());
1273        match result {
1274            Err(WorkerError::Internal(msg)) => {
1275                assert!(msg.contains("provider is required"));
1276            }
1277            _ => panic!("expected Internal error about missing provider"),
1278        }
1279    }
1280
1281    #[test]
1282    fn builder_build_with_provider_succeeds() {
1283        let provider = Arc::new(ClaudeCodeProvider::new());
1284        let builder = WorkerBuilder::new("http://localhost:3000", "token").provider(provider);
1285        let result = builder.build();
1286        assert!(result.is_ok());
1287    }
1288
1289    struct Named(&'static str);
1290
1291    impl WorkflowHandler for Named {
1292        fn name(&self) -> &str {
1293            self.0
1294        }
1295
1296        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1297            Box::pin(async { Ok(()) })
1298        }
1299    }
1300
1301    fn tagged_builder() -> WorkerBuilder {
1302        WorkerBuilder::new("http://localhost:3000", "token")
1303            .provider(Arc::new(ClaudeCodeProvider::new()))
1304    }
1305
1306    #[test]
1307    fn builder_tags_default_to_empty() {
1308        let worker = tagged_builder().build().unwrap();
1309        assert!(worker.capabilities().tags.is_empty());
1310        let engine_tags = worker.engine.worker_tags();
1311        assert!(
1312            engine_tags.is_some_and(<[String]>::is_empty),
1313            "{engine_tags:?}"
1314        );
1315    }
1316
1317    #[test]
1318    fn builder_tags_are_normalized_and_given_to_the_engine() {
1319        let worker = tagged_builder()
1320            .tags(["region:eu", " gpu ", "gpu"])
1321            .build()
1322            .unwrap();
1323        let expected = vec!["gpu".to_string(), "region:eu".to_string()];
1324        assert_eq!(worker.capabilities().tags, expected);
1325        assert_eq!(worker.engine.worker_tags(), Some(expected.as_slice()));
1326    }
1327
1328    #[test]
1329    fn builder_tags_extend_previous_ones() {
1330        let worker = tagged_builder()
1331            .tags(["gpu"])
1332            .tags(vec!["arm".to_string()])
1333            .build()
1334            .unwrap();
1335        assert_eq!(
1336            worker.capabilities().tags,
1337            vec!["arm".to_string(), "gpu".to_string()]
1338        );
1339    }
1340
1341    #[test]
1342    fn builder_tags_capabilities_list_registered_workflows_sorted() {
1343        let worker = tagged_builder()
1344            .register(Named("deploy"))
1345            .register(Named("build"))
1346            .build()
1347            .unwrap();
1348        assert_eq!(
1349            worker.capabilities().workflows,
1350            Some(vec!["build".to_string(), "deploy".to_string()])
1351        );
1352    }
1353
1354    #[test]
1355    fn builder_tags_capabilities_without_handlers_take_no_workflow() {
1356        let worker = tagged_builder().build().unwrap();
1357        assert_eq!(worker.capabilities().workflows, Some(Vec::new()));
1358    }
1359
1360    #[test]
1361    fn builder_tags_workflow_name_with_comma_disables_workflow_filter() {
1362        let worker = tagged_builder()
1363            .register(Named("deploy"))
1364            .register(Named("build,test"))
1365            .tags(["gpu"])
1366            .build()
1367            .unwrap();
1368        assert_eq!(worker.capabilities().workflows, None);
1369        assert_eq!(worker.capabilities().tags, vec!["gpu".to_string()]);
1370    }
1371
1372    #[test]
1373    fn builder_rejects_invalid_tags() {
1374        let invalid: [&[&str]; 3] = [&["bad tag"], &[""], &["gpu", "  "]];
1375        for tags in invalid {
1376            let result = tagged_builder().tags(tags.iter().copied()).build();
1377            let Err(WorkerError::Engine(EngineError::InvalidWorkerTag(_))) = result else {
1378                panic!("tags {tags:?} must be rejected");
1379            };
1380        }
1381    }
1382
1383    #[test]
1384    fn builder_rejects_too_many_tags() {
1385        let tags = (0..=MAX_WORKER_TAGS).map(|i| format!("tag-{i}"));
1386        let result = tagged_builder().tags(tags).build();
1387        let Err(WorkerError::Engine(EngineError::InvalidWorkerTag(err))) = result else {
1388            panic!("too many tags must be rejected");
1389        };
1390        assert!(matches!(err, WorkerTagError::TooMany { .. }), "{err:?}");
1391    }
1392
1393    #[test]
1394    fn builder_build_creates_worker_with_correct_concurrency() {
1395        let provider = Arc::new(ClaudeCodeProvider::new());
1396        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1397            .provider(provider)
1398            .concurrency(16);
1399        let worker = builder.build().unwrap();
1400        assert_eq!(worker.concurrency, 16);
1401    }
1402
1403    #[test]
1404    fn builder_build_creates_worker_with_correct_interval() {
1405        let provider = Arc::new(ClaudeCodeProvider::new());
1406        let interval = Duration::from_secs(10);
1407        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1408            .provider(provider)
1409            .poll_interval(interval);
1410        let worker = builder.build().unwrap();
1411        assert_eq!(worker.poll_interval, interval);
1412    }
1413
1414    #[test]
1415    fn builder_build_preserves_timeout() {
1416        let provider = Arc::new(ClaudeCodeProvider::new());
1417        let dur = Duration::from_secs(300);
1418        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1419            .provider(provider)
1420            .run_timeout(dur)
1421            .build()
1422            .unwrap();
1423        assert_eq!(worker.run_timeout, dur);
1424    }
1425
1426    #[test]
1427    fn builder_build_preserves_poison_pill_config() {
1428        let provider = Arc::new(ClaudeCodeProvider::new());
1429        let cooldown = Duration::from_secs(120);
1430        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1431            .provider(provider)
1432            .max_consecutive_panics(7)
1433            .panic_cooldown(cooldown)
1434            .build()
1435            .unwrap();
1436        assert_eq!(worker.max_consecutive_panics, 7);
1437        assert_eq!(worker.panic_cooldown, cooldown);
1438    }
1439
1440    #[test]
1441    fn builder_chaining_works() {
1442        let provider = Arc::new(ClaudeCodeProvider::new());
1443        let result = WorkerBuilder::new("http://localhost:3000", "token")
1444            .provider(provider)
1445            .concurrency(4)
1446            .poll_interval(Duration::from_secs(3))
1447            .run_timeout(Duration::from_secs(600))
1448            .max_consecutive_panics(5)
1449            .panic_cooldown(Duration::from_secs(120))
1450            .build();
1451        assert!(result.is_ok());
1452        let worker = result.unwrap();
1453        assert_eq!(worker.concurrency, 4);
1454        assert_eq!(worker.poll_interval, Duration::from_secs(3));
1455        assert_eq!(worker.run_timeout, Duration::from_secs(600));
1456        assert_eq!(worker.max_consecutive_panics, 5);
1457        assert_eq!(worker.panic_cooldown, Duration::from_secs(120));
1458    }
1459
1460    #[test]
1461    fn builder_empty_api_url_accepted() {
1462        let provider = Arc::new(ClaudeCodeProvider::new());
1463        let builder = WorkerBuilder::new("", "token").provider(provider);
1464        let result = builder.build();
1465        assert!(result.is_ok());
1466    }
1467
1468    #[test]
1469    fn builder_empty_token_accepted() {
1470        let provider = Arc::new(ClaudeCodeProvider::new());
1471        let builder = WorkerBuilder::new("http://localhost:3000", "").provider(provider);
1472        let result = builder.build();
1473        assert!(result.is_ok());
1474    }
1475
1476    #[cfg(feature = "heartbeat")]
1477    #[test]
1478    fn builder_heartbeat_defaults() {
1479        let builder = WorkerBuilder::new("http://localhost:3000", "token");
1480        assert!(builder.heartbeat_url.is_none());
1481        assert_eq!(builder.heartbeat_interval, DEFAULT_HEARTBEAT_INTERVAL);
1482    }
1483
1484    #[cfg(feature = "heartbeat")]
1485    #[test]
1486    fn builder_heartbeat_url_sets_url() {
1487        let builder = WorkerBuilder::new("http://localhost:3000", "token")
1488            .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc");
1489        assert_eq!(
1490            builder.heartbeat_url.as_deref(),
1491            Some("https://uptime.betterstack.com/api/v1/heartbeat/abc")
1492        );
1493    }
1494
1495    #[cfg(feature = "heartbeat")]
1496    #[test]
1497    fn builder_heartbeat_custom_interval() {
1498        let interval = Duration::from_secs(10);
1499        let builder =
1500            WorkerBuilder::new("http://localhost:3000", "token").heartbeat_interval(interval);
1501        assert_eq!(builder.heartbeat_interval, interval);
1502    }
1503
1504    #[cfg(feature = "heartbeat")]
1505    #[test]
1506    fn builder_build_preserves_heartbeat_config() {
1507        let provider = Arc::new(ClaudeCodeProvider::new());
1508        let interval = Duration::from_secs(15);
1509        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1510            .provider(provider)
1511            .heartbeat_url("https://example.com/heartbeat")
1512            .heartbeat_interval(interval)
1513            .build()
1514            .unwrap();
1515        assert_eq!(
1516            worker.heartbeat_url.as_deref(),
1517            Some("https://example.com/heartbeat")
1518        );
1519        assert_eq!(worker.heartbeat_interval, interval);
1520    }
1521
1522    #[cfg(feature = "heartbeat")]
1523    #[test]
1524    fn builder_build_without_heartbeat_url_has_none() {
1525        let provider = Arc::new(ClaudeCodeProvider::new());
1526        let worker = WorkerBuilder::new("http://localhost:3000", "token")
1527            .provider(provider)
1528            .build()
1529            .unwrap();
1530        assert!(worker.heartbeat_url.is_none());
1531    }
1532
1533    // --- PoisonPillTracker tests ---
1534
1535    #[test]
1536    fn poison_tracker_not_blocked_initially() {
1537        let tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1538        assert!(!tracker.is_blocked("my-workflow"));
1539    }
1540
1541    #[test]
1542    fn poison_tracker_blocked_after_max_panics() {
1543        let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1544        assert!(!tracker.record_panic("wf"));
1545        assert!(!tracker.record_panic("wf"));
1546        assert!(tracker.record_panic("wf"));
1547        assert!(tracker.is_blocked("wf"));
1548    }
1549
1550    #[test]
1551    fn poison_tracker_success_resets_count() {
1552        let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1553        tracker.record_panic("wf");
1554        tracker.record_panic("wf");
1555        tracker.record_success("wf");
1556        assert!(!tracker.is_blocked("wf"));
1557        // After reset, need 3 more panics to block
1558        assert!(!tracker.record_panic("wf"));
1559    }
1560
1561    #[test]
1562    fn poison_tracker_independent_per_workflow() {
1563        let mut tracker = PoisonPillTracker::new(2, Duration::from_secs(300));
1564        tracker.record_panic("wf-a");
1565        tracker.record_panic("wf-a");
1566        assert!(tracker.is_blocked("wf-a"));
1567        assert!(!tracker.is_blocked("wf-b"));
1568    }
1569
1570    #[test]
1571    fn poison_tracker_unblocks_after_cooldown() {
1572        let mut tracker = PoisonPillTracker::new(2, Duration::from_millis(0));
1573        tracker.record_panic("wf");
1574        tracker.record_panic("wf");
1575        // Cooldown is 0ms, should immediately unblock
1576        assert!(!tracker.is_blocked("wf"));
1577    }
1578
1579    #[tokio::test]
1580    async fn acquire_slot_returns_a_permit_when_a_slot_is_free() {
1581        let semaphore = Arc::new(Semaphore::new(1));
1582        let shutdown = CancellationToken::new();
1583
1584        let permit = acquire_slot(&semaphore, &shutdown)
1585            .await
1586            .expect("semaphore open");
1587
1588        assert!(permit.is_some());
1589        assert_eq!(semaphore.available_permits(), 0);
1590        drop(permit);
1591        assert_eq!(semaphore.available_permits(), 1);
1592    }
1593
1594    #[tokio::test]
1595    async fn acquire_slot_gives_up_on_shutdown_while_all_slots_are_busy() {
1596        let semaphore = Arc::new(Semaphore::new(1));
1597        let _busy = semaphore.clone().acquire_owned().await.expect("permit");
1598        let shutdown = CancellationToken::new();
1599
1600        let cancel = shutdown.clone();
1601        spawn(async move {
1602            sleep(Duration::from_millis(20)).await;
1603            cancel.cancel();
1604        });
1605
1606        let permit = timeout(Duration::from_secs(5), acquire_slot(&semaphore, &shutdown))
1607            .await
1608            .expect("waiting for a slot must stop on shutdown")
1609            .expect("semaphore open");
1610
1611        assert!(permit.is_none());
1612    }
1613
1614    #[tokio::test]
1615    async fn acquire_slot_takes_no_permit_once_shutdown_is_requested() {
1616        let semaphore = Arc::new(Semaphore::new(1));
1617        let shutdown = CancellationToken::new();
1618        shutdown.cancel();
1619
1620        let permit = acquire_slot(&semaphore, &shutdown)
1621            .await
1622            .expect("semaphore open");
1623
1624        assert!(permit.is_none());
1625        assert_eq!(semaphore.available_permits(), 1);
1626    }
1627
1628    #[tokio::test]
1629    async fn acquire_slot_fails_when_the_semaphore_is_closed() {
1630        let semaphore = Arc::new(Semaphore::new(1));
1631        semaphore.close();
1632        let shutdown = CancellationToken::new();
1633
1634        let result = acquire_slot(&semaphore, &shutdown).await;
1635
1636        assert!(matches!(result, Err(WorkerError::Internal(_))));
1637    }
1638}