Skip to content

A job's stdout stream holds only the job's own output - #449

Merged
tobert merged 2 commits into
mainfrom
fix/job-stream-routing
Sep 13, 2026
Merged

tobert merged 2 commits into
mainfrom
fix/job-stream-routing

Conversation

@tobert

@tobert tobert commented Sep 12, 2026

Copy link
Copy Markdown
Owner

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:

if true; then echo a; sh -c 'echo b'; echo c; fi &                 # stream: b
if true; then x=$(sh -c 'echo captured'); echo "got $x"; fi &     # stream: captured
if true; then sh -c 'echo to-file' > f; echo after; fi &          # stream: to-file
seq 1 2 | scatter | sh -c 'echo worker' | gather &                 # stream: worker, worker

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 from background_stream_external_output because it now governs builtins too. It is off inside $(...), for a stage that redirects stdout (>, >>, &>, >&2), 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.

A builtin publishes its output when it returns, after --json is applied, unless a command it re-dispatched already reached the stream, so timeout 5 echo hi & writes once. gather publishes its rows, which no dispatched command produces. finalize_streams no 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 running cargo 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

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>
@tobert
tobert changed the base branch from kaijutsu-async-programs to main September 13, 2026 11:10
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
tobert merged commit 9e9ce1e into main Sep 13, 2026
3 checks passed
@tobert
tobert deleted the fix/job-stream-routing branch September 13, 2026 11:59
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant