Files
HealthLog/tests/integration/tiered-context-local-day.test.ts
T
Marc-André Bombeck 27342ee734 fix(ai): name the real cause of failed briefings and stop retrying what cannot heal
The nightly briefing warm completed every night and read "all-failed",
because one run event held every account's annotations and the last
account won. Each candidate now runs in its own wide event and the run
reports every outcome through `reportJobRun`, so ok, dead sign-in,
withheld and failed accounts show side by side.

A dead Codex refresh token threw a bare error with no status, so the chain
read it as a network failure: the health ledger recorded hard_failed, no
"reconnect" hint appeared, and every night tried the same token. The
refresh now throws `CodexRefreshError` with a 401 for a dead grant
(invalid_grant / invalid_refresh_token on a 400 included), as do a missing
or undecryptable stored credential. The ledger records auth_failed, the
chain marks the primary credential expired, and the nightly pass reports
the account as auth_failed without queuing a retry.

A briefing the outbound screen withheld (a causal claim) failed outright,
and the 45-minute provider retry then asked the same question eight times
a night. The generator now makes one bounded repair that names the broken
rule and keeps the repaired payload only if it passes the same parse,
grounding and screen; a withheld outcome queues no retry.

A provider that keeps failing is paused: from five consecutive hard
failures the skip window starts at 30 minutes and doubles per further
failure, capped at a day. A paused provider is walked last and tried again
when the window lifts; one success resumes it. The operator card shows how
many accounts it is paused for and until when. After a 5xx from a
non-canonical OpenAI-compatible endpoint the client asks the free model
listing, and a configured model the endpoint no longer offers is named in
the error, on the hop line and on the card.

Also:
- The tiered context's live day band read `measured_at AT TIME ZONE tz`,
  which treats the stored UTC as local time; a far-from-UTC reader's
  evening readings landed on the next day. It converts from UTC first now.
- Google Health intraday heart rate re-reads two hours per sync instead of
  a day; daily summaries and sleep keep the day (#1023).
- The first filtered daily read that answers 400 and triggers the
  snake_case retry is recorded as a note, not an error (#1023).
- The single derived-score read goes through the derived cache with
  stale-while-revalidate, like the batch beside it.
2026-10-08 09:06:09 +02:00

70 lines
2.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.42 — the tiered context's live day band buckets on the reader's own
* calendar day, including on a daylight-saving changeover.
*
* For a reader far from UTC the 14–30-day band is read live, bucketed by
* `date_trunc('day', <local wall clock>)`. `measured_at` is a `timestamp`
* without time zone holding UTC, and the band read it with a single
* `AT TIME ZONE tz`, which interprets the value as local time and converts
* the wrong way: in Los Angeles every reading moved sixteen hours, and an
* evening reading was summarised as the next day. The Coach and the briefing
* then quoted a day's mean built from two different days.
*
* 2026-03-08 is the US changeover (02:00 PST becomes 03:00 PDT). Two weight
* readings on that local day, 08:00 PDT (15:00Z) and 23:30 PDT (06:30Z on
* the 9th), must form one bucket for the 8th.
*/
import { beforeAll, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
import { buildTieredSeries } from "@/lib/rollups/tiered-context";
const prisma = getPrismaClient();
const USER = "tiered-context-local-day";
const TZ = "America/Los_Angeles";
/** "Now" for the band arithmetic: the 8th sits inside the 14–30-day band. */
const NOW = new Date("2026-03-25T12:00:00Z").getTime();
beforeAll(async () => {
await truncateAllTables(prisma);
await prisma.user.create({
data: { id: USER, username: USER, timezone: TZ },
});
await prisma.measurement.createMany({
data: [
{ at: "2026-03-08T15:00:00Z", value: 70 },
{ at: "2026-03-09T06:30:00Z", value: 80 },
// A reading on the local 7th, late evening PST, for contrast.
{ at: "2026-03-08T07:30:00Z", value: 90 },
].map(({ at, value }) => ({
userId: USER,
type: "WEIGHT" as const,
value,
unit: "kg",
measuredAt: new Date(at),
source: "MANUAL" as const,
})),
});
});
describe("tiered context — live day band on the reader's calendar", () => {
it("puts an evening reading on its own local day across the DST changeover", async () => {
const series = await buildTieredSeries(USER, "WEIGHT", {
tz: TZ,
now: NOW,
skipEnsureFresh: true,
});
const byDay = Object.fromEntries(
series.dayBand.map((b) => [b.bucketStart.slice(0, 10), b]),
);
expect(Object.keys(byDay).sort()).toEqual(["2026-03-07", "2026-03-08"]);
expect(byDay["2026-03-08"]).toMatchObject({ count: 2, mean: 75 });
expect(byDay["2026-03-07"]).toMatchObject({ count: 1, mean: 90 });
});
});