fix(goals): decode quality-gate output as UTF-8 instead of the process 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.
This commit is contained in:
Drexuxux 2026-08-06 13:03:31 +03:00 committed by Teknium
parent 26eeb8568e
commit 5b5b5e8da0
2 changed files with 42 additions and 2 deletions

View File

@ -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,
)

View File

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