Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion apps/scout/convex/tasks/siteResearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import agentTest from "@convex-dev/agent/test";
import workflowTest from "@convex-dev/workflow/test";
import { convexTest } from "convex-test";
import { ConvexError } from "convex/values";
import { Firecrawl, SdkError } from "firecrawl";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
import { api, internal } from "../_generated/api";
Expand Down Expand Up @@ -1062,10 +1063,59 @@ it.each([404, 410])(
},
);

it("refreshes a timed-out job when Firecrawl reports processing but confirms it is already cancelled", async () => {
const t = await setup();
await t.identifyAndAdvance();
const shared = await t.sharedJob();
await t.process(shared._id);
getAgentStatus.mockResolvedValue(processing);
vi.setSystemTime(Math.ceil(shared._creationTime) + 360_000);
await t.process(shared._id);
await t.advance();
const failedTask = await t.inspect();
cancelAgent.mockRejectedValueOnce(
new SdkError("Agent is already cancelled", 409, "ERR_BAD_REQUEST"),
);

const retryId = await t.refresh();
expect(retryId).not.toBe(shared._id);
expect(await t.sharedJob()).toMatchObject({ jobId: null, state: { kind: "running" } });
expect(await t.refresh()).toBe(retryId);
expect(await t.inspect()).toEqual(failedTask);

startAgent.mockResolvedValueOnce({ success: true, id: "job-2" });
await t.process(retryId);
getAgentStatus.mockResolvedValue(result);
await t.process(retryId);
expect(await t.sharedJob()).toMatchObject({ jobId: "job-2", state: { kind: "completed" } });
expect((await t.site())?.profile?.name).toBe(result.data.name);
});

it("surfaces other cancellation conflicts with provider details and preserves the failed job", async () => {
const t = await setup();
await t.identifyAndAdvance();
const shared = await t.sharedJob();
await t.process(shared._id);
getAgentStatus.mockResolvedValue(processing);
vi.setSystemTime(Math.ceil(shared._creationTime) + 360_000);
await t.process(shared._id);
const before = await t.records();
cancelAgent.mockRejectedValueOnce(
new SdkError("Agent already completed", 409, "ERR_BAD_REQUEST"),
);

await expect(t.refresh()).rejects.toMatchObject({
data: "Firecrawl DELETE /v2/agent/job-1: Agent already completed (HTTP 409, code ERR_BAD_REQUEST)",
});
expect(await t.records()).toEqual(before);
expect((await t.site())?.researchId).toBe(shared._id);
});

it.each([
{ label: "generic error mentioning 404", error: new Error("404 Not Found") },
{ label: "SDK 500", error: new SdkError("Server error", 500) },
{ label: "SDK 503", error: new SdkError("Service unavailable", 503) },
{ label: "SDK 409 from status lookup", error: new SdkError("Agent is already cancelled", 409) },
])(
"blocks refresh when the old provider job cannot be confirmed absent: $label",
async ({ error }) => {
Expand All @@ -1082,7 +1132,14 @@ it.each([
await t.process(shared._id);
const before = await t.records();
getAgentStatus.mockRejectedValueOnce(error);
await expect(t.refresh()).rejects.toThrow(error.message);
const failedRefresh = t.refresh();
await expect(failedRefresh).rejects.toThrow(error.message);
if (error instanceof SdkError) {
await expect(failedRefresh).rejects.toBeInstanceOf(ConvexError);
await expect(failedRefresh).rejects.toMatchObject({
data: `Firecrawl GET /v2/agent/job-1: ${error.message} (HTTP ${error.status})`,
});
}
expect((await t.site())?.researchId).toBe(shared._id);
expect(await t.records()).toEqual(before);
expect(startAgent).toHaveBeenCalledTimes(1);
Expand Down
31 changes: 24 additions & 7 deletions apps/scout/convex/tasks/siteResearch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use node";

import { v } from "convex/values";
import { ConvexError, v } from "convex/values";
import { SdkError, type Firecrawl } from "firecrawl";
import { internal } from "../_generated/api";
import type { Doc } from "../_generated/dataModel";
Expand Down Expand Up @@ -104,20 +104,37 @@ export const refresh = action({
if (current?.state.kind === "running") return current._id;
if (current?.state.kind === "failed" && current.jobId) {
const client = createFirecrawlClient();
let method = "GET";
try {
const status = await client.getAgentStatus(current.jobId);
if (!status.success) throw new Error(status.error ?? "Firecrawl research request failed");
if (status.status === "completed") {
await saveCompletedResearch(ctx, current, status);
return current._id;
}
if (status.status === "processing" && !(await client.cancelAgent(current.jobId)))
throw new Error(
"Could not stop the previous Firecrawl research job. Retry after resolving it.",
);
if (status.status === "processing") {
method = "DELETE";
if (!(await client.cancelAgent(current.jobId)))
throw new ConvexError(
"Could not stop the previous Firecrawl research job. Retry after resolving it.",
);
}
} catch (error) {
if (!(error instanceof SdkError) || (error.status !== 404 && error.status !== 410))
throw error;
if (!(error instanceof SdkError)) throw error;
// Firecrawl can keep reporting processing after cancellation has finished.
const alreadyCancelled =
method === "DELETE" &&
error.status === 409 &&
error.message === "Agent is already cancelled";
if (error.status !== 404 && error.status !== 410 && !alreadyCancelled) {
const details = [
error.status === undefined ? null : `HTTP ${error.status}`,
error.code ? `code ${error.code}` : null,
].filter((detail) => detail !== null);
const failure = `Firecrawl ${method} /v2/agent/${current.jobId}: ${diagnosticMessage(error)}${details.length ? ` (${details.join(", ")})` : ""}`;
console.error(failure);
throw new ConvexError(failure);
}
}
}
return ctx.runMutation(internal.tasks.siteResearchRecords.refresh, {
Expand Down
7 changes: 5 additions & 2 deletions apps/scout/src/components/sites-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "convex/server";
import { useSyncExternalStore } from "react";
import type { UsePaginatedQueryReturnType } from "convex/react";
import { ConvexError } from "convex/values";
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
import { api } from "../../convex/_generated/api";
import { omitNullish } from "../../shared/omitNullish";
Expand Down Expand Up @@ -515,12 +516,14 @@ test("shows research failures and permits an explicit retry after an action fail
profile: null,
research: { status: "failed", error: "Research provider unavailable" },
});
remote.refresh.mockRejectedValueOnce(new Error("Could not start research"));
const providerError =
"Firecrawl DELETE /v2/agent/job-1: Agent already completed (HTTP 409, code ERR_BAD_REQUEST)";
remote.refresh.mockRejectedValueOnce(new ConvexError(providerError));
await openPage("/sites/chessmerge.com");
expect((await screen.findByRole("alert")).textContent).toBe("Research provider unavailable");
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Retry research" }));
expect((await screen.findByRole("alert")).textContent).toBe("Could not start research");
expect((await screen.findByRole("alert")).textContent).toBe(providerError);
expect(screen.getByRole("button", { name: "Retry research" })).toHaveProperty("disabled", false);
await user.click(screen.getByRole("button", { name: "Retry research" }));
expect(remote.refresh).toHaveBeenCalledTimes(2);
Expand Down
10 changes: 9 additions & 1 deletion apps/scout/src/routes/sites.$site.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import type { SidebarLayoutState } from "@samebase/sidebars/SidebarLayoutState";
import { useLocalStorageSidebarState } from "../sidebars/scoutSidebarState";
import { useAction } from "convex/react";
import { ConvexError } from "convex/values";
import { convexQuery } from "@convex-dev/react-query";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useSsrPaginatedQuery } from "#lib/useSsrPaginatedQuery";
Expand Down Expand Up @@ -325,7 +326,14 @@ function SiteResearchControl({
setError(null);
void refresh({ site: site.hostname })
.catch((cause) => {
setError(cause instanceof Error ? cause.message : "Couldn't start research.");
const detail = cause instanceof ConvexError ? z.string().safeParse(cause.data) : null;
setError(
detail?.success
? detail.data
: cause instanceof Error
? cause.message
: "Couldn't start research.",
);
})
.finally(() => setPending(false));
}}
Expand Down
10 changes: 8 additions & 2 deletions hackathon.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
- **Auth:** Convex Auth
- **AI models:** qwen/qwen3.7-flash, openai/gpt-5.6-luna, deepseek/deepseek-v4-flash-0731 (Convex AI Gateway); gpt-5.6-luna (OpenAI Agents API)
- **Started:** 2026-08-26T16:12:42Z
- **Last updated:** 2026-09-23T00:08:25Z
- **Last updated:** 2026-09-23T00:24:56Z

## Log

Expand Down Expand Up @@ -1973,9 +1973,15 @@ background information, explaining the wait before browser review starts.
Use the existing research state so the timer survives refresh and disappears when
preparation finishes or the task stops (`apps/scout/src/products/conversation/page.tsx`).

### 2026-09-23 - working tree - v315
### 2026-09-23 - 463d932 - v315

Let Scout identify the review's site from the user's request, then wait for its brief
through `set_review_site`. Remove URL extraction at startup and show preparation
progress during that same tool call. Both engines receive the saved brief path or
the research error before continuing (`apps/scout/convex/tasks/execution.ts`).

### 2026-09-23 - working tree - v316

Allow research refresh after Firecrawl confirms the previous job was already cancelled,
even when its status still says processing. Show remaining provider failures with their
method, path, HTTP status, and code (`apps/scout/convex/tasks/siteResearch.ts`).
Loading