commit b4e0215e07c14fe8a647b7b5f1a1f4d9142d7bbf
parent 9cc983adaa23b1d16fa2849758515ad7d33395a0
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 20:07:26 -0400
plans: relocate-channel-media as shipped, and the rollout it is waiting on
THE PLAN gets an "As shipped (2026-09-11)" section: the slice table with its sha
ranges, both review rounds (move-back having no state precondition and nothing
checking where the root was; then a rollback that undid a step it had not
recorded, plus four edges), and eight divergences — `needsMedia` opt-in with
absent meaning false, `relocate-channel-media` joining NO_REGEN_KINDS, Preview
being the confirm rather than a step before one, the Clear-marker hatch, root
containment as a rule rather than UI copy, `sanitizeStorage` dropping a relative
root instead of resolving it, autoRunner's lane gate deliberately keeping the
corpus volume, and the worktree port block drifting from index 7 to 8 while the
branch was in flight. The suite section records 522/522 and why neither failure
at `09a2174` was this branch's.
STATE.md turns the "Next: relocate FIRST" entry into the shipped one and states
the rollout order, which is the part that is not code: mount `sdb1`, then step 0
(the saved-video store by hand — that is the step that frees the 130 GB, and it
goes first because a 100 %-full /home is a hazard to every unrelated writer while
the channel moves run), then channels through the UI, largest deprioritized
first.
FACTS.md gets one paragraph under the Phase 1 section, anchored at the fix sha:
the symlinked `data/` and `config.dataDir`, `inspectChannelMedia`'s five states,
the four guards plus `needsMedia` and the one bypass with no job record, the
per-volume gate with its ENOENT-only ancestor walk, and the root-containment
rule with what it prevents.
CHANGELOG: the existing entry described slices 1 and 2 only. Slice 3 — the
Settings default media root and the ticked rows on /channels that queue one job
per channel — is now in it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 200 insertions(+), 8 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,7 +1,7 @@
# Changelog
## [Unreleased]
-- **A channel's media can live on another drive.** A channel page has a **Storage** panel: where its media actually is, how much audio is on disk, how much room is free on the volume holding it, and **Move media to…** — give it a directory on another disk, press *Preview* to see the bytes and the free space there, and the move copies, **verifies**, and only then swaps `data/` for a link to the new location and records it. **Move back in place** reverses it. The source is never touched until the copy has verified, so a cancelled or crashed move leaves everything where it was and the partial copy resumable; re-running finishes it. Nothing else changes: every page, every job, yt-dlp and the search index read the channel exactly as before, because the path they use is unchanged. **The point is what happens when the drive is not mounted.** `data/` reads as empty then, and an empty `data/` means "nothing has been downloaded" to the download runner — an instruction to re-fetch the entire channel onto the disk that was too full to hold it. So an unreachable channel is **refused rather than guessed at**: its media jobs will not start, the four lane runners skip it (and keep running every other channel — this is not a lane stop), its report will not regenerate over an empty directory, and a red **Media unreachable** badge names the path on `/channels`, on the dashboard and on the channel itself. A relocated-and-reachable channel gets a neutral badge saying where; a channel in place gets none. The low-disk floor now measures **the volume the bytes are actually going to** rather than always the corpus disk, and holds each volume separately — a full SSD no longer pauses downloads landing on the platter. The **Media location** line on a channel's Configure form is read-only on purpose: it is a record of what is on disk, written only by a move that succeeded. **Nothing moves on its own, and nothing on disk changes until you move a channel.**
+- **A channel's media can live on another drive.** A channel page has a **Storage** panel: where its media actually is, how much audio is on disk, how much room is free on the volume holding it, and **Move media to…** — give it a directory on another disk, press *Preview* to see the bytes and the free space there, and the move copies, **verifies**, and only then swaps `data/` for a link to the new location and records it. **Move back in place** reverses it. The source is never touched until the copy has verified, so a cancelled or crashed move leaves everything where it was and the partial copy resumable; re-running finishes it. Nothing else changes: every page, every job, yt-dlp and the search index read the channel exactly as before, because the path they use is unchanged. **The point is what happens when the drive is not mounted.** `data/` reads as empty then, and an empty `data/` means "nothing has been downloaded" to the download runner — an instruction to re-fetch the entire channel onto the disk that was too full to hold it. So an unreachable channel is **refused rather than guessed at**: its media jobs will not start, the four lane runners skip it (and keep running every other channel — this is not a lane stop), its report will not regenerate over an empty directory, and a red **Media unreachable** badge names the path on `/channels`, on the dashboard and on the channel itself. A relocated-and-reachable channel gets a neutral badge saying where; a channel in place gets none. The low-disk floor now measures **the volume the bytes are actually going to** rather than always the corpus disk, and holds each volume separately — a full SSD no longer pauses downloads landing on the platter. The **Media location** line on a channel's Configure form is read-only on purpose: it is a record of what is on disk, written only by a move that succeeded. The cold drive is typed **once**: **Settings → Default media root** seeds the root box in every channel's Storage panel, and `/channels` rows can now be ticked — select several and **Move media to…** queues one job per channel on that channel's own queue, so they serialize instead of fanning out, each one running its own space check at run time rather than at enqueue time (a root that fills partway through refuses the remainder cleanly, and a channel already on that root is skipped rather than failed). The default is a default and nothing more: it is never read by the move itself, which always takes an explicit root, and a relocated channel is not thereby deprioritized. **Nothing moves on its own, and nothing on disk changes until you move a channel.**
- **Every pipeline is dispatched by one thing now: its lane’s runner. The two corpus sweeps and the arbiter are gone.** Digest and Speaker work were driven by a *sweep* — a corpus walk armed by its own switch, with its own scope, its own order and its own console — while Download and Transcription were driven by the auto-queue runner, with rules, a claim ladder, a next-up and a pick log. Two mechanisms, two vocabularies, two sets of bugs. There is one: **each of the four lanes has a runner, a rule list, and Start / Drain / Stop beside its pause**, on the operation’s own page. Arming a corpus pass is switching the lane on; scoping it to particular channels or operations is a *rule*, written the same way auto-transcribe’s have been written since it shipped. The dashboard and the widget keep a one-click switch per lane — **Run every channel** / **Stop the lane** where they said *Sweep every channel* / *Stop sweeping* — and the scope lives on the lane’s page, where you can see what it would do next. **Your armed scope is carried over, and no lane is switched on that was not.** The ten settings fields the sweeps used (`digest.sweepEnabled`, `sweepChannels`, `recencyOrder`, `recencyReach`; `backfill.sweepEnabled`, `sweepKinds`, `sweepChannels`, `order`, `reach`, `weight`) are read once and written into the lane’s rules the first time the editor starts: a sweep armed on three channels becomes three rules, an unscoped one becomes a single *every channel* rule, and a disarmed sweep becomes a switched-off lane. What is retired rather than migrated: **Reach**, because a rule already orders every video it claims across every channel — which rule goes first is the rule list’s job; the digest **order**, whose real meaning was always *newest day first, shortest video within a day* and which the lane spells as **Shortest first** (pick *Newest first* there if you want the date order alone); and the backfill lane’s **Resource share**, which was one number answering two different questions. A lane now stands aside for transcription when it would actually compete for the graphics card, and keeps its slots when it would not — so speaker-naming over an LLM endpoint no longer parks itself behind a transcription it was not competing with. **The arbiter, which never ran a single unit in production, is deleted**; the runner is what dispatches an operation-named rule. **Nothing on disk changes**, and the retired keys are left in `settings.json` — harmless, ignored, and yours to delete.
- **The transcode operation is gone — it never fired.** A channel page had a *Transcode* stage, `/operations/transcode` had a "no console here" panel, `/cleanup` offered "Clear failed transcodings", and the video list drew a third status dot — all for a re-encode step built against two failures that never happened in production: in 68 channels, no snapshot has ever listed a video as missing its target format, no `failed-transcodings` file has ever held an id, and only four channels even met the stage's gate. Transcription never needed it — a video whose audio is in another format transcribes from that file. What stayed is everything that was never the operation's: the download path still re-encodes what it extracts itself, the video page still offers **Transcode audio.\<ext\> → \<fmt\>** per file, and both audio-format sweeps on the Cleanup stage and `/cleanup` are unchanged (gated on the channel having an `audioFormat`, which is what they compare against). The snapshot bucket behind the sweep is `wrongFormatAudio` now — its operator-facing name — and old reports keep their stray key until their next refresh. A `?stage=transcode` bookmark opens the channel overview. **Nothing on disk changes.** Also: the Pool's running-jobs list names the eight kinds its buttons enqueue, and the site's Search aliases tab no longer carries a "no site selected" branch that could not run.
- **A site has tabs, and the family has one page.** Charts, Search aliases, Deploy, Build and Homepage were five sidebar entries beside *Sites*, three of them reading the site from a `?site=` parameter the sidebar picker had to seed, one of them (Build) about no site at all, and one (Homepage) about the family's own hub. A site is one thing now: **`/sites/<id>` is Settings · Charts · Search aliases · Publish**, the site named in the path, the picker following it (and Dashboard and Channels following the picker). **`/sites` is the family page**: the list, then *Release notes*, *Build all sites* with the Basic/Docker mode, the *Hub*, and the *Pool* — the corpus-wide index, stats, sidecar and archive jobs — folded under a disclosure. Search aliases keep both sections on the site's tab: the global dictionary and the site's overrides. Every button, label and log is unchanged; "Select a specific site from the sidebar" is gone because a site's page always has one. The five routes redirect — a `?site=<id>` bookmark lands on that site's tab (the query rides along), `?site=__all__` and the bare routes on `/sites`; a bookmark to a deleted site 404s there exactly as `/sites/<id>` does. The Sites group is one entry; the nav is **eleven**, the IA doc's end state. **Nothing on disk changes.**
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -3587,3 +3587,54 @@ Unchanged from the Phase 0 entry, plus one thing Phase 1 learned twice: **`git a
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.
+
+### A channel's media may be on another drive (verified 2026-09-11, branch `storage/relocate-media`)
+
+`channels/<slug>/data` may be an **absolute symlink** to `<root>/<slug>/data` with
+`config.dataDir` recording the target (`common/lib/channelConfig.ts`, written only by the
+relocate job), and the `<slug>/data` suffix is fixed rather than configurable
+(`relocatedDataDir`, `common/lib/channelMedia.ts:102`) so an empty mountpoint can never be
+mistaken for the media and `deleteChannel`/`renameChannel` can recognise a target by shape.
+**No reader changed**: the on-disk contract `channelDir/data/<id>/…` is what yt-dlp's
+cwd-relative writes, the LMDB index (mtimes, which `rsync -a` preserves) and the export build
+already use, and the only `lstat`/`readlink`/`realpath`/`symlink` calls anywhere in `common/`,
+`editor/` or `export/` are in `channelMedia.ts`, `relocateChannelMedia.ts` and
+`renameChannel.ts` — every other hit is a comment. What that buys
+in call-site churn it owes in one failure mode — a dangling link reads as ENOENT and every
+enumerator swallows ENOENT as "this channel has no videos", which to a runner means
+*everything is undownloaded* — so `inspectChannelMedia` (`channelMedia.ts:191`, two stats and
+at most one small JSON read: `in-place` / `ok` / `unreachable` / `in-transition` /
+`inconsistent`) and `assertChannelMediaReachable` (`:319`, only the first two pass) are the one
+place that can tell the two apart, and **four guards plus one bypass** call them:
+`runManagedFunction` refuses before any job record exists (`common/jobs/streamCommand.ts:277`
+and `:292`) for a kind that declares `needsMedia` — **opt-in, absent means false**
+(`common/jobs/jobKinds.ts:60`, `kindNeedsMedia` at `:464`), so an unlisted kind behaves exactly
+as it did before the guard and `relocate-channel-media` writes `false` out because it is the
+thing that fixes an unreachable channel; the four lane runners skip the channel and keep
+running every other one (`common/controller/autoRunner.ts:370-386`, three syscalls per channel
+per tick, the parsed config passed in so it is not a 69th config read); snapshot generation
+throws rather than write a snapshot saying every video is undownloaded
+(`common/controller/channelSnapshot.ts:607-619` — the scheduler keeps the last good
+`snapshot.json` on a failed refresh); `runOperationBatch` asks before deciding the candidate
+list for both operation lanes (`common/controller/operationBatch.ts:1580-1585`), as does
+`normalizeAllTranscripts` (`common/controller/normalizeAll.ts:63`, which skips and counts a
+failure rather than reporting a clean 0/0/0/0); and the ONE path with no job record,
+`saveShardConfigAction` (`editor/app/channels/[slug]/shardActions.ts:88`), asks at the top for
+all three of its branches. The low-disk gate is now **per volume**: `diskGate(paths, settings,
+{ mode, dir })` defaults `dir` to `paths.transcriptsDir` and six media callers pass the
+channel's own `data` path, the hysteresis latch is a `Map<dir, boolean>`
+(`common/lib/diskSpace.ts:215`, `diskGate` at `:243`, `isDiskGateLatched(dir?)` at `:286` —
+no-arg answers for ANY volume), and `getFreeBytes` (`:29`) **walks up to the nearest existing
+ancestor on ENOENT only**, because `channels/<slug>/data` does not exist until a channel's
+first download and the old fail-open Infinity waved that download through; every other errno
+still fails open. `autoRunner`'s lane-level gate deliberately keeps the corpus volume — it runs
+before the pick, so there is no channel yet. **A relocation root is contained by rule, not by
+hint**: `relocationRootProblem` (`common/controller/relocateChannelMedia.ts:169`, asked by the
+preview, the action and the job — `:387`, `:491`) refuses blank, relative, inside the corpus,
+and a root whose `<slug>` level resolves back into the channel dir, comparing REAL paths and
+resolving the target separately from the root; without it `root = <transcriptsDir>/channels`
+makes the source its own target, `rsync -a src/ src/` succeeds, the verify compares the tree
+with itself, and the reclaim deletes the only copy. `channels/<slug>/.relocating.json`
+(`channelMedia.ts:40`) is the in-flight marker: present means "in transition" to every guard,
+its `phase` is what lets an interrupted move resume, `deleteChannel` and `renameChannel` refuse
+while it exists, and `clearRelocationMarker` (`:160`) removes it and nothing else.
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -3,7 +3,11 @@
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-08 — **one-core Phase 1 shipped** on branch `one-core/phase-1`
+**Last updated:** 2026-09-11 — **`relocate-channel-media` shipped**, all three slices, on
+branch `storage/relocate-media` off `61eae05` and unmerged; the entry with the rollout order is
+below, under the Phase 1 record it builds on.
+
+**2026-09-08 — one-core Phase 1 shipped** on branch `one-core/phase-1`
(`7f294df` → `81a663f` plus a docs commit, 36 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
@@ -155,13 +159,37 @@ assertion, and it was right: writing the two new snapshot entries doubled `/chan
and Transcribe coverage, because that page passes the external ids into `buildOperationBands`
and `addRegistryEntry` folded them on top of `addExternalBands`. Fixed in `d8754d2`.
-**Next:** [`relocate-channel-media.md`](relocate-channel-media.md) FIRST, 3 slices —
-`/home` is at **100 %, 6.9 G free**, and the mechanism (a symlinked `data/` plus a
-`config.dataDir` record and four guards) is orthogonal to phase 2, so it neither waits on
-nor complicates the contract work. Then phase 2 (the contract: one `ArchiveReader`), 3
-slices. Read
+**2026-09-11 — [`relocate-channel-media.md`](relocate-channel-media.md) SHIPPED**, all three
+slices, on branch `storage/relocate-media` (`4059dad` → `22a3b35` plus the suite fix, 21
+commits off `61eae05`, **unmerged**; the suite is **522/522 in 23.1 min** at `549dd2e`, and
+`common` is 968/968). A channel's `data/` can be an absolute symlink to
+another drive with `config.dataDir` recording the target, moved by a Storage panel on the
+channel page or in bulk from `/channels`; four guards plus a `needsMedia` flag on the job kind
+stand between an unmounted drive and a re-download, and the low-disk gate now measures the
+volume the bytes are going to and latches per volume. **Nothing on disk moved** — the mechanism
+shipped, the bytes did not. The slice table, the two review rounds and eight divergences are in
+that plan's "As shipped (2026-09-11)"; the anchors are in
+[`FACTS.md`](FACTS.md#one-core-phase-1-verified-2026-09-08).
+
+**The rollout is the operator's, in this order:**
+
+1. **Mount the platter.** `sdb1` (1.8 T, ext4) at `/mnt/platter` with `nofail` in fstab;
+ create `/mnt/platter/archilyzer-media` and `/mnt/platter/archilyzer-saved-videos`, owned by
+ `user`. Nothing below works before this, and no code needed it.
+2. **Step 0, the saved-video store, by hand** — the plan's runbook: rsync
+ `transcripts/saved-videos/` out, verify with `--dry-run --itemize-changes`, move the
+ original aside, symlink the store root, restart, then delete the original. **That is the
+ step that frees the 130 GB**, it needs no code at all (every pointer carries an absolute
+ `dir` and nothing walks the store root), and it goes first because a 100 %-full `/home` is
+ a hazard to every unrelated writer while the channel moves run.
+3. **Channels through the UI, largest deprioritized first.** Storage panel per channel, or tick
+ rows on `/channels` and use the bulk bar; `omnimirror` (130.3 GB) is the obvious first move.
+ Sizes are in the plan's table — they are sizes, not priorities.
+
+**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.
+what is still tangled, it shrank by one across phase 1, and no slice added an entry, relocate
+included.
**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
diff --git a/plans/relocate-channel-media.md b/plans/relocate-channel-media.md
@@ -441,3 +441,116 @@ platter through the link, and the disk gate watches **that** volume for it.
- Any coupling between relocation and auto-queue weights or lane priorities. Noted in §8 as a
possible later step; not designed, not built.
- Moving `index.mdb` (13 GB) — it is hot and small relative to the media.
+
+---
+
+## As shipped (2026-09-11)
+
+Branch `storage/relocate-media`, **`4059dad` → `22a3b35` plus the fix below**, 21 commits off
+`61eae05` (the plans commit that opened the branch, 19 of code and tests, and the suite fix),
+**unmerged**. All three slices landed on the day they were planned. **No data
+moved**: every byte in `transcripts/` is where it was, and the rollout below is still the
+operator's.
+
+| Slice | Commits | What landed |
+|---|---|---|
+| 1 — core | `1cf4e64` `5b0b18f` `a371832` `cb616e7` `c26b1d5` | `common/lib/channelMedia.ts`, the four guards + `needsMedia`, `common/controller/relocateChannelMedia.ts`, delete/rename reaching the other drive, the per-dir disk gate |
+| 2 — job + UI | `2cf7e37` `edbe6bd` `520a683` `a5dae31` `4c1b849` `1c5ef7b` `0d185a8` `0111a86` | the `relocate-channel-media` kind + `storageActions.ts`, the Storage panel and the badges, the ancestor walk, resume-observes-the-disk, two more `data/` readers, the shardActions bypass, the three docs, `editor/e2e/channel-storage.spec.ts` |
+| 3 — cold root + bulk | `bb92c00` `4039355` `00c1116` `45172e4` `96d2e25` `22a3b35` | `storage.mediaRoot` + `sanitizeStorage`, the two review rounds, per-row selection on `/channels`, `bulkStorageActions.ts`, the bulk e2e case |
+
+### The two review rounds, and what closed them
+
+**Round one (`4039355`) — the two ways a move ended by deleting the only copy.** Both were
+reachable from the shipped panel and both ended in `rm -r` on media nothing else held.
+*Move back had no state precondition*: `moveOut` refuses unless the channel is in-place,
+`moveBack` never asked, and `inspect()` reports `relocated: true` for `inconsistent` and
+`unreachable` as well as `ok` — so the panel offered "Move back in place" for a channel whose
+config records a target while `data/` is a real directory, which is exactly what
+`rsync --copy-links` of a channel produces. Closed by the mirror refusal in `moveBack` plus a
+disabled button with the reason. *Nothing checked where the root was*: `root =
+<transcriptsDir>/channels` makes `relocatedDataDir(root, slug)` the SOURCE, `rsync -a src/
+src/` succeeds, `verifyCopy` compares the tree with itself, and the reclaim sweep then deletes
+the only copy — every check in the happy path being the tree against itself. Closed by
+`relocationRootProblem`, asked by all three callers (the preview the operator reads, the
+action that enqueues, the job that moves), comparing REAL paths and resolving the target
+separately from the root. The unit harness had nested its "platter" inside the corpus, which
+is the shape now refused, and is why 16 green tests never saw it.
+
+**Round two (`00c1116`) — a rollback that undid a step it had not recorded, and four smaller
+edges.** `renameChannel` recorded `movedMedia` and `relinked` but not the `unlink` of `data/`
+between them, so a throw from the `symlink` rolled the media directory back while `data/`
+stayed deleted — `inconsistent`, which round one had just made a state move-back refuses, so
+the rollback left a channel with no way back through the UI. Also closed: the missing resume
+cell (back @ copy — the only resume that finishes an rsync rather than a rename, and the only
+one asked to credit a partial copy); a preview that never checked the root existed (the
+ancestor walk statfs'd the parent of a typo and previewed plausible numbers the job then
+refused); move back's space check using neither the resume margin nor credit for the bytes
+already in `data.incoming`; and three nits (`moveBack`'s swap refusing a `data/` of kind
+"other", `sweepParked`'s unused `keep`, and the channel page reading the marker twice while
+telling the operator to delete it by hand above the button that does it).
+
+### Divergences from the plan
+
+- **`needsMedia` is opt-in and absent means false** (`common/jobs/jobKinds.ts:457-462`). The
+ plan described the guard, not the default. An unlisted or unknown kind behaves exactly as it
+ did before the guard existed, which is what keeps `refresh-report` — not in the table at all
+ — running and reporting its own refusal. `relocate-channel-media` writes `needsMedia: false`
+ out rather than omitting it: it is the thing that FIXES an unreachable channel, so a `true`
+ there would refuse it precisely when the operator needs it.
+- **`relocate-channel-media` joins `NO_REGEN_KINDS`** (`common/jobs/snapshotScheduler.ts:50`),
+ which the plan did not call for. A move preserves every byte and mtime (`rsync -a`, the same
+ property that makes the LMDB index a no-op), so a regen would walk 11,000 video dirs to write
+ a byte-identical snapshot with a newer `generatedAt` immediately after moving 130 GB. It also
+ removes a race the operator would feel: the Storage panel refuses a move while the channel
+ has a queued job, so "move out, then move back" would be blocked by a report nobody needed.
+- **Preview is the confirm.** The plan said "live preview, confirm" as two things. The panel
+ makes them one: Move is disabled until the root in the box is the root a preview described
+ (`StorageStage.tsx:193-194`), and editing the input un-confirms it. There is no second
+ dialog, and there is no way to move to a root whose numbers the operator has not seen.
+- **A Clear-marker hatch.** Not in the plan, and needed once the marker became a state every
+ guard refuses: a marker whose run is gone (a killed process, a container replaced mid-copy)
+ is otherwise a dead end. `clearRelocationMarker` removes the marker and nothing else — no
+ link touched, no config rewritten, nothing deleted — so what `inspect()` says afterwards is
+ the truth the disk was already telling.
+- **Root containment is a rule, not a hint.** `relocationRootProblem` refuses blank, relative,
+ inside-the-corpus and resolves-into-the-channel-dir. The plan's UI copy ("an absolute
+ directory that already exists") described `transcripts/channels` almost word for word.
+- **`sanitizeStorage` DROPS a relative root to blank rather than resolving it**
+ (`common/lib/settings.ts`). Resolving would anchor the default to whatever cwd the editor
+ booted in — a different directory under docker, under a worktree and under `pnpm dev` — so
+ one settings.json would name three drives. Existence is deliberately not checked: the whole
+ point of a cold root is a drive that may be unmounted when settings are read.
+- **`autoRunner.ts`'s lane gate kept the corpus volume**, though the plan listed it among the
+ callers to thread. That check runs before the pick, so there is no channel yet and the lane
+ spans 68 of them on however many volumes; the per-channel answer is taken further down in
+ `runYtdlp`, where the slug is known and the bytes are about to be written.
+- **The worktree port block drifted under the work.** `pnpm wt list` assigns by position in
+ `git worktree list`, so `storage/relocate-media` moved from index 7 to index 8 (3711/3710 →
+ 3811/3810) while the branch was in flight, and `queue-lock --ports` only VERIFIES a block is
+ free — it does not set it. A hardcoded block in a runbook goes stale; read `pnpm wt ports`
+ each session, and pass the values as env.
+- **The e2e settings fixture is replaced, not merged, and it bit once here.**
+ `helpers.writeSettings` overwrites `test-settings.json` wholesale, so a spec that does not
+ name `minFreeDiskGB` inherits the PRODUCT default of 5 GB rather than the fixture's 0 — and
+ `/home` was at 6.9 GB free. `channel-storage.spec.ts:209` failed 4/4 on exactly that (the
+ move's space check is charged floor + resume margin = 7 GB) and was fixed by writing the key
+ out by hand at `:219-221`. The general fix landed with the suite below, and took `widget.spec.ts` — which wanted the gate ARMED and was getting it from the same gap — with it.
+
+### The suite
+
+**522 passed, 0 failed of 522, 23.1 min** at the fix sha `549dd2e`, one worker behind the
+machine-global queue lock, from a worktree with a composed fixture site in `export/public`.
+`common` is 968/968 and `tsc --noEmit` is clean in both packages. The
+run before the fix, at `22a3b35`, was **519 passed / 2 failed** — `backfill.spec.ts:457` and
+`scheduler.spec.ts:29` — and the first run WITH it was 521/1, the one failure being
+`widget.spec.ts:591`, which wanted the disk gate armed and had been getting it by accident
+from the same fixture gap; it names its floor now.
+
+Neither of the original two is this branch's: `backfill.spec.ts:457` is an old
+`uncheck()`-did-not-take flake, reproduced twice in six repeats here; `scheduler.spec.ts:29` did not
+reproduce at all — 8 green runs at the same sha with a clean tree, including an exact
+replication of the failing command — and the tick path this branch touched cannot empty
+`queued` on that fixture (the media guard answers `in-place` for `slow-a`, and the disk gate
+measures the same volume before and after the change, proved offline). Its assertion now
+carries the tick's own `reason` and slow-a's own skip line, because a tick has four ways to
+queue nothing and all four printed the same `Received: []`.