Skip to content

fix(goals): decode quality-gate output as UTF-8 instead of the process codepage - #80236

Closed
Drexuxux wants to merge 1 commit into
NousResearch:mainfrom
Drexuxux:fix/goal-gate-output-decoding
Closed

fix(goals): decode quality-gate output as UTF-8 instead of the process codepage#80236
Drexuxux wants to merge 1 commit into
NousResearch:mainfrom
Drexuxux:fix/goal-gate-output-decoding

Conversation

@Drexuxux

@Drexuxux Drexuxux commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What

A /goal quality gate runs whatever command the operator configured, so its output is arbitrary bytes. run_gate captured it in text mode with no encoding:

proc = subprocess.run(
    gate.command, shell=True, capture_output=True, text=True,
    timeout=max(1, int(gate.timeout_seconds)), cwd=cwd or None,
)

That decodes with locale.getpreferredencoding() under the default errors="strict". One byte the decoder rejects — a test runner's checkmarks or CJK on a non-UTF-8 Windows console, a stray binary byte anywhere in the stream — kills subprocess's reader thread. proc.stdout comes back as None, the or "" fallback right below turns that into an empty tail, and an unhandled traceback lands on stderr:

Exception in thread Thread-3 (_readerthread):
  ...
UnicodeDecodeError: 'charmap' codec can't decode byte 0x90 in position 19: character maps to <undefined>
run_gate -> passed=True exit=0 tail=''

The verdict itself survives — it rides on the exit code. What is lost is the tail, and the tail is the whole point of the retry loop: evaluate_gates stores it as last_output_tail and feeds it back so the agent can fix what broke. Empty, the agent is told a gate failed and handed nothing to act on, so it burns max_retries and the goal auto-pauses with quality gate exhausted N retries.

workspace_fingerprint has the same two calls. There a non-ASCII path in git status --porcelain empties the fingerprint, which silently disables the unchanged-gate skip — the rule that exists so a stalled agent can't spin re-running an identical red suite.

The fix

Decode as UTF-8 with errors="replace" at all three call sites. That is what git and modern toolchains actually emit, and it is already the house style: 262 of the repo's 299 text-mode subprocess calls pass an explicit encoding. errors="replace" also means genuinely undecodable bytes degrade to replacement characters instead of destroying the surrounding diagnostics.

Same class as the open working_diff and cron-script decode fixes; this is the /goal gate path, which none of them touch.

Tests and results

test_run_gate_keeps_diagnostics_when_a_byte_will_not_decode runs a gate that prints a realistic failure line with one undecodable byte embedded and exits 1, then asserts the diagnostic text reaches the tail. It fails on mainAssertionError: gate diagnostics were lost to a decode failure (tail='') — and passes with the fix. The byte chosen is invalid under strict UTF-8 as well as the Windows ANSI codepages, so the test pins the behaviour on Linux CI, not only on the platform where I found it.

The three goals suites (test_goal_gates.py, test_goals.py, test_kanban_goal_mode.py) run to 57 passed with the fix versus 56 on main — the one added test — with the same single pre-existing failure both ways: test_run_gate_fail_captures_output, which shells out with POSIX syntax (echo broken >&2; exit 3) and does not run on a Windows shell. It is untouched by this change.

…s codepage

A gate runs whatever command the operator configured, so its output is
arbitrary bytes. run_gate captured it with text=True and no encoding, which
decodes with locale.getpreferredencoding() under errors="strict".

One byte the decoder rejects — a test runner's checkmarks or CJK on a
non-UTF-8 Windows console, a stray binary byte anywhere in the stream — kills
subprocess's reader thread. proc.stdout comes back None, the `or ""` fallback
turns that into an empty tail, and an unhandled traceback is dumped to stderr.
The gate's pass/fail verdict still lands on the exit code, but the output tail
is exactly what the retry prompt feeds back so the agent can fix the failure.
With it empty the agent is told a gate failed and given nothing to act on, so
it burns every retry and the goal auto-pauses.

workspace_fingerprint has the same two calls; there a non-ASCII path in
`git status --porcelain` empties the fingerprint, silently disabling the
unchanged-gate skip that exists to stop a stalled agent re-running the same
red suite.

Decode as UTF-8 with errors="replace" — what git and modern toolchains emit,
and what 262 of the repo's 299 text-mode subprocess calls already do.
@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard platform/windows Native Windows-specific behavior or breakage P2 Medium — degraded but workaround exists sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 6, 2026
@teknium1

teknium1 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Merged in #81963 — clean cherry-pick with authorship (goals run_gate + workspace_fingerprint decodes). Thanks!

@teknium1 teknium1 closed this Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists platform/windows Native Windows-specific behavior or breakage sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants