Skip to content

Stop devpod's stream logger from mangling a command's stderr - #623

Merged
blooop merged 3 commits into
mainfrom
fix/agent-stderr-verbatim
Sep 14, 2026
Merged

blooop merged 3 commits into
mainfrom
fix/agent-stderr-verbatim

Conversation

@blooop

@blooop blooop commented Sep 14, 2026

Copy link
Copy Markdown
Owner

The last broken clause

docs/agents-using-dl.md publishes a subprocess contract, and until now one clause of it
was a section headed "stderr is not yours yet". dl <ws> -- <cmd> reaches the container
through devpod ssh --command, which asks for no pty, and without a pty the container's
stderr is folded into devpod's own and reformatted by devpod's stream logger on the way
out.

Measured on this host, devpod 0.26.1, dl <ws> -- sh -c 'echo ERR >&2':

before: <esc>[0;1;37m20:27:24<esc>[0m <esc>[0;1;36minfo<esc>[0m ERR <esc>[0;90mstream_logger.go:492<esc>[0m
after:  ERR

Timestamp, colour, level tag and a Go source location, all gone. stdout was already clean,
so a caller could parse a command's JSON but not its compiler's diagnostics.

The fix, and the trap next to it

devpod's global --log-output takes plain (the default), raw or json. raw is the
obvious choice and would have been a silent regression of the contract's first clause.

devpod means to pass a remote exit status through and cannot: its top-level handler
type-asserts on *ssh.ExitError after wrapping it three times with %w, so every nonzero
remote exit lands on the generic failure path and exits 1 with the real status buried in a
fatal line. dl recovers the number by reading that line, anchored on the word fatal so
a remote program printing the same sentence cannot impersonate the report. Measured:

plain: <esc>…fatal<esc>… tunnel to container: run in container: ssh session: Process exited with status 42
raw:   tunnel to container: run in container: ssh session: Process exited with status 42      <- no tag
json:  {"time":"…","message":"tunnel to container: … status 42","level":"fatal"}

Under raw the tag is gone, recovered_status returns None, and dl ws -- 'exit 42'
stops exiting 42.

So json, which keeps the level as structured data. That is both a stronger anchor than a
coloured tag and one the remote program cannot forge: devpod wraps whatever the container
writes in a record of its own at info, escaping it, so a container printing an entire
fatal record verbatim arrives as that record's message (measured, and pinned by a unit
test). --silent is wrong for the opposite reason: it swallows the command's stderr.

StderrFilter::push now parses each line as a record and forwards the bare message. A
parse miss falls through to the plain-text predicates unchanged, so a devpod too old to
know the flag, the attach route's plain log, and anything on the stream that is not a log
line at all behave exactly as they did. The --debug-hint hold-back is matched against the
message rather than the raw line, so it works on both readings.

Scoped to the non-pty case, on purpose

--log-output json goes on the --command arm of devpod_session and nowhere else.

A bare attach gets a pty, and under one the container's stderr never touches this stream --
the existing comment saying devpod's stderr there "carries devpod's own warnings and
nothing else" is right. The only thing json would change is the look of those warnings to
the person sitting in front of them, trading a coloured warn tag for nothing. So an
interactive dl <ws> logs exactly as it did. a_command_asks_devpod_to_log_in_json_and_an_attach_does_not
pins both halves.

The flag goes after the workspace id: session_manager's workspace_named_by reads
the id out of devpod ssh <id> by position, and a flag in front of it would make every
session anonymous to the manager. Cobra accepts a global flag in that position (measured
against the real binary).

Verified against a live container

Built binary, real workspace, real cache:

$ dl <ws> -- sh -c 'echo ERR >&2'   # stderr: ERR, nothing around it
$ dl <ws> -- sh -c 'exit 42'; echo $?     # 42
$ dl <ws> -- sh -c 'echo OUT'      # stdout: OUT
$ dl <ws> -- sh -c 'kill -INT $$'; echo $?  # 255, as the page documents

Every clause of the published contract was replayed against that container with the new
binary, the 2>&1 merge recipe included. All pass.

Docs and tests

  • test_stderr_is_the_commands_output_verbatim was a strict xfail whose stated purpose
    was to turn the suite red the day the transport was fixed. It is a normal passing test
    now, and a second e2e case asserts the wrapper is absent by name rather than only that
    the tail matches.
  • The "stderr is not yours yet" section is gone. stderr is the fifth clause of the contract
    list, with two honest caveats: lines are read one at a time, so an unterminated last line
    gains a newline; and devpod's logger strips ANSI from what it carries, so a tool that
    colours its errors arrives uncoloured (the command sees a pipe, so most would anyway).
  • The 2>&1 merge still works, is still shown, and is no longer presented as the required
    form -- only as what to reach for when you want one interleaved stream.
  • test_agent_contract_doc.py gained the fifth promise phrase.

Nothing contradicted the research

Everything in the brief reproduced: the three log modes, stdout untouched in all of them,
--silent swallowing stderr, and raw losing the fatal tag. Two things worth adding:
devpod's logger strips ANSI escapes from the message in both plain and json (only raw
preserves them), and dl's own provisioning devpod ssh --command calls go through
devpod::run rather than devpod_session, so they keep plain logging -- their stderr is
dl's business, not a caller's.

Expect a small CHANGELOG/docs conflict with #621.

🤖 Generated with Claude Code

Summary by Sourcery

Preserve command stderr while retaining remote exit-status handling by using structured devpod logging only for non-interactive command sessions.

New Features:

  • Preserve command stderr output without devpod stream-logger decoration for non-PTY commands.
  • Document stderr as part of the subprocess output contract, including newline and ANSI caveats.

Bug Fixes:

  • Maintain recovery of remote command exit statuses while removing stderr formatting.
  • Keep interactive attach logging unchanged while applying structured logging only to command sessions.

Enhancements:

  • Parse devpod JSON log records to forward command messages cleanly and safely distinguish remote failures from user output.
  • Retain plain-text fallback behavior for older devpod versions and non-JSON streams.

Documentation:

  • Update the changelog and subprocess contract documentation to describe verbatim stderr behavior and the continued use of in-container stream merging when interleaving is desired.

Tests:

  • Convert the stderr contract test from a strict expected failure to a passing end-to-end test and add coverage for undecorated stderr, exit-status recovery, forged fatal records, JSON documents, warning handling, and attach scoping.

`dl <ws> -- <cmd>` reaches the container through `devpod ssh --command`,
which asks for no pty, and without a pty the container's stderr is folded
into devpod's own and reformatted by devpod's stream logger on the way
out. Measured against devpod 0.26.1, `echo ERR >&2` came back as a
timestamp, a coloured `info` tag, the text, and `stream_logger.go:492` --
unparseable as a compiler's diagnostics, which is what the published
contract promises a caller gets.

devpod's `--log-output json` is now passed on that invocation, and the
stderr filter reads each line as a log record: the level is a field rather
than a coloured tag, and the bare `message` is forwarded, which for the
command's stderr is the command's bytes.

Not `raw`, though it is the obvious choice. devpod buries a nonzero remote
exit in a `fatal` line (it type-asserts on `*ssh.ExitError` after wrapping
it three times with `%w`), and dl recovers the status by reading that
line's tag. `raw` drops the tag, the recovery returns nothing, and
`dl ws -- 'exit 42'` silently stops exiting 42 -- trading the contract's
first clause for its last. json keeps the level, and a level the container
cannot forge: devpod escapes whatever the container writes into a record
of its own at `info`.

Scoped to the command arm. A bare attach gets a pty, the container's
stderr never touches this stream there, and json would only strip the
colour off devpod's own warnings to the person reading them.

A line that is not a record falls through to the plain-text predicates,
so an older devpod and the attach route behave exactly as before.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @blooop, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 12 hours and 55 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR switches non-PTY DevPod command sessions to JSON logging, unwraps log records in the stderr filter without compromising remote exit-status recovery or attach behavior, and formalizes the resulting clean-stderr guarantee through tests and documentation.

Sequence diagram for clean stderr and remote exit recovery

sequenceDiagram
    participant Caller
    participant DL
    participant Devpod
    participant Container
    Caller->>DL: dl workspace -- command
    DL->>Devpod: ssh workspace --log-output json --command payload
    Devpod->>Container: execute payload without pty
    Container-->>Devpod: stderr line
    Devpod-->>DL: JSON info record with message
    DL->>DL: LogRecord.parse(line)
    DL-->>Caller: bare message on stderr
    Container-->>Devpod: nonzero exit status
    Devpod-->>DL: JSON fatal record with status text
    DL->>DL: remote_status_in(message)
    DL-->>Caller: command exit status
Loading

Flow diagram for StderrFilter record handling

flowchart TD
    Start["Read stderr line"] --> Parse["LogRecord.parse(line)"]
    Parse -->|record| Message["Use record.message"]
    Parse -->|parse miss| Plain["Use original line"]
    Message --> Level{"level is fatal?"}
    Level -->|yes| Status["remote_status_in(message)"]
    Level -->|no| Forward["Forward message"]
    Plain --> Legacy["recovered_status(line) and legacy predicates"]
    Status -->|status found| Hold["Hold back report"]
    Status -->|no status| Forward
    Legacy -->|status found| Hold
    Legacy -->|no status| Forward
Loading

File-Level Changes

Change Details Files
Preserve command stderr while retaining remote exit-status recovery.
  • Request DevPod JSON logging only for non-PTY command sessions.
  • Parse JSON log records and forward their bare messages.
  • Recover remote statuses from fatal records while preventing command output from forging status reports.
  • Retain plain-text fallback behavior for older DevPod versions, attach sessions, and non-record lines.
  • Keep debug-hint suppression compatible with both JSON and plain formats.
rust/devlaunch-core/src/clients/devpod.rs
rust/devlaunch-core/src/flows/launch.rs
Verify command-session scoping and stream-filter behavior with unit and integration coverage.
  • Add unit tests for clean stderr, JSON fatal recovery, forged fatal records, warning forwarding, debug hints, JSON-document passthrough, and plain fallback.
  • Assert command invocations include the correctly positioned logging flag while interactive attaches remain unchanged.
  • Update command expectation fixtures across launch, rewrite, interactive, and dl tests.
  • Promote the stderr contract e2e test from strict xfail and add an assertion that logger decoration is absent.
rust/devlaunch-core/src/clients/devpod.rs
rust/devlaunch-core/src/flows/launch.rs
rust/aid/tests/interactive.rs
rust/aid/tests/rewrite.rs
rust/dl/tests/launch.rs
test/e2e/test_agent_subprocess_contract.py
Publish stderr as part of the subprocess contract and document its remaining transport caveats.
  • Add stderr as the fifth contract promise and remove the obsolete mangling/workaround section.
  • Document newline and ANSI-stripping caveats while retaining the in-container 2>&1 recipe for intentional stream merging.
  • Add documentation-contract coverage for the new promise.
  • Record the behavior change in the unreleased changelog.
docs/agents-using-dl.md
test/test_agent_contract_doc.py
CHANGELOG.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.09524% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.98%. Comparing base (e9a0c03) to head (1e6cfa4).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
rust/devlaunch-core/src/clients/devpod.rs 98.79% 1 Missing ⚠️
rust/devlaunch-core/src/flows/launch.rs 95.45% 1 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.23% <98.09%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 95.23% <98.09%> (+<0.01%) ⬆️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…atim

# Conflicts:
#	CHANGELOG.md
#	docs/agents-using-dl.md
@blooop
blooop merged commit 834b5a8 into main Sep 14, 2026
15 checks passed
@blooop
blooop deleted the fix/agent-stderr-verbatim branch September 14, 2026 21:11
@blooop blooop mentioned this pull request Sep 14, 2026
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