commit 0f8a0a1b38f9f64c2fc3277951efffc8005aaa7c
parent 90734588094312cfbe51122267dc11089c55a459
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 12:02:33 -0400
plans: slice RM, as shipped — a move holds the channel's writers and mirrors its copy; which writer got past the guard on 2026-09-30; FACTS; the editor changelog
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 254 insertions(+), 1 deletion(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -14,6 +14,7 @@
- **A job cancelled before it started now stays cancelled.** Its record on disk kept saying "queued", so a restart could put a job you had just cancelled back in its queue, and a clip fetch cancelled while waiting could be reported as still queued. Jobs still waiting when the editor shuts down are handled as before: the next start settles or re-queues them.
- **A site's Charts tab gives a sixth series its own colour.** The dashboard's charts coloured their series from five colours and started again at the sixth, so a chart broken down by six or more channels drew the sixth in the first one's colour. The sixth now takes the palette's sixth colour, and each from the seventh on a hue of its own; the first five are unchanged. The published sites' charts get the same change with their next build.
- **A form whose save is refused keeps what you typed.** Every editor form put its plain fields back to the stored values when its save was refused — a site's ID rejected, a page size out of range, a slug already taken — so everything typed had to be typed again. A refused save now leaves every field as you left it, beside the reason: **Settings**; a site's form (new and existing); the hub's config on `/sites`; **Cut release**; a channel's form (new and **Configure**), **Rename** and **Delete**; a video's **Delete directory**; **Drive health timing** on `/storage`; the backup config on `/saved-videos`; the sync operation's controls; the **Digest**, **Diarization**, **Speaker attribution** and **Speaker work lane** settings; and the worker list on `/workers`. A save that succeeds behaves as before, with one difference you may notice: a drop-down, and a checkbox or choice that the page tracks as you change it (a cadence, a worker's **Enabled**, a social link's **Keep in header**, a site membership, a site's accent), now shows what was saved. A form's own drop-downs used to go back to what the page had loaded with until a reload, and a second save from the same page sent that old choice again; the others went back until the page next refreshed itself (every 5 seconds by default).
+- **A media move no longer starts over a job that is writing into the channel, holds the channel's writers while it runs, and makes its copy match the source before it verifies — so a transcription or a download during a move cannot fail it.** A move that has waited its turn behind other moves now checks again when it starts: if a job is running on the channel, or an auto-queue lane is working on one of its videos, it stops at once and says which ("a transcription of v50t5yt is running (Transcribe all, job …) — wait for it or cancel it"), with nothing copied; **Preview** says the same, and the Storage panel's blocked message now names the job too. While a move's marker stands, the channel is held: every lane skips it, and every job that reads or writes its media (single-video transcriptions, downloads and transcodes and the availability checks now included) refuses to start, including one that was already queued when the move began. The rack shows a **media held** chip in the channel's Tier cell and the Storage panel says "Held: its media is moving"; both go when the move finishes or its marker is cleared. The copy is now followed by a pass that makes the destination copy match the source — files the source no longer has are removed from the copy, never from the source — so a file written or deleted during the copy (a transcriber's scratch folder, say) no longer fails the check, and **Resume move** finishes a move whose copy holds such leftovers. If the source keeps changing, the move stops and lists what differs: extra on the destination, missing there, or changed. A new **Reconcile and resume** button beside **Resume move** lists those differences, makes the copy match and finishes the move, so no file has to be deleted by hand. The saved-video store's move does the same matching and the same check before it starts. Needs a rebuild and restart of the editor.
## [0.11.0] - 2026-09-30
- **Transcripts that arrived after a video was first seen are counted.** The stats behind the homepage, the hub and every site's charts were cached per video and refreshed only when the video's metadata changed, so a transcript that came later — a Whisper run days after the download, or a video downloaded after the last index build — never reached them, and a video with YouTube captions alone had no transcription date. Counts and charts were low; the homepage could show a site with 0 transcripts, 0 channels and 0 hours while it served its videos. A stat is now also redone whenever the index re-reads the video, every transcript has a date, and a captioned video is dated by when its captions arrived rather than by a later Normalize run, so its place on "Transcribed over time" can move. **After updating, rebuild and restart the editor before anything else:** until then, **Build stats dataset** runs the old code and would undo the new stats, while a site, hub or homepage build already runs the new code — and the first stats build of any kind re-reads every video once (about 10–30 minutes on a large archive; it can be stopped and picks up where it stopped). Then build the index, the stats, the homepage, the hub, and the sites.
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -8090,3 +8090,92 @@ source mirror (homepage)". Anchors are at the branch.
(a read never throws). `forms-keep-input.spec.ts` `withSettingsUnwritable` restores the file in a
`finally`. A background writer in that window fails the same way, so the spec keeps it to one
submit.
+
+## A move holds the channel's writers and mirrors its copy (verified 2026-10-01, branch `r16/move-holds-writers`)
+
+The record is [`release-16.md`](release-16.md), "Slice RM, as shipped". Anchors are at the branch tip.
+This section amends "A channel's media may be on another drive" (above): **a move is now serialized
+against the channel's own writers by refusal at the job's start as well as at enqueue**, and the copy
+phase deletes from the destination.
+
+- **Which writer got past the guard on 2026-09-30 (`realcandaceo`, from the job metas, read only).**
+ Not a lane: the lanes skip a channel with a marker twice over (guard 2 in `buildChannelWork`, and the
+ pick→run backstop that reads the marker before a unit launches), and both held. It was a manual
+ **Transcribe all** (`whisper-all`, job `01M3TGYDXA7WCBGN014A90J3JW`, queue `transcription`, spec
+ `params: {}` — the scan over the channel's 434 items). The move (`01M3TGHZSMQHHYXH44ATXXFKSQ`) was
+ queued 21:16:42 and passed the action's busy check (nothing running); the Transcribe all was queued
+ and started at 21:23:30 — no marker existed, so its `needsMedia` guard passed, correctly; the move
+ started at 21:36:28 after waiting behind other moves on the one relocation queue, and asked nothing
+ about the channel's jobs. `v50t5yt`'s transcription (749.78 s) began ≈21:25:37, its parakeet scratch
+ dir (`.audio.mp3.parakeet/`, meta + four windows) was copied, and its outputs landed at
+ 21:38:06–07, after rsync had passed the directory; the verify at 22:02:37 refused (4 differ). The
+ batch then **kept writing while the marker stood** — `v511otv` at 22:40:29, `v519k6c` at 23:13:25,
+ `v51fpcd`'s scratch from 23:21 — until it was cancelled at 23:29:44: a job's guard ran only at
+ enqueue, and its per-video loop never re-asks. The resume (`01M3VX740RAK1H24RZ2CRKYZ79`, 10:17) copied
+ the new files and refused on the counts, 1755 against 1750 — the five scratch files on the
+ destination, which a copy that never deletes could not settle.
+- **The three closes.** (1) The move refuses over a writer at its preview, its job's FIRST STEP and a
+ third time right after the copy phase's marker is written (`relocateChannelMedia.ts:162`
+ `assertNoWriters`, `:488`, `:546`, `:667` `assertNoWritersUnderMarker` — the third removes a marker
+ this run created). (2) A `needsMedia` job's guard is asked again when the queue STARTS it
+ (`jobs/streamCommand.ts:429`, `refuseForUnreachableMedia` `:327`), so a job queued before a marker
+ and started after it fails before `fn` runs. (3) The per-video writers that were not in
+ `JOB_KINDS` at all — `whisper-video`, `transcribe-one`, `download-one-pipeline`, `transcode-audio`,
+ `check-availability`, `quick-availability-check`, `check-maybe-missing` (`jobs/jobKinds.ts:577`
+ on) — declare `needsMedia`; absent meant false, so none was ever refused. Labels stay absent
+ (`/jobs` unchanged). Not in the table and still not refused: `worker-transcribe`/`worker-unit` (this
+ box as a worker: a scratch dir or another machine's mount), `refresh-report` (asserts reachability
+ itself), and the corpus-wide `normalize-live-chat`/`archive-*` (no slug).
+- **Who is a writer: `controller/channelWriters.ts`.** `channelWriters(slug, opts)` (`:86`): registry
+ jobs whose `channelSlug` is the slug, running (queued too with `includeQueued`), minus
+ `ignoreKinds`, then every lane's in-flight units for the slug (`getAutoRunnerStatus(lane).inFlight`).
+ A lane download unit is also an `auto-download-unit` job; it is named once, as the lane's.
+ `describeChannelWriter` (`:151`) names the first task's video ("a transcription of v50t5yt is running
+ (Transcribe all, job …)"); `channelWritersRefusal` (`:177`) is the move's sentence. The move's job
+ passes `ignoreKinds: ["relocate-channel-media"]` (it is itself running on the slug; the relocation
+ queue runs one at a time); the preview passes nothing. **The editor's `channelMediaBusyReason`
+ (`editor/app/channels/lib/mediaBusy.ts`) reads the same list with `includeQueued`**, keeps its
+ counts sentence (the e2e regex `running\/queued job\(s\) for this channel` still matches) and
+ appends `Now: <the first writer>.`. Corpus-wide jobs carry no slug and are not writers here:
+ `normalizeAll` and `evictClipWindows` re-ask the hold per channel, and the mirror covers a write that
+ lands mid-copy.
+- **The hold's words: `lib/channelMediaHold.ts`.** `HELD_REASON["in-transition"]` (`:40`) leads with
+ "its media is moving (a move is in progress or was interrupted)" — it also reaches both builds' held
+ lines. `mediaHoldText(status)` (`:52`) is "held: <reason>" or null — the rack's chip
+ (`ChannelTierSelect`, `aria-label="media hold for <slug>"`, visible "media held", the sentence on
+ `title` and in sr-only text), the Storage panel's line (`aria-label="media hold"`), the refused
+ job's sentence, the runners' skip log. It reaches the row as `ChannelRowView.mediaHold`
+ (`views/channelRow.ts:114`) and the panel as a server-computed prop; `lib/storageLocations.ts`
+ (imported by the hold module) is never pulled into a client file by this. The runners skip on
+ `isMediaHeld` (`autoRunner.ts:641`); their skip log is now `[auto] skipping <slug>, held: <reason>:
+ media <status> — <detail>` (the old `media <status> — <detail>` is its tail).
+- **The mirror: `controller/relocateDir.ts`.** `MIRROR_ARGS = ["-a", "--delete"]` (`:344`); the
+ verify's dry run is `VERIFY_ARGS` (`:348`, `--delete` included, so an extra on the destination is a
+ difference, `*deleting`). **`--delete` only ever targets the copy under construction**:
+ `mirrorTree` (`:487`) calls `assertMirrorDirection` (`:461`) before rsync is spawned — the
+ destination must equal the caller's `underConstruction` (the directory the move made to copy into:
+ `<root>/<slug>/data` out, `data.incoming` back, `<root>/saved-videos` for the store) and neither
+ tree may contain the other (realpaths). `copyMirrorVerify` (`:550`) is the copy phase for both
+ movers and both directions: the progress copy (`COPY_ARGS`), the mirror pass, `verifyCopy` in mirror
+ mode (`verifyMirrored`, `:690`): an empty itemized dry run plus equal counts; a difference gets one
+ more mirror pass (`retried`), a second refuses with `CopyVerificationError` (`:425`) carrying the
+ differences by kind (`classifyDrift`, `:386`: `*deleting` → extra on the destination, all-`+`
+ attributes → missing on the destination, anything else → changed). **A swap-phase re-verify
+ mirrors only from the live media** (`data/` still a real directory, `relocateChannelMedia.ts:863`);
+ against a parked `data.relocated-*` it is the strict legacy verify — the target is never mirrored
+ from a copy that is no longer live. Resume takes the copy phase again, so stale files on a
+ destination are removed.
+- **Reconcile and resume** = `relocateChannelMedia({ reconcile: true })`, refused without a marker
+ (`:551`): the copy phase skips the progress copy, logs `diffTrees` (`relocateDir.ts:510`) by kind
+ ("Reconciling: the destination copy differs from the source — …"), and the mirror pass carries the
+ progress sink. Editor: `reconcileRelocationAction` (`storageActions.ts:136`, Resume's guards), the
+ Storage panel's button beside Resume move (log `"Reconcile and resume output"`), the job's done line
+ "(reconciled and resumed an interrupted move)".
+- **The saved-video store shares the pipeline** (`relocateSavedVideos.ts` copy phases →
+ `copyMirrorVerify`; the swap re-verify mirrors while the store is still a real directory) and takes
+ `busy` (`:246`, asked first, `:259`); the editor's job passes the store check with `runningOnly`
+ (`editor/app/storage/lib/storeBusy.ts`). It has no reconcile button.
+- **Not covered:** a lane tick that inspected the channel before the marker landed and dispatches
+ after the third ask (milliseconds; the pick→run backstop and the mirror cover it); a corpus-wide
+ writer mid-channel when a move starts; a resumed or reconciled move-out is charged the whole tree
+ against the destination's free space, not the remainder (move-back charges the remainder).
diff --git a/plans/release-16.md b/plans/release-16.md
@@ -24,7 +24,7 @@ slice's prompt carries its ruling, and this record carries what was built. Rules
| CK | `r16/search-in` | A "Search in" row — Transcripts, Posts, Live chat — on the export and hub search, Transcripts and Posts on by default | `common/components/{FiltersPanel,SearchSessionContext,SearchResults,SearchBar,exportFilterStorage}.tsx/.ts`, `common/lib/searchQuery.ts` and `common/lib/search/*` as its prompt names, `export/e2e/search-in.spec.ts` (new) and the specs its prompt names, `export/e2e/helpers.ts`; records: `plans/FACTS.md` |
| DX | `r16/research-setup` | The research-only setup (source → `pnpm install` → `claude mcp add archilyzer` → `/ask`) in one place, the homepage's AI and MCP doc; the sites' and the hub's Use-with-AI page removed and its links pointed at the doc; `README.md` §1/§4 and `mcp/README.md` their own copies (as amended) | `homepage/content/docs/ai-and-mcp.md`, `export/app/use-with-ai/` (removed), the Use with AI links (`export/app/components/{Header,MobileMenu,Footer}.tsx`, `export/app/(workspace)/ask/page.tsx`), `common/lib/{project,corpus}.ts` + `common/bin/compose-site.ts` (what named the page), `mcp/README.md`, `README.md` §1/§4 (wording only), `homepage/e2e/docs.spec.ts`, `export/e2e{,-hub}/use-with-ai-link.spec.ts` and the specs that visited the page |
| FK | `r16/forms-keep-input` | Every editor form keeps what was typed when its action fails: actions return the submitted values with the error, the shared field helpers seed from them | new `editor/app/lib/formState.ts` + test; `editor/app/components/forms/Field.tsx` and the local `Field`s in `SiteForm.tsx`, `ChannelForm.tsx`; every action that returns `{ok:false,error}`/`{error}` (sites, settings, operations/settingsActions, scheduler, storage, homepageActions, cutReleaseAction, channels, videoActions); the 15 forms the ruling lists; e2e `forms-keep-input.spec.ts` (new) + the existing `sites-crud`, `settings`, `channels` specs; records: `plans/FACTS.md`. As shipped, also `editor/app/components/forms/Controlled.tsx` (new) and one tag swap each in `DurationField`, `SocialLinksField`, `SiteMembershipsSection`, `WorkersField` ("Slice FK, as shipped") |
-| RM | `r16/move-holds-writers` | A media move holds the channel's writers and mirrors its copy, so a transcription or download during the move cannot fail it | `common/controller/relocateChannelMedia.ts` (+ the saved-video mover if it shares the code) + tests; the lane runners' per-channel skip (`common/controller/autoRunner.ts`, the backfill/digest/normalize/clip-fetch entry points, `common/lib/channelMediaHold.ts`); `common/jobs/jobKinds.ts` (`needsMedia`); the channel page's Storage panel and the rack's reason text; `editor/e2e/relocate*.spec.ts`; records: `plans/FACTS.md` |
+| RM | `r16/move-holds-writers` | A media move holds the channel's writers and mirrors its copy, so a transcription or download during the move cannot fail it | `common/controller/relocateChannelMedia.ts` (+ the saved-video mover if it shares the code) + tests; the lane runners' per-channel skip (`common/controller/autoRunner.ts`, the backfill/digest/normalize/clip-fetch entry points, `common/lib/channelMediaHold.ts`); `common/jobs/jobKinds.ts` (`needsMedia`); the channel page's Storage panel and the rack's reason text; `editor/e2e/relocate*.spec.ts`; records: `plans/FACTS.md`. As shipped, also `common/controller/{channelWriters,relocateDir}.ts` (+ tests), `common/jobs/streamCommand.ts`, `common/views/channelRow.ts`, `editor/app/channels/lib/{mediaBusy,relocationJob}.ts`, `editor/app/storage/lib/{storeBusy,savedVideosJob}.ts`, `editor/app/api/test/stuck-job/route.ts`; the relocate spec is `editor/e2e/channel-storage.spec.ts` ("Slice RM, as shipped") |
## Slice CK — the ruling (2026-09-30)
@@ -761,6 +761,169 @@ FK row.
`new-channel-onboarding`, `social-channel`, `sites-crud`, `settings`: **70 passed**, 0 failed,
5.6 min. The full suite was not re-run, as the parent directed.
+### Slice RM, as shipped — a move holds the channel's writers and mirrors its copy (2026-10-01)
+
+Branch `r16/move-holds-writers` off `main` `2e2f6b2a` (the ruling's remediation bullet merged in from
+`9cec5be6`, a fast-forward), worktree `~/Projects/plans-export-header-first-search` (editor 4201,
+test 4211, export 4210 — `pnpm wt list`'s block #12), one Opus implementer. Scratch files `rm-*` in
+the job's `tmp`. The ruling is above ("Slice RM — the ruling").
+
+**Which writer got past the guard on 2026-09-30** (the job metas and logs in `transcripts/.jobs/`
+and `realcandaceo/data/v50t5yt/transcribe-outcome.json`, read only). Not a lane: the lanes skip a
+channel with a marker twice over (the projection's media check and the pick→run backstop), and both
+held. It was a manual **Transcribe all** (`whisper-all`, job `01M3TGYDXA7WCBGN014A90J3JW`, the scan
+over the channel's 434 items):
+
+| When | What |
+|---|---|
+| 21:16:42 | The move (`01M3TGHZSMQHHYXH44ATXXFKSQ`) is queued; the action's busy check finds nothing running |
+| 21:23:30 | Transcribe all is queued and starts. No marker exists yet, so its media guard passes — correctly |
+| ≈21:25:37 | Its transcription of `v50t5yt` begins (749.78 s); parakeet's scratch dir `.audio.mp3.parakeet/` fills window by window |
+| 21:36:28 | The move starts, after twenty minutes behind other moves on the one relocation queue. It asks nothing about the channel's jobs, writes the marker and copies — the scratch dir (meta + four windows) included |
+| 21:38:06–07 | `v50t5yt`'s transcript, cues and outcome land, after rsync had passed the directory; the scratch dir is deleted from the source |
+| 22:02:37 | The verify refuses: four paths differ. The marker stays (phase copy) |
+| 22:40, 23:13, 23:21 | The batch keeps writing with the marker standing — `v511otv`, `v519k6c`, `v51fpcd`'s scratch — until cancelled at 23:29:44 |
+| 2026-10-01 10:17 | The resume copies the new files and refuses on the counts, 1755 against 1750: the five scratch files on the destination, which a copy that never deletes could not settle |
+
+So three things let it through, and the slice closes each: the move asked about the channel's jobs
+only when it was enqueued; a job's media guard ran only at enqueue, never when its queue started it
+(and its per-video loop never re-asks); and seven per-video writers were not in `JOB_KINDS` at all,
+so they were never refused for a moving channel.
+
+**What it does.**
+- **A move refuses to start over a writer, and names it.** `common/controller/channelWriters.ts`
+ (new) reads who is writing into a channel: running registry jobs on its slug (queued ones too for the
+ editor's courtesy check) and every lane's in-flight units for it; a lane's download unit, which is
+ also a registry job, is named once. The refusal: `Cannot move the media of "realcandaceo" now: a
+ transcription of v50t5yt is running (Transcribe all, job 01M3…) — wait for it or cancel it. Nothing
+ has been touched.` (a lane unit: "wait for it, or hold the transcription lane"). Asked by
+ `previewRelocation`, by the job's first step, and a third time the moment the copy phase's marker is
+ written — after which the lanes skip the channel and a media job refuses to start, so that answer
+ cannot go stale; a refusal there removes a marker this run created. The editor's
+ `channelMediaBusyReason` (the panel's blocked message, rename, delete, the bulk move's skips) reads
+ the same list and appends `Now: <the first writer>.` to its counts.
+- **The writers are held while the marker stands.** A media job's guard (`needsMedia`) is asked again
+ when its queue starts it (`jobs/streamCommand.ts`), so a job queued before a move and started during
+ it fails before it runs, in the hold's words: `Channel "x" is held: its media is moving (a move is in
+ progress or was interrupted) — …`. `whisper-video`, `transcribe-one`, `download-one-pipeline`,
+ `transcode-audio`, `check-availability`, `quick-availability-check` and `check-maybe-missing` now
+ declare `needsMedia` (labels still absent, so `/jobs` shows them as before). The lanes skip on
+ `isMediaHeld`, logging the hold's sentence. `HELD_REASON["in-transition"]` leads with "its media is
+ moving", and `mediaHoldText` is the one wording: the rack's **media held** chip in the Tier cell
+ (`aria-label="media hold for <slug>"`, the sentence on `title`) and the Storage panel's "Held: its
+ media is moving (…). The lanes skip this channel and its media jobs refuse to start until the hold
+ lifts — when the move completes, or its marker is cleared below." Both go with the marker.
+- **The copy mirrors, toward the destination only.** `relocateDir.ts`'s `copyMirrorVerify` is the copy
+ phase for both movers and both directions: the progress copy, then `rsync -a --delete` source → the
+ copy under construction, then a verify whose dry run carries `--delete` plus equal counts. One
+ change seen between the mirror and the check gets one more mirror pass; a second refuses with the
+ paths by kind — extra on the destination, missing on the destination, changed — and "Something is
+ still writing into <src>: stop it, then Reconcile and resume." `mirrorTree` asserts the direction
+ before rsync is spawned: the destination must be the directory the move created to copy into, and
+ neither tree may contain the other. A resume takes the same path, so the realcandaceo leftovers are
+ mirrored away. A swap-phase re-verify mirrors only while `data/` is still the live directory; against
+ a parked `data.relocated-*` it stays the strict verify, so the target is never mirrored from a copy
+ that is no longer live.
+- **Reconcile and resume** (the remediation bullet): a button beside **Resume move**, with its own log
+ ("Reconcile and resume output"), offered under Resume's condition (a marker, nothing running). One
+ relocation job with `reconcile`: no copy pass; it lists how the destination differs from the source
+ by kind ("Reconciling: the destination copy differs from the source — 2 extra on the destination
+ (…), 0 missing on the destination, 2 changed (…)"), mirrors (the progress bar rides the mirror
+ pass), verifies, swaps and reclaims; the done line says "(reconciled and resumed an interrupted
+ move)". Without a marker it refuses.
+- **The saved-video store's move shares the pipeline** — `relocateSavedVideos` uses
+ `copyMirrorVerify` both ways and mirrors its swap re-verify while the store is still a real
+ directory — and takes a `busy` first step, which the editor's job fills with the store check
+ (`savedVideosStoreBusyReason(…, { runningOnly: true })`: by then a merely queued download is not
+ writing, and its persist is refused by the marker anyway). It has no Reconcile button; its Resume
+ mirrors.
+
+**Commits**
+
+| Commit | What |
+|---|---|
+| `50fc580c` | `common:` `channelWriters.ts` + test; the mirror, the direction assertion, `copyMirrorVerify`, the verify by kind (`relocateDir.ts` + test); both movers (`reconcile`, `writers`, `busy`) + tests; the start-time media guard + test; seven `needsMedia` kinds; the hold's words, the lanes' skip + test; `ChannelRowView.mediaHold` |
+| `1c5b9739` | `editor:` the Storage panel's hold line and Reconcile and resume; the rack's chip; `mediaBusy` names the writer; the store job's first step |
+| `0ee291f9` | `editor(e2e):` `channel-storage.spec.ts` — four cases (below); `/api/test/stuck-job` takes `slug` and `task` |
+| this commit | `plans:` this section, the slices table's RM row; FACTS; the editor changelog |
+
+#### Gates (logs `$T/rm-*.log`)
+
+- **tsc** (all workspaces) clean at `0ee291f9`, after deleting the worktree's stale
+ `export/.next/{dev/,}types` (still naming the removed `/use-with-ai` page, DX's note).
+- **common:** **2,432/2,432**, 78 s (`main`'s 2,404 + 28) — new: `channelWriters.test.ts` 7, `relocateDir.test.ts` +8 (the
+ mirror carries a file added after the copy pass and removes one deleted after it; the realcandaceo
+ stale dir; one change → one more pass; a second → refused by kind; reconcile lists then mirrors;
+ the `--delete` direction refused before rsync spawns; the itemize classifier),
+ `relocateChannelMedia.test.ts` 39 (two old cases re-premised, below; +9: a stray file mirrored
+ away, the strict parked re-verify, the refusal over a running job, the preview's, a writer seen
+ under the marker, a resume over a stale scratch dir, the same coming back, reconcile, reconcile
+ without a marker),
+ `relocateSavedVideos.test.ts` +2, `streamCommand.test.ts` +1 (a media job queued before the marker
+ refuses at its start), `autoRunner.test.ts` +1 (every lane projects nothing for a marked channel and
+ the hold lifts with the marker). **Editor unit:** 109/109. **test:scripts:** 302 passed, 2 skipped
+ (304).
+- **Build:** the capped editor build with the corpus linked (`ln -sT` the primary's `transcripts`,
+ `systemd-run --scope -p MemoryMax=5G`, `pnpm --filter editor exec next build`, the link removed
+ after): exit 0, 47 s, 1.68 GB peak, at `0ee291f9`.
+- **Numbers tool:** none.
+
+ | Run | At | Specs | Result |
+ |---|---|---|---|
+ | 1 | `0ee291f9` | `channel-storage`, `storage-locations`, `channels-storage-columns`, `channels`, `channel-rename`, `ops-api`, `channel-priority`, `channels-rack-layers`, `saved-videos`, `review` (which also matched `site-publish-preview`), `fetch-window` | **88 passed**, 1 failed, 6.9 min — `channel-rename` "rename and delete refuse while a job is writing": **Delete channel** was disabled when clicked (below) |
+ | 2 | `0ee291f9` | `channel-rename` × 4 | **8 passed**, 0 failed, 49 s |
+ | 3 | `0ee291f9` | the full editor suite | **683 passed**, 3 failed, 12 skipped (the rack-audit shots), 51.8 min — `jobs-channel` "auto-refreshes the jobs list": every button on the Playlist stage still disabled after 30 s (the page never hydrated); `perf-budget` "the dashboard renders within budget": `resetData` met `EEXIST` making `test-transcripts/channels` (the fixture race helpers.ts describes); `widget` "+N more expands": `ERR_CONNECTION_REFUSED` — the log's one `[WebServer] ⚠ Server is approaching the used memory threshold, restarting...` landed on it |
+ | 4 | `0ee291f9` | `jobs-channel`, `perf-budget`, `widget` | **34 passed**, 0 failed, 1.4 min (after ~4 min waiting for the queue). None of the three touches what the slice changed beyond the shared job start |
+
+ The four new `channel-storage` cases: **a move that starts while a job writes into the channel
+ refuses, naming the job** (the 2026-09-30 shape: a stuck job holds the relocation queue for 4 s, the
+ move goes in through `/api/ops/relocate`, a fake running Transcribe all on the channel's video
+ appears, the move starts and fails with the exact sentence; no marker, no copy; the panel's blocked
+ message names the writer); **the rack and the Storage panel show the hold while a marker stands, and
+ it lifts with it** (Clear marker); **Resume move** extended with a planted stale scratch dir on the
+ destination, gone after the resume; **Reconcile and resume settles an extra and a changed file on
+ the destination**.
+
+#### Found and left
+
+- **Run 1's `channel-rename` failure is that spec's race with the page's own refresh**, not this
+ slice: the spec renders the Danger zone while the channel is quiet, starts a sync out of band, and
+ submits Rename then Delete from the stale page. The sync's start moves the pulse token, and when the
+ next pulse tick (every 5 s) lands between the two submits, the refreshed page renders Delete
+ disabled with the busy reason (`error-context.md` shows both forms blocked, the reasons naming the
+ sync). 8/8 on the rerun. Not changed: the spec is not this slice's.
+- **The two existing tests whose premise the ruling reversed**, re-premised: "a verify failure keeps
+ the source…" planted a stray file on the target and expected a refusal — now the mirror removes it
+ (that case is "a stray file on the destination is removed by the mirror pass"), and the refusal case
+ is a source that keeps changing; "out @ swap: a file the target is missing is still a refusal" is now
+ "… is mirrored by one more pass".
+- **Corpus-wide writers carry no slug**, so the move's check cannot name them: a corpus-wide Normalize
+ or Evict already inside a channel when its move starts is not refused. Both re-ask the hold per
+ channel, and a write that lands mid-copy is carried by the mirror (or refused by kind if it keeps
+ landing).
+- **Kinds still outside `JOB_KINDS` with a slug:** `worker-transcribe` / `worker-unit` (this box as a
+ worker, writing into a scratch dir or another machine's mount), `refresh-report` (it asserts
+ reachability itself), and the slug-less `normalize-live-chat` / `archive-*`.
+- **A lane tick that inspected the channel just before the marker landed** can dispatch a unit after
+ the third ask (milliseconds); the pick→run backstop reads the marker, and the mirror covers the
+ rest.
+- **A resumed or reconciled move-out is charged the whole tree against the destination's free space**,
+ not what is still missing (the move back charges the remainder). Unchanged; a near-full destination
+ can refuse a resume it has room for.
+- **A server action that writes into a video's directory without a job** (the video page's edits) is
+ not held by the marker; the mirror carries its write.
+
+#### Decisions the operator could overturn
+
+| What I did | The alternative |
+|---|---|
+| The move's own check refuses over RUNNING jobs and lane units; a QUEUED media job is refused when it starts instead | Refuse the move over queued jobs too (the editor's enqueue-time check does): the operator re-queues the move after a twenty-minute wait for a job that would not have written yet |
+| A third ask right after the marker is written, removing a marker the run created | The first step only; a job that starts while a big tree is measured would be copied over |
+| The swap-phase re-verify mirrors while `data/` is live, never from a parked copy | Mirror from the parked copy too: it would delete from the target what was written through the link after the swap |
+| The Storage panel's busy message keeps its counts and adds `Now: <writer>.` | Replace the counts with the named writer (the `ops-api` spec pins the counts' wording) |
+| The rack's hold is a chip in the Tier cell, shown for every held status (unreachable, stalled and inconsistent too: the lanes skip all of them) | Only for a moving channel, as the ruling's sentence names |
+| Reconcile and resume skips the progress copy and lets the mirror pass carry the bar | Run the copy pass first, as Resume does — the same bytes either way |
+
## Rollout
Both slices are export- and homepage-side; the editor and umtool are not rebuilt for this release.