feat(run): opt-in --stream for live task output (#507) - #509
Conversation
`fledge run` in human-readable mode already inherits the terminal, so its output was always live. The real gap was `--json`, which runs the task via `Command::output`: nothing is visible until exit and the child's stdin is closed, so a long-running or interactive task shows no progress and cannot prompt. `--stream` closes that gap without changing any default: - `--stream --json` tees both pipes β child bytes are mirrored to fledge's **stderr** as they arrive and still captured in full for the envelope. Mirroring targets stderr on purpose: stdout must stay exactly one parseable JSON document, so `| jq` consumers are unaffected even when the task itself prints JSON. - The child inherits stdin under `--stream`, so streamed tasks can prompt. - `--stream` without `--json` is accepted and is a no-op (that path already streams). - Forwarding is unconditional and verbatim β no TTY probe, no colouring or prefixing β so piped/CI runs stream too and the behaviour is testable. - Ordering is guaranteed per stream; cross-stream interleaving is best-effort (two pipes, two threads) and the spec says so rather than over-claiming. - Exit codes, the failure message and every envelope field are identical in both modes. `--stream` propagates to dependency tasks. Spec: run 6 -> 7, with new invariants, REQ-run-008..013, acceptance/rejection signals, and companion updates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KuhwrJF2XFDVHhX7qzDuyy
Records the #507 work through the normal lifecycle: interview, semantic delta for the run module, requirement evidence for REQ-run-020/021/022, definition approval, verify-native, closing approval. Allocated as CHG-0010 rather than the CHG-0008 the tool offered: #504 already claims 0008 and #505 claims 0009 on their branches while the sequence ledger on main is at 7, so the tool hands every open PR a colliding identity. Renumbered while still draft, before any approval digest covered the id. Also re-verified CHG-0007, which the sequence-ledger bump staled even though no real delivery input changed (CorvidLabs/spec-sync#481). Healed via reopen -> verify -> accept; no approval digests were rewritten. `specsync check` exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KuhwrJF2XFDVHhX7qzDuyy
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
There was a problem hiding this comment.
β Corvin says...
_
<(;\ .oO(oh no...)
|/(\
\(\\
" "\\
"I'm pecking through the errors..."
CI Summary
| Check | Status |
|---|---|
| Dependency Audit | β Passed |
| Integration (3 OS) | β skipped |
| Lint (fmt + clippy) | β Passed |
| Spec Validation | β failure |
| Tests (3 OS) | β failure |
Powered by corvid-pet
0xLeif
left a comment
There was a problem hiding this comment.
Review β REQUEST CHANGES
Automated review via Claude Code (/code-review), covering this PR alongside #505 and #504.
CI is currently red on this PR (test (windows-latest), spec-check, trust), and the review also turned up correctness bugs in the --stream implementation independent of that:
Blocker 1: write failure to the mirrored stderr stream discards the captured output/JSON envelope
src/run.rs:276 β pump() returns Err the instant sink.write_all(chunk)? fails, discarding the entire captured buffer even though the child process's real exit status is already known.
Repro: fledge run build --json --stream where fledge's own stderr becomes a pipe/file that stops accepting writes partway through (e.g. a downstream consumer exits early, or a full disk). The error propagates through run_streaming's out_handle.join()?? and execute_task's ? before the run_task envelope is ever built β the caller gets a bare I/O error instead of exit_code/stdout/stderr, with no indication the task actually succeeded.
Blocker 2: documented "stdout stays a single JSON document" guarantee is false for tasks with dependencies
src/cli.rs:203 (and the matching AGENTS.md line) β each recursive execute_task call for a dependency prints its own full JSON envelope to stdout.
Verified: fledge run build --json --stream where build depends on prep prints two concatenated JSON objects on stdout. Feeding that to serde_json::from_str / json.loads fails with a "trailing characters" error β exactly the long-running/multi-step scenario --stream is meant for, contradicting the newly added documented guarantee.
Blocker 3: stdout-pump join failure leaks the stderr-forwarder thread
src/run.rs:326 β in run_streaming, if out_handle.join() errors, the ?/?? short-circuits before err_handle.join() is ever called. err_handle's JoinHandle is dropped un-joined (which detaches, not stops, the thread in Rust), so it keeps reading the child's stderr pipe and writing to io::stderr() after execute_task has already bailed and the CLI is unwinding toward exit.
Verdict
Please fix the Windows test / spec-check / trust failures, then address the three correctness issues above (envelope loss on write failure, the single-JSON-document claim, and the leaked thread on join failure) before re-review.
`specsync change accept` bumps specs/run/run.spec.md to v8 and appends the requirements rows, but the previous commit staged only .specsync/, so those edits never landed. CI's spec-check then could not reconstruct CHG-0010's signed raw-content aggregate against the committed tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KuhwrJF2XFDVHhX7qzDuyy
There was a problem hiding this comment.
β Corvin says...
_
<(;\ .oO(oh no...)
|/(\
\(\\
" "\\
"I'm pecking through the errors..."
CI Summary
| Check | Status |
|---|---|
| Dependency Audit | β Passed |
| Integration (3 OS) | β skipped |
| Lint (fmt + clippy) | β Passed |
| Spec Validation | β Passed |
| Tests (3 OS) | β failure |
Powered by corvid-pet
β¦eam tests Addresses the #509 review. Windows CI: the three streaming integration tests defined their tasks with `;`, which `cmd /C` does not treat as a separator β the whole line became one `echo` redirected to stderr, so the envelope's `stdout` came back empty. They now pick `&` under `cfg!(windows)`, keeping Windows coverage of the feature rather than gating the tests to unix. Mirror-write failure no longer destroys the result. `pump` returns a `PumpOutcome` (capture + first mirror error): a failed write to fledge's stderr stops the echo and is recorded, while capture continues to EOF and the call still returns Ok. `execute_task` warns once (best-effort β the sink it warns on is the one that failed) and still emits the envelope with the child's real exit code and full output. A read failure on the child's own pipe stays a hard error, since the capture would then be incomplete. Verified against the old binary: with stderr on /dev/full a successful streamed task used to exit 101 with no envelope; it now exits 0 with one. Leaked forwarding thread fixed. `join_pumps` joins both handles before propagating either failure, so a stdout-side error no longer drops the stderr `JoinHandle` un-joined (which detaches, not stops, the thread). The same guard covers a failed `child.wait()`: kill the child so the pumps reach EOF, then join. Docs corrected, behaviour left alone: `--json` has always printed one `run_task` envelope per executed task, so a task with deps emits several concatenated objects. Only this PR's docs newly claimed otherwise, so the claim was fixed in the flag help, AGENTS.md, the CLI reference and the run spec β collapsing dependency envelopes would be a breaking `run_task` change unrelated to streaming, and is recorded as a gap instead. Tests: pump keeps capturing past a broken sink / propagates read errors; stream_child with an always-failing sink still reports exit 7 and full capture; join_pumps joins the sibling thread on failure (verified to fail against the old lazy-join form); an integration test pins the one-envelope -per-task stdout shape in both buffered and streamed modes.
There was a problem hiding this comment.
β Corvin says...
_
<(;\ .oO(oh no...)
|/(\
\(\\
" "\\
"Even the dumpster of code seems empty today."
CI Summary
| Check | Status |
|---|---|
| Dependency Audit | β Passed |
| Integration (3 OS) | β Passed |
| Lint (fmt + clippy) | β Passed |
| Spec Validation | β failure |
| Tests (3 OS) | β Passed |
Powered by corvid-pet
The review fixes changed src/ under an already-accepted record, staling
CHG-0008. Healed via reopen -> verify -> accept (verify-native green,
4 requirements re-evidenced).
The fixes also introduced src/remote.rs and tests/isolation.rs, which
CHG-0008 did not cover. spec-sync refuses to widen the definition of an
already-applied change ("perform further spec changes in a new change
workspace"), so those land as CHG-0011 with its own remote delta and
REQ-remote-010 rather than by editing the accepted definition.
Allocated 0011 because 0008 (this PR and #511), 0009 (#505) and 0010
(#509) are all claimed on open branches.
Also documents four github exports the coverage gate flagged: remote_base
and remote_url were sharing one table row, and API_BASE_ENV /
REMOTE_BASE_ENV were undocumented.
`specsync check` exits 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuhwrJF2XFDVHhX7qzDuyy
The review fixes changed src/run.rs and the run spec under an already- accepted record. Healed via reopen -> verify -> accept; verify-native green with all three requirements re-evidenced. No approval digests were rewritten. `specsync check` exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KuhwrJF2XFDVHhX7qzDuyy
0xLeif
left a comment
There was a problem hiding this comment.
Approve β the three reds are a GitHub outage, not your code
All three 2026-08-13 blockers are genuinely fixed, and I verified each against source rather than the commit message:
- Envelope loss on mirror failure β
pumpnow recordsPumpOutcome::mirror_error, stops echoing, keeps capturing, and the envelope still prints with the child's real exit code. - Leaked stderr thread β
join_pumpsjoins both handles unconditionally before evaluating either result. - The false "stdout stays a single JSON document" guarantee β corrected in docs rather than by changing behaviour, which is right: one envelope per executed task is pre-existing, and collapsing it would be a breaking contract change.
Each has a named regression test. The Windows failures were root-caused correctly (cmd treats ; as literal) and Windows CI is green.
On the red checks: audit, CodeQL (js-ts) and corvid-pet all died on No server is currently available during the GitHub API outage 17:33β17:42 UTC. audit actually printed {"vulnerabilities":{"found":false,"count":0}} and then failed. These need a re-run, not a fix. Every substantive check β lint, spec-check, test and Integration on all three OSes, trust, CodeQL actions β passed on bf1df42.
One thing to clear: the corvid-pet bot review still says "Spec Validation β" and describes commit 640dd2d, not head. spec-check is SUCCESS on head. That stale bot review is likely contributing to BLOCKED β worth dismissing.
Two things I checked that did NOT turn out to be problems, so nobody re-raises them:
- "Unconditional stdin inheritance deadlocks callers" reproduces, but the identical hang already exists on the default human-readable path on main and in every ordinary task runner. Documented at
src/cli.rs:203-206, strictly opt-in. - "The child buffers so you see nothing until exit" β I tested the matrix. Shell loops, Rust, Go, Node and Python-to-stderr all stream live over pipes; only C-stdio-to-stdout and unbuffered-less Python-to-stdout burst at exit. A one-clause docs caveat at most.
I also empirically checked the load-bearing concurrency claim: 2 threads Γ 2000 Γ 4096-byte writes through separate io::stderr() handles, 10 runs, zero torn chunks. The documented per-stream atomicity guarantee is real.
Three spec nits, all follow-ups, none blocking:
specs/run/requirements.md:13still asserts "exactly one JSON document on stdout" β the claim blocker 3 explicitly retracted. It contradicts REQ-run-010 twelve lines below. Same residue atrun.spec.md:183.REQ-run-020/021/022sit as###under## Out of Scope, so three normative requirements read as out-of-scope items β and they duplicate REQ-run-008..013.run.spec.mdhas two version-history rows for one change, with row 8 below row 1 in a descending table.
This is the rare follow-up that fixed causes rather than symptoms. The mirror-failure fix draws the right line β a failed echo is advisory, a failed read is fatal because capture would lie β and writes that reasoning down where the next person will find it.
π€ Reviewed via Claude Code
Implements #507 β opt-in live child output for
fledge run.A correction to the issue's premise
The issue says child stdout/stderr "are not visible until the task exits." That is true for one of the two paths:
Command::status(), which inherits fledge's stdio. Output there was always live and correctly interleaved.--jsonpath callsCommand::output(), which buffers both pipes until exit and closes the child's stdin.So the real defect is narrower and sharper than filed:
--jsonis the mode that goes dark, and it is exactly the mode an agent or CI wrapper uses for a long-running task.--streamtargets that path. Recording this so the scope isn't later mistaken for "fledge runwas entirely buffered", which it never was.Decisions
--stream --jsonstreams and captures, mirroring to stderr. Both pipes are teed: bytes go to fledge's stderr as they arrive, and into buffers that fill the envelope'sstdout/stderrexactly as before. Mirroring to stdout would interleave child bytes with the envelope and break every| jqconsumer; rejecting the combination would deny the feature to the only mode that needs it. The envelope field set andschema_versionare unchanged, andstdout/stderrare never silently emptied.The child also inherits stdin under
--stream, so prompts work β a real behavioral difference from the buffered path, captured as an invariant.Ordering is per-stream, not cross-stream. Each stream is forwarded in order, and
write_allonio::Stderrlocks per chunk so a chunk is never split by the other. Relative interleaving between stdout and stderr is best-effort β two OS pipes drained by two threads cannot reconstruct the child's true write order. The one design that would preserve it (a single shared fd) makesstdout/stderrinseparable in the envelope, a worse trade. The issue's "forwarded in order" is met per-stream, and the limit is stated in the spec, docs, and code rather than glossed.Non-TTY: forward anyway. No TTY probe. A flag whose effect silently vanishes in CI is surprising and untestable, and live logs are what a long CI task wants. Output is verbatim β no colour, prefixes, or line framing.
Also:
--streampropagates to dependency tasks (an output mode, not a task input), and--streamwithout--jsonis an accepted documented no-op so wrappers can pass it unconditionally.Implementation
pump<R: Read, W: Write>does the tee, generic so it unit-tests against in-memory buffers instead of racing pipes.run_streamingreturnsStreamedOutput { status, stdout, stderr }and writes throughio::stderr()rather than holding aStderrLockfor the stream's lifetime, which would starve the other thread until its pipe closed and defeat streaming.Substantive logic is confined to
src/run.rs;src/cli.rs,src/main.rsandsrc/watch.rsonly declare and forward the flag.Tests β all deterministic, no timing races
Liveness is asserted by the presence or absence of child bytes on fledge's stderr, which distinguishes the modes without racing them.
pumpcapture/mirror byte-equality, partial line (a prompt with no newline), empty input, payload larger than the 8 KiB buffer;run_streamingstream separation, exit-code propagation (exit 7, output before failure retained), and byte-parity withCommand::output.--stream --jsonmirrors both streams and still fills the envelope; stdout remains a single JSON document even when the task prints JSON-shaped text; a failing task reportsexit_code: 3with streamed and buffered envelopes asserted equal; deps stream; human-mode no-op keeps the task summary.Manually verified: a 3s task under
--stream --jsonshowedtick-1..3on stderr at t=1.5s with stdout still empty; an interactivereadtask producedhello-brunounder--streamversushello-(stdin closed) buffered.Verification
cargo test,cargo clippy --all-targets -- -D warnings,cargo fmt --checkall green.specsync checkexits 0.Specs:
run6 β 7 with invariants 12β18 and REQ-run-008..013, all four companions updated. Docs: a "Streaming (--stream)" section in the CLI reference covering the intended use for long-running/interactive commands, per the issue's last acceptance criterion. Governance: CHG-0010, verified with 3 requirements evidenced.Merge note
The introspect snapshot is the one real conflict risk with #505 β adding a flag changes it and #505 regenerates it too. Whoever merges second should re-run
INSTA_UPDATE=always cargo test --bin fledge introspect_json_schema_snapshot. Thecli.rs/main.rsedits are confined to theRunvariant while #505's are in thespecarea, so those merge trivially.Closes #507
π€ Generated with Claude Code
https://claude.ai/code/session_01KuhwrJF2XFDVHhX7qzDuyy