Archilyzer · Source

archilyzer

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

commit 6c507ab1b60be2cced0c5ce8b76b9ac724fd5c20
parent 9f2bbf9157363914a962680c972560d6b548264d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  8 Sep 2026 01:37:05 -0400

plans: slice 1.5 is shipped, and Phase 1 is closed out

The 1.5 record: what the two bucket-lane entries carry and why they are
hand-folded, both halves of the proof, and every divergence — the download
union order the plan had backwards, the 882 that is not 2,754, the claim space
that made the projection safe, and the band double-fold the e2e assertion
caught after two commits of claiming nothing had moved.

Plus a Phase 1 summary: five slice ranges, 107 files, 18 deleted files at 5,273
lines, tests 876 to 908, and what stays for the deletion slice with its exact
condition. STATE.md leads with Phase 1 and keeps the history below it;
FACTS.md gains the whole-phase anchors; one-core.md says shipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mplans/FACTS.md | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/STATE.md | 138+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mplans/one-core-phase-1.md | 284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/one-core.md | 8++++++++
4 files changed, 530 insertions(+), 56 deletions(-)

diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -3374,3 +3374,159 @@ EMPTY** (2,629 lines each), which is a direct test of the fallback: the live fil `transcriptionsPaused: true`, `downloadsPaused: false`, `digest.digestsPaused: false`, `backfill.enabled: true` and no `held` anywhere, and the script's four `held <lane>` lines still read `true/false/false/false` through `isGateHeld`. + +## One-core Phase 1 (verified 2026-09-08) — four lanes, one runner + +The whole-phase anchors. The slice-1.4 entry above stays as the detail on where a lane's +pause lives; this is everything else a session needs before touching dispatch. Branch +`one-core/phase-1`, `7f294df` → head, 33 commits, unmerged, off Phase 0's `af2a360`. The +record with every divergence is `plans/one-core-phase-1.md`. + +**The model in one sentence:** the LANE is the dispatch noun (a queue key, a policy 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. + +### The four lanes, and which half of the model each draws from + +`LANES` / `AutoQueueKind` (`common/lib/autoQueueTypes.ts`) is +`["transcription", "download", "digest", "backfill"]`, and `PauseLane` is an alias of it. +Two of them draw BUCKETS and two draw OPERATIONS, which is the split every function in +`jobs/autoQueuePolicy.ts` keys on: + +| lane | draws | its work list | +|---|---|---| +| `download` | buckets | `snapshot.backfill.download.ids` = `partialDownloads` ∪ `undownloadedIds` | +| `transcription` | buckets | `snapshot.backfill.transcription.ids` = `downloadedNoTranscript` ∪ `failedListed` | +| `digest` | operations | `snapshot.backfill.digest.ids` | +| `backfill` | operations | `snapshot.backfill.{diarization,attribution-text,attribution-diarized}.ids` | + +- **`bucketsForKind` / `optInBucketsForKind` / `selectableBucketsForKind` return `[]` for the + operation lanes**, and `operationsForLane` returns `[]` for the bucket lanes — download and + transcription are `EXTERNAL_OPERATIONS` with no `state()` or `run()`, so there is no + `Operation` to hand back even though they now have a snapshot entry. The lane asks + `bucketLaneOperationId(lane)` (`lib/operations.ts`) for its work list's KEY instead; it is + `pauseLaneFor` read backwards, off the same `ExternalOperation.runner` declaration. +- **`defaultBucketsForPolicy` describes the POPULATION; `defaultDrawsForPolicy` is what the + runner projects.** The first is what `policyDrawsBucket` asks ("would the runner draw this + bucket for this channel"). The second returns `[<operation id>, ...optIn if + replaceAutoSubs]` on a bucket lane — one list, named for the operation, with the opt-in + auto-captions buckets still strictly at the tail. +- **The lane's work list is projected into `ChannelWork.buckets`, not `.operations`**, and + that placement is load-bearing. `buildPendingByLeaf` claims a video as + `${operation}\0${id}`, so an operation draw and a bucket draw are DIFFERENT claim spaces: a + video an explicit retry-bucket leaf claimed would be claimed a second time by a catch-all + drawing the operation. On a bucket lane the operation and the union are the same work, so + it shares the bucket space — and `laneOperationIds` still answers `[]` there, which is why + `dropCompleted`, `leafOperationIds` and `markCompleted` were untouched by 1.5. +- **`bucketLaneWorkIds(lane, snapshot)`** (`jobs/autoQueuePolicy.ts`) is ONE fold with two + callers: `generateChannelSnapshot` writes it, and `autoRunner.buildChannelWork` falls back + to it for a snapshot with no entry. That is the whole migration — **no live snapshot is + regenerated by 1.5**, so all 68 take the fallback until their next regen, and the two sides + cannot disagree because they are the same function. +- **`bucketIdsFrom(source, name)`** knows the one asymmetry: `undownloadedIds` is a TOP-LEVEL + snapshot field and every other bucket is under `snapshot.buckets`. + +### Where `held` lives + +One key, `autoQueue[lane].held`, absent-is-not-false, with the four retired fields read +through `legacyGateHeld` in `common/lib/laneMigration.ts`. Full detail in the slice-1.4 +entry above, including why that function is not in `pauseGates.ts` (an import cycle: +`settings → pauseGates → operations → controller → settings`). **The same cycle is why +`bucketLaneOperationId` is in `lib/operations.ts` while the fold that uses it is in +`jobs/autoQueuePolicy.ts` and takes the name as a parameter** — `lib/settings.ts` imports +`autoQueuePolicy` for its sanitizers, so `autoQueuePolicy` importing `operations` would open +the same door. + +### The migration files + +- **`common/lib/laneMigration.ts`** — pure, two functions, no I/O. + `migrateSweepsToLanes(parsed)` runs on the PARSED settings file (absent-from-the-file is + its trigger, and a merged object can no longer tell absent from default); + `migrateHeldToLanes(merged)` runs on the MERGED object AFTER `sanitizeDigest` / + `sanitizeBackfill`, because two of the four fields live in those blocks. Tested in + `common/jobs/laneMigration.test.ts` — in `jobs/` because `architecture.test.ts` forbids + `lib/` importing `jobs/` and the migration must be asserted THROUGH `sanitizeAutoQueue`. +- **`laneRootFromScope`** is the ONE leaf builder: `armLaneAction` and the sweep migration + both call it, so "arm on channels X" and "migrate `sweepChannels: X`" are the same + function. Its optional `available` collapses a scope naming every operation to `[]`. +- **The migrated leaf ids are `digest-all` / `backfill-all`**, which only that builder + produces — visible in the numbers diff, which is how the migration was checked on the live + file rather than asserted. + +### `controller/operationBatch.ts` — one executor + +Replaces `digestBatch.ts` (647) and `backfillBatch.ts` (937). `openOperationRun` resolves the +engine, the duplicate-cluster plan and the fan-out envelope ONCE PER RUN; `runOperationUnit` +takes that prepared run, not a bag of per-video arguments. `laneLimit(settings, live)` takes +the lane on a tagged `live` union. `runOperationBatch({lane, …})` is one function with one +result type; `runDigestChannelJob` / `runBackfillChannelJob` keep their job kinds and queue +keys and call into it. + +Four things in it that only a long-lived runner can break, all already paid for: the metered +spend cap and the disk-floor latch survive the run context's 60 s refresh; the refresh waits +for the lane to be idle and carries the WHOLE `live` object across (`opened.live = +laneRun.run.live`) or a unit settling after the swap leaks a lease; a failed engine open is +covered by the TTL too; and the digest unit calls `digestVideo` rather than `op.run()` +because `OperationRunOutcome` cannot carry a cost and routing it through `run()` would +silently disable the spend cap. + +**The `laneFor` trap is closed.** The GPU idle-only rule asks +`laneYieldsToTranscription(laneForOperation(op.id) ?? op.lane)` on `contendsFor`, not on the +field EXISTING. `laneForOperation` (`controller/operationLane.ts`) resolves the registry +first and then `EXTERNAL_OPERATIONS`' declared lanes; **`sync` still resolves `null`** — it +is a per-channel cadence, the same answer `pauseLaneFor` gives it. + +### The snapshot memo, and what it is not on + +`readChannelSnapshotShared` (`controller/channels.ts`) memoizes the PARSE keyed on +`(mtime, size)`. It exists because `buildChannelWork` runs on the runner's scheduling tick +AND on a three-second status poll folding four lanes — ~6.5 MB of JSON re-parsed eight times +a tick. Measured: the poll is 86–102 ms before the two lanes went live, 386–413 ms after with +no memo, **256 ms with it**. It is deliberately NOT on `readChannelSnapshot`, which the +snapshot GENERATOR calls. `resetChannelSnapshotMemo` is wired into +`/api/test/invalidate-cache`. + +### The `cheapest` composition + +`digestBatch.ts` sorted by duration and then again, stably, by upload date — so the live +meaning was **date primary, duration as the tiebreak within a day**, the opposite of the +order the two `sort()` calls read in. That is one `cheapestComparator` now, with the date +half fixed at newest-first (`digest.recencyOrder` retired rather than migrated: it was only +the date HALF of a composition). Unknown durations sort LAST. `cheapest` on any other lane is +accepted by the sanitizer and falls back to LISTED — `recencyOrdering` returns a null +comparator, which everywhere in the runner means "do not sort". + +### `.auto-queue/state.json` is written WHOLE + +`writeAutoQueueState` serializes every lane, so the four runners share ONE in-memory object +on the auto-runner singleton (`sharedAutoQueueState`). Two runners each holding their own +copy meant every persist clobbered the other lanes' pick log and fairness memory — invisible +with one slow lane, immediate with two fast ones. Anything else that read-modify-writes that +file (`jobs/downloadBackoff.ts` does) can still lose picks; a new writer must take the shared +object. Its tmp path carries a per-write counter, not just the pid. + +### The band that is folded once + +`buildOperationBands` (`editor/app/components/pipelines/buildBands.ts`) folds +`addExternalBands` for download and transcription and `addRegistryEntry` for everything +else — and the registry fold now SKIPS the two external ids. `/channels` passes +`[...EXTERNAL_BAND_IDS, ...allOperations]` in, so once slice 1.5 wrote +`snapshot.backfill.download`, folding it on top of `addExternalBands` doubled every one of +that band's five numbers (a three-video channel read "6 done of 6"). The `/operations` rail +(`operations/lanes.ts`) and the channel page (`channelFlow.ts`) pass registry ids only and +were never affected. + +**The bands and the snapshot entries are two different measures and must stay so.** The +band's transcription `reachable` is `downloadedNoTranscript` ALONE — the retry bucket is not +in it, and on the live corpus that is 881 against the lane's 882; its `blocked` is what the +entry calls `missingInput`; its `eligible`/`present` are the playlist and +`totals.downloaded`/`totals.transcribed`. `EXTERNAL_BAND_IDS` is now +`EXTERNAL_OPERATIONS.map(o => o.id)` rather than a hand-written pair. + +### The `editor/content` symlink still blocks e2e in the primary checkout + +Unchanged from the Phase 0 entry, plus one thing Phase 1 learned twice: **`git add -A` stages +it** (it is untracked, not ignored), which carries the Turbopack panic into every worktree of +that commit. Add by path. A worktree also needs a composed fixture site copied into its +`export/public` or the export webServer 500s and Playwright times out at 120 s. diff --git a/plans/STATE.md b/plans/STATE.md @@ -3,65 +3,91 @@ The working memory for the local-AI derived-corpus work. Rewritten at the end of every session, before context is cleared. See [`README.md`](README.md) for the protocol. -**Last updated:** 2026-09-07 — **one-core Phase 0 shipped** on branch `one-core/phase-0` -(`df5eb48` → `1691c4f`, six commits, not merged): **guardrails and dead weight.** The umbrella -plan is [`one-core.md`](one-core.md); its "Phase 0, as shipped" section is the detail and -[`FACTS.md`](FACTS.md#one-core-phase-0-verified-2026-09-07) the anchors. Every item is a -deletion, a move or a guard. **Nothing on disk in a corpus changed and nothing on the wire -changed** — proved, not asserted: a fixture corpus composed through `build-index` + -`compose-site` before and after item 5 gives 16 public files and 7 index files that are -**identical apart from `generatedAt`** (and the archive zip's entry mtimes; its contents diff -clean). - -- **`scripts/report-to-video` → `umtool/report-to-video`**, package `umtool-report-to-video`. - A rename only. The survey undercounted: **eight** import sites, not four. -- **`common/architecture.test.ts`** is the layering. Back-edges **17 lines / 16 edges → 13 / - 12**. The four burned down were type-only and now live in `common/lib/autoQueueTypes.ts`, - re-exported by `jobs/autoQueue{Policy,State}.ts` so no import site outside `common/lib` - changed. The allow-list fails on a NEW edge *and* on a stale entry, so it can only shrink; - each of the 12 names the slice that removes it. **Nine wait on phase 1** — - `lib/operations.ts` → four controllers is the registry's `run()` closures, THE structural - back-edge, and it inverts at phase 1 step 4. -- **`common/package.json` has an `exports` map**; `mcp/package.json` finally declares - `yt-dlp-transcript-common` (it had been resolving by hoisting). One trap paid for: - `"./components/*": ["./components/*.tsx", "./components/*.ts"]` **type-checks and does not - build** — Turbopack takes the first entry of a fallback array and stops. The 25 `.ts` - modules under `components/` are listed explicitly; a new one needs a new line. -- **Deleted**: root `sites/`, `common/bin/migrate-to-sites.ts` (the *controller* stays — the - Sites page's Migrate button calls it), `create-archives.sh`'s commented tail, - `export/.compose-cache/`, and `PARALLEL_TRANSCRIBE_LIMIT` from three doc tables. - `export/.r2-staging/` (2.5 GB) deliberately untouched. **Moved**: the three bake-off - harnesses (3,828 lines, 58% of `common/bin/`) to `plans/tools/`. -- **One `pageFileName`, one `CONTRACT`.** The page-shard name existed **six** times, not - three. `CONTRACT` in `lib/corpus.ts` owns eight values plus the layer names; the seven - existing constants re-export its fields. - -**Decision taken here:** the bake-off move needed one new file, `common/bin/_lmdb.ts`, because -`plans/` is not a workspace package and a bare `import "lmdb"` resolves upward from the -importing file. Making `plans/tools` an eighth workspace member was rejected — it would add a -`Dockerfile` COPY and a package to a workspace this plan exists to shrink. +**Last updated:** 2026-09-08 — **one-core Phase 1 shipped** on branch `one-core/phase-1` +(`7f294df` → HEAD, 33 commits, not merged): **dispatch is one scheduler, and the lane is the +noun.** The slice-level record — every sha range, every divergence, both operator gates — is +[`one-core-phase-1.md`](one-core-phase-1.md); the umbrella is +[`one-core.md`](one-core.md) and the anchors are +[`FACTS.md`](FACTS.md#one-core-phase-1-verified-2026-09-08). **No live number moved**: the +before/after diff of `plans/tools/phase1-numbers.ts` over the real 78,000-video corpus is +EMPTY for 1.1, 1.2, 1.4 and 1.5, and for 1.3 is 18 added lines / 0 removed — the two sweeps' +scopes migrating into lane trees, both `enabled: false`. + +- **1.1 — four lanes in the model.** `AutoQueueKind` is `transcription | download | digest | + backfill`, exported as `LANES`, and `PauseLane` is an alias of it. `retainLeaves` was + DELETED rather than de-moded: `pending[leaf]` is what the leaf drew, so a post-hoc filter + over it can only re-apply a rule the draw already applied. The rule moved into the draw. +- **1.2 — the runner runs operations.** `digestBatch.ts` (647) + `backfillBatch.ts` (937) → + one `controller/operationBatch.ts`. The status poll costs ~150 ms more for the two new + lanes and a `(mtime, size)` parse memo gives ~140 ms of it back. Three runner-only bugs + review found (a spend cap a 60 s TTL could reset, a context swapped under an in-flight + unit, a dead engine re-probed every tick) and one the e2e spec found: **every runner held + its own copy of `.auto-queue/state.json`, which is serialized whole**, so each persist + clobbered the other lanes' pick log. They share one object now. +- **1.3 — the sweeps and the arbiter retire.** Arming is tree authoring; ten settings fields + migrate on read (`common/lib/laneMigration.ts`, a pure function tested through the + sanitizer) and are then deleted with their sanitizers and forms. `reach` and + `digest.recencyOrder` are RETIRED, not migrated, and the note says why. The one rendered + change in the whole phase is here and was kept on purpose: the /jobs strip's four lane rows + fold `gateHeld` now, so a held transcription row reads *Holding* where it read *Idle*. +- **1.4 — pause is lane state.** One key, `autoQueue[lane].held`, behind the unchanged + `isGateHeld` / `withGateHeld`. The four legacy fields survive as `legacyGateHeld`'s + read-time input in `lib/laneMigration.ts` — NOT in `pauseGates.ts`, which would have pulled + the operation registry into every reader of settings.json. The transcription + intent-vs-pool asymmetry is intact. +- **1.5 — one work list per lane in the snapshot.** `backfill.download` and + `backfill.transcription` are the fold of each lane's default buckets, so + `snapshot.backfill[op].ids` is where all four lanes' work lives; the runner reads it and + falls back to the same fold, which is the migration (nothing is regenerated). + `plans/tools/phase1-worklist-check.ts` checks all 68 live snapshots read-only: **OK**, + download 9, transcription 882, zero already carrying an entry. + +**Two operator gates are outstanding, and one of them gates a merge.** + +- **Gate A** — the digest lane's runner drives a production pass and comes back after a + restart. Run it from **`one-core/phase-1-gate-a`** (`0438a72`), which is the code as 1.2 + left it; the step-by-step runbook, including the exact `settings.json` block and the + channel to point it at, is in `one-core-phase-1.md`'s "Operator gate A" section. It gates + the MERGE of 1.3 onward, not their authorship, which is why the branch exists. +- **Gate B** — before the backfill lane is ever `enabled` in production, decide + `backfill.allowRedownload` and the channel scope. As configured today it would re-fetch + audio for ~66,540 videos. The migration left the lane disabled; the tree's channel leaves + are the scope from now on. **BLOCKER FOR ANY EDITOR e2e IN THE PRIMARY CHECKOUT, and it is not ours.** `editor/content` is an untracked **symlink to `/home/user/Projects/recipe-content`** (made -2026-08-31, after the last recorded e2e on 2026-08-30). Tailwind's automatic source detection -follows it and Turbopack **panics** compiling `editor/app/globals.css`: -`FileSystemPath("editor").join("../../recipe-content") leaves the filesystem root`. The dev -server never serves a page, so `pnpm e2e` dies before the first spec. **Reproduced on `main` -at `5ffb0e1` with the same panic**, so it is environmental, not a Phase 0 regression — and -`pnpm --filter editor exec next build` is unaffected (production builds do not run the dev -source scan). The operator should remove or move that symlink. - -**The suite itself is green.** Run from a detached `git worktree` of `1691c4f` (no such -symlink there): **511 passed, 0 failed, 23.8 min**, one worker, serial behind the queue lock. -Nothing to compare against FACTS.md's known-failing list — that list was superseded -2026-07-30 and the run had no reds at all. One extra step a worktree needs: its -`export/public` holds only the tracked static assets, so the export webServer 500s and -Playwright times out after 120 s. Seed it with a composed fixture site (the same one used for -the wire diff) and it serves 200. - -**Next:** phase 1 (dispatch: one scheduler), 4–5 slices, starting with persisting the arbiter. -Read `common/architecture.test.ts`'s allow-list first — it is the shortest accurate statement -of what is still tangled. +2026-08-31). Tailwind's source detection follows it and Turbopack **panics** compiling +`editor/app/globals.css`, so `pnpm e2e` dies before the first spec. Reproduced on `main` at +`5ffb0e1`, so it is environmental. Every Phase 1 slice ran its suite from a `git worktree` +instead — which needs one extra step, a composed fixture site copied into its `export/public` +so the export webServer answers 200 instead of 500. **`git add -A` stages that symlink**, and +a commit made that way carries the panic into every worktree of it: add by path. + +**One known flake, reported and not fixed:** `video-page.spec.ts:216` ("Delete directory +wrong-id confirmation shows an error") asserts a video directory still exists after a refused +delete. It red once in 1.3's full run and passed in 1.4's and 1.5's, and passes 20/20 re-run +alone on the same commit; nothing in this phase deletes a video directory. + +**The last full suite: 514 passed, 1 failed of 515, 25.1 min** from a worktree of `2133d94`. +The red was slice 1.5's own new band assertion and it was right — writing the two new +snapshot entries doubled `/channels`' Download and Transcribe coverage, because that page +passes the external ids into `buildOperationBands` and `addRegistryEntry` folded them on top +of `addExternalBands`. Fixed in `d8754d2`; the eight specs that could see it re-run from a +worktree of that commit, **87 passed, 0 failed**. + +**Next:** phase 2 (the contract: one `ArchiveReader`), 3 slices. Read +`common/architecture.test.ts`'s allow-list first — it is the shortest accurate statement of +what is still tangled, it shrank by one across phase 1, and no slice added an entry. + +**Previously:** 2026-09-07 — **one-core Phase 0 shipped** on branch `one-core/phase-0` +(`df5eb48` → `1691c4f`, six commits, not merged): **guardrails and dead weight**, every item a +deletion, a move or a guard. `common/architecture.test.ts` is the layering (back-edges 16 → 12, +the list can only shrink); `scripts/report-to-video` → `umtool/report-to-video`; +`common/package.json` has an `exports` map; one `pageFileName` and one `CONTRACT`. **Nothing on +disk in a corpus changed and nothing on the wire changed** — proved with a fixture corpus +composed through `build-index` + `compose-site` before and after, identical apart from +`generatedAt`. Detail: "Phase 0, as shipped" in [`one-core.md`](one-core.md); anchors in +[`FACTS.md`](FACTS.md#one-core-phase-0-verified-2026-09-07). **Previously:** 2026-08-31 — **the transcode operation is removed** (`904f1a9` → `f717a36`): it never fired in production. The census over 68 channels found the `untranscoded` bucket empty in every one, both `failed-transcodings` files 0 bytes, and only four channels even passing the stage's gate — and `resolveAudioFile` falls back to any real audio file anyway, so transcription never needed it. Gone: four controllers, four job kinds, the channel stage and station, the catalog entry and with it `appliesTo`/`operationApplies` (catalog 8 → 7). Kept: `transcodeAudio` and the download path, the per-file *Transcode …* rows, both wrong-format sweeps — whose bucket is `wrongFormatAudio` now. The slice-5 leftovers rode first (`measure-nav.mjs`, `BUILD_KINDS`, `EditorAliasesClient`). Plan: [`editor-transcode-removed.md`](editor-transcode-removed.md). diff --git a/plans/one-core-phase-1.md b/plans/one-core-phase-1.md @@ -1243,3 +1243,287 @@ whole reason the sanitizer refuses to default the key. - **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 `<leaf>/<video>` sequence. A fifth asserts a snapshot with +no entry falls back to an identical list. + +The corpus is SYNTHETIC rather than one of the e2e fixtures, and that is a +divergence worth naming: there are no snapshot fixtures on disk (`snapshot.json` +is generated at run time by every spec that needs one), and a synthetic one can +carry the three traps a fixture does not — a video in two default buckets +(`t1` in both `downloadedNoTranscript` and `failedListed`, so dedup order +decides), an `undownloadedIds` that is NOT sorted, and a `match.bucket` retry +leaf sitting above a catch-all so the claim space is exercised. + +**(b) `plans/tools/phase1-worklist-check.ts`, over the 68 live snapshots.** +Read-only, regenerating nothing. It re-derives the union BY HAND from the literal +bucket names in the literal priority order — the independent half, so the check +is not the fold testing itself — and compares it to `bucketLaneWorkIds`, to +`foldBucketLaneEntry(...).ids`, and to `reachableOperationWork`. It also asserts +that **no live snapshot carries an entry yet** (the loudest possible failure if +something wrote into `transcripts/`) and that `bucketsForKind` still matches the +list the script spells out. + +``` +channels = 68 +missingSnapshots = 0 +snapshotsAlreadyCarryingAnEntry = 0 +download work list = 9 download present = 79041 +transcription work list = 882 transcription present = 78153 +OK +``` + +### Divergences from the 1.5 bullets, and why + +- **The download entry's order is `partialDownloads` ∪ `undownloadedIds`, not the + other way round.** The bullet's parenthetical — and the "Facts that shape the + slices" line above it — spell `undownloadedIds ∪ partialDownloads`, but + `DOWNLOAD_BUCKETS` is `["partialDownloads", "undownloadedIds"]` (the + half-fetched file before the one not started), and the bullet's own normative + clause is "the union `defaultBucketsForPolicy` draws". Writing the parenthetical + order would have made the entry disagree with the fallback the runner takes for + every un-regenerated snapshot, which is a change to WHICH VIDEO the download + lane picks first. `undownloadedIds` still keeps playlist order inside the union + and is never sorted. **Live impact zero — `partialDownloads` sums to 0 across + all 68 channels** — which is precisely why nothing but a test would have caught + it, and there is one. +- **The transcription total is 882, not the 2,754 the brief expected.** 2,754 is + the union with `replaceAutoSubs` ON: 881 `downloadedNoTranscript` + 1,872 + `downloadedAutoSubsOnly` + 1. The DEFAULT union is `downloadedNoTranscript` ∪ + `failedListed` = 881 + 25, and **24 of those 25 are already in the first list**, + so the deduped answer is 882. The opt-in auto-captions bucket is a policy + switch, not a corpus fact, and folding it into a snapshot entry would make + `replaceAutoSubs` a property of the archive; it enters where it always has, at + the TAIL of `defaultDrawsForPolicy`. +- **The lane's work list is projected into `ChannelWork.buckets`, under the + operation's id — not into `.operations`.** This is the change that makes "no + number moves" true rather than merely intended. `buildPendingByLeaf` claims a + video as `${operation}\0${id}`, so an operation draw and a bucket draw live in + DIFFERENT claim spaces: a video an explicit `failedListed` leaf had claimed + would have been claimed a second time by a catch-all leaf drawing the operation, + and appeared twice in one tick. On a bucket lane the operation and the bucket + union are the same work and there is no second operation to keep apart from, so + it shares the bucket claim space. `laneOperationIds` therefore still returns + `[]` for the two bucket lanes, and `dropCompleted`, `leafOperationIds`, + `markCompleted` and `runOperationPick` are untouched. +- **`defaultBucketsForPolicy` is unchanged; a new `defaultDrawsForPolicy` sits + beside it.** The two answer different questions and 1.4's `policyDrawsBucket` + asks the first one ("would the runner draw this BUCKET for this channel", about + the population). The second is what the runner projects. Merging them would have + made `policyDrawsBucket` answer `false` for `downloadedNoTranscript` on a lane + that plainly draws it. +- **`/channels` bands still keep their own fold, and the special case that went is + the ID LIST.** The bullet asks the bands to draw from the operation entries with + no rendered number changed; those two halves cannot both hold, and the + arithmetic is why. The band's transcription `reachable` is + `downloadedNoTranscript` ALONE — the retry bucket is not in it — so reading + `entry.ids` would take a rendered corpus figure from **881 to 882** per channel + fold and, on the `failedListed` channels, visibly; its `blocked` is + `noTranscript − untranscribable − downloadedNoTranscript`, which the entry calls + `missingInput`; and its `eligible`/`present` are the playlist and + `totals.downloaded`/`totals.transcribed`, coverage measures the entry STATES + rather than counts. Two honest answers to two different questions. What did stop + being a special case: `EXTERNAL_BAND_IDS` is `EXTERNAL_OPERATIONS.map(o => o.id)` + rather than a hand-written pair — the same declaration `pauseLaneFor` and the new + `bucketLaneOperationId` read — and `addExternalBands`' header now names the two + numbers that keep it, so the next reader does not have to re-derive the refusal. + `backfillLaneOperationEntriesOf` already filtered on `BACKFILL_QUEUE`, so the + four surfaces that sum the lane never saw the new entries at all. +- **`laneForOperation` resolves EXTERNAL_OPERATIONS, not the whole catalog.** Sync + keeps its null: it is a per-channel cadence, not a per-video pipeline, which is + the same answer `pauseLaneFor` gives it. The old comment's fear — "a caller would + reserve the backfill queue for work no backfill operation can do" — is now two + assertions in the flipped test: neither declared lane is `BACKFILL_QUEUE`. The + one caller that reserves a key from this answer, + `operationJobs.runOperationChannelJob`, is only ever handed a registry id. +- **`bucketIdsFrom` returns the STORED array, not a copy** (`a7922a3`). The first + version returned `readonly string[]` and `buildChannelWork` spread it — 68 + channels x four buckets per scheduling tick and per three-second status poll, + up to 11,000 strings each, which is the allocation the snapshot parse memo + exists to avoid. Nothing mutates the list, and handing out the stored array is + what the code did before this slice. +- **`bucketLaneOperationId` lives in `lib/operations.ts` and the fold in + `jobs/autoQueuePolicy.ts`, deliberately apart.** The obvious shape — one + function in `autoQueuePolicy.ts` — would have made `jobs/autoQueuePolicy.ts` + import `lib/operations.ts`, and `lib/settings.ts` imports `autoQueuePolicy` for + its sanitizers: that is the runtime cycle `settings → operations → controller → + settings` the 1.4 note documents avoiding for `legacyGateHeld`, arriving by a + different door. So the fold takes the work-list NAME as a parameter and the + controllers supply it. **No entry was added to `architecture.test.ts`.** + +### What the band assertion caught, and `d8754d2` fixed + +**Writing the two entries doubled two rendered numbers on `/channels`, and the +e2e test written to prove they had not moved is what found it.** A three-video +channel's row read *Download — 0 can run now · 6 done of 6*. + +`buildOperationBands` seeds the two external bands, folds `addExternalBands` per +snapshot, and then folds `addRegistryEntry` for every id in `operationIds` — +and `/channels` passes `[...EXTERNAL_BAND_IDS, ...allOperations]`, because it +wants a column for every band. Until this slice that was harmless: +`snapshot.backfill.download` did not exist, so `addRegistryEntry` returned early. +The moment it existed, every one of the band's five numbers was counted twice. + +**This is exactly the trap `backfillLaneOperationEntriesOf` was written for, at +the one surface that does not go through it.** Its header says so: the four +surfaces that summed `Object.values(snapshot.backfill)` broke the day the map +stopped being one lane, and they were fixed by filtering on the DECLARATION. +`buildOperationBands` never summed the map — it indexes it by id — so it looked +immune, and the id list it is handed is what made it not. The registry fold now +skips the ids `addExternalBands` owns, with a unit test that fails without the +filter. + +Worth saying plainly because the slice's own claim was "no rendered number +moves": that claim was FALSE for two commits, and the only reason it is true now +is that the assertion was written as an invariance check in the browser rather +than as a re-reading of numbers copied out of an older build. + +### Carried into the deletion slice + +- **When `sanitizePolicy` gains a default for `held`, backfill's must be `true`.** + `sanitizeBackfill` defaults `enabled: false`, and `legacyGateHeld` reads + `backfill.enabled` INVERTED — so a fresh install with no `backfill` block reads + as HELD today. Defaulting `held: false` alongside deleting the fallback would + quietly un-hold the backfill lane on every settings file that has never been + written. The other three default `false`. +- **`editor/app/jobs/active/buildActiveJobs.ts` (~`:407`) reads `isGateHeld` for + the transcription lane**, which is a UI surface reading STORED intent where + `pauseGates.ts` says every UI surface must read the worker pool. Pre-existing — + it predates 1.4 and 1.4 did not move it — and named here rather than fixed, + because the fix is a decision about what the /jobs strip means when the pool and + the file disagree, not a rename. +- The 1.4 traps above all still stand, including the two greps + (`backfill.enabled` needs its own). + +## Phase 1, as shipped + +`7f294df` → `2133d94` plus this commit, **33 commits on `one-core/phase-1`** off +`af2a360` (Phase 0's head). Five slices: + +| slice | range | what it did | +|---|---|---| +| 1.1 | `7f294df` → `3ced7dd` | four lanes in the model; `retainLeaves` deleted, not de-moded | +| 1.2 | `83954f1` → `0438a72` | one `operationBatch.ts`; the runner runs operations on all four lanes | +| 1.3 | `253826b` → `451c454` | the two sweeps and the arbiter retire; ten settings fields migrate then go | +| 1.4 | `0040e39` → `1a4524b` | pause is `autoQueue[lane].held`, with the four legacy fields as a read-time fallback | +| 1.5 | `4cb226e` → this commit | one work list per lane in the snapshot | + +**107 files, 9,357 insertions, 7,284 deletions** (101 files / 7,441 / 7,198 +outside `plans/`). **Eighteen files deleted, 5,273 lines**: `backfillBatch.ts` +937, `digestBatch.ts` 647, `backfillSweep.ts` 517, `SweepLane.tsx` 422, +`arbiter.ts` 416, `digestSweep.ts` 368, `sweepPreview.test.ts` 321, +`SweepPlan.tsx` 264, `backfillBatch.test.ts` 231 (renamed to +`operationBatch.test.ts`), `arbiter.test.ts` 177, `sweepPreview.ts` 167, +`sweepPlan.ts` 163, `SweepScope.tsx` 154, `sweepRecency.ts` 141, +`OrderReach.tsx` 133, `ArbiterBar.tsx` 113, `arbiterWork.ts` 67, +`resume-backfill-sweep/route.ts` 35. Plus, in place: three job kinds, two boot +resume hooks, ten settings fields with their sanitizers and forms, four sweep +server actions, the arbiter's two actions, and `operations/lanes.ts` at 370 → 131. + +**Tests: common 876 → 908 (+32), mcp 205 → 205, e2e 511 → 515.** +`architecture.test.ts`'s allow-list **shrank by one** (`lib/sweepPlan.ts -> +controller/planOrder`, which died with the file) and **no entry was added by any +slice** — the guard fails on a stale entry too, which is how that was checked +rather than asserted. + +**Numbers: the before/after diff of `plans/tools/phase1-numbers.ts` over the live +corpus is EMPTY for 1.1, 1.2, 1.4 and 1.5, and for 1.3 is 18 ADDED lines and zero +removed** — the migrated `autoQueue.digest` / `.backfill` blocks, both +`enabled: false` with one catch-all leaf, which is exactly what the file's +disarmed, unscoped sweeps meant. Every pre-existing line is byte-identical in all +five. **No live number moved across the phase.** The one rendered-text change +anybody will see is named in 1.3's note and was kept on purpose: the /jobs strip's +four lane rows now fold `gateHeld`, so a held transcription row reads *Holding* +where it read *Idle*. + +### What stays, and what gates what + +- **Four legacy pause fields** — `transcriptionsPaused`, `downloadsPaused`, + `digest.digestsPaused`, `backfill.enabled` — remain in `SiteSettings` and in + their sanitizers as `legacyGateHeld`'s input and nothing else. `backfill.enabled` + has no writer left at all. The deletion condition is exact: **the live + `settings.json` carries all four `held` keys**, which happens on its first write + through the editor after 1.4. The two carried notes above belong to that slice. +- **`sanitizePolicy` must still never default `held`**, and the backfill default + when it finally does is `true`. See above. +- **Gate A** — the digest lane's runner driving a production pass and resuming + after a restart — is run from **`one-core/phase-1-gate-a`** (`0438a72`, the code + as 1.2 left it) and gates the MERGE of 1.3 onward, not their authorship. The + runbook is in the 1.2 section. +- **Gate B** — before the backfill lane is ever enabled in production, decide + `backfill.allowRedownload` and the channel scope. As configured today it would + re-fetch audio for ~66,540 `missingInput` videos. The 1.3 migration left the lane + `enabled: false`; the tree's channel leaves are the scope from now on. +- **`video-page.spec.ts:216`** ("Delete directory wrong-id confirmation shows an + error") is a known ordering flake, reported in 1.3 and 1.4 and not fixed: it + passes 20/20 when re-run alone on the same commit, and nothing in this phase + deletes a video directory. +- **e2e cannot run in the primary checkout** while the untracked `editor/content` + symlink exists. Every slice ran from a `git worktree`, seeded with a composed + fixture site so the export webServer answers 200. `git add -A` in this repo + stages that symlink into the commit and carries the panic into the worktree — + add by path. diff --git a/plans/one-core.md b/plans/one-core.md @@ -183,6 +183,14 @@ Makes the later deletions safe and cheap. ### Phase 1 — Dispatch: one scheduler (5 slices) +**SHIPPED 2026-09-08** on branch `one-core/phase-1` (`7f294df` → head, 33 commits, +unmerged, off Phase 0's `af2a360`), all five slices. The record — every slice's sha range, +every divergence, both operator gates and the totals — is +[`one-core-phase-1.md`](one-core-phase-1.md); the anchors are +[`FACTS.md`](FACTS.md#one-core-phase-1-verified-2026-09-08). **No live number moved**, and +both gates (A: the digest lane's production pass, from `one-core/phase-1-gate-a`; B: the +backfill lane's re-download scope) are the operator's and still outstanding. + Finishes `unified-operations-model.md` steps 5 and 6 and deletes what they retire. This is the phase with live numbers on a 78,000-video corpus; every slice is measured before/after with offline `tsx` over the real corpus (`plans/tools/phase1-numbers.ts`),