commit e0cea7ec237d7fd9ccc6cce7860291bb2b3bcf41
parent 5df886be3bbe8b7d9eb4724752e8b8e7dd3a1c2b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 01:42:57 -0400
channels: a bucket retry keeps its card and run log after it empties the bucket
Every video a retry fetches refreshes the snapshot, and the page's refresh
then handed NeedsCookiesList (and RetryBucketControl inside it) an empty
bucket while the run was still streaming; both returned null, unmounting the
StreamActionLog and its log (the cookies-mode.spec:241 intermittent, "Retry
needs cookies output" not found). The FACTS shape: a ranHere flag set in the
trigger; return null only while the bucket is empty and nothing ran here;
the button disabled while the bucket is empty. Applied to the three bucket
cards on the Download stage and to RetryBucketControl itself. A reload drops
an empty card as before; labels and test ids unchanged.
cookies-mode.spec.ts: the test waits for the refreshed, EMPTY card ("needs
cookies empty") and asserts the log and a disabled "Download with cookies
(0)" in it, which makes the old race certain rather than likely.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 56 insertions(+), 8 deletions(-)
diff --git a/editor/app/channels/[slug]/components/RetryBucketControl.tsx b/editor/app/channels/[slug]/components/RetryBucketControl.tsx
@@ -27,6 +27,9 @@ type Props = {
replaceAutoSubs?: boolean;
// Overrides the default "Retry (n)" button text.
buttonLabel?: string;
+ // Told when a run STARTS here, so a parent card that hides itself on an
+ // empty bucket can stay mounted for this run's log (see below).
+ onRun?: () => void;
};
export function RetryBucketControl({
@@ -39,14 +42,24 @@ export function RetryBucketControl({
forceCookies,
replaceAutoSubs,
buttonLabel,
+ onRun,
}: Props) {
const [queue, setQueue] = useState(defaultQueueKey);
const [handlingOverride, setHandlingOverride] = useState(
replaceAutoSubs ? "transcribe" : "",
);
const [abortOnError, setAbortOnError] = useState(false);
+ // A RETRY EMPTIES ITS OWN BUCKET, and the log must survive that (release 11
+ // slice O3; plans/FACTS.md, "A run log lives in the panel's React state").
+ // Every video the run fetches refreshes the snapshot, and the page's refresh
+ // then hands this control an empty `ids` while the run is still streaming —
+ // returning null there unmounted the StreamActionLog and took the log with
+ // it (cookies-mode.spec's intermittent "Retry needs cookies output" not
+ // found). Once a run has started here the control stays, with its button
+ // disabled while the bucket is empty; a reload drops it as before.
+ const [ranHere, setRanHere] = useState(false);
- if (ids.length === 0) return null;
+ if (ids.length === 0 && !ranHere) return null;
return (
<div
@@ -54,8 +67,10 @@ export function RetryBucketControl({
aria-label={`retry ${actionLabel} bucket`}
>
<StreamActionLog
- trigger={() =>
- retryBucketAction(
+ trigger={() => {
+ setRanHere(true);
+ onRun?.();
+ return retryBucketAction(
slug,
ids,
queue,
@@ -64,8 +79,9 @@ export function RetryBucketControl({
bucketKey,
forceCookies,
replaceAutoSubs,
- )
- }
+ );
+ }}
+ disabled={ids.length === 0}
cancelAction={cancelJobAction}
buttonLabel={
buttonLabel
diff --git a/editor/app/channels/[slug]/components/stages/DownloadStage.tsx b/editor/app/channels/[slug]/components/stages/DownloadStage.tsx
@@ -352,7 +352,10 @@ function NoTranscriptList({
defaultQueueKey: string;
existingQueues: string[];
}) {
- if (ids.length === 0) return null;
+ // Stays mounted once its retry has run: the run empties this bucket, and
+ // the log must outlive that (RetryBucketControl says why).
+ const [ranHere, setRanHere] = useState(false);
+ if (ids.length === 0 && !ranHere) return null;
return (
<div className="flex flex-col gap-2 rounded border border-border p-3">
<div>
@@ -372,12 +375,14 @@ function NoTranscriptList({
itemAriaLabel={(id) => `no transcript or download ${id}`}
/>
<RetryBucketControl
+ key="retry"
slug={slug}
ids={ids}
actionLabel="missing transcript and no download"
defaultQueueKey={defaultQueueKey}
existingQueues={existingQueues}
bucketKey="noTranscript"
+ onRun={() => setRanHere(true)}
/>
</div>
);
@@ -394,7 +399,10 @@ function PartialDownloadsList({
defaultQueueKey: string;
existingQueues: string[];
}) {
- if (ids.length === 0) return null;
+ // Stays mounted once its retry has run: the run empties this bucket, and
+ // the log must outlive that (RetryBucketControl says why).
+ const [ranHere, setRanHere] = useState(false);
+ if (ids.length === 0 && !ranHere) return null;
return (
<div className="flex flex-col gap-2 rounded border border-border p-3">
<div>
@@ -417,12 +425,14 @@ function PartialDownloadsList({
itemAriaLabel={(id) => `partial download ${id}`}
/>
<RetryBucketControl
+ key="retry"
slug={slug}
ids={ids}
actionLabel="resume partial downloads"
defaultQueueKey={defaultQueueKey}
existingQueues={existingQueues}
bucketKey="partialDownloads"
+ onRun={() => setRanHere(true)}
/>
</div>
);
@@ -439,7 +449,10 @@ function NeedsCookiesList({
defaultQueueKey: string;
existingQueues: string[];
}) {
- if (ids.length === 0) return null;
+ // Stays mounted once its retry has run: the run empties this bucket, and
+ // the log must outlive that (RetryBucketControl says why).
+ const [ranHere, setRanHere] = useState(false);
+ if (ids.length === 0 && !ranHere) return null;
return (
<div className="flex flex-col gap-2 rounded border border-border p-3">
<div>
@@ -464,12 +477,14 @@ function NeedsCookiesList({
itemAriaLabel={(id) => `needs cookies ${id}`}
/>
<RetryBucketControl
+ key="retry"
slug={slug}
ids={ids}
actionLabel="needs cookies"
defaultQueueKey={defaultQueueKey}
existingQueues={existingQueues}
bucketKey="needsCookies"
+ onRun={() => setRanHere(true)}
forceCookies
/>
</div>
diff --git a/editor/e2e/cookies-mode.spec.ts b/editor/e2e/cookies-mode.spec.ts
@@ -301,6 +301,23 @@ test("defer: excluded from batches, surfaced in Needs cookies, downloads via the
// The bucket empties once the snapshot regenerates.
await waitForNeedsCookiesBucket(0);
+ // …and the page refreshes onto the EMPTY bucket with the card still up and
+ // its run's log in it (release 11 slice O3). The card used to return null on
+ // an empty bucket, so whichever refresh landed first — the per-video
+ // snapshot's, mid-run — unmounted the log: the intermittent "Retry needs
+ // cookies output: element(s) not found" above. Waiting for the empty list
+ // makes that race certain rather than likely: it renders only after the
+ // refresh, inside the card that must have survived it.
+ await expect(page.getByLabel("needs cookies empty")).toBeVisible({
+ timeout: 20_000,
+ });
+ await expect(retryLog).toContainText(
+ "Managed download complete: 1 succeeded, 0 failed",
+ );
+ await expect(
+ bucket.getByRole("button", { name: /^Download with cookies \(0\)$/ }),
+ ).toBeDisabled();
+ // A fresh page has nothing to show for an empty bucket.
await page.reload();
await expect(page.getByLabel("retry needs cookies bucket")).toHaveCount(0);
});