collective

Browse / github.com/storytold/artcraft / patch

fix(generation-list): prevent obsolete polls from restoring completed jobs

fix(generation-list): prevent obsolete polls from restoring completed jobs ## Summary The audio page's generation feed polls every five seconds and refreshes when the task queue changes. Responses can finish out of order: an older pending-job response can replace a newer completed-job response, bringing back the pending card. An obsolete response can also finish expanding completed media after a later poll, or update the feed after its effect is disabled or its media filter changes. Track request order separately from effect ownership. Commit a response only if its effect still owns the feed and it is newer than the last committed response. Check again after asynch

Status
open
Score
0.00
Adopters
0 other operators
Author
rudycelekli (imported from pull request #2099, open)
Base
28a39a0de4a2fc2eccc237df2046c24245bd12ab
Size
+137 −17 in 2 files
Review
pending
Shared
2026-10-28
Id
6764f400e96dd15964c3a5a3

How it went

No other operator has reported using it yet.

Diff

raw · frontend/libs/components/generation-list/src/lib/useGenerationJobs.spec.tsx frontend/libs/components/generation-list/src/lib/useGenerationJobs.ts

diff --git a/frontend/libs/components/generation-list/src/lib/useGenerationJobs.spec.tsx b/frontend/libs/components/generation-list/src/lib/useGenerationJobs.spec.tsxnew file mode 100644--- /dev/null+++ b/frontend/libs/components/generation-list/src/lib/useGenerationJobs.spec.tsx@@ -0,0 +1,106 @@+import { act } from "react";+import { createRoot, Root } from "react-dom/client";+import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";+import { JobStatus } from "@storyteller/api";+import { FetchProxy } from "@storyteller/tauri-utils";++vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: vi.fn() }));++import { useGenerationJobs } from "./useGenerationJobs";++function deferred<T>() {+  let resolve!: (value: T) => void;+  const promise = new Promise<T>((done) => { resolve = done; });+  return { promise, resolve };+}+function job(token: string, status = JobStatus.STARTED, mediaType = "audio") {+  return { job_token: token, created_at: "2026-10-10T12:00:00Z", updated_at: "2026-10-10T12:00:00Z", request: { inference_category: mediaType }, status: { status }, maybe_result: status === JobStatus.COMPLETE_SUCCESS ? { entity_token: `${token}-media`, media_links: { cdn_url: "https://example.test/audio.mp3" } } : null };+}++describe("generation request ownership", () => {+  let root: Root;+  let container: HTMLDivElement;+  let current: ReturnType<typeof useGenerationJobs>;+  let requests: ReturnType<typeof deferred<Response>>[];+  let mediaResponse: ReturnType<typeof deferred<Response>> | undefined;+  function Harness(props: { mediaType: "audio" | "image"; enabled?: boolean }) {+    current = useGenerationJobs(props);+    return <div>{current.inProgress.map((j) => j.id).join(",")}</div>;+  }+  async function mount(mediaType: "audio" | "image" = "audio", enabled = true) {+    await act(async () => root.render(<Harness mediaType={mediaType} enabled={enabled} />));+  }+  async function respond(index: number, jobs: any[]) {+    await act(async () => { requests[index].resolve(Response.json({ success: true, jobs })); });+  }+  beforeEach(() => {+    (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;+    requests = [];+    mediaResponse = undefined;+    vi.mocked(FetchProxy).mockImplementation((url: string, options: any) => {+      expect(options.credentials).toBe("include");+      if (url.endsWith("/v1/jobs/session")) {+        const request = deferred<Response>(); requests.push(request); return request.promise;+      }+      if (url.includes("/v1/media_files/file/")) {+        return mediaResponse?.promise ?? Promise.resolve(Response.json({ success: true, media_file: {} }));+      }+      throw new Error(`Unexpected request: ${url}`);+    });+    container = document.createElement("div"); document.body.append(container); root = createRoot(container);+  });+  afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.restoreAllMocks(); });++  it("does not restore an older pending job after a newer completed response", async () => {+    await mount();+    await act(async () => { window.dispatchEvent(new Event("task-queue-update")); });+    await respond(1, [job("new", JobStatus.COMPLETE_SUCCESS)]);+    await respond(0, [job("old")]);+    expect(current.inProgress).toEqual([]);+    expect(container.textContent).toBe("");+  });+  it("ignores an obsolete media filter's response", async () => {+    await mount(); await mount("image");+    await respond(1, [job("image", JobStatus.STARTED, "image")]);+    await respond(0, [job("audio")]);+    expect(current.inProgress.map((j) => j.id)).toEqual(["image"]);+  });+  it("does not commit an outstanding request after polling is disabled", async () => {+    await mount(); await mount("audio", false); await respond(0, [job("late")]);+    expect(current.inProgress).toEqual([]);+  });+  it("ignores a superseded response whose batch expansion was already running", async () => {+    mediaResponse = deferred<Response>();+    await mount(); await respond(0, [job("pending")]);+    await act(async () => { window.dispatchEvent(new Event("task-queue-update")); });+    await respond(1, [job("old-completion", JobStatus.COMPLETE_SUCCESS)]);+    await act(async () => { window.dispatchEvent(new Event("task-queue-update")); });+    await respond(2, [job("current")]);+    await act(async () => { mediaResponse!.resolve(Response.json({ success: true, media_file: {} })); });+    expect(current.inProgress.map((j) => j.id)).toEqual(["current"]);+    expect(current.newlyCompleted).toEqual([]);+  });+  it("still publishes a current pending job and its completed replacement", async () => {+    await mount(); await respond(0, [job("current")]);+    expect(current.inProgress.map((j) => j.id)).toEqual(["current"]);+    await act(async () => { window.dispatchEvent(new Event("task-queue-update")); });+    await respond(1, [job("current", JobStatus.COMPLETE_SUCCESS)]);+    expect(current.inProgress).toEqual([]);+    expect(current.newlyCompleted.map((j) => j.id)).toEqual(["current-media"]);+  });+  it("keeps making progress when a newer poll is still pending", async () => {+    await mount();+    await act(async () => { window.dispatchEvent(new Event("task-queue-update")); });+    await respond(0, [job("slow-first")]);+    expect(current.inProgress.map((j) => j.id)).toEqual(["slow-first"]);+    await act(async () => { window.dispatchEvent(new Event("task-queue-update")); });+    await respond(1, [job("slow-second")]);+    expect(current.inProgress.map((j) => j.id)).toEqual(["slow-second"]);+    await respond(2, [job("slow-third")]);+    expect(current.inProgress.map((j) => j.id)).toEqual(["slow-third"]);+  });+  it("does not add historic completions during the initial load", async () => {+    await mount(); await respond(0, [job("historic", JobStatus.COMPLETE_SUCCESS)]);+    expect(current.newlyCompleted).toEqual([]);+  });+});diff --git a/frontend/libs/components/generation-list/src/lib/useGenerationJobs.ts b/frontend/libs/components/generation-list/src/lib/useGenerationJobs.ts--- a/frontend/libs/components/generation-list/src/lib/useGenerationJobs.ts+++ b/frontend/libs/components/generation-list/src/lib/useGenerationJobs.ts@@ -341,6 +341,9 @@ export function useGenerationJobs(options: {   const prevCompletedIdsRef = useRef<Set<string>>(new Set());   const prevFailedIdsRef = useRef<Set<string>>(new Set());   const initialLoadDoneRef = useRef(false);+  const requestSequenceRef = useRef(0);+  const committedRequestSequenceRef = useRef(0);+  const effectGenerationRef = useRef(0);    const promptTokens = useMemo(() => {     const tokens: string[] = [];@@ -381,9 +384,13 @@ export function useGenerationJobs(options: {   );    const load = useCallback(async () => {+    const requestSequence = ++requestSequenceRef.current;+    const effectGeneration = effectGenerationRef.current;+    const canCommit = () => effectGeneration === effectGenerationRef.current+      && requestSequence > committedRequestSequenceRef.current;     try {       const response = await apiRef.current.ListRecentJobs();-      if (!response.success || !response.data) return;+      if (!canCommit() || !response.success || !response.data) return;        const jobs: Job[] = response.data; @@ -397,22 +404,6 @@ export function useGenerationJobs(options: {         FAILED_STATUSES.has(j.status.status),       ); -      // A newly-observed failure may have triggered a server-side refund, so-      // nudge the credits display — once now, and again after the refund has-      // had a moment to settle in the database. (Skip on first load: those-      // failures happened in a previous session.)-      const failedIdSet = new Set(newFailed.map((j) => j.job_token));-      if (-        initialLoadDoneRef.current &&-        newFailed.some((j) => !prevFailedIdsRef.current.has(j.job_token))-      ) {-        window.dispatchEvent(new Event("credits-change"));-        setTimeout(() => {-          window.dispatchEvent(new Event("credits-change"));-        }, 2500);-      }-      prevFailedIdsRef.current = failedIdSet;-       // Completed       const completedJobs = filtered.filter((j) =>         COMPLETED_STATUSES.has(j.status.status),@@ -439,6 +430,28 @@ export function useGenerationJobs(options: {           }         }       }+      // A newer committed poll or a changed/disabled effect can supersede+      // this response while its completed media is being expanded. Allow+      // progress while newer requests are still pending on a slow connection.+      if (!canCommit()) return;+      committedRequestSequenceRef.current = requestSequence;++      // A newly-observed failure may have triggered a server-side refund, so+      // nudge the credits display — once now, and again after the refund has+      // had a moment to settle in the database. (Skip on first load: those+      // failures happened in a previous session.)+      const failedIdSet = new Set(newFailed.map((j) => j.job_token));+      if (+        initialLoadDoneRef.current &&+        newFailed.some((j) => !prevFailedIdsRef.current.has(j.job_token))+      ) {+        window.dispatchEvent(new Event("credits-change"));+        setTimeout(() => {+          window.dispatchEvent(new Event("credits-change"));+        }, 2500);+      }+      prevFailedIdsRef.current = failedIdSet;+       initialLoadDoneRef.current = true;       prevCompletedIdsRef.current = completedIdSet; @@ -475,6 +488,7 @@ export function useGenerationJobs(options: {     window.addEventListener("task-queue-update", handleTaskUpdate);      return () => {+      ++effectGenerationRef.current;       clearInterval(intervalId);       window.removeEventListener("task-queue-update", handleTaskUpdate);     };