diff --git a/backend/apps/agents/9router_gpt5_patch.js b/backend/apps/agents/9router_gpt5_patch.js index 2fa3bcc3..7e1ddfa0 100644 --- a/backend/apps/agents/9router_gpt5_patch.js +++ b/backend/apps/agents/9router_gpt5_patch.js @@ -100,19 +100,26 @@ const _http = require('http'); const TARGET_HOSTS = new Set(['api.openai.com', 'api.anthropic.com']); -// ENG-418: continuous history pruning rides the same interceptor. Loaded lazily and fail-open so -// a missing or broken module costs the prune, never the request. +// ENG-418: continuous history pruning rides the same interceptor. Fail-open: a missing or broken +// module costs the prune, never the request. Loaded EAGERLY so the verdict line lands in the router's +// start log at boot, where the backend reads it; a lazy load only spoke on the first request, which +// left a dead pruner indistinguishable from a quiet one until a chat had already paid for it. let _prune = null; -function historyPrune(bodyStr) { +function loadHistoryPrune() { try { - if (_prune === null) { - _prune = require(require('path').join(__dirname, '9router_history_prune.js')); - try { process.stderr.write('[history-prune] installed\n'); } catch (_) {} - } - return _prune.maybePrune(bodyStr); + _prune = require(require('path').join(__dirname, '9router_history_prune.js')); + try { process.stderr.write('[history-prune] installed\n'); } catch (_) {} } catch (_) { _prune = { maybePrune: (b) => b }; try { process.stderr.write('[history-prune] FAILED to load; requests pass through unpruned\n'); } catch (_) {} + } +} +loadHistoryPrune(); +function historyPrune(bodyStr) { + try { + return _prune.maybePrune(bodyStr); + } catch (_) { + try { process.stderr.write('[history-prune] request passed through unpruned (transform threw)\n'); } catch (_) {} return bodyStr; } } diff --git a/backend/apps/nine_router/history_prune_state.py b/backend/apps/nine_router/history_prune_state.py new file mode 100644 index 00000000..8071a8d9 --- /dev/null +++ b/backend/apps/nine_router/history_prune_state.py @@ -0,0 +1,57 @@ +"""The router's history pruner must never disable itself in silence. + +`9router_gpt5_patch.js` loads the pruner eagerly and writes one verdict line to stderr at boot. In a +packaged build that stderr lands only in the router start log, which nothing read, so a pruner that +failed to load looked identical to one that was working. This reads the verdict after a successful +start and says, loudly, what every chat on the machine just lost. +""" +import logging +import os +from typing import Literal +from typeguard import typechecked + +logger = logging.getLogger(__name__) +HistoryPruneState = Literal["installed", "failed", "unknown"] +P_INSTALLED_LINE = "[history-prune] installed" +P_FAILED_LINE = "[history-prune] FAILED to load" + + +@typechecked +def history_prune_state(log_path: str) -> HistoryPruneState: + try: + with open(log_path, "rb") as f: + text = f.read(200_000).decode("utf-8", errors="replace") + except OSError: + return "unknown" + if P_FAILED_LINE in text: + return "failed" + if P_INSTALLED_LINE in text: + return "installed" + return "unknown" + + +@typechecked +def report_history_prune_state(log_path: str, packaged: bool) -> HistoryPruneState: + """Packaged builds capture the router's stderr, so silence there means the patch never loaded (the + `--require` flag is dropped when the patch file is missing). Dev sends stderr to DEVNULL, so + silence there is just silence.""" + state = history_prune_state(log_path) + if state == "installed": + logger.info("9Router history pruner installed") + return state + if not packaged: + logger.debug("9Router history pruner state unknown (stderr not captured in dev)") + return state + logger.warning( + "9Router history pruner %s: every chat on this machine now resends its FULL tool history " + "on every step, so long chats will hit the context wall (the autocompact-thrash class). " + "Router start log: %s", + "FAILED to load" if state == "failed" else "never announced itself (patch not loaded?)", + log_path, + ) + try: + from backend.apps.service.client import submit_diagnostic + submit_diagnostic({"kind": "router", "subkind": f"history_prune_{state}", "log_path": log_path}) + except Exception: + logger.debug("submit_diagnostic history_prune_state failed", exc_info=True) + return state diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index ad006682..00a85920 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -26,6 +26,7 @@ import time from typing import Any, Dict, List, Optional import httpx +from backend.apps.nine_router.history_prune_state import report_history_prune_state logger = logging.getLogger(__name__) @@ -667,6 +668,7 @@ async def p_ensure_running_impl(): await asyncio.sleep(0.5) if is_running(): logger.info("9Router started successfully") + report_history_prune_state(p_cap_path, p_is_packaged) return # Verify-at-boot: it never answered. Report with the captured tail + the exit code (non-None = it crashed; None = wedged or just slow). p_report_start_failure( diff --git a/backend/tests/test_history_prune_state.py b/backend/tests/test_history_prune_state.py new file mode 100644 index 00000000..5a66bf20 --- /dev/null +++ b/backend/tests/test_history_prune_state.py @@ -0,0 +1,74 @@ +"""The router's pruner may never disable itself in silence (CLAUDE.md, row 6).""" +import logging +import re +from backend.apps.nine_router import history_prune_state as hps + + +def p_log(tmp_path, text: str) -> str: + p = tmp_path / "start.log" + p.write_bytes(text.encode()) + return str(p) + + +def p_capture(monkeypatch): + records: list = [] + handler = logging.Handler() + handler.emit = records.append + hps.logger.addHandler(handler) + hps.logger.setLevel(logging.DEBUG) + monkeypatch.setattr(hps, "logger", hps.logger) + return records, handler + + +def test_reads_all_three_verdicts(tmp_path) -> None: + assert hps.history_prune_state(p_log(tmp_path, "boot\n[history-prune] installed\n")) == "installed" + assert hps.history_prune_state(p_log(tmp_path, "[history-prune] FAILED to load; requests pass through unpruned\n")) == "failed" + assert hps.history_prune_state(p_log(tmp_path, "boot, nothing about pruning\n")) == "unknown" + assert hps.history_prune_state(str(tmp_path / "missing.log")) == "unknown" + + +def test_a_failed_pruner_in_a_packaged_build_warns_and_names_what_stopped(tmp_path, monkeypatch) -> None: + records, handler = p_capture(monkeypatch) + try: + state = hps.report_history_prune_state(p_log(tmp_path, "[history-prune] FAILED to load; requests pass through unpruned\n"), packaged=True) + finally: + hps.logger.removeHandler(handler) + assert state == "failed" + warns = [r for r in records if r.levelno >= logging.WARNING] + assert warns, "the fallback must be LOUD" + msg = warns[0].getMessage() + assert "FULL tool history" in msg and "context wall" in msg + + +def test_silence_in_a_packaged_build_is_also_a_warning(tmp_path, monkeypatch) -> None: + records, handler = p_capture(monkeypatch) + try: + state = hps.report_history_prune_state(p_log(tmp_path, "ready\n"), packaged=True) + finally: + hps.logger.removeHandler(handler) + assert state == "unknown" + assert [r for r in records if r.levelno >= logging.WARNING], "a patch that never announced itself did not load" + + +def test_installed_and_dev_silence_are_quiet(tmp_path, monkeypatch) -> None: + records, handler = p_capture(monkeypatch) + try: + hps.report_history_prune_state(p_log(tmp_path, "[history-prune] installed\n"), packaged=True) + hps.report_history_prune_state(p_log(tmp_path, "nothing\n"), packaged=False) + finally: + hps.logger.removeHandler(handler) + assert not [r for r in records if r.levelno >= logging.WARNING], "dev captures no stderr, so silence there is not a verdict" + + +def test_the_report_is_wired_onto_the_start_success_path() -> None: + src = open("backend/apps/nine_router/process.py").read() + ok = src.index('logger.info("9Router started successfully")') + call = src.index("report_history_prune_state(p_cap_path, p_is_packaged)") + ret = src.index("return", call) + assert ok < call < ret, "the verdict is read right after a successful start, before the function returns" + + +def test_the_patch_loads_the_pruner_eagerly_not_lazily() -> None: + src = open("backend/apps/agents/9router_gpt5_patch.js").read() + assert re.search(r"^loadHistoryPrune\(\);", src, re.M), "a lazy load only speaks on the first request; the boot log would stay empty" + assert src.index("loadHistoryPrune();") < src.index("function historyPrune(") diff --git a/electron/historyPrune.test.js b/electron/historyPrune.test.js index 7e3087fe..d2e20329 100644 --- a/electron/historyPrune.test.js +++ b/electron/historyPrune.test.js @@ -225,3 +225,37 @@ test('old SMALL results age too, newest stay sacred (the 32% floor from the real assert.match(res[i].content[0].text, /nothing to commit/, 'a recent small IS the answer and must survive'); } }); + +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const pathMod = require('node:path'); +const PATCH_SRC = pathMod.join(__dirname, '..', 'backend', 'apps', 'agents', '9router_gpt5_patch.js'); + +function bootPatchWith(pruneModuleSource) { + const dir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'osw-patch-')); + fs.copyFileSync(PATCH_SRC, pathMod.join(dir, '9router_gpt5_patch.js')); + if (pruneModuleSource !== null) fs.writeFileSync(pathMod.join(dir, '9router_history_prune.js'), pruneModuleSource); + const r = spawnSync(process.execPath, ['--require', pathMod.join(dir, '9router_gpt5_patch.js'), '-e', 'process.stdout.write("booted")'], { encoding: 'utf8' }); + fs.rmSync(dir, { recursive: true, force: true }); + return r; +} + +test('the pruner announces itself at LOAD, before any request (liveness, not just correctness)', () => { + const real = fs.readFileSync(pathMod.join(__dirname, '..', 'backend', 'apps', 'agents', '9router_history_prune.js'), 'utf8'); + const r = bootPatchWith(real); + assert.strictEqual(r.stdout, 'booted'); + assert.match(r.stderr, /\[history-prune\] installed/, 'the installed line must land at boot, with zero requests sent'); +}); + +test('a broken pruner module says so at boot and the router still boots (fail-open, loudly)', () => { + const r = bootPatchWith('throw new Error("simulated broken pruner");'); + assert.strictEqual(r.stdout, 'booted', 'a dead pruner must never take the router down with it'); + assert.match(r.stderr, /\[history-prune\] FAILED to load; requests pass through unpruned/); +}); + +test('a MISSING pruner module is the same story as a broken one', () => { + const r = bootPatchWith(null); + assert.strictEqual(r.stdout, 'booted'); + assert.match(r.stderr, /FAILED to load/); +});