Skip to main content

sayiir_runtime/
worker.rs

1//! Pooled worker for distributed, multi-worker workflow execution.
2//!
3//! A pooled worker is part of a worker pool that collaboratively executes workflows.
4//! Each worker polls the backend for available tasks, claims them (to prevent duplicates),
5//! executes them, and updates the snapshot. Multiple workers can process tasks from
6//! the same workflow instance in parallel.
7//!
8//! **Use this when**: You need horizontal scaling with multiple workers processing
9//! tasks concurrently across machines or processes.
10//!
11//! **Use [`CheckpointingRunner`](crate::runner::distributed::CheckpointingRunner) instead when**:
12//! You want a single process to run an entire workflow with crash recovery.
13
14use std::collections::HashMap;
15
16use bytes::Bytes;
17use chrono;
18use futures::FutureExt;
19use sayiir_core::codec::sealed;
20use sayiir_core::codec::{Codec, EnvelopeCodec, LoopDecision};
21use sayiir_core::context::{TaskExecutionContext, with_task_context};
22use sayiir_core::error::{BoxError, CodecError, WorkflowError};
23use sayiir_core::registry::TaskRegistry;
24use sayiir_core::snapshot::{
25    ExecutionPosition, SignalKind, SignalRequest, TaskDeadline, TaskHint, WorkflowSnapshot,
26};
27use sayiir_core::task_claim::AvailableTask;
28use sayiir_core::workflow::{Workflow, WorkflowContinuation, WorkflowStatus};
29use sayiir_persistence::{PersistentBackend, TaskClaimStore, TaskWakeupHint};
30use std::num::NonZeroUsize;
31use std::panic::AssertUnwindSafe;
32use std::pin::Pin;
33use std::sync::Arc;
34use std::time::Duration;
35use tokio::sync::mpsc;
36use tokio::time;
37
38/// Fallback poll-tick interval, phase-shifted per worker so a fleet spawned
39/// together doesn't fire the `find_available_tasks` scan in lockstep (a
40/// synchronized DB herd). Offset is `hash(worker_id) % poll_interval`. Keeps
41/// the default `Burst` missed-tick behavior: a worker that falls behind catches
42/// up immediately, which is what drives saturated back-to-back pickup.
43fn staggered_poll_interval(worker_id: &str, poll_interval: Duration) -> time::Interval {
44    use std::hash::{Hash, Hasher};
45    let mut hasher = std::collections::hash_map::DefaultHasher::new();
46    worker_id.hash(&mut hasher);
47    let period_ns = u64::try_from(poll_interval.as_nanos())
48        .unwrap_or(u64::MAX)
49        .max(1);
50    let offset = Duration::from_nanos(hasher.finish() % period_ns);
51    time::interval_at(time::Instant::now() + offset, poll_interval)
52}
53
54/// A list of workflow definitions keyed by their definition hash.
55pub type WorkflowRegistry<C, Input, M> =
56    Vec<(sayiir_core::DefinitionHash, Arc<Workflow<C, Input, M>>)>;
57
58/// Workflow definition for binding-friendly worker API.
59///
60/// Contains only the structural information (definition hash + continuation tree)
61/// needed by `PooledWorker` for position tracking, completion detection, retry
62/// policies, and timeouts. Task execution is delegated to an external executor.
63pub struct ExternalWorkflow {
64    /// The continuation tree describing the workflow structure.
65    pub continuation: Arc<WorkflowContinuation>,
66    /// Per-workflow `TaskId → metadata` index. Built once when the workflow
67    /// is registered; replaces the O(N) tree walk + per-node SHA-256 that
68    /// the worker would otherwise do for every task dispatch.
69    pub task_index: Arc<sayiir_core::TaskIndex>,
70    /// Human-readable workflow name (for log/error display and FFI task executor).
71    pub workflow_id: Arc<str>,
72    /// Optional JSON-encoded workflow-level metadata.
73    pub metadata_json: Option<Arc<str>>,
74}
75
76/// Workflow index keyed by definition hash for O(1) lookup during task dispatch.
77pub type WorkflowIndex = HashMap<sayiir_core::DefinitionHash, ExternalWorkflow>;
78
79/// Definition-hash containment over either [`WorkflowIndex`] (`HashMap`,
80/// O(1)) or [`WorkflowRegistry`] (`Vec`, O(n)). Lets `can_handle_hint`
81/// accept any worker-registered workflows collection uniformly instead
82/// of taking a hand-rolled closure per call site.
83pub(crate) trait WorkflowLookup {
84    fn contains_definition_hash(&self, hash: &sayiir_core::DefinitionHash) -> bool;
85}
86
87impl WorkflowLookup for WorkflowIndex {
88    fn contains_definition_hash(&self, hash: &sayiir_core::DefinitionHash) -> bool {
89        self.contains_key(hash)
90    }
91}
92
93impl<C, Input, M> WorkflowLookup for WorkflowRegistry<C, Input, M> {
94    fn contains_definition_hash(&self, hash: &sayiir_core::DefinitionHash) -> bool {
95        self.iter().any(|(k, _)| k == hash)
96    }
97}
98
99/// External task executor function signature.
100///
101/// Receives the task ID and input bytes, returns the output bytes.
102/// Used by language bindings (Python, Node.js) to delegate task execution
103/// to the host language's runtime.
104pub type ExternalTaskExecutor = Arc<
105    dyn Fn(
106            &str,
107            Bytes,
108        ) -> Pin<Box<dyn std::future::Future<Output = Result<Bytes, BoxError>> + Send>>
109        + Send
110        + Sync,
111>;
112
113/// Internal command sent from [`WorkerHandle`] to the actor loop.
114enum WorkerCommand {
115    Shutdown,
116}
117
118struct WorkerHandleInner<B> {
119    backend: Arc<B>,
120    shutdown_tx: mpsc::Sender<WorkerCommand>,
121    join_handle:
122        tokio::sync::Mutex<Option<tokio::task::JoinHandle<Result<(), crate::error::RuntimeError>>>>,
123}
124
125/// A cloneable handle for interacting with a running [`PooledWorker`].
126///
127/// Obtained from [`PooledWorker::spawn`]. The handle is cheap to clone and can
128/// be shared across tasks. Dropping **all** handles triggers a graceful
129/// shutdown of the worker (equivalent to calling [`shutdown`](Self::shutdown)).
130pub struct WorkerHandle<B> {
131    inner: Arc<WorkerHandleInner<B>>,
132}
133
134impl<B> Clone for WorkerHandle<B> {
135    fn clone(&self) -> Self {
136        Self {
137            inner: Arc::clone(&self.inner),
138        }
139    }
140}
141
142impl<B> WorkerHandle<B> {
143    /// Request a graceful shutdown of the worker.
144    ///
145    /// The worker will finish its current task (if any) and then exit.
146    /// This is a non-async, fire-and-forget operation — errors are ignored
147    /// (the actor may have already stopped).
148    pub fn shutdown(&self) {
149        let _ = self.inner.shutdown_tx.try_send(WorkerCommand::Shutdown);
150    }
151
152    /// Wait for the worker task to finish.
153    ///
154    /// The first caller gets the real result; subsequent callers get `Ok(())`.
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if the worker task panicked or returned an error.
159    pub async fn join(&self) -> Result<(), crate::error::RuntimeError> {
160        let jh = self.inner.join_handle.lock().await.take();
161        match jh {
162            Some(jh) => Ok(jh.await??),
163            None => Ok(()),
164        }
165    }
166
167    /// Get a reference to the backend.
168    #[must_use]
169    pub fn backend(&self) -> &Arc<B> {
170        &self.inner.backend
171    }
172}
173
174/// Owns a claimed task and provides explicit release methods.
175///
176/// No `Drop` impl — callers must explicitly call `release()` or `release_quietly()`.
177struct ActiveTaskClaim<'a, B> {
178    backend: &'a B,
179    instance_id: std::sync::Arc<str>,
180    task_id: sayiir_core::TaskId,
181    worker_id: String,
182}
183
184impl<B: TaskClaimStore> ActiveTaskClaim<'_, B> {
185    /// Release the claim, propagating backend errors.
186    async fn release(self) -> Result<(), crate::error::RuntimeError> {
187        self.backend
188            .release_task_claim(&self.instance_id, &self.task_id, &self.worker_id)
189            .await?;
190        Ok(())
191    }
192
193    /// Release the claim, silently ignoring errors. Use for error/panic paths.
194    async fn release_quietly(self) {
195        let _ = self.release().await;
196    }
197}
198
199/// Outcome of running a task through `execute_with_deadline`.
200enum ExecutionOutcome {
201    /// Task completed successfully.
202    Success(Bytes),
203    /// Task execution returned an error.
204    TaskError(crate::error::RuntimeError),
205    /// Task panicked.
206    Panic(Box<dyn std::any::Any + Send>),
207    /// Heartbeat detected an expired deadline (active cancellation).
208    Timeout(crate::error::RuntimeError),
209}
210
211/// Extract a human-readable message from a panic payload.
212fn extract_panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
213    if let Some(s) = payload.downcast_ref::<&str>() {
214        s.to_string()
215    } else if let Some(s) = payload.downcast_ref::<String>() {
216        s.clone()
217    } else {
218        "Task panicked with unknown payload".to_string()
219    }
220}
221
222/// A pooled worker that claims and executes tasks from a shared backend.
223///
224/// `PooledWorker` is designed for horizontal scaling: multiple workers can run
225/// across different machines/processes, all polling the same backend for tasks.
226/// Task claiming with TTL prevents duplicate execution while allowing automatic
227/// recovery when workers crash.
228///
229/// # When to Use
230///
231/// - **Horizontal scaling**: Multiple workers process tasks concurrently
232/// - **Fault tolerance**: Failed workers' tasks are automatically reclaimed
233/// - **Load balancing**: Tasks distributed across available workers
234///
235/// For single-process execution with checkpointing, use
236/// [`CheckpointingRunner`](crate::runner::distributed::CheckpointingRunner).
237///
238/// # Example
239///
240/// ```rust,no_run
241/// # use sayiir_runtime::prelude::*;
242/// # use std::sync::Arc;
243/// # use std::time::Duration;
244/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
245/// let backend = InMemoryBackend::new();
246/// let registry = TaskRegistry::new();
247/// let worker = PooledWorker::new("worker-1", backend, registry);
248///
249/// let ctx = WorkflowContext::new("my-wf", Arc::new(JsonCodec), Arc::new(()));
250/// let workflow = WorkflowBuilder::new(ctx)
251///     .then("step1", |i: u32| async move { Ok(i + 1) })
252///     .build()?;
253/// let workflows = vec![(*workflow.definition_hash(), Arc::new(workflow))];
254///
255/// // Spawn the worker and get a handle for lifecycle control
256/// let handle = worker.spawn(Duration::from_secs(1), workflows);
257/// // ... later ...
258/// handle.shutdown();
259/// handle.join().await?;
260/// # Ok(())
261/// # }
262/// ```
263pub struct PooledWorker<B> {
264    worker_id: String,
265    backend: Arc<B>,
266    #[allow(unused)]
267    registry: Arc<TaskRegistry>,
268    claim_ttl: Option<Duration>,
269    batch_size: NonZeroUsize,
270    aging_interval: Duration,
271    tags: Vec<String>,
272}
273
274impl<B> PooledWorker<B>
275where
276    B: PersistentBackend + TaskClaimStore + 'static,
277{
278    /// Create a new worker node.
279    ///
280    /// # Parameters
281    ///
282    /// - `worker_id`: Unique identifier for this worker node
283    /// - `backend`: The persistent backend to use
284    /// - `registry`: Task registry containing all task implementations
285    ///
286    /// # Heartbeat
287    ///
288    /// Heartbeats are derived automatically from `claim_ttl` (TTL / 2).
289    /// With the default 5-minute TTL, heartbeats fire every 2.5 minutes.
290    ///
291    pub fn new(worker_id: impl Into<String>, backend: B, registry: TaskRegistry) -> Self {
292        Self {
293            worker_id: worker_id.into(),
294            backend: Arc::new(backend),
295            registry: Arc::new(registry),
296            claim_ttl: Some(Duration::from_mins(5)), // Default 5 minutes
297            batch_size: NonZeroUsize::MIN,           // Default: fetch one task at a time (1)
298            aging_interval: Duration::from_mins(5),  // Default 5 minutes
299            tags: vec![],
300        }
301    }
302
303    /// Set the TTL for task claims.
304    ///
305    /// `None` means "never expires" — a crashed worker holding such a
306    /// claim pins the workflow undispatchable until the claim row is
307    /// manually released. Prefer a finite TTL (default 5 minutes) so
308    /// crashed-worker recovery happens via the eligibility predicate's
309    /// `expires_at > now()` check.
310    #[must_use]
311    pub fn with_claim_ttl(mut self, ttl: Option<Duration>) -> Self {
312        if ttl.is_none() {
313            tracing::warn!(
314                "PooledWorker::with_claim_ttl(None) disables claim expiry; \
315                 a crashed worker will pin its workflow until manual release"
316            );
317        }
318        self.claim_ttl = ttl;
319        self
320    }
321
322    /// Set the aging interval for priority-based scheduling.
323    ///
324    /// Lower-priority tasks that have been waiting longer than this interval
325    /// get their effective priority boosted, preventing starvation.
326    /// Default: 5 minutes (300 seconds).
327    ///
328    /// # Panics
329    ///
330    /// Panics if `interval` is zero.
331    #[must_use]
332    pub fn with_aging_interval(mut self, interval: Duration) -> Self {
333        assert!(!interval.is_zero(), "aging interval must be non-zero");
334        self.aging_interval = interval;
335        self
336    }
337
338    /// Set the number of tasks to fetch per poll (default: 1).
339    ///
340    /// With `batch_size=1`, the worker fetches one task, executes it, then polls again.
341    /// Other workers can pick up remaining tasks immediately.
342    ///
343    /// Higher values reduce polling overhead but may cause workers to hold task IDs
344    /// they won't process immediately (though other workers can still claim them).
345    #[must_use]
346    pub fn with_batch_size(mut self, size: NonZeroUsize) -> Self {
347        self.batch_size = size;
348        self
349    }
350
351    /// Set affinity tags for this worker.
352    ///
353    /// When tags are set, the worker only picks up tasks whose tags are a
354    /// subset of the worker's tags (or tasks with no tags). When no tags are
355    /// set (the default), the worker accepts all tasks.
356    #[must_use]
357    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
358        self.tags = tags;
359        self
360    }
361
362    /// Request cancellation of a workflow.
363    ///
364    /// This requests cancellation of the specified workflow instance.
365    /// Running tasks will complete, but no new tasks will be started.
366    ///
367    /// # Parameters
368    ///
369    /// - `instance_id`: The workflow instance ID to cancel
370    /// - `reason`: Optional reason for the cancellation
371    /// - `cancelled_by`: Optional identifier of who requested the cancellation
372    ///
373    /// # Errors
374    ///
375    /// Returns an error if the workflow cannot be cancelled (not found or in terminal state).
376    pub async fn cancel_workflow(
377        &self,
378        instance_id: &str,
379        reason: Option<String>,
380        cancelled_by: Option<String>,
381    ) -> Result<(), crate::error::RuntimeError> {
382        self.backend
383            .store_signal(
384                instance_id,
385                SignalKind::Cancel,
386                SignalRequest::new(reason, cancelled_by),
387            )
388            .await?;
389
390        Ok(())
391    }
392
393    /// Request pausing of a workflow.
394    ///
395    /// This requests pausing of the specified workflow instance.
396    /// Running tasks will complete, but no new tasks will be started.
397    ///
398    /// # Parameters
399    ///
400    /// - `instance_id`: The workflow instance ID to pause
401    /// - `reason`: Optional reason for the pause
402    /// - `paused_by`: Optional identifier of who requested the pause
403    ///
404    /// # Errors
405    ///
406    /// Returns an error if the workflow cannot be paused (not found or in terminal/paused state).
407    pub async fn pause_workflow(
408        &self,
409        instance_id: &str,
410        reason: Option<String>,
411        paused_by: Option<String>,
412    ) -> Result<(), crate::error::RuntimeError> {
413        self.backend
414            .store_signal(
415                instance_id,
416                SignalKind::Pause,
417                SignalRequest::new(reason, paused_by),
418            )
419            .await?;
420
421        Ok(())
422    }
423
424    /// Get a reference to the backend.
425    #[must_use]
426    pub fn backend(&self) -> &Arc<B> {
427        &self.backend
428    }
429
430    /// Spawn the worker as a background task and return a handle.
431    ///
432    /// Consumes `self`, creates an internal command channel, and spawns the
433    /// actor loop on the Tokio runtime. Returns a cloneable [`WorkerHandle`]
434    /// for lifecycle control — call [`WorkerHandle::shutdown`] to request
435    /// graceful shutdown and [`WorkerHandle::join`] to await completion.
436    ///
437    /// The worker runs until:
438    /// - [`WorkerHandle::shutdown`] is called, or
439    /// - All clones of the handle are dropped, or
440    /// - A fatal backend error occurs.
441    ///
442    /// # Parameters
443    ///
444    /// - `poll_interval`: How often to poll for new tasks
445    /// - `workflows`: Map of workflow definition hash to workflow
446    #[must_use]
447    pub fn spawn<C, Input, M>(
448        self,
449        poll_interval: Duration,
450        workflows: WorkflowRegistry<C, Input, M>,
451    ) -> WorkerHandle<B>
452    where
453        Input: Send + Sync + 'static,
454        M: Send + Sync + 'static,
455        C: Codec
456            + EnvelopeCodec
457            + sealed::DecodeValue<Input>
458            + sealed::EncodeValue<Input>
459            + 'static,
460    {
461        let (tx, rx) = mpsc::channel(1);
462        let backend = Arc::clone(&self.backend);
463        let join_handle =
464            tokio::spawn(async move { self.run_actor_loop(poll_interval, workflows, rx).await });
465        WorkerHandle {
466            inner: Arc::new(WorkerHandleInner {
467                backend,
468                shutdown_tx: tx,
469                join_handle: tokio::sync::Mutex::new(Some(join_handle)),
470            }),
471        }
472    }
473
474    /// Spawn the worker with an external executor and return a handle.
475    ///
476    /// Like [`spawn`](Self::spawn) but instead of executing tasks via typed
477    /// `Workflow` closures, delegates all task execution to the provided
478    /// `executor`. This is used by language bindings (Python, Node.js) where
479    /// task functions live in the host language.
480    ///
481    /// # Parameters
482    ///
483    /// - `poll_interval`: How often to poll for new tasks
484    /// - `workflows`: Workflow definitions (hash + continuation tree)
485    /// - `executor`: Closure that executes a task by ID given input bytes
486    #[must_use]
487    pub fn spawn_with_executor(
488        self,
489        poll_interval: Duration,
490        workflows: WorkflowIndex,
491        executor: ExternalTaskExecutor,
492    ) -> WorkerHandle<B> {
493        let (tx, rx) = mpsc::channel(1);
494        let backend = Arc::clone(&self.backend);
495        let join_handle = tokio::spawn(async move {
496            self.run_external_actor_loop(poll_interval, workflows, executor, rx)
497                .await
498        });
499        WorkerHandle {
500            inner: Arc::new(WorkerHandleInner {
501                backend,
502                shutdown_tx: tx,
503                join_handle: tokio::sync::Mutex::new(Some(join_handle)),
504            }),
505        }
506    }
507
508    /// Actor loop for external executor mode.
509    async fn run_external_actor_loop(
510        &self,
511        poll_interval: Duration,
512        workflows: WorkflowIndex,
513        executor: ExternalTaskExecutor,
514        mut cmd_rx: mpsc::Receiver<WorkerCommand>,
515    ) -> Result<(), crate::error::RuntimeError> {
516        let mut interval = staggered_poll_interval(&self.worker_id, poll_interval);
517
518        loop {
519            let hint = tokio::select! {
520                biased;
521
522                cmd = cmd_rx.recv() => {
523                    match cmd {
524                        Some(WorkerCommand::Shutdown) | None => {
525                            tracing::info!(worker_id = %self.worker_id, "Worker shutting down");
526                            return Ok(());
527                        }
528                    }
529                }
530                _ = interval.tick() => {
531                    tracing::trace!(worker_id = %self.worker_id, "fallback poll tick");
532                    None
533                }
534                hint = self.backend.wait_for_wakeup(poll_interval) => {
535                    let hint = hint?;
536                    tracing::debug!(
537                        worker_id = %self.worker_id,
538                        has_hint = hint.is_some(),
539                        "wakeup notification",
540                    );
541                    hint
542                }
543            };
544
545            if let Some(h) = hint.as_ref()
546                && !self.can_handle_hint(h, &workflows)
547            {
548                // This optimization cuts PG load proportional to NOTIFY volume x fleet-tag-fragmentation.
549                tracing::trace!(
550                    worker_id = %self.worker_id,
551                    instance_id = %h.instance_id,
552                    "skipping wakeup, hint not handleable here",
553                );
554                continue;
555            }
556
557            if let Some(h) = hint.as_ref() {
558                match self.try_hinted_execute(h, &workflows, &executor).await {
559                    Ok(true) => continue,
560                    Ok(false) => {}
561                    Err(ref e) if e.is_timeout() => {
562                        tracing::error!(worker_id = %self.worker_id, error = %e, "Task timed out — worker shutting down");
563                        return Ok(());
564                    }
565                    Err(e) => return Err(e),
566                }
567            }
568
569            let available_tasks = self
570                .backend
571                .find_available_tasks(
572                    &self.worker_id,
573                    self.batch_size.get(),
574                    chrono::Duration::from_std(self.aging_interval)
575                        .unwrap_or(chrono::Duration::MAX),
576                    &self.tags,
577                )
578                .await?;
579
580            for task in available_tasks {
581                if let Ok(WorkerCommand::Shutdown) | Err(mpsc::error::TryRecvError::Disconnected) =
582                    cmd_rx.try_recv()
583                {
584                    tracing::info!(worker_id = %self.worker_id, "Worker shutting down mid-batch");
585                    return Ok(());
586                }
587
588                if let Some(ext_wf) = workflows.get(&task.workflow_definition_hash) {
589                    match self
590                        .execute_external_task(
591                            ext_wf,
592                            &task.workflow_definition_hash,
593                            &executor,
594                            &task,
595                        )
596                        .await
597                    {
598                        Err(ref e) if e.is_timeout() => {
599                            tracing::error!(
600                                worker_id = %self.worker_id,
601                                error = %e,
602                                "Task timed out — worker shutting down"
603                            );
604                            return Ok(());
605                        }
606                        Ok(_) => {
607                            tracing::info!(worker_id = %self.worker_id, "completed task");
608                        }
609                        Err(e) => {
610                            tracing::error!(
611                                worker_id = %self.worker_id,
612                                error = %e,
613                                "task execution failed"
614                            );
615                        }
616                    }
617                }
618            }
619        }
620    }
621
622    /// True if the worker has the hint's workflow registered and its
623    /// tag set covers the hint's tags.
624    fn can_handle_hint(&self, hint: &TaskWakeupHint, workflows: &impl WorkflowLookup) -> bool {
625        let hash = sayiir_core::DefinitionHash::from_bytes(hint.definition_hash);
626        if !workflows.contains_definition_hash(&hash) {
627            return false;
628        }
629        hint.tags.iter().all(|t| self.tags.contains(t))
630    }
631
632    /// `Ok(true)` if the hinted task was claimed and executed (or its
633    /// execution failed non-fatally), `Ok(false)` to fall through to the
634    /// full polling scan, `Err(e)` on a fatal task timeout.
635    async fn try_hinted_execute(
636        &self,
637        hint: &TaskWakeupHint,
638        workflows: &WorkflowIndex,
639        executor: &ExternalTaskExecutor,
640    ) -> Result<bool, crate::error::RuntimeError> {
641        let Some(task) = self.backend.find_hinted_task(hint).await? else {
642            return Ok(false);
643        };
644        let Some(ext_wf) = workflows.get(&task.workflow_definition_hash) else {
645            return Ok(false);
646        };
647        match self
648            .execute_external_task(ext_wf, &task.workflow_definition_hash, executor, &task)
649            .await
650        {
651            Err(e) if e.is_timeout() => return Err(e),
652            Ok(_) => tracing::info!(worker_id = %self.worker_id, "completed hinted task"),
653            Err(e) => {
654                tracing::error!(worker_id = %self.worker_id, error = %e, "hinted task execution failed");
655            }
656        }
657        Ok(true)
658    }
659
660    /// Execute a single task using an external executor.
661    #[tracing::instrument(
662        name = "workflow",
663        skip_all,
664        fields(
665            worker_id = %self.worker_id,
666            instance_id = %available_task.instance_id,
667            task_id = %available_task.task_id,
668            definition_hash = %definition_hash,
669        ),
670    )]
671    async fn execute_external_task(
672        &self,
673        ext_wf: &ExternalWorkflow,
674        definition_hash: &sayiir_core::DefinitionHash,
675        executor: &ExternalTaskExecutor,
676        available_task: &AvailableTask,
677    ) -> Result<WorkflowStatus, crate::error::RuntimeError> {
678        // Link current span to the workflow's trace context (cross-worker propagation)
679        #[cfg(feature = "otel")]
680        if let Some(ref tp) = available_task.trace_parent {
681            use tracing_opentelemetry::OpenTelemetrySpanExt;
682            let remote_ctx = crate::trace_context::context_from_trace_parent(tp);
683            let _ = tracing::Span::current().set_parent(remote_ctx);
684        }
685
686        // Workers that lose the claim race drop the `available_task`
687        // (and its `Arc<WorkflowSnapshot>`) cheaply — keep the deep
688        // clone strictly past the claim_task gate.
689        let already_completed = Self::validate_task_preconditions(
690            definition_hash,
691            &ext_wf.task_index,
692            available_task,
693            &available_task.snapshot,
694        )?;
695        if already_completed {
696            return Ok(WorkflowStatus::InProgress);
697        }
698
699        let Some(claim) = self.claim_task(available_task).await? else {
700            return Ok(WorkflowStatus::InProgress);
701        };
702
703        if let Some(status) = self.check_post_claim_guards(available_task).await? {
704            claim.release_quietly().await;
705            return Ok(status);
706        }
707
708        // Past the claim gate — now pay for the deep clone exactly once.
709        let mut snapshot = (*available_task.snapshot).clone();
710
711        tracing::debug!(
712            instance_id = %available_task.instance_id,
713            task_id = %available_task.task_id,
714            "Executing task (external)"
715        );
716
717        let execution_result = self
718            .execute_with_deadline_ext(ext_wf, executor, available_task, &mut snapshot, &claim)
719            .await;
720
721        self.settle_execution_result_ext(
722            execution_result,
723            &ext_wf.continuation,
724            &ext_wf.task_index,
725            available_task,
726            &mut snapshot,
727            claim,
728        )
729        .await
730    }
731
732    /// Run the external executor with an optional deadline.
733    async fn execute_with_deadline_ext(
734        &self,
735        ext_wf: &ExternalWorkflow,
736        executor: &ExternalTaskExecutor,
737        available_task: &AvailableTask,
738        snapshot: &mut WorkflowSnapshot,
739        claim: &ActiveTaskClaim<'_, B>,
740    ) -> ExecutionOutcome {
741        let task_id = available_task.task_id;
742        let input = available_task.input.clone();
743        // Resolve the human-readable task name from the prebuilt index so the
744        // FFI executor can look up the Python/Node function by name. No tree
745        // walk and no per-node SHA-256 — this is a single hash-map probe.
746        let indexed_meta = ext_wf.task_index.get(&task_id);
747        let task_name: Arc<str> =
748            indexed_meta.map_or_else(|| Arc::from(task_id.to_hex()), |m| Arc::clone(m.name()));
749
750        let deadline =
751            if let Some(timeout) = indexed_meta.and_then(sayiir_core::TaskNodeMetadata::timeout) {
752                snapshot.set_task_deadline(task_id, timeout);
753                snapshot.refresh_task_deadline();
754                // Deadline lives in-process for `run_with_heartbeat`; it
755                // is persisted on the next save_task_result (which holds
756                // a FOR UPDATE lock and so can't race with check_and_*).
757                // A bare save_snapshot here would clobber a
758                // check_and_cancel that landed between
759                // check_post_claim_guards and now, silently resurrecting
760                // a cancelled workflow into InProgress.
761                snapshot.task_deadline.clone()
762            } else {
763                None
764            };
765
766        let task_ctx = TaskExecutionContext {
767            workflow_id: Arc::clone(&ext_wf.workflow_id),
768            instance_id: Arc::clone(&available_task.instance_id),
769            task_id: Arc::clone(&task_name),
770            metadata: ext_wf.task_index.build_task_metadata(&task_id),
771            workflow_metadata_json: ext_wf.metadata_json.clone(),
772        };
773
774        let execution_future = with_task_context(task_ctx, executor(&task_name, input));
775
776        let heartbeat_result = self
777            .run_with_heartbeat(
778                claim,
779                deadline.as_ref(),
780                AssertUnwindSafe(execution_future).catch_unwind(),
781            )
782            .await;
783
784        snapshot.clear_task_deadline();
785
786        match heartbeat_result {
787            Err(timeout_err) => ExecutionOutcome::Timeout(timeout_err),
788            Ok(Err(panic_payload)) => ExecutionOutcome::Panic(panic_payload),
789            Ok(Ok(Err(e))) => ExecutionOutcome::TaskError(e.into()),
790            Ok(Ok(Ok(output))) => ExecutionOutcome::Success(output),
791        }
792    }
793
794    /// Settle execution result for external executor mode.
795    #[tracing::instrument(
796        name = "settle_result",
797        skip_all,
798        fields(worker_id = %self.worker_id, instance_id = %available_task.instance_id, task_id = %available_task.task_id),
799    )]
800    async fn settle_execution_result_ext(
801        &self,
802        outcome: ExecutionOutcome,
803        continuation: &WorkflowContinuation,
804        task_index: &sayiir_core::TaskIndex,
805        available_task: &AvailableTask,
806        snapshot: &mut WorkflowSnapshot,
807        claim: ActiveTaskClaim<'_, B>,
808    ) -> Result<WorkflowStatus, crate::error::RuntimeError> {
809        tracing::debug!("settling execution result");
810        match outcome {
811            ExecutionOutcome::Timeout(err) => {
812                if let Ok(Some(status)) = self
813                    .try_schedule_retry(task_index, available_task, snapshot, &err.to_string())
814                    .await
815                {
816                    claim.release_quietly().await;
817                    return Ok(status);
818                }
819
820                tracing::warn!(
821                    instance_id = %available_task.instance_id,
822                    task_id = %available_task.task_id,
823                    error = %err,
824                    "Task timed out via heartbeat — marking workflow failed, shutting down"
825                );
826                snapshot.mark_failed(err.to_string());
827                let _ = self.backend.save_snapshot(snapshot).await;
828                claim.release_quietly().await;
829                Err(err)
830            }
831            ExecutionOutcome::Panic(panic_payload) => {
832                let panic_msg = extract_panic_message(&panic_payload);
833
834                if let Ok(Some(status)) = self
835                    .try_schedule_retry(task_index, available_task, snapshot, &panic_msg)
836                    .await
837                {
838                    claim.release_quietly().await;
839                    return Ok(status);
840                }
841
842                tracing::error!(
843                    instance_id = %available_task.instance_id,
844                    task_id = %available_task.task_id,
845                    panic = %panic_msg,
846                    "Task panicked - releasing claim"
847                );
848                claim.release_quietly().await;
849                Err(WorkflowError::TaskPanicked(panic_msg).into())
850            }
851            ExecutionOutcome::TaskError(e) => {
852                if let Ok(Some(status)) = self
853                    .try_schedule_retry(task_index, available_task, snapshot, &e.to_string())
854                    .await
855                {
856                    claim.release_quietly().await;
857                    return Ok(status);
858                }
859
860                tracing::error!(
861                    instance_id = %available_task.instance_id,
862                    task_id = %available_task.task_id,
863                    error = %e,
864                    "Task execution failed"
865                );
866                claim.release_quietly().await;
867                Err(e)
868            }
869            ExecutionOutcome::Success(output) => {
870                snapshot.clear_retry_state(&available_task.task_id);
871                self.commit_task_result(
872                    continuation,
873                    available_task,
874                    snapshot,
875                    output.clone(),
876                    claim,
877                )
878                .await?;
879                self.determine_post_task_status(continuation, available_task, snapshot, output)
880                    .await
881            }
882        }
883    }
884
885    /// The actor loop: poll for tasks, execute them, respond to shutdown.
886    ///
887    async fn run_actor_loop<C, Input, M>(
888        &self,
889        poll_interval: Duration,
890        workflows: WorkflowRegistry<C, Input, M>,
891        mut cmd_rx: mpsc::Receiver<WorkerCommand>,
892    ) -> Result<(), crate::error::RuntimeError>
893    where
894        Input: Send + 'static,
895        M: Send + Sync + 'static,
896        C: Codec
897            + EnvelopeCodec
898            + sealed::DecodeValue<Input>
899            + sealed::EncodeValue<Input>
900            + 'static,
901    {
902        let mut interval = staggered_poll_interval(&self.worker_id, poll_interval);
903
904        loop {
905            // In-process mode only filters irrelevant wakes; the direct-
906            // claim shortcut lives on the external-executor path.
907            let hint = tokio::select! {
908                biased;
909
910                cmd = cmd_rx.recv() => {
911                    match cmd {
912                        Some(WorkerCommand::Shutdown) | None => {
913                            tracing::info!(worker_id = %self.worker_id, "Worker shutting down");
914                            return Ok(());
915                        }
916                    }
917                }
918                _ = interval.tick() => {
919                    tracing::trace!(worker_id = %self.worker_id, "fallback poll tick");
920                    None
921                }
922                hint = self.backend.wait_for_wakeup(poll_interval) => {
923                    let hint = hint?;
924                    tracing::debug!(
925                        worker_id = %self.worker_id,
926                        has_hint = hint.is_some(),
927                        "wakeup notification",
928                    );
929                    hint
930                }
931            };
932
933            if let Some(h) = hint.as_ref()
934                && !self.can_handle_hint(h, &workflows)
935            {
936                // This optimization cuts PG load proportional to NOTIFY volume x fleet-tag-fragmentation.
937                tracing::trace!(
938                    worker_id = %self.worker_id,
939                    instance_id = %h.instance_id,
940                    "skipping wakeup, hint not handleable here",
941                );
942                continue;
943            }
944
945            let available_tasks = self
946                .backend
947                .find_available_tasks(
948                    &self.worker_id,
949                    self.batch_size.get(),
950                    chrono::Duration::from_std(self.aging_interval)
951                        .unwrap_or(chrono::Duration::MAX),
952                    &self.tags,
953                )
954                .await?;
955
956            for task in available_tasks {
957                if let Ok(WorkerCommand::Shutdown) | Err(mpsc::error::TryRecvError::Disconnected) =
958                    cmd_rx.try_recv()
959                {
960                    tracing::info!(worker_id = %self.worker_id, "Worker shutting down mid-batch");
961                    return Ok(());
962                }
963
964                if let Some((_, workflow)) = workflows
965                    .iter()
966                    .find(|(hash, _)| *hash == task.workflow_definition_hash)
967                // hash and task.workflow_definition_hash are both DefinitionHash
968                {
969                    match self.execute_task(workflow.as_ref(), task).await {
970                        Err(ref e) if e.is_timeout() => {
971                            tracing::error!(
972                                worker_id = %self.worker_id,
973                                error = %e,
974                                "Task timed out — worker shutting down"
975                            );
976                            return Ok(());
977                        }
978                        Ok(_) => {
979                            tracing::info!(worker_id = %self.worker_id, "completed task");
980                        }
981                        Err(e) => {
982                            tracing::error!(
983                                worker_id = %self.worker_id,
984                                error = %e,
985                                "task execution failed"
986                            );
987                        }
988                    }
989                }
990            }
991        }
992    }
993
994    /// Load cancellation status from a snapshot.
995    ///
996    /// Attempts to load the snapshot and extract cancellation details.
997    /// Returns `WorkflowStatus::Cancelled` with either the extracted details or defaults.
998    async fn load_cancelled_status(&self, instance_id: &str) -> WorkflowStatus {
999        if let Ok(snapshot) = self.backend.load_snapshot(instance_id).await
1000            && let Some((reason, cancelled_by)) = snapshot.state.cancellation_details()
1001        {
1002            return WorkflowStatus::Cancelled {
1003                reason,
1004                cancelled_by,
1005            };
1006        }
1007        WorkflowStatus::Cancelled {
1008            reason: None,
1009            cancelled_by: None,
1010        }
1011    }
1012
1013    /// Load paused status from a snapshot.
1014    ///
1015    /// Attempts to load the snapshot and extract pause details.
1016    /// Returns `WorkflowStatus::Paused` with either the extracted details or defaults.
1017    async fn load_paused_status(&self, instance_id: &str) -> WorkflowStatus {
1018        if let Ok(snapshot) = self.backend.load_snapshot(instance_id).await
1019            && let Some((reason, paused_by)) = snapshot.state.pause_details()
1020        {
1021            return WorkflowStatus::Paused { reason, paused_by };
1022        }
1023        WorkflowStatus::Paused {
1024            reason: None,
1025            paused_by: None,
1026        }
1027    }
1028
1029    /// Execute a single task from an available task.
1030    ///
1031    /// This claims the task, executes it, updates the snapshot, and releases the claim.
1032    ///
1033    /// # Errors
1034    ///
1035    /// Returns an error if:
1036    /// - The task cannot be claimed
1037    /// - The workflow definition hash doesn't match
1038    /// - Task execution fails
1039    /// - Snapshot update fails
1040    #[tracing::instrument(
1041        name = "workflow",
1042        skip_all,
1043        fields(
1044            worker_id = %self.worker_id,
1045            instance_id = %available_task.instance_id,
1046            task_id = %available_task.task_id,
1047            definition_hash = %available_task.workflow_definition_hash,
1048        ),
1049    )]
1050    pub async fn execute_task<C, Input, M>(
1051        &self,
1052        workflow: &Workflow<C, Input, M>,
1053        available_task: AvailableTask,
1054    ) -> Result<WorkflowStatus, crate::error::RuntimeError>
1055    where
1056        Input: Send + 'static,
1057        M: Send + Sync + 'static,
1058        C: Codec
1059            + EnvelopeCodec
1060            + sealed::DecodeValue<Input>
1061            + sealed::EncodeValue<Input>
1062            + 'static,
1063    {
1064        // Link current span to the workflow's trace context (cross-worker propagation)
1065        #[cfg(feature = "otel")]
1066        if let Some(ref tp) = available_task.trace_parent {
1067            use tracing_opentelemetry::OpenTelemetrySpanExt;
1068            let remote_ctx = crate::trace_context::context_from_trace_parent(tp);
1069            let _ = tracing::Span::current().set_parent(remote_ctx);
1070        }
1071
1072        // 1. Use the snapshot the dispatch SELECT already decoded.
1073        // Lost-race workers drop the `Arc<WorkflowSnapshot>` cheaply —
1074        // keep the deep clone strictly past the claim_task gate.
1075        let already_completed = Self::validate_task_preconditions(
1076            workflow.definition_hash(),
1077            workflow.task_index(),
1078            &available_task,
1079            &available_task.snapshot,
1080        )?;
1081        if already_completed {
1082            return Ok(WorkflowStatus::InProgress);
1083        }
1084
1085        let Some(claim) = self.claim_task(&available_task).await? else {
1086            return Ok(WorkflowStatus::InProgress);
1087        };
1088
1089        // 3. Post-claim guards (cancel/pause)
1090        if let Some(status) = self.check_post_claim_guards(&available_task).await? {
1091            claim.release_quietly().await;
1092            return Ok(status);
1093        }
1094
1095        // Past the claim gate — now pay for the deep clone exactly once.
1096        let mut snapshot = (*available_task.snapshot).clone();
1097
1098        tracing::debug!(
1099            instance_id = %available_task.instance_id,
1100            task_id = %available_task.task_id,
1101            "Executing task"
1102        );
1103
1104        // 4. Execute with deadline + heartbeat, then settle the result
1105        let execution_result = self
1106            .execute_with_deadline(workflow, &available_task, &mut snapshot, &claim)
1107            .await;
1108
1109        self.settle_execution_result(
1110            execution_result,
1111            workflow,
1112            &available_task,
1113            &mut snapshot,
1114            claim,
1115        )
1116        .await
1117    }
1118
1119    /// Run the task future with an optional deadline, returning the panic-wrapped result.
1120    ///
1121    /// Sets a deadline on the snapshot (if the task has a timeout), persists it,
1122    /// then runs the future inside `run_with_heartbeat`. On heartbeat-level timeout
1123    /// the task future is dropped and an `Err` is returned.
1124    async fn execute_with_deadline<C, Input, M>(
1125        &self,
1126        workflow: &Workflow<C, Input, M>,
1127        available_task: &AvailableTask,
1128        snapshot: &mut WorkflowSnapshot,
1129        claim: &ActiveTaskClaim<'_, B>,
1130    ) -> ExecutionOutcome
1131    where
1132        Input: Send + 'static,
1133        M: Send + Sync + 'static,
1134        C: Codec
1135            + EnvelopeCodec
1136            + sealed::DecodeValue<Input>
1137            + sealed::EncodeValue<Input>
1138            + 'static,
1139    {
1140        let continuation = workflow.continuation();
1141        let task_index = workflow.task_index();
1142        let task_id = available_task.task_id;
1143        // If this task is the entry of a Fork's join, rebuild the
1144        // `NamedBranchResults` envelope the join body deserialises into
1145        // `BranchOutputs`. The PooledWorker dispatch path otherwise
1146        // hands the join the LAST branch's output (whatever
1147        // `get_last_task_output` saw), which fails to deserialise as
1148        // soon as any branch result is read by id. The in-process
1149        // runner builds this through `resolve_join`; for the worker
1150        // path we have to do it explicitly because each task is its
1151        // own dispatch and there is no in-memory `branch_results`.
1152        let input = match Self::find_fork_branches_for_join(continuation, &task_id) {
1153            Some(branches) => {
1154                let build = || -> Result<Bytes, crate::error::RuntimeError> {
1155                    let mut results = Vec::with_capacity(branches.len());
1156                    for branch in branches {
1157                        // The join envelope keys results by `branch.id()`
1158                        // (the branch entry node) so user code can look
1159                        // up `results["branch-a"]`, but the actual output
1160                        // is stored under the branch's TERMINAL task —
1161                        // for multi-step branches the entry task's
1162                        // output is an intermediate value, not the
1163                        // branch's "result". TaskId::from(branch.id())
1164                        // works only for single-Task branches and either
1165                        // returns the wrong bytes (entry task's output)
1166                        // or surfaces a TaskNotFound that hides the real
1167                        // problem.
1168                        let branch_name = branch.id().to_string();
1169                        let terminal_tid = sayiir_core::TaskId::from(branch.terminal_task_id());
1170                        let output = snapshot
1171                            .get_task_result_bytes(&terminal_tid)
1172                            .ok_or_else(|| WorkflowError::TaskNotFound(branch_name.clone()))?;
1173                        results.push((branch_name, output));
1174                    }
1175                    crate::execution::serialize_branch_results(&results, workflow.codec().as_ref())
1176                };
1177                match build() {
1178                    Ok(bytes) => bytes,
1179                    Err(e) => return ExecutionOutcome::TaskError(e),
1180                }
1181            }
1182            None => available_task.input.clone(),
1183        };
1184        // Resolve human-readable task name from the prebuilt index for the
1185        // task context exposed to user code (no tree walk, no rehashing).
1186        let indexed_meta = task_index.get(&task_id);
1187        let task_name: Arc<str> =
1188            indexed_meta.map_or_else(|| Arc::from(task_id.to_hex()), |m| Arc::clone(m.name()));
1189
1190        // Set deadline if task has a timeout configured
1191        let deadline =
1192            if let Some(timeout) = indexed_meta.and_then(sayiir_core::TaskNodeMetadata::timeout) {
1193                snapshot.set_task_deadline(task_id, timeout);
1194                snapshot.refresh_task_deadline();
1195                // Deadline lives in-process for `run_with_heartbeat`; it
1196                // is persisted on the next save_task_result (which holds
1197                // a FOR UPDATE lock and so can't race with check_and_*).
1198                // A bare save_snapshot here would clobber a
1199                // check_and_cancel that landed between
1200                // check_post_claim_guards and now, silently resurrecting
1201                // a cancelled workflow into InProgress.
1202                snapshot.task_deadline.clone()
1203            } else {
1204                None
1205            };
1206
1207        let task_ctx = TaskExecutionContext {
1208            workflow_id: Arc::from(workflow.context().workflow_id()),
1209            instance_id: Arc::clone(&available_task.instance_id),
1210            task_id: Arc::clone(&task_name),
1211            metadata: task_index.build_task_metadata(&task_id),
1212            workflow_metadata_json: workflow.context().metadata_json.clone(),
1213        };
1214
1215        let execution_future = with_task_context(task_ctx, async move {
1216            Self::execute_task_by_id(continuation, &task_name, input).await
1217        });
1218
1219        let heartbeat_result = self
1220            .run_with_heartbeat(
1221                claim,
1222                deadline.as_ref(),
1223                AssertUnwindSafe(execution_future).catch_unwind(),
1224            )
1225            .await;
1226
1227        snapshot.clear_task_deadline();
1228
1229        match heartbeat_result {
1230            Err(timeout_err) => ExecutionOutcome::Timeout(timeout_err),
1231            Ok(Err(panic_payload)) => ExecutionOutcome::Panic(panic_payload),
1232            Ok(Ok(Err(e))) => ExecutionOutcome::TaskError(e),
1233            Ok(Ok(Ok(output))) => ExecutionOutcome::Success(output),
1234        }
1235    }
1236
1237    /// Try to schedule a retry for a failed task.
1238    ///
1239    /// Looks up the retry policy in the task index. If retries are available,
1240    /// records the retry state on the snapshot, clears the deadline, saves the
1241    /// snapshot, releases the claim, and returns `Ok(Some(InProgress))`.
1242    /// Otherwise returns `Ok(None)` (caller falls through to existing error handling).
1243    async fn try_schedule_retry(
1244        &self,
1245        task_index: &sayiir_core::TaskIndex,
1246        available_task: &AvailableTask,
1247        snapshot: &mut WorkflowSnapshot,
1248        error_msg: &str,
1249    ) -> Result<Option<WorkflowStatus>, crate::error::RuntimeError> {
1250        let Some(policy) = task_index.retry_policy(&available_task.task_id) else {
1251            return Ok(None);
1252        };
1253
1254        if snapshot.retries_exhausted(&available_task.task_id) {
1255            return Ok(None);
1256        }
1257
1258        let next_retry_at = snapshot.record_retry(
1259            available_task.task_id,
1260            policy,
1261            error_msg,
1262            Some(&self.worker_id),
1263        );
1264        snapshot.clear_task_deadline();
1265        let _ = self.backend.save_snapshot(snapshot).await;
1266
1267        tracing::info!(
1268            instance_id = %available_task.instance_id,
1269            task_id = %available_task.task_id,
1270            attempt = snapshot.get_retry_state(&available_task.task_id).map_or(0, |rs| rs.attempts),
1271            max_retries = policy.max_retries,
1272            %next_retry_at,
1273            "Scheduling retry"
1274        );
1275
1276        Ok(Some(WorkflowStatus::InProgress))
1277    }
1278
1279    /// Settle the outcome of task execution: persist results or errors, release claim.
1280    #[tracing::instrument(
1281        name = "settle_result",
1282        skip_all,
1283        fields(worker_id = %self.worker_id, instance_id = %available_task.instance_id, task_id = %available_task.task_id),
1284    )]
1285    async fn settle_execution_result<C, Input, M>(
1286        &self,
1287        outcome: ExecutionOutcome,
1288        workflow: &Workflow<C, Input, M>,
1289        available_task: &AvailableTask,
1290        snapshot: &mut WorkflowSnapshot,
1291        claim: ActiveTaskClaim<'_, B>,
1292    ) -> Result<WorkflowStatus, crate::error::RuntimeError>
1293    where
1294        Input: Send + 'static,
1295        M: Send + Sync + 'static,
1296        C: Codec
1297            + EnvelopeCodec
1298            + sealed::DecodeValue<Input>
1299            + sealed::EncodeValue<Input>
1300            + 'static,
1301    {
1302        tracing::debug!("settling execution result");
1303        match outcome {
1304            ExecutionOutcome::Timeout(err) => {
1305                if let Ok(Some(status)) = self
1306                    .try_schedule_retry(
1307                        workflow.task_index(),
1308                        available_task,
1309                        snapshot,
1310                        &err.to_string(),
1311                    )
1312                    .await
1313                {
1314                    claim.release_quietly().await;
1315                    return Ok(status);
1316                }
1317
1318                tracing::warn!(
1319                    instance_id = %available_task.instance_id,
1320                    task_id = %available_task.task_id,
1321                    error = %err,
1322                    "Task timed out via heartbeat — marking workflow failed, shutting down"
1323                );
1324                snapshot.mark_failed(err.to_string());
1325                let _ = self.backend.save_snapshot(snapshot).await;
1326                claim.release_quietly().await;
1327                Err(err)
1328            }
1329            ExecutionOutcome::Panic(panic_payload) => {
1330                let panic_msg = extract_panic_message(&panic_payload);
1331
1332                if let Ok(Some(status)) = self
1333                    .try_schedule_retry(workflow.task_index(), available_task, snapshot, &panic_msg)
1334                    .await
1335                {
1336                    claim.release_quietly().await;
1337                    return Ok(status);
1338                }
1339
1340                tracing::error!(
1341                    instance_id = %available_task.instance_id,
1342                    task_id = %available_task.task_id,
1343                    panic = %panic_msg,
1344                    "Task panicked - releasing claim"
1345                );
1346                claim.release_quietly().await;
1347                Err(WorkflowError::TaskPanicked(panic_msg).into())
1348            }
1349            ExecutionOutcome::TaskError(e) => {
1350                if let Ok(Some(status)) = self
1351                    .try_schedule_retry(
1352                        workflow.task_index(),
1353                        available_task,
1354                        snapshot,
1355                        &e.to_string(),
1356                    )
1357                    .await
1358                {
1359                    claim.release_quietly().await;
1360                    return Ok(status);
1361                }
1362
1363                tracing::error!(
1364                    instance_id = %available_task.instance_id,
1365                    task_id = %available_task.task_id,
1366                    error = %e,
1367                    "Task execution failed"
1368                );
1369                claim.release_quietly().await;
1370                Err(e)
1371            }
1372            ExecutionOutcome::Success(output) => {
1373                snapshot.clear_retry_state(&available_task.task_id);
1374                self.commit_task_result(
1375                    workflow.continuation(),
1376                    available_task,
1377                    snapshot,
1378                    output.clone(),
1379                    claim,
1380                )
1381                .await?;
1382                // After saving the body task result, resolve any parent loop
1383                // nodes so that is_workflow_complete() can detect loop completion.
1384                Self::resolve_loop_completions(
1385                    workflow.continuation(),
1386                    snapshot,
1387                    self.backend.as_ref(),
1388                )
1389                .await?;
1390                self.determine_post_task_status(
1391                    workflow.continuation(),
1392                    available_task,
1393                    snapshot,
1394                    output,
1395                )
1396                .await
1397            }
1398        }
1399    }
1400
1401    /// Validate task preconditions without side effects.
1402    ///
1403    /// Checks definition hash match, task existence in continuation,
1404    /// and that the task is not already completed in the snapshot.
1405    /// Returns `Ok(true)` if the task should be skipped (already completed).
1406    fn validate_task_preconditions(
1407        definition_hash: &sayiir_core::DefinitionHash,
1408        task_index: &sayiir_core::TaskIndex,
1409        available_task: &AvailableTask,
1410        snapshot: &WorkflowSnapshot,
1411    ) -> Result<bool, crate::error::RuntimeError> {
1412        if available_task.workflow_definition_hash != *definition_hash {
1413            return Err(WorkflowError::DefinitionMismatch {
1414                expected: *definition_hash,
1415                found: available_task.workflow_definition_hash,
1416            }
1417            .into());
1418        }
1419
1420        if !task_index.contains(&available_task.task_id) {
1421            tracing::error!(
1422                instance_id = %available_task.instance_id,
1423                task_id = %available_task.task_id,
1424                "Task does not exist in workflow"
1425            );
1426            return Err(WorkflowError::TaskNotFound(available_task.task_id.to_hex()).into());
1427        }
1428
1429        if snapshot.get_task_result(&available_task.task_id).is_some() {
1430            tracing::debug!(
1431                instance_id = %available_task.instance_id,
1432                task_id = %available_task.task_id,
1433                "Task already completed, skipping"
1434            );
1435            return Ok(true);
1436        }
1437
1438        Ok(false)
1439    }
1440
1441    /// Acquire a claim on the task, returning an `ActiveTaskClaim`.
1442    ///
1443    /// Returns `None` if already claimed by another worker.
1444    async fn claim_task(
1445        &self,
1446        available_task: &AvailableTask,
1447    ) -> Result<Option<ActiveTaskClaim<'_, B>>, crate::error::RuntimeError> {
1448        let claim = self
1449            .backend
1450            .claim_task(
1451                &available_task.instance_id,
1452                &available_task.task_id,
1453                &self.worker_id,
1454                self.claim_ttl
1455                    .and_then(|d| chrono::Duration::from_std(d).ok()),
1456            )
1457            .await?;
1458
1459        if claim.is_some() {
1460            tracing::debug!(
1461                instance_id = %available_task.instance_id,
1462                task_id = %available_task.task_id,
1463                "Claim successful"
1464            );
1465            Ok(Some(ActiveTaskClaim {
1466                backend: &self.backend,
1467                instance_id: Arc::clone(&available_task.instance_id),
1468                task_id: available_task.task_id,
1469                worker_id: self.worker_id.clone(),
1470            }))
1471        } else {
1472            tracing::debug!(
1473                instance_id = %available_task.instance_id,
1474                task_id = %available_task.task_id,
1475                "Task was already claimed by another worker"
1476            );
1477            Ok(None)
1478        }
1479    }
1480
1481    /// Check cancel/pause guards after claiming.
1482    ///
1483    /// Returns `Some(status)` if the workflow is cancelled or paused
1484    /// (caller should release claim and return status).
1485    /// Returns `None` if execution should proceed.
1486    async fn check_post_claim_guards(
1487        &self,
1488        available_task: &AvailableTask,
1489    ) -> Result<Option<WorkflowStatus>, crate::error::RuntimeError> {
1490        if self
1491            .backend
1492            .check_and_cancel(&available_task.instance_id, Some(available_task.task_id))
1493            .await?
1494        {
1495            tracing::info!(
1496                instance_id = %available_task.instance_id,
1497                task_id = %available_task.task_id,
1498                "Workflow was cancelled, releasing claim"
1499            );
1500            return Ok(Some(
1501                self.load_cancelled_status(&available_task.instance_id)
1502                    .await,
1503            ));
1504        }
1505
1506        if self
1507            .backend
1508            .check_and_pause(&available_task.instance_id)
1509            .await?
1510        {
1511            tracing::info!(
1512                instance_id = %available_task.instance_id,
1513                task_id = %available_task.task_id,
1514                "Workflow was paused, releasing claim"
1515            );
1516            return Ok(Some(
1517                self.load_paused_status(&available_task.instance_id).await,
1518            ));
1519        }
1520
1521        Ok(None)
1522    }
1523
1524    /// Execute a future while periodically extending the task claim.
1525    ///
1526    /// If a `deadline` is provided, the heartbeat tick also checks whether the
1527    /// deadline has expired. If it has, the task future is dropped (active
1528    /// cancellation) and a `TaskTimedOut` error is returned.
1529    #[tracing::instrument(
1530        name = "task",
1531        skip_all,
1532        fields(worker_id = %self.worker_id, instance_id = %claim.instance_id, task_id = %claim.task_id),
1533    )]
1534    async fn run_with_heartbeat<F, T>(
1535        &self,
1536        claim: &ActiveTaskClaim<'_, B>,
1537        deadline: Option<&TaskDeadline>,
1538        future: F,
1539    ) -> Result<T, crate::error::RuntimeError>
1540    where
1541        F: std::future::Future<Output = T>,
1542    {
1543        tracing::debug!("running task with heartbeat");
1544        let Some(ttl) = self.claim_ttl else {
1545            return Ok(future.await);
1546        };
1547        let Some(chrono_ttl) = chrono::Duration::from_std(ttl).ok() else {
1548            return Ok(future.await);
1549        };
1550
1551        let interval_duration = ttl / 2;
1552        let mut heartbeat_timer = time::interval(interval_duration);
1553        heartbeat_timer.tick().await; // skip first immediate tick
1554
1555        tokio::pin!(future);
1556
1557        loop {
1558            tokio::select! {
1559                result = &mut future => break Ok(result),
1560                _ = heartbeat_timer.tick() => {
1561                    // Check deadline during heartbeat
1562                    if let Some(dl) = deadline
1563                        && chrono::Utc::now() >= dl.deadline
1564                    {
1565                        tracing::warn!(
1566                            instance_id = %claim.instance_id,
1567                            task_id = %dl.task_id,
1568                            "Task deadline expired during heartbeat, cancelling"
1569                        );
1570                        return Err(WorkflowError::TaskTimedOut {
1571                            task_id: dl.task_id,
1572                            timeout: std::time::Duration::from_millis(dl.timeout_ms),
1573                        }
1574                        .into());
1575                    }
1576
1577                    tracing::trace!(
1578                        instance_id = %claim.instance_id,
1579                        task_id = %claim.task_id,
1580                        "Extending task claim via heartbeat"
1581                    );
1582                    if let Err(e) = self.backend
1583                        .extend_task_claim(
1584                            &claim.instance_id,
1585                            &claim.task_id,
1586                            &claim.worker_id,
1587                            chrono_ttl,
1588                        )
1589                        .await
1590                    {
1591                        tracing::warn!(
1592                            instance_id = %claim.instance_id,
1593                            task_id = %claim.task_id,
1594                            error = %e,
1595                            "Failed to extend task claim"
1596                        );
1597                    }
1598                }
1599            }
1600        }
1601    }
1602
1603    /// Persist task result and release the claim.
1604    async fn commit_task_result(
1605        &self,
1606        continuation: &WorkflowContinuation,
1607        available_task: &AvailableTask,
1608        snapshot: &mut WorkflowSnapshot,
1609        output: Bytes,
1610        claim: ActiveTaskClaim<'_, B>,
1611    ) -> Result<(), crate::error::RuntimeError> {
1612        snapshot.mark_task_completed(available_task.task_id, output);
1613        tracing::debug!(
1614            instance_id = %available_task.instance_id,
1615            task_id = %available_task.task_id,
1616            "Task completed"
1617        );
1618
1619        Self::update_position_after_task(continuation, &available_task.task_id, snapshot)?;
1620        #[cfg(feature = "otel")]
1621        {
1622            snapshot.trace_parent = crate::trace_context::current_trace_parent();
1623        }
1624        self.backend.save_snapshot(snapshot).await?;
1625
1626        // If we just entered AtSignal, drain any event that was
1627        // buffered while the snapshot was still at AtTask. The
1628        // race: send_event takes the lock during this worker's task
1629        // body (status='InProgress', position=AtTask), sees no
1630        // AtSignal to resume, INSERTs into sayiir_workflow_events,
1631        // and commits. Then this save_snapshot advances to AtSignal,
1632        // but PooledWorker has no AwaitSignal poll loop, so the
1633        // workflow would sit forever with a matching event sitting
1634        // unconsumed in the buffer. Closing the race here keeps the
1635        // signal-driven dispatch path single-source (send_event is
1636        // still primary; this is the worker's belt-and-suspenders).
1637        self.drain_pending_signal(&available_task.instance_id, snapshot)
1638            .await?;
1639
1640        claim.release().await?;
1641        Ok(())
1642    }
1643
1644    /// If `snapshot` is parked at `AtSignal { signal_name }` and a
1645    /// matching event sits buffered in `sayiir_workflow_events`,
1646    /// consume it and advance the snapshot to its next position. Loops
1647    /// in case advancement lands on another `AtSignal` whose buffered
1648    /// event is also already waiting.
1649    async fn drain_pending_signal(
1650        &self,
1651        instance_id: &Arc<str>,
1652        snapshot: &mut WorkflowSnapshot,
1653    ) -> Result<(), crate::error::RuntimeError> {
1654        loop {
1655            let (signal_id, signal_name, next_task_id) = match &snapshot.state {
1656                sayiir_core::snapshot::WorkflowSnapshotState::InProgress {
1657                    position:
1658                        sayiir_core::snapshot::ExecutionPosition::AtSignal {
1659                            signal_id,
1660                            signal_name,
1661                            next_task_id,
1662                            ..
1663                        },
1664                    ..
1665                } => (*signal_id, signal_name.clone(), *next_task_id),
1666                _ => return Ok(()),
1667            };
1668
1669            let Some(payload) = self
1670                .backend
1671                .consume_event(instance_id, &signal_name)
1672                .await?
1673            else {
1674                return Ok(());
1675            };
1676
1677            tracing::debug!(
1678                instance_id = %instance_id,
1679                %signal_name,
1680                "draining buffered signal that landed during the AtTask→AtSignal transition"
1681            );
1682            snapshot.mark_task_completed(signal_id, payload.clone());
1683            if let Some(next_id) = next_task_id {
1684                snapshot.update_position(sayiir_core::snapshot::ExecutionPosition::AtTask {
1685                    task_id: next_id,
1686                });
1687            } else {
1688                snapshot.mark_completed(payload);
1689            }
1690            self.backend.save_snapshot(snapshot).await?;
1691        }
1692    }
1693
1694    /// Determine workflow status after a task completes.
1695    ///
1696    /// Checks cancel/pause guards and workflow completion.
1697    async fn determine_post_task_status(
1698        &self,
1699        continuation: &WorkflowContinuation,
1700        available_task: &AvailableTask,
1701        snapshot: &mut WorkflowSnapshot,
1702        output: Bytes,
1703    ) -> Result<WorkflowStatus, crate::error::RuntimeError> {
1704        // Check for cancellation after task completion
1705        if self
1706            .backend
1707            .check_and_cancel(&available_task.instance_id, None)
1708            .await?
1709        {
1710            tracing::info!(
1711                instance_id = %available_task.instance_id,
1712                task_id = %available_task.task_id,
1713                "Workflow was cancelled after task completion"
1714            );
1715            return Ok(self
1716                .load_cancelled_status(&available_task.instance_id)
1717                .await);
1718        }
1719
1720        // Check for pause after task completion
1721        if self
1722            .backend
1723            .check_and_pause(&available_task.instance_id)
1724            .await?
1725        {
1726            tracing::info!(
1727                instance_id = %available_task.instance_id,
1728                task_id = %available_task.task_id,
1729                "Workflow was paused after task completion"
1730            );
1731            return Ok(self.load_paused_status(&available_task.instance_id).await);
1732        }
1733
1734        if Self::is_workflow_complete(continuation, snapshot) {
1735            tracing::info!(
1736                instance_id = %available_task.instance_id,
1737                task_id = %available_task.task_id,
1738                "Workflow complete"
1739            );
1740            snapshot.mark_completed(output);
1741            self.backend.save_snapshot(snapshot).await?;
1742            Ok(WorkflowStatus::Completed)
1743        } else {
1744            tracing::debug!(
1745                instance_id = %available_task.instance_id,
1746                task_id = %available_task.task_id,
1747                "Task completed, workflow continues"
1748            );
1749            Ok(WorkflowStatus::InProgress)
1750        }
1751    }
1752
1753    /// If `task_id` is the entry task of a `Fork`'s join continuation,
1754    /// return the slice of branches whose outputs feed the join. The
1755    /// `PooledWorker` dispatches each task with `available_task.input`
1756    /// set to the previous task's output — for a join, that's just one
1757    /// branch's output, not the `NamedBranchResults` the join body
1758    /// expects. Callers use this to rebuild the join input from
1759    /// `snapshot.completed_tasks` before invoking the join task.
1760    fn find_fork_branches_for_join<'a>(
1761        continuation: &'a WorkflowContinuation,
1762        task_id: &sayiir_core::TaskId,
1763    ) -> Option<&'a [Arc<WorkflowContinuation>]> {
1764        match continuation {
1765            WorkflowContinuation::Task { next, .. }
1766            | WorkflowContinuation::Delay { next, .. }
1767            | WorkflowContinuation::AwaitSignal { next, .. } => next
1768                .as_deref()
1769                .and_then(|n| Self::find_fork_branches_for_join(n, task_id)),
1770            WorkflowContinuation::Fork { branches, join, .. } => {
1771                if let Some(join_cont) = join {
1772                    let join_first = sayiir_core::TaskId::from(join_cont.first_task_id());
1773                    if join_first == *task_id {
1774                        return Some(&branches[..]);
1775                    }
1776                    if let Some(b) = Self::find_fork_branches_for_join(join_cont, task_id) {
1777                        return Some(b);
1778                    }
1779                }
1780                for branch in branches {
1781                    if let Some(b) = Self::find_fork_branches_for_join(branch, task_id) {
1782                        return Some(b);
1783                    }
1784                }
1785                None
1786            }
1787            WorkflowContinuation::Branch {
1788                branches,
1789                default,
1790                next,
1791                ..
1792            } => {
1793                for branch_cont in branches.values() {
1794                    if let Some(b) = Self::find_fork_branches_for_join(branch_cont, task_id) {
1795                        return Some(b);
1796                    }
1797                }
1798                if let Some(def) = default
1799                    && let Some(b) = Self::find_fork_branches_for_join(def, task_id)
1800                {
1801                    return Some(b);
1802                }
1803                next.as_deref()
1804                    .and_then(|n| Self::find_fork_branches_for_join(n, task_id))
1805            }
1806            WorkflowContinuation::Loop { body, next, .. } => {
1807                Self::find_fork_branches_for_join(body, task_id).or_else(|| {
1808                    next.as_deref()
1809                        .and_then(|n| Self::find_fork_branches_for_join(n, task_id))
1810                })
1811            }
1812            WorkflowContinuation::ChildWorkflow { child, next, .. } => {
1813                Self::find_fork_branches_for_join(child, task_id).or_else(|| {
1814                    next.as_deref()
1815                        .and_then(|n| Self::find_fork_branches_for_join(n, task_id))
1816                })
1817            }
1818        }
1819    }
1820
1821    /// Find a task function in the workflow continuation and return a reference.
1822    ///
1823    /// Tree-walk predicate: does `task_id` appear anywhere inside `continuation`?
1824    ///
1825    /// Only used by [`execute_task_by_id`] to pick which subtree to descend into
1826    /// at a `Fork`/`Branch`/`Loop`/`ChildWorkflow` boundary. Validation on the
1827    /// dispatch hot path uses [`sayiir_core::TaskIndex::contains`] instead.
1828    fn find_task_id_in_continuation(
1829        continuation: &WorkflowContinuation,
1830        task_id: &sayiir_core::TaskId,
1831    ) -> bool {
1832        match continuation {
1833            WorkflowContinuation::Task { id, next, .. }
1834            | WorkflowContinuation::Delay { id, next, .. }
1835            | WorkflowContinuation::AwaitSignal { id, next, .. } => {
1836                if sayiir_core::TaskId::from(id.as_str()) == *task_id {
1837                    return true;
1838                }
1839                next.as_ref()
1840                    .is_some_and(|n| Self::find_task_id_in_continuation(n, task_id))
1841            }
1842            WorkflowContinuation::Fork { branches, join, .. } => {
1843                for branch in branches {
1844                    if Self::find_task_id_in_continuation(branch, task_id) {
1845                        return true;
1846                    }
1847                }
1848                if let Some(join_cont) = join {
1849                    Self::find_task_id_in_continuation(join_cont, task_id)
1850                } else {
1851                    false
1852                }
1853            }
1854            WorkflowContinuation::Branch {
1855                branches,
1856                default,
1857                next,
1858                ..
1859            } => {
1860                for branch_cont in branches.values() {
1861                    if Self::find_task_id_in_continuation(branch_cont, task_id) {
1862                        return true;
1863                    }
1864                }
1865                if let Some(def) = default
1866                    && Self::find_task_id_in_continuation(def, task_id)
1867                {
1868                    return true;
1869                }
1870                next.as_ref()
1871                    .is_some_and(|n| Self::find_task_id_in_continuation(n, task_id))
1872            }
1873            WorkflowContinuation::Loop { body, next, .. } => {
1874                if Self::find_task_id_in_continuation(body, task_id) {
1875                    return true;
1876                }
1877                next.as_ref()
1878                    .is_some_and(|n| Self::find_task_id_in_continuation(n, task_id))
1879            }
1880            WorkflowContinuation::ChildWorkflow { child, next, .. } => {
1881                if Self::find_task_id_in_continuation(child, task_id) {
1882                    return true;
1883                }
1884                next.as_ref()
1885                    .is_some_and(|n| Self::find_task_id_in_continuation(n, task_id))
1886            }
1887        }
1888    }
1889
1890    /// Execute a task by ID from the workflow continuation (iterative, no boxing).
1891    #[allow(clippy::manual_async_fn)]
1892    fn execute_task_by_id<'a>(
1893        continuation: &'a WorkflowContinuation,
1894        task_id: &'a str,
1895        input: Bytes,
1896    ) -> impl std::future::Future<Output = Result<Bytes, crate::error::RuntimeError>> + Send + 'a
1897    {
1898        async move {
1899            let task_id_hash = sayiir_core::TaskId::from(task_id);
1900            let task_id = &task_id_hash;
1901            let mut current = continuation;
1902
1903            loop {
1904                match current {
1905                    WorkflowContinuation::Task { id, func, next, .. } => {
1906                        if sayiir_core::TaskId::from(id.as_str()) == *task_id {
1907                            let func = func
1908                                .as_ref()
1909                                .ok_or_else(|| WorkflowError::TaskNotImplemented(id.clone()))?;
1910                            return Ok(func.run(input).await?);
1911                        } else if let Some(next_cont) = next {
1912                            current = next_cont;
1913                        } else {
1914                            return Err(WorkflowError::TaskNotFound(task_id.to_string()).into());
1915                        }
1916                    }
1917                    WorkflowContinuation::Delay { next, .. }
1918                    | WorkflowContinuation::AwaitSignal { next, .. } => {
1919                        // Skip over delay/signal nodes when searching for a task
1920                        if let Some(next_cont) = next {
1921                            current = next_cont;
1922                        } else {
1923                            return Err(WorkflowError::TaskNotFound(task_id.to_string()).into());
1924                        }
1925                    }
1926                    WorkflowContinuation::Fork { branches, join, .. } => {
1927                        // Check branches
1928                        let mut found_in_branch = false;
1929                        for branch in branches {
1930                            if Self::find_task_id_in_continuation(branch, task_id) {
1931                                current = branch;
1932                                found_in_branch = true;
1933                                break;
1934                            }
1935                        }
1936                        if found_in_branch {
1937                            continue;
1938                        }
1939                        // Check join
1940                        if let Some(join_cont) = join {
1941                            current = join_cont;
1942                        } else {
1943                            return Err(WorkflowError::TaskNotFound(task_id.to_string()).into());
1944                        }
1945                    }
1946                    WorkflowContinuation::Branch {
1947                        branches,
1948                        default,
1949                        next,
1950                        ..
1951                    } => {
1952                        // Search branch sub-continuations for the task
1953                        let mut found = false;
1954                        for branch_cont in branches.values() {
1955                            if Self::find_task_id_in_continuation(branch_cont, task_id) {
1956                                current = branch_cont;
1957                                found = true;
1958                                break;
1959                            }
1960                        }
1961                        if found {
1962                            continue;
1963                        }
1964                        if let Some(def) = default
1965                            && Self::find_task_id_in_continuation(def, task_id)
1966                        {
1967                            current = def;
1968                            continue;
1969                        }
1970                        if let Some(next_cont) = next {
1971                            current = next_cont;
1972                        } else {
1973                            return Err(WorkflowError::TaskNotFound(task_id.to_string()).into());
1974                        }
1975                    }
1976                    WorkflowContinuation::Loop { body, next, .. } => {
1977                        if Self::find_task_id_in_continuation(body, task_id) {
1978                            current = body;
1979                            continue;
1980                        }
1981                        if let Some(next_cont) = next {
1982                            current = next_cont;
1983                        } else {
1984                            return Err(WorkflowError::TaskNotFound(task_id.to_string()).into());
1985                        }
1986                    }
1987                    WorkflowContinuation::ChildWorkflow { child, next, .. } => {
1988                        if Self::find_task_id_in_continuation(child, task_id) {
1989                            current = child;
1990                            continue;
1991                        }
1992                        if let Some(next_cont) = next {
1993                            current = next_cont;
1994                        } else {
1995                            return Err(WorkflowError::TaskNotFound(task_id.to_string()).into());
1996                        }
1997                    }
1998                }
1999            }
2000        }
2001    }
2002
2003    /// Set the snapshot's execution position to reflect "execution is now at
2004    /// the head of `cont`".
2005    ///
2006    /// For Task heads (and Branch / Fork / Loop / `ChildWorkflow` which all
2007    /// start with a Task-shaped node), set `AtTask(first_task_hint)`. For
2008    /// `Delay` / `AwaitSignal` heads, enter the corresponding park state
2009    /// directly — using `first_task_hint(...).id` would name the Delay or
2010    /// Signal node itself, but `execute_task_by_id` skips past those when
2011    /// dispatching, so the worker would loop on a phantom task ID.
2012    fn set_position_at(
2013        cont: &WorkflowContinuation,
2014        snapshot: &mut WorkflowSnapshot,
2015    ) -> Result<(), crate::error::RuntimeError> {
2016        use crate::execution::control_flow::{compute_signal_timeout, compute_wake_at};
2017        match cont {
2018            WorkflowContinuation::Delay { id, duration, next } => {
2019                let wake_at = compute_wake_at(duration)?;
2020                let entered_at = chrono::Utc::now();
2021                let next_hint = next.as_deref().map(WorkflowContinuation::first_task_hint);
2022                let next_task_id = next_hint.as_ref().map(|h| h.id);
2023                // Update task_priority/task_tags so backends advancing
2024                // AtDelay -> AtTask inherit the next task's routing hints
2025                // (mirrors save_park_checkpoint).
2026                snapshot.set_task_hint(next_hint.as_ref().unwrap_or(&TaskHint::default()));
2027                let delay_id = sayiir_core::TaskId::from(id.as_str());
2028                snapshot.update_position(ExecutionPosition::AtDelay {
2029                    delay_id,
2030                    entered_at,
2031                    wake_at,
2032                    next_task_id,
2033                });
2034                // Mirror the executor's behaviour when it parks at a delay:
2035                // mark the delay node as completed with the passthrough input
2036                // (which is the *last* completed task's output, sitting at the
2037                // top of `completed_tasks`) so downstream nodes see the same
2038                // continuous output chain.
2039                let passthrough = snapshot.get_last_task_output().unwrap_or_default();
2040                snapshot.mark_task_completed(delay_id, passthrough);
2041            }
2042            WorkflowContinuation::AwaitSignal {
2043                id,
2044                signal_name,
2045                timeout,
2046                next,
2047            } => {
2048                let wake_at = compute_signal_timeout(timeout.as_ref());
2049                let next_hint = next.as_deref().map(WorkflowContinuation::first_task_hint);
2050                let next_task_id = next_hint.as_ref().map(|h| h.id);
2051                // Update task_priority/task_tags so backends advancing
2052                // AtSignal -> AtTask inherit the next task's routing hints
2053                // (mirrors save_park_checkpoint).
2054                snapshot.set_task_hint(next_hint.as_ref().unwrap_or(&TaskHint::default()));
2055                snapshot.update_position(ExecutionPosition::AtSignal {
2056                    signal_id: sayiir_core::TaskId::from(id.as_str()),
2057                    signal_name: signal_name.clone(),
2058                    wake_at,
2059                    next_task_id,
2060                });
2061            }
2062            _ => {
2063                let hint = cont.first_task_hint();
2064                snapshot.update_position(ExecutionPosition::AtTask { task_id: hint.id });
2065                snapshot.set_task_hint(&hint);
2066            }
2067        }
2068        Ok(())
2069    }
2070
2071    /// Update execution position after a task completes.
2072    fn update_position_after_task(
2073        continuation: &WorkflowContinuation,
2074        completed_task_id: &sayiir_core::TaskId,
2075        snapshot: &mut WorkflowSnapshot,
2076    ) -> Result<(), crate::error::RuntimeError> {
2077        match continuation {
2078            WorkflowContinuation::Task { id, next, .. }
2079            | WorkflowContinuation::Delay { id, next, .. }
2080            | WorkflowContinuation::AwaitSignal { id, next, .. } => {
2081                if sayiir_core::TaskId::from(id.as_str()) == *completed_task_id {
2082                    if let Some(next_cont) = next.as_deref() {
2083                        Self::set_position_at(next_cont, snapshot)?;
2084                    }
2085                } else if let Some(next_cont) = next {
2086                    Self::update_position_after_task(next_cont, completed_task_id, snapshot)?;
2087                }
2088            }
2089            WorkflowContinuation::Fork { branches, join, .. } => {
2090                // First let recursion advance position WITHIN a branch (for
2091                // multi-task branch chains) or within the join continuation.
2092                for branch in branches {
2093                    Self::update_position_after_task(branch, completed_task_id, snapshot)?;
2094                }
2095                if let Some(join_cont) = join {
2096                    Self::update_position_after_task(join_cont, completed_task_id, snapshot)?;
2097                }
2098
2099                // If recursion didn't move the position off the just-completed
2100                // task, we finished a branch's terminal node — the inner Task
2101                // arm has no `next` to advance to. Drive the fork forward at
2102                // this level: pick the next branch that hasn't started, or
2103                // step into the join when every branch is done. Without this
2104                // step, single-Task branches (the common fan-out shape) leave
2105                // the snapshot pinned to `AtTask(branch_n)` forever and the
2106                // next dispatch just re-discovers the same already-completed
2107                // task — workflow stalls indefinitely.
2108                let still_at_completed = snapshot
2109                    .current_task_id()
2110                    .is_some_and(|c| c == *completed_task_id);
2111                if !still_at_completed {
2112                    return Ok(());
2113                }
2114
2115                // Sequential branch execution: the first branch whose entry
2116                // task has no result yet is where we resume. Matches
2117                // `collect_cached_branches`' lookup so the two views of "branch
2118                // is done" stay in sync.
2119                for branch in branches {
2120                    let first_tid = sayiir_core::TaskId::from(branch.first_task_id());
2121                    if snapshot.get_task_result(&first_tid).is_none() {
2122                        Self::set_position_at(branch, snapshot)?;
2123                        return Ok(());
2124                    }
2125                }
2126
2127                // Every branch finished — advance to the join. If the fork has
2128                // no join, leave the position at the last completed task and
2129                // let `is_workflow_complete` mark the workflow done.
2130                if let Some(join_cont) = join {
2131                    Self::set_position_at(join_cont, snapshot)?;
2132                }
2133            }
2134            WorkflowContinuation::Branch {
2135                branches,
2136                default,
2137                next,
2138                ..
2139            } => {
2140                for branch_cont in branches.values() {
2141                    Self::update_position_after_task(branch_cont, completed_task_id, snapshot)?;
2142                }
2143                if let Some(def) = default {
2144                    Self::update_position_after_task(def, completed_task_id, snapshot)?;
2145                }
2146                if let Some(next_cont) = next {
2147                    Self::update_position_after_task(next_cont, completed_task_id, snapshot)?;
2148                }
2149            }
2150            WorkflowContinuation::Loop { body, next, .. } => {
2151                Self::update_position_after_task(body, completed_task_id, snapshot)?;
2152                if let Some(next_cont) = next {
2153                    Self::update_position_after_task(next_cont, completed_task_id, snapshot)?;
2154                }
2155            }
2156            WorkflowContinuation::ChildWorkflow { child, next, .. } => {
2157                Self::update_position_after_task(child, completed_task_id, snapshot)?;
2158                if let Some(next_cont) = next {
2159                    Self::update_position_after_task(next_cont, completed_task_id, snapshot)?;
2160                }
2161            }
2162        }
2163        Ok(())
2164    }
2165
2166    /// Create a builder with sensible defaults.
2167    ///
2168    /// By default, the worker ID is derived from `{hostname}-{pid}`.
2169    /// Override with [`PooledWorkerBuilder::worker_id`].
2170    ///
2171    /// # Example
2172    ///
2173    /// ```rust,no_run
2174    /// # use sayiir_runtime::prelude::*;
2175    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
2176    /// // Auto-generated worker ID from hostname + PID
2177    /// let worker = PooledWorker::builder(InMemoryBackend::new(), TaskRegistry::new()).build();
2178    ///
2179    /// // Or override with explicit ID
2180    /// let worker = PooledWorker::builder(InMemoryBackend::new(), TaskRegistry::new())
2181    ///     .worker_id("custom-worker-1")
2182    ///     .build();
2183    /// # Ok(())
2184    /// # }
2185    /// ```
2186    pub fn builder(backend: B, registry: TaskRegistry) -> PooledWorkerBuilder<B> {
2187        PooledWorkerBuilder {
2188            worker_id: None,
2189            backend,
2190            registry,
2191            claim_ttl: Some(Duration::from_mins(5)),
2192            batch_size: NonZeroUsize::MIN,
2193            aging_interval: Duration::from_mins(5),
2194            tags: vec![],
2195        }
2196    }
2197
2198    /// Walk the continuation tree and resolve any Loop nodes whose body has
2199    /// completed. When the terminal body task's output decodes to
2200    /// `LoopDecision::Done`, the loop node is marked as completed in the
2201    /// snapshot. For `Again`, body task results are cleared and the iteration
2202    /// counter is advanced so the body becomes available for re-execution.
2203    async fn resolve_loop_completions(
2204        continuation: &WorkflowContinuation,
2205        snapshot: &mut WorkflowSnapshot,
2206        backend: &B,
2207    ) -> Result<(), crate::error::RuntimeError> {
2208        Self::resolve_loops_recursive(continuation, snapshot, backend).await
2209    }
2210
2211    #[allow(clippy::too_many_lines)]
2212    fn resolve_loops_recursive<'a>(
2213        continuation: &'a WorkflowContinuation,
2214        snapshot: &'a mut WorkflowSnapshot,
2215        backend: &'a B,
2216    ) -> Pin<
2217        Box<dyn std::future::Future<Output = Result<(), crate::error::RuntimeError>> + Send + 'a>,
2218    > {
2219        Box::pin(async move {
2220            match continuation {
2221                WorkflowContinuation::Loop {
2222                    id,
2223                    body,
2224                    max_iterations,
2225                    on_max,
2226                    next,
2227                } => {
2228                    // Only resolve if the loop isn't already marked complete.
2229                    if snapshot
2230                        .get_task_result(&sayiir_core::TaskId::from(id))
2231                        .is_none()
2232                    {
2233                        let terminal_id = body.terminal_task_id();
2234                        if let Some(result) =
2235                            snapshot.get_task_result(&sayiir_core::TaskId::from(terminal_id))
2236                        {
2237                            let output = result.output.clone();
2238                            match crate::execution::decode_loop_envelope(&output) {
2239                                Ok((LoopDecision::Done, inner)) => {
2240                                    snapshot.clear_loop_iteration(&sayiir_core::TaskId::from(id));
2241                                    snapshot
2242                                        .mark_task_completed(sayiir_core::TaskId::from(id), inner);
2243                                    backend.save_snapshot(snapshot).await?;
2244                                }
2245                                Ok((LoopDecision::Again, again_value)) => {
2246                                    let current_iter =
2247                                        snapshot.loop_iteration(&sayiir_core::TaskId::from(id));
2248                                    let next_iter = current_iter + 1;
2249                                    if next_iter >= *max_iterations {
2250                                        match on_max {
2251                                            sayiir_core::workflow::MaxIterationsPolicy::Fail => {
2252                                                return Err(WorkflowError::MaxIterationsExceeded {
2253                                                    loop_id: sayiir_core::TaskId::from(id),
2254                                                    max_iterations: *max_iterations,
2255                                                }
2256                                                .into());
2257                                            }
2258                                            sayiir_core::workflow::MaxIterationsPolicy::ExitWithLast => {
2259                                                snapshot.clear_loop_iteration(&sayiir_core::TaskId::from(id));
2260                                                snapshot.mark_task_completed(
2261                                                    sayiir_core::TaskId::from(id.as_str()),
2262                                                    again_value,
2263                                                );
2264                                                backend.save_snapshot(snapshot).await?;
2265                                            }
2266                                        }
2267                                    } else {
2268                                        // Clear body task results so the body becomes
2269                                        // available for re-execution on the next poll.
2270                                        let body_ser = body.to_serializable();
2271                                        for tid in &body_ser.task_ids() {
2272                                            snapshot.remove_task_result(
2273                                                &sayiir_core::TaskId::from(*tid),
2274                                            );
2275                                        }
2276                                        snapshot.set_loop_iteration(
2277                                            sayiir_core::TaskId::from(id),
2278                                            next_iter,
2279                                        );
2280                                        backend.save_snapshot(snapshot).await?;
2281                                    }
2282                                }
2283                                Err(e) => {
2284                                    return Err(CodecError::DecodeFailed {
2285                                        task_id: sayiir_core::TaskId::from(id),
2286                                        expected_type: "LoopEnvelope",
2287                                        source: e,
2288                                    }
2289                                    .into());
2290                                }
2291                            }
2292                        }
2293                    }
2294                    // Recurse into body and next.
2295                    Self::resolve_loops_recursive(body, snapshot, backend).await?;
2296                    if let Some(next) = next {
2297                        Self::resolve_loops_recursive(next, snapshot, backend).await?;
2298                    }
2299                }
2300                WorkflowContinuation::Task { next, .. }
2301                | WorkflowContinuation::Delay { next, .. }
2302                | WorkflowContinuation::AwaitSignal { next, .. }
2303                | WorkflowContinuation::Branch { next, .. } => {
2304                    if let Some(next) = next {
2305                        Self::resolve_loops_recursive(next, snapshot, backend).await?;
2306                    }
2307                }
2308                WorkflowContinuation::Fork { branches, join, .. } => {
2309                    for branch in branches {
2310                        Self::resolve_loops_recursive(branch, snapshot, backend).await?;
2311                    }
2312                    if let Some(join) = join {
2313                        Self::resolve_loops_recursive(join, snapshot, backend).await?;
2314                    }
2315                }
2316                WorkflowContinuation::ChildWorkflow { child, next, .. } => {
2317                    Self::resolve_loops_recursive(child, snapshot, backend).await?;
2318                    if let Some(next) = next {
2319                        Self::resolve_loops_recursive(next, snapshot, backend).await?;
2320                    }
2321                }
2322            }
2323            Ok(())
2324        })
2325    }
2326
2327    /// Check if the workflow is complete based on the snapshot.
2328    fn is_workflow_complete(
2329        continuation: &WorkflowContinuation,
2330        snapshot: &WorkflowSnapshot,
2331    ) -> bool {
2332        // Check if all tasks in the continuation are completed
2333        match continuation {
2334            WorkflowContinuation::Task { id, next, .. } => {
2335                if snapshot
2336                    .get_task_result(&sayiir_core::TaskId::from(id))
2337                    .is_none()
2338                {
2339                    return false;
2340                }
2341                if let Some(next_cont) = next {
2342                    Self::is_workflow_complete(next_cont, snapshot)
2343                } else {
2344                    true // Last task completed
2345                }
2346            }
2347            WorkflowContinuation::Delay { id, next, .. }
2348            | WorkflowContinuation::AwaitSignal { id, next, .. } => {
2349                if snapshot
2350                    .get_task_result(&sayiir_core::TaskId::from(id))
2351                    .is_none()
2352                {
2353                    return false;
2354                }
2355                next.as_ref()
2356                    .is_none_or(|n| Self::is_workflow_complete(n, snapshot))
2357            }
2358            WorkflowContinuation::Fork { branches, join, .. } => {
2359                // All branches must be completed (recursively check entire branch chain)
2360                for branch in branches {
2361                    if !Self::is_workflow_complete(branch, snapshot) {
2362                        return false;
2363                    }
2364                }
2365                // Join must be completed if it exists
2366                if let Some(join_cont) = join {
2367                    Self::is_workflow_complete(join_cont, snapshot)
2368                } else {
2369                    true
2370                }
2371            }
2372            WorkflowContinuation::Branch { id, next, .. } => {
2373                // Branch is complete when the branch node itself has a cached result
2374                if snapshot
2375                    .get_task_result(&sayiir_core::TaskId::from(id))
2376                    .is_none()
2377                {
2378                    return false;
2379                }
2380                next.as_ref()
2381                    .is_none_or(|n| Self::is_workflow_complete(n, snapshot))
2382            }
2383            WorkflowContinuation::Loop { id, next, .. } => {
2384                // Loop is complete when the loop node itself has a cached result
2385                if snapshot
2386                    .get_task_result(&sayiir_core::TaskId::from(id))
2387                    .is_none()
2388                {
2389                    return false;
2390                }
2391                next.as_ref()
2392                    .is_none_or(|n| Self::is_workflow_complete(n, snapshot))
2393            }
2394            WorkflowContinuation::ChildWorkflow { id, next, .. } => {
2395                // ChildWorkflow is complete when the node itself has a cached result
2396                if snapshot
2397                    .get_task_result(&sayiir_core::TaskId::from(id))
2398                    .is_none()
2399                {
2400                    return false;
2401                }
2402                next.as_ref()
2403                    .is_none_or(|n| Self::is_workflow_complete(n, snapshot))
2404            }
2405        }
2406    }
2407}
2408
2409/// Generate a default worker ID from `{hostname}-{pid}`.
2410fn default_worker_id() -> String {
2411    let host = hostname::get()
2412        .ok()
2413        .and_then(|h| h.into_string().ok())
2414        .unwrap_or_else(|| "unknown".to_string());
2415    format!("{host}-{}", std::process::id())
2416}
2417
2418/// Builder for [`PooledWorker`] with sensible defaults.
2419///
2420/// By default, derives the worker ID from `{hostname}-{pid}`.
2421/// Override with [`worker_id`](Self::worker_id).
2422///
2423/// Created via [`PooledWorker::builder`].
2424pub struct PooledWorkerBuilder<B> {
2425    worker_id: Option<String>,
2426    backend: B,
2427    registry: TaskRegistry,
2428    claim_ttl: Option<Duration>,
2429    batch_size: NonZeroUsize,
2430    aging_interval: Duration,
2431    tags: Vec<String>,
2432}
2433
2434impl<B> PooledWorkerBuilder<B>
2435where
2436    B: PersistentBackend + TaskClaimStore + 'static,
2437{
2438    /// Set an explicit worker ID.
2439    ///
2440    /// If not called, the ID is auto-generated from `{hostname}-{pid}`.
2441    #[must_use]
2442    pub fn worker_id(mut self, id: impl Into<String>) -> Self {
2443        self.worker_id = Some(id.into());
2444        self
2445    }
2446
2447    /// Set the TTL for task claims (default: 5 minutes).
2448    #[must_use]
2449    pub fn claim_ttl(mut self, ttl: Option<Duration>) -> Self {
2450        self.claim_ttl = ttl;
2451        self
2452    }
2453
2454    /// Set the number of tasks to fetch per poll (default: 1).
2455    #[must_use]
2456    pub fn batch_size(mut self, size: NonZeroUsize) -> Self {
2457        self.batch_size = size;
2458        self
2459    }
2460
2461    /// Set the aging interval for priority-based scheduling (default: 300s).
2462    ///
2463    /// # Panics
2464    ///
2465    /// Panics if `interval` is zero.
2466    #[must_use]
2467    pub fn aging_interval(mut self, interval: Duration) -> Self {
2468        assert!(!interval.is_zero(), "aging interval must be non-zero");
2469        self.aging_interval = interval;
2470        self
2471    }
2472
2473    /// Set affinity tags for this worker.
2474    ///
2475    /// When tags are set, the worker only picks up tasks whose tags are a
2476    /// subset of the worker's tags (or tasks with no tags). When no tags are
2477    /// set (the default), the worker accepts all tasks.
2478    #[must_use]
2479    pub fn tags(mut self, tags: Vec<String>) -> Self {
2480        self.tags = tags;
2481        self
2482    }
2483
2484    /// Build the [`PooledWorker`].
2485    ///
2486    /// If no `worker_id` was set, generates one from `{hostname}-{pid}`.
2487    #[must_use]
2488    pub fn build(self) -> PooledWorker<B> {
2489        let worker_id = self.worker_id.unwrap_or_else(default_worker_id);
2490        PooledWorker {
2491            worker_id,
2492            backend: Arc::new(self.backend),
2493            registry: Arc::new(self.registry),
2494            claim_ttl: self.claim_ttl,
2495            batch_size: self.batch_size,
2496            aging_interval: self.aging_interval,
2497            tags: self.tags,
2498        }
2499    }
2500}
2501
2502#[cfg(test)]
2503#[allow(clippy::unwrap_used)]
2504mod tests {
2505    use super::*;
2506    use crate::serialization::JsonCodec;
2507    use sayiir_core::registry::TaskRegistry;
2508    use sayiir_core::snapshot::WorkflowSnapshot;
2509    use sayiir_persistence::{InMemoryBackend, SignalStore, SnapshotStore};
2510
2511    type EmptyWorkflows = WorkflowRegistry<JsonCodec, (), ()>;
2512
2513    fn make_worker() -> PooledWorker<InMemoryBackend> {
2514        let backend = InMemoryBackend::new();
2515        let registry = TaskRegistry::new();
2516        PooledWorker::new("test-worker", backend, registry)
2517    }
2518
2519    #[tokio::test]
2520    async fn test_spawn_and_shutdown() {
2521        let worker = make_worker();
2522        let handle = worker.spawn(Duration::from_millis(50), EmptyWorkflows::new());
2523
2524        handle.shutdown();
2525
2526        let result = tokio::time::timeout(Duration::from_secs(5), handle.join()).await;
2527        assert!(result.is_ok(), "Worker should exit cleanly after shutdown");
2528        assert!(result.unwrap().is_ok());
2529    }
2530
2531    #[tokio::test]
2532    async fn test_handle_is_clone_and_send() {
2533        let worker = make_worker();
2534        let handle = worker.spawn(Duration::from_millis(50), EmptyWorkflows::new());
2535
2536        let handle2 = handle.clone();
2537        let remote = tokio::spawn(async move {
2538            handle2.shutdown();
2539        });
2540        remote.await.ok();
2541
2542        let result = tokio::time::timeout(Duration::from_secs(5), handle.join()).await;
2543        assert!(result.is_ok_and(|r| r.is_ok()));
2544    }
2545
2546    #[tokio::test]
2547    async fn test_cancel_via_client() {
2548        let backend = InMemoryBackend::new();
2549        let registry = TaskRegistry::new();
2550
2551        // Create a workflow snapshot so store_signal can validate it
2552        let mut snapshot = WorkflowSnapshot::new("wf-1", "hash-1".into());
2553        backend.save_snapshot(&mut snapshot).await.ok();
2554
2555        let worker = PooledWorker::new("test-worker", backend, registry);
2556        let handle = worker.spawn(Duration::from_millis(50), EmptyWorkflows::new());
2557
2558        // Cancel via WorkflowClient instead of handle
2559        let client = crate::WorkflowClient::from_shared(std::sync::Arc::clone(handle.backend()));
2560        client
2561            .cancel(
2562                "wf-1",
2563                Some("test reason".to_string()),
2564                Some("tester".to_string()),
2565            )
2566            .await
2567            .ok();
2568
2569        // Verify the signal was stored
2570        let signal = handle
2571            .backend()
2572            .get_signal("wf-1", SignalKind::Cancel)
2573            .await;
2574        assert!(signal.is_ok_and(|s| s.is_some()));
2575
2576        handle.shutdown();
2577        tokio::time::timeout(Duration::from_secs(5), handle.join())
2578            .await
2579            .ok();
2580    }
2581
2582    #[test]
2583    fn test_builder_auto_generates_worker_id() {
2584        let backend = InMemoryBackend::new();
2585        let registry = TaskRegistry::new();
2586        let worker = PooledWorker::builder(backend, registry).build();
2587
2588        // Should contain PID
2589        let pid = std::process::id().to_string();
2590        assert!(
2591            worker.worker_id.contains(&pid),
2592            "Auto-generated ID '{}' should contain PID '{}'",
2593            worker.worker_id,
2594            pid
2595        );
2596    }
2597
2598    #[test]
2599    fn test_builder_explicit_worker_id() {
2600        let backend = InMemoryBackend::new();
2601        let registry = TaskRegistry::new();
2602        let worker = PooledWorker::builder(backend, registry)
2603            .worker_id("my-worker")
2604            .build();
2605
2606        assert_eq!(worker.worker_id, "my-worker");
2607    }
2608
2609    #[test]
2610    fn test_builder_custom_settings() {
2611        let backend = InMemoryBackend::new();
2612        let registry = TaskRegistry::new();
2613        let worker = PooledWorker::builder(backend, registry)
2614            .worker_id("w1")
2615            .claim_ttl(Some(Duration::from_mins(2)))
2616            .batch_size(NonZeroUsize::new(8).unwrap())
2617            .build();
2618
2619        assert_eq!(worker.worker_id, "w1");
2620        assert_eq!(worker.claim_ttl, Some(Duration::from_mins(2)));
2621        assert_eq!(worker.batch_size.get(), 8);
2622    }
2623
2624    #[tokio::test]
2625    async fn test_dropped_handle_shuts_down_worker() {
2626        let worker = make_worker();
2627        let handle = worker.spawn(Duration::from_millis(50), EmptyWorkflows::new());
2628
2629        // Extract the join handle before dropping so we can still await completion
2630        let join_handle = handle.inner.join_handle.lock().await.take().unwrap();
2631        drop(handle);
2632
2633        let result = tokio::time::timeout(Duration::from_secs(5), join_handle)
2634            .await
2635            .ok()
2636            .and_then(Result::ok);
2637        assert!(
2638            result.is_some(),
2639            "Worker should exit when all handles are dropped"
2640        );
2641        assert!(result.is_some_and(|r| r.is_ok()));
2642    }
2643}