commit 1e0c7914d32935bf67c69a67121b711f554d0ee5
parent fc3ab071d74fbf9629e002d487b1e278f94766d8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 28 Aug 2026 12:55:21 -0400
operations: a runner page has its pause beside Start, Drain and Stop
/operations/transcription and /operations/download had three controls,
all of them about the RUNNER. The lane's gate — which outlives any
runner, and which a stopped runner can still be holding — was only
reachable from the dashboard. It is the fourth button now, the same one
every other lane surface draws.
The status payload carries `held`, and the two kinds answer it from
different places on purpose: transcription's hold is the live worker
pool, download's is the settings flag its dispatch reads. The comment
says which and why.
railStates stops hard-coding `gateHeld: false`, so the operations rail
can show a runner lane Holding for the first time.
And the transcription page stops lying. pauseAll() disables every worker,
so the runner reported `no-workers` and the page said "no enabled worker
to run it" directly beside its own Resume Transcriptions button. There is
a `workers-paused` idle reason now, asked before the no-workers branch,
reading "transcriptions are paused" on the operation page and
"transcriptions paused globally" on Active jobs — mirroring
downloads-paused, which had this right already.
auto-queue.spec's per-operation-lane test gains the runner pause: hold
from /operations/download, assert settings.downloadsPaused on disk, then
release it so the fixture is left as it was found.
Nothing under transcripts/ was read or written for this commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
8 files changed, 81 insertions(+), 2 deletions(-)
diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts
@@ -130,6 +130,10 @@ export type AutoRunnerIdleReason =
| "cooldown"
// No enabled, non-degraded worker slot exists. Transcription only.
| "no-workers"
+ // The worker pool is pause-all'd. Transcription only, and DISTINCT from
+ // no-workers: pauseAll disables every worker, so by slot count the two look
+ // identical, and only one of them is fixed by enabling a worker.
+ | "workers-paused"
// There is genuinely nothing to do.
| "no-pending"
// Work exists but every path to it is at a maxWorkers ceiling (a node cap, or
@@ -596,7 +600,18 @@ async function runLoop(
// A zero limit means runPool never calls next(), so this is the only place
// "every worker is off or degraded" can be observed — without it the page
// would show a running runner with no explanation at all.
- if (slots === 0 && live.inFlight.size === 0) live.idleReason = "no-workers";
+ //
+ // THE PAUSE IS ASKED FIRST, and it has to be. pauseAll() DISABLES every
+ // worker (remembering each one's state for the resume), so a paused pool is
+ // indistinguishable from an empty one by slot count alone — and
+ // /operations/transcription said "no enabled worker to run it" next to its
+ // own Resume Transcriptions button. Same shape as the download lane's
+ // downloads-paused: an idle reason, not a stop.
+ if (slots === 0 && live.inFlight.size === 0) {
+ live.idleReason = getWorkerPool().isPaused()
+ ? "workers-paused"
+ : "no-workers";
+ }
return Math.min(slots, policy.maxWorkers ?? Number.POSITIVE_INFINITY);
};
diff --git a/editor/app/jobs/active/buildActiveJobs.ts b/editor/app/jobs/active/buildActiveJobs.ts
@@ -393,6 +393,8 @@ function autoIdleNote(
return "every pending platform is in a rate-limit cooldown";
case "no-workers":
return "no enabled worker";
+ case "workers-paused":
+ return "transcriptions paused globally";
case "disk-gate":
return "disk gate closed";
case "downloads-paused":
diff --git a/editor/app/operations/components/LaneHeader.tsx b/editor/app/operations/components/LaneHeader.tsx
@@ -3,6 +3,7 @@
import Link from "next/link";
import { Button } from "yt-dlp-transcript-common/components/ui/button";
import type { AutoQueueKindStatus } from "../status";
+import { PauseLaneButton } from "../../components/lanes/pauseControl";
import { formatElapsed, idleReasonText } from "./dispatch";
// The lane's identity line: the <h2> every e2e lookup keys off, the state pill,
@@ -21,12 +22,17 @@ export function LaneHeader({
now,
busy,
onControl,
+ onChange,
}: {
title: string;
status: AutoQueueKindStatus;
now: number | null;
busy: boolean;
onControl: (action: "start" | "stop" | "drain") => void;
+ // Re-poll after the lane's gate moves. The pause is not one of the three
+ // runner controls — it holds the LANE, which outlives any runner — so it does
+ // not go through onControl.
+ onChange: () => void | Promise<void>;
}) {
const running = status.runner.running;
const inFlight = status.runner.inFlight.length;
@@ -81,6 +87,20 @@ export function LaneHeader({
>
Stop
</Button>
+ {/* THE FOURTH CONTROL, and the one that was missing. Start, Drain and
+ Stop all act on the RUNNER; this holds the LANE, which is a
+ different thing with a different lifetime — a held lane stays held
+ across a runner restart, and a stopped runner can still be holding.
+ The same button the dashboard, the widget and both sweep panels
+ draw, so there is one idea of what holding looks like.
+
+ A plain <button> plus a role="alert" span on error only: no nested
+ <section>, and role="status" stays reserved for "Saved." */}
+ <PauseLaneButton
+ lane={status.kind}
+ held={status.held}
+ onChange={onChange}
+ />
</span>
</div>
diff --git a/editor/app/operations/components/RunnerOperationView.tsx b/editor/app/operations/components/RunnerOperationView.tsx
@@ -88,6 +88,7 @@ export function RunnerOperationView({
now={now}
busy={busy}
onControl={control}
+ onChange={onRefresh}
/>
{kind === "transcription" && (
diff --git a/editor/app/operations/components/dispatch.ts b/editor/app/operations/components/dispatch.ts
@@ -103,6 +103,8 @@ export function idleReasonText(
return "every pending platform is in a rate-limit cooldown";
case "no-workers":
return "no enabled worker to run it";
+ case "workers-paused":
+ return "transcriptions are paused";
case "disk-gate":
return "disk gate closed — not enough free space";
case "downloads-paused":
diff --git a/editor/app/operations/components/railStates.ts b/editor/app/operations/components/railStates.ts
@@ -33,7 +33,7 @@ export function railStates(
// it will never pick anything up, which "Idle" does not say.
state: !running
? "unavailable"
- : deriveLaneState({ gateHeld: false, activeCount: inFlight }),
+ : deriveLaneState({ gateHeld: status.held, activeCount: inFlight }),
note: running ? idleReasonText(status.runner.idleReason, status.kind) : null,
};
}
diff --git a/editor/app/operations/status.ts b/editor/app/operations/status.ts
@@ -13,6 +13,8 @@ import {
readAutoQueueState,
} from "yt-dlp-transcript-common/jobs/autoQueueState";
import type { AutoQueuePolicy } from "yt-dlp-transcript-common/jobs/autoQueuePolicy";
+import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool";
+import { isGateHeld } from "yt-dlp-transcript-common/lib/pauseGates";
import { buildAutoQueueLanes, type AutoQueueLanesPayload } from "./lanes";
// Read-only payload for the Auto-Queue panel: per-kind runner status (running?,
@@ -50,6 +52,15 @@ export type AutoQueueKindStatus = {
picks: AutoQueuePick[];
// Platforms paused by a rate-limit/network backoff (download kind only).
cooldowns: PlatformCooldownView[];
+ // This runner's lane gate — what the page's pause button draws, and what lets
+ // the operations rail show a runner HOLDING for the first time.
+ //
+ // THE TWO KINDS ANSWER IT FROM DIFFERENT PLACES, on purpose. Transcription's
+ // hold is LIVE, on the worker pool; settings.transcriptionsPaused is only what
+ // the boot hook re-applies after a restart, and the e2e harness rewrites
+ // settings wholesale between tests while the pool keeps its pausedSnapshot.
+ // Download has no live counterpart: its flag IS the gate, read at dispatch.
+ held: boolean;
};
export type AutoQueueStatusPayload = {
@@ -89,6 +100,10 @@ async function buildKind(kind: AutoQueueKind): Promise<AutoQueueKindStatus> {
nextUp: pending.nextUp,
picks: state[kind].picks,
cooldowns,
+ held:
+ kind === "transcription"
+ ? getWorkerPool().isPaused()
+ : isGateHeld(getSettings(), "download"),
};
}
diff --git a/editor/e2e/auto-queue.spec.ts b/editor/e2e/auto-queue.spec.ts
@@ -1172,6 +1172,30 @@ test("UI: each operation page carries its own lane, and the rail as context", as
}),
).toBeVisible();
+ // A RUNNER PAGE HAS ITS PAUSE TOO, beside Start/Drain/Stop. Those three act on
+ // the runner; this holds the LANE, which outlives it — and until slice 7 the
+ // only way to hold a runner lane was the dashboard.
+ //
+ // It writes the same settings field the dashboard's button does, so the field
+ // is what is asserted, not just the label. The pause is released again at the
+ // end so this test leaves the fixture as it found it.
+ const download = page.locator('section[data-lane="download"]');
+ await awaitHydration(download);
+ const downloadsPaused = async () =>
+ (
+ await readJson<{ downloadsPaused?: boolean }>("test-settings.json").catch(
+ () => ({}) as { downloadsPaused?: boolean },
+ )
+ ).downloadsPaused ?? false;
+ const pause = download.getByRole("button", { name: "Pause Downloads" });
+ await expect(pause).toBeVisible();
+ await pause.click();
+ await expect.poll(downloadsPaused, { timeout: 20_000 }).toBe(true);
+ const resume = download.getByRole("button", { name: "Resume Downloads" });
+ await expect(resume).toBeVisible();
+ await resume.click();
+ await expect.poll(downloadsPaused, { timeout: 20_000 }).toBe(false);
+
// The digest lane is a page of its own, with both of its controls.
await page.goto("/operations/digest");
const digest = page.locator('section[data-lane="digest"]');