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:
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.