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}