# One core — Phase 1: four lanes, one runner ## Context `plans/one-core.md` (committed on `one-core/phase-0`, seven commits, unmerged, e2e 511/511) is the umbrella. This is the slice-level plan for **Phase 1 — Dispatch: one scheduler**: finish `plans/unified-operations-model.md` steps 5 and 6 and delete what they retire, so one dispatcher owns every lane of media-derived work. Three surveys on 2026-09-07 (dispatch backend, editor surfaces, live config and job history) changed the umbrella's Phase 1 wording in one way, agreed with the operator: - **The arbiter never ran.** Zero `operations-arbiter` records among 1,305 production jobs, no `.arbiter/` state, no boot resume, no fairness memory, no owner map, and it cannot dispatch download or transcription — the two lanes carrying 100% of live dispatch (`controller/operationLane.ts:22-30` resolves registry entries only). The auto-queue runner (`controller/autoRunner.ts`) is the proven loop: persisted picks, boot resume, worker slots, platform backoff, an e2e-pinned console. - **So the runner generalizes, and the arbiter and both sweeps are deleted.** `AutoQueueKind` widens from `{transcription, download}` to the four lanes `lib/pauseGates.ts:41` already names: `+ digest`, `+ backfill`. Each lane gets a policy tree, a runner and the same console. Download and transcription dispatch is untouched. - **Pause moves to `autoQueue[lane].held`**, behind the unchanged `isGateHeld` / `withGateHeld` API, with the four legacy fields read as a migration for one release. **The model in one sentence:** the *lane* is the dispatch noun (a queue key, a tree, a runner, a pause, a console), the *operation* is the work noun (a registry entry with a state per video), and a leaf in a lane's tree names channels and, where a lane carries more than one operation, an operation. ## Facts that shape the slices - Live `settings.json`: two trees (`autoQueue.transcription`, `.download`), both roots `strict` with ten channel leaves + a catch-all; no leaf names an operation; both sweeps disarmed; `transcriptionsPaused: true`; `backfill.allowRedownload: true` with `reach: "corpus"` and empty `sweepChannels`. Arming the backfill lane as configured would re-fetch audio for ~66,540 videos (`STATE.md` runbook item iii, unresolved). - `.auto-queue/state.json` (2026-09-02): 29 download picks, 26 from the catch-all leaf; `currentWeights` empty on both lanes (strict mode never writes them). - Snapshot `backfill` map keys are identical on all 68 channels: `attribution-diarized, attribution-text, diarization, digest`; `ids.length == missing + stale` everywhere (pinned `channelSnapshot.test.ts:28`). Baseline sums: digest missing 55,956 / stale 267 / deferred 15 / blocked 1,722; diarization missing 424 / missingInput 75,591; attribution-text missing 77,888; attribution-diarized missing 1,881 / blocked 76,015. Download's list is `snapshot.undownloadedIds` (top level, playlist order; sum 9) plus `buckets.partialDownloads`; transcription's is `buckets.downloadedNoTranscript` (881) + `failedListed` (+ opt-in `downloadedAutoSubsOnly`). **There is no `buckets.undownloaded` or `buckets.untranscribed`** — the umbrella's step 5 named fields that do not exist. - `EXTERNAL_OPERATIONS` (`lib/operations.ts:1321-1377`) are descriptors only: no `state`, `run`, `inputs`, `outputs`. The four registry `Operation`s have all of them. - `backfillBatch.ts` does not import `laneGuards`; it re-implements the yield inline and keys the GPU idle-only rule on the *presence* of `laneFor` (`:796`, the FACTS.md trap). `digestBatch.ts` uses the declared guards (`laneGuards.digestGate`, keyed on `contendsFor`). - Runner console contract: `RunnerOperationView.tsx:20-35` `
`, ~1,200 e2e lines depend on it. No UI exists for `match.operation` (`LadderRung.tsx:390-497` offers channel / platform / all). - e2e cannot run in the primary checkout while the untracked `editor/content` symlink exists; run it from a worktree as Phase 0 did. ## The slices Each is one reviewable diff that deletes something, committed with its measurements, on a branch off `one-core/phase-0` (or `main` once that merges). Live numbers are checked with offline `tsx` over the real corpus, never a second editor. ### 1.1 — Four lanes in the model (no behaviour change) - `lib/autoQueueTypes.ts:138`: `AutoQueueKind = "transcription" | "download" | "digest" | "backfill"`, exported as `LANES`; `lib/pauseGates.ts:41` `PauseLane` becomes an alias of it. Grep the ~20 files spelling the two-member union (`jobs/autoQueuePolicy.ts:105-152`, `editor/app/api/auto-queue/control/route.ts:17`, `jobs/active/buildActiveJobs.ts:493`). - `AutoQueueSettings` gains `digest` and `backfill` policies. `defaultAutoQueue()` / `sanitizeAutoQueue()` (`jobs/autoQueuePolicy.ts:521,554`) default them `enabled: false`, root `strict` with one `{type:"all"}` leaf. `AutoQueueOrder` gains `"cheapest"` (digest's shortest-first, comparator supplied by the runner from `readDurationFast`, memoized like recency keys); the digest lane defaults to it so today's ordering survives. - `bucketsForKind` / `optInBucketsForKind` / `selectableBucketsForKind` (`:104-121`) return `[]` for the new lanes. New `operationsForLane(lane, settings)` in `lib/operations.ts`: enabled catalog operations whose `(op.laneFor?.(settings) ?? op.lane).queueKey` is the lane's queue (`DIGEST_LOCAL_QUEUE | DIGEST_REMOTE_QUEUE` for digest, `BACKFILL_QUEUE` for backfill), in `orderByDependencies` order — a leaf naming no operation draws all of them, the way a bucket-less leaf draws the default union. - `retainLeaves(pending, root, "buckets" | "operations")` (`:225`) loses its mode: a leaf is retained when it draws anything from the lane's projection. - `jobs/autoQueueState.ts`: state keyed by all four lanes; missing lanes coerce to empty (extend `autoQueueState.test.ts`). - Editor: `LadderRung.tsx` `LeafControls` — an *operation* select, shown only for lanes with more than one operation (backfill); the bucket select hidden for lanes with none. `/api/auto-queue/status` `buildKind` (`operations/status.ts:76-108`) iterates `LANES`. - `startAutoRunnersIfEnabled` still starts only lanes that have an executor (the two). - Update `plans/one-core.md` Phase 1 to this design in the same commit. - Tests: sanitizer defaults, `operationsForLane` per lane and per `digest.remoteEnabled`, `pauseGates.test.ts` `pauseLaneFor` unchanged. **Numbers: none move.** ### 1.2 — The runner runs operations (digest and backfill lanes live; sweeps untouched) - New `controller/operationBatch.ts` — the per-unit executor and the per-lane limit, extracted from `digestBatch.ts:152-606` and `backfillBatch.ts:255-868`: - `runOperationUnit({op, videoId, channel, settings, signal, force})`: re-derive from disk (`readVideoFiles` + `op.state()`), `candidateAction` with its `never` default (`backfillBatch.ts:192-238`, generalized; digest's inline `allFresh` becomes the `fresh` arm), `op.run()`, the per-video overrides (`reacquireMediaFor` + `cleanup()` in `finally` for backfill kinds, `laneSharesDuplicates` canonical share for digest), `applyResult` on the remote-unit path. - `laneLimit(lane, settings, live)`: digest = `digestPreflight` + `digestGate` + concurrency + `remoteTerm` (`digestBatch.ts:514-559`); backfill = share + `remoteTerm` + `unitTerm` (`backfillBatch.ts:727-845`) with the GPU yield through `laneGuards.laneYieldsToTranscription(laneForOperation(op))` — keyed on `contendsFor`. The `laneFor`-presence check at `:796` is deleted; the comment at `operations.ts:438-445` and the FACTS.md entry are rewritten to say so. - `countOperationWork(lane, channel)` replaces the `countMissingDigests` / `countBackfillWork` twins (snapshot first, walk fallback for un-snapshotted channels). - `controller/autoRunner.ts`: `run` (`:823`) branches on lane — transcription and download unchanged; digest and backfill call `runOperationUnit`. `limit` (`:594-616`) calls `laneLimit` for the new lanes. `next` (`:621`) draws `ch.operations` for them; `buildChannelWork` (`:210`) already projects `snap.backfill[op].ids` at `:249-261` — stop zeroing those leaves. Runner job progress uses the existing `digests` / `backfills` metrics. **The runner for lane X refuses to start while sweep X is armed, and says which** (`arbiterBlockedReason` logic, `arbiter.ts:194-203`, moved and renamed `laneBlockedReason`). - `runDigestChannelJob` / `runBackfillChannelJob` (the channel and video pages' manual Run) keep their job kinds and call `operationBatch` inside their own pool, so there is one executor. `digestBatch.ts` and `backfillBatch.ts` are deleted; `backfillBatch.test.ts` becomes `operationBatch.test.ts` (`candidateAction` exhaustiveness, `laneLimit` cases). - Editor: `/operations/[id]` (`page.tsx:161`) chooses the runner console off the operation's lane, not `op.runner` (which stays `undefined` for sync, per the IA doc); every operation page with a lane renders `RunnerOperationView` with `data-lane` = the lane id, beside the sweep section for the length of this slice. `useOperationsStatus` polls all four. - e2e: new `lane-runner.spec.ts` on the fixture corpus — the digest lane starts, dispatches one unit through the Ollama stub, appears as a job, holds on pause, stops, and comes back after `startAutoRunnersIfEnabled`; the backfill lane dispatches diarization then attribution-diarized in dependency order through `fake-diarize`; digest and diarization run concurrently on distinct queue keys (the `backfill.spec.ts:615` invariant, moved). - **Operator gate A, at the end of this slice:** enable `autoQueue.digest` in production with one channel leaf, let the runner drive a pass, restart the editor, confirm it resumes. Record units dispatched, the snapshot's `digest.missing` delta and, if a transcription is started beside it, the observed yield. This is the "has run in production once" precondition every retirement below stands on. ### 1.3 — Sweeps and arbiter retire - Arming becomes tree authoring: `startDigestSweepAction(channels)` and `startBackfillSweepAction(kinds, channels)` (`editor/app/jobs/actions.ts:160-251`) become one `armLaneAction(lane, {operations, channels})` that writes `autoQueue[lane].root` (a strict group of channel leaves, each carrying `operation` when scoped) and sets `enabled: true`; disarm is `enabled: false`. `NextUp` is the plan (`computeLeafPending`); the "also runs on …" line (`SweepScope.tsx:122-155`, via `workerMatches`) moves into `LaneHeader`. - Settings migration on read in `getSettings`: `digest.sweepEnabled/sweepChannels/ recencyOrder/recencyReach` and `backfill.sweepEnabled/sweepKinds/sweepChannels/order/ reach/weight` populate `autoQueue.digest` / `autoQueue.backfill` when those blocks are absent (`sweepEnabled:false` → `enabled:false`; channels → leaves; order/reach → policy order/reach; `weight` is retired — the backfill lane yields on `contendsFor` like digest, and `maxWorkers` is its share). Then the ten fields, their sanitizers, the `LaneSettingsForm.tsx:80-86` weight field and `settingsActions.ts:215` are deleted. The migration must never *enable* a lane the sweep flag did not. - Delete, backend: `controller/digestSweep.ts`, `backfillSweep.ts`, `sweepPreview.ts`, `sweepRecency.ts`, `lib/sweepPlan.ts`, `arbiter.ts`, `arbiterWork.ts`; job kinds `digest-sweep`, `backfill-sweep`, `operations-arbiter` (`jobs/jobKinds.ts` + `jobKinds.test.ts`); `instrumentation.ts:109-126` resume hooks; `sweepPreview.test.ts`, `arbiter.test.ts` (its leaf-order case already lives in `autoQueuePolicy.test.ts:729-806`). `controller/digestPlan.buildDigestSweepPlan` stays (chunk-priced ETA for `bin/digest-plan`). - Delete, editor: `SweepLane.tsx`, `SweepScope.tsx`, `SweepPlan.tsx`, `OperationDetail.tsx:117-123,157-223`, `ArbiterBar.tsx`, `OperationsBoard.tsx:52`, `operations/lanes.ts` sweep half and `ArbiterStatus` (`:74-81,229-263,315-370`), `railStates.ts:48-92`, `operations/actions.ts:264-330` (`saveLaneOrderAction`, arbiter start/stop), `laneState.ts` `feedRunning` axis + `sweepLaneNote`, `MonitorWidget` `BackfillStrip` `sweeping`, `buildActiveJobs.ts:436-450` and `api/widget/sync/route.ts:297-304` sweep flags, `stageStatus.ts:126` job-kind map entry, `[id]/page.tsx:53-63,190-215` `JOB_KINDS_BY_LANE`; `/api/test/resume-backfill-sweep` → `/api/test/resume-lane?lane=`. - e2e: `backfill.spec.ts` seven sweep tests rewritten to the lane runner (arm = enable with scope, persisted as a tree; restart via `resume-lane`); `auto-queue.spec.ts` three arbiter tests deleted; `widget.spec.ts:776,787` and `laneState.test.ts` to runner state; `operation-settings.spec.ts:104,214` to the surviving `LaneSettingsForm` fields. - The retired routes rule: every deleted action's URL and aria-label either survives on the runner console or is asserted gone in the spec that pinned it. ### 1.4 — Pause is lane state - `AutoQueuePolicy.held?: boolean`. `isGateHeld(settings, lane)` reads `settings.autoQueue[lane].held`, falling back to the legacy field (`transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused`, `!backfill.enabled`) when undefined; `withGateHeld` writes the new key only; `getSettings` copies legacy → new on read so the next write persists it. All 13 callers are unchanged. The transcription asymmetry stays: the node value is intent, `workerPool.isPaused()` is live state (`instrumentation.ts:71-76` re-applies from the new key). - After one release (or immediately once the live `settings.json` carries all four `held` keys): delete the four legacy fields, their sanitizers, `settings/actions.ts:222-223`'s spread, and rewrite `LaneSettingsForm.tsx:68-73` to write `held` through `withGateHeld` (still a deliberate second writer of the same key). - Fixtures and specs that read the legacy fields from disk: `auto-queue.spec.ts:1199-1206`, `dashboard.spec.ts:79`, `backfill.spec.ts:796,835`, `test-settings.default.json`. `pauseGates.test.ts`'s polarity test becomes "held is a hold, never a stop" at the runner: `limit()` returns 0 and `runPool` idle-waits. ### 1.5 — One work list per lane in the snapshot - `generateChannelSnapshot` (`channelSnapshot.ts:524`) writes `backfill.download` and `backfill.transcription` entries: `ids` = the union `defaultBucketsForPolicy` draws (`undownloadedIds ∪ partialDownloads`; `downloadedNoTranscript ∪ failedListed`), `missing = ids.length`, `missingInput = noTranscript` for transcription, `eligible` as the others. `EXTERNAL_OPERATIONS` carry their declared `lane`, so `laneForOperation` resolves it (`laneForOperation.test.ts:80` flips from "still has no lane" to "resolves its declared lane"); `EXTERNAL_BAND_IDS`'s special case in `buildBands.ts` goes. The `ChannelSnapshot.backfill` type is aliased `operations`; the disk key is unchanged. - `autoRunner.buildChannelWork` reads `snap.backfill[op].ids` for every lane's default draw; `bucket` leaves keep working for sub-lists (retry buckets are a real UI). - Proof: a unit test on the fixture corpora that `next()` picks the same first N units under the bucket projection and the operation projection; an offline `tsx` script over the 68 live snapshots asserting the in-memory fold of the new entries equals the bucket union (no snapshot is regenerated — that writes into `transcripts/`). ## Operator gates - **A — before 1.3 lands:** the digest lane runner has driven a production pass and resumed after a restart (end of 1.2). - **B — before the backfill lane is ever enabled in production:** decide `backfill.allowRedownload` and the channel scope. As configured today it re-fetches audio for ~66,540 `missingInput` videos, channel-major, with no inter-download sleep. The 1.3 migration keeps the lane disabled; the tree's channel leaves are the scope from then on. ## What must not be lost `unified-operations-model.md` "What must not be lost" — reachable ≠ needs-media (never summed, ~91× apart); a zero limit is a hold, never a stop (`runPool` idle-waits, `null` from `next()` ends the job); re-derive eligibility from disk on every pull; one job per channel for the manual verbs, and the runner's in-process units for the lanes; the `never` default on `candidateAction`; distinct queue keys are the only concurrency mechanism (`backfill.spec.ts:615` moves, does not vanish); derived sidecars are not disk-gated. Plus: `laneFor` presence stops being load-bearing in 1.2 and the test that pinned it changes in the same commit; the runner console's `
` contract is the surface every lane now shares; the `transcriptionsPaused` intent-vs-pool asymmetry; `undownloadedIds` is playlist order and must stay unsorted. ## Verification Per slice: `tsc --noEmit` in common, editor, export, mcp, homepage, umtool; `pnpm --filter yt-dlp-transcript-common test` (architecture test included — 1.3 removes `lib/sweepPlan.ts → controller` and `jobs/*→ controller` entries from the allow-list rather than adding any); full editor e2e from a worktree behind the queue lock, detached, watched with Monitor. Numbers do not move: an offline `tsx` script over the 68 live snapshots and `settings.json` dumps, before and after each slice, every `backfill[op]` count, `undownloadedIds.length`, the per-lane `computeLeafPending` totals, and each lane's `held`/`enabled`; the diff is empty or explained in the slice note. After 1.3, the same script asserts the migrated `autoQueue.digest` / `.backfill` blocks are `enabled: false` with the expected leaves. Run the thing: gate A's production pass (units, sidecars, restart resume); after 1.3 an arm-from-the-console on the fixture corpus produces the same tree the migration would; after 1.4 a pause from the dashboard, the widget and the runner page all flip one `held` key and the runner idles without stopping; after 1.5 `/channels` bands for download and transcription draw from the operation entries with no rendered number changed. ## 1.1, as shipped `7f294df` (this plan) → `f762944` (the numbers script) → this commit (the slice), on `one-core/phase-1` off `af2a360`. **Numbers: the before/after diff of `plans/tools/phase1-numbers.ts` over the live corpus is EMPTY.** Verification: `tsc --noEmit` clean in common, editor, export, mcp, homepage and umtool; `yt-dlp-transcript-common` 888 tests (876 before, +12 here), `yt-dlp-transcript-mcp` 205; `next build` clean; full editor e2e from a `p1-e2e` worktree of the slice commit — **511 passed, 0 failed, 24.4 min**, one worker behind the queue lock, matching Phase 0's 511/511. The worktree needed the same seed Phase 0 recorded: `export/public` holds only the tracked static assets, so `build-index` + `compose-site` over a fixture corpus turns the export webServer's 500 into a 200. **The one design change: `retainLeaves` is deleted, not de-moded.** The plan said it "loses its mode: a leaf is retained when it draws anything from the lane's projection" — and that sentence, taken literally, describes the identity function. `pending[leaf]` IS what the leaf drew, so a post-hoc filter over it can only re-apply a rule the draw already applied. So the rule moved INTO the draw: `buildChannelWork` projects only `selectableBucketsForKind(lane)` and only `operationsNamedBy(root) ∩ operationsForLane(lane)`, and a leaf naming anything else finds no list. Verified equivalent at all three old call sites — the two runner ones (`operationsForLane` is `[]` for both, so an operation leaf projects nothing, exactly what the `"buckets"` mode zeroed) and the arbiter's, where `buildArbiterChannelWork` already projects `buckets: {}` and `planArbiterUnits` already skips every leaf with no `match.operation`. Shipped as planned: `LANES` + the four-member `AutoQueueKind` (`PauseLane` an alias); `digest`/`backfill` policies defaulting `enabled:false`, root `strict`, one `{type:"all"}` leaf, with an ABSENT lane block falling back to the whole default policy rather than to `sanitizePolicy({})` — a runner lane's absent root still means the empty tree it has always meant; `AutoQueueOrder` gains `"cheapest"` with the digest lane defaulting to it; `bucketsForKind`/`optInBucketsForKind`/`selectableBucketsForKind` return `[]` for the operation lanes; `operationsForLane(lane, settings)` in `lib/operations.ts`, off the queue key and off `laneFor` before `lane`; `autoQueueState` keyed by `LANES` with missing lanes coerced to empty on read AND on write; `/api/auto-queue/status` `buildKind` iterates `LANES`; `LeafControls` gains an operation select (drawn only above one operation) and hides the bucket select for a lane with none; `startAutoRunnersIfEnabled` still names the two lanes that have an executor. Two smaller divergences, both to keep "no UI text changes for the two existing lanes" true: - **`OrderReach` gained an `orders` prop** defaulting to the three date-keyed orders. `Object.keys(ORDER_LABEL)` would have put a fourth option on every existing order select, for an order whose comparator does not exist until 1.2. The digest lane passes its own list when the runner supplies the comparator. - **`/api/auto-queue/control` still refuses a lane that is not `transcription` or `download`.** Widening it would expose a Start button for a lane the runner cannot execute. It widens in 1.2 with `run()`. **Review fix-up, the commit after `621ffa6`.** Three things the slice shipped with: `makeRecencyComparator` did not cover `"cheapest"` and fell through to the `olderFirst = -1` arm, so the digest lane's default order silently meant OLDEST-FIRST (it returns null now); `buildChannelWork` read and parsed all 68 snapshots for the two lanes whose projection is empty today (early return, deleted in 1.2 when a lane's default draw is its operation union); and nothing pinned the slice's headline invariant — an operation leaf inside a runner lane's tree draws `[]` while the bucket leaves are untouched. common 890 tests, numbers diff still empty. **Open design point 1.2 must settle first: the real digest order is duration THEN recency.** `digestBatch.ts:242-252` composes both, and the live `digest.recencyOrder` is `"newest"` — but `AutoQueuePolicy.order` is one field holding one of them. So 1.2 has to NAME the composition (the obvious reading: `cheapest` = duration ascending, ties broken by the lane's `reach`/recency order) before 1.3 migrates `recencyOrder` into the tree, or the migration silently drops half of today's ordering. **For 1.2.** `buildChannelWork`'s `operations` argument is now `laneOperationIds(kind, root)` — an INTERSECTION, so the digest lane projects nothing while its tree is the default catch-all leaf. `next()` drawing the lane's default operation union is the change that makes the lane live, and it is also where the projection cost lands: one channel's `digest.ids` is up to 11,329 strings and the status payload now folds four lanes on a three-second poll, so widen the projection in `run()`'s loop rather than in `computeLeafPending` if the two need to differ. `AutoQueueStatusPayload` is `Record & { lanes }`, so `data.digest` / `data.backfill` are already on the wire. ## 1.2, as shipped `83954f1` (one executor) → `596ddb6` (the runner runs operations) → `8c5a49d` (the console per lane) → `ddbdb30` (the e2e spec) → `1f531fb` (dead half-rules removed) → `24a2069` (three fixes to the long-lived half) → `9a16d65` (two runners, one state object — the bug the e2e spec found) → the commit after `3bdf5ee` (the review fix-up, which carries this paragraph and so cannot name its own sha), on `one-core/phase-1` off `3ced7dd`. **Numbers: the before/after diff of `plans/tools/phase1-numbers.ts` over the live corpus is EMPTY.** It stays empty because the live `settings.json` carries no `autoQueue.digest` or `.backfill` block, and the script's lane sections come from the KEYS PRESENT IN THE FILE — which is exactly why it was written that way. The two lanes are live in code and disabled on disk. **Deleted:** `controller/digestBatch.ts` (647 lines) and `controller/backfillBatch.ts` (937), replaced by `controller/operationBatch.ts`. `countMissingDigests` and `countBackfillWork` are one `countOperationWork(lane, paths, channel, opts)`. `backfillBatch.test.ts` is `operationBatch.test.ts`, and `lib/operations.test.ts -> controller/backfillBatch` is off `architecture.test.ts`'s allow-list — the test that needed it moved to the dispatch layer where it belongs. **No entry was added.** Verification: `tsc --noEmit` clean in common, editor, export, mcp, homepage and umtool; `yt-dlp-transcript-common` **896** tests (890 before, +6 `laneLimit` cases; the moved `candidateAction` test is a relocation, not an addition), `yt-dlp-transcript-mcp` **205**; `next build` clean; full editor e2e from a `p12-e2e` worktree of `9a16d65` — **515 passed, 0 failed, 23.0 min**, one worker behind the queue lock. 511 before, +4 from `lane-runner.spec.ts`. ### What it measures **The status poll**, over the live corpus, `plans/tools/phase1-poll-timing.ts` (new; `ROUNDS=` and `NOMEMO=1`). It folds `computeLeafPending` for all four lanes exactly as `buildAutoQueueStatusPayload` does: | | warm round | what the two new lanes fold | |---|---|---| | before (1.1) | 86–102 ms | nothing — both projections were empty | | after, no memo | 386–413 ms | digest 56,223 · backfill 80,193 pending ids | | after, with memo | **256 ms** | same | So the lanes going live costs ~150 ms a poll and the `(mtime, size)` parse memo in `readChannelSnapshotShared` gives ~130–155 ms of that back. The first pass of a process is 4.8 s: that is the duration index filling for the `cheapest` order, 55,956 head-reads at concurrency 64, and a duration never changes, so it is paid once per process and never again. ### Decisions the plan asked for **The `cheapest` composition, preserved exactly.** `digestBatch.ts:242-261` sorted by DURATION and then sorted AGAIN, stably, by upload date. Array#sort is stable and the recency comparator returns 0 for two videos sharing a YYYYMMDD key, so the live meaning is **date primary, duration as the tiebreak within a day** — "newest day first, shortest video within a day" — which is the opposite of the order the two sort() calls are written in. That is now one comparator, `cheapestComparator`, and the date half's DIRECTION is a parameter, today `settings.digest.recencyOrder` (live value `newest`). Unknown durations sort LAST in either direction: the batch dropped them from candidacy entirely, and the runner cannot (its candidates are the snapshot's reachable ids), so it puts what it cannot price behind what it can rather than letting `null` sort first. **1.3 fixes the direction at newest-first** when `recencyOrder` retires into the tree; a digest lane wanting pure recency then sets `order: newest` and gets no duration term at all. `cheapest` on a lane other than digest is accepted by the sanitizer (one enum, one sanitizer) and **falls back to LISTED** — `recencyOrdering` returns a null comparator, which everywhere else in the runner means "do not sort". Not id order and not a date order nobody asked for: both would be a real reordering dressed up as a fallback. The console does not offer it there either (`LANE_ORDERS`). **The status poll memo** is above. It is deliberately NOT on `readChannelSnapshot` itself: that one is called by the snapshot GENERATOR and by half a dozen editor render paths, and handing out a shared parsed object is a claim about read-only-ness that code has never had to honour. ### What review found afterwards, and `24a2069` fixed Three things that only bite a runner, which is the half of this slice with no precedent — every previous caller of this machinery was a job that ended. - **The metered spend cap was resettable by a clock.** `openOperationRun` starts a context with `costUsd: 0`, and the runner rebuilds its context on a sixty-second TTL, so a `$5` per-run cap would have become `$5 a minute` with the log still printing a cap it was no longer enforcing. The accumulator and the disk-floor latch survive the refresh now. - **A refresh could swap the context out from under an in-flight unit**, whose llm and unit-executor leases decrement counters on the object it was dispatched with — a permanently leaked slot from `laneLimit`'s point of view. The refresh waits for the lane to be idle. - **A dead engine was re-probed every three seconds**, because the TTL covered a successful open and not a failed one. Plus two smaller ones: the batch's resume line said "Transcription finished" whatever the hold had been, so lifting an operator pause reported something that never happened; and `countOperationWork` resolved each operation's freshness target inside the per-video loop rather than once per operation. ### The bug the e2e spec found, and `9a16d65` fixed **Every runner held its own copy of `.auto-queue/state.json`, and `writeAutoQueueState` serializes the WHOLE file.** So each persist wrote back that copy's idea of the other three lanes: whichever runner dispatched most recently erased the others' pick log and fairness memory. It is a pre-existing defect that only this slice could expose — auto-transcribe persists minutes apart and auto-download rarely faster, so with one slow lane it is invisible. With two fast lanes it is immediate: the digest lane wrote a pick and the backfill runner overwrote it a few hundred milliseconds later, and `/api/auto-queue/status` reads the FILE, so the console showed a lane with zero picks and `no-pending` while its `ai-digest.json` files were appearing on disk. The runners share one object now, on the auto-runner singleton — whose lifetime is already right, since the e2e harness clears it between specs. `computeLeafPending` and the download lane's cooldown merge still read the file on purpose: the first wants a value it can clone without touching live fairness, the second exists precisely to pick up what a manual sync wrote from outside the runner. This is what the concurrency spec was for, and it is worth saying how it was nearly missed: the first version of that spec sampled for "both lanes in flight at one instant", which is the same property measured by a coin toss — it failed on a loaded machine and passed in isolation, which reads exactly like flake. The version that shipped compares the two timestamped pick logs AFTER the work, and it failed the same way in isolation, which is what turned "flaky test" into "empty pick log for a lane that is demonstrably working". ### The review fix-up, the commit after `3bdf5ee` Three things review found after the e2e run, one of them a hole in the fix above. **`refreshLaneRun` copied two fields of the ledger, not the object.** Its idle check runs BEFORE the await that opens the new run, so `limit()` keeps reading the old run while the new one is being built and `next()` can dispatch one more unit against it — reachable on a `maxWorkers: 1` digest lane whenever a unit finishes with the TTL lapsed. That unit's `llmActive--` and its own `costUsd` then land on a run `laneLimit` no longer reads: a lease under-count that never recovers, and a unit's spend dropped from the cap. The whole `live` object is carried across now (`opened.live = laneRun.run.live`) — every field on it is per-run, and a runner's run is its lifetime — with a unit test that dispatches on the old run, settles after the swap, and asserts the limit reading the new run sees both the released lease and the spend. Also: `writeAutoQueueState`'s tmp path was `${file}.tmp-${pid}`, one per PROCESS, and `persist()` is fire-and-forget — so two lanes persisting within a few milliseconds could rename over each other's half-written file, leaving a `state.json` that reads as empty (tolerated) and loses a persisted platform cooldown across a restart (not). Pre-existing; two fast lanes made it reachable. It carries a per-write counter now. And `resetChannelSnapshotMemo` is wired into `/api/test/invalidate-cache` beside the other process-wide caches rather than left exported and uncalled: the memo is self-invalidating on (mtime, size) in production, but `resetData()` rewrites the same fixture paths and two specs writing an identical snapshot inside one filesystem tick would share a parse. ### Divergences from the 1.2 bullets, and why - **One batch function, not two.** The plan kept `runDigestBatch` / `runBackfillBatch` and had them "drive operationBatch". They are one `runOperationBatch({lane, …})` with one `OperationBatchResult`, because the two result types were the drift: the card's summary printed `deferred` and `blocked`, the sweep's printed `skipped`, and neither printed the other's. `runDigestChannelJob` / `runBackfillChannelJob` keep their job kinds, their queue keys and their own summary lines, which is what the bullet was protecting. - **`runOperationUnit(run, unit, opts)` takes a prepared run context**, not `{op, videoId, channel, settings, signal, force}`. The engine, the duplicate-cluster plan and the fan-out envelope are per-RUN state — resolving them per video is precisely how a counter and a runner end up disagreeing about what is stale — so `openOperationRun` resolves them once and both callers (the batch, and the runner's loop) hold one. - **`laneLimit(settings, live)`**, with the lane on a tagged `live` union rather than as a separate first argument, so TypeScript narrows the shape instead of the function casting. - **The digest unit calls `digestVideo`, not `op.run()`.** `OperationRunOutcome` is five strings and digest's registry entry discards `engineCalls`, `costUsd` and `warningCount`. Routing it through `run()` would have silently disabled the SPEND CAP — `laneLimit` reads a per-run cost that would then never leave zero — and emptied the metered accounting line. It is one place, named in a comment, and the fix is to widen `OperationRunOutcome` to carry a cost, not to grow a second executor. - **`buildPendingByLeaf` gained `defaultOperations`.** The plan said a digest/backfill leaf naming no operation draws `operationsForLane(lane)`; the pure function had no way to express that, since `defaultBuckets` only reaches `ch.buckets`. It is the operation half of the same parameter and is `[]` for the two bucket lanes, so every tree written before operations existed is byte-identical. - **A pick is a VIDEO; the operation is chosen at dispatch.** `pending[leaf]` is a list of ids and a union-drawing leaf holds a video that may need diarization OR attribution, so `run()` asks the lane's operations in dependency order and runs the first with work. That forced the session's completed-set to be keyed by **(operation, video)**: retiring the whole video after one unit would break the dependency chain the lane exists to walk. A video leaves a leaf's list only once every operation THAT LEAF draws is done for it. - **The sweep panel's `data-lane` became `data-sweep-lane`.** The plan has the runner console render "beside the existing sweep section"; both carrying `data-lane` on one page fails every scoped lookup under Playwright's strict mode. `
` is the contract all four lanes share, so the attribute stayed with the runner and the panel being retired moved. Eight selectors across `auto-queue`, `backfill` and `attribution` specs follow it, and all eight are sweep-panel assertions that retire in 1.3. - **`/api/test/resume-lane?lane=` exists now**, not in 1.3. The plan called it "the existing resume test route"; there wasn't one for a runner — `/api/test/resume-backfill-sweep` resumes the SWEEP. This is the same shape, and 1.3 deletes that one rather than renaming it. - **Three new idle reasons**: `lane-blocked` (the lane's sweep is armed), `lane-held` (its own gate is shut — distinct from `downloads-paused`, which names one flag, and from `capped`, which is contention), `engine-unreachable`. The runner's engine preflight is an IDLE, not a throw: an unreachable ollama is a configuration problem an operator fixes without restarting anything, and it is re-probed on the run context's 60 s TTL. - **Two new job kinds**, `auto-digest` and `auto-backfill`, registered and pinned in `jobKinds.test.ts`. There is deliberately no `auto-digest-unit` to match `auto-download-unit`: a runner-dispatched digest runs IN-PROCESS and makes no job record, which is what lets a lane work per video where the manual verbs must work per channel. - **The concurrency spec measures overlapping pick-log INTERVALS**, not simultaneous in-flight counts. See the bug note above for why. - **`backfillLaneLimit` returns 0 for an empty operations list** where the old `Math.min(...kinds.map(...))` returned `Infinity`. Unreachable before (the batch returned early on no kinds) and unreachable now (the batch still does), but the limit is a shared function called by a runner too, and an unbounded limit for a lane with nothing to dispatch is not a defensible answer to have sitting there. Named because it is a silent behaviour fix, not a rewrite. - **`runOperationBatch`'s digest path uses `run.operations[0]`.** Correct while the digest lane carries exactly one operation, which its queue keys guarantee today; a second digest-queue operation would need the candidate list to carry its operation the way the backfill path's `flatMap` does. - **`backfill.spec.ts`'s concurrency test is kept as well as re-asserted.** The plan says the `backfill.spec.ts:615` invariant "moves, does not vanish". At the runner the mechanism is different — two loops on queueKey `""` dispatching in-process units, no queue key involved — so `lane-runner.spec.ts` proves the property there, and the original still covers the per-channel jobs, which still exist and still need distinct keys. 1.3 must keep at least one. - **One real behaviour change, and it is a convergence.** The digest lane's eligibility now goes through the registry's `state()` instead of an inline `allFresh`, so a video whose `transcript.cues.json` is superseded classifies `deferred` and is no longer dispatched-and-then-skipped by the engine, and `countOperationWork("digest")` counts the reachable set where `countMissingDigests` counted "not fresh". Corpus-wide `digest.deferred` is **15**, so a per-channel progress target moves by at most that. The registry entry's own header asked for exactly this: "If this entry and the lane's executor ever disagree about whether a video is digested, that is a bug." - **`plans/tools/phase1-poll-timing.ts`** is new, and `phase1-numbers.ts` was NOT extended: 1.2 moves no lane-visible number, for the reason at the top. ### The `laneFor` trap is closed The GPU idle-only rule keyed off `k.laneFor &&` — the field EXISTING — as a proxy for "this one's resource depends on settings". It asks the DECLARATION now: `laneYieldsToTranscription(laneForOperation(op.id) ?? op.lane)`, on `contendsFor`. Identical on today's registry (no BACKFILL_QUEUE operation declares a static GPU lane), and an operation with a fixed GPU lane and no `laneFor` is now caught rather than missed. `operationBatch.test.ts` ("the GPU carve-out is keyed on contendsFor, not on laneFor existing") pins it, and the `Operation.laneFor` comment and the `plans/FACTS.md` entry both say the presence is no longer load-bearing. ### Operator gate A — the production pass **Do this before 1.3 lands.** It is the "has run in production once" precondition every retirement in 1.3 stands on. ~20 minutes of attention, most of it waiting. Preconditions to check first, all read-only: 1. `settings.json` (repo root) has **no `autoQueue.digest` key** and `digest.sweepEnabled: false`. If the sweep is armed the runner refuses to start and says so — that is the intended answer, not a bug. 2. `digest.digestsPaused` is `false` and `digest.localAppId` is `ollama-direct` with `ollama serve` up. A dead engine idles the runner at `engine-unreachable` rather than failing 55,956 times. 3. `transcription` is where the GPU contention lives: `digest.yieldToTranscription` is on, so the lane stands aside while whisper works. Leave it on. **The edit.** Add ONE block to `settings.json` — everything else untouched: ```json "autoQueue": { "transcription": { …unchanged… }, "download": { …unchanged… }, "digest": { "enabled": true, "maxWorkers": 1, "order": "cheapest", "root": { "id": "digest-root", "mode": "strict", "children": [ { "id": "digest-obviousrises", "match": { "type": "channel", "value": "ObviousRises-rumble" }, "weight": 1, "maxWorkers": null } ] } } } ``` `ObviousRises-rumble` is chosen because it held **6** reachable digests and 0 blocked at the last snapshot — enough to watch several units, small enough to finish. Any channel works; note its `backfill.digest` counts before you start. **Do NOT add an `autoQueue.backfill` block:** `backfill.allowRedownload` is `true` with `reach: "corpus"` on this settings file, and that is gate B's decision, not gate A's. **Then, on `/operations/digest`:** 1. Restart the editor (or POST `{"kind":"digest","action":"start"}` to `/api/auto-queue/control`). The **Auto-digest** section — the one with `data-lane="digest"` — should read *Runner running* with a job-log link. 2. Watch the claim ladder's rule count fall and *Recent picks* fill. The pending figure beside it is this lane's own; it is not the sweep panel's. 3. Let it run a pass. Record: **units dispatched** (Recent picks, or the job log's `Auto-digest: /` lines), the channel's `backfill.digest.missing` **before and after** (read `snapshot.json`; the runner requests a regen after each unit), and any `Idle —` line and its reason. 4. If a transcription starts beside it, record the **observed yield**: the runner's idle reason goes to `capped` and the job log carries the `Yielding the GPU to transcription …` line ONCE per contention window (it is edge-triggered on purpose). Note whether digest throughput actually stopped. 5. **Restart the editor.** With `autoQueue.digest.enabled` still true, the boot hook must bring it back by itself — `startAutoRunnersIfEnabled` iterates LANES now. Confirm *Runner running* without touching a button, and that Recent picks continues rather than starting over. 6. Hold and resume once from the page's **Hold the lane** button: the runner must stay *running* with `Idle — the lane is held`, not stop. That is the invariant the whole design rests on and it costs ten seconds to see. 7. When you are done, set `"enabled": false` in the block (or leave it running — it is the intended end state). Record the numbers in this file under a `Gate A` heading. If step 5 or step 6 fails, 1.3 does not land. ### Traps for the 1.3 implementer - **`data-sweep-lane` goes with the panel.** Deleting `SweepOperationView` deletes the attribute; the eight specs pointing at it are all sweep tests and go with them. Do not "restore" `data-lane` to anything — the runner section owns it on all four lanes now. - **`laneBlockedReason` is the only thing left in `lib/pauseGates.ts` that reads a sweep flag.** It goes when the sweeps do, along with the `lane-blocked` idle reason and its two copies of the wording (`dispatch.ts`, `buildActiveJobs.ts`). `arbiter.ts`'s `arbiterBlockedReason` is now a two-line delegate to it and dies with the arbiter. - **`buildActiveJobs.buildLanes` still draws the digest and backfill rows as SWEEP lanes** (`sweepLane({kind: "digest-sweep" …})`) while the runner rows above them are named. 1.3's job is to make all four rows runner rows; the `getAutoRunnerStatus(lane)` they need already answers for four. - **`settings.digest.recencyOrder` is read in two places now**: the batch's own ordering and `recencyOrdering`'s `cheapest` composition in `autoRunner.ts`. The migration must carry BOTH halves of the digest order (duration and date) or it silently drops one — see the composition above. - **`.auto-queue/state.json` is written whole by whoever persists.** The runners share one in-memory object (`sharedAutoQueueState`) so that is safe; anything else that read-modify-writes it — `jobs/downloadBackoff.ts` does — can still lose picks recorded between its read and its write. If 1.3 adds another writer, give it the shared object rather than a fresh read. - **`OperationRun.settings` is a snapshot for IDENTITY only.** Guards re-read live settings at dispatch (`laneLimit` takes `getSettings()` from its caller). The runner rebuilds the whole context when the digest/backfill/diarization/ attribution blocks change or on a 60 s TTL; the duplicate-cluster plan is carried across those rebuilds on purpose. - **`countOperationWork` opens a run with `useClusters: false`.** Counting must not read the corpus-wide duplicates report, and a mirror is `present` or `missing` on its own merits either way — which is what the snapshot counts. ## 1.3, as shipped `253826b` (the migration, before anything is deleted) → `b172d5a` (the backend retirement) → `ca83051` (the editor half) → `65c0a49` (the e2e rewrites) → `ca28fb5` (the numbers script and the prose) → `43b2f9f` and `cdaccc4` (two fixes, below) → the commit after them, which carries this note and so cannot name its own sha. On `one-core/phase-1` off `0438a72`. **Gate A is run from `one-core/phase-1-gate-a` (which points at `0438a72`) and it gates the MERGE of this slice, not its authorship.** Everything below stands on "the digest lane's runner has driven a production pass and resumed after a restart"; the branch exists so the operator can do that against the code as it was when 1.2 finished, while 1.3 was written. Verification: `tsc --noEmit` clean in common, editor, export, mcp, homepage and umtool; `yt-dlp-transcript-common` **888** tests (897 before: **+8** `laneMigration`, **-17** — seven `sweepPreview`, seven `arbiter`, three `backfillLimit`/`sanitizeBackfill` weight cases), `yt-dlp-transcript-mcp` **205**; `next build` clean; full editor e2e from a `p13-e2e` worktree of the final commit — **511 passed, 1 failed, 31.2 min**, one worker behind the queue lock. 515 at 1.2, minus the three arbiter tests, is **512**, which is the total run: the three rewritten `backfill.spec.ts` tests and the rewritten Reach test are one-for-one replacements, so no other count moved. **The one failure is not this slice's**, and it is worth saying how that was established rather than asserted: `video-page.spec.ts:216 "Delete directory wrong-id confirmation shows an error"` asserts a video DIRECTORY still exists after a refused delete. Nothing in 1.3 deletes a video directory, the file is untouched by the slice, and re-running that spec alone on the same commit passes **20/20**. It is an ordering or reset flake, reported and not fixed. ### Deleted | File | Lines | |---|---| | `common/controller/backfillSweep.ts` | 519 | | `common/controller/arbiter.ts` | 418 | | `common/controller/digestSweep.ts` | 368 | | `common/controller/sweepPreview.test.ts` | 321 | | `common/controller/sweepPreview.ts` | 167 | | `common/lib/sweepPlan.ts` | 163 | | `common/controller/arbiter.test.ts` | 177 | | `common/controller/sweepRecency.ts` | 141 (git says `Bin` — the NUL byte) | | `common/controller/arbiterWork.ts` | 67 | | `editor/app/operations/components/SweepLane.tsx` | 422 | | `editor/app/operations/components/SweepPlan.tsx` | 264 | | `editor/app/operations/components/SweepScope.tsx` | 154 | | `editor/app/operations/components/ArbiterBar.tsx` | 113 | | `editor/app/api/test/resume-backfill-sweep/route.ts` | 35 | Plus, in place: the `backfill-sweep` and `operations-arbiter` job kinds, both `instrumentation.ts` resume hooks, `laneBlockedReason` (the last reader of a sweep flag) with the `lane-blocked` idle reason and its two copies of the wording, `saveLaneOrderAction`, `startArbiterAction` / `stopArbiterAction`, the four sweep server actions, `sweepLaneIdFor`, `SweepLaneStatus`, `ArbiterStatus`, `laneState.ts`'s `feedRunning` axis and `sweepLaneNote`, the `backfill-sweep` entry in `stageStatus`, and the `LaneSettingsForm` "Resource share" field. `operations/lanes.ts` goes 370 → 131. **Net across the slice: 1,448 insertions, 4,939 deletions over 71 files.** `controller/digestPlan.buildDigestSweepPlan` STAYS — `bin/digest-plan` prices the backlog with it — and needed nothing kept beside it: it imports `planOrder`, which `digestPlan` already owned. **The architecture allow-list shrank by ONE**, not three. `lib/sweepPlan.ts -> controller/planOrder` died with the file. The plan expected `jobs/* -> controller` to go too, but those two entries are `jobs/snapshotScheduler.ts -> controller/channelSnapshot` and `jobs/workerPool.ts -> controller/remoteCapacity` — a snapshot builder and a capacity probe, unrelated to the sweeps and still real back-edges. The guard fails on a stale entry, which is how that was checked rather than assumed. **No entry was added.** ### The migration `getSettings` runs `migrateSweepsToLanes(parsed)` before `sanitizeAutoQueue`, on the PARSED FILE rather than the merged object — "absent from the file" is the trigger, and a merged object has already had the defaults folded in and can no longer tell absent from default. | Retired field | Becomes | |---|---| | `digest.sweepEnabled` | `autoQueue.digest.enabled` | | `digest.sweepChannels` | `autoQueue.digest.root` — a strict group of channel leaves; empty ⇒ one `{type:"all"}` leaf | | `digest.recencyOrder` | **nothing** — see below | | `digest.recencyReach` | **nothing** — see below | | `backfill.sweepEnabled` | `autoQueue.backfill.enabled` | | `backfill.sweepChannels` | `autoQueue.backfill.root` channel leaves | | `backfill.sweepKinds` | `match.operation` on those leaves; empty ⇒ leaves naming none, which draw the lane's whole union | | `backfill.order` | `autoQueue.backfill.order` | | `backfill.reach` | **nothing** — see below | | `backfill.weight` | **nothing** — the yield is `contendsFor`, the share is `concurrency` + the lane's `maxWorkers` | **`digest.recencyOrder` is not migrated onto `order`, and this is the decision the 1.2 note asked 1.3 to take.** The digest order is a COMPOSITION — shortest inside a day, newest day first — which the lane already spells `cheapest`; `recencyOrder` was only its date half. Migrating it would have replaced the composition with one of its two terms. The date half is fixed at newest-first (the live value) in `recencyOrdering`, and a digest lane wanting pure recency sets `order: "newest"` and gets no duration term at all. **Neither `reach` is migrated, and `AutoQueuePolicy` gains no `reach` field.** The plan's bullet says `recencyReach → reach`, and that sentence predates what 1.1 actually shipped: `OrderReach.tsx` renders `reach={null}` for a runner lane and says why in a comment — a rule already orders every video it claims across every channel and bucket, so there is no second axis, and which RULE goes first is the tree. Adding a stored `reach` nothing reads would be config with no reader, contradicting a decision documented in three places. Behaviour-neutral either way: nothing reads it once the sweeps are gone. Written as a pure function, `common/lib/laneMigration.ts`, with `common/jobs/laneMigration.test.ts` (in `jobs/` because `architecture.test.ts` forbids `lib/` importing `jobs/`, and the migration has to be asserted THROUGH `sanitizeAutoQueue` — it deliberately emits raw nodes for the sanitizer to normalize rather than being a second implementation of it). Eight cases: the live shape, an armed digest sweep with three channels, an armed backfill sweep with two kinds and one channel, an operation scope with no channel scope, a file that already carries the blocks (untouched, and idempotent over its own output), an empty file, blank/duplicate scope entries, and the arm-equals-migration check. **The arm-equals-migration check is a unit test, and it holds by construction.** `armLaneAction` and `migrateSweepsToLanes` both call `laneRootFromScope`, so "arming the digest lane on channels X" and "migrating `sweepChannels: X`" are the same function; the test asserts it so that a second leaf builder added later fails there rather than in production. It is also visible in the numbers diff: the migrated leaf ids are `digest-all` and `backfill-all`, which only that builder produces. `backfill.spec.ts`'s rewritten scope test asserts the other half from the browser — a rule authored on the console persists as `match.operation` on the lane's root. ### Numbers Before/after `plans/tools/phase1-numbers.ts` over the live corpus: **0 lines removed, 18 added.** Every pre-existing line is byte-identical and in place, which is why the file's own lanes are still printed first and the migrated ones appended. ``` > download leaves = [{"type":"channel","value":"quartering-live"}, … ,{"type":"all"}] > transcription leaves = [{"type":"channel","value":"quartering-live"}, … ,{"type":"all"}] > digest enabled = false > digest order = cheapest > digest maxWorkers = null > digest replaceAutoSubs = false > digest leaves = [{"type":"all"}] > backfill enabled = false > backfill order = newest > backfill maxWorkers = null > backfill replaceAutoSubs = false > backfill leaves = [{"type":"all"}] > digest leaf digest-all = 56223 > digest TOTAL = 56223 > digest nextUp = digest-all/lv474Pl-H_k > backfill leaf backfill-all = 80193 > backfill TOTAL = 80193 > backfill nextUp = backfill-all/qXow_UvcHkI ``` `enabled: false` on both, one catch-all leaf apiece — exactly what the live file's disarmed, unscoped sweeps meant. The two totals match 1.2's poll-timing measurement (56,223 / 80,193) to the video. The two `leaves` lines on the runner lanes are the one addition outside the migrated sections: leaf shapes are printed for every lane rather than only the new two, because a section only some lanes get is the kind of asymmetry that rots. One new guard in the script: diagnostics from `common/` are dropped. Pricing the digest lane makes the recency index log a `[recency] dating N of M undated candidates` line whose numbers move between runs on the same corpus, and one nondeterministic line in a file whose whole purpose is byte-for-byte comparison would make every later slice's evidence unreadable. ### Divergences from the 1.3 bullets, and why - **`reach` is retired, not migrated.** Above. - **`digest.recencyOrder` is retired, not migrated.** Above — and this one the plan already asked for. - **The allow-list shrank by one, not three.** Above. - **`backfill.weight` retiring is a real behaviour change, and it is named.** One 0..1 scalar answered two questions: "does this run contend with transcription" and "how much of the machine may it have". Its default of 0 meant idle-only for the WHOLE lane, so a `contendsFor: "network"` attribution run parked itself behind a GPU transcription it was not competing with. `backfillLimit` takes `idleOnly` now, keyed on the operation's declared lane exactly as 1.2's carve-out already was. A diarization run is unaffected on a GPU backend; an attribution or a CPU diarization run no longer stands aside. The lane is disarmed on this corpus, so the live path this changes is the per-channel **manual** Run. - **`laneState.ts` keeps four states; `sweepLaneNote` is what actually died.** The plan says the `feedRunning` axis and the note both go. The axis did go — every lane is a runner lane, so "a corpus sweep is armed" is not a state anyone has — but `deriveLaneState` still has four values, because `holding` (gate shut, runner up) is the state with no other name and deleting it was never the point. - **`JOB_KINDS_BY_LANE` is rekeyed, not deleted.** It is the MANUAL per-channel jobs — `digest-channel-local`, `backfill-channel`, `diarize-channel` — and without them a hand-started digest is invisible on the digest page. The two sweep orchestrator kinds are gone from it and it is keyed by `AutoQueueKind` with `[]` for the two bucket lanes. - **`armLaneAction` takes an OPTIONAL scope.** Given one it writes the root; absent it keeps the stored tree. The dashboard and widget cards call it with `{}` — the whole corpus, which is what "Sweep every channel" meant — and the scope is authored on the lane's console. That keeps the invariant "the runner console is the only surface an arm can be SCOPED from" while not deleting the one-click corpus pass the two cards have always had. - **`disarmLaneAction` drains rather than stops.** The sweep's stop drained — the video in flight finishes — and that is worth more than symmetry with the console's Stop button, which is a different control with a different promise. - **The dashboard's arm control is renamed, not kept.** `start digest sweep` / `stop digest sweep` / `start backfill sweep` / `stop backfill sweep` become `arm|disarm digest|backfill lane`, and the labels become "Run every channel" / "Stop the lane". The retired-routes rule is honoured by asserting the old names gone in the specs that pinned them (`backfill.spec.ts`, `auto-queue.spec.ts`). - **The widget sync payload's `sweeping` becomes `armed`** (`autoQueue[lane]. enabled`) rather than being deleted: the deck's control needs to know which of its two states to draw, and the question is the same one asked of the thing that now answers it. A pinned widget tab that predates this reads `undefined ?? false` — not armed — until it is reloaded, exactly as the `held` rename did. - **`startAutoRunnerBlockedReason` survives with one reason.** It had two (a disabled policy, an armed sweep); it now answers "this lane's policy is switched off". Deleting it would have made the console's Start a silent no-op on a disabled lane, which is the failure the function was written for. - **`buildActiveJobs.buildLanes` takes no argument now.** It counted in-flight work on the sweep lanes by scanning the job rows for their queue keys; four runner rows read `getAutoRunnerStatus(lane).inFlight`, which is in-memory. **THIS CHANGES RENDERED TEXT ON A LIVE LANE, and the plan did not ask for it.** The four rows now fold `gateHeld` through `deriveLaneState`, so a held transcription or download row on the /jobs strip reads **Holding** where it read *Idle* — and the live `settings.json` has `transcriptionsPaused: true`, so this is what the operator will actually see. It is kept rather than reverted because the strip and the operations rail (which has derived it that way since slice 7) disagreed about the same lane until now, and one fold cannot answer twice. Said plainly here rather than filed as a no-op, because "no rendered number moved" is a claim this slice otherwise makes and this is the exception. - **The sweep panel's surviving prose moved rather than died.** The band, the "this operation is switched off" warning and the "shares one lane, one runner and one pause with …" sentence are a `LaneOperationContext` **div** above the console — a nested `
` would break every `locator("section", { has })` in the suite. The "also runs on …" line is in `LaneHeader`, per LANE: the slots belong to the machine, so two operations served by one endpoint are one row. - **`e2e/backfill.spec.ts` had three sweep tests, not seven.** The other four sweep-shaped assertions the bullet counted are `data-sweep-lane` selectors inside tests about other things (the diarization page's populations, the two pause tests, `attribution.spec.ts`'s per-operation bands); those were rehomed onto the runner section and the rail's row rather than rewritten. - **`operation-settings.spec.ts` needed no change.** Its two lane-form tests use "Run the backfill lane" and "Save lane settings", both of which survive; only the "Resource share" field went. ### What the specs pin now, where they pinned a sweep - **`the lane arms from the dashboard and disarms without losing its rules`** — the sweep pair pinned "the flag is persisted without the scope" and "stopping leaves the scope behind". Both are unreachable now (the scope IS the tree, in the same write as the switch), so the test pins the other half of that bargain: disarming must NOT clear the tree. - **`a lane can be scoped to one operation from its console, and the scope is a tree`** — drives the ladder's `rule operation` select and asserts `match.operation` on the persisted root. - **`an armed lane comes back after a restart`** — `/api/test/resume-lane?lane= backfill`, which 1.2 added for exactly this. - **`the digest lane offers Shortest first, and has no Reach axis`** — replaces the Reach test. Reach asserted GONE, `cheapest` asserted present on the one lane that can price its work, and the order asserted to land on `autoQueue.digest.order` with `digest.recencyOrder`/`recencyReach` gone from the file. - **`section[data-sweep-lane]` is asserted to have COUNT 0** on both an operation page and the digest page — not merely unused. - **The three arbiter tests are deleted.** Its leaf-order case already lives in `autoQueuePolicy.test.ts`. - **`backfill.spec.ts`'s concurrency test survives**, as the plan requires: it covers the per-channel jobs, which still exist and still need distinct queue keys. `lane-runner.spec.ts` test 4 covers the property at the runner. ### What review found before the e2e run **`LaneOperationContext` was drawn on every page with a runner** (`43b2f9f`). It is about THIS OPERATION — its band, its "switched off" warning, the sentence naming the operations it shares a queue with — and on `/operations/transcription` the lane's operation list is `[]` by construction, so `switchedOn` was false and the page claimed auto-transcribe was switched off in settings that do not exist. It is gated on `bucketsByKind[lane].length === 0` now: exactly the two lanes whose work list is an operation's ids rather than a snapshot bucket. Worth naming because it is the shape of mistake this slice invites — a block that was written for two lanes, moved onto a component that serves four. **The dashboard's arm button was discarding the lane's rules** (`cdaccc4`). `armLaneAction(lane, {})` is not the same call as `armLaneAction(lane)` — an empty scope object is still a scope, so the deck wrote a catch-all root over whatever tree an operator had authored on the console. Three channel rules, one click, gone, and disarming would not bring them back. The button it replaces did the opposite: `startDigestSweepAction()` with no argument INHERITED `sweepChannels` off disk. It passes no scope now, and the label follows the truth — "Run the lane", not "Run every channel", because on a narrowed lane every channel is not what it would do. **`git add -A editor` staged the operator's `editor/content` symlink** into the e2e commit, which put it in the worktree, which panicked Turbopack before the first spec — the exact failure `plans/FACTS.md` documents for the primary checkout, arriving by a route that entry did not cover. The two commits were rewritten to drop it and FACTS.md now says so. Add by path in this repo. ### The fix-up, after review The commit carrying this section, and so unable to name its own sha — the same knot the 1.2 note hit. Four things, none of them behaviour on a live lane: - **The arbiter's buttons are asserted GONE.** `ArbiterBar` carried `aria-label="Start the arbiter"` / `"Stop the arbiter"` and its three tests were deleted with it, so `grep -rn -i arbiter editor/e2e/` was EMPTY — nothing would have noticed a live Start button over a dispatcher that never ran a unit coming back. Same hole on the DISARM halves: only `start … sweep` was pinned gone, so `stop digest sweep`, `stop backfill sweep` and *Stop sweeping* could have survived over actions that no longer exist. Both halves, both surfaces, now `toHaveCount(0)`. - **The full-scope collapse moved into `laneRootFromScope`.** `armLaneAction` collapsed a scope naming every operation to `[]` (so the leaf tracks the registry) and the migration did not, which is two answers to one question through one builder. It is one rule now, keyed on a new optional `available` — passed by the arm action, absent from `getSettings`, which cannot ask the registry without pulling the controller layer into every reader of settings.json. Absent is a no-op on every settings file that exists: the live `sweepKinds` is `[]`, and empty already meant the union. **Numbers diff unchanged.** - `LaneDeck`'s backfill card still explained itself in terms of "the corpus-wide backfill sweep" and "at the default weight the lane is ALREADY idle-only". Both retired; the yield is the operation's declared `contendsFor`, which is NARROWER than the old sentence claimed — a network-bound attribution run does not stand aside at all. - The trap below about which writers must gain `held` was wrong. Corrected. **Dead after this slice, and 1.4 deletes them:** `AutoQueueReach`, `sanitizeAutoQueueReach` and `AUTO_QUEUE_REACHES` in `jobs/autoQueuePolicy.ts`/`lib/autoQueueTypes.ts` have no reader left, and `OrderReach.tsx`'s whole `reach !== null` branch (the select, `REACH_LABEL` and two of its four sentences) is unreachable — every caller passes `null`. ### Traps for the 1.4 implementer - **Every GATE read of the four legacy fields is inside `lib/pauseGates.ts`** — `transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused`, `!backfill.enabled`, in `isGateHeld` (`:85-92`) and `withGateHeld` (`:114+`). Nothing in `common/controller/`, `common/jobs/` or `editor/app/` asks the question any other way. The files that touch the FIELDS for another reason, and which 1.4's deletion step has to visit: `editor/app/settings/actions.ts:222-223` (spreads `transcriptionsPaused` and `downloadsPaused` through an unrelated save — this is the "settingsActions spread" the 1.4 bullet names), `editor/app/operations/settingsActions.ts:102` (same, for `digestsPaused`), `LaneSettingsForm` + `settingsActions` for `backfill.enabled` behind "Run the backfill lane", `editor/instrumentation.ts` re-applying `transcriptionsPaused` to the worker pool at boot (the intent-vs-pool asymmetry that must survive), and `editor/app/workers/buildWorkers.ts:54`, which already goes through `isGateHeld`. The e2e reads on disk are `auto-queue.spec.ts` (`downloadsPaused`), `dashboard.spec.ts`, `backfill.spec.ts` (`backfill.enabled`, three tests, via its `laneEnabled` helper) and `widget.spec.ts`. - **`held` should land on `AutoQueuePolicy`, beside `enabled` and `snoozeUntil`, and `sanitizePolicy` is the one place to default it.** **ONE writer has to gain the key: `saveAutoQueueAction`** (`editor/app/operations/actions.ts:~47`), which spells every policy field EXPLICITLY — its own comment says why, and that is exactly the hazard: a field it forgets is dropped on every save. Its neighbours are safe by construction — `armLaneAction` and `disarmLaneAction` (`editor/app/jobs/actions.ts:~203`) SPREAD `...policy` and override only `enabled` and `root`, so they carry `held` for free — and so does `snoozeAutoQueueAction`. An earlier draft of this note said all of them spell the fields out; it was wrong, and the difference is the whole trap. - **The numbers script already prints `held ` for all four lanes** through `isGateHeld`, so 1.4's before/after diff over the live corpus is a direct test of the fallback: it must stay `true/false/false/false` while the file carries only the legacy fields. - **`withGateHeld` must keep spreading.** `pauseGates.test.ts`'s "holding the backfill lane leaves the rest of its block alone" now also asserts `held.autoQueue` deep-equals the input's — the tree is the thing a rebuilt literal would drop next. - **`backfill.enabled` is a lane gate AND a form checkbox.** 1.4's "delete the four legacy fields" step has to decide what "Run the backfill lane" writes; today it writes the same field the pause does, on purpose. ## 1.4, as shipped `0040e39` (the model, the gate and the read-time copy) → `3ab52ba` (the editor's writers) → `fabde02` (the dead `reach` code) → `63242e4` (fixtures, specs and the three-surface e2e) → `960f51a` (the one spec the full run caught) → the commit carrying this note, which cannot name its own sha. On `one-core/phase-1` off `451c454`. **Numbers: the before/after diff of `plans/tools/phase1-numbers.ts` over the live corpus is EMPTY** — 2,629 lines each, byte-identical. That is not a formality here, it is the slice's own fallback test: the live `settings.json` carries `transcriptionsPaused: true`, `downloadsPaused: false`, `digest.digestsPaused: false`, `backfill.enabled: true` and **no `held` anywhere**, so the script's four `held ` lines (`true/false/false/false`) are produced entirely by the legacy path through `isGateHeld`. Verification: `tsc --noEmit` clean in common, editor, export, mcp, homepage and umtool; `yt-dlp-transcript-common` **896** tests (890 before: **+6** — three in `pauseGates.test.ts` replacing two, so net +1; four `migrateHeldToLanes` cases in `jobs/laneMigration.test.ts`; one hold-not-stop case in `operationBatch.test.ts`), `yt-dlp-transcript-mcp` **205**; `next build` clean; full editor e2e from a `p14-e2e` worktree of `63242e4` — **513 passed, 1 failed, 25.3 min**, one worker behind the queue lock. **514 total: 512 at 1.3, plus this slice's two new tests.** 1.3's `video-page.spec.ts:216` flake passed this time. **The one failure was mine, and the full run is what caught it.** `operation-settings.spec.ts:104` ("the lane's switch survives a save of the operation form beside it") ticks "Run the backfill lane", saves the diarization form beside it, and asserted the switch by reading `backfill.enabled` — the field the checkbox stopped writing three commits earlier. The property and the clicks are unchanged; only the key it reads back moved, and checked now means `held: false`. Fixed in `960f51a` and re-run from a worktree of that commit — `operation-settings.spec.ts`, `lane-runner.spec.ts` and `backfill.spec.ts` together, **31 passed, 0 failed**. **How it was missed, because the shape recurs:** the bullet's grep is `transcriptionsPaused|downloadsPaused|digestsPaused` over `editor/e2e`, and the FOURTH field is spelled `backfill.enabled` — a name too generic to grep for blindly, which is why the census that found it (`grep -rn "backfill\.enabled"`) was run over `common/` and `editor/app` with `e2e/` EXCLUDED, to keep the output readable. The lane's inverted field is the one that needs both greps. ### Where a lane's gate is written, and by whom | surface | control | writes | |---|---|---| | dashboard deck (`LaneDeck`) | Pause / Hold, all four lanes | `autoQueue[lane].held`, via `pauseLaneAction`/`resumeLaneAction` → `withGateHeld` | | monitor widget rail | the same deck | the same | | runner console (`LaneHeader`) | Hold the lane | the same | | `/workers`, `/jobs` strip | Pause Transcriptions / Downloads | the same (transcription flips the POOL first) | | `LaneSettingsForm` | "Run the backfill lane" | `autoQueue.backfill.held`, via `saveBackfillLaneSettingsAction` → `withGateHeld` — a deliberate second writer of one key | | lane console policy form | Save policy | carries `held` through `saveAutoQueueAction`, beside `snoozeUntil` | | `armLaneAction` / `disarmLaneAction` / `snoozeAutoQueueAction` | arm, disarm, snooze | nothing — they spread the policy and carry `held` for free | | `/settings` form, digest settings form | — | nothing; they PRESERVE their retired field and the whole `autoQueue` | | `getSettings` | — | nothing on disk: it fills `held` in memory, and the next write persists it | | `editor/instrumentation.ts` | boot | nothing; it READS `isGateHeld(…, "transcription")` and applies it to the pool | Every reader is `isGateHeld`, unchanged: `laneGuards` (digest), `operationBatch` (backfill), `autoRunner` (download), `operations/status.ts`, `api/widget/sync`, `buildActiveJobs`, `buildWorkers`, `channels/groupActions`, `pipelineActions` and the numbers script. ### What stays, and the condition for deleting it `transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused` and `backfill.enabled` **remain in `SiteSettings` and in their sanitizers**, as the migration's input and nothing else — no reader outside `legacyGateHeld` is left, and `backfill.enabled` now has no WRITER at all. They go in a later slice, and the condition is exact: **the live `settings.json` carries all four `held` keys**, which happens on its first write through the editor after this slice ships. Until then a file that has never been written is still read correctly, and that is the whole reason the sanitizer refuses to default the key. ### Divergences from the 1.4 bullets, and why - **`legacyGateHeld` lives in `common/lib/laneMigration.ts`, not in `pauseGates.ts`.** The bullet has `isGateHeld` "falling back to the legacy field", which reads as "in that file"; it cannot be. `getSettings` has to run the copy on EVERY read of settings.json, and `pauseGates.ts` imports `lib/operations.ts` (for `pauseLaneFor`), which imports the controller layer. A pause fallback defined there would pull the entire operation registry into every reader of settings.json — `bin/` scripts, the MCP server, the export build — and make a runtime cycle out of `settings → pauseGates → operations → controller → settings`. So the four RETIRED fields are read beside the other retired fields, and `isGateHeld` delegates. There is still exactly one place that knows the inversion. - **The copy runs on the MERGED settings, not the parsed file.** `migrateSweepsToLanes` takes `parsed` because "absent from the file" is its trigger; this one's trigger is "the sanitized policy has no `held`", and two of the four fields it reads live in the `digest` and `backfill` blocks, which are sanitized further down `getSettings`. It is called after them. - **Only ONE writer of a legacy pause field actually existed, and it is the one that was rewritten.** The bullet names three. `saveBackfillLaneSettingsAction` genuinely flipped `backfill.enabled` from a checkbox and now goes through `withGateHeld`. The other two — `settings/actions.ts`'s spread and `saveDigestSettingsAction`'s `digestsPaused: dD.digestsPaused` — PRESERVE the current value inside an object that also preserves the whole `autoQueue`, so they cannot flip either half and cannot drift. Routing them through `withGateHeld` would have turned two unrelated settings forms into pause writers, which is a larger claim than the bug being avoided. Both carry a comment saying they are preserving migration input. - **"limit() returns 0 for a held lane (all four)" is not a true statement, and the test says what is.** Only the two OPERATION lanes hold through `limit()`: `laneLimit` returns `{limit: 0, hold: {reason: "paused"}}` for digest and backfill. The download lane's gate is asked in `next()` (idle reason `downloads-paused`) and transcription's is the worker pool's slot count — one gate, three shapes, which is the asymmetry `pauseGates.ts` has always documented. So the unit test pins the two lanes where the claim holds, through the NEW key and against a disagreeing legacy field in both directions, and `lane-runner.spec.ts` proves the other half from the browser: `running` stays true at `lane-held`, on all three surfaces. - **The e2e fallback proof uses the DIGEST lane, not transcription.** The bullet offers `/operations/transcription` "or the status API"; neither can show it. No UI surface may read transcription's stored value — every one of them reads the pool, `/api/auto-queue/status` included (`status.ts:113-117`) — so a fixture carrying `transcriptionsPaused: true` is correctly invisible to a page, and a spec asserting otherwise would be asserting the bug that rule exists to prevent. Digest's flag IS its gate, so the fallback is observable there. Transcription's fallback is covered by `pauseGates.test.ts` and, over the live corpus, by the numbers diff above. - **`OrderReach.tsx` is `LaneOrder.tsx`.** Deleting the `reach !== null` branch leaves a component named for an axis it no longer has, on a page whose whole argument is that a runner has no such axis. One import site. - **`LaneSettingsForm`'s sweep prose went with the checkbox.** It still told the operator that the lane's scope "lives in the sweep controls directly above" — controls slice 1.3 deleted. It names the rule list on the console now, and the checkbox says it is the same gate as the Hold button. - **No `/api/test/settings` route was added.** The e2e helper `readJson( "test-settings.json")` already reads the file the editor writes; a route would have been a second way to ask. ### What the specs pin now - `auto-queue.spec.ts` — the runner-page pause writes `autoQueue.download.held`; a hold survives a rule reorder through `saveAutoQueueAction` (`held=true` is part of the same poll that asserts the order landed); and **an unmigrated file** — the retired `digest.digestsPaused`, no key — reads as held on `/api/auto-queue/status` and on the console, and carries `autoQueue.digest.held` after the first toggle. - `lane-runner.spec.ts` (6) — the console, the dashboard and the widget each hold the digest lane by writing the same key, with the other three lanes' keys compared before and after each hold AND each release, and the runner asserted `running` with `idleReason: "lane-held"` at every one. - `dashboard.spec.ts`, `backfill.spec.ts` (two tests), `widget.spec.ts` — the same reads, moved off `downloadsPaused` and `backfill.enabled`; the backfill helper's polarity stops being inverted. - `disk-space.spec.ts` — its three fixtures spell the manual pause as `autoQueue.download.held`, which is what an editor-written file now looks like. ### Traps for the 1.5 implementer - **`sanitizePolicy` must never default `held`.** An absent key is what makes the legacy fallback fire; defaulting it to `false` reads every unwritten settings.json as "no lane held" and resumes a paused corpus. The deletion slice removes the FALLBACK and the four fields together — at which point defaulting it becomes correct and is a required part of that change, not an optional tidy. - **`held: undefined` never reaches the file**: `JSON.stringify` drops it, so a lane that has not been through `getSettings`' copy stays genuinely absent rather than gaining a null. - **`plans/tools/phase1-numbers.ts` still prints `backfill.enabled`, `transcriptionsPaused` and `downloadsPaused` off the settings TYPE, and `digest.sweepEnabled`/`backfill.sweepEnabled` off the RAW FILE.** The deletion slice moves the first three to the raw file or drops them; either way it is a numbers-diff-visible change and has to be explained in that slice's note. - **`backfill.enabled` has no writer left.** It is frozen at whatever the file says, which on the live corpus is `true`. Nothing reads it but the migration — do not "fix" a surface to write it again. - **`laneGuards.test.ts` and `operationBatch.test.ts` cast partial objects to `SiteSettings`.** `isGateHeld` reads `settings.autoQueue?.[lane]?.held` with optional chaining for exactly that reason, while `legacyGateHeld` keeps the original non-optional access to the digest and backfill blocks — an absent block throws there, as it always did, rather than silently answering "not held" (which for the inverted backfill field would be the WRONG default). - **Grep `editor/e2e` for `backfill.enabled`, not just for the three `*Paused` names.** That asymmetry cost this slice its only red — see above. The deletion slice touches the same four fields and will hit the same trap. - **1.5 touches `generateChannelSnapshot`, not this.** No snapshot, bucket or operation count moved in 1.4; the numbers script's snapshot section is unchanged and remains the baseline. ## 1.5, as shipped `4cb226e` (the entries) → `3912e4f` (the runner draws a lane) → `9daf8c6` (the live-corpus check) → `2133d94` (the e2e) → `a7922a3` (the projection stops copying) → `d8754d2` (the band the e2e caught) → the commit carrying this note, which cannot name its own sha. On `one-core/phase-1` off `1a4524b`. **Numbers: the before/after diff of `plans/tools/phase1-numbers.ts` over the live corpus is EMPTY** — 2,629 lines each, byte-identical. That is the slice's own migration test, twice over. No snapshot is regenerated, so the script's generic `Object.keys(snapshot.backfill)` walk finds the same four operations on all 68 channels; and every `computeLeafPending` total is unchanged because the runner's new default draw — the lane's work list — is the same array the bucket union folded to, by construction. Verification: `tsc --noEmit` clean in common, editor, export, mcp, homepage, umtool and `plans/tools`; `yt-dlp-transcript-common` **908** tests (896 before: **+12** — eight in `autoQueuePolicy.test.ts`, three in `channelSnapshot.test.ts`, one in `operations.test.ts`), `yt-dlp-transcript-mcp` **205**, root `pnpm test:scripts` 71 passed / 1 skipped; `next build` clean; full editor e2e from a `p15-e2e` worktree of `2133d94` — **514 passed, 1 failed, 25.1 min**, one worker behind the queue lock. 515 total: 514 at 1.4 plus this slice's one new test. 1.3's `video-page.spec.ts:216` flake passed. **The one failure was this slice's own band assertion, and it was right** — see below. `d8754d2` fixes it, and the eight specs that could see the fold (`backfill`, `channels`, `channels-counts`, `channel-line`, `lane-runner`, `auto-queue`, `attribution`, `digest`) were re-run from a worktree of that commit: **87 passed, 0 failed, 4.6 min**. The only other code after the full run is `a7922a3`, which changes an allocation and nothing else. ### What it writes, and why the two entries are hand-folded `generateChannelSnapshot` now writes `backfill.download` and `backfill.transcription`. They are the only entries in that map with no `Operation.state()` behind them — download and transcription are `EXTERNAL_OPERATIONS`, registered for the dependency graph and dispatched by their own runners — so `foldBucketLaneEntry` states the four numbers rather than counting classifications, and is EXTRACTED beside `foldBackfillEntry` for exactly the reason that one was: inside the generator it could only be reached with lmdb, an archive reader and a corpus on disk. | field | download | transcription | |---|---|---| | `ids` | `partialDownloads` ∪ `undownloadedIds` | `downloadedNoTranscript` ∪ `failedListed` | | `missing` | `ids.length` | `ids.length` | | `stale` / `partial` / `blocked` / `deferred` | 0 | 0 | | `missingInput` | 0 — the input is the listing | `buckets.noTranscript.length` | | `eligible` | `totals.downloaded + missing` | `totals.transcribed + missing + missingInput` | `stale` and `partial` staying 0 is what keeps `channelSnapshot.test.ts`'s invariant (`ids.length === reachableOperationWork(entry)`) true for these entries too, and `eligible` is defined so `presentOperationWork()` gives back `totals.downloaded` / `totals.transcribed` exactly — pinned by a unit test and, over all 68 live channels, by the check script. Videos excluded from download and untranscribable ones are in neither half: they are this operation's not-applicable, the same exclusion `foldBackfillEntry` applies. ### The proof, both halves **(a) The projection equivalence, as a unit test.** Four cases in `autoQueuePolicy.test.ts` — both bucket lanes × `replaceAutoSubs` on and off — build the same tree over the same corpus under the bucket projection and the operation projection and assert the pending map deep-equals AND that draining both yields the same `/