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}