From b68d4b3ee747d79a83f04eff727fcc13bd9762f2 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Thu, 28 May 2026 07:22:44 +0300 Subject: [PATCH] =?UTF-8?q?fix(coach/trigger-tuner):=20crash=20after=20~1h?= =?UTF-8?q?=20=E2=80=94=20runOnce=20is=20sync,=20not=20a=20Promise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live bot died overnight with: TypeError: runOnce(...).catch is not a function at trigger-tuner.js:42 → [supervisor] child exited code=1 attach() wrapped the timer body as `runOnce().catch(...)` but runOnce() returns a plain {ok, flagged, ...} object (pure SQL, no await). The first tuner tick (60min after spawn) threw → killed the whole bot process. Never surfaced before because the bot rarely ran uninterrupted for a full hour during development. Fix: guard the synchronous call with try/catch, matching how persona/chatter.js already does its sync tick. (postmortem.drainOnce and reflect.runOnce ARE async, so their .catch is correct — audited.) Regression test added: captures the setInterval callback and invokes it synchronously, asserting it does not throw. Tests: 397 green. Co-Authored-By: Claude Opus 4.7 --- runtime/coach/trigger-tuner.js | 10 +++++++++- runtime/coach/trigger-tuner.test.js | 23 ++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) 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();