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 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-29 23:09:45 +07:00
parent dfb3275afe
commit 138be5ac96
2 changed files with 69 additions and 6 deletions

View File

@@ -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<Material[]> {
const kind = rule.type === 'content' ? rule.kind : 'summary'
const [drafts, status] = await Promise.all([
request<RawMaterial[]>(`${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<number, PlanItem & { planId: number }>()
for (const plan of status?.plans ?? []) {
@@ -201,6 +203,35 @@ export async function settleLimited<T, R>(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<T>(fn: (s: Schedule) => Promise<T[]>, pause = 3000) {
const last = new Map<number, T[]>()
return async (s: Schedule): Promise<T[]> => {
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 = <T extends { createdAt: string }>(a: T, b: T) => b.createdAt.localeCompare(a.createdAt)
return { materials: keepSettled(materials).sort(newest), sources: keepSettled(sources).sort(newest) }
}