Skip to content

Whole-program jobs stream stdout as it is produced - #451

Open
tobert wants to merge 2 commits into
mainfrom
fix/live-job-streams
Open

tobert wants to merge 2 commits into
mainfrom
fix/live-job-streams

Conversation

@tobert

@tobert tobert commented Sep 13, 2026

Copy link
Copy Markdown
Owner

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

A job started with `Kernel::execute_background_with_options` published
output only when each top-level statement finished. A `for` loop or a
single `cargo build` showed nothing in `/v/jobs/N/stdout` until it ended,
while the same command as `cmd &` streamed live after #449. A shell user
expects to see output as it is produced.

One flag governed both streams, so turning stdout streaming on for these
jobs would also turn on the external stderr tee, and the per-statement
writer would write that stderr a second time. The stderr tee now has its
own per-job flag, `background_stream_stderr`: true for `cmd &`, false for
a whole-program job. spawn.rs tees stderr only when both flags are on, so
`cmd &` behaves as before. A whole-program job turns stdout streaming on,
and its writer carries only each statement's stderr and the terminal
diagnostic.

Stderr stays per statement here. Live stderr for every job lands after
the execution context is threaded through the interpreter (GH #369).

The comment and `JobStreams::stderr` docs that said every pipeline stage
tees stderr were wrong: First and Middle stages stream nothing. Both now
say where stderr tees.

Streaming stdout exposed a routing hole #449 left open. An embedder
tool (`backend.call_tool`) returned its result without publishing it,
and nothing else published stdout once #449 dropped the completion
write, so `embedder_tool &` left `/v/jobs/N/stdout` empty. The
whole-program writer had hidden this by publishing whole statement
results. The backend-tool arm now publishes through
`ExecContext::publish_job_stdout`, the helper gather's rows use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tobert tobert changed the title fix/live job streams Whole-program jobs stream stdout as it is produced Sep 13, 2026
@tobert

tobert commented Sep 13, 2026

Copy link
Copy Markdown
Owner Author

kaibo review, cast deepseek (explorer and synth deepseek-flash). It read the whole files, with no diff, on commit fb62bb8. Its findings, and what the follow-up commit did about each:

D1: rendered --help never reached a job's stdout stream. execute_command_depth returns the help before the job_stdout capture, and finalize_streams never writes stdout. In a whole-program job this was a regression from the first commit, since the old per-statement writer used to carry it. In a cmd & job it has been broken since #449. Fixed: the help branch publishes through ExecContext::publish_job_stdout, and background_program_publishes_tool_help and background_job_publishes_tool_help both failed before the fix.

D2: in AST mode, a whole-program job's stream stayed empty while its result held the AST dump, and the AST test compared only results. Not a regression, since no statement runs in AST mode. Fixed: execute_streaming_inner publishes the dump, and background_program_in_ast_mode_matches_foreground now also asserts the stream.

D3: in a cmd & job, an earlier pipeline stage's stderr goes missing when the last stage is an external command that writes stderr. The completion write is skipped once anything streams live, and the /v/jobs help said "stderr takes every stage's". This is existing behavior, left for the stdout/stderr sinks work. The help text and EMBEDDING.md now state the real rule.

D4: background_job_publishes_redispatched_custom_tool_stdout_once passes even without the backend-arm publish, because timeout's own publish covers it. It now carries a doc comment: it pins "written once", and background_job_publishes_custom_tool_stdout is the test that catches a missing publish.

No defect found:

  • The new stderr flag propagates correctly through forks, snapshots, dispatch and scatter.
  • The backend-arm publish can't deadlock or write twice.

Noted for GH #369, the next PR in this stack:

  • The backend arm already held the exec_ctx write guard across call_tool, so a backend tool that calls back into the kernel would deadlock.
  • dispatch_command and dispatch_statement don't sync the new per-job flag. That's safe today, because every ctx they receive comes from the same kernel.
  • eval_expr_async's Expr::Command arm drops a command's stdout from the result after the command may already have published it. No path from a statement's condition reaches it: eval_condition_async handles && and ||, which condition_output_tests.rs:112 pins.

🤖

Review of the previous commit found two stdout producers with no publish.
`execute_command_depth` returns rendered `--help` before the tool runs, so
`ls --help &` left `/v/jobs/N/stdout` empty (since #449) and a whole-program
job would now lose it as well. `execute_streaming_inner` returns the AST
dump without running a statement. Both publish through
`ExecContext::publish_job_stdout`.

`finalize_streams` documented that a whole-program job publishes each
statement, which stopped being true; it now lists the producers.

The `/v/jobs` help and EMBEDDING.md said stderr takes every stage's. An
earlier stage's stderr and a builtin's reach the stream only at completion
and only when nothing streamed live, so a last-stage external writing
stderr hides them. The text now says so; live stderr for every stage is
the later sinks work.

The timeout guard test now says it pins "written once", not the backend
publish itself.

Co-Authored-By: DeepSeek V4 Flash <noreply@deepseek.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tobert
tobert added this pull request to stack #453 September 13, 2026 14:33
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