commit 7f59a8f6fba08977a62f1d36a3eeb81e817a8a09
parent da2945be8a1d46eee108dad789237db4ded722ac
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 7 Sep 2026 23:55:22 -0400
common: a lane's pause becomes a key on the lane, and the four old fields become its fallback
`AutoQueuePolicy.held` joins `enabled`, `order`, `snoozeUntil` and the tree, so
the one per-lane switch that lived somewhere else lives with them; `isGateHeld`
reads it and asks `legacyGateHeld` only when it is absent, `withGateHeld` writes
it and nothing else, and `getSettings` copies the legacy answer onto it after
the digest and backfill sanitizers so the next write persists it. The sanitizer
deliberately does NOT default `held`: every settings.json in existence spells
the retired fields and no key, and reading an absent key as `false` would resume
a paused corpus.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
8 files changed, 427 insertions(+), 76 deletions(-)
diff --git a/common/controller/operationBatch.test.ts b/common/controller/operationBatch.test.ts
@@ -352,6 +352,73 @@ test("the digest lane holds while digests are paused", () => {
assert.equal(verdict.hold?.reason, "paused");
});
+// HELD IS A HOLD, NEVER A STOP — at the runner, through the new key.
+//
+// The two tests above hold their lane through a RETIRED field (`backfill.
+// enabled`, `digest.digestsPaused`); this one holds it through
+// `autoQueue[lane].held`, which is where slice 1.4 put the gate. Both routes
+// have to reach the same zero, because a settings file can spell either one:
+// the retired fields are the migration's input until every lane's key is on
+// disk.
+//
+// WHAT "A HOLD" MEANS HERE is `limit: 0` with `hold.reason === "paused"`, which
+// is what makes runPool idle-wait and the runner report `lane-held` while
+// staying `running` (lane-runner.spec.ts test 1 asserts the other half, from
+// the browser). A stop would be `next()` aborting the controller, and nothing
+// on this path does that.
+//
+// Only the two OPERATION lanes hold through limit(): the download lane's gate
+// is asked in `next()` (idle reason `downloads-paused`) and transcription's is
+// the worker pool's slot count. Same gate, three shapes — see pauseGates.ts.
+function heldLane(lane: "digest" | "backfill", held: boolean): SiteSettings {
+ const base = defaultSiteSettings();
+ return {
+ ...base,
+ // The retired fields say the OPPOSITE of `held`, so a reader that still
+ // consulted them would fail this in both directions.
+ digest: { ...defaultDigest(), digestsPaused: !held },
+ backfill: { ...defaultBackfill(), enabled: held, concurrency: 2 },
+ autoQueue: {
+ ...base.autoQueue,
+ [lane]: { ...base.autoQueue[lane], held },
+ },
+ };
+}
+
+test("an operation lane held through autoQueue[lane].held stops dispatching without stopping", () => {
+ for (const lane of ["digest", "backfill"] as const) {
+ const live =
+ lane === "digest"
+ ? ({
+ lane: "digest",
+ appLane: "local-gpu",
+ metered: false,
+ costUsd: 0,
+ concurrency: 1,
+ fanOutEligible: false,
+ llmActive: 0,
+ } as const)
+ : ({
+ lane: "backfill",
+ operations: [],
+ llmOps: [],
+ remoteEligible: false,
+ llmActive: 0,
+ unitActive: 0,
+ } as const);
+
+ const held = laneLimit(heldLane(lane, true), live);
+ assert.equal(held.limit, 0, `${lane} held should dispatch nothing`);
+ assert.equal(held.hold?.reason, "paused", `${lane} held should say paused`);
+
+ // And `held: false` releases the lane even while the retired field it
+ // replaced still says "held" — the key wins, in both directions.
+ const free = laneLimit(heldLane(lane, false), live);
+ assert.ok(free.limit > 0, `${lane} free should dispatch`);
+ assert.equal(free.hold?.reason, undefined, `${lane} free should not hold`);
+ }
+});
+
test("the digest spend cap is per RUN, and parks the metered lane", () => {
const settings = settingsWith({
digest: { ...defaultDigest(), spendCapUsd: 5 },
diff --git a/common/jobs/autoQueuePolicy.ts b/common/jobs/autoQueuePolicy.ts
@@ -611,6 +611,14 @@ function sanitizePolicy(value: unknown, lane: AutoQueueKind): AutoQueuePolicy {
? defaultOrderFor(lane)
: sanitizeAutoQueueOrder(r.order),
snoozeUntil: sanitizeSnooze(r.snoozeUntil),
+ // A BOOLEAN OR NOTHING, and deliberately NOT defaulted. `held` is the lane's
+ // pause gate (AutoQueuePolicy.held); every settings.json written before
+ // slice 1.4 carries the four legacy pause fields instead, and `undefined`
+ // is what makes `isGateHeld` fall back to them. Defaulting it to false here
+ // would read a paused corpus as running — the one bug this field could
+ // introduce. `undefined` also never reaches the file: JSON.stringify drops
+ // it, so a lane that has not been through getSettings' copy stays absent.
+ held: typeof r.held === "boolean" ? r.held : undefined,
root:
r.root === undefined
? defaultRootFor(lane)
diff --git a/common/jobs/laneMigration.test.ts b/common/jobs/laneMigration.test.ts
@@ -1,6 +1,13 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { laneRootFromScope, migrateSweepsToLanes } from "../lib/laneMigration";
+import {
+ laneRootFromScope,
+ migrateHeldToLanes,
+ migrateSweepsToLanes,
+} from "../lib/laneMigration";
+import { isGateHeld } from "../lib/pauseGates";
+import { defaultSiteSettings, type SiteSettings } from "../lib/settings";
+import { LANES, type AutoQueueSettings } from "../lib/autoQueueTypes";
import { sanitizeAutoQueue } from "./autoQueuePolicy";
// The migration is asserted THROUGH THE SANITIZER, because that is how it is
@@ -294,3 +301,93 @@ test("blank and duplicate scope entries are dropped rather than becoming leaves"
],
);
});
+
+// ---------------------------------------------------------------------------
+// The pause fields' migration (slice 1.4): four flags become one key per lane.
+
+// A settings object shaped like the live file: the four RETIRED pause fields
+// spelled, no `held` anywhere. Everything else is the default.
+function withLegacyPauses(over: Partial<SiteSettings> = {}): SiteSettings {
+ const base = defaultSiteSettings();
+ return {
+ ...base,
+ transcriptionsPaused: true,
+ downloadsPaused: false,
+ digest: { ...base.digest, digestsPaused: false },
+ // The live file's value, and the one that makes this a real test: `enabled`
+ // is INVERTED, so `true` here must come out as `held: false`.
+ backfill: { ...base.backfill, enabled: true },
+ ...over,
+ };
+}
+
+const heldByLane = (autoQueue: AutoQueueSettings) =>
+ Object.fromEntries(LANES.map((l) => [l, autoQueue[l].held]));
+
+test("the live shape: transcriptionsPaused becomes transcription.held", () => {
+ const settings = withLegacyPauses();
+ // The precondition — nothing carries a `held` before the copy, which is what
+ // makes the fallback in isGateHeld the thing answering today.
+ assert.deepEqual(heldByLane(settings.autoQueue), {
+ transcription: undefined,
+ download: undefined,
+ digest: undefined,
+ backfill: undefined,
+ });
+ assert.deepEqual(heldByLane(migrateHeldToLanes(settings)), {
+ transcription: true,
+ download: false,
+ digest: false,
+ backfill: false,
+ });
+});
+
+test("a lane that already carries `held` is untouched, false included", () => {
+ // `false` is an ANSWER, not an absence. Coercing it would let a retired field
+ // resurrect a pause an operator had lifted — and there is no version marker
+ // to tell "migrated to false" from "never migrated" apart from this.
+ const base = withLegacyPauses();
+ const settings: SiteSettings = {
+ ...base,
+ autoQueue: {
+ ...base.autoQueue,
+ transcription: { ...base.autoQueue.transcription, held: false },
+ backfill: { ...base.autoQueue.backfill, held: true },
+ },
+ };
+ assert.deepEqual(heldByLane(migrateHeldToLanes(settings)), {
+ transcription: false,
+ download: false,
+ digest: false,
+ // Kept, though `backfill.enabled: true` says the opposite.
+ backfill: true,
+ });
+});
+
+test("the pause migration is idempotent, by identity on the second pass", () => {
+ const settings = withLegacyPauses();
+ const once = migrateHeldToLanes(settings);
+ const twice = migrateHeldToLanes({ ...settings, autoQueue: once });
+ assert.equal(twice, once, "a migrated file must not be rebuilt");
+});
+
+test("the pause migration never unholds a lane", () => {
+ // Every combination of the four retired fields: the copied value is exactly
+ // what isGateHeld was already returning, so no reading of a settings file can
+ // change on the day this runs.
+ for (let mask = 0; mask < 16; mask++) {
+ const base = defaultSiteSettings();
+ const settings: SiteSettings = {
+ ...base,
+ transcriptionsPaused: (mask & 1) !== 0,
+ downloadsPaused: (mask & 2) !== 0,
+ digest: { ...base.digest, digestsPaused: (mask & 4) !== 0 },
+ backfill: { ...base.backfill, enabled: (mask & 8) === 0 },
+ };
+ const before = LANES.map((l) => isGateHeld(settings, l));
+ const after = LANES.map((l) =>
+ isGateHeld({ ...settings, autoQueue: migrateHeldToLanes(settings) }, l),
+ );
+ assert.deepEqual(after, before, `mask ${mask}`);
+ }
+});
diff --git a/common/lib/autoQueueTypes.ts b/common/lib/autoQueueTypes.ts
@@ -128,6 +128,20 @@ export type AutoQueuePolicy = {
// itself when the moment passes. null/absent/past = not snoozed. Survives a
// restart because it lives in settings.json, not in runner memory.
snoozeUntil?: number | null;
+ // THE LANE'S PAUSE GATE. Shut means the lane holds: every dispatch path asks
+ // lib/pauseGates.ts, whose limit()/guard returns 0 so runPool idle-waits. A
+ // hold, never a stop — see that file's header.
+ //
+ // OPTIONAL, AND ABSENT IS NOT `false`. Until slice 1.4 four separate settings
+ // fields carried this — `transcriptionsPaused`, `downloadsPaused`,
+ // `digest.digestsPaused` and (inverted) `backfill.enabled` — and every
+ // settings.json in existence still spells those and not this. So `undefined`
+ // means "ask the legacy field", which is exactly what `isGateHeld` does, and
+ // the sanitizer must NOT default it to false: doing so would read every
+ // pre-1.4 file as "no lane held" and quietly resume a paused corpus.
+ // `getSettings` copies the legacy answer onto this key on read, so the next
+ // write persists it and the fallback stops being consulted.
+ held?: boolean;
root: AutoQueueGroup;
};
diff --git a/common/lib/laneMigration.ts b/common/lib/laneMigration.ts
@@ -37,7 +37,15 @@
// file. That is deliberate — a migration that built already-sanitized objects
// would be a second implementation of the sanitizer, and the two would drift.
-import type { AutoQueueGroup, AutoQueueLeaf, AutoQueueMatch } from "./autoQueueTypes";
+import {
+ LANES,
+ type AutoQueueGroup,
+ type AutoQueueKind,
+ type AutoQueueLeaf,
+ type AutoQueueMatch,
+ type AutoQueueSettings,
+} from "./autoQueueTypes";
+import type { SiteSettings } from "./settings";
// A lane's scope in the terms the sweeps used: some channels, some operations.
// Both empty means "everything", which is what an unscoped sweep meant.
@@ -198,3 +206,74 @@ export function migrateSweepsToLanes(
return autoQueue;
}
+
+// ---------------------------------------------------------------------------
+// THE PAUSE FIELDS' LAST ACT: four settings flags become one key per lane.
+//
+// Same shape as the sweep migration above, one slice later and one level down.
+// A lane's gate lived in four unrelated fields with three different polarities
+// (`transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused`, and
+// `backfill.enabled` INVERTED); it lives on `autoQueue[lane].held` now, which
+// is where every other per-lane switch already lives.
+//
+// WHY THE LEGACY READ LIVES HERE AND NOT IN pauseGates.ts, which is otherwise
+// the only place polarity is known: `getSettings` has to run this on every read
+// of settings.json, and pauseGates.ts imports lib/operations.ts (for
+// pauseLaneFor), which imports the controller layer. Pulling that into every
+// reader of settings.json — bin/ scripts, the MCP server, the export build —
+// to fill in one boolean would be a much larger change than the boolean is
+// worth. So the RETIRED fields are read here, beside the other retired fields,
+// and `isGateHeld` delegates to this function for its fallback. There is still
+// exactly one place that knows the inversion.
+
+// The gate as the four RETIRED fields spell it. Touches only the named lane's
+// field: laneGuards.test.ts casts a `{ digest }`-only object to SiteSettings,
+// so a reader that reached for `settings.backfill` on the way past would throw.
+export function legacyGateHeld(
+ settings: SiteSettings,
+ lane: AutoQueueKind,
+): boolean {
+ switch (lane) {
+ case "transcription":
+ // The PERSISTED intent, not the live pool. See pauseGates.ts's header.
+ return settings.transcriptionsPaused === true;
+ case "download":
+ return settings.downloadsPaused === true;
+ case "digest":
+ return settings.digest.digestsPaused === true;
+ case "backfill":
+ // INVERTED, and this line is why pauseGates.ts exists.
+ return settings.backfill.enabled === false;
+ }
+}
+
+// Copy the legacy answer onto `autoQueue[lane].held` for every lane that does
+// not already carry one, so the next write persists it and the fallback stops
+// being consulted.
+//
+// THREE RULES, the same three the sweep migration keeps:
+//
+// 1. IT NEVER UNHOLDS A LANE. The value copied is the one `isGateHeld` was
+// already returning, so this moves where the answer is stored and never
+// what it is. The live corpus has `transcriptionsPaused: true`; a
+// migration that read it as "not held" would resume the GPU by itself.
+// 2. A LANE THAT ALREADY CARRIES `held` IS UNTOUCHED — including a `false`,
+// which is a real answer and not an absence. Idempotent by construction.
+// 3. IT RETURNS THE INPUT OBJECT WHEN NOTHING CHANGED, so "this file was
+// already migrated" is identity, not a deep compare.
+//
+// Takes the MERGED, sanitized settings rather than the parsed file: unlike the
+// sweep migration, "absent" here is a property of the sanitized policy (which
+// deliberately leaves `held` undefined) and the legacy fields it reads have
+// been through their own sanitizers by this point.
+export function migrateHeldToLanes(settings: SiteSettings): AutoQueueSettings {
+ const out = { ...settings.autoQueue };
+ let changed = false;
+ for (const lane of LANES) {
+ const policy = out[lane];
+ if (!policy || typeof policy.held === "boolean") continue;
+ out[lane] = { ...policy, held: legacyGateHeld(settings, lane) };
+ changed = true;
+ }
+ return changed ? out : settings.autoQueue;
+}
diff --git a/common/lib/pauseGates.test.ts b/common/lib/pauseGates.test.ts
@@ -17,8 +17,13 @@ import {
//
// What these pin is the thing the file exists to stop: a second opinion about
// polarity. `backfill.enabled` is inverted and three readers used to spell that
-// inversion for themselves, so the round trip and the explicit backfill case
+// inversion for themselves, so the round trip and the explicit fallback case
// below are the contract, not decoration.
+//
+// Since slice 1.4 there are TWO places a gate can be spelled — `autoQueue[lane]
+// .held` and the retired field it replaced — which makes precedence part of
+// that contract: the key wins, an absent key asks the field, and a write never
+// touches the field. Those are the three tests after the round trip.
const LANES: PauseLane[] = [
"transcription",
@@ -46,34 +51,102 @@ test("held round-trips through withGateHeld on every lane", () => {
}
});
-test("backfill's polarity is pinned: enabled false IS held", () => {
- const settings = defaultSiteSettings();
- assert.equal(settings.backfill.enabled, false);
- assert.equal(isGateHeld(settings, "backfill"), true);
- const running = withGateHeld(settings, "backfill", false);
- assert.equal(running.backfill.enabled, true);
- assert.equal(isGateHeld(running, "backfill"), false);
+test("a lane with no `held` falls back to its retired field, inversion and all", () => {
+ // The migration input, pinned. Every settings.json written before slice 1.4
+ // spells these four and no `held`, so this is the answer the whole corpus is
+ // read with until its first write — and backfill's is INVERTED.
+ const base = defaultSiteSettings();
+ for (const lane of LANES) {
+ assert.equal(base.autoQueue[lane].held, undefined, `${lane} defaults held`);
+ }
+ const legacy: SiteSettings = {
+ ...base,
+ transcriptionsPaused: true,
+ downloadsPaused: false,
+ digest: { ...base.digest, digestsPaused: true },
+ // enabled TRUE is NOT held — the one line the whole file exists for.
+ backfill: { ...base.backfill, enabled: true },
+ };
+ assert.equal(isGateHeld(legacy, "transcription"), true);
+ assert.equal(isGateHeld(legacy, "download"), false);
+ assert.equal(isGateHeld(legacy, "digest"), true);
+ assert.equal(isGateHeld(legacy, "backfill"), false);
+});
+
+test("`held` wins over the retired field, in both directions", () => {
+ // Once the key is on disk the retired field is dead config. A reader that
+ // still consulted it would resurrect a pause an operator had lifted (or the
+ // reverse), which is precisely why every writer of those fields was rewritten
+ // in the same slice.
+ const base = defaultSiteSettings();
+ const disagreeing: SiteSettings = {
+ ...base,
+ transcriptionsPaused: true,
+ digest: { ...base.digest, digestsPaused: true },
+ backfill: { ...base.backfill, enabled: true },
+ };
+ const held = withGateHeld(
+ withGateHeld(withGateHeld(disagreeing, "transcription", false), "digest", false),
+ "backfill",
+ true,
+ );
+ assert.equal(isGateHeld(held, "transcription"), false);
+ assert.equal(isGateHeld(held, "digest"), false);
+ assert.equal(isGateHeld(held, "backfill"), true);
+ // And the retired fields are NOT rewritten to agree: withGateHeld writes the
+ // new key only.
+ assert.equal(held.transcriptionsPaused, true);
+ assert.equal(held.digest.digestsPaused, true);
+ assert.equal(held.backfill.enabled, true);
});
-test("holding the backfill lane leaves the rest of its block alone", () => {
+test("holding a lane leaves the rest of its policy alone", () => {
// The bug this forbids: a rebuilt literal instead of a spread would drop the
// lane's other settings on a pause click. It used to guard the sweep's
- // persisted scope; the scope is the lane's TREE now, in `autoQueue.backfill`,
- // which the same rule protects — `withGateHeld` must touch one field.
- const base: SiteSettings = {
- ...defaultSiteSettings(),
- backfill: {
- ...defaultSiteSettings().backfill,
- enabled: true,
- concurrency: 3,
- allowRedownload: true,
+ // persisted scope, then `backfill.concurrency`; the thing at risk is now the
+ // lane's TREE, its order and its snooze, which is strictly more to lose.
+ const base = defaultSiteSettings();
+ const authored: SiteSettings = {
+ ...base,
+ autoQueue: {
+ ...base.autoQueue,
+ backfill: {
+ ...base.autoQueue.backfill,
+ enabled: true,
+ order: "newest",
+ maxWorkers: 2,
+ snoozeUntil: null,
+ root: {
+ id: "backfill-root",
+ mode: "strict",
+ weight: 1,
+ maxWorkers: null,
+ children: [
+ {
+ id: "backfill-teamrcn",
+ match: { type: "channel", value: "teamrcn" },
+ weight: 1,
+ maxWorkers: null,
+ },
+ ],
+ },
+ },
},
+ backfill: { ...base.backfill, concurrency: 3, allowRedownload: true },
};
- const held = withGateHeld(base, "backfill", true);
- assert.equal(held.backfill.enabled, false);
- assert.equal(held.backfill.concurrency, 3);
- assert.equal(held.backfill.allowRedownload, true);
- assert.deepEqual(held.autoQueue, base.autoQueue);
+ const held = withGateHeld(authored, "backfill", true);
+ assert.equal(isGateHeld(held, "backfill"), true);
+ assert.deepEqual(
+ { ...held.autoQueue.backfill, held: undefined },
+ { ...authored.autoQueue.backfill, held: undefined },
+ );
+ // Nothing outside the lane's own policy moved — including the block that used
+ // to carry this gate.
+ assert.deepEqual(held.backfill, authored.backfill);
+ assert.deepEqual(
+ { ...held, autoQueue: null },
+ { ...authored, autoQueue: null },
+ );
});
test("isGateHeld touches only its own lane's field", () => {
diff --git a/common/lib/pauseGates.ts b/common/lib/pauseGates.ts
@@ -1,5 +1,6 @@
import type { SiteSettings } from "./settings";
import type { AutoQueueKind } from "./autoQueueTypes";
+import { legacyGateHeld } from "./laneMigration";
import { operationCatalog } from "./operations";
import {
BACKFILL_QUEUE,
@@ -9,11 +10,13 @@ import {
// A PAUSE GATE, DEFINED ONCE.
//
-// Four lanes can be held, their flags live in four settings fields, and until
-// this file three of those fields were read with three different polarities in
-// nine places. `backfill.enabled` in particular is INVERTED — held means false —
-// and every reader that forgot it reported a held lane as idle. This is the only
-// place that polarity is known.
+// Four lanes can be held. Their flag lived in four unrelated settings fields
+// read with three different polarities in nine places — `backfill.enabled` in
+// particular is INVERTED, held means false, and every reader that forgot it
+// reported a held lane as idle. This file was the one place that polarity was
+// known; since slice 1.4 the gate is ONE KEY ON THE LANE, `autoQueue[lane].held`,
+// and the four fields survive only as this file's read-time fallback (defined,
+// with the inversion, in lib/laneMigration.ts beside the other retired fields).
//
// A PAUSE IS A HOLD, NEVER A STOP. Every gate below is consulted at DISPATCH
// time by a limit() that returns 0, which makes runPool idle-wait: the job stays
@@ -21,21 +24,21 @@ import {
// Ending the job would be a different act with a different cost.
//
// THE TRANSCRIPTION ASYMMETRY, and it is the one thing to get right here.
-// `settings.transcriptionsPaused` is not "is transcription paused now" — it is
+// `autoQueue.transcription.held` is not "is transcription paused now" — it is
// "will the pool be paused after a restart". The live state is the worker pool's
// own (`getWorkerPool().isPaused()`), which editor/instrumentation.ts re-applies
-// from this flag at boot and which the pause action flips first. So:
+// from this node at boot and which the pause action flips first. So:
//
-// * the flag is read in exactly two places — the boot hook, and the action's
-// "did this change anything" check before it writes;
-// * NO UI SURFACE MAY READ THE FLAG for "is it held". Every one of them
+// * the node value is read in exactly two places — the boot hook, and the
+// action's "did this change anything" check before it writes;
+// * NO UI SURFACE MAY READ THE STORED VALUE for "is it held". Every one of them
// (the dashboard deck, /workers, /jobs, the widget, /api/pulse,
// /api/worker/health) reads the pool. The e2e harness rewrites
// test-settings.json wholesale between tests while the pool keeps its
// pausedSnapshot, so a surface reading the flag would disagree with the
// machine it is describing.
//
-// The other three lanes have no live counterpart: their flag IS the gate, read
+// The other three lanes have no live counterpart: their `held` IS the gate, read
// at dispatch, which is why they need no boot hook.
// A PAUSE LANE IS A LANE. This was a hand-written union of four names while
@@ -74,55 +77,51 @@ export function pauseLaneFor(operationId: string): PauseLane | null {
// Is this lane's gate shut?
//
-// THE ONLY PLACE POLARITY IS KNOWN: backfill's field is `enabled`, so held is
-// !enabled. Touches only the named lane's field — laneGuards.test.ts casts a
-// `{ digest }`-only object to SiteSettings, and a function that reached for
-// `settings.backfill` on the way past would throw on it.
+// ONE KEY, ON THE LANE'S OWN POLICY: `autoQueue[lane].held`. A lane already
+// owns an `enabled`, a `maxWorkers`, an `order`, a `snoozeUntil` and a tree;
+// its pause was the one per-lane switch living somewhere else, in four fields
+// with three polarities.
+//
+// THE FALLBACK IS THE MIGRATION. Every settings.json written before slice 1.4
+// carries the retired fields and no `held`, so an absent `held` — and only an
+// absent one — asks `legacyGateHeld`. `getSettings` copies that answer onto the
+// key on read, so the first write after this slice persists it and the fallback
+// goes quiet; the fields themselves are deleted in a later slice, once the live
+// file carries all four `held` keys.
+//
+// `autoQueue` is read defensively: laneGuards.test.ts casts a `{ digest }`-only
+// object to SiteSettings, and this must answer for it the way it always has.
export function isGateHeld(settings: SiteSettings, lane: PauseLane): boolean {
- switch (lane) {
- case "transcription":
- // The PERSISTED intent, not the live pool. See the header.
- return settings.transcriptionsPaused === true;
- case "download":
- return settings.downloadsPaused === true;
- case "digest":
- return settings.digest.digestsPaused === true;
- case "backfill":
- // INVERTED, and this line is why the file exists.
- return settings.backfill.enabled === false;
- }
+ const held = settings.autoQueue?.[lane]?.held;
+ if (typeof held === "boolean") return held;
+ return legacyGateHeld(settings, lane);
}
// Set a lane's gate, returning a NEW settings object. Pure — no I/O; the caller
// writes it.
//
-// The only writer AMONG THE PAUSE CONTROLS. The Speaker lane's "Run the backfill
-// lane" checkbox (operations/settingsActions.ts) deliberately writes
-// `backfill.enabled` too: one field, two places to set it, and they cannot
-// drift.
+// IT WRITES THE NEW KEY ONLY, and never the retired field it replaced. Those
+// fields are migration INPUT now: once `held` is on disk, `isGateHeld` stops
+// reading them, so a writer that also flipped `downloadsPaused` would be
+// maintaining a value nothing consults — which is how two sources of truth
+// start. Every other writer of a pause was rewritten to come through here in
+// the same slice, for the mirror-image reason: flipping only the legacy field
+// would be silently ignored.
//
-// SPREAD-AND-OVERRIDE, never a rebuilt literal: `backfill` also carries
-// `concurrency` and `allowRedownload`, and a literal here would silently reset
-// a disk-holding opt-in on a pause click.
+// SPREAD-AND-OVERRIDE, never a rebuilt literal: the policy also carries the
+// lane's TREE, its order and its snooze, and a literal here would drop an
+// operator's rules on a pause click. Same rule the block-shaped version had for
+// `backfill.concurrency`; the thing worth losing just got bigger.
export function withGateHeld(
settings: SiteSettings,
lane: PauseLane,
held: boolean,
): SiteSettings {
- switch (lane) {
- case "transcription":
- return { ...settings, transcriptionsPaused: held };
- case "download":
- return { ...settings, downloadsPaused: held };
- case "digest":
- return {
- ...settings,
- digest: { ...settings.digest, digestsPaused: held },
- };
- case "backfill":
- return {
- ...settings,
- backfill: { ...settings.backfill, enabled: !held },
- };
- }
+ return {
+ ...settings,
+ autoQueue: {
+ ...settings.autoQueue,
+ [lane]: { ...settings.autoQueue[lane], held },
+ },
+ };
}
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -20,7 +20,10 @@ import {
validateWorkers,
} from "./workers";
import type { AutoQueueSettings } from "./autoQueueTypes";
-import { migrateSweepsToLanes } from "./laneMigration";
+import {
+ migrateHeldToLanes,
+ migrateSweepsToLanes,
+} from "./laneMigration";
// The four SANITIZERS still come from the engine. They are the auto-queue's
// half of the settings schema and belong in lib/ with the rest of it, but that
// move is phase 3 slice 4 (one schema, one writer) — not a rename. Recorded in
@@ -1406,6 +1409,17 @@ export function getSettings(): SiteSettings {
merged.diarization = sanitizeDiarization(merged.diarization);
merged.backfill = sanitizeBackfill(merged.backfill);
merged.attribution = sanitizeAttribution(merged.attribution);
+ // THE PAUSE FIELDS, ON READ. `autoQueue[lane].held` is the gate since slice
+ // 1.4; the four retired fields (`transcriptionsPaused`, `downloadsPaused`,
+ // `digest.digestsPaused` and the inverted `backfill.enabled`) fill it in for
+ // any lane whose policy does not carry one, so the next write persists the
+ // key and `isGateHeld` stops consulting them. It never unholds a lane — the
+ // value copied is the one isGateHeld was already returning.
+ //
+ // AFTER the digest and backfill sanitizers above, not beside the sweep
+ // migration: two of the four fields it reads live in those blocks, and it
+ // must read them normalized. See lib/laneMigration.ts.
+ merged.autoQueue = migrateHeldToLanes(merged);
// Workers. When the file predates the worker model (no `workers` key),
// synthesize a default list from the (now-settled) active app + per-app
// configs so existing installs behave identically. Otherwise sanitize the