1use 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
16const RHAI_MAX_EXPR_DEPTH_STMT: usize = 128;
18const RHAI_MAX_EXPR_DEPTH_EXPR: usize = 64;
19const RHAI_MAX_STRING_SIZE: usize = 16 * 1024 * 1024;
21const RHAI_MAX_ARRAY_MAP_SIZE: usize = 64 * 1024;
23
24#[derive(Debug)]
26pub struct WorkflowRunParams {
27 pub script: String,
29 pub args: serde_json::Value,
31 pub journal: Journal,
33 pub host_tx: mpsc::UnboundedSender<WorkflowHostRequest>,
35 pub cancel: CancellationToken,
37 pub max_ops: u64,
39}
40
41impl WorkflowRunParams {
42 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#[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 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 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 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#[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_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 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 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 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); }
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 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 if let Some(err) = resumable_terminal {
575 release_n(ctx, live_count);
576 return Err(err);
577 }
578
579 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 } else if is_quota_null {
596 *slot = Some(Dynamic::UNIT);
597 } else {
598 *slot = Some(value_to_dynamic(&value)?);
599 }
600 }
601 }
602
603 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 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 Err(runtime_error(msg))
728 }
729 }
730}
731
732fn 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
847fn 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 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 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 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 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 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 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 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 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}