diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java
index ec9146b122..dfa83b69a0 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java
@@ -25,10 +25,8 @@ import lombok.AccessLevel;
import lombok.Getter;
/**
- * The registry of failure kinds, described as data: a stable id, i18n keys and an English fallback
- * like {@code ExceptionUtils.ErrorCode}, plus the facets a review surface needs.
- *
- *
A new kind ships as a registry entry plus copy. Each offer says who it is for and where.
+ * The registry of failure kinds as data: id, i18n keys, English fallback, plus the facets a review
+ * surface needs. A new kind ships as an entry plus copy; each offer says who it is for and where.
*/
@Getter
public enum FailureKind {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java
index 316dd34925..ae6b7acb0c 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java
@@ -64,16 +64,17 @@ public interface FileRunEventRepository extends JpaRepository ({
+ default: { post: (...args: unknown[]) => post(...args) },
+}));
+
+// Mirrors the response body, so a 0-byte answer really does produce an empty output.
+vi.mock("@app/utils/toolResponseProcessor", () => ({
+ processResponse: (blob: Blob, files: { name: string }[]) =>
+ Promise.resolve([new File([blob], `out-${files[0].name}`)]),
+}));
+
+const { useToolApiCalls } =
+ await import("@app/hooks/tools/shared/useToolApiCalls");
+
+const file = (name: string, id: string): StirlingFile =>
+ ({ name, fileId: id, size: 10 }) as unknown as StirlingFile;
+
+function run(files: StirlingFile[]) {
+ const { processFiles } = renderHook(() => useToolApiCalls()).result.current;
+ return processFiles(
+ undefined,
+ files,
+ {
+ endpoint: "/api/v1/misc/compress-pdf",
+ buildFormData: () => new FormData(),
+ },
+ () => {},
+ () => {},
+ );
+}
+
+beforeEach(() => {
+ post.mockReset();
+});
+
+describe("processFiles failure reporting", () => {
+ it("names the failed input and keeps its error while the rest succeed", async () => {
+ expectConsole.error("[processFiles] Failed");
+ const boom = new Error("corrupted");
+ let call = 0;
+ post.mockImplementation(() => {
+ call += 1;
+ return call === 2
+ ? Promise.reject(boom)
+ : Promise.resolve({ data: new Blob(["ok"]), status: 200, headers: {} });
+ });
+
+ const result = await run([
+ file("a.pdf", "f-a"),
+ file("bad.pdf", "f-bad"),
+ file("c.pdf", "f-c"),
+ ]);
+
+ expect(result.outputFiles).toHaveLength(2);
+ expect(result.successSourceIds).toEqual(["f-a", "f-c"]);
+ expect(result.failedInputs).toEqual([
+ { fileId: "f-bad", name: "bad.pdf", error: boom },
+ ]);
+ });
+
+ it("reports nothing when every input succeeded", async () => {
+ post.mockResolvedValue({
+ data: new Blob(["ok"]),
+ status: 200,
+ headers: {},
+ });
+
+ const result = await run([file("a.pdf", "f-a")]);
+
+ expect(result.failedInputs).toEqual([]);
+ });
+
+ it("treats an empty output as a failure the caller must hear about", async () => {
+ // A 200 with a 0-byte body is a failure the old code counted only in a status string.
+ expectConsole.warn("[processFiles] Empty output treated as failure");
+ let call = 0;
+ post.mockImplementation(() => {
+ call += 1;
+ return Promise.resolve({
+ data: new Blob(call === 1 ? ["ok"] : []),
+ status: 200,
+ headers: {},
+ });
+ });
+
+ const result = await run([file("a.pdf", "f-a"), file("empty.pdf", "f-e")]);
+
+ expect(result.successSourceIds).toEqual(["f-a"]);
+ expect(result.failedInputs.map((f) => f.fileId)).toEqual(["f-e"]);
+ });
+});
diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts b/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts
index cca1f5e563..c5c9246ef7 100644
--- a/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts
+++ b/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts
@@ -9,6 +9,13 @@ import { isEmptyOutput } from "@app/services/errorUtils";
import type { ProcessingProgress } from "@app/hooks/tools/shared/useToolState";
import type { StirlingFile, FileId } from "@app/types/fileContext";
+/** An input that did not survive the batch, with the error it failed on. */
+export interface FailedInput {
+ fileId: FileId;
+ name: string;
+ error: unknown;
+}
+
export interface ApiCallsConfig {
endpoint: string | null | ((params: TParams) => string | null);
buildFormData: (params: TParams, file: File) => FormData;
@@ -28,9 +35,16 @@ export const useToolApiCalls = () => {
onProgress: (progress: ProcessingProgress) => void,
onStatus: (status: string) => void,
markFileError?: (fileId: FileId) => void,
- ): Promise<{ outputFiles: File[]; successSourceIds: FileId[] }> => {
+ ): Promise<{
+ outputFiles: File[];
+ successSourceIds: FileId[];
+ failedInputs: FailedInput[];
+ }> => {
const processedFiles: File[] = [];
const successSourceIds: FileId[] = [];
+ // Kept with their errors: a batch where only some inputs fail still owes the caller a
+ // report for each one, and it cannot derive the kind without the error.
+ const failedInputs: FailedInput[] = [];
const failedFiles: string[] = [];
const total = validFiles.length;
@@ -89,6 +103,11 @@ export const useToolApiCalls = () => {
name: file.name,
});
failedFiles.push(file.name);
+ failedInputs.push({
+ fileId: file.fileId,
+ name: file.name,
+ error: new Error(`${endpoint} returned an empty output`),
+ });
try {
markFileError?.(file.fileId);
} catch (e) {
@@ -109,6 +128,7 @@ export const useToolApiCalls = () => {
}
console.error("[processFiles] Failed", { name: file.name, error });
failedFiles.push(file.name);
+ failedInputs.push({ fileId: file.fileId, name: file.name, error });
// mark errored file so UI can highlight
try {
markFileError?.(file.fileId);
@@ -140,7 +160,11 @@ export const useToolApiCalls = () => {
outputs: processedFiles.length,
failed: failedFiles.length,
});
- return { outputFiles: processedFiles, successSourceIds };
+ return {
+ outputFiles: processedFiles,
+ successSourceIds,
+ failedInputs,
+ };
},
[],
);
diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts
index 28f58664f5..295f6c9084 100644
--- a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts
@@ -138,6 +138,49 @@ export const useToolOperation = (
outputFileIds: FileId[];
} | null>(null);
+ /**
+ * Record a failure and keep what a retry needs. Shared with the batch's per-input failures:
+ * one bad file in twenty is still a failure the user has to be told about.
+ */
+ const reportFailure = useCallback(
+ (
+ error: unknown,
+ fileIds: FileId[],
+ runtimeEndpoint: string | undefined,
+ params: TParams,
+ ) => {
+ if (fileIds.length === 0 || wasCancelled(error)) return;
+
+ void reportToolFailure({
+ operation: config.operationType,
+ error,
+ fileIds,
+ }).then(refreshNotificationsNow);
+
+ // Skipped where nothing could use it: a custom processor's request cannot be replayed
+ // generically, and a build with no bell has nothing to read the stash.
+ if (
+ !runtimeEndpoint ||
+ config.toolType === ToolType.custom ||
+ !notificationsAvailable
+ ) {
+ return;
+ }
+ void errorCodeOf(error).then((errorCode) =>
+ stashRetryPayload({
+ operation: config.operationType,
+ endpoint: runtimeEndpoint,
+ params: params as Record,
+ fileIds,
+ multiFile: config.toolType === ToolType.multiFile,
+ errorCode,
+ recordedAt: Date.now(),
+ }),
+ );
+ },
+ [config.operationType, config.toolType, notificationsAvailable],
+ );
+
const executeOperation = useCallback(
async (params: TParams, selectedFiles: StirlingFile[]): Promise => {
// Validation
@@ -268,9 +311,20 @@ export const useToolOperation = (
);
processedFiles = result.outputFiles;
successSourceIds = result.successSourceIds;
+ // Reported here, not in the catch: this loop only throws when EVERY input failed,
+ // so a batch that lost one file to a bad PDF reaches the success path.
+ for (const failed of result.failedInputs) {
+ reportFailure(
+ failed.error,
+ [failed.fileId],
+ runtimeEndpoint,
+ params,
+ );
+ }
console.debug("[useToolOperation] Multi-file results", {
outputFiles: processedFiles.length,
successSources: result.successSourceIds.length,
+ failedInputs: result.failedInputs.length,
});
break;
}
@@ -633,35 +687,13 @@ export const useToolOperation = (
void _e;
}
- // Report it so a leader sees the failure too, then carry on with the user's
- // own error handling. Fire-and-forget: the reporter swallows its own errors.
- // Chained, not fired alongside: the re-read must happen after the row exists.
- void reportToolFailure({
- operation: config.operationType,
+ // The whole run failed, so every input is a casualty.
+ reportFailure(
error,
- fileIds: validFiles.map((file) => file.fileId),
- }).then(refreshNotificationsNow);
-
- // Keep what a retry needs: the report carries none of it. Skipped where nothing
- // could use it, and gated on the reporter's own cancellation test.
- if (
- !wasCancelled(error) &&
- runtimeEndpoint &&
- config.toolType !== ToolType.custom &&
- notificationsAvailable
- ) {
- void errorCodeOf(error).then((errorCode) =>
- stashRetryPayload({
- operation: config.operationType,
- endpoint: runtimeEndpoint,
- params: params as Record,
- fileIds: validFiles.map((file) => file.fileId),
- multiFile: config.toolType === ToolType.multiFile,
- errorCode,
- recordedAt: Date.now(),
- }),
- );
- }
+ validFiles.map((file) => file.fileId),
+ runtimeEndpoint,
+ params,
+ );
const errorMessage =
config.getErrorMessage?.(error) || extractErrorMessage(error);
@@ -689,6 +721,7 @@ export const useToolOperation = (
checkCredits,
continueResolutions,
notificationsAvailable,
+ reportFailure,
],
);
diff --git a/frontend/editor/src/core/services/failureReporting.ts b/frontend/editor/src/core/services/failureReporting.ts
index 664513e98f..52f2aa891f 100644
--- a/frontend/editor/src/core/services/failureReporting.ts
+++ b/frontend/editor/src/core/services/failureReporting.ts
@@ -158,15 +158,8 @@ function messageOf(error: unknown): string {
}
/**
- * A user cancelling is the one failure worth dropping: nothing went wrong and there
- * is nothing for a reviewer to do. `useToolApiCalls` rethrows an axios cancellation
- * as a plain Error with the original as its cause, so both shapes are checked.
- *
- * Everything else is reported, client-side refusals included: an unsupported input
- * format is the same class of problem as the processor rejecting a file type, which
- * is already recorded.
- *
- *
Exported so a run the user cancelled does not get a retry stashed for it.
+ * A user cancelling is the one failure worth dropping. Both shapes are checked, since
+ * `useToolApiCalls` rethrows an axios cancellation as an Error with the original as its cause.
*/
export function wasCancelled(error: unknown): boolean {
const candidate = error as {
diff --git a/frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx b/frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx
index 9cdf82f8eb..9793a280bf 100644
--- a/frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx
+++ b/frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx
@@ -637,33 +637,8 @@ describe("useNotificationActions", () => {
expect(navigate).toHaveBeenCalledWith("/processor/documents#failures");
});
- it("opens a new tab rather than costing the reader a loaded workbench", () => {
- // Navigating away would unload their files, costing them every upload again.
+ it("navigates in place even with a loaded workbench, never opening a tab", () => {
openFileIds = ["f-1"];
- const openTab = vi.spyOn(window, "open").mockReturnValue({} as Window);
-
- registry().VIEW_IN_PROCESSOR?.run(context());
-
- expect(openTab).toHaveBeenCalledWith(
- "/processor/documents#failures",
- "_blank",
- "noopener",
- );
- expect(navigate).not.toHaveBeenCalled();
- openTab.mockRestore();
- });
-
- it("navigates in place when the tab would be refused", () => {
- openFileIds = ["f-1"];
- const openTab = vi.spyOn(window, "open").mockReturnValue(null);
-
- registry().VIEW_IN_PROCESSOR?.run(context());
-
- expect(navigate).toHaveBeenCalledWith("/processor/documents#failures");
- openTab.mockRestore();
- });
-
- it("navigates in place from an empty workbench, which costs the reader nothing", () => {
const openTab = vi.spyOn(window, "open");
registry().VIEW_IN_PROCESSOR?.run(context());
@@ -673,7 +648,7 @@ describe("useNotificationActions", () => {
openTab.mockRestore();
});
- it("navigates in place from the processor, which has no workbench to lose", () => {
+ it("navigates in place from the processor too", () => {
const openTab = vi.spyOn(window, "open");
registry(inProcessor).VIEW_IN_PROCESSOR?.run(context());
diff --git a/frontend/editor/src/proprietary/components/notifications/notificationActions.ts b/frontend/editor/src/proprietary/components/notifications/notificationActions.ts
index b8307690da..5c3ddcab92 100644
--- a/frontend/editor/src/proprietary/components/notifications/notificationActions.ts
+++ b/frontend/editor/src/proprietary/components/notifications/notificationActions.ts
@@ -97,11 +97,6 @@ function takeSelection(): Handoff | null {
}
}
-/** False when the browser refused it, so the caller can fall back to navigating in place. */
-function openInNewTab(path: string): boolean {
- return window.open(withBasePath(path), "_blank", "noopener") !== null;
-}
-
/** Not the router's `navigate`: the editor reads its tool on mount and on a history pop. */
function goToEditor(path: string): void {
window.history.pushState({}, "", withBasePath(path));
@@ -458,14 +453,7 @@ export function useNotificationActions(): ClientActionRegistry {
// Dev-only until failures get a review screen; portal/views/Documents holds the other half.
available: () => import.meta.env.DEV,
closesPanel: true,
- run: () => {
- // Leaving would cost them a loaded workbench, and every file in it a re-upload.
- const holdsFiles = (fileStore?.getState().files.ids.length ?? 0) > 0;
- if (canOpenHere && holdsFiles && openInNewTab(FAILURES_DESTINATION)) {
- return;
- }
- navigate(FAILURES_DESTINATION);
- },
+ run: () => navigate(FAILURES_DESTINATION),
};
return {
diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts
index 3ee9057b3e..72c3e33b21 100644
--- a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts
+++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts
@@ -1,8 +1,10 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
+ appliedCategoriesFor,
dispatchKey,
getRun,
isDispatched,
+ localPassFailed,
markDispatched,
recordRunStart,
removeRun,
@@ -106,6 +108,119 @@ describe("policyRunStore", () => {
expect(isDispatched("classification", "f1")).toBe(true);
});
+ describe("localPassFailed", () => {
+ it("reports a browser-local pass that could not produce a verdict", () => {
+ recordRunStart(
+ rec({
+ runId: "local-c",
+ categoryId: "classification",
+ fileId: "f1",
+ target: "local",
+ browserLocal: true,
+ }),
+ );
+ updateRun("local-c", { status: "FAILED", error: "encrypted" });
+
+ expect(localPassFailed("classification", "f1")).toBe(true);
+ expect(localPassFailed("classification", "f2")).toBe(false);
+ });
+
+ it("is false while the pass is still running, so the gate keeps waiting", () => {
+ recordRunStart(
+ rec({
+ runId: "local-c",
+ categoryId: "classification",
+ fileId: "f1",
+ target: "local",
+ browserLocal: true,
+ status: "RUNNING",
+ }),
+ );
+
+ expect(localPassFailed("classification", "f1")).toBe(false);
+ });
+
+ it("ignores a failed SERVER run, which says nothing about the local pass", () => {
+ recordRunStart(
+ rec({ runId: "srv-1", categoryId: "classification", fileId: "f1" }),
+ );
+ updateRun("srv-1", { status: "FAILED", error: "boom" });
+
+ expect(localPassFailed("classification", "f1")).toBe(false);
+ });
+ });
+
+ describe("appliedCategoriesFor", () => {
+ it("walks a rewriting chain back to the uploaded document", () => {
+ recordRunStart(
+ rec({ runId: "r-w", categoryId: "watermark", fileId: "f1" }),
+ );
+ updateRun("r-w", { status: "COMPLETED", outputFileIds: ["f2"] });
+ recordRunStart(
+ rec({ runId: "r-s", categoryId: "security", fileId: "f2" }),
+ );
+ updateRun("r-s", { status: "COMPLETED", outputFileIds: ["f3"] });
+
+ expect([...appliedCategoriesFor("f3")].sort()).toEqual([
+ "security",
+ "watermark",
+ ]);
+ });
+
+ it("counts an annotating run, which names its input as its own output", () => {
+ recordRunStart(
+ rec({ runId: "r-c", categoryId: "classification", fileId: "f1" }),
+ );
+ updateRun("r-c", { status: "COMPLETED", outputFileIds: ["f1"] });
+
+ expect([...appliedCategoriesFor("f1")]).toEqual(["classification"]);
+ });
+
+ it("does not count a browser-local pass as the policy having run", () => {
+ // The local heuristic settles COMPLETED with the input as its own output. Counting it
+ // would make a retry skip classification, killing the escalation #7667 restored.
+ recordRunStart(
+ rec({
+ runId: "local-c",
+ categoryId: "classification",
+ fileId: "f1",
+ target: "local",
+ browserLocal: true,
+ }),
+ );
+ updateRun("local-c", { status: "COMPLETED", outputFileIds: ["f1"] });
+
+ expect(appliedCategoriesFor("f1").size).toBe(0);
+ });
+
+ it("keeps climbing past an annotating run rather than stalling on it", () => {
+ // The annotating run's output IS its input, so the walk must not treat that as a
+ // lineage step - otherwise the cursor never moves and earlier policies are missed.
+ recordRunStart(
+ rec({ runId: "r-w", categoryId: "watermark", fileId: "f1" }),
+ );
+ updateRun("r-w", { status: "COMPLETED", outputFileIds: ["f2"] });
+ recordRunStart(
+ rec({ runId: "r-c", categoryId: "classification", fileId: "f2" }),
+ );
+ updateRun("r-c", { status: "COMPLETED", outputFileIds: ["f2"] });
+
+ expect([...appliedCategoriesFor("f2")].sort()).toEqual([
+ "classification",
+ "watermark",
+ ]);
+ });
+
+ it("ignores a run that failed, so it stays eligible to run again", () => {
+ recordRunStart(
+ rec({ runId: "r-f", categoryId: "security", fileId: "f1" }),
+ );
+ updateRun("r-f", { status: "FAILED", outputFileIds: ["f2"] });
+
+ expect(appliedCategoriesFor("f2").size).toBe(0);
+ });
+ });
+
it("never evicts in-flight runs, even past the soft cap", () => {
// A large upload batch can exceed the cap while still processing. Dropping a
// live run would orphan its polling/import and undercount progress, so every
diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts
index 0f16cf34bd..bb77f21b21 100644
--- a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts
+++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts
@@ -215,20 +215,34 @@ export function isDispatched(categoryId: string, fileId: string): boolean {
return state.dispatched.includes(dispatchKey(categoryId, fileId));
}
-/** Walked back through this document's lineage. Only a COMPLETED run counts as applied. */
+/** True when the browser's own pass ran for this file and could not produce a verdict. */
+export function localPassFailed(categoryId: string, fileId: string): boolean {
+ return state.runs.some(
+ (run) =>
+ run.browserLocal === true &&
+ run.status === "FAILED" &&
+ run.categoryId === categoryId &&
+ run.fileId === fileId,
+ );
+}
+
+/** Walked back through this document's lineage. Only a COMPLETED server run counts as applied. */
export function appliedCategoriesFor(fileId: string): Set {
const applied = new Set();
- let cursor = fileId;
+ let cursor: string | null = fileId;
// A lineage cannot outrun the recorded runs, and the bound also breaks a hand-edited cycle.
for (let step = 0; step < state.runs.length; step++) {
- const child = cursor;
- const producer = state.runs.find(
- (run) =>
- run.status === "COMPLETED" && (run.outputFileIds ?? []).includes(child),
- );
- if (!producer) break;
- applied.add(producer.categoryId);
- cursor = producer.fileId;
+ if (cursor === null) break;
+ const child: string = cursor;
+ cursor = null;
+ for (const run of state.runs) {
+ // A local first pass is not the policy's run: counting it would skip the escalation.
+ if (run.status !== "COMPLETED" || run.browserLocal) continue;
+ if (!(run.outputFileIds ?? []).includes(child)) continue;
+ applied.add(run.categoryId);
+ // An annotating run names its input as its own output, so it adds no lineage step.
+ if (run.fileId !== child) cursor = run.fileId;
+ }
}
return applied;
}
diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
index ac1b468be5..68a70cfd8c 100644
--- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
+++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
@@ -49,6 +49,7 @@ import {
dispatchKey,
getRun,
isDispatched,
+ localPassFailed,
markDispatched,
recordRunStart,
removeRun,
@@ -252,8 +253,17 @@ export function usePolicyAutoRun(): void {
) {
continue;
}
- // A confident local verdict stands; only an unsure one is escalated to the engine.
- if (!shouldDispatchToAi(firstCategory, stub)) continue;
+ // A confident local verdict stands; only an unsure one is escalated to the engine. A pass
+ // that threw counts as unsure: an unreadable file will never report one of its own.
+ if (
+ !shouldDispatchToAi(
+ firstCategory,
+ stub,
+ localPassFailed(firstCategory, stub.id),
+ )
+ ) {
+ continue;
+ }
dispatching.current.add(key);
void runPolicyOnFile(firstCategory, backendId, stub.id, stub.name)
.catch(() => {
diff --git a/frontend/editor/src/proprietary/data/classificationPolicy.test.ts b/frontend/editor/src/proprietary/data/classificationPolicy.test.ts
index cb5dfec779..88b842684a 100644
--- a/frontend/editor/src/proprietary/data/classificationPolicy.test.ts
+++ b/frontend/editor/src/proprietary/data/classificationPolicy.test.ts
@@ -97,6 +97,18 @@ describe("shouldDispatchToAi", () => {
expect(shouldDispatchToAi("classification", stub("none"))).toBe(true);
});
+ it("escalates an upload whose local pass threw, rather than waiting forever", () => {
+ // An encrypted document never reports a local verdict, so waiting means the server run
+ // never dispatches, nothing records the failure, and the bell stays empty.
+ expect(shouldDispatchToAi("classification", stub(), true)).toBe(true);
+ });
+
+ it("still lets a confident verdict stand even if an earlier pass had thrown", () => {
+ expect(shouldDispatchToAi("classification", stub("high"), true)).toBe(
+ false,
+ );
+ });
+
it("escalates a tool-derived file with no verdict at all", () => {
// A derived file gets no local pass (useClientSideClassification skips it), so
// there is no verdict to wait for: holding back would skip it forever. This is
diff --git a/frontend/editor/src/proprietary/data/classificationPolicy.ts b/frontend/editor/src/proprietary/data/classificationPolicy.ts
index 60ef0de23f..c51c5abdda 100644
--- a/frontend/editor/src/proprietary/data/classificationPolicy.ts
+++ b/frontend/editor/src/proprietary/data/classificationPolicy.ts
@@ -56,18 +56,17 @@ export function orderRewritesFirst(categoryIds: string[]): string[] {
const TRUSTED_CONFIDENCE: ClassificationConfidence = "high";
/**
- * Whether the AI classifier should be asked about this file. For an upload, only once the
- * heuristic has reported: dispatching before then races the first pass and bills for an answer it
- * was about to produce. A tool-derived file gets no local pass (useClientSideClassification skips
- * it) and only ever carries an inherited verdict, so an absent verdict there is permanent -
- * escalate rather than wait for a report that will never come.
+ * Whether to ask the AI classifier. An upload waits for the local pass, since dispatching races it
+ * and bills for a free answer; a derived file or a failed pass waits forever, so both escalate.
*/
export function shouldDispatchToAi(
categoryId: string,
stub: StirlingFileStub,
+ localPassFailed = false,
): boolean {
if (!isClassificationCategory(categoryId)) return true;
const confidence = stub.classificationConfidence;
- if (confidence == null) return Boolean(stub.derivedFromTool);
+ if (confidence == null)
+ return Boolean(stub.derivedFromTool) || localPassFailed;
return confidence !== TRUSTED_CONFIDENCE;
}