diff --git a/runtime/coach/trigger-tuner.js b/runtime/coach/trigger-tuner.js index d6d69de..a99d370 100644 --- a/runtime/coach/trigger-tuner.js +++ b/runtime/coach/trigger-tuner.js @@ -39,7 +39,15 @@ export function attach({ intervalMs = TUNE_INTERVAL_MS } = {}) { return; } _timer = setInterval(() => { - runOnce().catch((e) => warn("tuner", `tick err: ${e?.message ?? e}`)); + // runOnce is synchronous (pure SQL, no await) — must NOT call + // .catch on its plain-object return. A try/catch here is the + // correct guard. (This exact bug crashed the live bot after + // ~1h uptime when the first tuner tick fired.) + try { + runOnce(); + } catch (e) { + warn("tuner", `tick err: ${e?.message ?? e}`); + } }, intervalMs); _timer.unref?.(); info("tuner", `attached; tune every ${Math.round(intervalMs / 60000)} min`); diff --git a/runtime/coach/trigger-tuner.test.js b/runtime/coach/trigger-tuner.test.js index 7021727..7cdd124 100644 --- a/runtime/coach/trigger-tuner.test.js +++ b/runtime/coach/trigger-tuner.test.js @@ -6,10 +6,31 @@ import { join } from "node:path"; import { initKnowledge, isAvailable, listImprovements } from "../knowledge/index.js"; import { closeStore, __resetForTests } from "../knowledge/store.js"; -import { runOnce, __testing } from "./trigger-tuner.js"; +import { runOnce, attach, detach, __testing } from "./trigger-tuner.js"; const { MIN_SAMPLE } = __testing; +test("attach: timer tick does not crash (runOnce is sync, regression for .catch bug)", async () => { + // Reproduces the crash that killed the live bot after ~1h: the + // setInterval body called runOnce().catch(...) but runOnce returns + // a plain object, not a Promise. attach must guard with try/catch. + detach(); + let threw = false; + const origSetInterval = globalThis.setInterval; + let captured = null; + // capture the interval callback without actually waiting + globalThis.setInterval = (fn) => { captured = fn; return { unref() {} }; }; + try { + attach({ intervalMs: 999999 }); + // invoke the captured tick synchronously — must not throw + try { captured?.(); } catch { threw = true; } + } finally { + globalThis.setInterval = origSetInterval; + detach(); + } + assert.equal(threw, false, "tuner timer tick must not throw"); +}); + async function bootstrap() { const tmp = mkdtempSync(join(tmpdir(), "pepa-tuner-test-")); __resetForTests();