Skip to main content

machi_workflow/
engine.rs

1//! Rhai workflow engine with journaled host calls.
2
3use std::sync::{Arc, Mutex};
4
5use rhai::{Dynamic, Engine, EvalAltResult, Position};
6use tokio::sync::{mpsc, oneshot};
7use tokio_util::sync::CancellationToken;
8
9use crate::host::{AgentOpts, HostError, WorkflowHostRequest};
10use crate::journal::{
11    Journal, JournalError, host_error_message, host_error_sentinel, request_hash,
12};
13use crate::run::{PauseKind, WorkflowOutcome};
14use crate::{MAX_HOST_CALLS, MAX_PARALLEL};
15
16/// Rhai expression nesting limits (statement depth, expression depth).
17const RHAI_MAX_EXPR_DEPTH_STMT: usize = 128;
18const RHAI_MAX_EXPR_DEPTH_EXPR: usize = 64;
19/// Max Rhai string size (bytes).
20const RHAI_MAX_STRING_SIZE: usize = 16 * 1024 * 1024;
21/// Max Rhai array / map length.
22const RHAI_MAX_ARRAY_MAP_SIZE: usize = 64 * 1024;
23
24/// Parameters for [`run_workflow`].
25#[derive(Debug)]
26pub struct WorkflowRunParams {
27    /// Script source.
28    pub script: String,
29    /// Bound `args` global.
30    pub args: serde_json::Value,
31    /// Journal (may already contain entries for resume).
32    pub journal: Journal,
33    /// Host channel.
34    pub host_tx: mpsc::UnboundedSender<WorkflowHostRequest>,
35    /// Cancellation.
36    pub cancel: CancellationToken,
37    /// Max Rhai operations.
38    pub max_ops: u64,
39}
40
41impl WorkflowRunParams {
42    /// Default max ops.
43    pub const DEFAULT_MAX_OPS: u64 = 100_000_000;
44}
45
46#[derive(Debug, Clone)]
47enum ControlToken {
48    Complete(serde_json::Value),
49    Pause(PauseKind, String),
50    Budget(String),
51    Cancelled,
52    Fatal(String),
53}
54
55struct Ctx {
56    host_tx: mpsc::UnboundedSender<WorkflowHostRequest>,
57    journal: Journal,
58    seq: u64,
59}
60
61impl Ctx {
62    fn next_seq(&mut self) -> Result<u64, Box<EvalAltResult>> {
63        if self.seq >= MAX_HOST_CALLS {
64            return Err(terminated(ControlToken::Fatal(
65                "workflow exceeded max host calls".into(),
66            )));
67        }
68        let seq = self.seq;
69        self.seq += 1;
70        Ok(seq)
71    }
72}
73
74type ScriptResult<T> = Result<T, Box<EvalAltResult>>;
75
76/// Run a workflow script to a terminal outcome.
77#[must_use]
78pub fn run_workflow(params: WorkflowRunParams) -> WorkflowOutcome {
79    let WorkflowRunParams {
80        script,
81        args,
82        journal,
83        host_tx,
84        cancel,
85        max_ops,
86    } = params;
87
88    let ctx = Arc::new(Mutex::new(Ctx {
89        host_tx,
90        journal,
91        seq: 0,
92    }));
93
94    let mut engine = Engine::new();
95    engine.set_max_operations(max_ops);
96    engine.set_max_call_levels(64);
97    engine.set_max_expr_depths(RHAI_MAX_EXPR_DEPTH_STMT, RHAI_MAX_EXPR_DEPTH_EXPR);
98    engine.set_max_string_size(RHAI_MAX_STRING_SIZE);
99    engine.set_max_array_size(RHAI_MAX_ARRAY_MAP_SIZE);
100    engine.set_max_map_size(RHAI_MAX_ARRAY_MAP_SIZE);
101    engine.set_module_resolver(rhai::module_resolvers::DummyModuleResolver::new());
102    engine.disable_symbol("eval");
103    engine.register_fn("timestamp", || -> ScriptResult<()> {
104        Err(runtime_error(
105            "timestamp() is unavailable: workflows must be deterministic",
106        ))
107    });
108    engine.register_fn("sleep", |_s: i64| -> ScriptResult<()> {
109        Err(runtime_error("sleep() is unavailable in workflow scripts"))
110    });
111
112    let cancel_flag = cancel.clone();
113    engine.on_progress(move |_| {
114        if cancel_flag.is_cancelled() {
115            Some(Dynamic::from(ControlToken::Cancelled))
116        } else {
117            None
118        }
119    });
120
121    register_fns(&mut engine, &ctx);
122
123    let mut scope = rhai::Scope::new();
124    let args_dyn = match rhai::serde::to_dynamic(&args) {
125        Ok(d) => d,
126        Err(e) => {
127            return WorkflowOutcome::Failed {
128                error: format!("invalid args: {e}"),
129            };
130        }
131    };
132    scope.push_dynamic("args", args_dyn);
133
134    match engine.eval_with_scope::<Dynamic>(&mut scope, &script) {
135        Ok(value) => WorkflowOutcome::Completed {
136            result: dynamic_to_value(value),
137        },
138        Err(err) => outcome_from_error(*err),
139    }
140}
141
142fn register_fns(engine: &mut Engine, ctx: &Arc<Mutex<Ctx>>) {
143    register_agent_fns(engine, ctx);
144    register_notify_fns(engine, ctx);
145    register_io_fns(engine, ctx);
146    register_control_fns(engine, ctx);
147}
148
149fn register_agent_fns(engine: &mut Engine, ctx: &Arc<Mutex<Ctx>>) {
150    let c = Arc::clone(ctx);
151    engine.register_fn("agent", move |prompt: &str| -> ScriptResult<Dynamic> {
152        spawn_agent(
153            &c,
154            AgentOpts {
155                prompt: prompt.to_owned(),
156                ..AgentOpts::default()
157            },
158        )
159    });
160
161    let c = Arc::clone(ctx);
162    engine.register_fn(
163        "agent",
164        move |prompt: &str, opts: rhai::Map| -> ScriptResult<Dynamic> {
165            let mut agent_opts = agent_opts_from_map(opts)?;
166            if agent_opts.prompt.is_empty() {
167                agent_opts.prompt = prompt.to_owned();
168            }
169            spawn_agent(&c, agent_opts)
170        },
171    );
172
173    let c = Arc::clone(ctx);
174    engine.register_fn(
175        "parallel",
176        move |items: rhai::Array| -> ScriptResult<rhai::Array> { spawn_agents_parallel(&c, items) },
177    );
178}
179
180fn register_notify_fns(engine: &mut Engine, ctx: &Arc<Mutex<Ctx>>) {
181    let c = Arc::clone(ctx);
182    engine.register_fn("phase", move |title: &str| {
183        fire_notify(&c, |replayed| WorkflowHostRequest::Phase {
184            title: title.to_owned(),
185            replayed,
186        });
187    });
188
189    let c = Arc::clone(ctx);
190    engine.register_fn("log", move |message: &str| {
191        fire_notify(&c, |replayed| WorkflowHostRequest::Log {
192            message: message.to_owned(),
193            replayed,
194        });
195    });
196
197    // `print` / `debug` map to the same host log channel (deterministic, non-journaled).
198    let c = Arc::clone(ctx);
199    engine.register_fn("print", move |message: &str| {
200        fire_notify(&c, |replayed| WorkflowHostRequest::Log {
201            message: message.to_owned(),
202            replayed,
203        });
204    });
205
206    let c = Arc::clone(ctx);
207    engine.register_fn("debug", move |message: &str| {
208        fire_notify(&c, |replayed| WorkflowHostRequest::Log {
209            message: format!("debug: {message}"),
210            replayed,
211        });
212    });
213
214    let c = Arc::clone(ctx);
215    engine.register_fn("telemetry_event", move |name: &str, fields: rhai::Map| {
216        fire_notify(&c, |replayed| WorkflowHostRequest::Telemetry {
217            name: name.to_owned(),
218            fields: dynamic_to_value(Dynamic::from_map(fields)),
219            replayed,
220        });
221    });
222}
223
224fn fire_notify(ctx: &Arc<Mutex<Ctx>>, make: impl FnOnce(bool) -> WorkflowHostRequest) {
225    let (tx, replaying) = {
226        let g = ctx
227            .lock()
228            .unwrap_or_else(std::sync::PoisonError::into_inner);
229        (g.host_tx.clone(), g.journal.covers(g.seq))
230    };
231    let _ = tx.send(make(replaying));
232}
233
234fn register_io_fns(engine: &mut Engine, ctx: &Arc<Mutex<Ctx>>) {
235    let c = Arc::clone(ctx);
236    engine.register_fn(
237        "write_scratch_file",
238        move |name: &str, content: &str| -> ScriptResult<String> {
239            host_string_call(
240                &c,
241                "write_scratch_file",
242                serde_json::json!({ "name": name, "content": content }),
243                |reply| WorkflowHostRequest::WriteScratchFile {
244                    name: name.to_owned(),
245                    content: content.to_owned(),
246                    reply,
247                },
248            )
249        },
250    );
251
252    let c = Arc::clone(ctx);
253    engine.register_fn(
254        "read_scratch_file",
255        move |name: &str| -> ScriptResult<String> {
256            host_string_call(
257                &c,
258                "read_scratch_file",
259                serde_json::json!({ "name": name }),
260                |reply| WorkflowHostRequest::ReadScratchFile {
261                    name: name.to_owned(),
262                    reply,
263                },
264            )
265        },
266    );
267
268    let c = Arc::clone(ctx);
269    engine.register_fn(
270        "render_template",
271        move |name: &str, vars: Dynamic| -> ScriptResult<String> {
272            let vars_v = dynamic_to_value(vars);
273            let name_owned = name.to_owned();
274            host_string_call(
275                &c,
276                "render_template",
277                serde_json::json!({ "name": name_owned, "vars": vars_v }),
278                move |reply| WorkflowHostRequest::RenderTemplate {
279                    name: name_owned,
280                    vars: vars_v,
281                    reply,
282                },
283            )
284        },
285    );
286
287    let c = Arc::clone(ctx);
288    engine.register_fn(
289        "git_diff_since",
290        move |commit: &str| -> ScriptResult<String> {
291            host_string_call(
292                &c,
293                "git_diff_since",
294                serde_json::json!({ "commit": commit }),
295                |reply| WorkflowHostRequest::GitDiffSince {
296                    commit: commit.to_owned(),
297                    reply,
298                },
299            )
300        },
301    );
302
303    let c = Arc::clone(ctx);
304    engine.register_fn("budget", move || -> ScriptResult<Dynamic> {
305        let (reply_tx, reply_rx) = oneshot::channel();
306        {
307            let g = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
308            g.host_tx
309                .send(WorkflowHostRequest::BudgetQuery { reply: reply_tx })
310                .map_err(|_| {
311                    terminated(ControlToken::Fatal("workflow host channel closed".into()))
312                })?;
313        }
314        let state = reply_rx
315            .blocking_recv()
316            .map_err(|_| terminated(ControlToken::Fatal("workflow host dropped reply".into())))?
317            .map_err(|e| runtime_error(e.to_string()))?;
318        let value = serde_json::to_value(state).unwrap_or(serde_json::Value::Null);
319        value_to_dynamic(&value)
320    });
321}
322
323fn register_control_fns(engine: &mut Engine, ctx: &Arc<Mutex<Ctx>>) {
324    engine.register_fn("json_encode", |value: Dynamic| -> ScriptResult<String> {
325        serde_json::to_string(&dynamic_to_value(value))
326            .map_err(|e| runtime_error(format!("json_encode failed: {e}")))
327    });
328
329    engine.register_fn("fingerprint", |text: &str| -> String {
330        // Pure deterministic fingerprint (16-byte hex of SHA-256).
331        use sha2::{Digest, Sha256};
332        let digest = Sha256::digest(text.as_bytes());
333        encode_hex16(digest.iter().take(16).copied())
334    });
335
336    engine.register_fn("complete", |value: Dynamic| -> ScriptResult<()> {
337        Err(terminated(ControlToken::Complete(dynamic_to_value(value))))
338    });
339
340    engine.register_fn("complete", || -> ScriptResult<()> {
341        Err(terminated(ControlToken::Complete(serde_json::Value::Null)))
342    });
343
344    engine.register_fn("pause", |kind: &str, message: &str| -> ScriptResult<()> {
345        let kind = match kind {
346            "user" => PauseKind::User,
347            "back_off" | "backoff" => PauseKind::BackOff,
348            "no_progress" => PauseKind::NoProgress,
349            "verification" | "blocked" => PauseKind::Verification,
350            "infra" => PauseKind::Infra,
351            other => {
352                return Err(runtime_error(format!("unknown pause kind: {other}")));
353            }
354        };
355        Err(terminated(ControlToken::Pause(kind, message.to_owned())))
356    });
357
358    // Journaled pause: first run records then pauses; resume skips.
359    let c = Arc::clone(ctx);
360    engine.register_fn(
361        "await_user",
362        move |kind: &str, message: &str| -> ScriptResult<()> {
363            let payload = serde_json::json!({ "kind": kind, "message": message });
364            let hash = request_hash("await_user", &payload);
365            let seq = {
366                let mut g = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
367                g.next_seq()?
368            };
369            {
370                let g = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
371                if g.journal
372                    .replay(seq, "await_user", &hash)
373                    .map_err(journal_fatal)?
374                    .is_some()
375                {
376                    return Ok(());
377                }
378            }
379            {
380                let mut g = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
381                g.journal
382                    .record(seq, "await_user", hash, serde_json::Value::Null)
383                    .map_err(journal_fatal)?;
384            }
385            let pause_kind = match kind {
386                "user" => PauseKind::User,
387                "back_off" | "backoff" => PauseKind::BackOff,
388                "no_progress" => PauseKind::NoProgress,
389                "verification" | "blocked" => PauseKind::Verification,
390                "infra" => PauseKind::Infra,
391                _ => PauseKind::User,
392            };
393            Err(terminated(ControlToken::Pause(
394                pause_kind,
395                message.to_owned(),
396            )))
397        },
398    );
399}
400
401enum ParallelSlot {
402    Replayed(serde_json::Value),
403    Pending {
404        opts: Box<AgentOpts>,
405        seq: u64,
406        hash: String,
407    },
408    Live {
409        seq: u64,
410        hash: String,
411        reply_rx: oneshot::Receiver<Result<crate::host::AgentResult, HostError>>,
412    },
413}
414
415/// Fan-out: reserve live slots once, dispatch all host spawns, wait as a barrier.
416#[allow(
417    clippy::too_many_lines,
418    clippy::excessive_nesting,
419    reason = "parallel barrier + budget conservation is intentionally collocated"
420)]
421fn spawn_agents_parallel(ctx: &Arc<Mutex<Ctx>>, items: rhai::Array) -> ScriptResult<rhai::Array> {
422    if items.len() > MAX_PARALLEL {
423        return Err(runtime_error(format!(
424            "parallel() accepts at most {MAX_PARALLEL} items"
425        )));
426    }
427
428    let mut prepared: Vec<(AgentOpts, String, u64)> = Vec::with_capacity(items.len());
429    for item in items {
430        let map = item
431            .try_cast::<rhai::Map>()
432            .ok_or_else(|| runtime_error("parallel() items must be maps"))?;
433        let opts = agent_opts_from_map(map)?;
434        let payload = serde_json::to_value(&opts)
435            .map_err(|e| runtime_error(format!("invalid agent options: {e}")))?;
436        let hash = request_hash("spawn_agent", &payload);
437        let seq = {
438            let mut g = ctx
439                .lock()
440                .unwrap_or_else(std::sync::PoisonError::into_inner);
441            g.next_seq()?
442        };
443        prepared.push((opts, hash, seq));
444    }
445
446    let mut pending: Vec<ParallelSlot> = Vec::with_capacity(prepared.len());
447    let mut live_count = 0u64;
448    for (opts, hash, seq) in prepared {
449        let replayed = {
450            let g = ctx
451                .lock()
452                .unwrap_or_else(std::sync::PoisonError::into_inner);
453            g.journal
454                .replay(seq, "spawn_agent", &hash)
455                .map_err(journal_fatal)?
456        };
457        if let Some(value) = replayed {
458            pending.push(ParallelSlot::Replayed(value));
459        } else {
460            live_count = live_count.saturating_add(1);
461            pending.push(ParallelSlot::Pending {
462                opts: Box::new(opts),
463                seq,
464                hash,
465            });
466        }
467    }
468
469    // Reserve before any live spawn so budget failure never races partial fan-out.
470    reserve_n(ctx, live_count)?;
471
472    let mut slots: Vec<ParallelSlot> = Vec::with_capacity(pending.len());
473    for slot in pending {
474        match slot {
475            ParallelSlot::Replayed(v) => slots.push(ParallelSlot::Replayed(v)),
476            ParallelSlot::Pending { opts, seq, hash } => {
477                let (reply_tx, reply_rx) = oneshot::channel();
478                {
479                    let g = ctx
480                        .lock()
481                        .unwrap_or_else(std::sync::PoisonError::into_inner);
482                    g.host_tx
483                        .send(WorkflowHostRequest::SpawnAgent {
484                            opts: *opts,
485                            reply: reply_tx,
486                        })
487                        .map_err(|_| {
488                            terminated(ControlToken::Fatal("workflow host channel closed".into()))
489                        })?;
490                }
491                slots.push(ParallelSlot::Live {
492                    seq,
493                    hash,
494                    reply_rx,
495                });
496            }
497            ParallelSlot::Live { .. } => {
498                return Err(terminated(ControlToken::Fatal(
499                    "internal: live slot before dispatch".into(),
500                )));
501            }
502        }
503    }
504
505    // Ordered outputs; live slots journaled after the barrier unless resumable.
506    let mut ordered: Vec<Option<Dynamic>> = Vec::with_capacity(slots.len());
507    let mut live_to_journal: Vec<(usize, u64, String, serde_json::Value)> = Vec::new();
508    let mut resumable_terminal: Option<Box<EvalAltResult>> = None;
509    let mut first_catchable: Option<Box<EvalAltResult>> = None;
510    // Reserved slots that returned Quota on spawn and must be released (dense Null journaled).
511    let mut quota_release: u64 = 0;
512
513    for (idx, slot) in slots.into_iter().enumerate() {
514        match slot {
515            ParallelSlot::Replayed(value) => {
516                if let Some(msg) = host_error_message(&value) {
517                    first_catchable.get_or_insert_with(|| runtime_error(msg.to_owned()));
518                    ordered.push(None);
519                    continue;
520                }
521                // Soft-null from a prior Quota journal replays as unit.
522                if value.is_null() {
523                    ordered.push(Some(Dynamic::UNIT));
524                    continue;
525                }
526                ordered.push(Some(value_to_dynamic(&value)?));
527            }
528            ParallelSlot::Pending { .. } => {
529                return Err(terminated(ControlToken::Fatal(
530                    "internal: pending slot after dispatch".into(),
531                )));
532            }
533            ParallelSlot::Live {
534                seq,
535                hash,
536                reply_rx,
537            } => {
538                let reply = reply_rx.blocking_recv().map_err(|_| {
539                    terminated(ControlToken::Fatal("workflow host dropped reply".into()))
540                })?;
541                match reply {
542                    Ok(result) => {
543                        let value = serde_json::to_value(result).unwrap_or(serde_json::Value::Null);
544                        live_to_journal.push((idx, seq, hash, value));
545                        ordered.push(None); // filled after journal
546                    }
547                    Err(HostError::BudgetExceeded) => {
548                        resumable_terminal.get_or_insert(terminated(ControlToken::Budget(
549                            "workflow agent budget exceeded".into(),
550                        )));
551                        ordered.push(None);
552                    }
553                    Err(HostError::Cancelled) => {
554                        resumable_terminal.get_or_insert(terminated(ControlToken::Cancelled));
555                        ordered.push(None);
556                    }
557                    Err(HostError::AgentCallQuotaExceeded { .. }) => {
558                        // Catchable null — journal Null to keep dense seq (W1.10 + journal invariant).
559                        live_to_journal.push((idx, seq, hash, serde_json::Value::Null));
560                        quota_release = quota_release.saturating_add(1);
561                        ordered.push(None);
562                    }
563                    Err(HostError::Unsupported(msg) | HostError::Failed(msg)) => {
564                        live_to_journal.push((idx, seq, hash, host_error_sentinel(&msg)));
565                        first_catchable.get_or_insert_with(|| runtime_error(msg));
566                        ordered.push(None);
567                    }
568                }
569            }
570        }
571    }
572
573    // Budget conservation: resumable terminal releases all live reservations and journals nothing.
574    if let Some(err) = resumable_terminal {
575        release_n(ctx, live_count);
576        return Err(err);
577    }
578
579    // Journal in seq order so dense validation cannot fail on out-of-order collection.
580    live_to_journal.sort_by_key(|(_, seq, _, _)| *seq);
581    for (idx, seq, hash, value) in live_to_journal {
582        let is_host_err = host_error_message(&value).is_some();
583        let is_quota_null = value.is_null();
584        {
585            let mut g = ctx
586                .lock()
587                .unwrap_or_else(std::sync::PoisonError::into_inner);
588            g.journal
589                .record(seq, "spawn_agent", hash, value.clone())
590                .map_err(journal_fatal)?;
591        }
592        if let Some(slot) = ordered.get_mut(idx) {
593            if is_host_err {
594                // Catchable path surfaces after all journaled.
595            } else if is_quota_null {
596                *slot = Some(Dynamic::UNIT);
597            } else {
598                *slot = Some(value_to_dynamic(&value)?);
599            }
600        }
601    }
602
603    // Hosts that reject spawn with Quota without consuming reserved slots need release.
604    if quota_release > 0 {
605        release_n(ctx, quota_release);
606    }
607
608    if let Some(err) = first_catchable {
609        return Err(err);
610    }
611
612    let mut results = rhai::Array::with_capacity(ordered.len());
613    for item in ordered {
614        results.push(item.unwrap_or(Dynamic::UNIT));
615    }
616    Ok(results)
617}
618
619fn map_spawn_reply_live(
620    reply: Result<crate::host::AgentResult, HostError>,
621    seq: u64,
622    hash: String,
623) -> Result<serde_json::Value, SpawnLiveError> {
624    match reply {
625        Ok(result) => Ok(serde_json::to_value(result).unwrap_or(serde_json::Value::Null)),
626        Err(HostError::BudgetExceeded) => Err(SpawnLiveError::Resumable(terminated(
627            ControlToken::Budget("workflow agent budget exceeded".into()),
628        ))),
629        Err(HostError::Cancelled) => Err(SpawnLiveError::Resumable(terminated(
630            ControlToken::Cancelled,
631        ))),
632        Err(HostError::AgentCallQuotaExceeded { requested, maximum }) => {
633            Err(SpawnLiveError::Catchable(runtime_error(format!(
634                "workflow agent-call quota exceeded: requested {requested}, maximum {maximum}"
635            ))))
636        }
637        Err(HostError::Unsupported(msg) | HostError::Failed(msg)) => {
638            Err(SpawnLiveError::JournalThenCatchable { seq, hash, msg })
639        }
640    }
641}
642
643enum SpawnLiveError {
644    Resumable(Box<EvalAltResult>),
645    Catchable(Box<EvalAltResult>),
646    JournalThenCatchable { seq: u64, hash: String, msg: String },
647}
648
649fn spawn_agent(ctx: &Arc<Mutex<Ctx>>, opts: AgentOpts) -> ScriptResult<Dynamic> {
650    let payload = serde_json::to_value(&opts)
651        .map_err(|e| runtime_error(format!("invalid agent options: {e}")))?;
652    let hash = request_hash("spawn_agent", &payload);
653    let seq = {
654        let mut g = ctx
655            .lock()
656            .unwrap_or_else(std::sync::PoisonError::into_inner);
657        g.next_seq()?
658    };
659
660    {
661        let g = ctx
662            .lock()
663            .unwrap_or_else(std::sync::PoisonError::into_inner);
664        if let Some(recorded) = g
665            .journal
666            .replay(seq, "spawn_agent", &hash)
667            .map_err(journal_fatal)?
668        {
669            if let Some(msg) = host_error_message(&recorded) {
670                return Err(runtime_error(msg.to_owned()));
671            }
672            return value_to_dynamic(&recorded);
673        }
674    }
675
676    reserve_one(ctx)?;
677
678    let (reply_tx, reply_rx) = oneshot::channel();
679    {
680        let g = ctx
681            .lock()
682            .unwrap_or_else(std::sync::PoisonError::into_inner);
683        g.host_tx
684            .send(WorkflowHostRequest::SpawnAgent {
685                opts,
686                reply: reply_tx,
687            })
688            .map_err(|_| terminated(ControlToken::Fatal("workflow host channel closed".into())))?;
689    }
690
691    let reply = reply_rx
692        .blocking_recv()
693        .map_err(|_| terminated(ControlToken::Fatal("workflow host dropped reply".into())))?;
694
695    match map_spawn_reply_live(reply, seq, hash.clone()) {
696        Ok(value) => {
697            {
698                let mut g = ctx
699                    .lock()
700                    .unwrap_or_else(std::sync::PoisonError::into_inner);
701                g.journal
702                    .record(seq, "spawn_agent", hash, value.clone())
703                    .map_err(journal_fatal)?;
704            }
705            value_to_dynamic(&value)
706        }
707        Err(SpawnLiveError::Resumable(err)) => {
708            // Budget conservation: release reserved slot; journal nothing.
709            release_n(ctx, 1);
710            Err(err)
711        }
712        Err(SpawnLiveError::Catchable(err)) => {
713            release_n(ctx, 1);
714            Err(err)
715        }
716        Err(SpawnLiveError::JournalThenCatchable { seq, hash, msg }) => {
717            let sentinel = host_error_sentinel(&msg);
718            {
719                let mut g = ctx
720                    .lock()
721                    .unwrap_or_else(std::sync::PoisonError::into_inner);
722                g.journal
723                    .record(seq, "spawn_agent", hash, sentinel)
724                    .map_err(journal_fatal)?;
725            }
726            // Slot was spent on a failed host call that is journaled — do not release.
727            Err(runtime_error(msg))
728        }
729    }
730}
731
732/// Journaled host RPC returning a string (scratch / template / …).
733fn host_string_call<F>(
734    ctx: &Arc<Mutex<Ctx>>,
735    kind: &str,
736    payload: serde_json::Value,
737    make_req: F,
738) -> ScriptResult<String>
739where
740    F: FnOnce(oneshot::Sender<Result<String, HostError>>) -> WorkflowHostRequest,
741{
742    let hash = request_hash(kind, &payload);
743    let seq = {
744        let mut g = ctx
745            .lock()
746            .unwrap_or_else(std::sync::PoisonError::into_inner);
747        g.next_seq()?
748    };
749
750    {
751        let g = ctx
752            .lock()
753            .unwrap_or_else(std::sync::PoisonError::into_inner);
754        if let Some(recorded) = g.journal.replay(seq, kind, &hash).map_err(journal_fatal)? {
755            if let Some(msg) = host_error_message(&recorded) {
756                return Err(runtime_error(msg.to_owned()));
757            }
758            return Ok(recorded
759                .as_str()
760                .map(str::to_owned)
761                .unwrap_or_else(|| recorded.to_string()));
762        }
763    }
764
765    let (reply_tx, reply_rx) = oneshot::channel();
766    {
767        let g = ctx
768            .lock()
769            .unwrap_or_else(std::sync::PoisonError::into_inner);
770        g.host_tx
771            .send(make_req(reply_tx))
772            .map_err(|_| terminated(ControlToken::Fatal("workflow host channel closed".into())))?;
773    }
774
775    let reply = reply_rx
776        .blocking_recv()
777        .map_err(|_| terminated(ControlToken::Fatal("workflow host dropped reply".into())))?;
778
779    let value = match reply {
780        Ok(s) => s,
781        Err(HostError::Cancelled) => return Err(terminated(ControlToken::Cancelled)),
782        Err(HostError::BudgetExceeded | HostError::AgentCallQuotaExceeded { .. }) => {
783            return Err(terminated(ControlToken::Budget(
784                "workflow agent budget exceeded".into(),
785            )));
786        }
787        Err(HostError::Unsupported(msg) | HostError::Failed(msg)) => {
788            let sentinel = host_error_sentinel(&msg);
789            {
790                let mut g = ctx
791                    .lock()
792                    .unwrap_or_else(std::sync::PoisonError::into_inner);
793                g.journal
794                    .record(seq, kind, hash, sentinel)
795                    .map_err(journal_fatal)?;
796            }
797            return Err(runtime_error(msg));
798        }
799    };
800
801    {
802        let mut g = ctx
803            .lock()
804            .unwrap_or_else(std::sync::PoisonError::into_inner);
805        g.journal
806            .record(seq, kind, hash, serde_json::Value::String(value.clone()))
807            .map_err(journal_fatal)?;
808    }
809    Ok(value)
810}
811
812fn reserve_one(ctx: &Arc<Mutex<Ctx>>) -> ScriptResult<()> {
813    reserve_n(ctx, 1)
814}
815
816fn reserve_n(ctx: &Arc<Mutex<Ctx>>, count: u64) -> ScriptResult<()> {
817    if count == 0 {
818        return Ok(());
819    }
820    let (reply_tx, reply_rx) = oneshot::channel();
821    {
822        let g = ctx
823            .lock()
824            .unwrap_or_else(std::sync::PoisonError::into_inner);
825        g.host_tx
826            .send(WorkflowHostRequest::ReserveAgentCalls {
827                count,
828                reply: reply_tx,
829            })
830            .map_err(|_| terminated(ControlToken::Fatal("workflow host channel closed".into())))?;
831    }
832    match reply_rx
833        .blocking_recv()
834        .map_err(|_| terminated(ControlToken::Fatal("workflow host dropped reply".into())))?
835    {
836        Ok(()) => Ok(()),
837        Err(HostError::BudgetExceeded | HostError::AgentCallQuotaExceeded { .. }) => {
838            Err(terminated(ControlToken::Budget(
839                "workflow agent budget exceeded".into(),
840            )))
841        }
842        Err(HostError::Cancelled) => Err(terminated(ControlToken::Cancelled)),
843        Err(HostError::Unsupported(msg) | HostError::Failed(msg)) => Err(runtime_error(msg)),
844    }
845}
846
847/// Release reserved agent-call slots (budget conservation on resumable termination).
848fn release_n(ctx: &Arc<Mutex<Ctx>>, count: u64) {
849    if count == 0 {
850        return;
851    }
852    let (reply_tx, reply_rx) = oneshot::channel();
853    let send_ok = {
854        let g = ctx
855            .lock()
856            .unwrap_or_else(std::sync::PoisonError::into_inner);
857        g.host_tx
858            .send(WorkflowHostRequest::ReleaseAgentCalls {
859                count,
860                reply: reply_tx,
861            })
862            .is_ok()
863    };
864    if send_ok {
865        let _ = reply_rx.blocking_recv();
866    }
867}
868
869fn encode_hex16(bytes: impl IntoIterator<Item = u8>) -> String {
870    const HEX: &[u8; 16] = b"0123456789abcdef";
871    let mut out = String::with_capacity(32);
872    for b in bytes {
873        let hi = usize::from(b >> 4);
874        let lo = usize::from(b & 0x0f);
875        if let (Some(&h), Some(&l)) = (HEX.get(hi), HEX.get(lo)) {
876            out.push(char::from(h));
877            out.push(char::from(l));
878        }
879    }
880    out
881}
882
883fn agent_opts_from_map(map: rhai::Map) -> ScriptResult<AgentOpts> {
884    let value = rhai::serde::from_dynamic::<serde_json::Value>(&Dynamic::from_map(map))
885        .map_err(|e| runtime_error(format!("invalid options map: {e}")))?;
886    serde_json::from_value(value).map_err(|e| runtime_error(format!("invalid agent options: {e}")))
887}
888
889fn dynamic_to_value(d: Dynamic) -> serde_json::Value {
890    rhai::serde::from_dynamic(&d).unwrap_or(serde_json::Value::Null)
891}
892
893fn value_to_dynamic(v: &serde_json::Value) -> ScriptResult<Dynamic> {
894    rhai::serde::to_dynamic(v).map_err(|e| runtime_error(format!("host result conversion: {e}")))
895}
896
897fn terminated(token: ControlToken) -> Box<EvalAltResult> {
898    Box::new(EvalAltResult::ErrorTerminated(
899        Dynamic::from(token),
900        Position::NONE,
901    ))
902}
903
904fn runtime_error(message: impl Into<String>) -> Box<EvalAltResult> {
905    Box::new(EvalAltResult::ErrorRuntime(
906        Dynamic::from(message.into()),
907        Position::NONE,
908    ))
909}
910
911fn journal_fatal(error: JournalError) -> Box<EvalAltResult> {
912    terminated(ControlToken::Fatal(error.to_string()))
913}
914
915fn find_control_token(err: &EvalAltResult) -> Option<ControlToken> {
916    match err {
917        EvalAltResult::ErrorTerminated(token, _) => token.clone().try_cast::<ControlToken>(),
918        EvalAltResult::ErrorInFunctionCall(_, _, inner, _) => find_control_token(inner),
919        EvalAltResult::ErrorInModule(_, inner, _) => find_control_token(inner),
920        _ => None,
921    }
922}
923
924fn outcome_from_error(err: EvalAltResult) -> WorkflowOutcome {
925    if let Some(token) = find_control_token(&err) {
926        return match token {
927            ControlToken::Complete(result) => WorkflowOutcome::Completed { result },
928            ControlToken::Pause(kind, message) => WorkflowOutcome::Paused { kind, message },
929            ControlToken::Budget(message) => WorkflowOutcome::BudgetExceeded { message },
930            ControlToken::Cancelled => WorkflowOutcome::Cancelled,
931            ControlToken::Fatal(error) => WorkflowOutcome::Failed { error },
932        };
933    }
934    WorkflowOutcome::Failed {
935        error: err.to_string(),
936    }
937}
938
939#[cfg(test)]
940#[allow(
941    clippy::expect_used,
942    clippy::unwrap_used,
943    clippy::panic,
944    clippy::excessive_nesting,
945    reason = "unit tests use expect/panic for setup"
946)]
947mod tests {
948    use std::sync::Arc;
949    use std::sync::atomic::{AtomicU64, Ordering};
950
951    use super::*;
952    use crate::host::{AgentResult, WorkflowHostRequest};
953
954    fn spawn_host(
955        budget: u64,
956    ) -> (
957        mpsc::UnboundedSender<WorkflowHostRequest>,
958        tokio::task::JoinHandle<()>,
959        Arc<AtomicU64>,
960    ) {
961        let (tx, mut rx) = mpsc::unbounded_channel();
962        let spent = Arc::new(AtomicU64::new(0));
963        let spent2 = spent.clone();
964        let handle = tokio::task::spawn(async move {
965            let mut reserved = 0u64;
966            while let Some(req) = rx.recv().await {
967                match req {
968                    WorkflowHostRequest::ReserveAgentCalls { count, reply } => {
969                        if reserved + count + spent2.load(Ordering::SeqCst) > budget {
970                            let _ = reply.send(Err(HostError::BudgetExceeded));
971                        } else {
972                            reserved += count;
973                            let _ = reply.send(Ok(()));
974                        }
975                    }
976                    WorkflowHostRequest::ReleaseAgentCalls { count, reply } => {
977                        reserved = reserved.saturating_sub(count);
978                        let _ = reply.send(Ok(()));
979                    }
980                    WorkflowHostRequest::SpawnAgent { opts, reply } => {
981                        spent2.fetch_add(1, Ordering::SeqCst);
982                        reserved = reserved.saturating_sub(1);
983                        let _ = reply.send(Ok(AgentResult {
984                            agent_id: format!("a-{}", spent2.load(Ordering::SeqCst)),
985                            success: true,
986                            output: serde_json::json!({"prompt": opts.prompt}),
987                            cancelled: false,
988                            tokens_used: 1,
989                            duration_ms: 1,
990                        }));
991                    }
992                    WorkflowHostRequest::Phase { .. }
993                    | WorkflowHostRequest::Log { .. }
994                    | WorkflowHostRequest::Telemetry { .. } => {}
995                    WorkflowHostRequest::BudgetQuery { reply } => {
996                        let spent = spent2.load(Ordering::SeqCst);
997                        let _ = reply.send(Ok(crate::host::BudgetState {
998                            total: Some(budget),
999                            spent,
1000                            reserved,
1001                            remaining: Some(budget.saturating_sub(spent + reserved)),
1002                        }));
1003                    }
1004                    WorkflowHostRequest::RenderTemplate { reply, .. }
1005                    | WorkflowHostRequest::WriteScratchFile { reply, .. }
1006                    | WorkflowHostRequest::ReadScratchFile { reply, .. }
1007                    | WorkflowHostRequest::GitDiffSince { reply, .. } => {
1008                        let _ = reply.send(Err(HostError::Unsupported(
1009                            "not implemented in test host".into(),
1010                        )));
1011                    }
1012                }
1013            }
1014        });
1015        (tx, handle, spent)
1016    }
1017
1018    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1019    async fn agent_and_complete() {
1020        let (tx, host, spent) = spawn_host(10);
1021        let script = r#"
1022            let meta = #{ name: "t", description: "t" };
1023            phase("go");
1024            let r = agent("hello");
1025            complete(r);
1026        "#;
1027        let outcome = tokio::task::spawn_blocking(move || {
1028            run_workflow(WorkflowRunParams {
1029                script: script.into(),
1030                args: serde_json::json!({}),
1031                journal: Journal::new(None),
1032                host_tx: tx,
1033                cancel: CancellationToken::new(),
1034                max_ops: WorkflowRunParams::DEFAULT_MAX_OPS,
1035            })
1036        })
1037        .await
1038        .expect("join");
1039        drop(host);
1040        assert!(
1041            matches!(outcome, WorkflowOutcome::Completed { .. }),
1042            "{outcome:?}"
1043        );
1044        assert_eq!(spent.load(Ordering::SeqCst), 1);
1045    }
1046
1047    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1048    async fn journal_resume_skips_first_agent() {
1049        let dir = tempfile::tempdir().expect("tmp");
1050        let path = dir.path().join("j.jsonl");
1051        let (tx, host, spent) = spawn_host(10);
1052        let script = r#"
1053            let meta = #{ name: "t", description: "t" };
1054            let a = agent("one");
1055            let b = agent("two");
1056            complete(#{ a: a, b: b });
1057        "#;
1058        let outcome1 = {
1059            let tx = tx.clone();
1060            let path = path.clone();
1061            let script = script.to_owned();
1062            tokio::task::spawn_blocking(move || {
1063                run_workflow(WorkflowRunParams {
1064                    script,
1065                    args: serde_json::json!({}),
1066                    journal: Journal::new(Some(path)),
1067                    host_tx: tx,
1068                    cancel: CancellationToken::new(),
1069                    max_ops: WorkflowRunParams::DEFAULT_MAX_OPS,
1070                })
1071            })
1072            .await
1073            .expect("join")
1074        };
1075        assert!(matches!(outcome1, WorkflowOutcome::Completed { .. }));
1076        assert_eq!(spent.load(Ordering::SeqCst), 2);
1077
1078        // Resume: both agent calls should replay from journal (no new spawns).
1079        spent.store(0, Ordering::SeqCst);
1080        let journal = Journal::load(path).expect("load");
1081        assert_eq!(journal.len(), 2);
1082        let outcome2 = tokio::task::spawn_blocking(move || {
1083            run_workflow(WorkflowRunParams {
1084                script: script.into(),
1085                args: serde_json::json!({}),
1086                journal,
1087                host_tx: tx,
1088                cancel: CancellationToken::new(),
1089                max_ops: WorkflowRunParams::DEFAULT_MAX_OPS,
1090            })
1091        })
1092        .await
1093        .expect("join");
1094        drop(host);
1095        assert!(matches!(outcome2, WorkflowOutcome::Completed { .. }));
1096        assert_eq!(
1097            spent.load(Ordering::SeqCst),
1098            0,
1099            "resume must not re-spawn agents"
1100        );
1101    }
1102
1103    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1104    async fn json_encode_and_scratch_surface() {
1105        let (tx, host, _) = spawn_host(4);
1106        // Override unsupported scratch replies: use a host that implements scratch.
1107        drop(host);
1108        drop(tx);
1109        let (tx, mut rx) = mpsc::unbounded_channel::<WorkflowHostRequest>();
1110        let host = tokio::spawn(async move {
1111            let mut spent = 0u64;
1112            while let Some(req) = rx.recv().await {
1113                match req {
1114                    WorkflowHostRequest::ReserveAgentCalls { count, reply } => {
1115                        let _ = reply.send(Ok(()));
1116                        let _ = count;
1117                    }
1118                    WorkflowHostRequest::ReleaseAgentCalls { count, reply } => {
1119                        let _ = reply.send(Ok(()));
1120                        let _ = count;
1121                    }
1122                    WorkflowHostRequest::SpawnAgent { opts, reply } => {
1123                        spent += 1;
1124                        let _ = reply.send(Ok(AgentResult {
1125                            agent_id: format!("a-{spent}"),
1126                            success: true,
1127                            output: serde_json::json!({"echo": opts.prompt}),
1128                            cancelled: false,
1129                            tokens_used: 1,
1130                            duration_ms: 1,
1131                        }));
1132                    }
1133                    WorkflowHostRequest::BudgetQuery { reply } => {
1134                        let _ = reply.send(Ok(crate::host::BudgetState {
1135                            total: Some(4),
1136                            spent,
1137                            reserved: 0,
1138                            remaining: Some(4_u64.saturating_sub(spent)),
1139                        }));
1140                    }
1141                    WorkflowHostRequest::WriteScratchFile {
1142                        name,
1143                        content,
1144                        reply,
1145                    } => {
1146                        let _ = reply.send(Ok(format!("scratch/{name}:{content}")));
1147                    }
1148                    WorkflowHostRequest::ReadScratchFile { name, reply } => {
1149                        let _ = reply.send(Ok(format!("read:{name}")));
1150                    }
1151                    WorkflowHostRequest::Phase { .. }
1152                    | WorkflowHostRequest::Log { .. }
1153                    | WorkflowHostRequest::Telemetry { .. } => {}
1154                    WorkflowHostRequest::RenderTemplate { reply, .. }
1155                    | WorkflowHostRequest::GitDiffSince { reply, .. } => {
1156                        let _ = reply.send(Err(HostError::Unsupported("n/a".into())));
1157                    }
1158                }
1159            }
1160        });
1161        let script = r#"
1162            let meta = #{ name: "json", description: "encode" };
1163            let enc = json_encode(#{ a: 1, b: "x" });
1164            let path = write_scratch_file("n.txt", enc);
1165            let b = budget();
1166            complete(#{ enc: enc, path: path, remaining: b.remaining });
1167        "#;
1168        let outcome = tokio::task::spawn_blocking(move || {
1169            run_workflow(WorkflowRunParams {
1170                script: script.into(),
1171                args: serde_json::json!({}),
1172                journal: Journal::new(None),
1173                host_tx: tx,
1174                cancel: CancellationToken::new(),
1175                max_ops: WorkflowRunParams::DEFAULT_MAX_OPS,
1176            })
1177        })
1178        .await
1179        .expect("join");
1180        drop(host);
1181        match outcome {
1182            WorkflowOutcome::Completed { result } => {
1183                let enc = result.get("enc").and_then(|v| v.as_str()).expect("enc");
1184                assert!(enc.contains("\"a\":") || enc.contains("'a'"), "{enc}");
1185                assert!(
1186                    result
1187                        .get("path")
1188                        .and_then(|v| v.as_str())
1189                        .unwrap_or("")
1190                        .contains("n.txt"),
1191                    "{result}"
1192                );
1193            }
1194            other => panic!("expected completed: {other:?}"),
1195        }
1196    }
1197
1198    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1199    async fn budget_exceeded() {
1200        let (tx, host, _) = spawn_host(0);
1201        let script = r#"
1202            let meta = #{ name: "t", description: "t" };
1203            agent("x");
1204            complete(1);
1205        "#;
1206        let outcome = tokio::task::spawn_blocking(move || {
1207            run_workflow(WorkflowRunParams {
1208                script: script.into(),
1209                args: serde_json::json!({}),
1210                journal: Journal::new(None),
1211                host_tx: tx,
1212                cancel: CancellationToken::new(),
1213                max_ops: WorkflowRunParams::DEFAULT_MAX_OPS,
1214            })
1215        })
1216        .await
1217        .expect("join");
1218        drop(host);
1219        assert!(
1220            matches!(outcome, WorkflowOutcome::BudgetExceeded { .. }),
1221            "{outcome:?}"
1222        );
1223    }
1224
1225    /// Host that can cancel or budget-fail the Nth spawn while tracking reserved slots.
1226    fn spawn_host_with_policy(
1227        budget: u64,
1228        fail_mode: &'static str,
1229    ) -> (
1230        mpsc::UnboundedSender<WorkflowHostRequest>,
1231        tokio::task::JoinHandle<()>,
1232        Arc<AtomicU64>,
1233        Arc<AtomicU64>,
1234    ) {
1235        let (tx, mut rx) = mpsc::unbounded_channel();
1236        let spent = Arc::new(AtomicU64::new(0));
1237        let reserved_peak = Arc::new(AtomicU64::new(0));
1238        let spent2 = spent.clone();
1239        let reserved_peak2 = reserved_peak.clone();
1240        let handle = tokio::task::spawn(async move {
1241            let mut reserved = 0u64;
1242            while let Some(req) = rx.recv().await {
1243                match req {
1244                    WorkflowHostRequest::ReserveAgentCalls { count, reply } => {
1245                        if reserved + count + spent2.load(Ordering::SeqCst) > budget {
1246                            let _ = reply.send(Err(HostError::BudgetExceeded));
1247                        } else {
1248                            reserved += count;
1249                            reserved_peak2.fetch_max(reserved, Ordering::SeqCst);
1250                            let _ = reply.send(Ok(()));
1251                        }
1252                    }
1253                    WorkflowHostRequest::ReleaseAgentCalls { count, reply } => {
1254                        reserved = reserved.saturating_sub(count);
1255                        let _ = reply.send(Ok(()));
1256                    }
1257                    WorkflowHostRequest::SpawnAgent { opts, reply } => match fail_mode {
1258                        "cancel" => {
1259                            let _ = reply.send(Err(HostError::Cancelled));
1260                        }
1261                        "budget_on_spawn" => {
1262                            let _ = reply.send(Err(HostError::BudgetExceeded));
1263                        }
1264                        "fail" => {
1265                            let _ = reply.send(Err(HostError::Failed("host boom".into())));
1266                        }
1267                        _ => {
1268                            spent2.fetch_add(1, Ordering::SeqCst);
1269                            reserved = reserved.saturating_sub(1);
1270                            let _ = reply.send(Ok(AgentResult {
1271                                agent_id: format!("a-{}", spent2.load(Ordering::SeqCst)),
1272                                success: true,
1273                                output: serde_json::json!({"prompt": opts.prompt}),
1274                                cancelled: false,
1275                                tokens_used: 1,
1276                                duration_ms: 1,
1277                            }));
1278                        }
1279                    },
1280                    WorkflowHostRequest::Phase { .. }
1281                    | WorkflowHostRequest::Log { .. }
1282                    | WorkflowHostRequest::Telemetry { .. } => {}
1283                    WorkflowHostRequest::BudgetQuery { reply } => {
1284                        let spent = spent2.load(Ordering::SeqCst);
1285                        let _ = reply.send(Ok(crate::host::BudgetState {
1286                            total: Some(budget),
1287                            spent,
1288                            reserved,
1289                            remaining: Some(budget.saturating_sub(spent + reserved)),
1290                        }));
1291                    }
1292                    WorkflowHostRequest::RenderTemplate { reply, .. }
1293                    | WorkflowHostRequest::WriteScratchFile { reply, .. }
1294                    | WorkflowHostRequest::ReadScratchFile { reply, .. }
1295                    | WorkflowHostRequest::GitDiffSince { reply, .. } => {
1296                        let _ = reply.send(Err(HostError::Unsupported("n/a".into())));
1297                    }
1298                }
1299            }
1300        });
1301        (tx, handle, spent, reserved_peak)
1302    }
1303
1304    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1305    async fn cancelled_live_agent_releases_budget_so_resume_does_not_double_charge() {
1306        let dir = tempfile::tempdir().expect("tmp");
1307        let path = dir.path().join("j.jsonl");
1308        let (tx, host, spent, _) = spawn_host_with_policy(4, "cancel");
1309        let script = r#"
1310            let meta = #{ name: "t", description: "t" };
1311            agent("one");
1312            complete(1);
1313        "#;
1314        let outcome = {
1315            let tx = tx.clone();
1316            let path = path.clone();
1317            let script = script.to_owned();
1318            tokio::task::spawn_blocking(move || {
1319                run_workflow(WorkflowRunParams {
1320                    script,
1321                    args: serde_json::json!({}),
1322                    journal: Journal::new(Some(path)),
1323                    host_tx: tx,
1324                    cancel: CancellationToken::new(),
1325                    max_ops: WorkflowRunParams::DEFAULT_MAX_OPS,
1326                })
1327            })
1328            .await
1329            .expect("join")
1330        };
1331        assert!(matches!(outcome, WorkflowOutcome::Cancelled), "{outcome:?}");
1332        // Cancelled path must not journal the interrupted spawn.
1333        let journal = Journal::load(path.clone()).expect("load");
1334        assert_eq!(journal.len(), 0, "cancelled spawn must not be journaled");
1335        assert_eq!(spent.load(Ordering::SeqCst), 0);
1336
1337        // Resume with a healthy host must succeed without double-charging.
1338        drop(host);
1339        let (tx2, host2, spent2) = spawn_host(4);
1340        let journal = Journal::load(path).expect("load2");
1341        let script = r#"
1342            let meta = #{ name: "t", description: "t" };
1343            agent("one");
1344            complete(1);
1345        "#;
1346        let outcome2 = tokio::task::spawn_blocking(move || {
1347            run_workflow(WorkflowRunParams {
1348                script: script.into(),
1349                args: serde_json::json!({}),
1350                journal,
1351                host_tx: tx2,
1352                cancel: CancellationToken::new(),
1353                max_ops: WorkflowRunParams::DEFAULT_MAX_OPS,
1354            })
1355        })
1356        .await
1357        .expect("join");
1358        drop(host2);
1359        assert!(
1360            matches!(outcome2, WorkflowOutcome::Completed { .. }),
1361            "{outcome2:?}"
1362        );
1363        assert_eq!(spent2.load(Ordering::SeqCst), 1);
1364    }
1365
1366    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1367    async fn budget_exceeded_live_agent_releases_and_journals_nothing() {
1368        let dir = tempfile::tempdir().expect("tmp");
1369        let path = dir.path().join("j.jsonl");
1370        let (tx, host, _, _) = spawn_host_with_policy(4, "budget_on_spawn");
1371        let script = r#"
1372            let meta = #{ name: "t", description: "t" };
1373            agent("one");
1374            complete(1);
1375        "#;
1376        let path_run = path.clone();
1377        let outcome = tokio::task::spawn_blocking(move || {
1378            run_workflow(WorkflowRunParams {
1379                script: script.into(),
1380                args: serde_json::json!({}),
1381                journal: Journal::new(Some(path_run)),
1382                host_tx: tx,
1383                cancel: CancellationToken::new(),
1384                max_ops: WorkflowRunParams::DEFAULT_MAX_OPS,
1385            })
1386        })
1387        .await
1388        .expect("join");
1389        drop(host);
1390        assert!(
1391            matches!(outcome, WorkflowOutcome::BudgetExceeded { .. }),
1392            "{outcome:?}"
1393        );
1394        let journal = Journal::load(path).expect("load");
1395        assert_eq!(journal.len(), 0);
1396    }
1397
1398    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1399    async fn host_error_sentinel_journals_and_replays() {
1400        let dir = tempfile::tempdir().expect("tmp");
1401        let path = dir.path().join("j.jsonl");
1402        let (tx, host, _, _) = spawn_host_with_policy(4, "fail");
1403        let script = r#"
1404            let meta = #{ name: "t", description: "t" };
1405            agent("one");
1406            complete(1);
1407        "#;
1408        let outcome = {
1409            let tx = tx.clone();
1410            let path = path.clone();
1411            let script = script.to_owned();
1412            tokio::task::spawn_blocking(move || {
1413                run_workflow(WorkflowRunParams {
1414                    script,
1415                    args: serde_json::json!({}),
1416                    journal: Journal::new(Some(path)),
1417                    host_tx: tx,
1418                    cancel: CancellationToken::new(),
1419                    max_ops: WorkflowRunParams::DEFAULT_MAX_OPS,
1420                })
1421            })
1422            .await
1423            .expect("join")
1424        };
1425        drop(host);
1426        assert!(
1427            matches!(outcome, WorkflowOutcome::Failed { .. }),
1428            "{outcome:?}"
1429        );
1430        let journal = Journal::load(path.clone()).expect("load");
1431        assert_eq!(journal.len(), 1);
1432        assert!(
1433            journal
1434                .entries()
1435                .first()
1436                .is_some_and(|e| crate::journal::is_host_error_sentinel(&e.result)),
1437            "expected host-error sentinel"
1438        );
1439
1440        // Resume re-raises without calling host spawn.
1441        let (tx2, host2, spent2) = spawn_host(4);
1442        let outcome2 = tokio::task::spawn_blocking(move || {
1443            run_workflow(WorkflowRunParams {
1444                script: script.into(),
1445                args: serde_json::json!({}),
1446                journal: Journal::load(path).expect("load2"),
1447                host_tx: tx2,
1448                cancel: CancellationToken::new(),
1449                max_ops: WorkflowRunParams::DEFAULT_MAX_OPS,
1450            })
1451        })
1452        .await
1453        .expect("join");
1454        drop(host2);
1455        assert!(
1456            matches!(outcome2, WorkflowOutcome::Failed { .. }),
1457            "{outcome2:?}"
1458        );
1459        assert_eq!(spent2.load(Ordering::SeqCst), 0);
1460    }
1461
1462    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1463    async fn await_user_pauses_once_then_passes_on_resume() {
1464        let dir = tempfile::tempdir().expect("tmp");
1465        let path = dir.path().join("j.jsonl");
1466        let (tx, host, spent) = spawn_host(4);
1467        let script = r#"
1468            let meta = #{ name: "t", description: "t" };
1469            await_user("user", "needs a human");
1470            agent("after");
1471            complete(1);
1472        "#;
1473        let outcome1 = {
1474            let tx = tx.clone();
1475            let path = path.clone();
1476            let script = script.to_owned();
1477            tokio::task::spawn_blocking(move || {
1478                run_workflow(WorkflowRunParams {
1479                    script,
1480                    args: serde_json::json!({}),
1481                    journal: Journal::new(Some(path)),
1482                    host_tx: tx,
1483                    cancel: CancellationToken::new(),
1484                    max_ops: WorkflowRunParams::DEFAULT_MAX_OPS,
1485                })
1486            })
1487            .await
1488            .expect("join")
1489        };
1490        assert!(
1491            matches!(
1492                outcome1,
1493                WorkflowOutcome::Paused {
1494                    kind: PauseKind::User,
1495                    ..
1496                }
1497            ),
1498            "{outcome1:?}"
1499        );
1500        assert_eq!(spent.load(Ordering::SeqCst), 0);
1501
1502        let journal = Journal::load(path).expect("load");
1503        assert_eq!(journal.len(), 1);
1504        let outcome2 = tokio::task::spawn_blocking(move || {
1505            run_workflow(WorkflowRunParams {
1506                script: script.into(),
1507                args: serde_json::json!({}),
1508                journal,
1509                host_tx: tx,
1510                cancel: CancellationToken::new(),
1511                max_ops: WorkflowRunParams::DEFAULT_MAX_OPS,
1512            })
1513        })
1514        .await
1515        .expect("join");
1516        drop(host);
1517        assert!(
1518            matches!(outcome2, WorkflowOutcome::Completed { .. }),
1519            "{outcome2:?}"
1520        );
1521        assert_eq!(spent.load(Ordering::SeqCst), 1);
1522    }
1523
1524    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1525    async fn cancelled_parallel_releases_budget() {
1526        let (tx, host, spent, _) = spawn_host_with_policy(8, "cancel");
1527        let script = r#"
1528            let meta = #{ name: "t", description: "t" };
1529            parallel([#{ prompt: "a" }, #{ prompt: "b" }]);
1530            complete(1);
1531        "#;
1532        let outcome = tokio::task::spawn_blocking(move || {
1533            run_workflow(WorkflowRunParams {
1534                script: script.into(),
1535                args: serde_json::json!({}),
1536                journal: Journal::new(None),
1537                host_tx: tx,
1538                cancel: CancellationToken::new(),
1539                max_ops: WorkflowRunParams::DEFAULT_MAX_OPS,
1540            })
1541        })
1542        .await
1543        .expect("join");
1544        drop(host);
1545        assert!(matches!(outcome, WorkflowOutcome::Cancelled), "{outcome:?}");
1546        assert_eq!(spent.load(Ordering::SeqCst), 0);
1547    }
1548
1549    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1550    async fn parallel_quota_journals_null_and_keeps_dense_seq() {
1551        // Host: first spawn succeeds, second returns Quota, third succeeds.
1552        let (tx, mut rx) = mpsc::unbounded_channel::<WorkflowHostRequest>();
1553        let host = tokio::spawn(async move {
1554            let mut reserved = 0u64;
1555            let mut spawn_n = 0u64;
1556            while let Some(req) = rx.recv().await {
1557                match req {
1558                    WorkflowHostRequest::ReserveAgentCalls { count, reply } => {
1559                        reserved = reserved.saturating_add(count);
1560                        let _ = reply.send(Ok(()));
1561                    }
1562                    WorkflowHostRequest::ReleaseAgentCalls { count, reply } => {
1563                        reserved = reserved.saturating_sub(count);
1564                        let _ = reply.send(Ok(()));
1565                    }
1566                    WorkflowHostRequest::SpawnAgent { opts, reply } => {
1567                        spawn_n = spawn_n.saturating_add(1);
1568                        if spawn_n == 2 {
1569                            // Do not consume reserved — engine must release.
1570                            let _ = reply.send(Err(HostError::AgentCallQuotaExceeded {
1571                                requested: 1,
1572                                maximum: 0,
1573                            }));
1574                        } else {
1575                            reserved = reserved.saturating_sub(1);
1576                            let _ = reply.send(Ok(AgentResult {
1577                                agent_id: format!("a-{spawn_n}"),
1578                                success: true,
1579                                output: serde_json::json!({"prompt": opts.prompt}),
1580                                cancelled: false,
1581                                tokens_used: 1,
1582                                duration_ms: 1,
1583                            }));
1584                        }
1585                    }
1586                    WorkflowHostRequest::BudgetQuery { reply } => {
1587                        let _ = reply.send(Ok(crate::host::BudgetState {
1588                            total: Some(10),
1589                            spent: 0,
1590                            reserved,
1591                            remaining: Some(10),
1592                        }));
1593                    }
1594                    WorkflowHostRequest::Phase { .. }
1595                    | WorkflowHostRequest::Log { .. }
1596                    | WorkflowHostRequest::Telemetry { .. } => {}
1597                    WorkflowHostRequest::RenderTemplate { reply, .. }
1598                    | WorkflowHostRequest::WriteScratchFile { reply, .. }
1599                    | WorkflowHostRequest::ReadScratchFile { reply, .. }
1600                    | WorkflowHostRequest::GitDiffSince { reply, .. } => {
1601                        let _ = reply.send(Err(HostError::Unsupported("n/a".into())));
1602                    }
1603                }
1604            }
1605        });
1606        let dir = tempfile::tempdir().expect("tmp");
1607        let path = dir.path().join("quota.jsonl");
1608        let script = r#"
1609            let meta = #{ name: "q", description: "q" };
1610            let rows = parallel([
1611                #{ prompt: "a" },
1612                #{ prompt: "b" },
1613                #{ prompt: "c" }
1614            ]);
1615            complete(#{ n: rows.len() });
1616        "#;
1617        let path_run = path.clone();
1618        let outcome = tokio::task::spawn_blocking(move || {
1619            run_workflow(WorkflowRunParams {
1620                script: script.into(),
1621                args: serde_json::json!({}),
1622                journal: Journal::new(Some(path_run)),
1623                host_tx: tx,
1624                cancel: CancellationToken::new(),
1625                max_ops: WorkflowRunParams::DEFAULT_MAX_OPS,
1626            })
1627        })
1628        .await
1629        .expect("join");
1630        drop(host);
1631        assert!(
1632            matches!(outcome, WorkflowOutcome::Completed { .. }),
1633            "{outcome:?}"
1634        );
1635        let journal = Journal::load(path).expect("load");
1636        assert_eq!(journal.len(), 3, "dense seq: success, null, success");
1637        assert!(
1638            journal.entries().get(1).is_some_and(|e| e.result.is_null()),
1639            "middle slot must be journaled null"
1640        );
1641    }
1642
1643    #[test]
1644    fn fingerprint_is_deterministic() {
1645        use sha2::{Digest, Sha256};
1646        let digest = Sha256::digest(b"hello");
1647        let a = encode_hex16(digest.iter().take(16).copied());
1648        let b = encode_hex16(digest.iter().take(16).copied());
1649        assert_eq!(a, b);
1650        assert_eq!(a.len(), 32);
1651    }
1652}