Skip to main content

mlua_swarm/worker/
adapter.rs

1//! The second stage of the two-stage pipeline: `SpawnerAdapter`.
2//!
3//! From the engine's viewpoint there is only one trait,
4//! `SpawnerAdapter`; its `spawn` returns `Box<dyn Worker>` (see
5//! `crate::worker::Worker`). Worker shape is an implementation detail of
6//! each spawner; the engine only touches Workers through three
7//! operations — `id()` / `cancel_token()` / `join()`.
8//!
9//! The old `WorkerAdapter` trait and `InProcWorker` struct — which
10//! assumed a three-stage `Spawner.spawn → WorkerAdapter → invoke`
11//! pipeline — were removed on this turn. Nothing instantiated or
12//! dispatched them (dead code), and the multi-invocation path from
13//! was collapsed in the implementation anyway.
14//! The interface is now consolidated into the new `trait Worker` in
15//! `src/worker.rs`.
16
17use crate::core::ctx::Ctx;
18use crate::core::engine::Engine;
19use crate::types::{CapToken, StepId};
20use crate::worker::Worker;
21use async_trait::async_trait;
22use serde_json::Value;
23use std::collections::HashMap;
24use std::future::Future;
25use std::pin::Pin;
26use std::sync::Arc;
27use thiserror::Error;
28
29/// Errors that can occur while `SpawnerAdapter::spawn` is setting up a
30/// worker, before the worker itself starts running.
31#[derive(Debug, Error)]
32pub enum SpawnError {
33    /// No `WorkerFn` is registered for the requested agent name.
34    #[error("worker not registered: {0}")]
35    NotRegistered(String),
36    /// A middleware layer vetoed the spawn (e.g. capability check, rate
37    /// limit, policy gate).
38    #[error("spawn rejected by middleware: {0}")]
39    RejectedByMiddleware(String),
40    /// Any other setup failure (e.g. `fetch_prompt` failed).
41    #[error("internal: {0}")]
42    Internal(String),
43}
44
45/// Errors surfaced once a worker is running, via `Worker::join`.
46#[derive(Debug, Error)]
47pub enum WorkerError {
48    /// The worker fn itself returned an error.
49    #[error("worker fn returned error: {0}")]
50    Failed(String),
51    /// The worker was cancelled through its `CancellationToken`.
52    #[error("cancelled")]
53    Cancelled,
54}
55
56/// The value a `WorkerFn` hands back on success, folded into an
57/// `OutputEvent::Final` by the spawner.
58#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
59pub struct WorkerResult {
60    /// The worker fn's output payload.
61    pub value: Value,
62    /// Whether the agent itself considers this a successful result
63    /// (distinct from `Result::Err` — a worker fn can return `Ok(..)`
64    /// with `ok: false` to signal an agent-level failure).
65    pub ok: bool,
66    /// Optional normalized per-attempt stats sidecar (token usage /
67    /// model / num_turns / adapter-specific raw data), produced by the
68    /// worker boundary that knows them (agent-block result captor,
69    /// subprocess stdout normalization, …). The spawner's fold site
70    /// forwards it to `Engine::record_worker_stats`; it never rides
71    /// into `OutputEvent::Final` (the BP-chain value stays stats-free).
72    /// `None` = no stats reported — every pre-stats worker fn is
73    /// unaffected (`#[serde(default)]` keeps wire compat).
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub stats: Option<crate::store::trace::WorkerStats>,
76}
77
78impl WorkerResult {
79    /// Ensure `stats.worker_kind` is set — the design invariant that
80    /// every dispatched step's terminal `StepEntry` carries at least
81    /// its worker kind label ("rust_fn" / "lua" / "agent_block" /
82    /// "subprocess" / "operator" / …), even when no LLM-shaped stats
83    /// (usage / model / num_turns) apply. Idempotent: if a boundary
84    /// already reported a `worker_kind` (agent_block / subprocess
85    /// stats sidecar, operator ack), that value wins; otherwise the
86    /// fold site's `kind` becomes the value. Called at every worker
87    /// fold site (`InProcSpawner` spawn task, subprocess spawn task,
88    /// `OperatorDelegateMiddleware`).
89    pub fn ensure_worker_kind(mut self, kind: &str) -> Self {
90        let stats = self
91            .stats
92            .get_or_insert_with(crate::store::trace::WorkerStats::default);
93        if stats.worker_kind.is_none() {
94            stats.worker_kind = Some(kind.to_string());
95        }
96        self
97    }
98}
99
100/// First stage of the two-stage pipeline: builds a `Box<dyn Worker>` for
101/// one attempt. Every concrete spawner (`InProcSpawner`, `ProcessSpawner`,
102/// the Operator spawner) implements this; the engine only ever holds a
103/// `Arc<dyn SpawnerAdapter>` and knows nothing about the Worker shape
104/// behind it.
105#[async_trait]
106pub trait SpawnerAdapter: Send + Sync {
107    /// Spawn one attempt as a worker. Returns `Box<dyn Worker>`.
108    ///
109    /// The `directive` argument was removed in design intent: prompts are
110    /// pulled on demand through
111    /// `engine.fetch_prompt(token, task_id, attempt)`. Spawners are free
112    /// to use whatever protocol they like internally — push, pull, or a
113    /// hybrid. `ProcessSpawner` runs `fetch_prompt` and pushes the
114    /// result into the child's stdin; `InProcSpawner` injects a prep
115    /// snapshot as `WorkerInvocation.prompt`; a child process could
116    /// even re-pull with the token itself.
117    async fn spawn(
118        &self,
119        engine: &Engine,
120        ctx: &Ctx,
121        task_id: StepId,
122        attempt: u32,
123        token: CapToken,
124    ) -> Result<Box<dyn Worker>, SpawnError>;
125}
126
127// ─── InProcSpawner ────────────────────────────────────────────────────────
128
129/// Invocation context handed to a Worker fn. Bundles `token` +
130/// `task_id` + `prompt` + `sink`.
131///
132/// The `prompt` field was added in design intent, folding the old
133/// `Fn(inv, directive)` `directive` argument into the invocation. The
134/// spawner is expected to call
135/// `engine.fetch_prompt(token, task_id, attempt)` in its prep step and
136/// inject the snapshot into the invocation (push form). The `WorkerFn`
137/// side may still re-pull if it needs to — for example to fetch the
138/// prompt for a different attempt.
139///
140/// The `sink` field was added in design intent as the formal contract for
141/// the spawner's intake surface. A worker fn can stream intermediate
142/// events with things like
143/// `inv.sink.emit(OutputEvent::Progress { .. })`. Child-process
144/// spawners (`ProcessSpawner`, etc.) do not use `sink` — the child
145/// speaks the stdout protocol; `InProcSpawner` injects one. Even
146/// without `sink`, the `WorkerResult` returned by the fn is still
147/// folded into a `Final` event on the spawner side, running alongside
148/// the older return-value path.
149#[derive(Clone)]
150pub struct WorkerInvocation {
151    /// Capability token authorizing this attempt.
152    pub token: CapToken,
153    /// The task this invocation belongs to.
154    pub task_id: StepId,
155    /// Attempt number within the task (used to key output events).
156    pub attempt: u32,
157    /// Registered agent name the `WorkerFn` was looked up under.
158    pub agent: String,
159    /// The prompt/prep snapshot pulled via `engine.fetch_prompt`,
160    /// injected here (push form) so the worker fn does not need to call
161    /// back into the engine for the common case.
162    pub prompt: String,
163    /// Intake: sink the worker fn uses to emit intermediate
164    /// `OutputEvent`s. Injected by `InProcSpawner`. `None` means the
165    /// sink path is not wired for this invocation.
166    pub sink: Option<std::sync::Arc<dyn crate::worker::output::OutputSink>>,
167    /// Upstream task cancel token — the clone of `cancel_inner`
168    /// generated by `InProcSpawner` for `JoinHandleWorker`. Worker fns
169    /// bridge this to their child futures or their SDK's
170    /// `shutdown_token`, propagating external cancellation all the way
171    /// down. `None` — like `sink` above — means the caller path is not
172    /// carrying the cancel channel.
173    pub cancel_token: Option<tokio_util::sync::CancellationToken>,
174}
175
176impl std::fmt::Debug for WorkerInvocation {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        f.debug_struct("WorkerInvocation")
179            .field("token", &self.token)
180            .field("task_id", &self.task_id)
181            .field("attempt", &self.attempt)
182            .field("agent", &self.agent)
183            .field("prompt", &self.prompt)
184            .field("sink", &self.sink.as_ref().map(|_| "<OutputSink>"))
185            .field(
186                "cancel_token",
187                &self.cancel_token.as_ref().map(|_| "<CancellationToken>"),
188            )
189            .finish()
190    }
191}
192
193/// A registered agent implementation: takes a `WorkerInvocation` and
194/// resolves to a `WorkerResult` (or a `WorkerError`). Boxed as a
195/// type-erased `Future` so heterogeneous agent implementations (async
196/// fns, closures capturing state, etc.) can share one registry entry
197/// type.
198pub type WorkerFn = Arc<
199    dyn Fn(
200            WorkerInvocation,
201        ) -> Pin<Box<dyn Future<Output = Result<WorkerResult, WorkerError>> + Send>>
202        + Send
203        + Sync,
204>;
205
206/// `agent`-string → `WorkerFn` registry. The generic parameter `W` pins
207/// the per-kind Worker concrete type at the type level, so AgentBlock /
208/// Lua / RustFn each produce their own Worker type through
209/// `InProcSpawner<W>` and the type binding is preserved right up until
210/// `SpawnerAdapter::spawn()` erases the return as `Box<dyn Worker>`.
211/// `W` must be constructible from `WorkerJoinHandler` via `From` — i.e.
212/// a newtype that embeds the async-signal handle.
213pub struct InProcSpawner<W = crate::worker::MiddlewareWorker> {
214    /// Agent name → implementation lookup table.
215    pub registry: HashMap<String, WorkerFn>,
216    _phantom: std::marker::PhantomData<W>,
217}
218
219// Inherent impl for the default W = MiddlewareWorker (so `InProcSpawner::new()`
220// in existing tests picks this default).
221impl InProcSpawner {
222    /// Creates an empty registry, defaulting the Worker type to
223    /// `MiddlewareWorker` (used by existing call sites and tests).
224    pub fn new() -> Self {
225        Self {
226            registry: HashMap::new(),
227            _phantom: std::marker::PhantomData,
228        }
229    }
230
231    /// Registers a `WorkerFn`-shaped async closure under `agent`,
232    /// overwriting any previous registration for the same name. Returns
233    /// `&mut Self` for chained registration calls.
234    pub fn register<F, Fut>(&mut self, agent: impl Into<String>, f: F) -> &mut Self
235    where
236        F: Fn(WorkerInvocation) -> Fut + Send + Sync + 'static,
237        Fut: Future<Output = Result<WorkerResult, WorkerError>> + Send + 'static,
238    {
239        let f = Arc::new(f);
240        let wrapped: WorkerFn = Arc::new(move |inv| {
241            let f = f.clone();
242            Box::pin(f(inv))
243        });
244        self.registry.insert(agent.into(), wrapped);
245        self
246    }
247}
248
249// Generic typed impl (the factory.build path that constructs a per-kind Worker).
250impl<W> InProcSpawner<W>
251where
252    W: Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static,
253{
254    /// Creates an empty registry pinned to Worker type `W` (the
255    /// `factory.build` path uses this to get a per-kind Worker out of
256    /// `spawn()` instead of the default `MiddlewareWorker`).
257    pub fn typed() -> Self {
258        Self {
259            registry: HashMap::new(),
260            _phantom: std::marker::PhantomData,
261        }
262    }
263}
264
265impl Default for InProcSpawner {
266    fn default() -> Self {
267        Self::new()
268    }
269}
270
271#[async_trait]
272impl<W: Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static> SpawnerAdapter
273    for InProcSpawner<W>
274{
275    async fn spawn(
276        &self,
277        engine: &Engine,
278        ctx: &Ctx,
279        task_id: StepId,
280        attempt: u32,
281        token: CapToken,
282    ) -> Result<Box<dyn Worker>, SpawnError> {
283        let f = self
284            .registry
285            .get(&ctx.agent)
286            .cloned()
287            .ok_or_else(|| SpawnError::NotRegistered(ctx.agent.clone()))?;
288
289        // design intent: prompts are pulled via engine.fetch_prompt (the directive argument is retired)
290        let prompt = engine
291            .fetch_prompt(&token, &task_id)
292            .await
293            .map_err(|e| SpawnError::Internal(format!("fetch_prompt: {e}")))?;
294        // In-process WorkerInvocation consumes `prompt` as `String` (issue #18
295        // boundary render): Value flows end-to-end through the engine, and is
296        // stringified here for the RustFn / Lua worker.
297        let prompt = crate::core::engine::render_directive_to_string(&prompt);
298
299        let (tx, rx) = tokio::sync::oneshot::channel();
300        let cancel = tokio_util::sync::CancellationToken::new();
301        let cancel_inner = cancel.clone();
302        let worker_id = crate::types::WorkerId::new();
303        // issue #11: surface the minted WorkerId in the trace log.
304        tracing::debug!(worker_id = %worker_id, step_id = %task_id, "worker spawned (rustfn)");
305        // design intent: hand `engine` / `token` to the spawn task so it can emit
306        // OutputEvent::Final via submit_output (side-by-side with the
307        // WorkerResult oneshot path).
308        let engine_for_emit = engine.clone();
309        let token_for_emit = token.clone();
310        let task_id_for_emit = task_id.clone();
311        // Wire the receiving end by injecting an EngineSink into WorkerInvocation.sink.
312        let sink = std::sync::Arc::new(crate::worker::output::EngineSink::new(
313            engine.clone(),
314            token.clone(),
315            task_id.clone(),
316            attempt,
317        )) as std::sync::Arc<dyn crate::worker::output::OutputSink>;
318        let inv = WorkerInvocation {
319            token,
320            task_id,
321            attempt,
322            agent: ctx.agent.clone(),
323            prompt,
324            sink: Some(sink),
325            cancel_token: Some(cancel_inner.clone()),
326        };
327
328        tokio::spawn(async move {
329            let result = tokio::select! {
330                r = f(inv) => r,
331                _ = cancel_inner.cancelled() => Err(WorkerError::Cancelled),
332            };
333            // Fold WorkerResult into OutputEvent::Final. Contract: one Final per attempt.
334            if let Ok(wr) = &result {
335                // Stats sidecar: forward boundary-reported stats to the
336                // engine (drained by the dispatcher's outcome fold into
337                // the terminal StepEntry). This single fold site covers
338                // every InProc worker kind (RustFn / Lua / AgentBlock).
339                if let Some(stats) = wr.stats.clone() {
340                    engine_for_emit
341                        .record_worker_stats(&task_id_for_emit, attempt, stats)
342                        .await;
343                }
344                let ev = crate::worker::output::OutputEvent::Final {
345                    content: crate::worker::output::ContentRef::Inline {
346                        value: wr.value.clone(),
347                    },
348                    ok: wr.ok,
349                };
350                let _ = engine_for_emit
351                    .submit_output(&token_for_emit, &task_id_for_emit, attempt, ev)
352                    .await;
353            }
354            let signal: Result<(), WorkerError> = result.map(|_| ());
355            let _ = tx.send(signal);
356        });
357
358        let handler = crate::worker::WorkerJoinHandler {
359            worker_id,
360            cancel,
361            completion: rx,
362        };
363        Ok(Box::new(W::from(handler)))
364    }
365}