From 138be5ac96581cec6ad6893840ce4642fd62a884 Mon Sep 17 00:00:00 2001 From: Iqbal Date: Thu, 29 Oct 2026 23:09:45 +0700 Subject: [PATCH] Keep a schedule's last results while the engine is busy A schedule the proxy's rate limit answered with 503 was left out of that poll, so every count on Production dipped for a minute and came back on the next. A busy schedule is now asked once more after a pause, and if it still fails its results from the last poll are shown. A refused one (4xx) is still left out. Co-Authored-By: Claude Opus 5.5 --- src/express/results.test.ts | 36 ++++++++++++++++++++++++++++++++-- src/express/results.ts | 39 +++++++++++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/express/results.test.ts b/src/express/results.test.ts index e37f551..dd3d99b 100644 --- a/src/express/results.test.ts +++ b/src/express/results.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from 'vitest' -import { correctIndex, keepSettled, questionsOf, settleLimited, toMarkdown, withPlanItem, type Material } from './results' +import { describe, expect, it, vi } from 'vitest' +import { EngineError } from '../api/client' +import { correctIndex, keepSettled, questionsOf, remembering, settleLimited, toMarkdown, withPlanItem, type Material } from './results' import { sentencesOf, scriptLang } from '../components/AudioPlayer' +import type { Schedule } from './schedules' describe('correctIndex', () => { const options = ['Listen first', 'Interrupt', 'Leave'] @@ -81,6 +83,36 @@ describe('settleLimited', () => { }) }) +describe('remembering', () => { + const schedule = (id: number) => ({ agent: { id } }) as Schedule + const answers = (...seq: (number[] | number)[]) => vi.fn(async () => { + const next = seq.shift()! + if (typeof next === 'number') throw new EngineError('err', next) + return next + }) + + it('asks a busy engine once more', async () => { + const fn = answers(503, [1]) + expect(await remembering(fn, 0)(schedule(1))).toEqual([1]) + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('keeps the last results while the engine stays busy', async () => { + const read = remembering(answers([1, 2], 503, 503, 502), 0) + const s = schedule(1) + expect(await read(s)).toEqual([1, 2]) + expect(await read(s)).toEqual([1, 2]) + expect(await read(s)).toEqual([1, 2]) + }) + + it('drops a schedule the engine refuses, and one never read', async () => { + const read = remembering(answers([1], 404), 0) + await read(schedule(1)) + await expect(read(schedule(1))).rejects.toThrow() + await expect(remembering(answers(503, 503), 0)(schedule(2))).rejects.toThrow() + }) +}) + describe('keepSettled', () => { const ok = (v: number[]): PromiseSettledResult => ({ status: 'fulfilled', value: v }) const bad = (message: string): PromiseSettledResult => ({ status: 'rejected', reason: new Error(message) }) diff --git a/src/express/results.ts b/src/express/results.ts index 3a8f3dc..995fa61 100644 --- a/src/express/results.ts +++ b/src/express/results.ts @@ -2,7 +2,7 @@ // content plan item that ordered them), rendered videos (video-drafts) and web sources found by // research (source-drafts). Audio and SCORM are Express kinds built from Compose outputs: an audio // lesson is a narration summary, a SCORM module is the summary + quiz pair one plan item produced. -import { request } from '../api/client' +import { EngineError, request, sleep } from '../api/client' import { t } from '../i18n' import { kindLabel, type ContentKind, type Rule } from './parse' import { expressAgents, MAX_ATTEMPTS, PLAN_ACTIVE, productionStatus, retryItem, runNow, type PlanItem, type Schedule } from './schedules' @@ -93,7 +93,9 @@ async function materialsOf(schedule: Schedule): Promise { const kind = rule.type === 'content' ? rule.kind : 'summary' const [drafts, status] = await Promise.all([ request(`${AGENTS}/${agent.id}/material-drafts`), - productionStatus(agent.id).catch(() => null), + // Without its plan a schedule still has drafts; a busy engine fails the whole read instead, so + // the last complete one is shown rather than drafts missing their titles and planned pieces. + productionStatus(agent.id).catch(e => { if (passing(e)) throw e; return null }), ]) const items = new Map() for (const plan of status?.plans ?? []) { @@ -201,6 +203,35 @@ export async function settleLimited(items: T[], limit: number, fn: (item: const PER_SCHEDULE_LIMIT = 4 +// The proxy's rate limit (503/429), a 5xx or no answer at all passes; a 4xx (e.g. a deleted agent) does not. +const passing = (e: unknown) => e instanceof EngineError && (e.status === 0 || e.status === 429 || e.status >= 500) + +/** Wraps a per-schedule read so a passing failure does not change what the page shows: a busy + * engine is asked once more after a pause, and if it still fails, the schedule's results from the + * last poll that read them are used. Dropping them instead made every count on the page dip for + * one poll and come back on the next. */ +export function remembering(fn: (s: Schedule) => Promise, pause = 3000) { + const last = new Map() + return async (s: Schedule): Promise => { + try { + const read = await fn(s).catch(async e => { + if (!(e instanceof EngineError && (e.status === 503 || e.status === 429))) throw e + await sleep(pause) + return fn(s) + }) + last.set(s.agent.id, read) + return read + } catch (e) { + const kept = last.get(s.agent.id) + if (kept && passing(e)) return kept + throw e + } + } +} + +const readMaterials = remembering(materialsOf) +const readSources = remembering(sourcesOf) + // Results cost one request per schedule (every video schedule's drafts), so they are read once a // minute, and a page opened or refocused within that minute reuses what was read. An action on a // result or schedule still refreshes them at once. @@ -211,8 +242,8 @@ export async function listResults(): Promise<{ materials: Material[]; sources: F const content = schedules.filter(s => s.rule.type === 'content') const research = schedules.filter(s => s.rule.type === 'research') // One shared budget: research runs after content rather than alongside it. - const materials = await settleLimited(content, PER_SCHEDULE_LIMIT, materialsOf) - const sources = await settleLimited(research, PER_SCHEDULE_LIMIT, sourcesOf) + const materials = await settleLimited(content, PER_SCHEDULE_LIMIT, readMaterials) + const sources = await settleLimited(research, PER_SCHEDULE_LIMIT, readSources) const newest = (a: T, b: T) => b.createdAt.localeCompare(a.createdAt) return { materials: keepSettled(materials).sort(newest), sources: keepSettled(sources).sort(newest) } }