Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit cf3c0abab3f35edc741d81f805482e96ea2932b4
parent d99651df6ad7549dcc3124111542f321224d0b48
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 18:43:14 -0400

common: /jobs flags a stall by quiet time, not age

A running slot read `possibly-stalled` once its record had held the slot
10 min with no task in flight. A metadata scan never has a task in flight,
so a healthy one at ~10 videos/min read STUCK · POSSIBLY-STALLED on
2026-09-25.

JobRecord gains `progressAt`, stamped when the job moves: `noteProgress`
(the setProgress every runManagedFunction job gets) stamps it only when
the snapshot's numbers changed, and `recordTaskDuration` stamps it when a
sub-operation finishes. reconcileSlots measures quiet time from the later
of the start and that stamp. Stamped at the source rather than diffed in a
view-side map, so the first /jobs render after an hour away is as right as
the hundredth. jobRows.test.ts +2: an hour of advancing progress is never
stuck; progress frozen past 10 min is (both fail on the old rule).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/jobs/registry.ts | 27+++++++++++++++++++++++++++
Mcommon/jobs/streamCommand.ts | 3++-
Mcommon/views/jobRows.test.ts | 54+++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/views/jobRows.ts | 10++++++++--
4 files changed, 90 insertions(+), 4 deletions(-)

diff --git a/common/jobs/registry.ts b/common/jobs/registry.ts @@ -129,8 +129,34 @@ export type JobRecord = { // task-count average cannot express it — the corpus is 77k videos and 77k // audio-hours, and those distribute completely differently. completedTaskAudioSeconds?: number; + // When the job last MOVED (epoch ms): its progress snapshot changed + // (`noteProgress`) or a sub-operation finished (`recordTaskDuration`). + // Undefined until the first. What the /jobs stall heuristic measures quiet + // time from — a slow scan at 10 videos/min that holds a slot for an hour with + // no in-flight tasks is still moving (release 9, B4a). + progressAt?: number; }; +// Store a progress snapshot on the record, stamping `progressAt` only when the +// numbers changed — a runner that re-reports the same count is not moving. +export function noteProgress( + record: JobRecord, + snap: JobProgress, + now: number = Date.now(), +): void { + const prev = record.progress; + if ( + !prev || + prev.current !== snap.current || + prev.target !== snap.target || + prev.initial !== snap.initial || + prev.remainingAudioSeconds !== snap.remainingAudioSeconds + ) { + record.progressAt = now; + } + record.progress = snap; +} + export type QueueSnapshot = { name: string; running?: JobRecord; @@ -360,6 +386,7 @@ class JobRegistry { if (!job || durationMs < 0) return; job.completedTaskCount = (job.completedTaskCount ?? 0) + 1; job.completedTaskMs = (job.completedTaskMs ?? 0) + durationMs; + job.progressAt = Date.now(); if (typeof audioSeconds === "number" && audioSeconds > 0) { job.completedTaskAudioSeconds = (job.completedTaskAudioSeconds ?? 0) + audioSeconds; diff --git a/common/jobs/streamCommand.ts b/common/jobs/streamCommand.ts @@ -5,6 +5,7 @@ import { execa } from "execa"; import { getRegistry, newJobId, + noteProgress, type JobProgress, type JobRecord, type JobStatus, @@ -342,7 +343,7 @@ export async function runManagedFunction( }; const setProgress = (snap: JobProgress) => { - record.progress = snap; + noteProgress(record, snap); }; const ctx: JobRunContext = { diff --git a/common/views/jobRows.test.ts b/common/views/jobRows.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { newJobId, type JobRecord } from "../jobs/registry"; +import { newJobId, noteProgress, type JobRecord } from "../jobs/registry"; import type { JobListEntry } from "../jobs/listJobs"; import { createScheduler } from "../jobs/scheduler"; import { @@ -399,3 +399,55 @@ test("fromInFlight: a note replaces the detail and drops the synthetic video id" assert.equal(row.videoId, undefined); assert.equal(row.id, "download:metadata-scan alpha"); }); + +// THE STALL LABEL MEASURES QUIET, NOT AGE (release 9, B4a). A metadata scan +// never has a task in flight and advances its count every few seconds; on +// 2026-09-25 one at ~10 videos/min read STUCK · POSSIBLY-STALLED after 10 min. +test("reconcileSlots: a long-running idle job whose progress advances is never stuck", () => { + const { scheduler, ids } = scheduledQueue(); + const id = ids[0]; + const start = 10_000_000; + const r = rec({ + id, + queueKey: "qFacts", + kind: "metadata-scan", + status: "running", + startedAt: start, + tasks: [], + }); + // One scanned video every 6 s for an hour, checked each minute. + for (let t = 0; t <= 60 * 60 * 1000; t += 6_000) { + noteProgress( + r, + { metric: "scans", initial: 0, target: 1_382, current: t / 6_000 }, + start + t, + ); + if (t % 60_000 === 0) { + const [fact] = reconcileSlots(scheduler.queues(), () => r, start + t); + assert.equal(fact.stuck, undefined, `flagged at ${t / 60_000} min`); + } + } +}); + +test("reconcileSlots: idle tasks and progress frozen past 10 min is possibly-stalled", () => { + const { scheduler, ids } = scheduledQueue(); + const id = ids[0]; + const start = 10_000_000; + const r = rec({ + id, + queueKey: "qFacts", + status: "running", + startedAt: start, + tasks: [], + }); + noteProgress(r, { metric: "scans", initial: 0, target: 100, current: 40 }, start + 20 * 60_000); + // The same count re-reported is not a move. + noteProgress(r, { metric: "scans", initial: 0, target: 100, current: 40 }, start + 25 * 60_000); + assert.equal(r.progressAt, start + 20 * 60_000); + const at = (min: number) => + reconcileSlots(scheduler.queues(), () => r, start + min * 60_000)[0]; + assert.equal(at(29).stuck, undefined); + const late = at(31); + assert.equal(late.stuck?.reason, "possibly-stalled"); + assert.equal(late.heal, false); +}); diff --git a/common/views/jobRows.ts b/common/views/jobRows.ts @@ -23,7 +23,11 @@ import type { JobRowView, StuckReason } from "./jobRowView"; export const RECENT_MS = 30_000; // A running slot is treated as "possibly stalled" (the soft case) once a -// running/draining record has held its slot this long with no in-flight tasks. +// running/draining record has held its slot this long with no in-flight tasks +// AND no progress: the clock runs from the later of its start and its last move +// (`JobRecord.progressAt`). Age alone flagged a healthy metadata scan at ~10 +// videos/min as STUCK on 2026-09-25 — it never has a task in flight, and it +// advances its count every few seconds (release 9, B4a). // Distinct from the hard cases (terminal-but-holding-slot / record-evicted), // which are always safe to auto-heal; the soft case is only surfaced/force- // releasable, never auto-healed. @@ -78,8 +82,10 @@ export function reconcileSlots( heal = true; } else { const ageMs = now - (rec.startedAt ?? rec.queuedAt); + const quietMs = + now - Math.max(rec.startedAt ?? rec.queuedAt, rec.progressAt ?? 0); const idle = !rec.tasks || rec.tasks.length === 0; - if (idle && ageMs > STUCK_AGE_MS) { + if (idle && quietMs > STUCK_AGE_MS) { // Soft case: a live running/draining record sitting idle far too // long. Force-releasable, but deliberately NOT auto-healed. stuck = { reason: "possibly-stalled", heldMs: ageMs };