mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
A status run (a metric card, the batched assessment, a derived score, a workout line, a Coach memory pass) is one model call plus the one repeat the Local and gateway clients send when a thinking model answered with reasoning alone, each with the person's response timeout. The run's outer bound was a single call, so the repeat was cut off, and the open card stopped polling after 48 s, so a note from a model that needs minutes never reached the page. The bound is now the per-call timeout times STATUS_MODEL_CALLS, and the card's poll ceiling follows the published response timeout (fast for the first 48 s, every 15 s after), only while the route reports the note in flight; the fallback text stays meanwhile. The status queue is exclusive, so the longer poll cannot queue a second generation beside a running one. The morning refresh is one comprehensive generation of up to five calls in a row, with no outer bound and pg-boss's fifteen-minute expiry, so a large setting had pg-boss fail and retry it while it was still running. It now runs behind the same bound as the warms, and the job carries an expiry derived from that worst case. The per-metric note, the workout line and the Coach memory refresh get expiries derived the same way. queue-runtime.ts gains a bounded class for these jobs: model time read off model-time.ts, an expiry above it, carried on every send. A new guard names every caller of runStatusCompletion and generateComprehensiveInsight with the queues that reach it, and holds each such queue to bounded above its runs or long under a run lock. Refs #1274
152 lines
5.8 KiB
TypeScript
152 lines
5.8 KiB
TypeScript
/**
|
|
* Structural guard (#1267): a browser request that waits on a model call
|
|
* takes its abort from the person's response-timeout setting, never from a
|
|
* constant.
|
|
*
|
|
* The force regenerate aborted at a fixed 45 s while the server still had
|
|
* minutes left on the person's 600 s setting, and a save of the about-me
|
|
* self-context (which derives the clarifying questions inline) ran under the
|
|
* 15 s request default. Both now read `clientAbortMs` through the account
|
|
* payload's `ai.provider.responseTimeoutMs`; the inventory of every such
|
|
* request is in the release notes for #1267.
|
|
*
|
|
* Its limit: it reads source text. A new synchronous AI route called from the
|
|
* browser with a fixed `AbortSignal.timeout(...)` is not found by name here;
|
|
* `use-insights-advisor-force.test.ts` proves the behaviour for the
|
|
* regenerate with fake timers.
|
|
*
|
|
* #1274 — the same holds for a browser that waits on a model by polling: the
|
|
* briefing read polls while the warm it enqueued runs, and a fixed ten polls
|
|
* (225 s) gave up on a warm the setting allowed far longer, so a briefing that
|
|
* landed later never reached the open page. The read's poll ceiling comes
|
|
* from `advisorRevalidatePollMaxAttempts(responseTimeoutMs)`;
|
|
* `insight-pregenerate.test.ts` proves it outlasts the warm's own bound for
|
|
* every setting.
|
|
*
|
|
* The status cards poll the same way while their note is generated, and
|
|
* stopped after 48 s whatever the setting, so a slow model's note never
|
|
* reached the open card. Every status poll passes
|
|
* `statusPollMaxAttempts(responseTimeoutMs)`, to the schedule and to the
|
|
* exhaustion that ends it; `status-provider-slow-model.test.ts` proves the
|
|
* window outlasts the run's own bound for every setting.
|
|
*
|
|
* Mutation check: put `FORCE_ADVISOR_TIMEOUT_MS = 45_000` back into the
|
|
* advisor, drop the `signal` from one about-me save, or drop the ceiling
|
|
* argument from the advisor read's poll or from one status poll, and this
|
|
* goes red.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
readSourceText,
|
|
stripComments,
|
|
walkSourceFiles,
|
|
} from "./helpers/source-files";
|
|
|
|
const SRC = join(process.cwd(), "src");
|
|
|
|
/** The text of the call that starts at `open` (the index of its `(`). */
|
|
function callText(source: string, open: number): string {
|
|
let depth = 0;
|
|
for (let i = open; i < source.length; i += 1) {
|
|
if (source[i] === "(") depth += 1;
|
|
else if (source[i] === ")") {
|
|
depth -= 1;
|
|
if (depth === 0) return source.slice(open, i + 1);
|
|
}
|
|
}
|
|
return source.slice(open);
|
|
}
|
|
|
|
describe("browser aborts for model-backed requests", () => {
|
|
it("the force regenerate derives its abort from the setting", () => {
|
|
const source = stripComments(
|
|
readFileSync(
|
|
join(SRC, "components/insights/use-insights-advisor.ts"),
|
|
"utf8",
|
|
),
|
|
);
|
|
expect(source).not.toMatch(/FORCE_ADVISOR_TIMEOUT_MS/);
|
|
expect(source).toMatch(
|
|
/options\.force\s*\?\s*forceAdvisorTimeoutMs\(options\.responseTimeoutMs\)/,
|
|
);
|
|
expect(source).toMatch(
|
|
/fetchAdvisor\(\{\s*force:\s*true,\s*responseTimeoutMs\s*\}\)/,
|
|
);
|
|
});
|
|
|
|
it("the briefing read polls for the warm as long as the setting allows", () => {
|
|
const source = stripComments(
|
|
readFileSync(
|
|
join(SRC, "components/insights/use-insights-advisor.ts"),
|
|
"utf8",
|
|
),
|
|
);
|
|
const polls = [...source.matchAll(/\bnextAdvisorPollInterval\s*\(/g)]
|
|
.map((m) => callText(source, (m.index ?? 0) + m[0].length - 1))
|
|
// The definition, not a call.
|
|
.filter((text) => !/:\s*boolean\s*\|\s*undefined/.test(text));
|
|
expect(polls).toHaveLength(1);
|
|
expect(polls[0]).toMatch(
|
|
/advisorRevalidatePollMaxAttempts\(\s*responseTimeoutMs\s*\)/,
|
|
);
|
|
});
|
|
|
|
it("every status card polls for its note as long as the setting allows", () => {
|
|
const calls: Array<{ rel: string; text: string }> = [];
|
|
for (const rel of walkSourceFiles(SRC, { floor: 3000 })) {
|
|
if (rel.includes("__tests__/") || rel.startsWith("generated/")) continue;
|
|
const source = stripComments(readSourceText(join(SRC, rel)));
|
|
for (const m of source.matchAll(
|
|
/\b(?:nextStatusPollInterval|applyStatusPollExhaustion)\s*\(/g,
|
|
)) {
|
|
const text = callText(source, (m.index ?? 0) + m[0].length - 1);
|
|
// The definitions, not calls.
|
|
if (/:\s*(?:boolean|InsightStatusData)\s*\|\s*undefined/.test(text)) {
|
|
continue;
|
|
}
|
|
calls.push({ rel, text });
|
|
}
|
|
}
|
|
// Three hooks poll and end the poll, the compliance card polls.
|
|
expect(calls.length).toBeGreaterThanOrEqual(7);
|
|
expect(
|
|
calls
|
|
.filter(
|
|
({ text }) =>
|
|
!/statusPollMaxAttempts\(\s*responseTimeoutMs\s*\)/.test(text),
|
|
)
|
|
.map(({ rel }) => rel),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("every about-me save carries the setting-derived signal", () => {
|
|
const sites: string[] = [];
|
|
const missing: string[] = [];
|
|
for (const rel of walkSourceFiles(SRC, { floor: 3000 })) {
|
|
if (rel.includes("__tests__/") || rel.startsWith("generated/")) continue;
|
|
if (!rel.startsWith("components/") && !rel.startsWith("app/")) continue;
|
|
const source = stripComments(readFileSync(join(SRC, rel), "utf8"));
|
|
const pattern =
|
|
/apiPut(?:<[^>]*>)?\s*\(\s*["']\/api\/coach\/about-me["']/g;
|
|
for (const match of source.matchAll(pattern)) {
|
|
const open = (match.index ?? 0) + match[0].indexOf("(");
|
|
sites.push(rel);
|
|
if (
|
|
!/signal:\s*AbortSignal\.timeout\(saveTimeoutMs\)/.test(
|
|
callText(source, open),
|
|
)
|
|
) {
|
|
missing.push(rel);
|
|
}
|
|
}
|
|
}
|
|
// Six saves today; a matcher that finds none must not read as clean.
|
|
expect(sites.length).toBeGreaterThanOrEqual(6);
|
|
expect(missing).toEqual([]);
|
|
});
|
|
});
|