返回 CodeWhale
vm.rs
1 //! The sandboxed QuickJS VM that executes Workflow scripts.
2 //!
3 //! Threading model (design §2.2): `rquickjs` contexts and every `'js` value
4 //! are `!Send`, so each run gets a dedicated OS thread with its own
5 //! current-thread tokio reactor. Host functions do no heavy work inline —
6 //! only `Send` data (JSON strings, [`TaskRequest`]s, oneshot replies) crosses
7 //! to the driver; conversion back into JS values happens on the VM thread
8 //! after the await resolves.
9 //!
10 //! Sandbox: the context registers only standard ECMAScript intrinsics plus
11 //! the Workflow globals (`task`, `parallel`, `pipeline`, `log`, `phase`,
12 //! `budget`, `args`). There is no module loader, no fs/net/process access,
13 //! and `Date`/`Math.random` are overridden to throw so recorded runs stay
14 //! deterministic for replay.
15
16 use std::cell::Cell;
17 use std::env;
18 use std::rc::Rc;
19 use std::sync::atomic::{AtomicBool, Ordering};
20 use std::sync::{Arc, OnceLock};
21
22 use rquickjs::function::{Async, Func};
23 use rquickjs::{AsyncContext, AsyncRuntime, CatchResultExt, CaughtError, Ctx, Promise, Value};
24 use serde::Deserialize;
25 use tokio::sync::{OwnedSemaphorePermit, Semaphore, oneshot, watch};
26
27 use crate::driver::{ProgressEvent, TaskCompletion, TaskRequest, WorkflowDriver};
28 use crate::error::{TaskError, TaskErrorKind, WorkflowJsError};
29 use crate::schema::{
30 ReplyDecodeError, SCHEMA_REPAIR_MAX_ATTEMPTS, carried_raw, compile_schema, decode_reply,
31 repair_prompt,
32 };
33 use crate::{
34 CODEMODE_MAX_TOOL_CALLS, PARALLEL_MAX_ITEMS, ToolCallRequest, ToolInvoker,
35 WORKFLOW_LIFETIME_CAP, normalize_profile,
36 };
37
38 const DEFAULT_VM_MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
39 const MIN_VM_MEMORY_LIMIT_BYTES: usize = 4 * 1024 * 1024;
40 const MAX_VM_MEMORY_LIMIT_BYTES: usize = 512 * 1024 * 1024;
41 const DEFAULT_VM_STACK_BYTES: usize = 1024 * 1024;
42 const MIN_VM_STACK_BYTES: usize = 128 * 1024;
43 const MAX_VM_STACK_BYTES: usize = 8 * 1024 * 1024;
44 const DEFAULT_VM_THREAD_STACK_BYTES: usize = 2 * 1024 * 1024;
45 const MIN_VM_THREAD_STACK_BYTES: usize = 512 * 1024;
46 const MAX_VM_THREAD_STACK_BYTES: usize = 16 * 1024 * 1024;
47 const DEFAULT_MAX_CONCURRENT_VMS: usize = 4;
48 const MAX_CONCURRENT_VMS: usize = 256;
49
50 const VM_MEMORY_LIMIT_MB_ENV: &str = "CODEWHALE_WORKFLOW_JS_MEMORY_LIMIT_MB";
51 const VM_STACK_KB_ENV: &str = "CODEWHALE_WORKFLOW_JS_STACK_KB";
52 const VM_THREAD_STACK_KB_ENV: &str = "CODEWHALE_WORKFLOW_JS_THREAD_STACK_KB";
53 const VM_MAX_CONCURRENT_ENV: &str = "CODEWHALE_WORKFLOW_JS_MAX_CONCURRENT";
54
55 /// Resource limits applied to the QuickJS runtime before any script runs.
56 ///
57 /// There is deliberately no wall-clock timeout here: cancellation (dropping
58 /// the run future, or the driver's cancel cascade) is the deadline mechanism.
59 #[derive(Debug, Clone, Copy)]
60 pub struct VmLimits {
61 /// QuickJS heap ceiling in bytes (default 32 MiB).
62 pub memory_limit_bytes: usize,
63 /// Maximum interpreter stack in bytes (default 1 MiB).
64 pub max_stack_bytes: usize,
65 }
66
67 impl Default for VmLimits {
68 fn default() -> Self {
69 Self::from_env()
70 }
71 }
72
73 impl VmLimits {
74 pub fn from_env() -> Self {
75 Self {
76 memory_limit_bytes: env_usize_bytes(
77 VM_MEMORY_LIMIT_MB_ENV,
78 1024 * 1024,
79 MIN_VM_MEMORY_LIMIT_BYTES,
80 MAX_VM_MEMORY_LIMIT_BYTES,
81 DEFAULT_VM_MEMORY_LIMIT_BYTES,
82 ),
83 max_stack_bytes: env_usize_bytes(
84 VM_STACK_KB_ENV,
85 1024,
86 MIN_VM_STACK_BYTES,
87 MAX_VM_STACK_BYTES,
88 DEFAULT_VM_STACK_BYTES,
89 ),
90 }
91 }
92 }
93
94 fn env_usize_bytes(name: &str, unit: usize, min: usize, max: usize, default: usize) -> usize {
95 env::var(name)
96 .ok()
97 .and_then(|raw| raw.parse::<usize>().ok())
98 .and_then(|value| value.checked_mul(unit))
99 .map(|bytes| bytes.clamp(min, max))
100 .unwrap_or(default)
101 }
102
103 fn max_concurrent_vms() -> usize {
104 env::var(VM_MAX_CONCURRENT_ENV)
105 .ok()
106 .and_then(|raw| raw.parse::<usize>().ok())
107 .map(|value| value.clamp(1, MAX_CONCURRENT_VMS))
108 .unwrap_or(DEFAULT_MAX_CONCURRENT_VMS)
109 }
110
111 fn vm_thread_stack_bytes() -> usize {
112 env_usize_bytes(
113 VM_THREAD_STACK_KB_ENV,
114 1024,
115 MIN_VM_THREAD_STACK_BYTES,
116 MAX_VM_THREAD_STACK_BYTES,
117 DEFAULT_VM_THREAD_STACK_BYTES,
118 )
119 }
120
121 fn vm_admission() -> &'static Arc<Semaphore> {
122 static ADMISSION: OnceLock<Arc<Semaphore>> = OnceLock::new();
123 ADMISSION.get_or_init(|| Arc::new(Semaphore::new(max_concurrent_vms())))
124 }
125
126 /// Executes Workflow scripts, one isolated QuickJS runtime per run.
127 ///
128 /// Every [`WorkflowVm::run_script`] call spins up a fresh interpreter on a
129 /// dedicated thread, so runs share nothing (globals, heap, interned atoms)
130 /// and a wedged script can never stall a sibling run.
131 #[derive(Debug, Clone, Default)]
132 pub struct WorkflowVm {
133 limits: VmLimits,
134 }
135
136 impl WorkflowVm {
137 /// A VM with the default [`VmLimits`].
138 pub fn new() -> Self {
139 Self::default()
140 }
141
142 /// A VM with explicit resource limits.
143 pub fn with_limits(limits: VmLimits) -> Self {
144 Self { limits }
145 }
146
147 /// Run one Workflow script to completion.
148 ///
149 /// * `source` is the script body; it is wrapped in an async function, so
150 /// top-level `await` and `return` both work. The returned value is the
151 /// script's `return` value, JSON-encoded (`undefined` becomes `null`).
152 /// * `args` is exposed verbatim to the script as the `args` global.
153 /// * `driver` executes `task()` spawns and receives progress events. A
154 /// driver instance is scoped to exactly one run: `cancel_all` is always
155 /// invoked at run teardown (success, script error, or cancellation), so
156 /// stray children never outlive the script that spawned them.
157 ///
158 /// Cancellation cascade (design §9): dropping the returned future cancels
159 /// the run — the interrupt handler aborts executing JS, pending `task()`
160 /// awaits resolve to errors, and `driver.cancel_all()` is invoked
161 /// immediately from the dropping thread.
162 pub async fn run_script(
163 &self,
164 source: &str,
165 args: serde_json::Value,
166 driver: Arc<dyn WorkflowDriver>,
167 ) -> Result<serde_json::Value, WorkflowJsError> {
168 self.run_script_with_cancel(source, args, driver, WorkflowRunCancel::new())
169 .await
170 }
171
172 /// Like [`Self::run_script`], but accepts an external cancel handle so the
173 /// host can interrupt the VM without dropping the run future.
174 pub async fn run_script_with_cancel(
175 &self,
176 source: &str,
177 args: serde_json::Value,
178 driver: Arc<dyn WorkflowDriver>,
179 cancel: WorkflowRunCancel,
180 ) -> Result<serde_json::Value, WorkflowJsError> {
181 self.run_inner(source, args, driver, None, cancel).await
182 }
183
184 /// Run a code-mode script: the same VM, plus the `tools.call()` host
185 /// binding backed by `invoker`. Workflow runs (no invoker) never see the
186 /// binding, so the Workflow sandbox keeps its documented host surface.
187 pub async fn run_tools_script(
188 &self,
189 source: &str,
190 args: serde_json::Value,
191 driver: Arc<dyn WorkflowDriver>,
192 invoker: Arc<dyn ToolInvoker>,
193 cancel: WorkflowRunCancel,
194 ) -> Result<serde_json::Value, WorkflowJsError> {
195 self.run_inner(source, args, driver, Some(invoker), cancel)
196 .await
197 }
198
199 async fn run_inner(
200 &self,
201 source: &str,
202 args: serde_json::Value,
203 driver: Arc<dyn WorkflowDriver>,
204 invoker: Option<Arc<dyn ToolInvoker>>,
205 cancel: WorkflowRunCancel,
206 ) -> Result<serde_json::Value, WorkflowJsError> {
207 let args_json = serde_json::to_string(&args)
208 .map_err(|err| WorkflowJsError::InvalidArgs(err.to_string()))?;
209 let cancel = cancel.0;
210 let (result_tx, result_rx) = oneshot::channel();
211 let mut guard = RunGuard {
212 cancel: cancel.clone(),
213 driver: driver.clone(),
214 armed: true,
215 };
216
217 let permit = vm_admission()
218 .clone()
219 .acquire_owned()
220 .await
221 .map_err(|_| WorkflowJsError::VmInit("VM admission gate closed".to_string()))?;
222 let limits = self.limits;
223 let source = source.to_string();
224 let thread_driver = driver.clone();
225 let thread_cancel = cancel.clone();
226 let thread_invoker = invoker.clone();
227 let spawned = std::thread::Builder::new()
228 .name("workflow-js-vm".to_string())
229 .stack_size(vm_thread_stack_bytes())
230 .spawn(move || {
231 let _permit: OwnedSemaphorePermit = permit;
232 let outcome = vm_thread_main(
233 source,
234 args_json,
235 thread_driver.clone(),
236 thread_cancel,
237 thread_invoker,
238 limits,
239 );
240 // Run teardown: this driver is scoped to one run, so any task
241 // still in flight is unreachable now — cancel the cascade.
242 thread_driver.cancel_all();
243 let _ = result_tx.send(outcome);
244 });
245 if let Err(err) = spawned {
246 guard.armed = false;
247 return Err(WorkflowJsError::VmInit(format!(
248 "failed to spawn VM thread: {err}"
249 )));
250 }
251
252 match result_rx.await {
253 Ok(outcome) => {
254 // The VM thread has already torn down and cancelled children.
255 guard.armed = false;
256 outcome
257 }
258 // VM thread panicked before reporting; leave the guard armed so
259 // its drop (right now, at return) cancels outstanding tasks.
260 Err(_) => Err(WorkflowJsError::VmTerminated(
261 "VM thread exited without reporting a result".to_string(),
262 )),
263 }
264 }
265 }
266
267 /// Cooperative cancel signal shared by the run future (guard side) and the VM
268 /// thread. The atomic flag feeds the QuickJS interrupt handler (sync, called
269 /// mid-bytecode); the watch channel wakes host futures parked on driver
270 /// completions.
271 #[derive(Clone)]
272 pub struct WorkflowRunCancel(CancelHandle);
273
274 impl WorkflowRunCancel {
275 #[must_use]
276 pub fn new() -> Self {
277 Self(CancelHandle::new())
278 }
279
280 pub fn cancel(&self) {
281 self.0.cancel();
282 }
283 }
284
285 impl Default for WorkflowRunCancel {
286 fn default() -> Self {
287 Self::new()
288 }
289 }
290
291 #[derive(Clone)]
292 struct CancelHandle {
293 flag: Arc<AtomicBool>,
294 tx: Arc<watch::Sender<bool>>,
295 }
296
297 impl CancelHandle {
298 fn new() -> Self {
299 let (tx, _rx) = watch::channel(false);
300 Self {
301 flag: Arc::new(AtomicBool::new(false)),
302 tx: Arc::new(tx),
303 }
304 }
305
306 fn cancel(&self) {
307 self.flag.store(true, Ordering::SeqCst);
308 self.tx.send_replace(true);
309 }
310
311 fn is_cancelled(&self) -> bool {
312 self.flag.load(Ordering::SeqCst)
313 }
314
315 async fn cancelled(&self) {
316 let mut rx = self.tx.subscribe();
317 let _ = rx.wait_for(|cancelled| *cancelled).await;
318 }
319
320 fn flag_arc(&self) -> Arc<AtomicBool> {
321 self.flag.clone()
322 }
323 }
324
325 /// Fires the cancel cascade if the caller drops the run future before the VM
326 /// reports a result.
327 struct RunGuard {
328 cancel: CancelHandle,
329 driver: Arc<dyn WorkflowDriver>,
330 armed: bool,
331 }
332
333 impl Drop for RunGuard {
334 fn drop(&mut self) {
335 if self.armed {
336 self.cancel.cancel();
337 self.driver.cancel_all();
338 }
339 }
340 }
341
342 fn vm_thread_main(
343 source: String,
344 args_json: String,
345 driver: Arc<dyn WorkflowDriver>,
346 cancel: CancelHandle,
347 invoker: Option<Arc<dyn ToolInvoker>>,
348 limits: VmLimits,
349 ) -> Result<serde_json::Value, WorkflowJsError> {
350 let reactor = tokio::runtime::Builder::new_current_thread()
351 .enable_all()
352 .build()
353 .map_err(|err| WorkflowJsError::VmInit(format!("failed to build VM reactor: {err}")))?;
354 reactor.block_on(run_in_vm(
355 source, args_json, driver, cancel, invoker, limits,
356 ))
357 }
358
359 async fn run_in_vm(
360 source: String,
361 args_json: String,
362 driver: Arc<dyn WorkflowDriver>,
363 cancel: CancelHandle,
364 invoker: Option<Arc<dyn ToolInvoker>>,
365 limits: VmLimits,
366 ) -> Result<serde_json::Value, WorkflowJsError> {
367 let runtime = AsyncRuntime::new().map_err(|err| WorkflowJsError::VmInit(err.to_string()))?;
368 runtime.set_memory_limit(limits.memory_limit_bytes).await;
369 runtime.set_max_stack_size(limits.max_stack_bytes).await;
370 let interrupt_flag = cancel.flag_arc();
371 runtime
372 .set_interrupt_handler(Some(Box::new(move || {
373 interrupt_flag.load(Ordering::Acquire)
374 })))
375 .await;
376 let context = AsyncContext::full(&runtime)
377 .await
378 .map_err(|err| WorkflowJsError::VmInit(err.to_string()))?;
379
380 let result = context
381 .async_with(async |ctx| run_in_ctx(ctx, source, args_json, driver, invoker, cancel).await)
382 .await;
383 drop(context);
384 runtime.run_gc().await;
385 result
386 }
387
388 async fn run_in_ctx(
389 ctx: Ctx<'_>,
390 source: String,
391 args_json: String,
392 driver: Arc<dyn WorkflowDriver>,
393 invoker: Option<Arc<dyn ToolInvoker>>,
394 cancel: CancelHandle,
395 ) -> Result<serde_json::Value, WorkflowJsError> {
396 install_host(&ctx, driver, invoker.clone(), cancel.clone(), &args_json)?;
397 ctx.eval::<(), _>(prelude())
398 .catch(&ctx)
399 .map_err(|err| WorkflowJsError::VmInit(format!("prelude failed: {err}")))?;
400 if invoker.is_some() {
401 ctx.eval::<(), _>(CODEMODE_PRELUDE)
402 .catch(&ctx)
403 .map_err(|err| WorkflowJsError::VmInit(format!("codemode prelude failed: {err}")))?;
404 }
405
406 let desugared = desugar_export_default(&source);
407 let wrapped = format!("(async () => {{\n{desugared}\n}})()");
408 let promise = ctx
409 .eval::<Promise, _>(wrapped)
410 .catch(&ctx)
411 .map_err(|err| script_error(&cancel, err))?;
412 let value = promise
413 .into_future::<Value>()
414 .await
415 .catch(&ctx)
416 .map_err(|err| script_error(&cancel, err))?;
417 js_value_to_json(&ctx, value)
418 }
419
420 /// Rewrite the documented module-style authoring shape
421 /// (`export default async function (args) { ... }`) into the script form the
422 /// VM actually evals. Sources are wrapped in an async IIFE, where the
423 /// module-only `export` keyword is a syntax error, so without this every
424 /// imperative `export default` workflow (including the #4131 dogfood
425 /// fixtures) failed to parse. The default export is captured, invoked with
426 /// the `args` global when it is a function, and its result becomes the run
427 /// result; a non-function default export is returned as-is.
428 fn desugar_export_default(source: &str) -> String {
429 const EXPORT_DEFAULT: &str = "export default";
430 let Some(offset) = line_leading_export_default(source) else {
431 return source.to_string();
432 };
433 let mut out = source.to_string();
434 out.replace_range(
435 offset..offset + EXPORT_DEFAULT.len(),
436 "globalThis.__workflow_default =",
437 );
438 out.push('\n');
439 out.push_str(
440 ";{\n const __wf_default = globalThis.__workflow_default;\n delete globalThis.__workflow_default;\n if (typeof __wf_default === \"function\") {\n return await __wf_default(args);\n }\n if (__wf_default !== undefined) {\n return __wf_default;\n }\n}\n",
441 );
442 out
443 }
444
445 /// Return the byte offset of a line-leading `export default` token that is
446 /// actual JavaScript syntax, not text inside a string, template literal, or
447 /// comment. This intentionally recognizes only the documented authoring shape
448 /// instead of attempting to implement a general JavaScript module parser.
449 fn line_leading_export_default(source: &str) -> Option<usize> {
450 const EXPORT_DEFAULT: &[u8] = b"export default";
451 let bytes = source.as_bytes();
452 let mut idx = 0usize;
453 let mut quote = None;
454 let mut escaped = false;
455 let mut line_comment = false;
456 let mut block_comment = false;
457 let mut line_has_only_whitespace = true;
458
459 while idx < bytes.len() {
460 let byte = bytes[idx];
461
462 if line_comment {
463 if byte == b'\n' {
464 line_comment = false;
465 line_has_only_whitespace = true;
466 }
467 idx += 1;
468 continue;
469 }
470
471 if block_comment {
472 if byte == b'*' && bytes.get(idx + 1) == Some(&b'/') {
473 block_comment = false;
474 line_has_only_whitespace = false;
475 idx += 2;
476 continue;
477 }
478 if byte == b'\n' {
479 line_has_only_whitespace = true;
480 } else if !byte.is_ascii_whitespace() {
481 line_has_only_whitespace = false;
482 }
483 idx += 1;
484 continue;
485 }
486
487 if let Some(active_quote) = quote {
488 if byte == b'\n' {
489 line_has_only_whitespace = true;
490 escaped = false;
491 } else {
492 if !byte.is_ascii_whitespace() {
493 line_has_only_whitespace = false;
494 }
495 if escaped {
496 escaped = false;
497 } else if byte == b'\\' {
498 escaped = true;
499 } else if byte == active_quote {
500 quote = None;
501 }
502 }
503 idx += 1;
504 continue;
505 }
506
507 if byte == b'\n' {
508 line_has_only_whitespace = true;
509 idx += 1;
510 continue;
511 }
512 if line_has_only_whitespace && byte.is_ascii_whitespace() {
513 idx += 1;
514 continue;
515 }
516 if line_has_only_whitespace && bytes[idx..].starts_with(EXPORT_DEFAULT) {
517 return Some(idx);
518 }
519
520 line_has_only_whitespace = false;
521 if byte == b'/' && bytes.get(idx + 1) == Some(&b'/') {
522 line_comment = true;
523 idx += 2;
524 } else if byte == b'/' && bytes.get(idx + 1) == Some(&b'*') {
525 block_comment = true;
526 idx += 2;
527 } else {
528 if matches!(byte, b'\'' | b'"' | b'`') {
529 quote = Some(byte);
530 }
531 idx += 1;
532 }
533 }
534
535 None
536 }
537
538 fn script_error(cancel: &CancelHandle, err: CaughtError<'_>) -> WorkflowJsError {
539 if cancel.is_cancelled() {
540 WorkflowJsError::Cancelled
541 } else {
542 WorkflowJsError::Script(err.to_string())
543 }
544 }
545
546 fn js_value_to_json<'js>(
547 ctx: &Ctx<'js>,
548 value: Value<'js>,
549 ) -> Result<serde_json::Value, WorkflowJsError> {
550 if value.is_undefined() {
551 return Ok(serde_json::Value::Null);
552 }
553 let text = ctx
554 .json_stringify(value)
555 .map_err(|err| WorkflowJsError::ResultEncoding(err.to_string()))?;
556 match text {
557 None => Ok(serde_json::Value::Null),
558 Some(text) => {
559 let text = text
560 .to_string()
561 .map_err(|err| WorkflowJsError::ResultEncoding(err.to_string()))?;
562 serde_json::from_str(&text)
563 .map_err(|err| WorkflowJsError::ResultEncoding(err.to_string()))
564 }
565 }
566 }
567
568 /// Prelude fragment for code-mode runs only: captures `__codemode_call` into
569 /// the frozen `globalThis.tools` surface, then deletes the raw binding.
570 /// Workflow runs never eval this, so their documented host surface is
571 /// unchanged — `tools` simply does not exist there.
572 const CODEMODE_PRELUDE: &str = r#"
573 (() => {
574 const hostCall = __codemode_call;
575 globalThis.tools = Object.freeze({
576 call: async (tool, input) => {
577 const envelope = JSON.parse(
578 await hostCall(JSON.stringify({ tool, input: input === undefined ? {} : input }))
579 );
580 if (envelope.error !== undefined) {
581 const err = new Error(envelope.error);
582 err.kind = envelope.error_kind;
583 throw err;
584 }
585 return envelope.result;
586 },
587 });
588 try { delete globalThis.__codemode_call; } catch (_) { /* frozen shape */ }
589 })();
590 "#;
591
592 /// The `tools.call()` host call. Infallible at the binding level, like
593 /// `task_host`: outcomes return through the `{result}` /
594 /// `{error, error_kind}` envelope so the prelude rethrows typed errors.
595 /// Gate refusals arrive as admission errors (nothing ran); a nested tool
596 /// that ran and failed arrives as an agent error (work failed).
597 async fn tools_call_host(
598 call_json: String,
599 invoker: Arc<dyn ToolInvoker>,
600 cancel: CancelHandle,
601 invoked: Rc<Cell<u64>>,
602 ) -> String {
603 let outcome = tools_call_host_inner(call_json, invoker, cancel, invoked).await;
604 let envelope = match outcome {
605 Ok(result) => serde_json::json!({ "result": result }),
606 Err(TaskError { kind, message }) => {
607 serde_json::json!({ "error": message, "error_kind": kind.as_str() })
608 }
609 };
610 envelope.to_string()
611 }
612
613 async fn tools_call_host_inner(
614 call_json: String,
615 invoker: Arc<dyn ToolInvoker>,
616 cancel: CancelHandle,
617 invoked: Rc<Cell<u64>>,
618 ) -> Result<serde_json::Value, TaskError> {
619 let admission = |message: String| TaskError::new(TaskErrorKind::Admission, message);
620 let request: ToolCallRequest = serde_json::from_str(&call_json).map_err(|err| {
621 admission(format!(
622 "tools.call(): expected {{\"tool\", \"input\"}} JSON: {err}"
623 ))
624 })?;
625 if request.tool.trim().is_empty() {
626 return Err(admission(
627 "tools.call(): `tool` must be a non-empty string".to_string(),
628 ));
629 }
630 if !request.input.is_object() {
631 return Err(admission(
632 "tools.call(): `input` must be a JSON object".to_string(),
633 ));
634 }
635 if invoked.get() >= CODEMODE_MAX_TOOL_CALLS {
636 return Err(admission(format!(
637 "tools.call(): per-run tool-call cap ({CODEMODE_MAX_TOOL_CALLS}) reached"
638 )));
639 }
640 if cancel.is_cancelled() {
641 return Err(TaskError::new(
642 TaskErrorKind::Cancelled,
643 "tools.call(): run cancelled".to_string(),
644 ));
645 }
646 invoked.set(invoked.get() + 1);
647 let response = tokio::select! {
648 _ = cancel.cancelled() => {
649 return Err(TaskError::new(
650 TaskErrorKind::Cancelled,
651 "tools.call(): run cancelled".to_string(),
652 ));
653 }
654 response = invoker.invoke(request) => response.map_err(|err| {
655 TaskError::new(
656 TaskErrorKind::from(&err),
657 format!("tools.call(): {err}"),
658 )
659 })?,
660 };
661 if response.ok {
662 Ok(response.result)
663 } else {
664 let message = response
665 .result
666 .as_str()
667 .unwrap_or("tools.call(): tool failed without a message")
668 .to_string();
669 Err(TaskError::new(TaskErrorKind::Agent, message))
670 }
671 }
672
673 fn install_host(
674 ctx: &Ctx<'_>,
675 driver: Arc<dyn WorkflowDriver>,
676 invoker: Option<Arc<dyn ToolInvoker>>,
677 cancel: CancelHandle,
678 args_json: &str,
679 ) -> Result<(), WorkflowJsError> {
680 let globals = ctx.globals();
681
682 let args_value: Value = ctx
683 .json_parse(args_json)
684 .map_err(|err| WorkflowJsError::InvalidArgs(err.to_string()))?;
685 globals.set("args", args_value).map_err(init_err)?;
686
687 // Per-run lifetime counter (design §4.3): counts spawn *attempts*, and the
688 // check + increment happen with no await in between so a parallel burst
689 // cannot slip past the cap on the single-threaded VM.
690 let spawned = Rc::new(Cell::new(0u64));
691
692 let task_driver = driver.clone();
693 let task_cancel = cancel.clone();
694 globals
695 .set(
696 "__workflow_task",
697 Func::from(Async(move |opts_json: String| {
698 let driver = task_driver.clone();
699 let cancel = task_cancel.clone();
700 let spawned = spawned.clone();
701 async move { task_host(opts_json, driver, cancel, spawned).await }
702 })),
703 )
704 .map_err(init_err)?;
705
706 // Code-mode runs only: per-run tool-call counter. Same single-threaded
707 // check+increment discipline as `spawned` above — no await between the
708 // cap check and the increment, so a burst cannot slip past it.
709 if let Some(invoker) = invoker {
710 let invoked = Rc::new(Cell::new(0u64));
711 let call_invoker = invoker.clone();
712 let call_cancel = cancel.clone();
713 globals
714 .set(
715 "__codemode_call",
716 Func::from(Async(move |call_json: String| {
717 let invoker = call_invoker.clone();
718 let cancel = call_cancel.clone();
719 let invoked = invoked.clone();
720 async move { tools_call_host(call_json, invoker, cancel, invoked).await }
721 })),
722 )
723 .map_err(init_err)?;
724 }
725
726 let log_driver = driver.clone();
727 globals
728 .set(
729 "__workflow_log",
730 Func::from(move |message: String| {
731 log_driver.progress(ProgressEvent::Log { message });
732 }),
733 )
734 .map_err(init_err)?;
735
736 // Structured twin of the prelude's "every slot failed" breadcrumb (R9):
737 // a dead fan-out of script-thrown thunks leaves no task record behind,
738 // so the host needs a typed event — not a log line — to keep the run's
739 // terminal status honest.
740 let fanout_driver = driver.clone();
741 globals
742 .set(
743 "__workflow_every_slot_failed",
744 Func::from(move |construct: String, failed: u32, total: u32| {
745 fanout_driver.progress(ProgressEvent::FanoutAllSlotsFailed {
746 construct,
747 failed,
748 total,
749 });
750 }),
751 )
752 .map_err(init_err)?;
753
754 // Structured twin of the per-slot "dropped a failed slot as null"
755 // breadcrumb (R9): a PARTIALLY failed fan-out still resolves and leaves
756 // no task record for the dropped slot, so without this event the ledger
757 // cannot see the loss and records a clean Completed.
758 let fanout_driver = driver.clone();
759 globals
760 .set(
761 "__workflow_slot_dropped",
762 Func::from(move |construct: String, kind: String, slot: u32| {
763 fanout_driver.progress(ProgressEvent::FanoutSlotDropped {
764 construct,
765 kind,
766 slot,
767 });
768 }),
769 )
770 .map_err(init_err)?;
771
772 let phase_driver = driver.clone();
773 globals
774 .set(
775 "__workflow_phase",
776 Func::from(move |title: String| {
777 phase_driver.progress(ProgressEvent::Phase { title });
778 }),
779 )
780 .map_err(init_err)?;
781
782 // Budget reads are live driver snapshots (design §5.2). NaN encodes
783 // "no ceiling" for `total`; the prelude maps it to `null`.
784 let total_driver = driver.clone();
785 globals
786 .set(
787 "__workflow_budget_total",
788 Func::from(move || -> f64 {
789 match total_driver.budget().total {
790 Some(total) => total as f64,
791 None => f64::NAN,
792 }
793 }),
794 )
795 .map_err(init_err)?;
796
797 let spent_driver = driver.clone();
798 globals
799 .set(
800 "__workflow_budget_spent",
801 Func::from(move || -> f64 { spent_driver.budget().spent as f64 }),
802 )
803 .map_err(init_err)?;
804
805 globals
806 .set(
807 "__workflow_budget_remaining",
808 Func::from(move || -> f64 {
809 match driver.budget().remaining() {
810 Some(remaining) => remaining as f64,
811 None => f64::INFINITY,
812 }
813 }),
814 )
815 .map_err(init_err)?;
816
817 Ok(())
818 }
819
820 fn init_err(err: rquickjs::Error) -> WorkflowJsError {
821 WorkflowJsError::VmInit(err.to_string())
822 }
823
824 /// The `task()` host call. Everything that can go wrong is reported through
825 /// the JSON envelope (`{"error": ..., "error_kind": ...}`) so the prelude
826 /// re-throws it as a real JS `Error` with a script-side stack and a typed
827 /// [`TaskErrorKind`] on `.kind` (R9). The kind is assigned here, where the
828 /// failure actually happened — never re-derived from the message text.
829 async fn task_host(
830 opts_json: String,
831 driver: Arc<dyn WorkflowDriver>,
832 cancel: CancelHandle,
833 spawned: Rc<Cell<u64>>,
834 ) -> String {
835 let outcome = task_host_inner(opts_json, driver, cancel, spawned).await;
836 let envelope = match outcome {
837 Ok(value) => serde_json::json!({ "value": value }),
838 Err(TaskError { kind, message }) => {
839 serde_json::json!({ "error": message, "error_kind": kind.as_str() })
840 }
841 };
842 envelope.to_string()
843 }
844
845 /// Best-effort `label`/`phase` from raw `task()` options, for rejection
846 /// receipts when the options never survived parsing.
847 fn task_identity_hint(opts_json: &str) -> (Option<String>, Option<String>) {
848 let value: serde_json::Value =
849 serde_json::from_str(opts_json).unwrap_or(serde_json::Value::Null);
850 let pluck = |key: &str| {
851 value
852 .get(key)
853 .and_then(serde_json::Value::as_str)
854 .map(str::trim)
855 .filter(|text| !text.is_empty())
856 .map(str::to_string)
857 };
858 (pluck("label"), pluck("phase"))
859 }
860
861 /// Record a pre-spawn `task()` rejection on the host ledger, then hand the
862 /// message back for the JS throw. Rejections that never reach `spawn_task`
863 /// would otherwise be invisible to the run record (#5035's surviving gap).
864 fn reject_task(driver: &Arc<dyn WorkflowDriver>, opts_json: &str, message: String) -> String {
865 let (label, phase) = task_identity_hint(opts_json);
866 driver.progress(ProgressEvent::TaskRejected {
867 label,
868 phase,
869 message: message.clone(),
870 });
871 message
872 }
873
874 /// Emit the terminal schema-failure receipt for a `task()` whose reply failed
875 /// `responseSchema` with no repair left to try, and hand the message back for
876 /// the JS throw. `note` (when present) names why a repair was skipped, so the
877 /// operator can tell "repair refused to run" from "repair also failed".
878 fn fail_schema(
879 driver: &Arc<dyn WorkflowDriver>,
880 task_id: String,
881 attempt: u32,
882 error: &ReplyDecodeError,
883 raw: String,
884 raw_truncated: bool,
885 note: Option<String>,
886 ) -> String {
887 let message = match note {
888 Some(note) => format!("{} (repair skipped: {note})", error.message()),
889 None => error.message().to_string(),
890 };
891 driver.progress(ProgressEvent::TaskSchemaValidationFailed {
892 task_id,
893 kind: error.kind().to_string(),
894 attempt,
895 message: message.clone(),
896 raw,
897 raw_truncated,
898 });
899 message
900 }
901
902 /// Build the repair request for `next_attempt` (#5583): the same child
903 /// identity, budget fields, and schema as the original, with a repair prompt
904 /// carrying the original task, the schema, the failed reply, and why it
905 /// failed, plus the wall clock the first attempt did not spend.
906 fn repair_request(
907 original: &TaskRequest,
908 next_attempt: u32,
909 error: &ReplyDecodeError,
910 failed_raw: &str,
911 wall_time_secs: Option<u64>,
912 ) -> TaskRequest {
913 let mut request = original.clone();
914 let schema = original
915 .response_schema
916 .as_ref()
917 .expect("repair only runs when responseSchema is set");
918 // The bracket prefix identifies the repair at a glance on progress
919 // surfaces and lets tests script the repair reply by rule order.
920 request.description = format!(
921 "[schema repair {next_attempt}] {}",
922 repair_prompt(&original.description, schema, failed_raw, error)
923 );
924 request.wall_time_secs = wall_time_secs;
925 // Label and phase stay inherited so progress surfaces group the repair
926 // with its task.
927 request
928 }
929
930 /// The one cancellation error: the run's deadline fired. Fatal in every
931 /// `parallel()` / `pipeline()` mode — a cancelled run must never resolve into
932 /// a slot value.
933 fn cancelled_task() -> TaskError {
934 TaskError::new(TaskErrorKind::Cancelled, "task(): run cancelled")
935 }
936
937 /// A terminal `responseSchema` failure, already receipted by [`fail_schema`].
938 fn schema_task(message: String) -> TaskError {
939 TaskError::new(TaskErrorKind::Schema, message)
940 }
941
942 async fn task_host_inner(
943 opts_json: String,
944 driver: Arc<dyn WorkflowDriver>,
945 cancel: CancelHandle,
946 spawned: Rc<Cell<u64>>,
947 ) -> Result<serde_json::Value, TaskError> {
948 let admission = |message: String| TaskError::new(TaskErrorKind::Admission, message);
949 let request = parse_task_options(&opts_json)
950 .map_err(|message| admission(reject_task(&driver, &opts_json, message)))?;
951 // Compile the schema before spawning so a malformed one fails fast
952 // instead of burning a subagent.
953 let validator = request
954 .response_schema
955 .as_ref()
956 .map(compile_schema)
957 .transpose()
958 .map_err(|message| admission(reject_task(&driver, &opts_json, message)))?;
959
960 // Lifetime backstop (design §4.3) — checked and bumped before any await.
961 if spawned.get() >= WORKFLOW_LIFETIME_CAP {
962 return Err(admission(reject_task(
963 &driver,
964 &opts_json,
965 format!(
966 "task(): Workflow lifetime agent cap ({WORKFLOW_LIFETIME_CAP}) reached for this run"
967 ),
968 )));
969 }
970 // Fast-fail budget gate. The authoritative reservation lives in the
971 // driver (design §5.3); this only stops obviously-doomed spawns early.
972 let snapshot = driver.budget();
973 if snapshot.exhausted() {
974 return Err(TaskError::new(
975 TaskErrorKind::Budget,
976 reject_task(
977 &driver,
978 &opts_json,
979 format!(
980 "task(): budget exhausted ({} of {} tokens spent)",
981 snapshot.spent,
982 snapshot.total.unwrap_or(0)
983 ),
984 ),
985 ));
986 }
987 if cancel.is_cancelled() {
988 return Err(cancelled_task());
989 }
990
991 // Bounded schema repair (#5583): after a failed `responseSchema` decode,
992 // re-ask the same route before throwing. `None` is the default single
993 // repair; `Some(0)` disables it. The first attempt is attempt 1, so the
994 // task is schema-terminal once `attempt` reaches this ceiling.
995 let last_attempt = 1 + request.schema_repair_attempts.unwrap_or(1);
996 // The wall clock is shared across attempts: a repair inherits the time
997 // the first attempt did not spend, not a fresh budget.
998 let started = std::time::Instant::now();
999 let mut wall_time_secs_left = request.wall_time_secs;
1000 let mut current = request.clone();
1001 let mut attempt: u32 = 0;
1002 loop {
1003 attempt += 1;
1004 spawned.set(spawned.get() + 1);
1005 let spawned_task = driver
1006 .spawn_task(current.clone())
1007 .await
1008 .map_err(|err| TaskError::new(TaskErrorKind::from(&err), err.to_string()))?;
1009 let task_id = spawned_task.task_id;
1010 let completion_rx = spawned_task.completion;
1011 let completion = tokio::select! {
1012 _ = cancel.cancelled() => return Err(cancelled_task()),
1013 completion = completion_rx => completion.map_err(|_| {
1014 TaskError::new(
1015 TaskErrorKind::Driver,
1016 "task(): driver dropped the completion channel",
1017 )
1018 })?,
1019 };
1020
1021 let text = match completion {
1022 TaskCompletion::Completed { text } => text,
1023 TaskCompletion::Failed { message } => {
1024 return Err(TaskError::new(
1025 TaskErrorKind::Agent,
1026 format!("task(): subagent failed: {message}"),
1027 ));
1028 }
1029 TaskCompletion::Cancelled => {
1030 return Err(TaskError::new(
1031 TaskErrorKind::Cancelled,
1032 "task(): subagent cancelled",
1033 ));
1034 }
1035 TaskCompletion::BudgetExhausted { message } => {
1036 return Err(TaskError::new(
1037 TaskErrorKind::Budget,
1038 format!("task(): budget exhausted: {message}"),
1039 ));
1040 }
1041 };
1042 // Without a schema the raw text is the contract; with one, the decode
1043 // decides — and a failure may still be repaired.
1044 let Some(validator) = validator.as_ref() else {
1045 return Ok(serde_json::Value::String(text));
1046 };
1047 let error = match decode_reply(&text, validator) {
1048 Ok(value) => return Ok(value),
1049 Err(error) => error,
1050 };
1051 let (raw, raw_truncated) = carried_raw(&text);
1052 if attempt >= last_attempt {
1053 return Err(schema_task(fail_schema(
1054 &driver,
1055 task_id,
1056 attempt,
1057 &error,
1058 raw,
1059 raw_truncated,
1060 None,
1061 )));
1062 }
1063 // The attempt failed but a repair remains: record it as a receipt
1064 // (visible even when the repair succeeds), then re-run the admission
1065 // gates — a repair is a real child, not a free retry.
1066 driver.progress(ProgressEvent::TaskSchemaRepairAttempted {
1067 task_id: task_id.clone(),
1068 kind: error.kind().to_string(),
1069 attempt,
1070 message: error.message().to_string(),
1071 raw: raw.clone(),
1072 raw_truncated,
1073 });
1074 if spawned.get() >= WORKFLOW_LIFETIME_CAP {
1075 return Err(schema_task(fail_schema(
1076 &driver,
1077 task_id,
1078 attempt,
1079 &error,
1080 raw,
1081 raw_truncated,
1082 Some(format!(
1083 "workflow lifetime agent cap ({WORKFLOW_LIFETIME_CAP}) reached"
1084 )),
1085 )));
1086 }
1087 let snapshot = driver.budget();
1088 if snapshot.exhausted() {
1089 return Err(schema_task(fail_schema(
1090 &driver,
1091 task_id,
1092 attempt,
1093 &error,
1094 raw,
1095 raw_truncated,
1096 Some("budget exhausted".to_string()),
1097 )));
1098 }
1099 if cancel.is_cancelled() {
1100 return Err(cancelled_task());
1101 }
1102 if let Some(wall) = wall_time_secs_left {
1103 let remaining = wall.saturating_sub(started.elapsed().as_secs());
1104 if remaining == 0 {
1105 return Err(schema_task(fail_schema(
1106 &driver,
1107 task_id,
1108 attempt,
1109 &error,
1110 raw,
1111 raw_truncated,
1112 Some("no wall-time left from wallTimeSecs".to_string()),
1113 )));
1114 }
1115 wall_time_secs_left = Some(remaining);
1116 }
1117 current = repair_request(&request, attempt + 1, &error, &raw, wall_time_secs_left);
1118 }
1119 }
1120
1121 /// JS-facing option names for `task()` (design §3.3). Unknown fields are
1122 /// rejected so a typo (`responseschema`) fails loudly instead of being
1123 /// silently dropped. Every multi-word field also accepts its snake_case
1124 /// spelling, and the `agent` tool's `workspace_policy` name is accepted as an
1125 /// alias for worktree isolation — the two spawn surfaces are written by the
1126 /// same authors (often models), so a schema that runs on one must not be an
1127 /// unknown-field error on the other.
1128 #[derive(Debug, Deserialize)]
1129 #[serde(rename_all = "camelCase", deny_unknown_fields)]
1130 struct TaskOptions {
1131 #[serde(alias = "title")]
1132 description: Option<String>,
1133 prompt: Option<String>,
1134 #[serde(alias = "type", alias = "subagent_type")]
1135 subagent_type: Option<String>,
1136 /// Fleet role name (#4177). Preferred step identity field.
1137 role: Option<String>,
1138 profile: Option<String>,
1139 model: Option<String>,
1140 #[serde(alias = "model_strength")]
1141 model_strength: Option<String>,
1142 thinking: Option<String>,
1143 cwd: Option<String>,
1144 #[serde(default)]
1145 worktree: bool,
1146 /// `agent`-tool alias for worktree isolation: "shared" | "worktree".
1147 #[serde(default, alias = "workspace_policy")]
1148 workspace_policy: Option<String>,
1149 #[serde(alias = "write_authority")]
1150 write_authority: Option<String>,
1151 #[serde(default, alias = "write_roots")]
1152 write_roots: Vec<String>,
1153 #[serde(default, alias = "exact_files")]
1154 exact_files: Vec<String>,
1155 #[serde(default, alias = "coordination_contracts")]
1156 coordination_contracts: Vec<String>,
1157 #[serde(default)]
1158 dependencies: Vec<String>,
1159 #[serde(default)]
1160 acceptance: Vec<String>,
1161 #[serde(alias = "allowed_tools")]
1162 allowed_tools: Option<Vec<String>>,
1163 #[serde(alias = "max_depth")]
1164 max_depth: Option<u32>,
1165 #[serde(alias = "token_budget")]
1166 token_budget: Option<u64>,
1167 #[serde(alias = "max_steps")]
1168 max_steps: Option<u32>,
1169 #[serde(alias = "wall_time_secs")]
1170 wall_time_secs: Option<u64>,
1171 #[serde(alias = "response_schema")]
1172 response_schema: Option<serde_json::Value>,
1173 /// Bounded `responseSchema` repair attempts after a failed decode
1174 /// (#5583): re-ask the same route with the schema and the failed reply.
1175 /// Defaults to one; `0` disables; capped at
1176 /// [`SCHEMA_REPAIR_MAX_ATTEMPTS`] so repair stays a bounded recovery.
1177 #[serde(default, alias = "schema_repair_attempts")]
1178 schema_repair_attempts: Option<u32>,
1179 label: Option<String>,
1180 phase: Option<String>,
1181 }
1182
1183 fn parse_task_options(opts_json: &str) -> Result<TaskRequest, String> {
1184 let mut options: TaskOptions =
1185 serde_json::from_str(opts_json).map_err(|err| format!("task(): invalid options: {err}"))?;
1186 if let Some(policy) = options.workspace_policy.take() {
1187 match policy.trim().to_ascii_lowercase().as_str() {
1188 "worktree" => options.worktree = true,
1189 "shared" => {
1190 if options.worktree {
1191 return Err(
1192 "task(): workspacePolicy 'shared' conflicts with worktree: true"
1193 .to_string(),
1194 );
1195 }
1196 }
1197 other => {
1198 return Err(format!(
1199 "task(): workspacePolicy must be shared or worktree; got {other:?}"
1200 ));
1201 }
1202 }
1203 }
1204 let description = options
1205 .prompt
1206 .or(options.description)
1207 .filter(|description| !description.trim().is_empty())
1208 .ok_or_else(|| "task(): 'description' (or 'prompt') is required".to_string())?;
1209 let role = options
1210 .role
1211 .as_deref()
1212 .map(normalize_profile)
1213 .transpose()
1214 .map_err(|err| format!("task(): role: {err}"))?;
1215 let profile = options
1216 .profile
1217 .as_deref()
1218 .map(normalize_profile)
1219 .transpose()
1220 .map_err(|err| format!("task(): {err}"))?;
1221 options.write_roots = normalize_task_paths("writeRoots", options.write_roots, 32)?;
1222 options.exact_files = normalize_task_paths("exactFiles", options.exact_files, 32)?;
1223 let cwd = options
1224 .cwd
1225 .take()
1226 .map(|value| normalize_task_paths("cwd", vec![value], 1))
1227 .transpose()?
1228 .and_then(|mut paths| paths.pop());
1229 options.coordination_contracts =
1230 normalize_task_string_list("coordinationContracts", options.coordination_contracts, 16)?;
1231 options.dependencies = normalize_task_string_list("dependencies", options.dependencies, 8)?;
1232 options.acceptance = normalize_task_string_list("acceptance", options.acceptance, 8)?;
1233 let write_authority = options
1234 .write_authority
1235 .as_deref()
1236 .map(|value| value.trim().to_ascii_lowercase())
1237 .map(|value| match value.as_str() {
1238 "read_only" | "workspace_write" | "worktree_write" => Ok(value),
1239 _ => Err(format!(
1240 "task(): writeAuthority must be read_only, workspace_write, or worktree_write; got {value:?}"
1241 )),
1242 })
1243 .transpose()?;
1244 if write_authority.as_deref() == Some("worktree_write") && !options.worktree {
1245 return Err("task(): writeAuthority worktree_write requires worktree: true".to_string());
1246 }
1247 let role_kind = role.as_deref().and_then(task_role_kind);
1248 let type_kind = options.subagent_type.as_deref().and_then(task_role_kind);
1249 if let (Some(role_kind), Some(type_kind)) = (role_kind, type_kind)
1250 && role_kind != type_kind
1251 {
1252 return Err("task(): role and subagentType declare contradictory authorities".to_string());
1253 }
1254 let declared_kind = role_kind.or(type_kind);
1255 if matches!(declared_kind, Some(TaskRoleKind::ReadOnly))
1256 && write_authority
1257 .as_deref()
1258 .is_some_and(|authority| authority != "read_only")
1259 {
1260 return Err("task(): read-only roles cannot declare write-capable authority".to_string());
1261 }
1262 if write_authority
1263 .as_deref()
1264 .is_some_and(|authority| authority != "read_only")
1265 && options.write_roots.is_empty()
1266 && options.exact_files.is_empty()
1267 && options.coordination_contracts.is_empty()
1268 {
1269 return Err(
1270 "task(): write-capable authority requires writeRoots, exactFiles, or coordinationContracts"
1271 .to_string(),
1272 );
1273 }
1274 let explicit_write_identity = declared_kind == Some(TaskRoleKind::Implementer)
1275 || (declared_kind == Some(TaskRoleKind::General)
1276 && (role.is_some() || options.subagent_type.is_some()))
1277 || (profile.is_some() && declared_kind.is_none());
1278 if explicit_write_identity
1279 && write_authority.as_deref() != Some("read_only")
1280 && options.write_roots.is_empty()
1281 && options.exact_files.is_empty()
1282 && options.coordination_contracts.is_empty()
1283 {
1284 return Err(
1285 "task(): explicit write-capable identities require writeRoots, exactFiles, or coordinationContracts"
1286 .to_string(),
1287 );
1288 }
1289 if let Some(attempts) = options.schema_repair_attempts
1290 && attempts > SCHEMA_REPAIR_MAX_ATTEMPTS
1291 {
1292 return Err(format!(
1293 "task(): schemaRepairAttempts is bounded to {SCHEMA_REPAIR_MAX_ATTEMPTS}; \
1294 repair is a bounded recovery, not a retry loop"
1295 ));
1296 }
1297 Ok(TaskRequest {
1298 description,
1299 subagent_type: options.subagent_type,
1300 role,
1301 profile,
1302 model: options.model,
1303 model_strength: options.model_strength,
1304 thinking: options.thinking,
1305 cwd,
1306 worktree: options.worktree,
1307 write_authority,
1308 write_roots: options.write_roots,
1309 exact_files: options.exact_files,
1310 coordination_contracts: options.coordination_contracts,
1311 dependencies: options.dependencies,
1312 acceptance: options.acceptance,
1313 allowed_tools: options.allowed_tools,
1314 // Host-imposed only: a script cannot set (or clear) a deny list.
1315 disallowed_tools: Vec::new(),
1316 max_depth: options.max_depth,
1317 token_budget: options.token_budget,
1318 max_steps: options.max_steps,
1319 wall_time_secs: options.wall_time_secs,
1320 response_schema: options.response_schema,
1321 schema_repair_attempts: options.schema_repair_attempts,
1322 label: options.label,
1323 phase: options.phase,
1324 })
1325 }
1326
1327 fn normalize_task_string_list(
1328 field: &str,
1329 values: Vec<String>,
1330 limit: usize,
1331 ) -> Result<Vec<String>, String> {
1332 if values.len() > limit {
1333 return Err(format!("task(): {field} accepts at most {limit} entries"));
1334 }
1335 let mut normalized = Vec::new();
1336 for value in values {
1337 let value = value.trim();
1338 if value.is_empty() || value.chars().count() > 512 {
1339 return Err(format!(
1340 "task(): {field} entries must be 1..=512 characters"
1341 ));
1342 }
1343 if !normalized.iter().any(|existing| existing == value) {
1344 normalized.push(value.to_string());
1345 }
1346 }
1347 Ok(normalized)
1348 }
1349
1350 fn normalize_task_paths(
1351 field: &str,
1352 values: Vec<String>,
1353 limit: usize,
1354 ) -> Result<Vec<String>, String> {
1355 if values.len() > limit {
1356 return Err(format!("task(): {field} accepts at most {limit} entries"));
1357 }
1358 let mut normalized = Vec::new();
1359 for raw in values {
1360 let raw = raw.trim().replace('\\', "/");
1361 let windows_drive = raw.as_bytes().get(1) == Some(&b':')
1362 && raw.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
1363 if raw.is_empty()
1364 || raw.chars().count() > 512
1365 || raw.starts_with('/')
1366 || raw.starts_with("//")
1367 || windows_drive
1368 || raw.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n'))
1369 {
1370 return Err(format!(
1371 "task(): {field} entries must be bounded repo-relative paths"
1372 ));
1373 }
1374 let mut segments = Vec::new();
1375 for segment in raw.split('/') {
1376 match segment {
1377 "" | "." => {}
1378 ".." => {
1379 return Err(format!(
1380 "task(): {field} paths cannot contain parent traversal"
1381 ));
1382 }
1383 value => segments.push(value),
1384 }
1385 }
1386 let path = if segments.is_empty() {
1387 ".".to_string()
1388 } else {
1389 segments.join("/")
1390 };
1391 if !normalized.contains(&path) {
1392 normalized.push(path);
1393 }
1394 }
1395 Ok(normalized)
1396 }
1397
1398 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1399 enum TaskRoleKind {
1400 ReadOnly,
1401 General,
1402 Implementer,
1403 }
1404
1405 fn task_role_kind(value: &str) -> Option<TaskRoleKind> {
1406 match value.trim().to_ascii_lowercase().as_str() {
1407 "explore" | "explorer" | "scout" | "plan" | "planner" | "review" | "reviewer"
1408 | "verify" | "verifier" => Some(TaskRoleKind::ReadOnly),
1409 "general" | "worker" => Some(TaskRoleKind::General),
1410 "implement" | "implementer" | "builder" => Some(TaskRoleKind::Implementer),
1411 _ => None,
1412 }
1413 }
1414
1415 /// The JS prelude injected before every script: determinism bans, the
1416 /// `task`/`parallel`/`pipeline`/`log`/`phase` stdlib (design §7), and the
1417 /// `budget` global.
1418 fn prelude() -> String {
1419 PRELUDE_TEMPLATE.replace("__MAX_ITEMS__", &PARALLEL_MAX_ITEMS.to_string())
1420 }
1421
1422 const PRELUDE_TEMPLATE: &str = r#""use strict";
1423 (() => {
1424 const banned = (name) => () => {
1425 throw new Error(name + " is unavailable in Workflow scripts: runs must be deterministic for record/replay");
1426 };
1427 const BannedDate = function Date() {
1428 throw new Error("new Date()/Date() is unavailable in Workflow scripts: runs must be deterministic for record/replay");
1429 };
1430 BannedDate.now = banned("Date.now()");
1431 BannedDate.parse = banned("Date.parse()");
1432 BannedDate.UTC = banned("Date.UTC()");
1433 globalThis.Date = BannedDate;
1434 Math.random = banned("Math.random()");
1435
1436 // Capture temporary host bindings into this closure, then strip them from
1437 // globalThis so scripts only see the documented Workflow surface (#4129).
1438 const hostTask = __workflow_task;
1439 const hostLog = __workflow_log;
1440 const hostEverySlotFailed = __workflow_every_slot_failed;
1441 const hostSlotDropped = __workflow_slot_dropped;
1442 const hostPhase = __workflow_phase;
1443 const hostBudgetTotal = __workflow_budget_total;
1444 const hostBudgetSpent = __workflow_budget_spent;
1445 const hostBudgetRemaining = __workflow_budget_remaining;
1446
1447 const MAX_ITEMS = __MAX_ITEMS__;
1448 const taskErrorText = (err) => String(err && err.message !== undefined ? err.message : err);
1449
1450 // Typed slot errors (R9). Every error thrown by task() carries a
1451 // host-assigned `kind` copied off the task envelope; anything else that
1452 // reaches a slot was thrown by the script itself and reports as "script".
1453 //
1454 // The kind is read from the error object and never guessed from message
1455 // text. A substring classifier let a child's own words ("...budget
1456 // exhausted...", "...responseSchema...") forge a fatal classification and
1457 // abort a healthy run, and it could not tell a genuine subagent failure
1458 // apart from a plain `throw new Error(...)` in a stage.
1459 const HOST_KINDS = ["admission", "budget", "cancelled", "agent", "schema", "driver"];
1460 const SCRIPT_KIND = "script";
1461 const taskErrorKind = (err) =>
1462 err !== null && typeof err === "object" && HOST_KINDS.indexOf(err.kind) !== -1
1463 ? err.kind
1464 : SCRIPT_KIND;
1465 // Fatal kinds are never absorbed into a slot value: cancellation is the
1466 // run's own deadline, and a schema breach means the contract the caller
1467 // explicitly asked for was not met. `mode: "partial"` opts out for schema
1468 // (and only schema) by keeping it as a structured slot value instead.
1469 const isFatalTaskError = (err) => {
1470 const kind = taskErrorKind(err);
1471 return kind === "cancelled" || kind === "schema";
1472 };
1473
1474 // Stamp the resolved kind onto an error that is about to be rethrown, so a
1475 // script's own `catch (err) { err.kind }` reads the same vocabulary the
1476 // slot classifier used. Host errors already carry theirs; this only names
1477 // the script throws, which would otherwise surface as `undefined`.
1478 const stampKind = (err, kind) => {
1479 if (err !== null && typeof err === "object" && err.kind === undefined) {
1480 try {
1481 err.kind = kind;
1482 } catch (_) {
1483 // A frozen error keeps whatever it has; the log line still names it.
1484 }
1485 }
1486 return err;
1487 };
1488
1489 const SLOT_MODES = ["settled", "fail-fast", "partial"];
1490 // `settled` is the default and is exactly today's behavior: a non-fatal
1491 // slot failure resolves to `null` so an author need not handle every error.
1492 // An unrecognized mode throws rather than silently falling back — a typo
1493 // like `mode: "failfast"` used to read as `settled` and quietly keep
1494 // dropping slots the author believed were now fatal.
1495 const slotMode = (fn, opts) => {
1496 if (opts === null || typeof opts !== "object" || opts.mode === undefined) {
1497 return "settled";
1498 }
1499 if (SLOT_MODES.indexOf(opts.mode) === -1) {
1500 throw new Error(
1501 fn + "(): unknown mode " + JSON.stringify(opts.mode) +
1502 "; expected one of " + SLOT_MODES.join(", ")
1503 );
1504 }
1505 return opts.mode;
1506 };
1507
1508 // The failure ledger for one fan-out, attached to the resolved array as a
1509 // non-enumerable `errors` property. Non-enumerable and non-index, so the
1510 // array's contents, length, and JSON encoding are byte-identical to before:
1511 // `results.filter(Boolean)` still works, and a script that wants to know
1512 // WHY a slot is null can now ask instead of guessing.
1513 const attachSlotErrors = (results, errors) => {
1514 errors.sort((a, b) => a.index - b.index);
1515 Object.defineProperty(results, "errors", {
1516 value: Object.freeze(errors.map((entry) => Object.freeze(entry))),
1517 enumerable: false,
1518 configurable: false,
1519 writable: false,
1520 });
1521 return results;
1522 };
1523
1524 globalThis.task = async (opts) => {
1525 if (opts === null || typeof opts !== "object") {
1526 throw new TypeError("task(): expected an options object");
1527 }
1528 const envelope = JSON.parse(await hostTask(JSON.stringify(opts)));
1529 if (envelope.error !== undefined) {
1530 const err = new Error(envelope.error);
1531 // The host always names the kind; a missing one means an envelope this
1532 // prelude did not produce, which is not a typed task failure.
1533 err.kind = HOST_KINDS.indexOf(envelope.error_kind) !== -1
1534 ? envelope.error_kind
1535 : SCRIPT_KIND;
1536 throw err;
1537 }
1538 return envelope.value;
1539 };
1540
1541 globalThis.parallel = (thunks, opts) => {
1542 if (!Array.isArray(thunks)) {
1543 throw new TypeError("parallel(): expected an array of thunks");
1544 }
1545 if (thunks.length > MAX_ITEMS) {
1546 throw new Error("parallel(): max " + MAX_ITEMS + " items per call");
1547 }
1548 const mode = slotMode("parallel", opts);
1549 const failFast = mode === "fail-fast";
1550 const partial = mode === "partial";
1551 const errors = [];
1552 // Returns the slot value, or throws to reject the whole fan-out.
1553 const onSlotError = (index, err) => {
1554 const kind = taskErrorKind(err);
1555 const message = taskErrorText(err);
1556 stampKind(err, kind);
1557 // Cancellation is the run's deadline in every mode, partial included.
1558 if (kind === "cancelled") throw err;
1559 if (partial) {
1560 // Opt-in partial mode: every non-cancellation slot failure becomes a
1561 // structured value the script can branch on. It never masquerades as
1562 // a success -- `__taskError` is the whole point of the shape.
1563 errors.push({ index: index, kind: kind, message: message });
1564 hostLog(
1565 "parallel(): partial mode kept a failed slot as __taskError (kind=" +
1566 kind + ", slot " + index + "): " + message
1567 );
1568 return { __taskError: { index: index, kind: kind, message: message } };
1569 }
1570 if (isFatalTaskError(err)) throw err;
1571 if (failFast) {
1572 hostLog(
1573 "parallel(): fail-fast slot error (kind=" + kind + ", slot " + index + "): " + message
1574 );
1575 throw err;
1576 }
1577 errors.push({ index: index, kind: kind, message: message });
1578 hostLog(
1579 "parallel(): dropped a failed slot as null (kind=" + kind + ", slot " + index + "): " +
1580 message
1581 );
1582 hostSlotDropped("parallel", kind, index);
1583 return null;
1584 };
1585 const slots = thunks.map((thunk, index) => {
1586 try {
1587 return Promise.resolve(typeof thunk === "function" ? thunk() : thunk)
1588 .catch((err) => onSlotError(index, err));
1589 } catch (err) {
1590 try {
1591 return onSlotError(index, err);
1592 } catch (rethrown) {
1593 return Promise.reject(rethrown);
1594 }
1595 }
1596 });
1597 return Promise.all(slots).then((results) => {
1598 // A fan-out where nothing survived is a dead fan-out, not resilience.
1599 // The default stays ergonomic (the array still resolves) but the run
1600 // log says so in one line an operator can grep for, and the structured
1601 // event below lets the host status classifier refuse to call such a
1602 // run a plain success.
1603 if (results.length > 0 && errors.length === results.length) {
1604 hostLog(
1605 "parallel(): every slot failed (" + errors.length + " of " + results.length +
1606 "); no work survived this fan-out"
1607 );
1608 hostEverySlotFailed("parallel", errors.length, results.length);
1609 }
1610 return attachSlotErrors(results, errors);
1611 });
1612 };
1613
1614 globalThis.pipeline = (items, ...stages) => {
1615 if (!Array.isArray(items)) {
1616 throw new TypeError("pipeline(): expected an array of items");
1617 }
1618 if (items.length > MAX_ITEMS) {
1619 throw new Error("pipeline(): max " + MAX_ITEMS + " items per call");
1620 }
1621 // Options overload: pipeline(items, { stages: [...], mode: "fail-fast" }).
1622 let mode = "settled";
1623 if (
1624 stages.length === 1 &&
1625 stages[0] !== null &&
1626 typeof stages[0] === "object" &&
1627 Array.isArray(stages[0].stages)
1628 ) {
1629 mode = slotMode("pipeline", stages[0]);
1630 stages = stages[0].stages;
1631 }
1632 const failFast = mode === "fail-fast";
1633 const partial = mode === "partial";
1634 const errors = [];
1635 return Promise.all(items.map(async (item, index) => {
1636 let value = item;
1637 for (const stage of stages) {
1638 try {
1639 value = await stage(value, item, index);
1640 } catch (err) {
1641 const kind = taskErrorKind(err);
1642 const message = taskErrorText(err);
1643 stampKind(err, kind);
1644 if (kind === "cancelled") throw err;
1645 if (partial) {
1646 errors.push({ index: index, kind: kind, message: message });
1647 hostLog(
1648 "pipeline(): partial mode kept item " + index +
1649 " as __taskError (kind=" + kind + "): " + message
1650 );
1651 return { __taskError: { index: index, kind: kind, message: message } };
1652 }
1653 if (isFatalTaskError(err)) throw err;
1654 if (failFast) {
1655 hostLog(
1656 "pipeline(): fail-fast stage error on item " + index +
1657 " (kind=" + kind + "): " + message
1658 );
1659 throw err;
1660 }
1661 errors.push({ index: index, kind: kind, message: message });
1662 hostLog(
1663 "pipeline(): dropped item " + index + " as null (kind=" + kind + "): " + message
1664 );
1665 hostSlotDropped("pipeline", kind, index);
1666 return null;
1667 }
1668 }
1669 return value;
1670 })).then((results) => {
1671 if (results.length > 0 && errors.length === results.length) {
1672 hostLog(
1673 "pipeline(): every item failed (" + errors.length + " of " + results.length +
1674 "); no work survived this pipeline"
1675 );
1676 hostEverySlotFailed("pipeline", errors.length, results.length);
1677 }
1678 return attachSlotErrors(results, errors);
1679 });
1680 };
1681
1682 globalThis.log = (message) => {
1683 hostLog(typeof message === "string" ? message : (JSON.stringify(message) ?? String(message)));
1684 };
1685 globalThis.phase = (title) => {
1686 hostPhase(String(title));
1687 };
1688
1689 const total = hostBudgetTotal();
1690 globalThis.budget = Object.freeze({
1691 total: Number.isNaN(total) ? null : total,
1692 spent: () => hostBudgetSpent(),
1693 remaining: () => hostBudgetRemaining(),
1694 });
1695
1696 for (const name of [
1697 "__workflow_task",
1698 "__workflow_log",
1699 "__workflow_every_slot_failed",
1700 "__workflow_phase",
1701 "__workflow_budget_total",
1702 "__workflow_budget_spent",
1703 "__workflow_budget_remaining",
1704 ]) {
1705 try {
1706 delete globalThis[name];
1707 } catch (_) {
1708 // Non-configurable bindings stay; the inventory test will fail closed.
1709 }
1710 }
1711 })();
1712 "#;
1713
1713 lines RUST