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