From 5b5b5e8da0d5a457b5217d54444d04442834d22e Mon Sep 17 00:00:00 2001 From: Drexuxux Date: Thu, 6 Aug 2026 13:03:31 +0300 Subject: [PATCH] fix(goals): decode quality-gate output as UTF-8 instead of the process codepage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/goals.py | 14 ++++++++++++-- tests/hermes_cli/test_goal_gates.py | 30 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index 4762710cd314b..b906979e8d319 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -479,13 +479,15 @@ def workspace_fingerprint(cwd: Optional[str] = None) -> str: try: head = subprocess.run( ["git", "rev-parse", "HEAD"], - capture_output=True, text=True, timeout=10, cwd=workdir, + capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=10, cwd=workdir, ) if head.returncode != 0: return "" status = subprocess.run( ["git", "status", "--porcelain"], - capture_output=True, text=True, timeout=30, cwd=workdir, + capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=30, cwd=workdir, ) if status.returncode != 0: return "" @@ -509,6 +511,14 @@ def run_gate(gate: GoalGate, *, cwd: Optional[str] = None) -> Tuple[bool, int, s shell=True, capture_output=True, text=True, + # A gate runs whatever the operator configured, so its output is + # arbitrary bytes. The default text mode decodes with the process + # codepage under errors="strict": one byte the codepage can't map + # (emoji or CJK from a test runner on a non-UTF-8 Windows console, + # or stray binary) kills the reader thread, leaves stdout as None, + # and the tail the agent needs to fix the failure arrives empty. + encoding="utf-8", + errors="replace", timeout=max(1, int(gate.timeout_seconds)), cwd=cwd or None, ) diff --git a/tests/hermes_cli/test_goal_gates.py b/tests/hermes_cli/test_goal_gates.py index d90a63e0fb13d..4dfc79478784e 100644 --- a/tests/hermes_cli/test_goal_gates.py +++ b/tests/hermes_cli/test_goal_gates.py @@ -1,6 +1,7 @@ """Tests for /goal quality gates (GoalGate, run_gate, GoalManager gate flow).""" import json +import sys import time from unittest.mock import patch @@ -77,6 +78,35 @@ def test_run_gate_timeout(): assert "timed out" in out +def test_run_gate_keeps_diagnostics_when_a_byte_will_not_decode(tmp_path): + """A gate's output tail must survive bytes the decoder rejects. + + A gate runs whatever the operator configured, so its output is arbitrary + bytes — a test runner's checkmarks or CJK on a non-UTF-8 Windows console, + or stray binary. Decoding strictly means one bad byte kills subprocess's + reader thread, stdout comes back None, and the tail lands empty: the agent + is told the gate failed with nothing to act on, so it burns every retry and + the goal auto-pauses. + """ + script = tmp_path / "gate.py" + script.write_text( + "import os, sys\n" + "os.write(1, b'FAILED: 3 tests broken \\x90\\x8d rerun me\\n')\n" + "sys.exit(1)\n", + encoding="utf-8", + ) + + passed, code, out = run_gate( + GoalGate(command=f'"{sys.executable}" "{script}"'), + ) + + assert passed is False + assert code == 1 + assert "FAILED: 3 tests broken" in out, ( + f"gate diagnostics were lost to a decode failure (tail={out!r})" + ) + + # ────────────────────────────────────────────────────────────────────── # GoalManager gate management # ──────────────────────────────────────────────────────────────────────