Skip to main content

kaish_kernel/scheduler/
scatter.rs

1//! Scatter/Gather — Parallel pipeline execution.
2//!
3//! Scatter splits input into items and runs the pipeline in parallel.
4//! Gather collects the parallel results.
5//!
6//! # Example
7//!
8//! ```text
9//! cat urls.txt | scatter | fetch url=${ITEM} | gather
10//! ```
11//!
12//! This reads URLs, then for each URL runs `fetch` in parallel,
13//! then collects all results.
14
15use std::sync::Arc;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::time::Duration;
18
19use tokio::sync::Semaphore;
20use tracing::Instrument;
21
22use crate::ast::{Command, Redirect, Value};
23use crate::dispatch::CommandDispatcher;
24use crate::duration::parse_duration;
25use crate::interpreter::ExecResult;
26use crate::tools::{ExecContext, ToolRegistry};
27
28use super::pipeline::{apply_redirects, PipelineRunner};
29
30/// Options for scatter operation.
31#[derive(Debug, Clone)]
32pub struct ScatterOptions {
33    /// Variable name to bind each item to (default: "ITEM").
34    pub var_name: String,
35    /// Maximum parallelism (default: 8).
36    pub limit: usize,
37    /// Per-worker timeout. When `Some`, each worker is cancelled after this
38    /// duration; the worker's external children get SIGTERM/SIGKILL and the
39    /// `ScatterResult.timed_out` flag is set.
40    pub timeout: Option<Duration>,
41}
42
43/// Options for gather operation.
44#[derive(Debug, Clone, Default)]
45pub struct GatherOptions {
46    /// `--lines`: emit each successful worker's raw `out` in item order instead
47    /// of JSONL rows — and HARD-ERROR (exit 123, no partial text) if any worker
48    /// failed, since bare lines cannot represent a failure. The text escape
49    /// hatch that keeps the old line mode's safety property.
50    pub lines: bool,
51    /// `--json` (the kernel-wide flag; scatter/gather own their output, so it
52    /// reaches the tool): render the result records as ONE JSON array instead
53    /// of JSONL rows. Same records, same `.data`.
54    pub json: bool,
55}
56
57impl Default for ScatterOptions {
58    fn default() -> Self {
59        Self {
60            var_name: "ITEM".to_string(),
61            limit: 8,
62            timeout: None,
63        }
64    }
65}
66
67/// One typed scatter item: the JSON element that fans out to a worker.
68///
69/// `json` is the source of truth — the worker binding derives from it via
70/// [`json_to_value_no_envelope`](crate::interpreter::json_to_value_no_envelope)
71/// (the exact conversion `for v in $(cmd)` uses), and the gather row's `item`
72/// field carries it typed. `label` is a char-safe truncated display form for
73/// spans and error messages.
74#[derive(Debug, Clone)]
75pub struct ScatterItem {
76    /// The element as JSON (string items are JSON strings).
77    pub json: serde_json::Value,
78    /// Compact display label for tracing and error text.
79    pub label: String,
80}
81
82impl ScatterItem {
83    fn new(json: serde_json::Value) -> Self {
84        let full = match &json {
85            serde_json::Value::String(s) => s.clone(),
86            other => other.to_string(),
87        };
88        // Char-safe truncation (a byte slice at 64 can split a UTF-8 char).
89        let label = if full.chars().count() > 64 {
90            let head: String = full.chars().take(64).collect();
91            format!("{head}...")
92        } else {
93            full
94        };
95        Self { json, label }
96    }
97
98    fn from_text_line(line: &str) -> Self {
99        Self::new(serde_json::Value::String(line.to_string()))
100    }
101}
102
103/// Result from a single scatter worker.
104#[derive(Debug, Clone)]
105pub struct ScatterResult {
106    /// The input item that was processed.
107    pub item: ScatterItem,
108    /// The execution result.
109    pub result: ExecResult,
110    /// Whether the worker was cancelled by the per-worker `--timeout`.
111    pub timed_out: bool,
112}
113
114/// Runs scatter/gather pipelines.
115///
116/// Uses a single dispatcher for sequential stages (pre_scatter, post_gather),
117/// and forks it per parallel worker via [`CommandDispatcher::fork`]. Each
118/// worker gets its own subkernel with snapshotted session state so they can
119/// run concurrently without racing on scope/cwd/aliases.
120pub struct ScatterGatherRunner {
121    tools: Arc<ToolRegistry>,
122    /// Full dispatch chain for sequential stages (pre_scatter, post_gather).
123    /// Parallel workers fork from this dispatcher.
124    sequential_dispatcher: Arc<dyn CommandDispatcher>,
125}
126
127impl ScatterGatherRunner {
128    /// Create a new scatter/gather runner.
129    ///
130    /// `dispatcher` drives sequential stages directly and serves as the fork
131    /// source for parallel workers.
132    pub fn new(
133        tools: Arc<ToolRegistry>,
134        dispatcher: Arc<dyn CommandDispatcher>,
135    ) -> Self {
136        Self { tools, sequential_dispatcher: dispatcher }
137    }
138
139    /// Execute a scatter/gather pipeline.
140    ///
141    /// The pipeline is split into three parts:
142    /// - pre_scatter: commands before scatter
143    /// - parallel: commands between scatter and gather
144    /// - post_gather: commands after gather
145    ///
146    /// Returns the final result after all stages complete.
147    #[tracing::instrument(level = "info", skip(self, pre_scatter, scatter_opts, parallel, gather_opts, post_gather, ctx), fields(item_count = tracing::field::Empty, parallelism = scatter_opts.limit))]
148    #[allow(clippy::too_many_arguments)]
149    pub async fn run(
150        &self,
151        pre_scatter: &[Command],
152        scatter_opts: ScatterOptions,
153        parallel: &[Command],
154        gather_opts: GatherOptions,
155        gather_redirects: &[Redirect],
156        post_gather: &[Command],
157        ctx: &mut ExecContext,
158    ) -> ExecResult {
159        let runner = PipelineRunner::new(self.tools.clone());
160
161        // Run pre-scatter commands to get input.
162        // Uses run_sequential to avoid async recursion (scatter → run → scatter).
163        let (text, data) = if pre_scatter.is_empty() {
164            // Use existing stdin — structured data, a buffered byte vector, or
165            // a lazy `pipe_stdin` (a frontend-seeded process-stdin pipe).
166            // `take_stdin` alone would miss the pipe; `read_stdin_to_text`
167            // prefers it.
168            let data = ctx.take_stdin_data();
169            let text = match ctx.read_stdin_to_text().await {
170                Ok(s) => s.unwrap_or_default(),
171                Err(e) => return ExecResult::failure(2, format!("scatter: {e}")),
172            };
173            (text, data)
174        } else {
175            let mut result = runner.run_sequential(pre_scatter, ctx, &*self.sequential_dispatcher).await;
176            // GH #250: `run_sequential` never applies the output-limit spill
177            // check or the `did_spill` -> exit-3 remap
178            // (`output_limit::apply_spill_contract`) — that seam only
179            // reached the parallel workers, background jobs, and the
180            // top-level pipeline result (#249). Without it, a pre_scatter
181            // command whose output overflowed the enabled limit or the
182            // ~10MB capture ring (#191) kept `code == 0` (`result.ok()`
183            // stayed `true`), so the `!result.ok()` check below never fired
184            // and scatter fanned out over a truncated/capped preview of the
185            // input instead of refusing to run at all. Applying the
186            // contract here reuses that same check as the enforcement
187            // point: a spilled pre_scatter result now remaps to code 3, the
188            // guard below returns early, and scatter never sees the
189            // truncated data — the same hard-stop `post_gather` already
190            // gets for free via the top-level seam once this result
191            // reaches it.
192            crate::output_limit::apply_spill_contract(&mut result, &ctx.output_limit).await;
193            if !result.ok() {
194                return result;
195            }
196            (result.text_out().into_owned(), result.data)
197        };
198
199        // Extract items from structured data or text
200        let items = match extract_items(data.as_ref(), &text) {
201            Ok(items) => items,
202            Err(msg) => return ExecResult::failure(1, msg),
203        };
204        if items.is_empty() {
205            return ExecResult::success("");
206        }
207
208        tracing::Span::current().record("item_count", items.len());
209
210        // Run parallel stage
211        let results = self
212            .run_parallel(&items, &scatter_opts, parallel, ctx)
213            .await;
214
215        // Gather per the GH #73 contract: JSONL result records by default (one
216        // row per worker, failures included), or `--lines` raw text. Exit codes
217        // are A′: 0 all ok · 123 any worker failed · 2 usage (clap layer).
218        let gathered = gather_results(&results, &gather_opts);
219
220        // `gather`'s own trailing redirect (`… | gather > results.jsonl | …`)
221        // must apply to gather's OWN result before anything downstream sees
222        // it — matching shell semantics where a file redirect on a pipeline
223        // stage wins over the pipe (`cmd > file | next` sends cmd's real
224        // stdout to the file; `next` reads nothing from cmd). Applying it
225        // unconditionally (regardless of exit code) matches a redirect being
226        // a file-descriptor operation independent of the command's success —
227        // `false > file` still creates the file. Previously this only ran
228        // when gather was the pipeline's last command, so a trailing
229        // `gather > file | jq` silently skipped the file and let the
230        // unredirected rows flow to `jq` instead.
231        let gathered = apply_redirects(gathered, gather_redirects, ctx, &*self.sequential_dispatcher).await;
232
233        // Run post-gather commands if any. A failed gather short-circuits —
234        // feeding partial/failed output onward would propagate corruption.
235        if post_gather.is_empty() || gathered.code != 0 {
236            gathered
237        } else {
238            ctx.set_stdin_with_data(
239                gathered.text_out().into_owned(),
240                gathered.data.clone(),
241            );
242            runner.run_sequential(post_gather, ctx, &*self.sequential_dispatcher).await
243        }
244    }
245
246    /// Run the parallel stage for all items.
247    ///
248    /// Each worker gets its own forked dispatcher via
249    /// [`CommandDispatcher::fork`]. The fork snapshots per-session state
250    /// (scope, cwd, aliases, user tools) so workers can run concurrently
251    /// without racing. Forks are cheap (Scope is COW, plus a few Arc bumps),
252    /// and they unlock the full dispatch chain inside workers — user tools,
253    /// `.kai` scripts, and `$(...)` in args all work.
254    #[tracing::instrument(level = "debug", skip(self, items, opts, commands, base_ctx), fields(worker_count = items.len()))]
255    async fn run_parallel(
256        &self,
257        items: &[ScatterItem],
258        opts: &ScatterOptions,
259        commands: &[Command],
260        base_ctx: &ExecContext,
261    ) -> Vec<ScatterResult> {
262        let semaphore = Arc::new(Semaphore::new(opts.limit));
263        let tools = self.tools.clone();
264        let var_name = opts.var_name.clone();
265
266        // Spawn parallel tasks
267        let mut handles = Vec::with_capacity(items.len());
268
269        for item in items.iter().cloned() {
270            let permit = semaphore.clone().acquire_owned().await;
271            let tools = tools.clone();
272            // Fork attached: the worker's cancel token is a child of the
273            // parent kernel's, so a parent cancel (request timeout, embedder
274            // Kernel::cancel) cascades into the worker and kills its
275            // external children via the wait_or_kill discipline.
276            let worker_dispatcher = self.sequential_dispatcher.fork_attached().await;
277            let commands = commands.to_vec();
278            let parent_token = base_ctx.cancel.clone();
279            let worker_token = parent_token.child_token();
280
281            // Build the worker context FROM THE PARENT, not from scratch. A
282            // from-scratch `ExecContext::with_backend_and_scope` starts
283            // `watchdog = None`; `dispatch_command` then syncs that `None` INTO
284            // the subkernel (kernel.rs `ec.watchdog = ctx.watchdog.clone()`),
285            // clobbering the fork's inherited watchdog — so inside a worker the
286            // script clock is gone and any `ctx.patient` hold suspends a
287            // *missing* timer, yielding false-positive request timeouts that
288            // kill the worker. `child_for_pipeline` clones exactly what a worker
289            // needs in one shot — watchdog, vfs_budget, aliases, ignore_config,
290            // output_limit, allow_external_commands, backend, cwd, scope,
291            // dispatcher — replacing the manual field-copy that was easy to let
292            // drift (and that dropped the watchdog). `base_ctx` is a borrow (not
293            // `'static`), so the child MUST be built here and MOVED into the
294            // spawn — it cannot be constructed inside the closure.
295            let mut worker_ctx = base_ctx.child_for_pipeline();
296            // Per-worker TYPED binding — the same json→Value conversion the
297            // for-loop uses for `$(cmd)` items (GH #73), so a record element
298            // subscripts as `${ITEM[k]}`.
299            worker_ctx.scope.set(
300                &var_name,
301                crate::interpreter::json_to_value_no_envelope(item.json.clone()),
302            );
303            // Per-worker cancel token (a child of the parent's), so the timeout
304            // timer and a parent cancel both reach this worker's externals.
305            worker_ctx.cancel = worker_token.clone();
306
307            // Per-worker timeout: spawn a delay task that cancels the worker's
308            // child token after `opts.timeout`. The cancel cascades into the
309            // worker's externals via the fork's cancel link. `timed_out_flag`
310            // distinguishes timeout from explicit parent cancellation when
311            // tagging ScatterResult.
312            let timed_out_flag = Arc::new(AtomicBool::new(false));
313            let timer_handle: Option<tokio::task::JoinHandle<()>> = opts.timeout.map(|d| {
314                let cancel = worker_token.clone();
315                let flag = timed_out_flag.clone();
316                tokio::spawn(async move {
317                    tokio::time::sleep(d).await;
318                    flag.store(true, Ordering::SeqCst);
319                    cancel.cancel();
320                })
321            });
322            let timed_out_check = timed_out_flag.clone();
323
324            let worker_span = tracing::debug_span!("scatter_worker", item = %item.label);
325            // Propagate the embedder's trace context across the spawn boundary so
326            // each worker's spans stay in the same trace. `.instrument` below
327            // provides the tracing parent; this provides the OTel parent.
328            let handle = tokio::spawn(crate::telemetry::bind_current_context(async move {
329                let _permit = permit; // Hold permit until done
330                let mut worker_ctx = worker_ctx; // moved in; built from parent above
331
332                // Run through PipelineRunner + dispatcher (full resolution chain).
333                // Uses run_sequential to avoid async recursion and infinite future size.
334                let runner = PipelineRunner::new(tools);
335                let mut result =
336                    runner.run_sequential(&commands, &mut worker_ctx, &*worker_dispatcher).await;
337
338                // Capture the worker's OWN completion signal before the spill
339                // remap below can rewrite `result.code` to 3 — GH #132's tie-break
340                // (below) needs to know whether the command itself genuinely
341                // finished clean, and a spilled-but-otherwise-successful result
342                // must not be misread as "this worker timed out" just because
343                // its (remapped) exit code is no longer 0.
344                let genuinely_completed = result.ok();
345
346                // Per-worker spill boundary + exit-3 remap. `run_sequential` never
347                // reaches the kernel's top-level post-run spill contract
348                // (`Kernel::execute_pipeline`'s `apply_spill_contract` call), so
349                // without this each worker both holds its FULL output in memory —
350                // N concurrent workers × large output evades the sandbox
351                // `output_limit` (10 workers × 1 GB = 10 GB resident before
352                // anything spills) — AND reports its original exit code even when
353                // its output was capped, so `gather`'s 0-vs-123 decision (which
354                // reads `result.ok()`) silently counted a spilled worker as
355                // success (GH #212). `child_for_pipeline` shares the parent's
356                // `output_limit`, so workers cap against the same budget and get
357                // the same loud exit-3 contract the foreground pipeline does.
358                crate::output_limit::apply_spill_contract(&mut result, &worker_ctx.output_limit).await;
359
360                // Worker finished — abort the timer if still pending so it
361                // doesn't fire a now-pointless cancel and idle resources.
362                if let Some(h) = timer_handle {
363                    h.abort();
364                }
365
366                // Completion wins ties (GH #132): a worker whose command
367                // finishes right as its timeout timer fires can read the
368                // flag AFTER the delay task sets it, even though its own
369                // result is a genuine, clean success. This isn't just a
370                // stale-read gap — `sleep`'s (and any similarly-built
371                // builtin's) own `tokio::select! { operation, cancelled() }`
372                // is unbiased: if cancellation has *already* been signaled by
373                // the time the operation's own timer also matures, tokio can
374                // still pick the operation's branch, so `genuinely_completed`
375                // can be `true` even after the flag was set and
376                // `cancel.cancel()` was called. The result's own (pre-remap)
377                // success is the ground truth the flag can't override — a
378                // worker that truly finished successfully must never be
379                // reported as timed out, no matter what the racing flag says.
380                let timed_out = timed_out_check.load(Ordering::SeqCst) && !genuinely_completed;
381
382                ScatterResult { item, result, timed_out }
383            }.instrument(worker_span)));
384
385            handles.push(handle);
386        }
387
388        // Collect results
389        let mut results = Vec::with_capacity(handles.len());
390        for handle in handles {
391            match handle.await {
392                Ok(result) => results.push(result),
393                Err(e) => {
394                    results.push(ScatterResult {
395                        item: ScatterItem::new(serde_json::Value::String(
396                            "<worker panicked>".to_string(),
397                        )),
398                        result: ExecResult::failure(1, format!("Task panicked: {}", e)),
399                        timed_out: false,
400                    });
401                }
402            }
403        }
404
405        results
406    }
407}
408
409/// Extract typed items from structured data or text (GH #73 contract).
410///
411/// Structured `.data` wins and fans out TYPED: a JSON array yields one item per
412/// element with the element's real type (a record element subscripts as
413/// `${ITEM[k]}` in the worker; number `1` and string `"1"` stay distinct). A
414/// `null` element is a loud error — a worker silently running with a null
415/// binding is corruption. A single non-array OBJECT is a loud error with a
416/// select-the-array hint (one worker running on the `{"jobs":[…]}` envelope is
417/// never what was meant); a single scalar is one item. Binary data is a loud
418/// error.
419///
420/// Plain-text stdin is split on newlines only — one item per line, each a
421/// string — matching the for-loop `$(cmd)` contract: trailing newlines trimmed
422/// once, each line's trailing `\r` stripped, whitespace within a line never
423/// split. Blank lines are SKIPPED (panel-ratified: a worker spawned on `""` is
424/// silent corruption of the most common input shape). Empty input yields zero
425/// items (the caller exits 0 with no rows).
426pub fn extract_items(data: Option<&Value>, text: &str) -> Result<Vec<ScatterItem>, String> {
427    // 1. Structured data wins over text (arch_data_iteration contract).
428    match data {
429        // JSON array — fan out per element, typed (seq/split/glob/find/jq).
430        Some(Value::Json(serde_json::Value::Array(arr))) => {
431            let mut items = Vec::with_capacity(arr.len());
432            for (i, elem) in arr.iter().enumerate() {
433                if elem.is_null() {
434                    return Err(format!(
435                        "scatter: item {i} is null — filter nulls out first, \
436                         e.g. jq 'map(select(. != null))'"
437                    ));
438                }
439                items.push(ScatterItem::new(elem.clone()));
440            }
441            return Ok(items);
442        }
443        // Kaish scalars — one typed item each.
444        Some(Value::String(s)) => {
445            return Ok(vec![ScatterItem::new(serde_json::Value::String(s.clone()))])
446        }
447        Some(Value::Int(i)) => return Ok(vec![ScatterItem::new(serde_json::json!(i))]),
448        Some(Value::Float(f)) => return Ok(vec![ScatterItem::new(serde_json::json!(f))]),
449        Some(Value::Bool(b)) => return Ok(vec![ScatterItem::new(serde_json::json!(b))]),
450        Some(Value::Null) => {
451            return Err("scatter: input is null — nothing to fan out".to_string())
452        }
453        // A single JSON object is almost always the unselected envelope around
454        // the array the caller meant — loud, with the fix in the message.
455        Some(Value::Json(serde_json::Value::Object(map))) => {
456            let hint = map
457                .iter()
458                .find(|(_, v)| v.is_array())
459                .map(|(k, _)| format!(" (did you mean jq '.{k}'?)"))
460                .unwrap_or_default();
461            return Err(format!(
462                "scatter: input is a single object, not an array — select the array to \
463                 fan out over{hint}"
464            ));
465        }
466        Some(Value::Json(serde_json::Value::Null)) => {
467            return Err("scatter: input is null — nothing to fan out".to_string())
468        }
469        // Single JSON scalar — one typed item.
470        Some(Value::Json(json)) => return Ok(vec![ScatterItem::new(json.clone())]),
471        // Binary can't bind a worker variable meaningfully — loud, never a
472        // placeholder string item.
473        Some(Value::Bytes(b)) => {
474            return Err(format!(
475                "scatter: input is binary ({} bytes) — decode it to text or JSON first",
476                b.len()
477            ))
478        }
479        // No structured data — fall through to plain-text newline-split.
480        None => {}
481    }
482
483    // 2. Plain text — newline-split, mirroring kernel.rs for-loop $(cmd)
484    // semantics; every text item is a string.
485    let trimmed = text.trim_end_matches(['\n', '\r']);
486    if trimmed.is_empty() {
487        return Ok(vec![]);
488    }
489    Ok(trimmed
490        .split('\n')
491        .map(|line| line.trim_end_matches('\r'))
492        .filter(|line| !line.is_empty())
493        .map(ScatterItem::from_text_line)
494        .collect())
495}
496
497/// Strip exactly one trailing newline (`\n` or `\r\n`), leaving everything
498/// else raw — the GH #73 contract for the row's `out` and `err` fields.
499fn strip_one_trailing_newline(s: &str) -> &str {
500    let s = s.strip_suffix('\n').unwrap_or(s);
501    s.strip_suffix('\r').unwrap_or(s)
502}
503
504/// Build one JSONL result record for a worker (GH #73 row schema).
505///
506/// `{"i":N, "item":<typed>, "ok":bool, "code":N, "out":"…", "err":"…"}` plus
507/// `data` (the worker's structured output, typed) when present and
508/// `timed_out:true` when it was. `i`/`item`/`ok`/`code`/`out`/`err` are always
509/// present (`err` deliberately so — omit-empty on the most-read field would
510/// make `${r[err]}` a loud missing-key error on every successful row). A
511/// timed-out worker reports `code` 124 (the `timeout(1)` prior) and `ok` false.
512///
513/// # Binary-output hazard
514///
515/// A worker's `out` is a `text_out()`-shaped string field, but the worker's
516/// `ExecResult` payload can be `OutputPayload::Bytes` — several builtins
517/// already produce it (`cat`/`head`/`tail`/`base64 -d`/`xxd -r`/`dd`/`tee`/
518/// external commands via `env`/`spawn`), so a worker running e.g. `cat
519/// binary.file` is not a hypothetical, it is reachable today. `text_out()`
520/// would lossily replace invalid UTF-8 with U+FFFD — silent data corruption
521/// riding through the row as if it were the worker's real text output. Per
522/// "crash beats corrupt" we go loud at row granularity instead: `try_text_out`
523/// catches it, the row is forced `ok:false` with a clear `err` (never a
524/// lossily-decoded `out`), and the OTHER rows are unaffected — see
525/// `docs/binary-data.md` for the broader binary-data plan.
526fn result_row(i: usize, r: &ScatterResult) -> serde_json::Value {
527    let mut ok = r.result.ok() && !r.timed_out;
528    let mut code = if r.timed_out { 124 } else { r.result.code };
529
530    let (out_text, err_text) = match r.result.try_text_out() {
531        Ok(text) => (
532            strip_one_trailing_newline(&text).to_string(),
533            strip_one_trailing_newline(&r.result.err).to_string(),
534        ),
535        Err(e) => {
536            ok = false;
537            if code == 0 {
538                code = 1;
539            }
540            (
541                String::new(),
542                format!(
543                    "binary worker output not representable as text ({} bytes) — \
544                     encode it in the worker (base64/xxd)",
545                    e.len
546                ),
547            )
548        }
549    };
550
551    let mut row = serde_json::Map::new();
552    row.insert("i".into(), serde_json::json!(i));
553    row.insert("item".into(), r.item.json.clone());
554    row.insert("ok".into(), serde_json::json!(ok));
555    row.insert("code".into(), serde_json::json!(code));
556    row.insert("out".into(), serde_json::json!(out_text));
557    row.insert("err".into(), serde_json::json!(err_text));
558    if let Some(data) = &r.result.data {
559        row.insert("data".into(), kaish_types::value_to_json(data));
560    }
561    if r.timed_out {
562        row.insert("timed_out".into(), serde_json::json!(true));
563    }
564    serde_json::Value::Object(row)
565}
566
567/// Render gathered worker results as an [`ExecResult`] (GH #73 contract).
568///
569/// Default: JSONL — one compact result record per worker, in item order, EVERY
570/// worker including failures. One source, three views: the pipe text is the
571/// JSONL, `.data` is the typed record array (so `for r in $(… | gather)`
572/// iterates records and post-gather stages see typed stdin), and the kernel
573/// `--json` flag renders the same array as one JSON document via `rich_json`.
574///
575/// `--lines`: each successful worker's raw `out` in item order — and a HARD
576/// error (no partial text) if any worker failed, because bare lines cannot
577/// represent a failure (the old line mode's safety property, kept as a flag).
578///
579/// Exit codes (A′): `0` all workers ok · `123` any worker failed, partial or
580/// total (timeouts count) — partial-vs-total is distinguished in the rows.
581fn gather_results(results: &[ScatterResult], opts: &GatherOptions) -> ExecResult {
582    // A worker's binary stdout that can't decode as text is a failure for
583    // gather's purposes too — see `result_row`'s hazard doc. Folding it into
584    // `failed` here keeps the overall exit code (0 vs 123) honest: a `--lines`
585    // or JSONL caller checking `$?` must see non-zero, not a silent "0 all
586    // ok" while one row was actually corruption-guarded away.
587    let is_unrepresentable = |r: &ScatterResult| r.result.try_text_out().is_err();
588
589    let failed: Vec<&ScatterResult> = results
590        .iter()
591        .filter(|r| !r.result.ok() || r.timed_out || is_unrepresentable(r))
592        .collect();
593    let code = if failed.is_empty() { 0 } else { 123 };
594    let err = if failed.is_empty() {
595        String::new()
596    } else {
597        let names = failed
598            .iter()
599            .map(|r| {
600                if is_unrepresentable(r) {
601                    format!("{} (binary output not representable as text)", r.item.label)
602                } else {
603                    r.item.label.clone()
604                }
605            })
606            .collect::<Vec<_>>()
607            .join(", ");
608        format!("gather: {} of {} worker(s) failed: {names}", failed.len(), results.len())
609    };
610
611    if opts.lines {
612        // Bare lines can't carry a failure — refuse with no partial text
613        // rather than silently dropping rows. This also covers binary output
614        // (folded into `failed` above): `--lines` is a text-only escape
615        // hatch, and a U+FFFD-laden line would be exactly the silent
616        // corruption this hardening pass exists to prevent.
617        if !failed.is_empty() {
618            return ExecResult::failure(code, format!("{err} (drop --lines to get per-worker rows)"));
619        }
620        let text = results
621            .iter()
622            .map(|r| strip_one_trailing_newline(&r.result.text_out()).to_string())
623            .collect::<Vec<_>>()
624            .join("\n");
625        return ExecResult::success(text);
626    }
627
628    let rows: Vec<serde_json::Value> =
629        results.iter().enumerate().map(|(i, r)| result_row(i, r)).collect();
630    let text = if opts.json {
631        // `--json`: the same records as one JSON document.
632        serde_json::to_string_pretty(&rows).unwrap_or_default()
633    } else {
634        // Default: JSONL — one compact record per line.
635        rows.iter().map(|row| row.to_string()).collect::<Vec<_>>().join("\n")
636    };
637    let array = serde_json::Value::Array(rows);
638    // gather's rows ARE its value, not a structured view of the JSONL it
639    // printed: `for r in $(… | gather)` binds a RECORD per worker and reads
640    // `${r[ok]}`. It is built here rather than through the dispatcher, so it
641    // marks itself — see `ExecResult::data_is_value`.
642    let mut result = ExecResult::from_parts(code, text, err, Some(Value::Json(array)));
643    result.data_is_value = true;
644    result
645}
646
647/// Human-readable repr of a `Value` for a "wrong type" error message —
648/// deliberately not `Debug` (whose `String("five")` quoting/enum-tag noise
649/// reads badly to a user who just typed `--limit five`).
650fn describe_value(v: &Value) -> String {
651    match v {
652        Value::Null => "null".to_string(),
653        Value::Bool(b) => b.to_string(),
654        Value::Int(n) => n.to_string(),
655        Value::Float(f) => f.to_string(),
656        Value::String(s) => format!("{s:?}"),
657        Value::Json(j) => j.to_string(),
658        Value::Bytes(b) => format!("<{} bytes>", b.len()),
659    }
660}
661
662/// Parse scatter options from tool args.
663///
664/// A flag key *present* in `args.named` with a value of the wrong type is a
665/// loud `Err`, never a silent fall-back to the default — `scatter --limit
666/// five` must not quietly run at the default limit, and `scatter --as 42`
667/// must not quietly bind `$ITEM`. (An *absent* or unresolved flag falls back
668/// to a bare boolean earlier, at the pipeline arg-binding layer — that lenient
669/// path is unrelated and untouched here.)
670pub fn parse_scatter_options(args: &crate::tools::ToolArgs) -> Result<ScatterOptions, String> {
671    let mut opts = ScatterOptions::default();
672
673    match args.named.get("as") {
674        None => {}
675        Some(Value::String(name)) => {
676            // The worker binding is the one door with no parse-time name scan
677            // in front of it, so the rule is applied here instead.
678            crate::name::validate(name)
679                .map_err(|bad| format!("scatter --as: `{name}': {bad}"))?;
680            opts.var_name = name.clone();
681        }
682        Some(other) => {
683            return Err(format!(
684                "scatter --as: expected a variable name, got {}",
685                describe_value(other)
686            ))
687        }
688    }
689
690    match args.named.get("limit") {
691        None => {}
692        Some(Value::Int(n)) => opts.limit = clamp_scatter_limit(*n),
693        // Values from variables often stringify (`--limit "$n"`) — coerce a
694        // numeric string the same as an int.
695        Some(Value::String(s)) => match s.trim().parse::<i64>() {
696            Ok(n) => opts.limit = clamp_scatter_limit(n),
697            Err(_) => {
698                return Err(format!(
699                    "scatter --limit: expected a positive integer, got {}",
700                    describe_value(&Value::String(s.clone()))
701                ))
702            }
703        },
704        Some(other) => {
705            return Err(format!(
706                "scatter --limit: expected a positive integer, got {}",
707                describe_value(other)
708            ))
709        }
710    }
711
712    // --timeout DURATION: per-worker timeout. Accepts the same forms as the
713    // `timeout` builtin (30, 5s, 500ms, 2m, 1h). A present-but-invalid value
714    // is a loud Err — a typo here must not silently disable cancellation.
715    match args.named.get("timeout") {
716        None => {}
717        Some(Value::String(s)) => match parse_duration(s) {
718            Some(d) => opts.timeout = Some(d),
719            None => {
720                return Err(format!(
721                    "scatter --timeout: invalid duration {} (try: 30, 5s, 500ms, 2m, 1h)",
722                    describe_value(&Value::String(s.clone()))
723                ))
724            }
725        },
726        Some(Value::Int(n)) if *n >= 0 => opts.timeout = Some(Duration::from_secs(*n as u64)),
727        Some(other) => {
728            return Err(format!(
729                "scatter --timeout: expected a non-negative duration, got {}",
730                describe_value(other)
731            ))
732        }
733    }
734
735    Ok(opts)
736}
737
738/// Clamp a requested `--limit` to `[1, SCATTER_LIMIT_MAX]`, warning (not
739/// erroring) when the ceiling clamps a value down — this ceiling exists to
740/// protect the host, not to reject user input, so it stays a warn+clamp.
741fn clamp_scatter_limit(requested: i64) -> usize {
742    let clamped = requested.clamp(1, SCATTER_LIMIT_MAX as i64);
743    if requested > SCATTER_LIMIT_MAX as i64 {
744        tracing::warn!(
745            target: "kaish::scatter",
746            requested = requested,
747            ceiling = SCATTER_LIMIT_MAX,
748            "scatter limit clamped to ceiling"
749        );
750    }
751    clamped as usize
752}
753
754/// Upper bound on the concurrency `scatter --limit N` accepts. Users who
755/// ask for more get a `tracing::warn` and are clamped to this value —
756/// silent clamping would violate the "no silent fallbacks" rule.
757pub const SCATTER_LIMIT_MAX: usize = 10_000;
758
759/// Parse gather options from tool args.
760///
761/// Returns `Err` for a present-but-wrong-typed flag value, mirroring
762/// [`parse_scatter_options`]. Today gather's only value-carrying flags are
763/// boolean (`--lines`/`--json`), so this can't yet fail — the `Result` return
764/// keeps the signature symmetric with scatter's and ready for the next
765/// value-carrying gather flag.
766pub fn parse_gather_options(args: &crate::tools::ToolArgs) -> Result<GatherOptions, String> {
767    let mut opts = GatherOptions::default();
768
769    if args.has_flag("lines") {
770        opts.lines = true;
771    }
772
773    if args.has_flag("json") {
774        opts.json = true;
775    }
776
777    Ok(opts)
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    fn labels(items: &[ScatterItem]) -> Vec<String> {
785        items.iter().map(|i| i.label.clone()).collect()
786    }
787
788    fn item(s: &str) -> ScatterItem {
789        ScatterItem::new(serde_json::Value::String(s.to_string()))
790    }
791
792    #[test]
793    fn test_extract_items_structured_json_array() {
794        let data = Value::Json(serde_json::json!(["a", "b", "c"]));
795        let items = extract_items(Some(&data), "").unwrap();
796        assert_eq!(labels(&items), vec!["a", "b", "c"]);
797    }
798
799    #[test]
800    fn test_extract_items_structured_mixed_types_stay_typed() {
801        // GH #73: number 1 and string "1" must remain distinct through the
802        // fan-out — the old Vec<String> path conflated them silently.
803        let data = Value::Json(serde_json::json!([1, "1", true, {"id": 7}]));
804        let items = extract_items(Some(&data), "").unwrap();
805        assert_eq!(items[0].json, serde_json::json!(1));
806        assert_eq!(items[1].json, serde_json::json!("1"));
807        assert_ne!(items[0].json, items[1].json, "1 and \"1\" must not conflate");
808        assert_eq!(items[2].json, serde_json::json!(true));
809        assert_eq!(items[3].json, serde_json::json!({"id": 7}));
810    }
811
812    #[test]
813    fn test_extract_items_null_element_is_loud() {
814        let data = Value::Json(serde_json::json!(["a", null, "c"]));
815        let err = extract_items(Some(&data), "").unwrap_err();
816        assert!(err.contains("null"), "should name the problem: {err}");
817        assert!(err.contains("item 1"), "should name the position: {err}");
818    }
819
820    #[test]
821    fn test_extract_items_single_object_is_loud_with_hint() {
822        let data = Value::Json(serde_json::json!({"jobs": [1, 2]}));
823        let err = extract_items(Some(&data), "").unwrap_err();
824        assert!(err.contains("single object"), "{err}");
825        assert!(err.contains("jq '.jobs'"), "should hint the array key: {err}");
826    }
827
828    #[test]
829    fn test_extract_items_binary_is_loud() {
830        let data = Value::Bytes(vec![0, 1, 2]);
831        let err = extract_items(Some(&data), "").unwrap_err();
832        assert!(err.contains("binary"), "{err}");
833    }
834
835    #[test]
836    fn test_extract_items_structured_string() {
837        let data = Value::String("single".into());
838        let items = extract_items(Some(&data), "").unwrap();
839        assert_eq!(labels(&items), vec!["single"]);
840    }
841
842    #[test]
843    fn test_extract_items_single_line_text() {
844        let items = extract_items(None, "hello").unwrap();
845        assert_eq!(labels(&items), vec!["hello"]);
846    }
847
848    #[test]
849    fn test_extract_items_empty() {
850        let items = extract_items(None, "").unwrap();
851        assert!(items.is_empty());
852    }
853
854    #[test]
855    fn test_extract_items_multiline_fans_out_per_line() {
856        let items = extract_items(None, "one\ntwo\nthree").unwrap();
857        assert_eq!(labels(&items), vec!["one", "two", "three"]);
858    }
859
860    #[test]
861    fn test_extract_items_trailing_newline_no_phantom_item() {
862        let items = extract_items(None, "one\ntwo\n").unwrap();
863        assert_eq!(labels(&items), vec!["one", "two"]);
864    }
865
866    #[test]
867    fn test_extract_items_crlf_per_line() {
868        let items = extract_items(None, "one\r\ntwo\r\n").unwrap();
869        assert_eq!(labels(&items), vec!["one", "two"]);
870    }
871
872    #[test]
873    fn test_extract_items_blank_lines_skipped() {
874        // GH #73 panel finding: a worker spawned on "" is silent corruption of
875        // the most common input shape — blank lines are skipped, not items.
876        let items = extract_items(None, "a\n\nb").unwrap();
877        assert_eq!(labels(&items), vec!["a", "b"]);
878    }
879
880    #[test]
881    fn test_extract_items_whitespace_within_line_not_split() {
882        let items = extract_items(None, "a b\nc d").unwrap();
883        assert_eq!(labels(&items), vec!["a b", "c d"]);
884    }
885
886    #[test]
887    fn test_extract_items_only_newlines_is_empty() {
888        let items = extract_items(None, "\n\n").unwrap();
889        assert!(items.is_empty());
890    }
891
892    #[test]
893    fn test_extract_items_structured_overrides_text() {
894        let data = Value::Json(serde_json::json!(["x", "y"]));
895        let items = extract_items(Some(&data), "ignored\ntext").unwrap();
896        assert_eq!(labels(&items), vec!["x", "y"]);
897    }
898
899    #[test]
900    fn test_item_label_truncates_on_char_boundary() {
901        // 100 multibyte chars — a byte-slice truncation would panic.
902        let long: String = "é".repeat(100);
903        let it = ScatterItem::new(serde_json::Value::String(long));
904        assert!(it.label.ends_with("..."));
905        assert_eq!(it.label.chars().count(), 67);
906    }
907
908    #[test]
909    fn test_gather_results_jsonl_rows_carry_everything() {
910        let results = vec![
911            ScatterResult {
912                item: item("a"),
913                result: ExecResult::success("result_a\n"),
914                timed_out: false,
915            },
916            ScatterResult {
917                item: item("b"),
918                result: ExecResult::failure(7, "boom\n"),
919                timed_out: false,
920            },
921        ];
922        let out = gather_results(&results, &GatherOptions::default());
923        assert_eq!(out.code, 123, "any failure → 123 (A′)");
924        let rows: Vec<serde_json::Value> = out
925            .text_out()
926            .lines()
927            .map(|l| serde_json::from_str(l).unwrap())
928            .collect();
929        assert_eq!(rows.len(), 2, "every worker gets a row, failures included");
930        assert_eq!(rows[0]["i"], 0);
931        assert_eq!(rows[0]["item"], "a");
932        assert_eq!(rows[0]["ok"], true);
933        assert_eq!(rows[0]["out"], "result_a", "trailing newline stripped");
934        assert_eq!(rows[0]["err"], "", "err always present");
935        assert!(rows[0].get("timed_out").is_none(), "omit-false");
936        assert!(rows[0].get("data").is_none(), "omit-empty");
937        assert_eq!(rows[1]["i"], 1);
938        assert_eq!(rows[1]["ok"], false);
939        assert_eq!(rows[1]["code"], 7);
940        assert_eq!(rows[1]["err"], "boom");
941        // .data carries the typed array for iteration / post-gather.
942        assert!(matches!(out.data, Some(Value::Json(serde_json::Value::Array(_)))));
943    }
944
945    #[test]
946    fn test_gather_results_all_ok_is_zero() {
947        let results = vec![ScatterResult {
948            item: item("a"),
949            result: ExecResult::success("x"),
950            timed_out: false,
951        }];
952        let out = gather_results(&results, &GatherOptions::default());
953        assert_eq!(out.code, 0);
954        assert!(out.err.is_empty());
955    }
956
957    #[test]
958    fn test_gather_results_timeout_row_is_124() {
959        let results = vec![ScatterResult {
960            item: item("slow"),
961            result: ExecResult::failure(1, "cancelled"),
962            timed_out: true,
963        }];
964        let out = gather_results(&results, &GatherOptions::default());
965        assert_eq!(out.code, 123);
966        let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
967        assert_eq!(row["code"], 124, "timeout reports the timeout(1) code");
968        assert_eq!(row["ok"], false);
969        assert_eq!(row["timed_out"], true);
970    }
971
972    #[test]
973    fn test_gather_results_typed_record_item_in_row() {
974        let results = vec![ScatterResult {
975            item: ScatterItem::new(serde_json::json!({"id": 3, "host": "web1"})),
976            result: ExecResult::success("ok"),
977            timed_out: false,
978        }];
979        let out = gather_results(&results, &GatherOptions::default());
980        let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
981        assert_eq!(row["item"]["id"], 3, "row item is the TYPED value, not a string");
982    }
983
984    #[test]
985    fn test_gather_results_worker_data_rides_the_row() {
986        let mut r = ExecResult::success("text");
987        r.data = Some(Value::Json(serde_json::json!({"k": 1})));
988        let results = vec![ScatterResult { item: item("a"), result: r, timed_out: false }];
989        let out = gather_results(&results, &GatherOptions::default());
990        let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
991        assert_eq!(row["data"]["k"], 1, "worker .data lands typed in the row");
992        assert_eq!(row["out"], "text", "out stays alongside data");
993    }
994
995    // GH #212: gather's 0-vs-123 decision reads `result.ok()` (`code == 0`).
996    // Before the fix, `run_parallel` never remapped a spilled worker's exit
997    // code, so a worker whose output overflowed the shared output limit kept
998    // `code == 0` (`did_spill == true` but otherwise untouched) and this
999    // aggregation silently read it as a success. `apply_spill_contract` (now
1000    // called from `run_parallel`) remaps a spilled worker's own `code` to 3
1001    // before it ever reaches `gather_results` — this test pins that
1002    // `gather_results` itself already does the right thing once it's handed
1003    // a properly-remapped result, independent of any real spill-file I/O.
1004    #[test]
1005    fn test_gather_results_spilled_worker_counts_as_failed() {
1006        let mut spilled = ExecResult::success("truncated preview");
1007        spilled.did_spill = true;
1008        spilled.original_code = Some(0);
1009        spilled.code = 3; // what `apply_spill_contract` does to a spilled worker
1010
1011        let results = vec![
1012            ScatterResult { item: item("a"), result: spilled, timed_out: false },
1013            ScatterResult { item: item("b"), result: ExecResult::success("clean"), timed_out: false },
1014        ];
1015        let out = gather_results(&results, &GatherOptions::default());
1016        assert_eq!(
1017            out.code, 123,
1018            "a spilled worker (code remapped to 3) must count as failed, not silently succeed"
1019        );
1020        let rows: Vec<serde_json::Value> = out
1021            .text_out()
1022            .lines()
1023            .map(|l| serde_json::from_str(l).unwrap())
1024            .collect();
1025        assert_eq!(rows[0]["ok"], false, "spilled row must read ok:false: {:?}", rows[0]);
1026        assert_eq!(rows[0]["code"], 3, "spilled row must carry the remapped exit 3: {:?}", rows[0]);
1027        assert_eq!(rows[1]["ok"], true, "the clean worker's own row is unaffected: {:?}", rows[1]);
1028    }
1029
1030    #[test]
1031    fn test_gather_results_lines_happy_path() {
1032        let results = vec![
1033            ScatterResult { item: item("a"), result: ExecResult::success("result_a\n"), timed_out: false },
1034            ScatterResult { item: item("b"), result: ExecResult::success("result_b"), timed_out: false },
1035        ];
1036        let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
1037        assert_eq!(out.code, 0);
1038        assert_eq!(&*out.text_out(), "result_a\nresult_b");
1039    }
1040
1041    #[test]
1042    fn test_gather_results_lines_hard_errors_on_any_failure() {
1043        // Bare lines can't represent a failure — no partial text, loud 123.
1044        let results = vec![
1045            ScatterResult { item: item("a"), result: ExecResult::success("good"), timed_out: false },
1046            ScatterResult { item: item("b"), result: ExecResult::failure(1, "boom"), timed_out: false },
1047        ];
1048        let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
1049        assert_eq!(out.code, 123);
1050        assert!(out.text_out().is_empty(), "no partial text on --lines failure");
1051        assert!(out.err.contains("b"), "names the failed item: {}", out.err);
1052    }
1053
1054    #[test]
1055    fn test_parse_scatter_options() {
1056        use crate::tools::ToolArgs;
1057
1058        let mut args = ToolArgs::new();
1059        args.named.insert("as".to_string(), Value::String("URL".to_string()));
1060        args.named.insert("limit".to_string(), Value::Int(4));
1061
1062        let opts = parse_scatter_options(&args).unwrap();
1063        assert_eq!(opts.var_name, "URL");
1064        assert_eq!(opts.limit, 4);
1065    }
1066
1067    #[test]
1068    fn test_parse_gather_options() {
1069        use crate::tools::ToolArgs;
1070
1071        let mut args = ToolArgs::new();
1072        args.flags.insert("lines".to_string());
1073
1074        let opts = parse_gather_options(&args).unwrap();
1075        assert!(opts.lines);
1076        assert!(!parse_gather_options(&ToolArgs::new()).unwrap().lines, "default is JSONL");
1077    }
1078
1079    #[test]
1080    fn scatter_limit_clamps_to_ceiling() {
1081        use crate::tools::ToolArgs;
1082
1083        let mut args = ToolArgs::new();
1084        args.named.insert("limit".to_string(), Value::Int(999_999));
1085        let opts = parse_scatter_options(&args).unwrap();
1086        assert_eq!(opts.limit, SCATTER_LIMIT_MAX);
1087    }
1088
1089    #[test]
1090    fn scatter_limit_raises_zero_to_one() {
1091        use crate::tools::ToolArgs;
1092
1093        let mut args = ToolArgs::new();
1094        args.named.insert("limit".to_string(), Value::Int(0));
1095        let opts = parse_scatter_options(&args).unwrap();
1096        assert_eq!(opts.limit, 1);
1097    }
1098
1099    #[test]
1100    fn scatter_limit_raises_negative_to_one() {
1101        use crate::tools::ToolArgs;
1102
1103        let mut args = ToolArgs::new();
1104        args.named.insert("limit".to_string(), Value::Int(-42));
1105        let opts = parse_scatter_options(&args).unwrap();
1106        assert_eq!(opts.limit, 1);
1107    }
1108
1109    #[test]
1110    fn scatter_limit_preserves_valid_values() {
1111        use crate::tools::ToolArgs;
1112
1113        let mut args = ToolArgs::new();
1114        args.named.insert("limit".to_string(), Value::Int(500));
1115        let opts = parse_scatter_options(&args).unwrap();
1116        assert_eq!(opts.limit, 500);
1117    }
1118
1119    // ── FIX A: loud on present-but-wrong-typed flag values ──
1120
1121    #[test]
1122    fn scatter_limit_wrong_type_is_loud_error() {
1123        use crate::tools::ToolArgs;
1124
1125        let mut args = ToolArgs::new();
1126        args.named.insert("limit".to_string(), Value::String("five".to_string()));
1127        let err = parse_scatter_options(&args).unwrap_err();
1128        assert!(err.contains("--limit"), "{err}");
1129        assert!(err.contains("five"), "{err}");
1130    }
1131
1132    #[test]
1133    fn scatter_limit_bool_is_loud_error() {
1134        use crate::tools::ToolArgs;
1135
1136        let mut args = ToolArgs::new();
1137        args.named.insert("limit".to_string(), Value::Bool(true));
1138        let err = parse_scatter_options(&args).unwrap_err();
1139        assert!(err.contains("--limit"), "{err}");
1140    }
1141
1142    #[test]
1143    fn scatter_limit_numeric_string_coerces() {
1144        // Values from variables often stringify: `scatter --limit "$n"`.
1145        use crate::tools::ToolArgs;
1146
1147        let mut args = ToolArgs::new();
1148        args.named.insert("limit".to_string(), Value::String("5".to_string()));
1149        let opts = parse_scatter_options(&args).unwrap();
1150        assert_eq!(opts.limit, 5);
1151    }
1152
1153    #[test]
1154    fn scatter_as_wrong_type_is_loud_error() {
1155        use crate::tools::ToolArgs;
1156
1157        let mut args = ToolArgs::new();
1158        args.named.insert("as".to_string(), Value::Int(42));
1159        let err = parse_scatter_options(&args).unwrap_err();
1160        assert!(err.contains("--as"), "{err}");
1161        assert!(err.contains("42"), "{err}");
1162    }
1163
1164    #[test]
1165    fn scatter_timeout_negative_int_is_loud_error() {
1166        use crate::tools::ToolArgs;
1167
1168        let mut args = ToolArgs::new();
1169        args.named.insert("timeout".to_string(), Value::Int(-5));
1170        let err = parse_scatter_options(&args).unwrap_err();
1171        assert!(err.contains("--timeout"), "{err}");
1172    }
1173
1174    #[test]
1175    fn scatter_timeout_unparseable_string_is_loud_error() {
1176        use crate::tools::ToolArgs;
1177
1178        let mut args = ToolArgs::new();
1179        args.named.insert("timeout".to_string(), Value::String("banana".to_string()));
1180        let err = parse_scatter_options(&args).unwrap_err();
1181        assert!(err.contains("--timeout"), "{err}");
1182        assert!(err.contains("banana"), "{err}");
1183    }
1184
1185    #[test]
1186    fn scatter_timeout_valid_duration_string_parses() {
1187        use crate::tools::ToolArgs;
1188
1189        let mut args = ToolArgs::new();
1190        args.named.insert("timeout".to_string(), Value::String("5s".to_string()));
1191        let opts = parse_scatter_options(&args).unwrap();
1192        assert_eq!(opts.timeout, Some(Duration::from_secs(5)));
1193    }
1194
1195    #[test]
1196    fn scatter_timeout_nonnegative_int_is_seconds() {
1197        use crate::tools::ToolArgs;
1198
1199        let mut args = ToolArgs::new();
1200        args.named.insert("timeout".to_string(), Value::Int(30));
1201        let opts = parse_scatter_options(&args).unwrap();
1202        assert_eq!(opts.timeout, Some(Duration::from_secs(30)));
1203    }
1204
1205    // ── FIX C: binary worker output must not silently corrupt to U+FFFD ──
1206
1207    fn binary_result(invalid_utf8: Vec<u8>) -> ExecResult {
1208        ExecResult::success_bytes(invalid_utf8)
1209    }
1210
1211    #[test]
1212    fn gather_row_goes_loud_not_lossy_on_binary_out() {
1213        // 0xFF is never valid UTF-8 on its own — text_out() would replace it
1214        // with U+FFFD; try_text_out() must catch it instead.
1215        let results = vec![ScatterResult {
1216            item: item("bin"),
1217            result: binary_result(vec![0xFF, 0xFE, 0x00, 0x01]),
1218            timed_out: false,
1219        }];
1220        let out = gather_results(&results, &GatherOptions::default());
1221        assert_eq!(out.code, 123, "a binary row flips the overall exit code too");
1222        let row: serde_json::Value =
1223            serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
1224        assert_eq!(row["ok"], false, "binary output must not be silently ok:true");
1225        assert_ne!(row["code"], 0, "must carry a nonzero code");
1226        assert!(row["out"].as_str().unwrap().is_empty(), "no lossy text in out");
1227        let err_text = row["err"].as_str().unwrap();
1228        assert!(err_text.contains("binary"), "{err_text}");
1229        assert!(!err_text.contains('\u{FFFD}'), "must not carry U+FFFD: {err_text}");
1230    }
1231
1232    #[test]
1233    fn gather_lines_hard_errors_on_binary_out() {
1234        // --lines is the raw-text escape hatch; binary must hard-error the
1235        // whole gather rather than emit a U+FFFD-laden line.
1236        let results = vec![
1237            ScatterResult { item: item("a"), result: ExecResult::success("good"), timed_out: false },
1238            ScatterResult {
1239                item: item("bin"),
1240                result: binary_result(vec![0xFF, 0xFE]),
1241                timed_out: false,
1242            },
1243        ];
1244        let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
1245        assert_eq!(out.code, 123);
1246        assert!(out.text_out().is_empty(), "no partial/lossy text on binary --lines failure");
1247        assert!(!out.err.contains('\u{FFFD}'), "must not carry U+FFFD: {}", out.err);
1248        assert!(out.err.contains("binary") || out.err.contains("bin"), "{}", out.err);
1249    }
1250
1251    // ── FIX D: workers must inherit the parent's watchdog ──
1252
1253    fn ctx_with_memory_fs() -> ExecContext {
1254        use crate::vfs::{MemoryFs, VfsRouter};
1255        use std::sync::Arc;
1256        let mut vfs = VfsRouter::new();
1257        vfs.mount("/", MemoryFs::new());
1258        ExecContext::new(Arc::new(vfs))
1259    }
1260
1261    #[test]
1262    fn worker_ctx_inherits_parent_watchdog() {
1263        use crate::watchdog::Watchdog;
1264        use std::sync::Arc;
1265
1266        let mut parent = ctx_with_memory_fs();
1267        parent.watchdog = Some(Arc::new(Watchdog::new(Duration::from_secs(30))));
1268
1269        // The fix: workers are built via `child_for_pipeline`, which clones the
1270        // parent's watchdog. The old from-scratch `with_backend_and_scope`
1271        // path (below) dropped it — this test would fail against that path.
1272        let worker_ctx = parent.child_for_pipeline();
1273        assert!(
1274            worker_ctx.watchdog.is_some(),
1275            "worker must carry the parent's script watchdog, not None"
1276        );
1277
1278        // Document the trap the fix closes: the old construction starts with a
1279        // None watchdog, which dispatch_command then syncs into the subkernel.
1280        let from_scratch =
1281            ExecContext::with_backend_and_scope(parent.backend.clone(), parent.scope.clone());
1282        assert!(
1283            from_scratch.watchdog.is_none(),
1284            "the abandoned from-scratch path is exactly why the worker lost its watchdog"
1285        );
1286    }
1287
1288    // ── FIX E: workers cap output against the shared spill budget ──
1289
1290    #[tokio::test]
1291    async fn worker_spills_over_the_shared_output_limit() {
1292        use crate::output_limit::{apply_spill_contract, OutputLimitConfig};
1293
1294        // Small in-memory limit (no disk writes in tests — CLAUDE.md).
1295        let mut cfg = OutputLimitConfig::agent().in_memory();
1296        cfg.set_limit(Some(64));
1297
1298        let mut parent = ctx_with_memory_fs();
1299        parent.output_limit = cfg;
1300
1301        // `child_for_pipeline` shares the parent's output_limit, so the worker
1302        // caps against the same budget — this is exactly what the worker path
1303        // now reads (`worker_ctx.output_limit`) before building its
1304        // ScatterResult.
1305        let worker_ctx = parent.child_for_pipeline();
1306        assert!(worker_ctx.output_limit.is_enabled(), "budget must reach the worker");
1307
1308        // Mirror the worker sequence: a large result, then the per-worker
1309        // spill/exit-3 contract (GH #212 — `apply_spill_contract` is what
1310        // `run_parallel` now calls, replacing a bare `spill_if_needed` that
1311        // never remapped the exit code).
1312        let mut result = ExecResult::success("x".repeat(4096));
1313        assert!(worker_ctx.output_limit.is_enabled());
1314        apply_spill_contract(&mut result, &worker_ctx.output_limit).await;
1315
1316        assert!(result.did_spill, "worker output over the limit must spill, not stay resident");
1317        assert_eq!(
1318            result.code, 3,
1319            "a spilled worker must exit 3 so gather's ok()-based aggregation counts it as failed"
1320        );
1321        assert_eq!(result.original_code, Some(0), "the worker's own clean exit is preserved");
1322        assert!(
1323            result.text_out().len() < 4096,
1324            "spilled output must be truncated, not the full payload: {} bytes",
1325            result.text_out().len()
1326        );
1327    }
1328
1329    // ── GH #132: a worker completing at the timeout boundary was
1330    // misclassified as timed out — reproduced and fixed ──
1331    //
1332    // Confirmed mechanism: the per-worker timer task does, in order,
1333    // `flag.store(true, SeqCst); cancel.cancel();` — both statements on the
1334    // SAME task, so by the time `cancel()` runs the flag is already true.
1335    // `sleep`'s own `tokio::select! { sleep(d) => success, cancelled() =>
1336    // failure(130) }` is unbiased: if BOTH branches are ready at the same
1337    // poll (the worker's own timer AND the just-cancelled token), tokio picks
1338    // between them pseudo-randomly. If it picks the sleep branch,
1339    // `run_sequential` returns a genuine success — but the flag was already
1340    // set moments earlier by the same timer task. Before the fix, the worker
1341    // trusted the flag unconditionally (`timed_out_check.load()`), tagging a
1342    // truly-successful result `timed_out: true` / code 124. The fix (see
1343    // `let timed_out = timed_out_check.load(...) && !result.ok();` above)
1344    // makes the result's own success authoritative: completion wins ties.
1345    //
1346    // Repro strategy: tie the worker's own `sleep <D>` EXACTLY to `scatter
1347    // --timeout <D>` so both timers mature at the identical virtual instant
1348    // under `start_paused`, then run many iterations. `start_paused` requires
1349    // the `current_thread` flavor (tokio rejects it combined with
1350    // `multi_thread`), so there's no genuine OS-thread-scheduling
1351    // non-determinism here — the variance across iterations comes entirely
1352    // from `tokio::select!`'s own pseudo-random tie-break (fastrand,
1353    // advancing per call) when `sleep`'s internal select has both branches
1354    // ready at once. Verified: this test fails ~45% of iterations against
1355    // the pre-fix code (a plain flag load) and passes 100% against the fix.
1356    #[tokio::test(flavor = "current_thread", start_paused = true)]
1357    async fn worker_completing_at_timeout_boundary_is_not_misclassified() {
1358        use crate::ast::{Arg, Expr};
1359        use crate::dispatch::BackendDispatcher;
1360        use crate::tools::register_builtins;
1361        use crate::vfs::{MemoryFs, VfsRouter};
1362
1363        let mut registry = ToolRegistry::new();
1364        register_builtins(&mut registry);
1365        let tools = Arc::new(registry);
1366        let dispatcher: Arc<dyn CommandDispatcher> =
1367            Arc::new(BackendDispatcher::new(tools.clone()));
1368        let runner = ScatterGatherRunner::new(tools.clone(), dispatcher);
1369
1370        // 20ms on both sides — the exact tie the race depends on.
1371        let commands = vec![Command {
1372            name: "sleep".to_string(),
1373            args: vec![Arg::Positional(Expr::Literal(Value::String("0.02".to_string())))],
1374            redirects: vec![],
1375        }];
1376        let opts = ScatterOptions {
1377            timeout: Some(Duration::from_millis(20)),
1378            ..ScatterOptions::default()
1379        };
1380
1381        let mut false_positives = 0;
1382        let mut genuine_timeouts = 0;
1383        let mut clean_success = 0;
1384        let iterations = 300;
1385        for _ in 0..iterations {
1386            // `BackendDispatcher::dispatch` routes through `ctx.backend.call_tool`,
1387            // not the registry directly — `with_vfs_and_tools` wires a
1388            // `LocalBackend` backed by OUR registry, so `sleep` actually
1389            // resolves instead of falling through to "command not found".
1390            let mut vfs = VfsRouter::new();
1391            vfs.mount("/", MemoryFs::new());
1392            let ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools.clone());
1393            let items = vec![item("x")];
1394            let results = runner.run_parallel(&items, &opts, &commands, &ctx).await;
1395            assert_eq!(results.len(), 1);
1396            let r = &results[0];
1397            match (r.timed_out, r.result.ok()) {
1398                (true, true) => false_positives += 1,
1399                (true, false) => genuine_timeouts += 1,
1400                (false, _) => clean_success += 1,
1401            }
1402        }
1403
1404        eprintln!(
1405            "worker_completing_at_timeout_boundary: {false_positives} false-positive(s), \
1406             {genuine_timeouts} genuine timeout(s), {clean_success} clean success(es) out of \
1407             {iterations} iterations"
1408        );
1409        // Silence isn't success: if the tie stopped forming (e.g. a tokio
1410        // upgrade changes select!'s tie-break behavior), 0 false positives
1411        // would be meaningless rather than reassuring. Assert the race
1412        // actually fires both ways, so this test can't quietly stop testing
1413        // anything.
1414        assert!(
1415            genuine_timeouts > 0 && clean_success > 0,
1416            "the tie never formed (genuine_timeouts={genuine_timeouts}, \
1417             clean_success={clean_success}) — this test needs the race to actually occur to \
1418             mean anything; check the tied durations still create a real contest"
1419        );
1420        assert_eq!(
1421            false_positives, 0,
1422            "GH #132: a worker whose operation genuinely completed (result.ok()) must never \
1423             be reported timed_out — completion should win the tie"
1424        );
1425    }
1426}