commit 5cef97da786ba04872c24c9e8f13713231df0e3f
parent 46801ea4dca7abc8c3cfd2e7968210f104dd3514
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 17 Sep 2026 13:49:01 -0400
relocate: tests for the two halves of the omnimirror refusal
The mid-run marker guard gets its own file for the settings seam getPaths()
memoizes — the videoOperations.test.ts arrangement. The batch runs the real
backfill lane over two seeded videos whose only classification is
`missing-input`, because the guard sits BEFORE the classification and what the
classification answers only has to be deterministic.
setProgress is the seam for "a relocation started mid-run": it fires once to
seed the bar and once per resolved candidate, so the second call is exactly
the window between the first video and the second pull. A control case asserts
both videos are reached when no marker appears.
The verify pair runs at phase swap, where the re-verify runs with no `rsync -a`
ahead of it — the only shape that can tell a settled timestamp from a copied
one. A file written into the source after the copy bumps its directory mtime
TOO, so the refusal case proves one non-directory line is enough.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 223 insertions(+), 0 deletions(-)
diff --git a/common/controller/operationBatchRelocation.test.ts b/common/controller/operationBatchRelocation.test.ts
@@ -0,0 +1,132 @@
+// THE BATCH'S FIFTH GUARD: a relocation that starts AFTER the run did.
+//
+// Run with:
+// node_modules/.bin/tsx --test common/controller/operationBatchRelocation.test.ts
+//
+// Its own file because it needs the SETTINGS SEAM — the diarization entry is
+// only in the backfill lane when settings say so, and getPaths() memoizes its
+// first answer at module scope, so TRANSCRIPTS_DIR and SETTINGS_FILE must be
+// set before anything can import the module under test. Same arrangement as
+// videoOperations.test.ts and laneForOperation.test.ts.
+//
+// WHAT IT PINS. The reachability guard at the top of runOperationBatch runs
+// ONCE, at start. The omnimirror incident (2026-09-13) is what that misses: the
+// relocate copy began while an operation batch was already running, and a unit
+// writing into `data/` mid-rsync left the copy's verify refusing. The marker is
+// now re-read on every candidate pull, and finding one ENDS the run.
+
+import { mkdtempSync, writeFileSync } from "node:fs";
+import { mkdir, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { test, after } from "node:test";
+import assert from "node:assert/strict";
+
+const ROOT = mkdtempSync(path.join(os.tmpdir(), "batch-relocation-"));
+process.env.TRANSCRIPTS_DIR = ROOT;
+const SETTINGS_FILE = path.join(ROOT, "settings.json");
+process.env.SETTINGS_FILE = SETTINGS_FILE;
+
+// The backfill lane armed with ONE operation, and that operation's cheapest
+// classification: every seeded video has a transcript and no audio, so
+// diarization reads `missing-input` — counted, never dispatched, and (with
+// re-download off) needing no engine, no model and no network. The guard under
+// test lives BEFORE the classification, so what the classification answers only
+// has to be deterministic, not interesting.
+writeFileSync(
+ SETTINGS_FILE,
+ JSON.stringify({
+ backfill: { enabled: true, allowRedownload: false, concurrency: 1 },
+ diarization: {
+ enabled: true,
+ segModel: "/models/seg-1.onnx",
+ embModel: "/models/emb-1.onnx",
+ },
+ attribution: { enabled: false, diarizedEnabled: false, textOnlyEnabled: false },
+ }),
+ "utf8",
+);
+
+const { runOperationBatch } = await import("./operationBatch");
+const { getPaths } = await import("../lib/paths");
+const { relocationMarkerPath } = await import("../lib/channelMedia");
+
+after(() => rm(ROOT, { recursive: true, force: true }));
+
+async function seed(slug: string, ids: string[]): Promise<void> {
+ const channelDir = path.join(getPaths().channelsDir, slug);
+ await mkdir(channelDir, { recursive: true });
+ await writeFile(
+ path.join(channelDir, "config.json"),
+ JSON.stringify({ handling: "transcribe", url: "https://example.com/c" }),
+ );
+ for (const id of ids) {
+ const videoDir = path.join(channelDir, "data", id);
+ await mkdir(videoDir, { recursive: true });
+ // Transcribed (so diarization is applicable) and no audio (so its input is
+ // missing). A non-empty transcription array keeps it out of
+ // `isUntranscribable`.
+ await writeFile(
+ path.join(videoDir, "transcript.json"),
+ JSON.stringify({ transcription: [{ text: "hello", offsets: {} }] }),
+ );
+ }
+}
+
+test("every candidate is classified when no relocation is in flight", async () => {
+ await seed("quiet", ["v1", "v2"]);
+ const lines: string[] = [];
+ const result = await runOperationBatch({
+ lane: "backfill",
+ channelSlug: "quiet",
+ paths: getPaths(),
+ operationIds: ["diarization"],
+ onLog: (m) => lines.push(m),
+ });
+ // The control for the case below: both videos are reached and counted.
+ assert.equal(result.missingInput, 2);
+ assert.equal(result.attempted, 0);
+ assert.ok(!lines.some((l) => l.includes("Stopping: a relocation")));
+});
+
+test("a marker written mid-run ends the batch instead of racing the copy", async () => {
+ await seed("omni", ["v1", "v2"]);
+ const marker = relocationMarkerPath(getPaths(), "omni");
+
+ // setProgress fires once to seed the bar and then once per resolved
+ // candidate, so the SECOND call is the instant after the first video was
+ // classified and before the second is pulled — exactly the window a relocate
+ // job starts in. Written synchronously, because that is how the disk would
+ // have changed by the time the next pull reads it.
+ let progressCalls = 0;
+ const lines: string[] = [];
+ const result = await runOperationBatch({
+ lane: "backfill",
+ channelSlug: "omni",
+ paths: getPaths(),
+ operationIds: ["diarization"],
+ onLog: (m) => lines.push(m),
+ setProgress: () => {
+ progressCalls++;
+ if (progressCalls !== 2) return;
+ writeFileSync(
+ marker,
+ JSON.stringify({
+ target: "/platter/omni/data",
+ direction: "out",
+ startedAt: new Date().toISOString(),
+ phase: "copy",
+ }),
+ "utf8",
+ );
+ },
+ });
+
+ // ONE video was classified, not two: the run stopped at the next pull rather
+ // than working its way through the rest of the channel while rsync copied it.
+ assert.equal(result.missingInput, 1);
+ assert.equal(result.attempted, 0);
+ const stop = lines.find((l) => l.includes("Stopping: a relocation"));
+ assert.ok(stop, `expected a stop line, got:\n${lines.join("\n")}`);
+ assert.match(stop as string, /\(out\) of omni to \/platter\/omni\/data/);
+});
diff --git a/common/controller/relocateChannelMedia.test.ts b/common/controller/relocateChannelMedia.test.ts
@@ -898,3 +898,94 @@ test("a relative root is refused by the preview, not only by the job", async ()
);
});
});
+
+// THE OMNIMIRROR REFUSAL (2026-09-13), in a tmpdir.
+//
+// A 131 GB copy landed byte-complete and the verify refused it. The whole of the
+// drift was one directory timestamp: a sidecar written into `data/v4p31nz/`
+// while rsync was already past that directory bumped the SOURCE directory's
+// mtime and left the target's behind. `.d..t` is rsync's itemization for
+// exactly that — a directory, and only its time differs — and it means nothing
+// about the bytes.
+//
+// Both cases run at phase `swap`, where the re-verify runs on its own with no
+// `rsync -a` ahead of it. That is the shape that can see the difference: in the
+// copy phase the transfer itself would have set the timestamps.
+
+test("out @ swap: a directory timestamp is settled by one more pass, not refused", async () => {
+ await withTmp(async (paths, root) => {
+ const channelDir = await seed(paths, "alpha", {
+ v1: { "audio.m4a": "one".repeat(500), "transcript.json": "{}" },
+ v2: { "audio.m4a": "two".repeat(500) },
+ });
+ const target = relocatedDataDir(root, "alpha");
+ await copyTree(path.join(channelDir, "data"), target);
+ // The echo of the sidecar: the target's copy of v1/ carries a different
+ // mtime from the source's. Every file underneath is identical.
+ await utimes(path.join(target, "v1"), MTIME, MTIME);
+ await seedMarker(paths, "alpha", { target, direction: "out", phase: "swap" });
+
+ const lines: string[] = [];
+ const res = await relocateChannelMedia({
+ paths,
+ slug: "alpha",
+ direction: "out",
+ root,
+ onLog: (m) => lines.push(m),
+ });
+
+ assert.equal(res.retried, true);
+ assert.equal(res.resumed, true);
+ assert.ok(
+ lines.some((l) => l.includes("directory timestamp")),
+ "the extra pass is announced in the job log",
+ );
+ // And the move completed: link, config, no leftovers, no marker.
+ assert.equal((await inspectChannelMedia(paths, "alpha")).status, "ok");
+ assert.equal((await readChannelConfig(paths, "alpha"))?.dataDir, target);
+ assert.deepEqual(await leftoverCopies(channelDir), []);
+ assert.equal(await readRelocationMarker(paths, "alpha"), null);
+ assert.equal(
+ await readFile(path.join(target, "v1", "transcript.json"), "utf8"),
+ "{}",
+ );
+ });
+});
+
+test("out @ swap: a file the target is missing is still a refusal", async () => {
+ await withTmp(async (paths, root) => {
+ const channelDir = await seed(paths, "alpha", {
+ v1: { "audio.m4a": "one".repeat(500) },
+ });
+ const dataDir = path.join(channelDir, "data");
+ const target = relocatedDataDir(root, "alpha");
+ await copyTree(dataDir, target);
+ // CONTENT drift, arriving the same way the timestamp did — a sidecar
+ // written into the source after the copy. It bumps v1/'s mtime too, so the
+ // drift list is a `.d..t` line AND a file line: one non-directory line is
+ // all it takes, and the retry must not swallow it.
+ await writeFile(path.join(dataDir, "v1", "diarization.json"), "{}");
+ await seedMarker(paths, "alpha", { target, direction: "out", phase: "swap" });
+
+ await assert.rejects(
+ () =>
+ relocateChannelMedia({
+ paths,
+ slug: "alpha",
+ direction: "out",
+ root,
+ onLog: () => {},
+ }),
+ /The source has NOT been touched/,
+ );
+
+ // Nothing committed: `data/` is still the real directory, holding both files.
+ assert.ok((await lstat(dataDir)).isDirectory());
+ assert.ok(!(await lstat(dataDir)).isSymbolicLink());
+ assert.equal(
+ await readFile(path.join(dataDir, "v1", "audio.m4a"), "utf8"),
+ "one".repeat(500),
+ );
+ assert.equal((await readChannelConfig(paths, "alpha"))?.dataDir, undefined);
+ });
+});