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