Archilyzer · Source

archilyzer

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

commit 4d862762eaaf14f4bff1a384b1dd32eb533b9a7b
parent a39afd0001f7f59e691ac1143c72bddfe452351e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 30 Aug 2026 11:28:57 -0400

plans: slice 8c shipped, and the docs say so

The CHANGELOG entry, the IA doc's "Slice 8c, as shipped" with the thirteen
places the slice-8 bullet could not be taken literally, STATE's #12 struck
through with the design question answered, and the FACTS census the plan was
built on — the three shapes, who reads a live row, who called the heal before
and who calls it now, and why the payload carries builtAt.

Slice 8 is complete: the nav's end state is eleven entries, not twelve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Diffstat:
Meditor/CHANGELOG.md | 1+
Mplans/FACTS.md | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/STATE.md | 35+++++++++++++++++++++++++++--------
Mplans/editor-operations-ia.md | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
4 files changed, 207 insertions(+), 14 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Jobs is one list.** `/jobs`, `/jobs/active` and `/jobs/queue` were three pages over three shapes — the directory listing, the registry's running work with its progress bars, and the scheduler's slots with their stuck reasons — and the same job was drawn three ways or hidden by one page's filter. `/jobs` is one table now: **one row per job**, the live head first (running, then queued in queue order with *next in line* / *2nd in line*, then anything that finished in the last half-minute) and the paged history below it, refreshed as before. A running row carries its per-task progress bars, its ETA and its *Transcripts: n / m* inside the Status cell; a queued row its Promote / ↑ / ↓; a stuck slot its `stuck · reason` badge, the last line of its log and **Force-release**, with **Reap stuck** on the health line above (active queues · running · queued · stuck · workers) and the lane strip above that. The kind/status/search filters apply to every row, live ones included. **The scheduler's drift check rides the live payload now**: every surface that draws it — this page, the dashboard, the monitor widget, `/api/jobs/active` — frees a running slot whose record is finished or gone the moment it sees one, after drawing it once. The channel, video, build and operation pages' *Active jobs* cards come off the same builder, so they show the progress bars they used to drop. `/jobs/active` and `/jobs/queue` redirect; the sidebar's *Active* entry and its running-count badge are gone (*Jobs* still counts running + queued); `/api/jobs/active` never moved. **Nothing on disk changes.** - **Sync is an operation on the board, and the schedule is its page.** The pipelines rail's first row is **Sync** — channels on a cadence, due now, overdue, in the same four state words every other row uses — because a corpus that has stopped noticing new videos is not idle, it is broken, and until now that fact sat in a panel of its own below the rail. Its page is **`/operations/sync`**, which is the schedule that used to be at `/scheduler`: the per-channel cadence table, the bulk retune bar, *Run scheduler now* and the recent ticks, every control unchanged — with **every Sync scheduler setting below it**, the fieldset that used to be on **Settings**, saved by one button. `/scheduler` redirects, so bookmarks land; the API paths and the `pnpm sync:tick` cron client never moved; the sidebar's separate *Schedule* entry is gone, and searching the command palette for "schedule" or "cadence" finds Operations. The two **Storage chores** that ride the same heartbeat — the keep-latest deletion check and the saved-video backup — are named as such on the console, in a tick's own summary line and on **Saved videos**: they are not sync, they just share the one timer the editor has. **Transcription workers are configured on Workers now**, directly under the live list, with their own *Save workers* — so seeing a worker and changing it are no longer two pages. **Settings** keeps the machine and the site, and points at both. The reason a lane is not working is now one table rather than two. **Nothing on disk changes; no setting is renamed.** - **A video's page shows one panel per operation, not just the digest's.** Speaker diarization and both *Speaker names* operations wrote records that no per-video screen could show — and for diarization that record is often the only surviving evidence of audio the cleanup sweep has since deleted. Each operation now has its own panel: what produced its record (engine, models, threshold, which transcript the names were read from, how many chunks survived), and the **same state word the channel's own counts use** — *current*, *stale*, *partly stale*, *not generated*, *held — …* with the reason spelled out, or *waiting on …* naming what has to happen first. Each speaker panel carries a **Run** button that runs **that one video** on the speaker lane, in front of the sweep's work; the channel's Speakers stage still runs the whole channel. A record whose feature has since been switched off is still shown, marked *switched off*, with no Run button — hiding it is how a record that cost audio nobody has any more becomes invisible. Both *Speaker names* operations read the one `attribution.json` they share, so their panels differ in the state word and not in the record; the `method` line says which lane wrote it. The digest panel is unchanged apart from its heading, which is now **Digest** rather than *AI digest* — the name the rest of the editor already uses for it — and the page no longer works out the digest's freshness a second way of its own, so a video's panel and its channel's count cannot disagree. A re-run of a per-video run from **Jobs** stays per-video. **Nothing on disk changes.** - **One pause, drawn wherever a lane is — and the runner pages have one now too.** Four lanes could be held, and each of them had grown its own button: three components (one of which nothing imported), four inline descriptors on the dashboard, and a fifth on the sweep panels with the opposite emphasis and an aria-label of its own. They are one control now, over one pair of server actions, keyed by LANE rather than by operation — three speaker operations share one backfill queue and therefore one pause, so an operation page is not a per-operation switch. **Every button name is unchanged.** New: `/operations/transcription` and `/operations/download` have their pause beside **Start**, **Drain** and **Stop** — those three act on the runner, the pause holds the lane, and a held lane outlives any runner. A held runner lane now shows as *Holding* on the operations rail, which it could never do before. The transcription page also stops contradicting itself: pausing disables every worker, so it used to say "no enabled worker to run it" directly beside its own *Resume Transcriptions* button, and now says "transcriptions are paused". **Resume is always clickable** — on a lane with no workers, or one switched off, the pause is disabled and the resume is not, because a paused pool with zero workers has to be releasable. The operation page's hold button also adopts the dashboard's emphasis: filled while the lane is HELD, where it used to tint itself while the lane was running fine. **Nothing on disk changes** — the same four settings fields, and the Speaker lane's *Run the backfill lane* checkbox and the pause button still write the same one, on purpose. The monitor widget's own data feed renames `digest.paused` and `backfill.enabled` to `held`; a pinned widget tab shows that lane as not held until it is reloaded. diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -2896,3 +2896,92 @@ error rather than a blank panel. `editor/app/workers/components/` holds `WorkersField.tsx` (was under `settings/components/`) and the new `WorkersConfigForm.tsx`; `sweepLaneNote` is in `editor/app/components/lanes/laneState.ts` with `laneState.test.ts` beside it. + +--- + +## Verified 2026-08-30 — editor IA slice 8c seams (`/jobs` is one list) + +Read-only census against `a609566` (clean), before the four commits `2226680` → `21feed3`. +Line numbers are that tree's. + +**The three shapes, and how little they overlap.** + +| source | type | fields nothing else had | +|---|---|---| +| `.jobs` directory ∪ registry | `JobListEntry` (`common/jobs/listJobs.ts:12-29`) | `logSize`, `logPath`, `inRegistry`, `replayable`, `status: JobStatus \| "archived"` | +| registry, running/queued only | `RunningJobsListItem` (`editor/app/jobs/components/RunningJobsList.tsx:38-69`) | `progress`, `tasks`, `draining`, `drainable`, `background`, `canMoveUp/Down`, `status: "queued" \| "running"` | +| scheduler ∪ registry | `QueueSlotView` (`editor/app/jobs/queue/buildQueueView.ts:19-31`) | `ageMs`, `stuck`, `stuckReason`, `pid`, `lastLogLine`, `status: string` incl. `"evicted"` | + +`JobRowView` (`editor/app/jobs/jobRowView.ts`) is the union: every `RunningJobsListItem` field +NAME kept, `status` widened to `JobStatus | "archived" | "evicted"`, everything else optional. + +**Who reads a live row, and on what.** `MonitorWidget.tsx` reads `status`, `kind`, +`background`, `channelSlug`, `progress`, `tasks` (`:1047-1086`) and the `progress`/`tasks` +element types (`:1121,1139,1176`); `PipelineBand.tsx:37-39` reads `jobs.jobs[].status`; +`LaneDeck.tsx:64` reads `jobs.disk`; `WidgetControls.tsx:75` passes the payload through. +**None narrows on `status`**, which is why widening it compiled with one import line changed. + +**The type dependency was backwards.** `buildActiveJobs.ts:18` imported its row type from +`RunningJobsList.tsx:38` — a `"use client"` component. It worked because it was `import type`; +the shape three surfaces read now lives in a types-only module whose only imports are +`JobStatus`, `JobProgressMetric`, `JobTaskKind` from `common/jobs/registry`. + +**Who called the heal, before and after.** Before: `buildQueueView.ts:231-234`, reached from +`/jobs/queue`'s render, its 2 s poll — and from **`/jobs/active`'s server render**, which +called `buildQueueView()` purely for a badge count (`active/page.tsx:15-20`). After: +`buildActiveJobsPayload`, i.e. `/jobs`, `DashboardCockpit.tsx:39-44`'s 1 s poll, +`MonitorWidget.tsx:71-76`'s poll and `/api/jobs/active`. `/api/pulse` does NOT join them +(`api/pulse/route.ts:36-53`: it observes, it "MUST NEVER CONSTRUCT"). + +**The `"running"` badge's consumers.** `nav.ts:45,103`; `layout.tsx:59` (the seed) and +`:106-117` (the `metric` switch); `SidebarBadges.tsx:22-33`; `api/pulse/route.ts:73,121` +(computes `runningJobs`, derives `busy` from it); `components/pulse.ts:20,30,69`. Only +`jobs-active-order.spec.ts:64` pinned `"1 running job"`. The nav key, the seed and the prop +went; **`PulsePayload.runningJobs` stays** — `busy` reads it and removing it is a wire change +no page needs. + +**Today's queued order vs the scheduler's.** `buildActiveJobs.ts:182-185` sorted running +before queued and otherwise kept `registry.list()`'s order, which is `queuedAt` DESC +(`registry.ts:157-161`). The scheduler's order is the array order in `queues()` +(`scheduler.ts:167-178`): running prefix, then queued. Queued rows now sort by +(`queueKey` localeCompare, `position`) off ONE `queues()` snapshot — `buildQueueView.ts:112-115` +already made the argument for snapshotting once. + +**`forceRelease`'s contract**, which is what makes reaping an already-healed id safe: +`registry.ts:260-276` — `scheduler.complete` is a no-op for an unknown id and the method +returns `true` regardless; it also **stamps `endedAt`** on a running/queued record +(`:266-269`), so a force-released job is a `recent` row for `RECENT_MS`. + +**`builtAt`, and why.** `next.config.ts:46` sets `staleTimes.dynamic: 15`, so a `/jobs` +render served from the router cache can be OLDER than the client's last poll. Adopting a new +`initial` prop unconditionally would show a finished job as running again. The payload carries +the server's `Date.now()` and the client renders whichever of the prop and the poll is newer. + +**Client-safety of the pure module.** `common/jobs/jobKinds.ts` has ZERO imports and is +already in the client graph via `jobKindLabels.ts`; `common/jobs/ulid.ts` has zero imports. +Those two are `jobRows.ts`'s only value imports, which is what lets a `"use client"` table +import the same adapters the server builder uses — the `laneState.ts` precedent. + +**The `nth(1)` assertions, one by one.** Order after the fold: stuck, running (newest first), +queued (queue, position), finished ≤ 30 s (newest first), then history newest first. +`queues.spec.ts:36` (slow-a running → first live row); `queues.spec.ts:126-129` (slow-a +running nth 1, slow-b queued nth 2 — the assertion is symmetric and today's order was the +reverse); `digest.spec.ts:382-384` and `:466-467`; `bulk-actions.spec.ts:65,96,117`; +`channels-actions.spec.ts:41,59`; `jobs-channel.spec.ts:21,43-52`. Each holds because the job +under test is either running (head, first) or finished seconds ago (`recent`, first). The +pre-existing `refresh-report` hydration race (FACTS.md:1004-1010) is exactly as it was. + +**As shipped.** `editor/app/jobs/jobRowView.ts` (types only), `editor/app/jobs/jobRows.ts` +(adapters, order, merge, `reconcileSlots`, `fromSlot`, `RECENT_MS`, `STUCK_AGE_MS`) with +`jobRows.test.ts` beside it; `buildActiveJobs.ts` gains `rowsForRecords`, `liveJobRows`, +`listJobRows`, `readLastLogLine`, `stuckJobIds` and the heal loop; `components/LaneStrip.tsx` +and `components/JobProgressBars.tsx` are byte-identical extractions; `components/JobsTable.tsx` +is the one list; `components/ReapStuckButton.tsx` is the queue strip's action. Deleted: +`jobs/active/page.tsx`, `jobs/components/ActiveJobsLive.tsx`, `jobs/queue/` (three files) and +`api/jobs/queue/route.ts`. + +**One consumer the plan did not name, found by tsc.** +`editor/app/channels/[slug]/lib/stageStatus.ts:165` typed its `runningJobs` input `JobRecord[]` +while reading only `status` and `kind`. The channel page no longer holds `JobRecord`s, so the +input takes the SHAPE (`ReadonlyArray<{ status: string; kind: string }>`) instead. Its other +caller (`videos/page.tsx` → `videoRowsServer.ts`) still passes records and is untouched. diff --git a/plans/STATE.md b/plans/STATE.md @@ -3,7 +3,22 @@ The working memory for the local-AI derived-corpus work. Rewritten at the end of every session, before context is cleared. See [`README.md`](README.md) for the protocol. -**Last updated:** 2026-08-29 — **editor IA slice 8a+8b shipped** (`ea04b4e` → `fecac0f`): +**Last updated:** 2026-08-30 — **editor IA slice 8c shipped** (`2226680` → `21feed3`): +**`/jobs` is one list, and slice 8 is complete.** The design question #12 asked first — what +is a ROW — is answered *a row is one job*, whichever of three places knows about it: a registry +record, a `.log` + sidecar the registry has forgotten, or a scheduler slot whose record was +evicted (a phantom). One `JobRowView`, one builder, one adapter per source, merged VIEWS by id +with the live row winning. The page is one `<table>` with no mode: the live head (running, +queued in queue order, anything that finished in the last half-minute) polled at 1 s while any +row is non-terminal, the paged history below it on the global pulse. **The scheduler's drift +check rides the live payload**, so every surface that draws work — `/jobs`, the dashboard, the +widget, `/api/jobs/active` — frees a running slot whose record is terminal or gone, after +drawing it once; `/api/pulse` does not build it and must not. `/jobs/active` and `/jobs/queue` +307; the sidebar's *Active* entry and its running badge are gone. The five pages that +hand-rolled a six-field copy of a running job now call `liveJobRows`, so their cards show the +progress bars they used to drop. **Nothing on disk changes.** See "Slice 8c, as shipped" in +`editor-operations-ia.md`. +Previously: 2026-08-29 — **editor IA slice 8a+8b shipped** (`ea04b4e` → `fecac0f`): **sync is a catalogued operation and the schedule is its page.** `OperationDescriptor` gains `scope` ("video" | "channel") and `trigger` ("backlog" | "cadence"), `SYNC_OPERATION` is first in `operationCatalog()`, and `/operations/sync` — today's `/scheduler`, which 307s — is the @@ -283,13 +298,17 @@ nothing renders. [`editor-ia-slice-8ab.md`](editor-ia-slice-8ab.md); outcome: "Slice 8a+8b, as shipped" in `editor-operations-ia.md` and the FACTS section "Verified 2026-08-29 — editor IA slice 8a+8b seams". -12. **The `/jobs` fold — the next IA candidate, and the last third of slice 8.** `/jobs`, - `/jobs/active` and `/jobs/queue` become one page with a mode and one table (umtool's `/` - is already that shape). **The design question to settle before writing the plan:** the - three pages read three different sources — the registry's job list, `buildActiveJobs`'s - lane-and-task view, and `buildQueueView`'s registry-vs-scheduler drift check — and "one - table" is only honest if a row means the same thing in all three modes. Decide what a ROW - is first; a mode switch over three shapes is three pages with shared chrome. +12. ~~**The `/jobs` fold — the last third of slice 8**~~ — **DONE 2026-08-30**, `2226680` → + `21feed3`. Plan: [`editor-ia-slice-8c.md`](editor-ia-slice-8c.md); outcome: "Slice 8c, as + shipped" in `editor-operations-ia.md` and the FACTS section "Verified 2026-08-30 — editor + IA slice 8c seams". The design question was answered **"a row is one job"** — and because + it was, there is no mode: one list, live head and paged tail, one `<tr>` per job. Slice 8 + is complete. + +**Recommended next (editor IA).** The remaining candidates are **slice 5** (Sites — Charts, +Search aliases, Deploy, Build and Homepage fold into `/sites/[siteId]`), the **transcode band** +(`EXTERNAL_BAND_IDS` to three, which needs the snapshot writer to record a transcode +population), and **Phase 6**. --- diff --git a/plans/editor-operations-ia.md b/plans/editor-operations-ia.md @@ -65,12 +65,12 @@ and concludes the model was wrong. ## The nav, end state -Four groups, twelve top-level entries, down from three groups and nineteen: +Four groups, eleven top-level entries, down from three groups and nineteen: - **Corpus** — Dashboard, Channels, Review - **Operations** — Operations *(Schedule folded in, 2026-08-29: it is `/operations/sync`)* - **Sites** — Sites *(+ Charts, Search aliases, Deploy, Build, Homepage until slice 5)* -- **Machine** — Jobs, Active, Workers, Cleanup, Saved videos, Settings, Changelog +- **Machine** — Jobs *(Active and Queue folded in, 2026-08-30)*, Workers, Cleanup, Saved videos, Settings, Changelog **The rule, and it is umtool's rule, not a new one.** `umtool/components/AppNav.tsx` states it after the same disease: "SEVEN visible entries, and the cap is still NINE … The next tool @@ -165,14 +165,17 @@ dependencies allow. Sizes are S/M/L. per operation, and three speaker operations share one gate); and the runner pages, which the bullet did not mention, got their pause too. Every aria label survived, byte-identical. **M, medium risk.** -8. **Machine.** `/jobs`, `/jobs/active` and `/jobs/queue` become one page with a mode and one - table; `WorkersField.tsx` (556 lines) moves to `/workers`; `/scheduler` becomes +8. **Machine.** `/jobs`, `/jobs/active` and `/jobs/queue` become one page and one LIST; + `WorkersField.tsx` (556 lines) moves to `/workers`; `/scheduler` becomes `/operations/sync`. umtool's `/` already reads "one activity list over both job registries" — this is the same shape. **M–L.** **8a+8b SHIPPED** (`ea04b4e` → `fecac0f`; see "Slice 8a+8b, as shipped" below): sync is catalogued and `/operations/sync` is its page with the whole `syncScheduler` block on it, - and the worker list is configured on `/workers`. **The `/jobs` fold is its own plan** and - is what remains of this slice. + and the worker list is configured on `/workers`. + **8c SHIPPED** (`2226680` → `21feed3`; see "Slice 8c, as shipped" below): `/jobs` is one + list — the live head is polled, the history tail is paged, and a row is one job. The + bullet said "a mode and one table"; a mode over three shapes is three pages with shared + chrome, so there is no mode. **Slice 8 is complete.** 9. **Sweeps retire; the tree dispatches everything.** unified-ops step 6 plus arbiter persistence. Deletes `digestSweep.ts`, `backfillSweep.ts`, `SweepLane.tsx`, `SweepScope.tsx`, the sweep checks at `arbiter.ts:205-213` and the resume hooks at @@ -596,3 +599,84 @@ strip and `/api/widget/sync`. `SweepLane`'s prose (see 4). `SchedulerRun`'s shap `syncSchedulerState`, `runSchedulerTick`'s logic and every settings key. `Field.tsx` / `CardField`. No `console` field on the descriptor — one cadence operation exists, and a second would name its console rather than have `page.tsx` branch on an id. + +## Slice 8c, as shipped + +Four commits after the plan ([`editor-ia-slice-8c.md`](editor-ia-slice-8c.md), `9622a5f`): +`2226680` (one row type, one builder) → `ec3279d` (one list: the polled head, the paged tail) +→ `21feed3` (the scheduler's view on the live payload, and its heal) → this docs commit. This +is the last third of slice 8, and slice 8 is now complete. + +**Three operator decisions, taken before the plan was written** (STATE.md #12 asked the +design question first — *what is a ROW* — and the answer is the whole design): + +- **A row is one job**, whichever of three places knows about it: a registry `JobRecord`, a + `.log` + sidecar the registry has forgotten, or a scheduler slot whose record was evicted (a + phantom). One view type, one server builder, one adapter per source. Merged VIEWS, never + merged registries — umtool's `lib/activity.ts` is the precedent. +- **The page is one list**, not a mode: live head, history tail, one `<table>`, one `<tr>` per + job, merged by id with the live row winning. +- **The scheduler reconciliation and its auto-heal fold into the live payload**, so every + consumer of it — `/jobs`, the dashboard, the widget, `/api/jobs/active` — heals drift when + it observes it. `/api/pulse` does not build it and must not: it observes, it never + constructs. + +**Thirteen places the slice-8 bullet could not be implemented literally, and what happened +instead.** The census is in FACTS.md ("Verified 2026-08-30 — editor IA slice 8c seams"); +these are the decisions. + +1. **Three shapes with almost no overlap.** `JobListEntry` (`logSize`, `inRegistry`, + `replayable`, `status: JobStatus | "archived"`), the card renderer's row (`progress`, + `tasks`, `draining`, `drainable`, `canMoveUp/Down`) and the queue's slot view (`ageMs`, + `stuck`, `pid`, `lastLogLine`, `status` including `"evicted"`). **One `JobRowView` that is + the union**: every field name the card renderer had is kept, `status` is widened to + `JobStatus | "archived" | "evicted"`, and the history and slot fields are optional. No + reader narrows on `status`, so the widening compiles. +2. **The type dependency was backwards.** The server builder imported its row type from a + `"use client"` component. `editor/app/jobs/jobRowView.ts` is types-only now, and + `registry.ts`'s "one re-spelled copy" comment names it. +3. **No page read all three sources.** Head = scheduler ∪ registry (live); tail = registry ∪ + logs (history); merged BY ID, live wins — a running job is in both and must be one `<tr>`. +4. **The heal already ran on more surfaces than the queue page** (`/jobs/active` built the + queue view for a badge count). Folding it into the live payload widens the set to every + surface that draws work, which is the decision above. +5. **The card renderer groups by channel; a table has no sections.** Eleven spec locators were + `section[aria-label='Active jobs for <displayName>']`; they are row filters by slug now — + the locator every existing `/jobs` spec already used. `payload.channels` had no other + reader and is gone. +6. **The last-log-line tail was read for EVERY slot, every poll** (8 KB each). It moved into + the builder and is read only for a row with a `stuck` fact. +7. **The `"running"` nav badge had five consumers and one spec.** The nav key, the layout seed + and the `metric` prop are gone; **`PulsePayload.runningJobs` stays** — `busy` reads it, and + deleting it would be a wire change no page needs. +8. **"Queued in scheduler order" is a change.** `/jobs/active` ordered queued jobs by the + registry's `queuedAt`. They sort by (queue name, position) off one `scheduler.queues()` + snapshot now — that is what *2nd in line* means. `jobs-reorder.spec.ts` still asserts the + EFFECT (a promoted job runs next), which is why it did not have to change shape. +9. **`nth(1)` means "the newest job" in nine specs.** Each was checked one by one; all hold, + because in every case the job under test is running (head, first) or finished seconds ago + (`recent`, first). +10. **`/jobs`'s empty state counts the registry, not the scheduler.** The lane strip and the + health line render ALWAYS — a lane is not a job, so an empty work list is not an empty + page. "No jobs have run yet." renders when the merged rows are empty AND the total is 0; + "No active queues." is gone, because the health line's `0` beside *active queues* says it. +11. **"Reap stuck" cannot be a header action.** Its visibility is `summary.stuck > 0`, a live + fact from the poll and not from the SSR render, so it rides the health line where the old + strip had it. The four static actions (Retry all failed, Clear logs, Pause Transcriptions, + Drain all) are the header. +12. **`reapStuckJobsAction` read the queue view.** It calls `stuckJobIds()` off the live + payload now. `forceRelease` on an already-healed id is safe, and it stamps `endedAt`, so a + force-released job is a `recent` row for half a minute — which is what `queue.spec.ts` + reads after the click. +13. **The two polls disagree about freshness.** A render served from the router cache + (`staleTimes.dynamic: 15`) can be older than the client's last poll, so the payload carries + `builtAt` and the client renders whichever snapshot is newer. A job that just finished + never vanishes and never regresses. + +**What stayed.** `buildActiveJobs.ts` in `jobs/active/` (a route directory with no `page.tsx` +is not routable — the `scheduler/` precedent). `RunningJobsList` as the renderer for a page's +OWN jobs, now fed by `liveJobRows` so those cards carry the progress bars they used to drop. +The widget's own `JobRow`. `PulsePayload.runningJobs`. `/api/jobs/active`. `STUCK_AGE_MS` as a +constant, not a setting. The reorder spec's effect assertion. The `gap-0.5` climb from a task +bar to its elapsed timer — the bars are a shared component, and the table's Status stack is +`gap-1` so the nearest `gap-0.5` ancestor is still the task's own wrapper.