A job's stdout stream holds only the job's own output - #449
Merged
Merged
Conversation
A background job's stdout stream was fed two ways: external commands teed each chunk when their pipeline position was Only or Last, and the job's captured result filled the stream at completion only when nothing had streamed. Position alone could not see where output was going, and the completion rule lost everything once any external had written: if true; then echo a; sh -c 'echo b'; echo c; fi & -> "b" if true; then x=$(sh -c 'echo captured'); echo "got $x"; fi & -> "captured" if true; then sh -c 'echo to-file' > f; echo after; fi & -> "to-file" seq 1 2 | scatter | sh -c 'echo worker' | gather & -> "worker\nworker" The routing decision now travels as `background_stream_output`, renamed from `background_stream_external_output` because it governs builtins too. It is off inside `$(...)`, for a stage that redirects stdout, for scatter workers and the stages before `scatter`, and for a compound stage that is not Only or Last, whose inner commands otherwise run as Only. Each dispatch sets it for its own command and restores the kernel's value afterward, so one command's setting does not leak into the next. A builtin publishes its output when it returns, after `--json` is applied, unless the command it re-dispatched already reached the stream (`timeout 5 echo hi &`). gather publishes its rows, which no dispatched command produces. `finalize_streams` no longer writes stdout: every producer publishes as it runs, and a completion write would only repeat or hide a routing hole. stderr keeps its completion rule. Co-Authored-By: DeepSeek V4 Flash <noreply@deepseek.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in #445, #447, #450, #446, and #448. Two conflicts, both from additions at the same spot: CHANGELOG.md Fixed bullets (all kept, in merge order) and scheduler/pipeline.rs, where #448's `fault_result` and this branch's `redirects_stdout` were each added after `finalize_scatter_gather_error`. Both helpers are kept; the dispatch site merged cleanly with `fault_result(e)` and the stream-flag restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tobert
added a commit
that referenced
this pull request
Sep 19, 2026
A job started with `Kernel::execute_background_with_options` published output only when each top-level statement finished, so a loop or a long external command showed nothing in `/v/jobs/N/stdout` until it ended. The same command started as `cmd &` streams live. Whole-program jobs now stream stdout the same way: an external command per chunk, a builtin when it returns. ``` for i in 1 2; do echo "tick-$i"; sleep 2; done before: stdout empty for 4s, then "tick-1\ntick-2\n" after: "tick-1\n" readable while the job runs, "tick-2\n" two seconds later ``` One flag governed both streams. Turning stdout streaming on for these jobs would also have turned on the external stderr tee, and the job's per-statement writer would write that stderr again. The stderr tee now has its own per-job flag, `ExecContext::background_stream_stderr`: true for `cmd &`, false for a whole-program job. spawn.rs tees stderr only when both flags are on, so `cmd &` is unchanged. A whole-program job's writer now carries only statement stderr and its terminal diagnostic. Stderr stays per statement in this change. Live stderr for every job is planned on top of GH #369, which threads the execution context through the interpreter so stdout and stderr can be sinks that redirects and `$(...)` swap. `JobStreams::stderr` and the spawn.rs comment said every pipeline stage tees stderr. First and Middle stages stream nothing; both now say where stderr tees. Streaming stdout exposed a routing hole left by #449: an embedder tool's result was never published, so `embedder_tool &` left `/v/jobs/N/stdout` empty and the whole-program writer had been hiding it. The backend-tool dispatch now publishes through `ExecContext::publish_job_stdout`, the same helper gather's rows use. `background_job_publishes_custom_tool_stdout` fails against main; `background_job_publishes_redispatched_custom_tool_stdout_once` pins that `timeout 5 embedder_tool &` writes the tool's output once. `background_program_publishes_tool_help`, `background_job_publishes_tool_help`, and the stream assertion in `background_program_in_ast_mode_matches_foreground` fail against the tree before the fix. Two more producers had no publish. A tool's `--help` text returns before the tool runs, so `ls --help &` left the stream empty since #449, and with this change a whole-program job would have lost it too. AST mode returns its dump without running a statement. Both now publish through the same helper. `JobManager::finalize_streams` said a whole-program job publishes each statement; it now lists the producers. The `/v/jobs` help and EMBEDDING.md said stderr takes every stage's; they now say an external command at the end of its pipeline streams live and other stderr arrives at completion only when nothing streamed. Tests: `whole_program_external_stdout_is_live` and `whole_program_builtin_output_inside_a_loop_is_live` fail against main. `whole_program_stderr_stream_matches_the_result_in_order` and `whole_program_substitution_stderr_is_job_stderr` pass against main and guard against the duplicate stderr this split prevents. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
tobert
added a commit
that referenced
this pull request
Sep 21, 2026
A spilled background job and one that genuinely exited 3 were the same report. `Job::to_info` copied `result.code` and nothing else, so `did_spill` and `original_code` never reached `JobInfo`, and `failed:3` was the whole answer either way. An embedder reading `jobs --json` had no way to ask whether the 3 was the command's own. `failed:3` stays as the status string — it is the loud signal that GH #212 installed, and replacing it would trade one ambiguity for a compatibility break. The two facts ride alongside it instead. `JobInfo` is `#[non_exhaustive]` with a builder, so the fields are additive, and `jobs --json` serializes `JobInfo` directly, so they appear with no rendering change: ``` {"id":1,"command":"seq 1 5000","status":"failed","exit_code":3,"did_spill":true,"original_code":0} ``` The test that proves it goes through `Job::to_info` rather than building a `JobInfo` by hand — which is how the gap stayed invisible, since every existing `JobInfo` test constructs the struct itself and so cannot miss a field the converter forgets. It runs two jobs under one output limit and separates them on the new fields alone. Second: the test-only `BackendDispatcher` drained an external's stdout into its own ring with no tee into the background job's stream, so no test driven through that dispatcher could observe stream routing at all — a hole directly under the streaming work in #446/#448/#449, in the twin that exists so tests can reach exactly that. It now tees the way the production spawn site does, under the same only-or-last-stage rule, with a test that fails when the tee is removed. Gates: `cargo test --all` clean, `cargo clippy --all --all-targets` zero warnings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
tobert
added a commit
that referenced
this pull request
Sep 22, 2026
A background job's stderr stream (`/v/jobs/N/stderr`) now fills live at every place stderr is produced, the way #449 made stdout live. Before, a builtin's stderr reached the stream only through a write at job completion, and only when no external had written stderr first. Two designs were weighed: publish once per statement, or publish at every producer. We chose every producer, because it is live and in order, on one condition: a producer the design misses must fail a test rather than drop bytes silently. `ExecResult.stderr_published_len` counts how many leading bytes of `err` the stream already holds; publishing writes `err[n..]`. Appends, redirects, `StderrStream` chunks, `timeout`'s note and chain faults each preserve that count, and release-mode assertions refuse a state that would print twice or drop. The completion write is gone. Evidence the net holds: disabling any one of the 13 publish points fails at least one test; a corpus compares every job's stream with its foreground `err` byte for byte; liveness tests read the stream while the job still runs. Recursion depth is unchanged. Found and fixed on the way: - a fault ending a block republished earlier stderr - a failing `[[ ]]` or `test` left of `&&`/`||` published twice - a failing condition command (`if test 1 -eq abc`) printed its diagnostic three times, in the foreground too - `$(...)` stderr was lost under `2>/dev/null` - a killed external could lose its last chunk - a panicked job task left its streams open - a cancel could not end a wait on a pipe a grandchild held open One pre-existing bug gets its own commit: redirects now apply left to right with dup semantics, checked against bash. ``` ls /missing > f 2>&1 # both streams to f (stderr used to reach the terminal) ls /missing 2>&1 > f # stderr to the old stdout, stdout to f ``` Not changed: kaish still applies redirects after a command runs, so `f() { echo a > out; }; f > out` leaves `out` empty (bash keeps `a`). CHANGELOG entries land with the batch's changelog PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A background job's stdout stream (
/v/jobs/N/stdout) was fed two ways. External commands teed each chunk when their pipeline position was Only or Last, and the job's captured result filled the stream at completion only when nothing had streamed. Position alone cannot see where output is going, and the completion rule dropped all builtin output once any external had written:Each now streams only the job's own output:
a b c,got captured,after, and gather's JSONL rows.The routing decision travels as
background_stream_output, renamed frombackground_stream_external_outputbecause it now governs builtins too. It is off inside$(...), for a stage that redirects stdout (>,>>,&>,>&2), for scatter workers and the stages beforescatter, and for a compound stage that is not Only or Last, whose inner commands otherwise run as Only. Each dispatch sets it for its own command and restores the kernel's value afterward.A builtin publishes its output when it returns, after
--jsonis applied, unless a command it re-dispatched already reached the stream, sotimeout 5 echo hi &writes once. gather publishes its rows, which no dispatched command produces.finalize_streamsno longer writes stdout; with every producer publishing as it runs, a completion write would only repeat output or hide a routing hole. The live tee for a runningcargo build &is unchanged.stderr keeps its completion rule: a builtin's stderr still reaches the stream only when no external wrote stderr first.
This stacks on #445.
Co-Authored-By: DeepSeek V4 Flash noreply@deepseek.com
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
🤖 Generated with Claude Code