fix(insights): give the briefing warm every call of its generation

The out-of-band warm the Insights page relies on bounded one
comprehensive generation at the account's response timeout plus 30 s.
A generation is up to five model calls in a row: the generation, the
JSON retry, the grounding retry, the outbound-screen repair, and the one
repeat a thinking model gets when it answered with reasoning alone. Each
call honoured the setting, but a local model needing 300 s per answer
under a 600 s setting met the 630 s bound in the middle of its first
corrective retry, and the briefing was dropped.

The bound is now the per-call timeout times COMPREHENSIVE_MODEL_CALLS
plus the headroom, for the nightly pass and the forced warm alike. A
test drives the real warm, generator, chain runner, Local client,
safeFetch and pinned dispatcher against a loopback server that answers
after 300 s; under the old bound it reports a timeout on the third
answer.

The response-timeout guard now names every outer bound around model
work and holds both briefing generators' stages to the call counts the
outer waits multiply by.

Refs #1274
This commit is contained in:
Marc-André Bombeck committed 2026-10-24 09:23:20 +02:00
1 parent 810a949d2b
commit 73e98c6ef5
8 files changed
+562 -96

No files matched your search

+101 -4
View File
@@ -19,7 +19,14 @@
* 3. a client is constructed outside `provider.ts`, where no resolver binds
* it (the offline evaluation harness is the one named exception);
* 4. a surface opts out with `timeoutPolicy: "surface-ceiling"` without
* being named below with its reason.
* being named below with its reason;
* 5. an outer bound waits on several model calls in a row but is sized as
* one (#1274: the briefing warm gave a five-call generation one call
* plus 30 s, so a local model answering in 300 s under a 600 s setting
* lost its briefing on the first corrective retry). Every `withTimeout`
* around model work is named below with what it must allow, and the
* briefing generators' stages are held to the call counts the outer
* waits multiply by.
*
* Its limit: it reads source text. A provider object built by hand in
* production code that dials the network itself would slip all four matchers,
@@ -29,9 +36,10 @@
*
* Mutation check: put `params.timeoutMs ?? 60_000` back into any client, drop
* `bindResponseTimeout` from `resolveProviderChain`, construct a
* `LocalOpenAICompatibleClient` in a route, or add
* `timeoutPolicy: "surface-ceiling"` to the document summary: each one goes
* red here.
* `LocalOpenAICompatibleClient` in a route, add
* `timeoutPolicy: "surface-ceiling"` to the document summary, wrap a model
* call in a new `withTimeout`, or add a sixth `runBriefingCompletion` stage to
* the comprehensive generator: each one goes red here.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
@@ -187,3 +195,92 @@ describe("surface ceilings are frozen", () => {
expect(sites.sort()).toEqual(Object.keys(SURFACE_CEILING_SITES).sort());
});
});
/**
* Every `withTimeout` around model work. It cannot stop the calls it waits
* for, so it is a backstop against a wedged run and has to sit above the
* longest run those calls allow: the per-call timeout times the calls made
* one after another. `bound` is what each call site's budget must read.
*/
const OUTER_BOUND_SITES: Record<
string,
{ sites: number; bound: RegExp; sizedBy?: RegExp; why: string }
> = {
"lib/jobs/insight-pregenerate.ts": {
sites: 2,
bound: /\bwarmBudgetMs\b/,
sizedBy: /\bwarmBudgetMs\s*=\s*comprehensiveWarmBudgetMs\s*\(/g,
why: "The comprehensive warm, nightly and forced: the per-call timeout times COMPREHENSIVE_MODEL_CALLS, plus headroom.",
},
"lib/insights/status-provider.ts": {
sites: 1,
bound: /\beffectiveTimeoutMs\b/,
why: "One short status note per chain run, bounded at the per-call timeout on purpose: the card has deterministic fallback copy and the next read retries.",
},
};
describe("outer bounds around model work allow every call they wait on", () => {
const files = walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.filter((p) => p !== "lib/insights/with-timeout.ts");
const callsIn = (rel: string): string[] => {
const source = code(rel);
return [...source.matchAll(/\bwithTimeout\s*\(/g)].map((m) =>
source.slice(m.index, m.index + 900),
);
};
const sites = files.filter((rel) => callsIn(rel).length > 0);
it("names every file that bounds model work from outside", () => {
expect(sites.length).toBeGreaterThan(0);
expect(sites.sort()).toEqual(Object.keys(OUTER_BOUND_SITES).sort());
});
it.each(Object.entries(OUTER_BOUND_SITES))(
"%s sizes each bound from the setting",
(rel, { sites: count, bound, sizedBy }) => {
const calls = callsIn(rel);
expect(calls).toHaveLength(count);
for (const call of calls) expect(call).toMatch(bound);
if (sizedBy) {
expect(code(rel).match(sizedBy) ?? []).toHaveLength(count);
}
},
);
/** The `stage` of every briefing completion a file starts. */
function stagesIn(rel: string): string[] {
return [...code(rel).matchAll(/\bstage:\s*"([a-z-]+)",/g)].map((m) => m[1]);
}
it("the comprehensive generator makes no more calls in a row than the warm waits for", () => {
// `reroll` replaces `generate` on an unchanged snapshot; the other four
// run one after another, and the Local and gateway clients may ask the
// first again once. A new stage means a longer generation: recount
// COMPREHENSIVE_MODEL_CALLS before adding it here.
expect(stagesIn("lib/insights/comprehensive-generate.ts").sort()).toEqual([
"generate",
"grounding-retry",
"json-retry",
"reroll",
"screen-retry",
]);
expect(code("lib/ai/ai-budgets.ts")).toMatch(
/\bCOMPREHENSIVE_MODEL_CALLS\s*=\s*5\b/,
);
});
it("the on-demand regenerate makes no more calls in a row than the browser waits for", () => {
// The generation and its grounding retry, and the one repeat inside the
// client: FORCE_ADVISOR_MODEL_CALLS in the advisor hook.
expect([
...stagesIn("lib/insights/briefing-request/completion.ts"),
...stagesIn("lib/insights/briefing-request/grounding.ts"),
]).toEqual(["generate", "grounding-retry"]);
expect(code("components/insights/use-insights-advisor.ts")).toMatch(
/\bFORCE_ADVISOR_MODEL_CALLS\s*=\s*3\b/,
);
});
});
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 89,
"total": 99593,
"total": 99565,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -68,7 +68,7 @@
"lib/insights/status-cache.ts": 812,
"lib/integrations/status.ts": 1445,
"lib/jobs/coach-nudge.ts": 1084,
"lib/jobs/insight-pregenerate.ts": 1259,
"lib/jobs/insight-pregenerate.ts": 1231,
"lib/jobs/medication-intake-import.ts": 819,
"lib/jobs/offhost-backup.ts": 1147,
"lib/jobs/reaction-line.ts": 824,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2897,
"total": 9285,
"total": 9283,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -2324,7 +2324,7 @@
"lib/jobs/google-health-oauth-state-cleanup.ts": 1,
"lib/jobs/google-health-sleep-repair.ts": 1,
"lib/jobs/insight-pregenerate-shared.ts": 1,
"lib/jobs/insight-pregenerate.ts": 39,
"lib/jobs/insight-pregenerate.ts": 37,
"lib/jobs/insight-status-generate-shared.ts": 3,
"lib/jobs/insight-status-generate.ts": 3,
"lib/jobs/intake-auto-skip.ts": 8,