Archilyzer · Source

archilyzer

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

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:
Mcommon/controller/operationBatch.test.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/jobs/autoQueuePolicy.ts | 8++++++++
Mcommon/jobs/laneMigration.test.ts | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/lib/autoQueueTypes.ts | 14++++++++++++++
Mcommon/lib/laneMigration.ts | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/lib/pauseGates.test.ts | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcommon/lib/pauseGates.ts | 99+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcommon/lib/settings.ts | 16+++++++++++++++-
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