` with five stats from `live.summary`, each
`
` holding the
value `` and the label `` (`:72-83` verbatim: the label text is exactly
`active queues`, `running`, `queued`, `stuck`, `workers` / `workers (paused)`). Right side:
`summary.stuck > 0 && ` — `:99-123` extracted to
`components/ReapStuckButton.tsx` (`"use client"`), label **"Reap stuck"** (was "Reap all
stuck"; not pinned), the confirm text kept. "Copy diagnostics" (`:124-140`) is not carried:
the payload is `/api/jobs/active`, one URL away.
- **Status cell**, after the pill: `{j.stuck &&
stuck · {j.stuck.reason}}` (`:221-225`'s markup; `pid` folds into the title rather than
a fifth thing in the cell).
- **Duration cell** for a row with `heldMs` and no `startedAt` (a phantom): `held for
${formatDuration(Math.round(heldMs/1000)) || "0:00"}` (`:185,212`).
- **Log size cell**: `j.stuck && j.lastLogLine ? {j.lastLogLine}`
(`:230-234`) else as commit 2.
- **Actions**: `ForceReleaseJobButton` for `status === "running" || j.stuck` (a stuck
terminal/evicted row has no Cancel to offer). ``.
- The sr-only live region `QueueViewLive.tsx:60-62` (`{n} stuck slot(s)`) carried under the
health line.
**Deleted (`git rm`):** `editor/app/jobs/queue/buildQueueView.ts`,
`editor/app/jobs/queue/components/QueueViewLive.tsx`, `editor/app/jobs/queue/page.tsx`,
`editor/app/api/jobs/queue/route.ts`. `editor/app/api/test/stuck-job/route.ts:13-14` comment
"the Queue diagnostics page exists to surface" → "/jobs surfaces (the live payload's
reconciliation)". `/api/jobs/active` stays.
**Unit test (commit 3), in `jobRows.test.ts`:** `reconcileSlots` driven by
`createScheduler()` (`common/jobs/scheduler.ts:204`, no registry): submit `a` (runs), `b`, `c`
on one key with no-op `start`; a `Map` lookup — `a` terminal → `terminal-but-holding-slot`,
`heal`; `a` absent → `record-evicted`, `heal`; `a` running with `startedAt = now - 11 min` and
`tasks: []` → `possibly-stalled`, no heal; `startedAt = now - 1 min` → no `stuck`; `b`, `c`
carry positions 1, 2 and no stuck; the heal list holds each id once; after
`facts.filter(f => f.heal).forEach(f => scheduler.complete(f.id))`, `queues()[0].running`
is `["b"]`. `fromSlot` yields `status: "evicted"`, `source: "slot"`; `orderLiveRows` puts a
stuck row before a running one.
**Specs (commit 3):** `queue.spec.ts` header `:1-5` → "/jobs: a job holding a running slot
that has gone quiet is surfaced as STUCK on its own row…"; test 1 title → "a stuck slot shows
on /jobs and Force-release clears it"; `:25` → `/jobs`; `:28` → heading "Jobs" level 1;
`:29` unchanged; `:32-35` →
```ts
const row = page.locator(`tr[data-job-id="${id}"]`);
await expect(row).toBeVisible();
await expect(row.getByText(/possibly-stalled/)).toBeVisible();
await expect(page.locator('[data-stat="stuck"]')).toContainText("1");
```
`:39` → `row.getByRole("button", { name: /^Force-release$/ }).click()`; `:41-47` →
`await expect(row.getByText(/possibly-stalled/)).toHaveCount(0, { timeout: 10_000 });
await expect(row).toContainText("cancelled"); await expect(page.locator('[data-stat="stuck"]')).toContainText("0");`
(forceRelease marks the record cancelled and sets `endedAt`, `registry.ts:266-269`; it stays
in the head as a `recent` row). Test 2 title → "loads with an empty queue and renders the
health line"; `:54` → `/jobs`; `:55` → heading "Jobs"; `:56` unchanged; `:57` →
`await expect(page.locator('[data-stat="queues"]')).toContainText("0");
await expect(page.getByText("No jobs have run yet.")).toBeVisible();`.
---
## Locator / URL table (old → new; which spec pins it)
| old | new | pinned by |
|---|---|---|
| `/jobs/active` | `/jobs` (307) | `jobs-active-order:47`, `jobs-reorder:87`, `jobs-batch-tasks-drain` ×6, `workers:132,150,191`, `parakeet-partial:140`, `auto-queue:540,578,693,1282`; `navigation` (new) |
| `/jobs/queue` | `/jobs` (307) | `queue:25,54`; `navigation` (new) |
| h1 "Active jobs" / "Queue" | h1 **"Jobs"** (unchanged) | `navigation:44,53,72,78`; `queue` (repointed) |
| `section[aria-label^='Active jobs for ']` (order, count) | `tbody tr[data-live]` order | `jobs-active-order:49-60` |
| `section[aria-label='Active jobs for ']` | `getByRole("row").filter({ hasText: "" })` (+ `"Transcribe all"` where a refresh-report follows) | `jobs-reorder:88-90`, `jobs-batch-tasks-drain:116,154,201,255,292`, `workers:151,192` |
| `section.getByText("running"/"queued", { exact: true })` | same, row-scoped | the same specs, `queues:134-135` |
| `getByRole("progressbar", { name: /Transcribing slowop/ })` | **unchanged** (`JobProgressBars.tsx`) | `jobs-batch-tasks-drain:70`, `workers:134` |
| `xpath=ancestor::div[contains(@class,'gap-0.5')][1]` | **unchanged** (component shared; the cell stack is `gap-1`) | `jobs-batch-tasks-drain:88` |
| `Transcripts: n / 6`, `~m:ss left`, `on Only` | unchanged, inside the row | `jobs-batch-tasks-drain:118,129`, `workers:195` |
| `promote job ${id} to front of queue`, `move job ${id} up/down`, `Drain`, `^Cancel$`, `Retry`, `retry job ${id}` | **unchanged** | `jobs-reorder:99-104`, `jobs-batch-tasks-drain`, `cancel:48`, `jobs-retry:83-89,146` |
| `section[aria-label='System jobs: Auto-transcribe']`, its ``, Drain/Cancel, `nothing pending`, `System jobs: Digest`, no "Other" heading | **unchanged** (`LaneStrip.tsx`) | `auto-queue:542-545,580-593,1284-1300` |
| "Pause Transcriptions" / "Resume Transcriptions" | unchanged, on `/jobs` | `parakeet-partial:141-150` |
| "Drain all" | unchanged, on `/jobs` | `jobs-batch-tasks-drain:210` |
| `getByLabel("2 active jobs")` | unchanged | `jobs-active-order:63` |
| `getByLabel("1 running job")` | **gone** | `jobs-active-order:64` (deleted) |
| `section[aria-label='Queue qStuck']`, `Job ${id}` | `tr[data-job-id="${id}"]` | `queue:32-34` |
| `getByText("stuck", { exact: true })`, `getByText("active queues", { exact: true })` | unchanged (health line labels) | `queue:29,56` |
| `/possibly-stalled/`, `^Force-release$` | unchanged, row-scoped | `queue:35,39` |
| "No active queues." | `[data-stat="queues"]` reads `0` + "No jobs have run yet." | `queue:45,57` |
| "Reap all stuck" | "Reap stuck" | none |
| `tr[data-kind]`, `jobRowByKind` | unchanged, on every row (live too) | 16 specs (`helpers.ts:183-188`) |
| `getByRole("row").nth(1)` = newest | = first LIVE row, else newest | see below |
| "No jobs have run yet.", "Clear logs", "All finished logs", "Search jobs", `/1 hidden/`, "Reset filters", kind/status chips | unchanged | `jobs:8-14`, `jobs-filters:110-138`, `channel-work:135-144` |
| sidebar link `/jobs/active` "Active" | gone; one sidebar link matches `/jobs/i` | `dashboard:120` |
| `aria-label="pipeline idle"` | unchanged (`jobs.jobs` semantics kept) | `dashboard:32` |
| `/api/jobs/active` `body.jobs[]{kind,status,queueKey,tasks[].phase}`, `body.disk` | unchanged (fields added, none removed except `channels`) | `backfill:22-24`, `audio-check-scenarios:601-609`, `auto-queue:658-668`, `disk-space:229-262` |
**The `nth(1)`/`nth(2)` assertions, one by one** (order after the fold: stuck, running
(newest first), queued (queue, position), finished ≤ 30 s (newest first), history newest
first):
- `queues.spec.ts:36` — slow-a's sync is running → first live row → holds.
- `queues.spec.ts:126-129` — slow-a running (nth 1), slow-b queued (nth 2); both `qShared` →
holds (today it was the reverse order, and the assertion is symmetric).
- `digest.spec.ts:382-384` — the digest job finished seconds ago → `recent`, first → holds;
the channel's `refresh-report` lands ~1 s after and is newer — the same pre-hydration race
as today (FACTS.md:1004-1010), unchanged.
- `digest.spec.ts:466-467` — remote (newer) then local, both `recent` or the older in the
tail; either way remote precedes local → holds.
- `bulk-actions.spec.ts:65,96,117` — the batch is running (head) or just finished (`recent`)
→ first → holds, same refresh-report caveat.
- `channels-actions.spec.ts:41,59` — check-availability / whisper-all just finished →
`recent` first → holds.
- `jobs-channel.spec.ts:21` — store-playlist just finished → holds. `:43-52` — the running
row flips to "cancelled" via the 1 s poll now (it moves from `jobs` to `recent`), well inside
the 10 s.
- `cancel.spec.ts:47-49`, `job-stream-cancel.spec.ts:65-67`, `jobs-retry.spec.ts:91-95`,
`sync-deep.spec.ts:250-253` (`toHaveCount(1)` — merge by id, one row), `sync-break-on-
existing.spec.ts:38-46`, `channel-groups.spec.ts:145-147`, `cleanup-actionable.spec.ts:97`,
`channel-work.spec.ts:135-144`, `jobs.spec.ts:28-40`, `jobs-filters.spec.ts` — row filters
by text/kind; unaffected by order.
## Verification
1. After each of commits 1–3: tsc in the six packages; `pnpm -C common test`; editor units
(`jobRows.test.ts` included).
2. Grep gates after commit 3, over `editor/ common/` excluding `node_modules`, `.next`,
`CHANGELOG.md`, `plans/`:
- `/jobs/active|/jobs/queue` → only `editor/next.config.ts` (two `source`s + the comment)
and `editor/e2e/navigation.spec.ts`; the four comment sites named in commit 2 are
rewritten.
- `ActiveJobsLive|QueueViewLive|buildQueueView|RunningJobsListItem|RunningJobsTask|QueueSlotView|QueueViewPayload|QueueGroupView` → 0.
- `editor/app/jobs/active/page.tsx`, `editor/app/jobs/queue/`, `editor/app/api/jobs/queue/`,
`editor/app/jobs/components/ActiveJobsLive.tsx` do not exist; `editor/app/jobs/active/
buildActiveJobs.ts` does.
- `label: "Active"|Activity,|"running"` in `lib/nav.ts` → 0; `runningJobs|metric=` in
`layout.tsx` → 0; `metric` in `SidebarBadges.tsx` → 0; `runningJobs` in
`api/pulse/route.ts` → still present (kept on purpose).
- `revalidatePath("/jobs/queue")` → 0; `revalidatePath("/jobs")` count unchanged (16).
- `readLastLogLine(` → only `buildActiveJobs.ts`, and its one call site is under `stuck`.
- `scheduler.complete(|getScheduler().complete(` under `editor/app` → only the heal loop in
`buildActiveJobs.ts`.
- `Active jobs for |No active queues|Reap all stuck|Copy diagnostics` → 0 under `editor/`.
- `gap-0.5` → present in `JobProgressBars.tsx` (the task wrapper) and NOT in `JobsTable.tsx`'s
Status cell.
- `^import` in `jobRowView.ts` → type-only; in `jobRows.ts` → type-only plus `jobKinds` and
`ulid`; `from ".*buildActiveJobs"` in any `"use client"` file → every hit is `import type`.
- the render-path guard: `pnpm -C common exec tsx --test controller/noCorpusWalkInRenderPaths.test.ts`.
3. e2e once after commit 4, detached (`cd editor && setsid nohup sh -c 'pnpm e2e -- ;
echo exit=$?' > $CLAUDE_JOB_DIR/tmp/e2e.log 2>&1 < /dev/null & disown`):
`jobs-active-order.spec.ts jobs-reorder.spec.ts jobs-batch-tasks-drain.spec.ts
workers.spec.ts parakeet-partial.spec.ts auto-queue.spec.ts queue.spec.ts queues.spec.ts
navigation.spec.ts dashboard.spec.ts widget.spec.ts jobs.spec.ts jobs-filters.spec.ts
jobs-channel.spec.ts jobs-retry.spec.ts cancel.spec.ts job-stream-cancel.spec.ts
bulk-actions.spec.ts channels-actions.spec.ts digest.spec.ts channel-work.spec.ts
channel-groups.spec.ts cleanup-actionable.spec.ts sync-deep.spec.ts
sync-break-on-existing.spec.ts backfill.spec.ts disk-space.spec.ts
audio-check-scenarios.spec.ts pulse.spec.ts auto-refresh.spec.ts perf-budget.spec.ts`.
Port fallback `PORT=3111 EXPORT_PORT=3110 OLLAMA_STUB_PORT=11535`; never kill anything.
4. **No editor boot against `transcripts/`, nothing written under it.**
5. Manual (optional, `PORT=3021 pnpm dev:test`): `/jobs` shows the lane strip, the health line
with five figures, then one table; start a slow sync → its row is first with progress bars
in the Status cell and "next in line" on a queued sibling; cancel it → the row reads
`cancelled` within a second and stays until the tail carries it; `/jobs/active` and
`/jobs/queue` land on `/jobs`; the sidebar's Machine group has no Active entry; the
dashboard's Pipeline band draws the same rows; `GET /api/test/stuck-job?queue=q` then
`/jobs` → a `stuck · possibly-stalled` badge, the last log line in the Log-size column,
Force-release on the row and Reap stuck on the health line.
## Commit 4 — `plans: slice 8c shipped, and the docs say so`
- `editor/CHANGELOG.md` `[Unreleased]` first bullet, house voice (`:4` is the model): **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.**
- `plans/editor-operations-ia.md:68` "twelve" → "eleven"; `:73` → "Jobs *(Active and Queue
folded in, 2026-08-30)*, Workers, …"; `:168-175` slice-8 bullet → "one page and one LIST"
with "**8c SHIPPED** (`` → ``; see 'Slice 8c, as shipped')"; new
"## Slice 8c, as shipped" after the 8a+8b section: the four commits; findings 1–13; the three
operator decisions (a row is one job; one list, no mode; every consumer heals); what stayed
(`buildActiveJobs.ts`'s directory, `RunningJobsList` as the five pages' renderer, the
widget's `JobRow`, `PulsePayload.runningJobs`, `/api/jobs/active`, `STUCK_AGE_MS`, the
reorder spec's effect assertion, the `gap-0.5` climb).
- `plans/STATE.md:6` "Last updated" prepend (previous entry demoted to "Previously:");
`:286-291` #12 → struck through, "**DONE 2026-08-30**, `` → ``. Plan:
`editor-ia-slice-8c.md`; outcome: 'Slice 8c, as shipped'; the design question was answered
'a row is one job'"; "Recommended next": the remaining editor candidates are slice 5 (Sites),
the transcode band, and Phase 6.
- `plans/FACTS.md`: `## Verified 2026-08-30 — editor IA slice 8c seams (/jobs is one list)`
appended after the 8a+8b section: the three shapes and their fields; the live-row readers
(finding 1's list with line numbers); the backwards type import; who called the heal before
and who calls it now; the badge's consumers; today's queued order vs the scheduler's; the
`nth(1)` table; `forceRelease`'s no-op contract and its `endedAt`; `jobKinds.ts` and
`ulid.ts` having no imports; `builtAt` and why.
- Memory: left to the reviewer (`ia-slice-8c-shipped.md` + a `MEMORY.md` line; amend
`ia-slice-8ab-shipped`'s "slice 8c NOT done").
## Out of scope
- Replacing `RunningJobsList` on the channel, video, build and operation pages with the table.
The dashboard's double poll (`DashboardCockpit.tsx:39-44` + the embedded table). The widget's
own `JobRow`. `STUCK_AGE_MS`, `RECENT_MS` as settings. `listAllJobs`'s paging, retention and
`pruneJobLogs`. `/jobs/[id]` and `JobLogTail`. `PulsePayload`'s wire shape. A `data-task-id`
hook for the `gap-0.5` climb (only if it rots). The `bookmarks` surfaces the CHANGELOG
mentions — already gone from the tree.
## Handoff — the cadence
On approval, Fable does not implement (memory `plan-then-opus-implements`): write the plan to
`plans/editor-ia-slice-8c.md`, commit it alone, then spawn one `general-purpose` agent,
`model: "opus"`, with: the plan path, the shell caveats (the Bash tool runs zsh here — quote
every glob, `--include='*.ts'`; no word-splitting of `$VAR` commands; POSIX loops inside
`sh -c`; `git commit -F `; quote `[slug]`/`[id]` paths; stage
by explicit path, never `git add -A` — another session commits into this checkout), never
boot against `transcripts/`, e2e detached (memory `e2e-run-detached`), tmp under
`$CLAUDE_JOB_DIR/tmp`, the two trailer lines, and the report contract (shas + one line each;
exact gate outputs; e2e per spec with retries; every divergence and why; anything undone).
Fable reviews on return (`git log --oneline a609566..`; `jobRowView.ts`, `jobRows.ts` + test,
`buildActiveJobs.ts` hunks — the header, `rowsForRecords`, `liveJobRows`, `listJobRows`,
`reconcileSlots` call and the heal loop, `stuckJobIds`; the five hand-map sites;
`RunningJobsList.tsx` diff-against-rename of the bars; `JobsTable.tsx`; `LaneStrip.tsx` and
`JobProgressBars.tsx` diff-against-move; `jobs/page.tsx`; `PipelineBand.tsx`; `nav.ts`,
`layout.tsx`, `SidebarBadges.tsx`, `next.config.ts`; `jobs/actions.ts`; the deletions; the
eight repointed specs; re-runs grep gates + `pnpm -C common test` + editor units, not e2e),
sends fixes via SendMessage, and reports.