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 # ──────────────────────────────────────────────────────────────────────