Skip to content

Capture agent stderr and surface it as a failure diagnostic#65

Open
Tim-Pohlmann wants to merge 8 commits into
infra/job-yml-composite-actionsfrom
feat/capture-agent-stderr
Open

Capture agent stderr and surface it as a failure diagnostic#65
Tim-Pohlmann wants to merge 8 commits into
infra/job-yml-composite-actionsfrom
feat/capture-agent-stderr

Conversation

@Tim-Pohlmann

Copy link
Copy Markdown
Owner

Summary

  • ProcessWrapper.RunAsync discarded stderr entirely (RedirectStandardError: false) and dropped stdout's last line on the failure path, so every agent-CLI failure (auth, bad model, bad flag) collapsed into the same generic "exited with code N" reason.
  • Now captures the last non-empty line of each stream (stderr preferred, falling back to stdout) as ProcessFailure.Diagnostic, folded into JobFailure.Error as "agent failed: exited with code N: <diagnostic>".
  • Needed so PR Add live multi-config integration test via the real job.yml composite actions #64's agent-plumbing CI assertions can actually distinguish an auth failure from other agent-exit failures, instead of only observing status: failure.

Test plan

  • dotnet test — all 178 tests pass, including new ProcessWrapperTests/JobRunnerTests coverage for stderr capture and diagnostic threading.
  • CI green

The agent-plumbing CI assertions can only check "status: failure" -
every agent CLI failure (auth, bad model, bad flag) collapses into
the same "exited with code N" reason, since stderr was discarded
entirely (RedirectStandardError: false) and even stdout's last line
was dropped on the failure path.

Capture the last non-empty line of each stream (stderr preferred,
falling back to stdout) as ProcessFailure.Diagnostic, and fold it
into JobFailure's error message, so callers can tell auth failures
apart from other agent-exit failures instead of just observing that
something went wrong.
@Tim-Pohlmann
Tim-Pohlmann force-pushed the feat/capture-agent-stderr branch from 26630c7 to fdec34b Compare July 9, 2026 17:42
@Tim-Pohlmann
Tim-Pohlmann changed the base branch from feat/multi-provider-model-passthrough to infra/job-yml-composite-actions July 9, 2026 17:43
@Tim-Pohlmann
Tim-Pohlmann requested a review from Copilot July 9, 2026 20:51

Copilot AI 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.

Pull request overview

This PR improves failure diagnostics from subprocess executions by capturing stderr (and falling back to stdout) and threading the last meaningful line into job failure errors, enabling CI/tests to distinguish common agent failure modes (auth/usage/bad flags) instead of only seeing a generic exit code.

Changes:

  • Capture and drain stderr in ProcessWrapper.RunAsync, producing ProcessFailure.Diagnostic from the last non-empty stderr line (or stdout fallback).
  • Thread process diagnostics into JobFailure.Error as an error suffix when present.
  • Add unit tests covering diagnostic capture behavior and error-message formatting.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
tests/Rix.Tests/ProcessWrapperTests.cs Adds tests verifying stderr-preferred diagnostics, stdout fallback, and null diagnostic when no output.
tests/Rix.Tests/JobRunnerTests.cs Adds tests verifying JobFailure.Error includes/omits diagnostic suffix appropriately.
src/Rix/Process/ProcessWrapper.cs Redirects and drains stderr concurrently; adds ProcessFailure.Diagnostic and sets it on non-zero exit.
src/Rix/Job/JobRunner.cs Appends diagnostic detail to agent failure error text when available.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Rix/Process/ProcessWrapper.cs
Comment thread tests/Rix.Tests/ProcessWrapperTests.cs
- ProcessWrapper.RunAsync now awaits stderrTask (best-effort) before
  stdoutTask's exception propagates, so a callback exception doesn't
  leave stderr's read loop running unobserved against a disposed
  process's stream.
- EchoToStderr now shell/PowerShell-quotes its arg instead of raw
  interpolation, so a future test passing metacharacters won't break
  or expand unexpectedly.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread src/Rix/Process/ProcessWrapper.cs
Comment thread src/Rix/Process/ProcessWrapper.cs Outdated
stdoutTask and stderrTask are now drained symmetrically on fault (either
one faulting first now kills the process and best-effort-drains both),
and stderr lines are forwarded through the existing onStdoutLine callback
with a "[stderr] " prefix so redirecting/capturing stderr doesn't silently
drop it from logs.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/Rix/Process/ProcessWrapper.cs Outdated
onStdoutLine is null at several call sites (git, npm install) that used
to rely on stderr being inherited straight from the console before this
method redirected/captured it. Those calls now got zero stderr visibility
instead of the "prefixed but still visible" behavior other callers kept.
Fall back to Console.Error.WriteLine so it's restored for them too.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/Rix/Process/ProcessWrapper.cs Outdated
The Console.Error fallback (used when onStdoutLine is null) was still
prefixing with "[stderr] ", contradicting its own comment and needlessly
altering the emitted line - that fallback already writes to a stream
that never carries stdout, so there's nothing to disambiguate.
Sonar flagged RunAsync's cognitive complexity (16 > 15) and a suspicious
always-true condition in the switch pattern. Extracting the forwarder
selection into its own method and using a plain null-check resolves both.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread src/Rix/Process/ProcessWrapper.cs Outdated
Comment thread src/Rix/Process/ProcessWrapper.cs Outdated
- ProcessFailure.Diagnostic: clarify it's null unless the process actually
  ran and exited non-zero - the Win32Exception and timeout paths construct
  it without one, which the prior wording didn't account for.
- BuildStderrForwarder: stdout/stderr are separate streams: what's actually
  shared (and needs the prefix to stay distinguishable) is the callback.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/Rix/Process/ProcessWrapper.cs Outdated
Draining stdout and stderr concurrently (added to fix the pipe-deadlock
issue) let a caller-supplied onStdoutLine be invoked from both reader
tasks at once - a real hazard for callers that assume serialized calls
(test spies appending to a List<string>, non-thread-safe loggers).

Wrap the callback once in a lock-based gate and share that synchronized
delegate across both streams so invocations are always serialized,
regardless of which stream a line arrived from.

Synchronize is internal rather than private so the guarantee can be
pinned down with a deterministic test: real OS pipe delivery can't be
forced to overlap on demand (child stdout is block-buffered and arrives
in one end-of-process burst on this platform, while stderr trickles line
by line, so an end-to-end timing test can run for a long time without
ever actually racing). Driving Synchronize directly with two threads
under our own control proves the mutual exclusion instead.
@sonarqubecloud

Copy link
Copy Markdown

@Tim-Pohlmann
Tim-Pohlmann requested a review from Copilot July 10, 2026 19:00

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

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.

2 participants