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 <noreply@anthropic.com>
120 lines
4.4 KiB
JavaScript
120 lines
4.4 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { initKnowledge, isAvailable, listImprovements } from "../knowledge/index.js";
|
|
import { closeStore, __resetForTests } from "../knowledge/store.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();
|
|
await initKnowledge({ stateDir: tmp });
|
|
return tmp;
|
|
}
|
|
|
|
function cleanup(tmp) {
|
|
closeStore();
|
|
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
}
|
|
|
|
test("runOnce: empty stats → ok with 0 flagged", async () => {
|
|
const tmp = await bootstrap();
|
|
if (!isAvailable()) { cleanup(tmp); return; }
|
|
const r = runOnce({ stats: [] });
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.flagged, 0);
|
|
cleanup(tmp);
|
|
});
|
|
|
|
test("runOnce: ignores small samples (below MIN_SAMPLE)", async () => {
|
|
const tmp = await bootstrap();
|
|
if (!isAvailable()) { cleanup(tmp); return; }
|
|
const stats = [
|
|
{ trigger_reason: "wedged_60s", total: 2, applied: 2, succeeded: 0, failed: 2, avg_in: 700, avg_out: 40, avg_latency_ms: 5000 },
|
|
];
|
|
const r = runOnce({ stats });
|
|
assert.equal(r.flagged, 0, "applied=2 is below MIN_SAMPLE; skipped");
|
|
cleanup(tmp);
|
|
});
|
|
|
|
test("runOnce: flags low success-rate trigger as improvement", async () => {
|
|
const tmp = await bootstrap();
|
|
if (!isAvailable()) { cleanup(tmp); return; }
|
|
const stats = [
|
|
{ trigger_reason: "wedged_60s", total: 10, applied: 10, succeeded: 1, failed: 9, avg_in: 700, avg_out: 40, avg_latency_ms: 5000 },
|
|
];
|
|
const r = runOnce({ stats });
|
|
assert.equal(r.flagged, 1);
|
|
const requests = listImprovements({ source: "tuner" });
|
|
assert.ok(requests.some((req) => req.title.includes("wedged_60s") && req.title.includes("low success")));
|
|
cleanup(tmp);
|
|
});
|
|
|
|
test("runOnce: flags expensive prompt with mediocre payoff", async () => {
|
|
const tmp = await bootstrap();
|
|
if (!isAvailable()) { cleanup(tmp); return; }
|
|
const stats = [
|
|
{ trigger_reason: "repeat_4_explore.far", total: 10, applied: 10, succeeded: 4, failed: 6, avg_in: 1500, avg_out: 50, avg_latency_ms: 7000 },
|
|
];
|
|
const r = runOnce({ stats });
|
|
assert.equal(r.flagged, 1);
|
|
const requests = listImprovements({ source: "tuner", category: "tuning" });
|
|
assert.ok(requests.some((req) => req.title.includes("expensive")));
|
|
cleanup(tmp);
|
|
});
|
|
|
|
test("runOnce: healthy trigger does NOT get flagged", async () => {
|
|
const tmp = await bootstrap();
|
|
if (!isAvailable()) { cleanup(tmp); return; }
|
|
const stats = [
|
|
{ trigger_reason: "emergency_hp4_creeper@3", total: 8, applied: 8, succeeded: 7, failed: 1, avg_in: 700, avg_out: 40, avg_latency_ms: 5000 },
|
|
];
|
|
const r = runOnce({ stats });
|
|
assert.equal(r.flagged, 0);
|
|
cleanup(tmp);
|
|
});
|
|
|
|
test("runOnce: re-running with same low-success stats bumps votes, not row count", async () => {
|
|
const tmp = await bootstrap();
|
|
if (!isAvailable()) { cleanup(tmp); return; }
|
|
const stats = [
|
|
{ trigger_reason: "wedged_unique_label", total: 10, applied: 10, succeeded: 1, failed: 9, avg_in: 700, avg_out: 40, avg_latency_ms: 5000 },
|
|
];
|
|
runOnce({ stats });
|
|
runOnce({ stats });
|
|
const requests = listImprovements({ source: "tuner" }).filter((r) => r.title.includes("wedged_unique_label"));
|
|
assert.equal(requests.length, 1, "single row for the same title");
|
|
assert.ok(requests[0].votes >= 2, "votes bumped on re-flagging");
|
|
cleanup(tmp);
|
|
});
|
|
|
|
test("MIN_SAMPLE constant is reasonable", () => {
|
|
assert.ok(MIN_SAMPLE >= 3 && MIN_SAMPLE <= 10);
|
|
});
|