From 4001274e3ba25a3946df1e9f2dc82ca6781cd2bf Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 12 Jun 2026 16:28:07 +0900 Subject: feat(cache-warming): consume CR-4 lifecycle — tab-close cancel + scope-aware subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - closeTab now POSTs /conversations/:id/close (abort in-flight turn + stop/disable warming server-side); disconnect still leaves both running (transport-contract@0.9.0) - syncSubscriptions honors catalog scope (ui-contract@0.2.0): global surfaces are not re-subscribed on conversation switch - fix(ws): the surface-message parser dropped the conversationId echo (CR-4d was ours, not the backend's) — preserved + unit-tested - secondsUntilNext: 3s stale guard — a past nextWarmAt renders as waiting, not 0s - re-pinned + re-mirrored ui-contract@0.2.0 / transport-contract@0.9.0 - scripts/probe-cache-warming.ts: live CR-4 probe (default-off, future nextWarmAt, repeated warms, mid-turn close abort, idempotent re-close) — 17/17 against bin/up --- src/features/cache-warming/logic/view-model.test.ts | 10 +++++++++- src/features/cache-warming/logic/view-model.ts | 14 +++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) (limited to 'src/features/cache-warming') diff --git a/src/features/cache-warming/logic/view-model.test.ts b/src/features/cache-warming/logic/view-model.test.ts index 3d6f6d0..d5ea901 100644 --- a/src/features/cache-warming/logic/view-model.test.ts +++ b/src/features/cache-warming/logic/view-model.test.ts @@ -215,6 +215,14 @@ describe("secondsUntilNext (authoritative, from nextWarmAt)", () => { expect(secondsUntilNext(10_000, 10_000)).toBe(0); expect(secondsUntilNext(250_000, 10_000)).toBe(240); expect(secondsUntilNext(70_000, 10_000)).toBe(60); - expect(secondsUntilNext(5_000, 999_999)).toBe(0); // already past + }); + + it("treats a nextWarmAt past the stale grace as not scheduled (belt-and-braces)", () => { + // Within the 3s grace an on-time warm may briefly read "0s"… + expect(secondsUntilNext(10_000, 11_000)).toBe(0); + expect(secondsUntilNext(10_000, 13_000)).toBe(0); + // …but beyond it the value is stale → null (the "waiting…" state). + expect(secondsUntilNext(10_000, 13_001)).toBeNull(); + expect(secondsUntilNext(5_000, 999_999)).toBeNull(); }); }); diff --git a/src/features/cache-warming/logic/view-model.ts b/src/features/cache-warming/logic/view-model.ts index f7740d7..eb105f6 100644 --- a/src/features/cache-warming/logic/view-model.ts +++ b/src/features/cache-warming/logic/view-model.ts @@ -231,12 +231,24 @@ export function observeWarm( return { history, lastWarmAt }; } +/** + * Grace before a PAST `nextWarmAt` is treated as "not scheduled" (→ the + * "waiting…" state instead of a perpetual "0s"). The backend pushes the FUTURE + * `nextWarmAt` after every warm (CR-4b) and `null` while generating/disabled, so + * this is a belt-and-braces guard that should never trigger — it only matters + * against a stale/buggy emitter, and the small window lets an on-time warm show + * "0s" for the second it takes to complete. + */ +const STALE_NEXT_WARM_MS = 3000; + /** * Seconds until the next automatic warm, AUTHORITATIVE: derived straight from the * backend's `nextWarmAt` epoch-ms (never FE-anchored/guessed). `null` when nothing - * is scheduled (disabled, or a turn is generating so the timer is cancelled). + * is scheduled (disabled, or a turn is generating so the timer is cancelled) — or + * when `nextWarmAt` is stale (further than the grace into the past). */ export function secondsUntilNext(nextWarmAt: number | null, now: number): number | null { if (nextWarmAt === null) return null; + if (now - nextWarmAt > STALE_NEXT_WARM_MS) return null; return Math.max(0, Math.ceil((nextWarmAt - now) / 1000)); } -- cgit v1.2.3