Skip to content

feat(run): opt-in --stream for live task output (#507) - #509

Open
0xGaspar wants to merge 5 commits into
mainfrom
feat/run-stream-output
Open

feat(run): opt-in --stream for live task output (#507)#509
0xGaspar wants to merge 5 commits into
mainfrom
feat/run-stream-output

Conversation

@0xGaspar

Copy link
Copy Markdown
Contributor

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:

  • The human-readable path calls Command::status(), which inherits fledge's stdio. Output there was always live and correctly interleaved.
  • The --json path calls Command::output(), which buffers both pipes until exit and closes the child's stdin.

So the real defect is narrower and sharper than filed: --json is the mode that goes dark, and it is exactly the mode an agent or CI wrapper uses for a long-running task. --stream targets that path. Recording this so the scope isn't later mistaken for "fledge run was entirely buffered", which it never was.

Decisions

--stream --json streams 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's stdout/stderr exactly as before. Mirroring to stdout would interleave child bytes with the envelope and break every | jq consumer; rejecting the combination would deny the feature to the only mode that needs it. The envelope field set and schema_version are unchanged, and stdout/stderr are 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_all on io::Stderr locks 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) makes stdout/stderr inseparable 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: --stream propagates to dependency tasks (an output mode, not a task input), and --stream without --json is 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_streaming returns StreamedOutput { status, stdout, stderr } and writes through io::stderr() rather than holding a StderrLock for 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.rs and src/watch.rs only 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.

  • Unit: pump capture/mirror byte-equality, partial line (a prompt with no newline), empty input, payload larger than the 8 KiB buffer; run_streaming stream separation, exit-code propagation (exit 7, output before failure retained), and byte-parity with Command::output.
  • Integration: buffered stays the default; --stream --json mirrors both streams and still fills the envelope; stdout remains a single JSON document even when the task prints JSON-shaped text; a failing task reports exit_code: 3 with streamed and buffered envelopes asserted equal; deps stream; human-mode no-op keeps the task summary.

Manually verified: a 3s task under --stream --json showed tick-1..3 on stderr at t=1.5s with stdout still empty; an interactive read task produced hello-bruno under --stream versus hello- (stdin closed) buffered.

Verification

cargo test, cargo clippy --all-targets -- -D warnings, cargo fmt --check all green. specsync check exits 0.

Specs: run 6 β†’ 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. The cli.rs/main.rs edits are confined to the Run variant while #505's are in the spec area, so those merge trivially.

Closes #507

πŸ€– Generated with Claude Code

https://claude.ai/code/session_01KuhwrJF2XFDVHhX7qzDuyy

0xGaspar and others added 2 commits August 12, 2026 20:30
`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
@0xGaspar
0xGaspar requested a review from a team as a code owner August 12, 2026 21:53
@0xGaspar
0xGaspar requested review from 0xLeif, Kyntrin and tofu-ux August 12, 2026 21:53
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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 0xLeif left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@github-actions
github-actions Bot dismissed their stale review August 16, 2026 02:25

Superseded by updated review.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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.
@github-actions
github-actions Bot dismissed their stale review August 17, 2026 01:25

Superseded by updated review.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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

0xGaspar added a commit that referenced this pull request Aug 17, 2026
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 0xLeif left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Envelope loss on mirror failure β€” pump now records PumpOutcome::mirror_error, stops echoing, keeps capturing, and the envelope still prints with the child's real exit code.
  2. Leaked stderr thread β€” join_pumps joins both handles unconditionally before evaluating either result.
  3. 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:13 still asserts "exactly one JSON document on stdout" β€” the claim blocker 3 explicitly retracted. It contradicts REQ-run-010 twelve lines below. Same residue at run.spec.md:183.
  • REQ-run-020/021/022 sit as ### under ## Out of Scope, so three normative requirements read as out-of-scope items β€” and they duplicate REQ-run-008..013.
  • run.spec.md has 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

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.

Feature: stream child output for interactive fledge run tasks

2 participants