Skip to main content

cuttlefish_host/
runner.rs

1//! The reactor loop: the host drives the guest, one command at a time.
2//!
3//! This module is the shape of the whole system. The guest never calls the host
4//! and waits — it returns a [`Command`], the host carries it out, and the host
5//! steps the guest again with the resulting [`Event`]. Two things follow from
6//! that inversion, and both are why it is worth the awkwardness:
7//!
8//! - **Cancellation needs no guest cooperation.** The host simply stops
9//!   stepping. A guest cannot ignore, delay, or trap its way out of it.
10//! - **Every iteration is observable.** Progress, token counts, and capability
11//!   decisions all pass through the host, even for a block whose internal loop
12//!   the DAG cannot see.
13//!
14//! The alternative — host functions the guest imports and blocks on — is not
15//! merely less tidy, it does not work: a single-threaded core-wasm guest offers
16//! no execution context for the host to call back into, and the wasmtime `Store`
17//! is `!Sync` while inference must run on a separate thread.
18
19use crate::caps::Capabilities;
20use crate::handles::Handles;
21use crate::infer::{InferBackend, InferRequest, InferResult};
22use cuttlefish_abi::{error_codes, Command, Envelope, Event, JobError, JobStatus, Usage};
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::Arc;
25use std::time::Instant;
26use tokio::sync::mpsc;
27use tokio_util::sync::CancellationToken;
28use wasmtime::{Engine, Instance, Linker, Memory, Module, Store, TypedFunc};
29
30/// Width, in pixels, that document pages render to.
31///
32/// Vision models work from a fixed-size input anyway, and a larger raster costs
33/// encode time and tokens without adding detail the model can use.
34const RENDER_WIDTH: u16 = 1024;
35
36/// Something worth telling a watcher about while a job runs.
37#[derive(Debug, Clone)]
38pub enum JobEvent {
39    /// One generated token.
40    Token(String),
41    /// Guest-supplied progress.
42    Progress(serde_json::Value),
43}
44
45/// Backends for models *other than* the job's own, keyed by the model that
46/// names them.
47///
48/// Named `alternates` rather than `backends` on purpose: `run_job` already
49/// takes the job's own backend, and a field called `backends` sitting beside
50/// a parameter called `backend` is a trap. Everything here is something the
51/// job reaches only by explicit instruction — an `on_fail = [ reroute ... ]`
52/// rung, or a `Judge` that names its own model.
53///
54/// Resolved once at daemon startup, so a model this build cannot serve fails
55/// as the daemon comes up rather than three hours into a campaign, at the
56/// exact moment something has already gone wrong.
57pub type Alternates =
58    std::collections::HashMap<cuttlefish_core::spec::ModelRef, Arc<dyn InferBackend>>;
59
60/// Every model a spec can reach *besides* its own — the `reroute` targets
61/// and the model-bearing `Judge`s, deduplicated.
62///
63/// Exists so the daemon can resolve them all at startup. Collecting them
64/// here rather than in `cuttlefishd` keeps the knowledge of which node
65/// fields name a model next to the type that consumes them, so adding a
66/// third such field later is one edit rather than two.
67pub fn alternate_models_of(
68    spec: &cuttlefish_core::spec::Spec,
69) -> Vec<cuttlefish_core::spec::ModelRef> {
70    use cuttlefish_core::graph::{AcceptCheck, Rung};
71    let mut seen: Vec<cuttlefish_core::spec::ModelRef> = Vec::new();
72    let mut push = |model: cuttlefish_core::spec::ModelRef| {
73        // The job's own model is not an alternate; it already has a backend.
74        if model != spec.model && !seen.contains(&model) {
75            seen.push(model);
76        }
77    };
78    for (_, node) in &spec.nodes.nodes {
79        for rung in &node.on_fail {
80            if let Rung::Reroute(model) = rung {
81                push(model.clone());
82            }
83        }
84        for check in &node.accept {
85            if let AcceptCheck::Judge {
86                model: Some(model), ..
87            } = check
88            {
89                push(model.clone());
90            }
91        }
92    }
93    seen
94}
95
96/// Everything needed to run one job.
97pub struct JobSpec {
98    /// The checked graph, in topological order — safe to execute
99    /// front-to-back, threading `outputs` forward.
100    pub nodes: Vec<crate::dag::CheckedNode>,
101    /// Which nodes are exclusive to which branch decision+label — see
102    /// `crate::dag::BranchExclusivity`.
103    pub exclusive_to: std::collections::HashMap<String, crate::dag::BranchExclusivity>,
104    /// The job's input, handed to every entry node (a node with no `input`
105    /// expression — `node.input.is_none()`).
106    pub input: serde_json::Value,
107    /// What this job is permitted to reach.
108    pub caps: Capabilities,
109    /// Backends for models beyond the job's own — see [`Alternates`].
110    /// Empty for a spec with no `reroute` rung and no model-bearing `Judge`,
111    /// which is every spec that existed before acceptance contracts.
112    pub alternates: Alternates,
113}
114
115/// Pointer width of a guest module, read from the module rather than assumed.
116///
117/// Only [`Abi::W32`] is supported today; 64-bit guests are rejected with a clear
118/// message. The enum exists anyway so that adding wasm64 later is a new arm plus
119/// a second set of [`TypedFunc`] signatures, rather than a hunt through this
120/// file for every place a pointer was assumed to be four bytes wide.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum Abi {
123    /// 32-bit linear memory.
124    W32,
125    /// 64-bit linear memory (memory64).
126    W64,
127}
128
129impl Abi {
130    /// Size of one pointer-sized field, and so half a descriptor.
131    fn ptr_size(self) -> usize {
132        match self {
133            Abi::W32 => 4,
134            Abi::W64 => 8,
135        }
136    }
137}
138
139struct Guest {
140    store: Store<()>,
141    memory: Memory,
142    abi: Abi,
143    alloc: TypedFunc<u32, u32>,
144    init: TypedFunc<(u32, u32), u32>,
145    step: TypedFunc<(u32, u32), u32>,
146    on_token: Option<TypedFunc<(u32, u32), i32>>,
147}
148
149impl Guest {
150    fn new(
151        engine: &Engine,
152        cache: &crate::module_cache::ModuleCache,
153        module_bytes: &[u8],
154    ) -> anyhow::Result<Self> {
155        let module = cache.compile(engine, module_bytes)?;
156
157        // An empty linker, deliberately. Guest blocks are built for
158        // `wasm32-unknown-unknown` and import nothing at all — a wasip1 guest
159        // would drag in `fd_write` and `proc_exit` through its panic path alone
160        // and fail to instantiate here.
161        let linker: Linker<()> = Linker::new(engine);
162        let mut store = Store::new(engine, ());
163        let instance: Instance = linker.instantiate(&mut store, &module)?;
164
165        let memory = instance
166            .get_memory(&mut store, "memory")
167            .ok_or_else(|| anyhow::anyhow!("guest exports no memory"))?;
168
169        // Width comes from the module itself. A 64-bit guest exports `cf_init`
170        // as `(i64, i64) -> i64`, so the typed lookups below would otherwise
171        // fail with a signature mismatch that says nothing about the real cause.
172        let abi = if memory.ty(&store).is_64() {
173            Abi::W64
174        } else {
175            Abi::W32
176        };
177        if abi == Abi::W64 {
178            anyhow::bail!("guest uses 64-bit memory; only 32-bit guests are supported");
179        }
180
181        Ok(Self {
182            alloc: instance.get_typed_func(&mut store, "cf_alloc")?,
183            init: instance.get_typed_func(&mut store, "cf_init")?,
184            step: instance.get_typed_func(&mut store, "cf_step")?,
185            // Optional: a block indifferent to streaming need not export it.
186            on_token: instance.get_typed_func(&mut store, "cf_on_token").ok(),
187            memory,
188            abi,
189            store,
190        })
191    }
192
193    fn write(&mut self, bytes: &[u8]) -> anyhow::Result<(u32, u32)> {
194        let len = bytes.len() as u32;
195        let ptr = self.alloc.call(&mut self.store, len)?;
196        self.memory.write(&mut self.store, ptr as usize, bytes)?;
197        Ok((ptr, len))
198    }
199
200    /// Read the descriptor the guest returned, then the payload it points at.
201    ///
202    /// Two reads rather than unpacking one integer — the cost of keeping these
203    /// signatures identical across pointer widths.
204    fn read_desc(&mut self, desc_ptr: u32) -> anyhow::Result<Vec<u8>> {
205        let w = self.abi.ptr_size();
206        let mut desc = vec![0u8; 2 * w];
207        self.memory
208            .read(&mut self.store, desc_ptr as usize, &mut desc)?;
209
210        let field = |bytes: &[u8]| -> u64 {
211            match w {
212                4 => u32::from_le_bytes(bytes.try_into().expect("4 bytes")) as u64,
213                _ => u64::from_le_bytes(bytes.try_into().expect("8 bytes")),
214            }
215        };
216        let ptr = field(&desc[..w]) as usize;
217        let len = field(&desc[w..]) as usize;
218
219        let mut buf = vec![0u8; len];
220        self.memory.read(&mut self.store, ptr, &mut buf)?;
221        Ok(buf)
222    }
223
224    fn call_init(&mut self, input: &serde_json::Value) -> anyhow::Result<Command> {
225        let bytes = serde_json::to_vec(input)?;
226        let (ptr, len) = self.write(&bytes)?;
227        let desc = self.init.call(&mut self.store, (ptr, len))?;
228        Ok(serde_json::from_slice(&self.read_desc(desc)?)?)
229    }
230
231    fn call_step(&mut self, event: &Event) -> anyhow::Result<Command> {
232        let bytes = serde_json::to_vec(event)?;
233        let (ptr, len) = self.write(&bytes)?;
234        let desc = self.step.call(&mut self.store, (ptr, len))?;
235        Ok(serde_json::from_slice(&self.read_desc(desc)?)?)
236    }
237
238    /// Ask the guest whether generation should continue.
239    fn call_on_token(&mut self, token: &str) -> anyhow::Result<bool> {
240        // Cloned rather than moved: wasmtime's TypedFunc is Clone but not Copy,
241        // and cloning also ends the borrow of `self` before `write` needs it
242        // mutably.
243        let Some(f) = self.on_token.clone() else {
244            return Ok(true);
245        };
246        let (ptr, len) = self.write(token.as_bytes())?;
247        Ok(f.call(&mut self.store, (ptr, len))? == 0)
248    }
249}
250
251/// Read a block's declared signature out of a compiled module.
252///
253/// Instantiates the module and calls its `cf_signature` export. That is heavier
254/// than parsing a sidecar file, and it is the point: the answer comes from the
255/// artifact that will actually run, so it cannot describe a different version of
256/// the block than the one being checked.
257///
258/// A block built before signatures existed has no such export. That is not an
259/// error — it reports the permissive default, so an older block still composes,
260/// just without the seam being checked.
261pub fn read_signature(
262    engine: &Engine,
263    module_bytes: &[u8],
264) -> anyhow::Result<cuttlefish_abi::Signature> {
265    let permissive = cuttlefish_abi::Signature {
266        input: cuttlefish_abi::Ty::Json,
267        output: cuttlefish_abi::Ty::Json,
268    };
269
270    // Deliberately does not go through `Guest`, which requires the whole reactor
271    // — alloc, init, step. Reading a declaration should not demand that a module
272    // be runnable: a block missing an export has a real problem, but it is one
273    // worth reporting when the job runs, with the job's error handling, rather
274    // than as a confusing failure during a typecheck.
275    let module = Module::new(engine, module_bytes)?;
276    let linker: Linker<()> = Linker::new(engine);
277    let mut store = Store::new(engine, ());
278    let instance = linker.instantiate(&mut store, &module)?;
279
280    let Ok(signature) = instance.get_typed_func::<(), u32>(&mut store, "cf_signature") else {
281        return Ok(permissive);
282    };
283    let Some(memory) = instance.get_memory(&mut store, "memory") else {
284        return Ok(permissive);
285    };
286
287    let desc_ptr = signature.call(&mut store, ())? as usize;
288    let mut desc = [0u8; 8];
289    memory.read(&mut store, desc_ptr, &mut desc)?;
290    let ptr = u32::from_le_bytes(desc[..4].try_into().expect("4 bytes")) as usize;
291    let len = u32::from_le_bytes(desc[4..].try_into().expect("4 bytes")) as usize;
292
293    let mut buf = vec![0u8; len];
294    memory.read(&mut store, ptr, &mut buf)?;
295    Ok(serde_json::from_slice(&buf)?)
296}
297
298fn fail(code: &str, message: impl Into<String>, usage: Usage) -> Envelope {
299    Envelope {
300        status: JobStatus::Failed,
301        result: None,
302        error: Some(JobError {
303            code: code.into(),
304            message: message.into(),
305        }),
306        usage,
307    }
308}
309
310/// Whether an `InputExpr` (transitively) references any node in `skipped`.
311fn references_any(
312    expr: &cuttlefish_core::graph::InputExpr,
313    skipped: &std::collections::HashSet<String>,
314) -> bool {
315    use cuttlefish_core::graph::InputExpr;
316    match expr {
317        InputExpr::FromNode(n) => skipped.contains(n),
318        InputExpr::Record(fields) => fields.values().any(|e| references_any(e, skipped)),
319        InputExpr::List(items) => items.iter().any(|e| references_any(e, skipped)),
320    }
321}
322
323/// Compose an `InputExpr` into an actual JSON value, by looking up each
324/// referenced node's already-produced output. Mirrors `dag::evaluate_expr_ty`
325/// (which does the same composition at the *type* level, at check time) —
326/// this is the runtime analogue.
327fn evaluate_input(
328    expr: &cuttlefish_core::graph::InputExpr,
329    outputs: &std::collections::HashMap<String, serde_json::Value>,
330) -> serde_json::Value {
331    use cuttlefish_core::graph::InputExpr;
332    match expr {
333        InputExpr::FromNode(n) => outputs.get(n).cloned().unwrap_or(serde_json::Value::Null),
334        InputExpr::Record(fields) => {
335            let mut map = serde_json::Map::new();
336            for (k, v) in fields {
337                map.insert(k.clone(), evaluate_input(v, outputs));
338            }
339            serde_json::Value::Object(map)
340        }
341        InputExpr::List(items) => {
342            serde_json::Value::Array(items.iter().map(|e| evaluate_input(e, outputs)).collect())
343        }
344    }
345}
346
347fn cancelled(usage: Usage, message: &str) -> Envelope {
348    Envelope {
349        status: JobStatus::Cancelled,
350        result: None,
351        error: Some(JobError {
352            code: error_codes::CANCELLED.into(),
353            message: message.into(),
354        }),
355        usage,
356    }
357}
358
359/// Drive one job to completion.
360///
361/// Always returns an [`Envelope`]; failures are values, not errors, because the
362/// caller has to report *something* to whoever submitted the job.
363///
364/// `ledger` is consulted before any resume-sensitive decision (branch-skip,
365/// transitive-skip, or actually running a node) and written to immediately
366/// after that decision is made, so that a process restart mid-job — a later
367/// task's concern, not this function's — can resume from exactly the state
368/// this function left behind. The whole body runs inside a single labeled
369/// block (`'run: { ... }`) so that every exit path, however it got there,
370/// still reaches the one `ledger.finish(...)` call at the end.
371pub async fn run_job(
372    engine: Arc<Engine>,
373    backend: Arc<dyn InferBackend>,
374    job: JobSpec,
375    events: mpsc::Sender<JobEvent>,
376    cancel: CancellationToken,
377    ledger: &crate::ledger::Ledger,
378    cache: &crate::module_cache::ModuleCache,
379) -> Envelope {
380    let started = Instant::now();
381    let mut usage = Usage {
382        model: backend.model_name(),
383        ..Usage::default()
384    };
385
386    // Dropped when this function returns, closing every file the job opened.
387    // That job-scoped lifetime is what makes handles unforgeable across jobs.
388    //
389    // Shared across stages on purpose: a handle produced by one block — a
390    // rendered page, say — stays usable by the next. Confining it to one stage
391    // would make a pipeline strictly weaker than a single block that did the
392    // same work, while adding nothing, since the job boundary is what the
393    // security property rests on.
394    let mut handles = Handles::default();
395
396    let envelope = 'run: {
397        if job.nodes.is_empty() {
398            usage.duration_ms = started.elapsed().as_millis() as u64;
399            break 'run fail(
400                error_codes::SCHEMA_VALIDATION_FAILED,
401                "this job has no nodes to run",
402                usage,
403            );
404        }
405
406        // Compile every node's `accept` list up front, indexed by node
407        // position. A malformed schema is a property of the spec, so it
408        // should stop the job at the start rather than surface as a bizarre
409        // acceptance failure once half a campaign has already run.
410        let checks: Vec<crate::accept::CompiledChecks> = match job
411            .nodes
412            .iter()
413            .map(|n| {
414                crate::accept::CompiledChecks::compile(&n.accept)
415                    .map_err(|e| format!("node `{}`: {e}", n.name))
416            })
417            .collect()
418        {
419            Ok(c) => c,
420            Err(message) => {
421                usage.duration_ms = started.elapsed().as_millis() as u64;
422                break 'run fail(error_codes::SCHEMA_VALIDATION_FAILED, message, usage);
423            }
424        };
425
426        let total = job.nodes.len();
427        let mut outputs: std::collections::HashMap<String, serde_json::Value> =
428            std::collections::HashMap::new();
429        let mut skipped: std::collections::HashSet<String> = std::collections::HashSet::new();
430        let mut route_taken: std::collections::HashMap<String, String> =
431            std::collections::HashMap::new();
432
433        for (index, node) in job.nodes.iter().enumerate() {
434            // Resume: a node the ledger already marked skipped stays skipped
435            // — its branch decision is not re-evaluated.
436            match ledger.is_skipped(&node.name) {
437                Ok(true) => {
438                    skipped.insert(node.name.clone());
439                    continue;
440                }
441                Ok(false) => {}
442                Err(e) => {
443                    usage.duration_ms = started.elapsed().as_millis() as u64;
444                    break 'run fail(
445                        error_codes::SCHEMA_VALIDATION_FAILED,
446                        format!("reading ledger skip state for node `{}`: {e}", node.name),
447                        usage,
448                    );
449                }
450            }
451
452            // Resume: a completed checkpoint means reuse the cached output
453            // instead of re-running.
454            match ledger.get_completed(&node.name) {
455                Ok(Some(cached)) => {
456                    // If this was a branches decision node, its route must be
457                    // recorded in `route_taken` here too — not just after a
458                    // fresh `run_stage` call below — or a downstream,
459                    // not-yet-reached branch-exclusive node would see no
460                    // recorded decision at all on a resumed run and
461                    // (incorrectly) never be skipped. The checkpoint only
462                    // ever holds a value that already passed this same route
463                    // validation the first time it ran, so this is purely
464                    // re-deriving `route_taken`, never re-validating.
465                    if let Some(route) = cached.get("route").and_then(|v| v.as_str()) {
466                        route_taken.insert(node.name.clone(), route.to_string());
467                    }
468                    outputs.insert(node.name.clone(), cached);
469                    continue;
470                }
471                Ok(None) => {}
472                Err(e) => {
473                    usage.duration_ms = started.elapsed().as_millis() as u64;
474                    break 'run fail(
475                        error_codes::SCHEMA_VALIDATION_FAILED,
476                        format!("reading ledger checkpoint for node `{}`: {e}", node.name),
477                        usage,
478                    );
479                }
480            }
481
482            // Tell watchers which node is running. A pipeline that stalls is
483            // much easier to diagnose when the stream says where.
484            if total > 1 {
485                let _ = events
486                    .send(JobEvent::Progress(serde_json::json!({
487                        "stage": index + 1,
488                        "of": total,
489                        "node": node.name,
490                    })))
491                    .await;
492            }
493
494            // Fresh branch-skip decision (only reached if the ledger had no
495            // recorded state for this node — i.e. this is either a fresh
496            // run, or a resumed run that hadn't reached this node yet).
497            //
498            // Branch-skip: this node is exclusive to a decision+label, and
499            // that decision's chosen route (already recorded earlier in this
500            // same loop, since the branching node is always topologically
501            // before the nodes exclusive to its labels) doesn't match.
502            if let Some(ex) = job.exclusive_to.get(&node.name) {
503                if let Some(taken) = route_taken.get(&ex.decision) {
504                    if taken != &ex.label {
505                        skipped.insert(node.name.clone());
506                        if let Err(e) = ledger.write_skipped(&node.name) {
507                            usage.duration_ms = started.elapsed().as_millis() as u64;
508                            break 'run fail(
509                                error_codes::SCHEMA_VALIDATION_FAILED,
510                                format!("recording skip for node `{}` in ledger: {e}", node.name),
511                                usage,
512                            );
513                        }
514                        continue;
515                    }
516                }
517            }
518            // Transitive skip: this node's input needs a skipped node's
519            // output.
520            if let Some(expr) = &node.input {
521                if references_any(expr, &skipped) {
522                    skipped.insert(node.name.clone());
523                    if let Err(e) = ledger.write_skipped(&node.name) {
524                        usage.duration_ms = started.elapsed().as_millis() as u64;
525                        break 'run fail(
526                            error_codes::SCHEMA_VALIDATION_FAILED,
527                            format!("recording skip for node `{}` in ledger: {e}", node.name),
528                            usage,
529                        );
530                    }
531                    continue;
532                }
533            }
534
535            let node_input = match &node.input {
536                None => job.input.clone(),
537                Some(expr) => evaluate_input(expr, &outputs),
538            };
539
540            // Fan-out: this node runs once per manifest line rather than
541            // once, with each item checkpointed independently. Handled by
542            // its own helper because it is a genuinely different execution
543            // shape, not a variation on the repeat_until loop below (which
544            // it is parse-time forbidden to combine with).
545            if node.over.is_some() {
546                let collected = match run_fanout_node(
547                    &engine,
548                    cache,
549                    &backend,
550                    &job.alternates,
551                    &checks[index],
552                    node,
553                    &job.caps,
554                    &mut handles,
555                    &events,
556                    &cancel,
557                    &mut usage,
558                    started,
559                    index,
560                    total,
561                    ledger,
562                )
563                .await
564                {
565                    Ok(v) => v,
566                    Err(envelope) => break 'run envelope,
567                };
568                if let Err(e) = ledger.write_completed(&node.name, &collected) {
569                    usage.duration_ms = started.elapsed().as_millis() as u64;
570                    break 'run fail(
571                        error_codes::SCHEMA_VALIDATION_FAILED,
572                        format!(
573                            "recording checkpoint for node `{}` in ledger: {e}",
574                            node.name
575                        ),
576                        usage,
577                    );
578                }
579                outputs.insert(node.name.clone(), collected);
580                continue;
581            }
582
583            // Run the node, holding its output to the declared type and to
584            // every `accept` check, and climbing `on_fail` if it isn't
585            // accepted. The bounded `repeat_until` loop lives inside one
586            // attempt — see `Ladder`.
587            //
588            // Nothing checked a block's *actual* output against what it
589            // declared until this existed: a Script or Rust block could
590            // claim `{summary: text}` and return `{text: "..."}` and the
591            // job would still complete "successfully", silently handing
592            // whatever consumed `summary` a `null`. `matches_value` is
593            // deliberately permissive about `bytes`/`image`/`document` (see
594            // its own doc comment) so this only ever catches genuine shape
595            // mismatches, not false positives on types this protocol has no
596            // fixed JSON encoding for.
597            let ladder = Ladder {
598                engine: &engine,
599                cache,
600                default_backend: &backend,
601                alternates: &job.alternates,
602                checks: &checks[index],
603                expected: &node.signature.output,
604                on_fail: &node.on_fail,
605                repeat_until: node.repeat_until.as_deref(),
606                max_iterations: node.max_iterations,
607                caps: &job.caps,
608                events: &events,
609                cancel: &cancel,
610                started,
611                index,
612            };
613            let result = match ladder
614                .run(
615                    &node.module_bytes,
616                    node.script.as_deref(),
617                    node_input.clone(),
618                    &mut handles,
619                    &mut usage,
620                )
621                .await
622            {
623                Ok(value) => value,
624                Err(LadderError::Fatal(envelope)) => break 'run *envelope,
625                Err(LadderError::Exhausted {
626                    reason,
627                    escalated,
628                    envelope,
629                }) => {
630                    // Record the give-up *before* failing the job. An
631                    // escalation that only exists in the returned envelope is
632                    // gone the moment the caller stops looking, which defeats
633                    // the point: the whole reason to escalate is that nobody
634                    // is watching right now.
635                    if escalated {
636                        // With the input, so the escalation is drainable:
637                        // "here is exactly what this node was handed when it
638                        // gave up" is what reproducing it requires.
639                        if let Err(e) =
640                            ledger.write_escalated(&node.name, None, &reason, Some(&node_input))
641                        {
642                            eprintln!("recording escalation for node `{}`: {e}", node.name);
643                        }
644                    }
645                    usage.duration_ms = started.elapsed().as_millis() as u64;
646                    // The block's own envelope when it has one, so a
647                    // `wasm_trap` still reads as a `wasm_trap`.
648                    break 'run match envelope {
649                        Some(e) => *e,
650                        None => fail(
651                            error_codes::SCHEMA_VALIDATION_FAILED,
652                            format!("node `{}`: {reason}", node.name),
653                            usage,
654                        ),
655                    };
656                }
657            };
658
659            // A branching node: read its `route` field and record the
660            // decision for later nodes' branch-skip check (top of this
661            // loop).
662            //
663            // Whether this node is actually a `branches` decision at all is
664            // determined from `job.exclusive_to` rather than by threading
665            // `Branches` itself into `JobSpec`: `dag::compute_branch_exclusivity`
666            // seeds an entry for every `(label, target)` pair of every declared
667            // decision, so the set of `label`s across all `exclusive_to` entries
668            // whose `decision` names this node IS exactly this decision's full,
669            // valid label set. An unmatched or missing `route` on a genuine
670            // decision node is a job failure — loud, not a silent no-op — per
671            // the design spec's "Conditional dispatch" section.
672            let valid_labels: Vec<&str> = job
673                .exclusive_to
674                .values()
675                .filter(|ex| ex.decision == node.name)
676                .map(|ex| ex.label.as_str())
677                .collect();
678            if !valid_labels.is_empty() {
679                match result.get("route").and_then(|v| v.as_str()) {
680                    Some(route) if valid_labels.contains(&route) => {
681                        route_taken.insert(node.name.clone(), route.to_string());
682                    }
683                    Some(route) => {
684                        usage.duration_ms = started.elapsed().as_millis() as u64;
685                        break 'run fail(
686                            error_codes::SCHEMA_VALIDATION_FAILED,
687                            format!(
688                                "node `{}` produced route \"{route}\", which doesn't match any \
689                                 declared label for this branches decision",
690                                node.name
691                            ),
692                            usage,
693                        );
694                    }
695                    None => {
696                        usage.duration_ms = started.elapsed().as_millis() as u64;
697                        break 'run fail(
698                            error_codes::SCHEMA_VALIDATION_FAILED,
699                            format!(
700                                "node `{}` is a branches decision but its output has no `route` field",
701                                node.name
702                            ),
703                            usage,
704                        );
705                    }
706                }
707            } else if let Some(route) = result.get("route").and_then(|v| v.as_str()) {
708                // Not a declared decision node, but its output happens to
709                // carry a route field anyway — harmless, record it
710                // defensively (no downstream node's exclusive_to can
711                // reference this decision name if it isn't a real decision,
712                // so this is inert either way, just avoids silently
713                // dropping information that happens to be present).
714                route_taken.insert(node.name.clone(), route.to_string());
715            }
716
717            // Checkpoint a genuinely-executed node's result before recording
718            // it in `outputs` — the ledger write must happen before the
719            // in-memory outputs map update so a crash between the two can't
720            // leave the ledger silently behind the in-memory state (the
721            // in-memory state doesn't survive a crash anyway, so
722            // ledger-first is the only order that matters for durability;
723            // the reverse order would just be a smaller window for the same
724            // underlying property, not correctness).
725            if let Err(e) = ledger.write_completed(&node.name, &result) {
726                usage.duration_ms = started.elapsed().as_millis() as u64;
727                break 'run fail(
728                    error_codes::SCHEMA_VALIDATION_FAILED,
729                    format!(
730                        "recording checkpoint for node `{}` in ledger: {e}",
731                        node.name
732                    ),
733                    usage,
734                );
735            }
736            outputs.insert(node.name.clone(), result);
737        }
738
739        usage.duration_ms = started.elapsed().as_millis() as u64;
740
741        // What is "the" job result for a graph? Convention: the output of the
742        // LAST node in topological order that wasn't skipped. This exactly
743        // matches today's linear-pipeline behavior in the degenerate case (a
744        // linear chain's last node in topo order is its sole sink), and
745        // generalizes sensibly to a branching graph (exactly one path executes,
746        // so there's still a well-defined "last node that actually ran") and to
747        // a fan-in graph without branches (the join/sink node is last in topo
748        // order, since nothing depends on it).
749        //
750        // Known limitation: for a graph with two or more independent sinks (no
751        // edge between them at all — `cuttlefishd`'s daemon path genuinely
752        // accepts such graphs, unlike `cuttlefish build`, which restricts itself
753        // to linear graphs), "last in topological order" is decided by the
754        // topological sort's tie-break (alphabetical node name among ready
755        // nodes), not by any deliberate semantic answer about which sink's
756        // output should represent the job. Don't mistake that tie-break for a
757        // considered multi-sink policy — it isn't one.
758        let result = job
759            .nodes
760            .iter()
761            .rev()
762            .find_map(|n| outputs.get(&n.name).cloned());
763
764        match result {
765            Some(value) => Envelope {
766                status: JobStatus::Completed,
767                result: Some(value),
768                error: None,
769                usage,
770            },
771            None => fail(
772                error_codes::SCHEMA_VALIDATION_FAILED,
773                "every node in this job was skipped; there is no result",
774                usage,
775            ),
776        }
777    };
778
779    let ledger_status = match envelope.status {
780        JobStatus::Completed => "completed",
781        JobStatus::Failed => "failed",
782        JobStatus::Cancelled => "cancelled",
783        _ => "running", // shouldn't occur — defensive only
784    };
785    if let Err(e) = ledger.finish(ledger_status) {
786        // A ledger write failure at this final step doesn't invalidate the
787        // job's already-computed, correct result — losing it here means a
788        // wrong Interrupted-detection on next restart, not a wrong result
789        // now. Loud enough to notice, not loud enough to discard real work
790        // that already succeeded or failed for its own, unrelated reason.
791        eprintln!("warning: failed to record job {ledger_status} status in ledger: {e}");
792    }
793    envelope
794}
795
796/// Run one block to completion, returning what it produced.
797///
798/// `Err` carries a finished [`Envelope`]: a stage that fails ends the whole job,
799/// because a later stage's input is the earlier one's output and there is
800/// nothing sensible to feed it.
801/// Run one fan-out node: its block once per manifest line, each item
802/// checkpointed independently, then the results materialized for whatever
803/// consumes them downstream.
804///
805/// Returns the collection record described by
806/// [`crate::dag::fanout_collection_ty`] — deliberately not any one item's
807/// result, since downstream consumes all of them.
808///
809/// # Why the ledger is authoritative and the files are a projection
810///
811/// Items are recorded in SQLite as they conclude, and `results.jsonl` /
812/// `failures.jsonl` are written once at the end by reading those rows back.
813/// Appending to the text files as items finished would be simpler, but a
814/// crash mid-append leaves a torn final line that resume then has to
815/// reconcile against the ledger. Projecting at the end makes that class of
816/// bug unrepresentable: SQLite is already transactional, so let it be the
817/// thing that's true.
818#[allow(clippy::too_many_arguments)]
819async fn run_fanout_node(
820    engine: &Engine,
821    cache: &crate::module_cache::ModuleCache,
822    backend: &Arc<dyn InferBackend>,
823    alternates: &Alternates,
824    checks: &crate::accept::CompiledChecks,
825    node: &crate::dag::CheckedNode,
826    caps: &Capabilities,
827    handles: &mut Handles,
828    events: &mpsc::Sender<JobEvent>,
829    cancel: &CancellationToken,
830    usage: &mut Usage,
831    started: Instant,
832    index: usize,
833    total: usize,
834    ledger: &crate::ledger::Ledger,
835) -> Result<serde_json::Value, Envelope> {
836    use sha2::{Digest, Sha256};
837
838    let manifest_path = node
839        .over
840        .as_ref()
841        .expect("run_fanout_node is only called for a node with `over`");
842    let node_name = node.name.as_str();
843    // NOT `node.signature.output` — that has already been replaced with the
844    // collection record for downstream typing, so validating an item against
845    // it would reject every single item.
846    let item_output = node
847        .item_output
848        .as_ref()
849        .unwrap_or(&node.signature.output)
850        .clone();
851
852    let bail = |message: String, usage: &mut Usage| -> Envelope {
853        usage.duration_ms = started.elapsed().as_millis() as u64;
854        fail(
855            error_codes::SCHEMA_VALIDATION_FAILED,
856            message,
857            usage.clone(),
858        )
859    };
860
861    // --- Read and validate the manifest up front -------------------------
862    //
863    // A malformed manifest is an authoring error, so it fails the whole job
864    // before any item runs, rather than surfacing as N mysterious item
865    // failures partway through.
866    let bytes = std::fs::read(manifest_path).map_err(|e| {
867        bail(
868            format!(
869                "node `{node_name}`: reading fan-out manifest {}: {e}",
870                manifest_path.display()
871            ),
872            usage,
873        )
874    })?;
875
876    let text = String::from_utf8(bytes.clone()).map_err(|e| {
877        bail(
878            format!(
879                "node `{node_name}`: fan-out manifest {} is not valid UTF-8: {e}",
880                manifest_path.display()
881            ),
882            usage,
883        )
884    })?;
885
886    let mut items: Vec<serde_json::Value> = Vec::new();
887    for (i, line) in text.lines().enumerate() {
888        if line.trim().is_empty() {
889            continue;
890        }
891        match serde_json::from_str(line) {
892            Ok(v) => items.push(v),
893            Err(e) => {
894                return Err(bail(
895                    format!(
896                        "node `{node_name}`: fan-out manifest {} line {} is not valid JSON: {e}",
897                        manifest_path.display(),
898                        i + 1
899                    ),
900                    usage,
901                ))
902            }
903        }
904    }
905
906    if items.is_empty() {
907        return Err(bail(
908            format!(
909                "node `{node_name}`: fan-out manifest {} is empty — zero items almost always \
910                 means the step that produced it failed, and reducing over nothing would \
911                 silently look like success",
912                manifest_path.display()
913            ),
914            usage,
915        ));
916    }
917
918    // --- Pin this node to the manifest it ran against --------------------
919    //
920    // An item_index is only meaningful relative to one manifest, and no
921    // graph fingerprint can see an edit to the manifest *file*.
922    let digest = crate::hex::encode(Sha256::digest(&bytes));
923    match ledger.check_or_record_manifest(node_name, &digest, items.len()) {
924        Ok(Ok(())) => {}
925        Ok(Err(previous)) => {
926            return Err(bail(
927                format!(
928                    "node `{node_name}`: fan-out manifest {} has changed since this job first \
929                     ran (was {previous}, now {digest}) — recorded item indices no longer refer \
930                     to the same inputs, so resuming would pair results with the wrong items; \
931                     re-submit the job instead",
932                    manifest_path.display()
933                ),
934                usage,
935            ))
936        }
937        Err(e) => {
938            return Err(bail(
939                format!("node `{node_name}`: recording fan-out manifest digest: {e}"),
940                usage,
941            ))
942        }
943    }
944
945    // --- Run each item ---------------------------------------------------
946    let mut succeeded = 0usize;
947    let mut failed = 0usize;
948
949    for (item_index, item_input) in items.iter().enumerate() {
950        // Cancellation is checked here, not only inside run_stage: without
951        // it a cancelled 500-item campaign would keep starting new items,
952        // finishing only once the manifest ran out.
953        if cancel.is_cancelled() {
954            usage.duration_ms = started.elapsed().as_millis() as u64;
955            return Err(Envelope {
956                status: JobStatus::Cancelled,
957                result: None,
958                error: None,
959                usage: usage.clone(),
960            });
961        }
962
963        // Resume: an item that already concluded — either way — is not run
964        // again. An item that was merely in flight when a previous run died
965        // left no row, so it lands here and runs now.
966        match ledger.item_concluded(node_name, item_index) {
967            Ok(true) => {
968                if ledger
969                    .get_item_completed(node_name, item_index)
970                    .unwrap_or(None)
971                    .is_some()
972                {
973                    succeeded += 1;
974                } else {
975                    failed += 1;
976                }
977                continue;
978            }
979            Ok(false) => {}
980            Err(e) => {
981                return Err(bail(
982                    format!("node `{node_name}`: reading item {item_index} from ledger: {e}"),
983                    usage,
984                ))
985            }
986        }
987
988        let _ = events
989            .send(JobEvent::Progress(serde_json::json!({
990                "stage": index + 1,
991                "of": total,
992                "node": node_name,
993                "item": item_index,
994                "items": items.len(),
995                "succeeded": succeeded,
996                "failed": failed,
997            })))
998            .await;
999
1000        // Each item's input must satisfy what the block declared. A single
1001        // mismatched line is a data-quality problem, not an authoring one,
1002        // so it fails that item and the run continues.
1003        if !node.signature.input.matches_value(item_input) {
1004            failed += 1;
1005            let message = format!(
1006                "item {item_index} does not match the block's declared input `{}`",
1007                node.signature.input
1008            );
1009            if let Err(e) =
1010                ledger.write_item_failed(node_name, item_index, &message, Some(item_input))
1011            {
1012                return Err(bail(
1013                    format!("node `{node_name}`: recording item {item_index} failure: {e}"),
1014                    usage,
1015                ));
1016            }
1017            continue;
1018        }
1019
1020        // The same ladder the ordinary-node path uses, per item. `expected`
1021        // is the *per-item* output, and `repeat_until` is `None` because
1022        // combining it with `over` is forbidden at parse time.
1023        let ladder = Ladder {
1024            engine,
1025            cache,
1026            default_backend: backend,
1027            alternates,
1028            checks,
1029            expected: &item_output,
1030            on_fail: &node.on_fail,
1031            repeat_until: None,
1032            max_iterations: None,
1033            caps,
1034            events,
1035            cancel,
1036            started,
1037            index,
1038        };
1039
1040        match ladder
1041            .run(
1042                &node.module_bytes,
1043                node.script.as_deref(),
1044                item_input.clone(),
1045                handles,
1046                usage,
1047            )
1048            .await
1049        {
1050            Ok(value) => {
1051                succeeded += 1;
1052                if let Err(e) = ledger.write_item_completed(node_name, item_index, &value) {
1053                    return Err(bail(
1054                        format!("node `{node_name}`: recording item {item_index} result: {e}"),
1055                        usage,
1056                    ));
1057                }
1058            }
1059            // A cancelled job must not be recorded as a per-item failure —
1060            // nothing about the item was wrong, and doing so would make the
1061            // cancellation permanent across a later resume.
1062            Err(LadderError::Fatal(envelope)) => return Err(*envelope),
1063            // A fan-out item's envelope is deliberately dropped: an item
1064            // failure is recorded as text and the *job* keeps going, so
1065            // there is nothing here for an envelope to become.
1066            Err(LadderError::Exhausted {
1067                reason, escalated, ..
1068            }) => {
1069                failed += 1;
1070                let message = format!("item {item_index} {reason}");
1071                // An escalated item is still a concluded failure — it counts
1072                // toward `failed` and lands in `failures.jsonl` like any
1073                // other. The escalation row is *additional*: it is what makes
1074                // this one findable later without re-reading every job.
1075                // Both carry the item's input, so a later drain can hand
1076                // the work back. Without it an escalation names an item
1077                // index against a manifest that may since have moved, which
1078                // is not enough to act on.
1079                let recorded = if escalated {
1080                    ledger.write_escalated(node_name, Some(item_index), &message, Some(item_input))
1081                } else {
1082                    ledger.write_item_failed(node_name, item_index, &message, Some(item_input))
1083                };
1084                if let Err(e) = recorded {
1085                    return Err(bail(
1086                        format!("node `{node_name}`: recording item {item_index} failure: {e}"),
1087                        usage,
1088                    ));
1089                }
1090            }
1091        }
1092    }
1093
1094    if succeeded == 0 {
1095        // Quote the first item's actual error. "All 500 items failed" with no
1096        // cause is a dead end, and when every item fails the same way — which
1097        // is the common case — the first one is the whole story.
1098        let first_error = ledger
1099            .concluded_items(node_name)
1100            .ok()
1101            .and_then(|items| items.into_iter().find_map(|(_, _, err)| err))
1102            .unwrap_or_else(|| "no error recorded".to_string());
1103        return Err(bail(
1104            format!(
1105                "node `{node_name}`: all {failed} fan-out item(s) failed — there is nothing for \
1106                 a downstream node to reduce over. First failure: {first_error}"
1107            ),
1108            usage,
1109        ));
1110    }
1111
1112    // --- Materialize the results from the ledger -------------------------
1113    //
1114    // Named per node: a graph may legally contain more than one fan-out
1115    // node, and flat results.jsonl / failures.jsonl would clobber.
1116    let results_dir = ledger.job_dir().join("results");
1117    std::fs::create_dir_all(&results_dir).map_err(|e| {
1118        bail(
1119            format!(
1120                "node `{node_name}`: creating {}: {e}",
1121                results_dir.display()
1122            ),
1123            usage,
1124        )
1125    })?;
1126    let results_path = results_dir.join(format!("{node_name}.results.jsonl"));
1127    let failures_path = results_dir.join(format!("{node_name}.failures.jsonl"));
1128
1129    let concluded = ledger.concluded_items(node_name).map_err(|e| {
1130        bail(
1131            format!("node `{node_name}`: reading concluded items from ledger: {e}"),
1132            usage,
1133        )
1134    })?;
1135
1136    let (mut results_out, mut failures_out) = (String::new(), String::new());
1137    for (item_index, output, error) in concluded {
1138        match (output, error) {
1139            (Some(value), _) => results_out.push_str(&format!(
1140                "{}\n",
1141                serde_json::json!({"item": item_index, "result": value})
1142            )),
1143            (None, Some(message)) => failures_out.push_str(&format!(
1144                "{}\n",
1145                serde_json::json!({"item": item_index, "error": message})
1146            )),
1147            (None, None) => {}
1148        }
1149    }
1150
1151    for (path, contents) in [
1152        (&results_path, &results_out),
1153        (&failures_path, &failures_out),
1154    ] {
1155        std::fs::write(path, contents).map_err(|e| {
1156            bail(
1157                format!("node `{node_name}`: writing {}: {e}", path.display()),
1158                usage,
1159            )
1160        })?;
1161    }
1162
1163    Ok(serde_json::json!({
1164        "results_path": results_path.to_string_lossy(),
1165        "failures_path": failures_path.to_string_lossy(),
1166        "succeeded": succeeded,
1167        "failed": failed,
1168    }))
1169}
1170
1171/// Why an attempt ladder ran out of rungs.
1172enum LadderError {
1173    /// Every rung was climbed and the work still wasn't accepted.
1174    Exhausted {
1175        /// The last thing that went wrong, verbatim — this becomes the text
1176        /// a human reads in `cuttlefish escalations`, so it has to name the
1177        /// actual failing check rather than "acceptance failed".
1178        reason: String,
1179        /// Whether the ladder ended on an explicit `escalate` rung, as
1180        /// opposed to simply running out. Only the former gets an escalation
1181        /// row: the author asked for someone to be told.
1182        escalated: bool,
1183        /// The last attempt's own envelope, when the last thing that went
1184        /// wrong was the block failing rather than a check rejecting it.
1185        ///
1186        /// Carried so the error *code* survives the ladder. Without it every
1187        /// `wasm_trap` and `capability_denied` would reach the caller
1188        /// flattened into `schema_validation_failed`, which is both wrong and
1189        /// a silent downgrade for the many nodes that declare no `on_fail` at
1190        /// all and should behave exactly as they did before ladders existed.
1191        envelope: Option<Box<Envelope>>,
1192    },
1193    /// The job as a whole must stop — cancellation, or a host-level error
1194    /// that has nothing to do with this node's output being wrong. Never
1195    /// retried, because retrying a cancellation is how a cancelled job comes
1196    /// back to life.
1197    Fatal(Box<Envelope>),
1198}
1199
1200/// Everything the ladder needs that doesn't change between attempts.
1201///
1202/// A struct rather than a dozen more parameters: `run_stage` already carries
1203/// thirteen, and the ladder wraps it from two call sites (one node, one
1204/// fan-out item) that must behave identically. Bundling the invariant part is
1205/// what makes "identically" checkable by eye.
1206struct Ladder<'a> {
1207    engine: &'a Engine,
1208    cache: &'a crate::module_cache::ModuleCache,
1209    /// The job's own backend: where the first attempt goes, and — always —
1210    /// where judges are asked. A judge run on the rerouted model would be
1211    /// grading its own work.
1212    default_backend: &'a Arc<dyn InferBackend>,
1213    alternates: &'a Alternates,
1214    checks: &'a crate::accept::CompiledChecks,
1215    /// What this attempt's output must structurally be. For a fan-out item
1216    /// this is the block's per-item output, *not* the collection the node
1217    /// presents downstream.
1218    expected: &'a cuttlefish_abi::Ty,
1219    on_fail: &'a [cuttlefish_core::graph::Rung],
1220    /// The node's bounded loop, if it has one. Held here, rather than around
1221    /// the ladder, because one *attempt* has to mean one whole run of the
1222    /// node: retrying a node that reached `max_iterations` without finishing
1223    /// means running its loop again from the top, not resuming it.
1224    repeat_until: Option<&'a str>,
1225    /// Required alongside `repeat_until`, enforced at parse time.
1226    max_iterations: Option<u32>,
1227    caps: &'a Capabilities,
1228    events: &'a mpsc::Sender<JobEvent>,
1229    cancel: &'a CancellationToken,
1230    started: Instant,
1231    index: usize,
1232}
1233
1234impl Ladder<'_> {
1235    /// Run the work, climbing `on_fail` until something is accepted or the
1236    /// ladder runs out.
1237    ///
1238    /// Nothing here writes to the ledger. That is the whole point: a value
1239    /// that is about to be retried is not a conclusion, and recording it
1240    /// would make a transient rejection permanent across a resume.
1241    #[allow(clippy::too_many_arguments)]
1242    async fn run(
1243        &self,
1244        module_bytes: &[u8],
1245        script: Option<&str>,
1246        input: serde_json::Value,
1247        handles: &mut Handles,
1248        usage: &mut Usage,
1249    ) -> Result<serde_json::Value, LadderError> {
1250        use cuttlefish_core::graph::Rung;
1251
1252        let mut backend = self.default_backend.clone();
1253        let mut rung = 0usize;
1254        let mut retries_left = 0u32;
1255
1256        'ladder: loop {
1257            let (reason, envelope) = match self
1258                .attempt(
1259                    module_bytes,
1260                    script,
1261                    input.clone(),
1262                    &backend,
1263                    handles,
1264                    usage,
1265                )
1266                .await
1267            {
1268                Ok(value) => return Ok(value),
1269                Err(LadderError::Fatal(e)) => return Err(LadderError::Fatal(e)),
1270                Err(LadderError::Exhausted {
1271                    reason, envelope, ..
1272                }) => (reason, envelope),
1273            };
1274
1275            // Decide the next move. This inner loop only ever advances
1276            // `rung`, so it cannot spin: a `Retry` sets a budget and falls
1277            // straight back to the attempt, and every other arm either
1278            // returns or re-attempts.
1279            loop {
1280                if retries_left > 0 {
1281                    retries_left -= 1;
1282                    continue 'ladder;
1283                }
1284                match self.on_fail.get(rung) {
1285                    None => {
1286                        return Err(LadderError::Exhausted {
1287                            reason,
1288                            escalated: false,
1289                            envelope,
1290                        })
1291                    }
1292                    Some(Rung::Retry(n)) => {
1293                        rung += 1;
1294                        retries_left = *n;
1295                    }
1296                    Some(Rung::Reroute(model)) => {
1297                        rung += 1;
1298                        match self.alternates.get(model) {
1299                            Some(b) => {
1300                                backend = b.clone();
1301                                continue 'ladder;
1302                            }
1303                            // Startup resolution should have caught this, so
1304                            // reaching here is a bug — but failing the node
1305                            // beats taking the daemon down mid-campaign, and
1306                            // the reason says exactly what happened.
1307                            None => {
1308                                return Err(LadderError::Exhausted {
1309                                    reason: format!(
1310                                        "reroute names model `{model}`, which was not resolved \
1311                                         at startup (after: {reason})"
1312                                    ),
1313                                    escalated: false,
1314                                    // Not the block's fault — this is an
1315                                    // authoring/resolution error, so it keeps
1316                                    // its own code rather than the last
1317                                    // attempt's.
1318                                    envelope: None,
1319                                });
1320                            }
1321                        }
1322                    }
1323                    Some(Rung::Escalate) => {
1324                        return Err(LadderError::Exhausted {
1325                            reason,
1326                            escalated: true,
1327                            envelope,
1328                        })
1329                    }
1330                }
1331            }
1332        }
1333    }
1334
1335    /// One attempt: run the block, then hold its output to the declared type
1336    /// and to every `accept` check, in that order.
1337    ///
1338    /// Ordering is deliberate and cost-driven. The type check is free, a
1339    /// schema costs a file's worth of validation, and a judge costs a whole
1340    /// inference — so structurally broken output never pays for a judge, and
1341    /// a judge is never asked to grade something it would grade incoherently.
1342    #[allow(clippy::too_many_arguments)]
1343    async fn attempt(
1344        &self,
1345        module_bytes: &[u8],
1346        script: Option<&str>,
1347        input: serde_json::Value,
1348        backend: &Arc<dyn InferBackend>,
1349        handles: &mut Handles,
1350        usage: &mut Usage,
1351    ) -> Result<serde_json::Value, LadderError> {
1352        // A check said no. The block itself is fine, so there is no envelope
1353        // to preserve.
1354        let rejected = |reason: String| LadderError::Exhausted {
1355            reason,
1356            escalated: false,
1357            envelope: None,
1358        };
1359
1360        // The bounded loop. A node without `repeat_until` takes the `None`
1361        // arm on its first pass and runs exactly once.
1362        let mut current = input.clone();
1363        let mut iterations: u32 = 0;
1364        let value = loop {
1365            let produced = match run_stage(
1366                self.engine,
1367                self.cache,
1368                backend,
1369                module_bytes,
1370                current.clone(),
1371                script,
1372                self.caps,
1373                handles,
1374                self.events,
1375                self.cancel,
1376                usage,
1377                self.started,
1378                self.index,
1379            )
1380            .await
1381            {
1382                Ok(v) => v,
1383                // A cancelled job stops the ladder dead; anything else is the
1384                // block failing, which is exactly what a ladder exists to
1385                // survive.
1386                Err(envelope) if envelope.status == JobStatus::Cancelled => {
1387                    return Err(LadderError::Fatal(Box::new(envelope)))
1388                }
1389                Err(envelope) => {
1390                    let reason = envelope
1391                        .error
1392                        .as_ref()
1393                        .map(|e| format!("{}: {}", e.code, e.message))
1394                        .unwrap_or_else(|| "failed with no error detail".to_string());
1395                    return Err(LadderError::Exhausted {
1396                        reason,
1397                        escalated: false,
1398                        envelope: Some(Box::new(envelope)),
1399                    });
1400                }
1401            };
1402
1403            let Some(field) = self.repeat_until else {
1404                break produced;
1405            };
1406            iterations += 1;
1407            if produced.get(field).and_then(|v| v.as_str()) == Some("done") {
1408                break produced;
1409            }
1410            let max = self
1411                .max_iterations
1412                .expect("repeat_until requires max_iterations, enforced at parse time");
1413            if iterations >= max {
1414                return Err(rejected(format!(
1415                    "did not reach repeat_until=\"done\" within max_iterations={max}"
1416                )));
1417            }
1418            current = produced;
1419        };
1420
1421        if !self.expected.matches_value(&value) {
1422            return Err(rejected(format!(
1423                "produced {value}, which doesn't match the declared output `{}`",
1424                self.expected
1425            )));
1426        }
1427
1428        if let Err(why) = self.checks.check_schemas(&value) {
1429            return Err(rejected(why));
1430        }
1431
1432        match self
1433            .checks
1434            .run_judges(&input, &value, self.default_backend, self.alternates)
1435            .await
1436        {
1437            crate::accept::JudgeVerdict::Accepted => Ok(value),
1438            crate::accept::JudgeVerdict::Rejected(why) => Err(rejected(format!("judge: {why}"))),
1439            // A broken grader is worth retrying — the next attempt may get a
1440            // parseable verdict — but it is reported as what it is, so nobody
1441            // reads the escalation as "the model said no".
1442            crate::accept::JudgeVerdict::Unusable(why) => {
1443                Err(rejected(format!("judge gave no usable verdict: {why}")))
1444            }
1445        }
1446    }
1447}
1448
1449#[allow(clippy::too_many_arguments)]
1450async fn run_stage(
1451    engine: &Engine,
1452    cache: &crate::module_cache::ModuleCache,
1453    backend: &Arc<dyn InferBackend>,
1454    module_bytes: &[u8],
1455    input: serde_json::Value,
1456    script: Option<&str>,
1457    caps: &Capabilities,
1458    handles: &mut Handles,
1459    events: &mpsc::Sender<JobEvent>,
1460    cancel: &CancellationToken,
1461    usage: &mut Usage,
1462    started: Instant,
1463    stage_index: usize,
1464) -> Result<serde_json::Value, Envelope> {
1465    // Naming the stage turns "the job failed" into "the second block failed",
1466    // which is the difference between a usable error and a hunt.
1467    let blame = |message: String| -> String {
1468        if stage_index == 0 {
1469            message
1470        } else {
1471            format!("block {} of the pipeline: {message}", stage_index + 1)
1472        }
1473    };
1474    // Documents are read from their path rather than their descriptor — both
1475    // extraction and rendering want a file. Kept beside the handle table so the
1476    // two are dropped together at the end of the job.
1477    let mut doc_paths: std::collections::HashMap<u32, std::path::PathBuf> =
1478        std::collections::HashMap::new();
1479    // Extracted text, memoized per handle for this stage. Scoped exactly as
1480    // `doc_paths` is: a handle is job-scoped, so the text keyed by it can be
1481    // too, and both are dropped together when the stage ends.
1482    let mut doc_texts: std::collections::HashMap<u32, std::sync::Arc<String>> =
1483        std::collections::HashMap::new();
1484    // Page-tree counts, memoized the same way and for the same reason.
1485    let mut doc_pages: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();
1486
1487    let mut guest = match Guest::new(engine, cache, module_bytes) {
1488        Ok(g) => g,
1489        Err(e) => {
1490            return Err(fail(
1491                error_codes::WASM_TRAP,
1492                blame(e.to_string()),
1493                usage.clone(),
1494            ))
1495        }
1496    };
1497
1498    // A Script-kind stage's `module_bytes` is always the shared interpreter
1499    // (see `pipeline::resolve_and_load`), which expects its script text
1500    // wrapped alongside the real job input — the interpreter itself never
1501    // receives the raw input directly, and this is the one place per job
1502    // where that wrapping actually happens, since the script is fixed at
1503    // catalog time but the input is only known per job.
1504    let input = match script {
1505        Some(script) => serde_json::json!({
1506            "__cuttlefish_script": script,
1507            "input": input,
1508        }),
1509        None => input,
1510    };
1511
1512    let mut command = match guest.call_init(&input) {
1513        Ok(c) => c,
1514        Err(e) => {
1515            return Err(fail(
1516                error_codes::WASM_TRAP,
1517                blame(e.to_string()),
1518                usage.clone(),
1519            ))
1520        }
1521    };
1522
1523    loop {
1524        if cancel.is_cancelled() {
1525            usage.duration_ms = started.elapsed().as_millis() as u64;
1526            return Err(cancelled(usage.clone(), "job cancelled"));
1527        }
1528
1529        let event = match command {
1530            // Ends this stage, not the job: the value becomes the next
1531            // block's input, or the job's result if this was the last.
1532            Command::Done { result } => return Ok(result),
1533            Command::Fail { code, message } => {
1534                usage.duration_ms = started.elapsed().as_millis() as u64;
1535                return Err(fail(&code, blame(message), usage.clone()));
1536            }
1537            Command::Emit { progress } => {
1538                let _ = events.send(JobEvent::Progress(progress)).await;
1539                Event::Emitted
1540            }
1541
1542            // The capability check lives here, at Open, and nowhere else. Slice
1543            // takes a handle rather than a path, and handles are job-scoped, so
1544            // there is no second place a path can enter the system.
1545            Command::Open { path } => {
1546                let p = std::path::PathBuf::from(&path);
1547                if !caps.allows_read(&p) {
1548                    usage.duration_ms = started.elapsed().as_millis() as u64;
1549                    return Err(fail(
1550                        error_codes::CAPABILITY_DENIED,
1551                        format!("read not permitted: {path}"),
1552                        usage.clone(),
1553                    ));
1554                }
1555                match handles.open(&p) {
1556                    Ok((handle, len, kind)) => {
1557                        // A PDF's page count and text layer need the whole file,
1558                        // which the handle layer deliberately does not read. Ask
1559                        // the document layer, and fall back to the plain kind if
1560                        // it cannot answer — a malformed PDF is still a file a
1561                        // block may want to read bytes from.
1562                        let kind = match kind {
1563                            cuttlefish_abi::MediaKind::Document { .. } => {
1564                                match crate::documents::inspect(&p) {
1565                                    Ok(info) => cuttlefish_abi::MediaKind::Document {
1566                                        pages: info.pages,
1567                                        has_text_layer: info.has_text_layer,
1568                                    },
1569                                    Err(_) => cuttlefish_abi::MediaKind::Binary,
1570                                }
1571                            }
1572                            other => other,
1573                        };
1574                        // Remember the path: rendering and text extraction work
1575                        // from a file, not from the open descriptor.
1576                        doc_paths.insert(handle, p.clone());
1577                        Event::Opened { handle, len, kind }
1578                    }
1579                    Err(e) => {
1580                        usage.duration_ms = started.elapsed().as_millis() as u64;
1581                        return Err(fail(
1582                            error_codes::CAPABILITY_DENIED,
1583                            e.to_string(),
1584                            usage.clone(),
1585                        ));
1586                    }
1587                }
1588            }
1589
1590            Command::Slice {
1591                handle,
1592                offset,
1593                len,
1594            } => match handles.slice(handle, offset, len) {
1595                Ok(w) => Event::Sliced {
1596                    text: w.text,
1597                    next_offset: w.next_offset,
1598                },
1599                Err(e) => {
1600                    usage.duration_ms = started.elapsed().as_millis() as u64;
1601                    return Err(fail(
1602                        error_codes::CAPABILITY_DENIED,
1603                        e.to_string(),
1604                        usage.clone(),
1605                    ));
1606                }
1607            },
1608
1609            Command::SliceBytes {
1610                handle,
1611                offset,
1612                len,
1613            } => match handles.slice_bytes(handle, offset, len) {
1614                Ok((bytes, next_offset)) => {
1615                    use base64::Engine;
1616                    Event::SlicedBytes {
1617                        bytes_base64: base64::engine::general_purpose::STANDARD.encode(&bytes),
1618                        next_offset,
1619                    }
1620                }
1621                Err(e) => {
1622                    usage.duration_ms = started.elapsed().as_millis() as u64;
1623                    return Err(fail(
1624                        error_codes::CAPABILITY_DENIED,
1625                        e.to_string(),
1626                        usage.clone(),
1627                    ));
1628                }
1629            },
1630
1631            Command::PageText { handle, page } => {
1632                let Some(path) = doc_paths.get(&handle).cloned() else {
1633                    usage.duration_ms = started.elapsed().as_millis() as u64;
1634                    return Err(fail(
1635                        error_codes::CAPABILITY_DENIED,
1636                        format!("no such handle: {handle}"),
1637                        usage.clone(),
1638                    ));
1639                };
1640                // Extract once per handle, not once per page. The old shape
1641                // re-extracted the whole document on every call, which made
1642                // a page walk quadratic: a 342-page filing meant 342 full
1643                // extractions, and across thousands of documents that is not
1644                // slow but unrunnable.
1645                let text = match doc_text(&mut doc_texts, handle, &path) {
1646                    Ok(t) => t,
1647                    Err(e) => {
1648                        usage.duration_ms = started.elapsed().as_millis() as u64;
1649                        return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
1650                    }
1651                };
1652                // The page-tree count is what makes the error honest: it can
1653                // say "1 addressable segment, 227 pages in the tree" rather
1654                // than blaming a scan.
1655                //
1656                // From `page_count`, never `inspect`: inspect also answers
1657                // has_text_layer, which it can only do by extracting the
1658                // whole document. Reaching for it here re-introduced the
1659                // very quadratic walk the text cache had just removed.
1660                let page_tree_count = doc_page_count(&mut doc_pages, handle, &path);
1661                match crate::documents::page_text_from(&text, page, page_tree_count) {
1662                    Ok(text) => Event::PageTexted { text },
1663                    Err(e) => {
1664                        usage.duration_ms = started.elapsed().as_millis() as u64;
1665                        return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
1666                    }
1667                }
1668            }
1669
1670            Command::DocumentText { handle } => {
1671                let Some(path) = doc_paths.get(&handle).cloned() else {
1672                    usage.duration_ms = started.elapsed().as_millis() as u64;
1673                    return Err(fail(
1674                        error_codes::CAPABILITY_DENIED,
1675                        format!("no such handle: {handle}"),
1676                        usage.clone(),
1677                    ));
1678                };
1679                match doc_text(&mut doc_texts, handle, &path) {
1680                    // Cloned out of the Arc only here, at the point the text
1681                    // actually crosses to the guest.
1682                    Ok(text) => Event::PageTexted {
1683                        text: text.as_ref().clone(),
1684                    },
1685                    Err(e) => {
1686                        usage.duration_ms = started.elapsed().as_millis() as u64;
1687                        return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
1688                    }
1689                }
1690            }
1691
1692            Command::PageImage { handle, page } => {
1693                let Some(path) = doc_paths.get(&handle).cloned() else {
1694                    usage.duration_ms = started.elapsed().as_millis() as u64;
1695                    return Err(fail(
1696                        error_codes::CAPABILITY_DENIED,
1697                        format!("no such handle: {handle}"),
1698                        usage.clone(),
1699                    ));
1700                };
1701                // A rendered page becomes a handle like any other, so it can be
1702                // named in Infer exactly as a file-backed image would be.
1703                match crate::documents::render_page(&path, page, RENDER_WIDTH) {
1704                    Ok(png) => {
1705                        let (handle, len) = handles.insert_bytes(
1706                            png,
1707                            cuttlefish_abi::MediaKind::Image {
1708                                format: "png".into(),
1709                            },
1710                        );
1711                        Event::PageImaged { handle, len }
1712                    }
1713                    Err(e) => {
1714                        usage.duration_ms = started.elapsed().as_millis() as u64;
1715                        return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
1716                    }
1717                }
1718            }
1719
1720            Command::ImageOp { handle, op } => {
1721                // Pixels stay host-side: the guest names a handle, gets a
1722                // new handle, and never sees the bytes — same shape as
1723                // PageImage, so a transformed image is usable exactly
1724                // wherever a file-backed one is.
1725                let bytes = match handles.read_all(handle) {
1726                    Ok(b) => b,
1727                    Err(e) => {
1728                        usage.duration_ms = started.elapsed().as_millis() as u64;
1729                        return Err(fail(
1730                            error_codes::CAPABILITY_DENIED,
1731                            e.to_string(),
1732                            usage.clone(),
1733                        ));
1734                    }
1735                };
1736                match crate::images::apply(&bytes, &op) {
1737                    Ok(png) => {
1738                        let (handle, len) = handles.insert_bytes(
1739                            png,
1740                            cuttlefish_abi::MediaKind::Image {
1741                                format: "png".into(),
1742                            },
1743                        );
1744                        Event::PageImaged { handle, len }
1745                    }
1746                    Err(e) => {
1747                        usage.duration_ms = started.elapsed().as_millis() as u64;
1748                        return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
1749                    }
1750                }
1751            }
1752
1753            Command::Infer {
1754                prompt,
1755                max_tokens,
1756                images,
1757            } => {
1758                // Images are named by handle; the host loads the bytes, so they
1759                // never pass through guest memory.
1760                // Refuse rather than drop. Sending images to a backend that
1761                // cannot use them produces a confident answer about nothing,
1762                // which reads as a bad model rather than a misconfigured job —
1763                // and the caller has no way to tell the difference.
1764                if !images.is_empty() && !backend.supports_images() {
1765                    usage.duration_ms = started.elapsed().as_millis() as u64;
1766                    return Err(fail(
1767                        error_codes::UNSUPPORTED,
1768                        format!(
1769                            "this job supplied {} image(s), but the backend serving `{}` cannot \
1770                             accept them. Use a vision-capable model through the `ollama` \
1771                             provider, or change the block to send text only.",
1772                            images.len(),
1773                            backend.model_name()
1774                        ),
1775                        usage.clone(),
1776                    ));
1777                }
1778                let mut image_bytes = Vec::with_capacity(images.len());
1779                for handle in &images {
1780                    match handles.read_all(*handle) {
1781                        Ok(bytes) => image_bytes.push(bytes),
1782                        Err(e) => {
1783                            usage.duration_ms = started.elapsed().as_millis() as u64;
1784                            return Err(fail(
1785                                error_codes::CAPABILITY_DENIED,
1786                                e.to_string(),
1787                                usage.clone(),
1788                            ));
1789                        }
1790                    }
1791                }
1792
1793                // Tokens must reach the guest *while* generation runs, because
1794                // the guest's Stop verdict is what ends it early. The wasmtime
1795                // Store is !Sync and cannot be touched from inside the backend's
1796                // callback, so a channel carries tokens out and a shared flag
1797                // carries the verdict back — without sharing the Store.
1798                let (tx, mut rx) = mpsc::unbounded_channel::<String>();
1799                let stop = Arc::new(AtomicBool::new(false));
1800                let sink_stop = stop.clone();
1801                let mut sink = move |t: &str| {
1802                    tx.send(t.to_string()).is_ok() && !sink_stop.load(Ordering::Relaxed)
1803                };
1804
1805                let mut trap: Option<String> = None;
1806                let outcome: Option<anyhow::Result<InferResult>> = {
1807                    let request = InferRequest {
1808                        prompt: &prompt,
1809                        max_tokens,
1810                        images: &image_bytes,
1811                    };
1812                    let infer = backend.infer(request, &mut sink);
1813                    tokio::pin!(infer);
1814                    loop {
1815                        tokio::select! {
1816                            biased;
1817                            _ = cancel.cancelled() => break None,
1818                            Some(tok) = rx.recv() => {
1819                                let _ = events.send(JobEvent::Token(tok.clone())).await;
1820                                match guest.call_on_token(&tok) {
1821                                    Ok(true) => {}
1822                                    Ok(false) => stop.store(true, Ordering::Relaxed),
1823                                    Err(e) => {
1824                                        trap = Some(e.to_string());
1825                                        break None;
1826                                    }
1827                                }
1828                            }
1829                            r = &mut infer => break Some(r),
1830                        }
1831                    }
1832                };
1833
1834                if let Some(message) = trap {
1835                    usage.duration_ms = started.elapsed().as_millis() as u64;
1836                    return Err(fail(error_codes::WASM_TRAP, message, usage.clone()));
1837                }
1838
1839                // Tokens generated in the same poll as the last one are still
1840                // queued; forward them so the stream is complete.
1841                while let Ok(tok) = rx.try_recv() {
1842                    let _ = events.send(JobEvent::Token(tok)).await;
1843                }
1844
1845                match outcome {
1846                    None => {
1847                        usage.duration_ms = started.elapsed().as_millis() as u64;
1848                        return Err(cancelled(usage.clone(), "cancelled during inference"));
1849                    }
1850                    Some(Err(e)) => {
1851                        usage.duration_ms = started.elapsed().as_millis() as u64;
1852                        return Err(fail(
1853                            error_codes::MODEL_LOAD_FAILED,
1854                            e.to_string(),
1855                            usage.clone(),
1856                        ));
1857                    }
1858                    Some(Ok(r)) => {
1859                        usage.tokens_in += r.tokens_in;
1860                        usage.tokens_out += r.tokens_out;
1861                        Event::InferDone {
1862                            text: r.text,
1863                            tokens_out: r.tokens_out,
1864                        }
1865                    }
1866                }
1867            }
1868        };
1869
1870        command = match guest.call_step(&event) {
1871            Ok(c) => c,
1872            Err(e) => {
1873                usage.duration_ms = started.elapsed().as_millis() as u64;
1874                return Err(fail(
1875                    error_codes::WASM_TRAP,
1876                    blame(e.to_string()),
1877                    usage.clone(),
1878                ));
1879            }
1880        };
1881    }
1882}
1883
1884/// Extracted text for `handle`, extracting only on the first ask.
1885///
1886/// A `String` behind an `Arc` because a large PDF's text is megabytes and
1887/// every page of a walk would otherwise clone it.
1888fn doc_text(
1889    cache: &mut std::collections::HashMap<u32, std::sync::Arc<String>>,
1890    handle: u32,
1891    path: &std::path::Path,
1892) -> anyhow::Result<std::sync::Arc<String>> {
1893    if let Some(hit) = cache.get(&handle) {
1894        return Ok(hit.clone());
1895    }
1896    let text = std::sync::Arc::new(crate::documents::document_text(path)?);
1897    cache.insert(handle, text.clone());
1898    Ok(text)
1899}
1900
1901/// The page-tree count for `handle`, read once.
1902///
1903/// Zero when the document cannot be loaded: this value exists only to make
1904/// an error message more informative, so failing to obtain it must never
1905/// turn into a second failure.
1906fn doc_page_count(
1907    cache: &mut std::collections::HashMap<u32, u32>,
1908    handle: u32,
1909    path: &std::path::Path,
1910) -> u32 {
1911    if let Some(hit) = cache.get(&handle) {
1912        return *hit;
1913    }
1914    let count = crate::documents::page_count(path).unwrap_or(0);
1915    cache.insert(handle, count);
1916    count
1917}