feat(FN-2999): harden research lifecycle with idempotent cancel/retry, SSE

Merges FN-2999 research hardening (idempotent cancel/retry routes, aligned SSE event wiring, and cleaned status handling in the core research store and orchestrator) plus UI improvements to AgentDetailView header actions and planning disclosure UX in the modal, with a CSS token fallback fix in Scrip

Fusion-Task-Id: FN-2999
This commit is contained in:
Fusion
2026-05-03 09:06:09 -07:00
committed by gsxdsm
parent 4102a4028a
commit f2fa44e270
16 changed files with 307 additions and 79 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Harden research subsystem with bounded rate/concurrency limits, cancellation safety, timeout handling, bounded retries, and graceful disabled/setup/error states across dashboard, API, CLI, and agent tooling.

View File

@@ -65,15 +65,17 @@ fn research retry RR-001 --json
| Subcommand | Description | | Subcommand | Description |
|---|---| |---|---|
| `fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]` | Create a run and optionally wait for completion. | | `fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]` | Create a run and optionally wait for completion. |
| `fn research list \| ls [--status <status>] [--limit <n>] [--json]` | List recent runs (statuses: `pending`, `running`, `completed`, `failed`, `cancelled`). | | `fn research list \| ls [--status <status>] [--limit <n>] [--json]` | List recent runs (statuses: `queued`, `running`, `cancelling`, `retry_waiting`, `completed`, `failed`, `cancelled`, `timed_out`, `retry_exhausted`). |
| `fn research show <run-id> [--json]` | Show one run with timestamps, summary, and error details. | | `fn research show <run-id> [--json]` | Show one run with timestamps, summary, and error details. |
| `fn research export <run-id> [--format <json\|markdown\|pdf>] [--output <path>] [--json]` | Export run results and persist an export record. | | `fn research export <run-id> [--format <json\|markdown\|pdf>] [--output <path>] [--json]` | Export run results and persist an export record. |
| `fn research cancel <run-id> [--json]` | Request cancellation for an active run. | | `fn research cancel <run-id> [--json]` | Request cancellation for an active run. |
| `fn research retry <run-id> [--json]` | Create a new retry run from a failed/cancelled run. | | `fn research retry <run-id> [--json]` | Create a new retry run from a failed/cancelled run. |
Disabled/setup behavior mirrors dashboard and agent surfaces: Disabled/setup behavior mirrors dashboard and agent surfaces:
- Feature disabled → `feature-disabled` error (enable research in settings) - Feature disabled → `FEATURE_DISABLED` (enable project/global research settings)
- Provider unconfigured → `provider-unavailable` error (configure credentials/provider) - Missing credentials → `MISSING_CREDENTIALS` (configure provider auth)
- Provider unavailable or cooldown/rate limit → `PROVIDER_UNAVAILABLE` / `RATE_LIMITED` with retry metadata
- Non-retryable failures and invalid state transitions are surfaced as structured errors instead of generic failures
--- ---

View File

@@ -266,6 +266,8 @@ This applies to:
The standalone Research route is feature-gated separately via `experimentalFeatures.researchView`. The standalone Research route is feature-gated separately via `experimentalFeatures.researchView`.
When that flag is disabled, the Settings modal also hides both Research sections (`Research Defaults` and project `Research`) and falls back to the first visible section if a hidden research section is requested directly. When that flag is disabled, the Settings modal also hides both Research sections (`Research Defaults` and project `Research`) and falls back to the first visible section if a hidden research section is requested directly.
Research failures are normalized to a shared error-code contract (`FEATURE_DISABLED`, `MISSING_CREDENTIALS`, `PROVIDER_UNAVAILABLE`, `RATE_LIMITED`, `PROVIDER_TIMEOUT`, `RUN_CANCELLED`, `RETRY_EXHAUSTED`, `INVALID_TRANSITION`, `NON_RETRYABLE_PROVIDER_ERROR`, `INTERNAL_ERROR`) with retryability metadata so dashboard, API, CLI, and agent tooling show consistent recovery guidance.
**Credential storage rule:** API keys for Research providers are not stored in settings JSON. They are managed through the existing auth storage pipeline (`/api/auth/status`, `POST /api/auth/api-key`, `DELETE /api/auth/api-key`) and persisted in auth credential storage with masked hints in API responses. **Credential storage rule:** API keys for Research providers are not stored in settings JSON. They are managed through the existing auth storage pipeline (`/api/auth/status`, `POST /api/auth/api-key`, `DELETE /api/auth/api-key`) and persisted in auth credential storage with masked hints in API responses.
### Node Routing settings (project scope) ### Node Routing settings (project scope)

View File

@@ -90,6 +90,6 @@ describe("research extension tools", () => {
const cancelTool = api.tools.get("fn_research_cancel")!; const cancelTool = api.tools.get("fn_research_cancel")!;
const cancelResult = await cancelTool.execute("call-4", { id: runResult.details.runId }, undefined, undefined, makeCtx(tmpDir)); const cancelResult = await cancelTool.execute("call-4", { id: runResult.details.runId }, undefined, undefined, makeCtx(tmpDir));
expect(cancelResult.details.status).toBe("cancelled"); expect(["cancelling", "cancelled"]).toContain(cancelResult.details.status);
}); });
}); });

View File

@@ -1306,10 +1306,9 @@ export default function kbExtension(pi: ExtensionAPI) {
}; };
} }
researchStore.updateStatus(params.id, "cancelled", { cancelledAt: new Date().toISOString(), error: "Cancelled via extension" }); const updated = researchStore.requestCancellation(params.id);
const updated = researchStore.getRun(params.id)!;
return { return {
content: [{ type: "text", text: `Marked research run ${params.id} as cancelled.` }], content: [{ type: "text", text: `Requested cancellation for research run ${params.id} (status: ${updated.status}).` }],
details: toResearchRunDetails(updated), details: toResearchRunDetails(updated),
}; };
}, },

View File

@@ -24,7 +24,7 @@ describe("ResearchStore", () => {
const updated = store.updateRun(run.id, { topic: "new topic", error: "oops" }); const updated = store.updateRun(run.id, { topic: "new topic", error: "oops" });
expect(updated?.topic).toBe("new topic"); expect(updated?.topic).toBe("new topic");
const listed = store.listRuns({ status: "pending" }); const listed = store.listRuns({ status: "queued" });
expect(listed.map((r) => r.id)).toContain(run.id); expect(listed.map((r) => r.id)).toContain(run.id);
expect(store.deleteRun(run.id)).toBe(true); expect(store.deleteRun(run.id)).toBe(true);
@@ -58,8 +58,8 @@ describe("ResearchStore", () => {
expect(() => store.updateRun(run.id, { topic: "changed" })).toThrow(ResearchLifecycleError); expect(() => store.updateRun(run.id, { topic: "changed" })).toThrow(ResearchLifecycleError);
const pending = store.createRun({ query: "pending" }); const queued = store.createRun({ query: "queued" });
expect(() => store.updateStatus(pending.id, "completed")).toThrow(/Invalid run status transition/i); expect(() => store.updateStatus(queued.id, "completed")).toThrow(/Invalid run status transition/i);
}); });
it("persists lifecycle events to research_run_events", () => { it("persists lifecycle events to research_run_events", () => {
@@ -131,6 +131,32 @@ describe("ResearchStore", () => {
expect(store.getExports(r1.id)).toHaveLength(0); expect(store.getExports(r1.id)).toHaveLength(0);
}); });
it("supports idempotent cancellation request transition", () => {
const run = store.createRun({ query: "cancel me" });
const first = store.requestCancellation(run.id);
expect(first.status).toBe("cancelling");
const second = store.requestCancellation(run.id);
expect(second.status).toBe("cancelling");
store.updateStatus(run.id, "cancelled");
const terminal = store.requestCancellation(run.id);
expect(terminal.status).toBe("cancelled");
});
it("marks retry exhaustion when max attempts reached", () => {
const run = store.createRun({ query: "retry", lifecycle: { attempt: 3, maxAttempts: 3 } });
store.updateStatus(run.id, "failed", {
lifecycle: {
...(run.lifecycle ?? {}),
retryable: true,
failureClass: "retryable_transient",
},
});
expect(() => store.createRetryRun(run.id)).toThrow(/non-retryable|exhausted retries/i);
expect(store.getRun(run.id)?.status).toBe("retry_exhausted");
});
it("emits status events and throws for missing run mutations", () => { it("emits status events and throws for missing run mutations", () => {
const onStatus = vi.fn(); const onStatus = vi.fn();
const onCompleted = vi.fn(); const onCompleted = vi.fn();

View File

@@ -10,6 +10,7 @@ import type {
ResearchRun, ResearchRun,
ResearchRunCreateInput, ResearchRunCreateInput,
ResearchRunEvent, ResearchRunEvent,
ResearchErrorCode,
ResearchRunFailureClass, ResearchRunFailureClass,
ResearchRunListOptions, ResearchRunListOptions,
ResearchRunStatus, ResearchRunStatus,
@@ -47,15 +48,36 @@ function mergeRecord(
return Object.keys(merged).length > 0 ? merged : undefined; return Object.keys(merged).length > 0 ? merged : undefined;
} }
const TERMINAL_STATUSES = new Set<ResearchRunStatus>(["completed", "failed", "cancelled"]); const TERMINAL_STATUSES = new Set<ResearchRunStatus>([
"completed",
"failed",
"cancelled",
"timed_out",
"retry_exhausted",
]);
const VALID_STATUS_TRANSITIONS: Record<ResearchRunStatus, ResearchRunStatus[]> = { const VALID_STATUS_TRANSITIONS: Record<ResearchRunStatus, ResearchRunStatus[]> = {
pending: ["running", "cancelled", "failed"], queued: ["running", "cancelling", "cancelled", "failed", "retry_waiting", "timed_out"],
running: ["completed", "failed", "cancelled"], running: ["completed", "failed", "cancelling", "cancelled", "retry_waiting", "timed_out"],
cancelling: ["cancelled", "failed", "timed_out"],
retry_waiting: ["queued", "running", "cancelled", "retry_exhausted", "failed"],
completed: [], completed: [],
failed: [], failed: ["retry_exhausted"],
cancelled: [], cancelled: [],
timed_out: ["retry_exhausted"],
retry_exhausted: [],
}; };
function normalizeStatus(status: ResearchRunStatus | "pending"): ResearchRunStatus {
return status === "pending" ? "queued" : status;
}
function defaultErrorCodeForFailureClass(failureClass?: ResearchRunFailureClass): ResearchErrorCode {
if (failureClass === "timed_out") return "PROVIDER_TIMEOUT";
if (failureClass === "cancelled") return "RUN_CANCELLED";
if (failureClass === "non_retryable") return "NON_RETRYABLE_PROVIDER_ERROR";
return "INTERNAL_ERROR";
}
export class ResearchStore extends EventEmitter<ResearchStoreEvents> { export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
constructor(private readonly db: Database) { constructor(private readonly db: Database) {
super(); super();
@@ -68,7 +90,7 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
id: generateRunId(), id: generateRunId(),
query: input.query, query: input.query,
topic: input.topic, topic: input.topic,
status: "pending", status: "queued",
projectId: input.projectId, projectId: input.projectId,
trigger: input.trigger, trigger: input.trigger,
providerConfig: input.providerConfig, providerConfig: input.providerConfig,
@@ -79,7 +101,7 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
metadata: input.metadata, metadata: input.metadata,
lifecycle: { lifecycle: {
attempt: input.lifecycle?.attempt ?? 1, attempt: input.lifecycle?.attempt ?? 1,
maxAttempts: input.lifecycle?.maxAttempts ?? 1, maxAttempts: input.lifecycle?.maxAttempts ?? 3,
rootRunId: input.lifecycle?.rootRunId, rootRunId: input.lifecycle?.rootRunId,
retryOfRunId: input.lifecycle?.retryOfRunId, retryOfRunId: input.lifecycle?.retryOfRunId,
...input.lifecycle, ...input.lifecycle,
@@ -130,15 +152,25 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
const existing = this.getRun(id); const existing = this.getRun(id);
if (!existing) return undefined; if (!existing) return undefined;
if (TERMINAL_STATUSES.has(existing.status) && Object.keys(input).some((key) => key !== "events" && key !== "metadata")) { const normalizedExistingStatus = normalizeStatus(existing.status as ResearchRunStatus | "pending");
const normalizedInputStatus = input.status
? normalizeStatus(input.status as ResearchRunStatus | "pending")
: undefined;
const nonMutableKeys = Object.keys(input).filter((key) => key !== "events" && key !== "metadata");
if (
TERMINAL_STATUSES.has(normalizedExistingStatus)
&& nonMutableKeys.length > 0
&& !(nonMutableKeys.length === 1 && nonMutableKeys[0] === "status")
) {
throw new ResearchLifecycleError(`Run ${id} is terminal and immutable`, "terminal_immutable"); throw new ResearchLifecycleError(`Run ${id} is terminal and immutable`, "terminal_immutable");
} }
if (input.status && input.status !== existing.status) { if (normalizedInputStatus && normalizedInputStatus !== normalizedExistingStatus) {
const allowed = VALID_STATUS_TRANSITIONS[existing.status]; const allowed = VALID_STATUS_TRANSITIONS[normalizedExistingStatus];
if (!allowed.includes(input.status)) { if (!allowed.includes(normalizedInputStatus)) {
throw new ResearchLifecycleError( throw new ResearchLifecycleError(
`Invalid run status transition: ${existing.status} -> ${input.status}`, `Invalid run status transition: ${normalizedExistingStatus} -> ${normalizedInputStatus}`,
"invalid_transition", "invalid_transition",
); );
} }
@@ -152,6 +184,7 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
const updated: ResearchRun = { const updated: ResearchRun = {
...existing, ...existing,
...input, ...input,
status: normalizedInputStatus ?? normalizedExistingStatus,
providerConfig: mergedProviderConfig, providerConfig: mergedProviderConfig,
metadata: mergedMetadata, metadata: mergedMetadata,
lifecycle: Object.keys(mergedLifecycle).length > 0 ? mergedLifecycle : undefined, lifecycle: Object.keys(mergedLifecycle).length > 0 ? mergedLifecycle : undefined,
@@ -346,31 +379,55 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
const run = this.getRun(runId); const run = this.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`); if (!run) throw new Error(`Research run not found: ${runId}`);
const normalizedStatus = normalizeStatus(status as ResearchRunStatus | "pending");
const now = new Date().toISOString(); const now = new Date().toISOString();
const patch: ResearchRunUpdateInput = { const patch: ResearchRunUpdateInput = {
...(extra ?? {}), ...(extra ?? {}),
status, status: normalizedStatus,
lifecycle: { lifecycle: {
...(run.lifecycle ?? {}), ...(run.lifecycle ?? {}),
}, },
}; };
if (status === "running" && !run.startedAt) { if (normalizedStatus === "running" && !run.startedAt) patch.startedAt = now;
patch.startedAt = now; if (TERMINAL_STATUSES.has(normalizedStatus) && !run.completedAt) patch.completedAt = now;
} if (normalizedStatus === "cancelled" && !run.cancelledAt) patch.cancelledAt = now;
if ((status === "completed" || status === "failed") && !run.completedAt) {
patch.completedAt = now;
}
if (status === "cancelled" && !run.cancelledAt) {
patch.cancelledAt = now;
}
if (status === "completed") { if (normalizedStatus === "completed") {
patch.lifecycle = { ...(patch.lifecycle ?? {}), terminalReason: "completed", retryable: false }; patch.lifecycle = { ...(patch.lifecycle ?? {}), terminalReason: "completed", retryable: false, errorCode: undefined };
} else if (status === "failed") { } else if (normalizedStatus === "failed") {
patch.lifecycle = { ...(patch.lifecycle ?? {}), terminalReason: "failed", retryable: patch.lifecycle?.failureClass === "retryable_transient" }; const failureClass = patch.lifecycle?.failureClass;
} else if (status === "cancelled") { patch.lifecycle = {
patch.lifecycle = { ...(patch.lifecycle ?? {}), terminalReason: "cancelled", retryable: false, failureClass: "cancelled" }; ...(patch.lifecycle ?? {}),
terminalReason: "failed",
retryable: failureClass === "retryable_transient",
errorCode: patch.lifecycle?.errorCode ?? defaultErrorCodeForFailureClass(failureClass),
};
} else if (normalizedStatus === "cancelled") {
patch.lifecycle = {
...(patch.lifecycle ?? {}),
terminalReason: "cancelled",
retryable: false,
failureClass: "cancelled",
errorCode: patch.lifecycle?.errorCode ?? "RUN_CANCELLED",
};
} else if (normalizedStatus === "timed_out") {
patch.lifecycle = {
...(patch.lifecycle ?? {}),
terminalReason: "timed_out",
retryable: true,
failureClass: "timed_out",
errorCode: patch.lifecycle?.errorCode ?? "PROVIDER_TIMEOUT",
timeoutAt: patch.lifecycle?.timeoutAt ?? now,
};
} else if (normalizedStatus === "retry_exhausted") {
patch.lifecycle = {
...(patch.lifecycle ?? {}),
terminalReason: "retry_exhausted",
retryable: false,
failureClass: patch.lifecycle?.failureClass ?? "non_retryable",
errorCode: "RETRY_EXHAUSTED",
};
} }
const updated = this.updateRun(runId, patch); const updated = this.updateRun(runId, patch);
@@ -378,15 +435,16 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
this.appendLifecycleEvent(runId, { this.appendLifecycleEvent(runId, {
type: "status_changed", type: "status_changed",
message: `Status changed to ${status}`, message: `Status changed to ${normalizedStatus}`,
status, status: normalizedStatus,
classification: updated.lifecycle?.failureClass, classification: updated.lifecycle?.failureClass,
}); });
this.emit("run:status_changed", updated); this.emit("run:status_changed", updated);
if (status === "completed") this.emit("run:completed", updated); if (normalizedStatus === "completed") this.emit("run:completed", updated);
if (status === "failed") this.emit("run:failed", updated); if (normalizedStatus === "failed") this.emit("run:failed", updated);
if (status === "cancelled") this.emit("run:cancelled", updated); if (normalizedStatus === "cancelled") this.emit("run:cancelled", updated);
if (normalizedStatus === "timed_out") this.emit("run:timed_out", updated);
} }
createExport(runId: string, format: ResearchExportFormat, content: string): ResearchExport { createExport(runId: string, format: ResearchExportFormat, content: string): ResearchExport {
@@ -444,11 +502,15 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
`).all() as Array<{ status: ResearchRunStatus; count: number }>; `).all() as Array<{ status: ResearchRunStatus; count: number }>;
const byStatus: Record<ResearchRunStatus, number> = { const byStatus: Record<ResearchRunStatus, number> = {
pending: 0, queued: 0,
running: 0, running: 0,
cancelling: 0,
retry_waiting: 0,
completed: 0, completed: 0,
failed: 0, failed: 0,
cancelled: 0, cancelled: 0,
timed_out: 0,
retry_exhausted: 0,
}; };
for (const row of rows) { for (const row of rows) {
@@ -462,7 +524,7 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
getActiveRun(projectId: string, trigger: string): ResearchRun | undefined { getActiveRun(projectId: string, trigger: string): ResearchRun | undefined {
const row = this.db.prepare(` const row = this.db.prepare(`
SELECT * FROM research_runs SELECT * FROM research_runs
WHERE projectId = ? AND trigger = ? AND status IN ('pending', 'running') WHERE projectId = ? AND trigger = ? AND status IN ('queued', 'running', 'cancelling', 'retry_waiting')
ORDER BY createdAt DESC ORDER BY createdAt DESC
LIMIT 1 LIMIT 1
`).get(projectId, trigger) as Record<string, unknown> | undefined; `).get(projectId, trigger) as Record<string, unknown> | undefined;
@@ -483,38 +545,46 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
const run = this.getRun(runId); const run = this.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`); if (!run) throw new Error(`Research run not found: ${runId}`);
if (TERMINAL_STATUSES.has(run.status)) { if (TERMINAL_STATUSES.has(run.status)) {
throw new ResearchLifecycleError(`Run ${runId} is already terminal`, "invalid_transition"); return run;
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
const alreadyCancelling = run.status === "cancelling";
const updated = this.updateRun(runId, { const updated = this.updateRun(runId, {
status: "cancelled", status: "cancelling",
cancelledAt: run.cancelledAt ?? now,
lifecycle: { lifecycle: {
...(run.lifecycle ?? {}), ...(run.lifecycle ?? {}),
cancellationRequestedAt: now, cancellationRequestedAt: run.lifecycle?.cancellationRequestedAt ?? now,
terminalReason: "cancelled",
terminalCause: reason, terminalCause: reason,
failureClass: "cancelled", errorCode: "RUN_CANCELLED",
retryable: false, retryable: false,
}, },
}); });
if (!updated) throw new Error(`Research run not found: ${runId}`); if (!updated) throw new Error(`Research run not found: ${runId}`);
this.appendLifecycleEvent(runId, { type: "cancel_requested", message: reason, status: "cancelled", classification: "cancelled" }); if (!alreadyCancelling) {
this.appendLifecycleEvent(runId, { type: "cancel_requested", message: reason, status: "cancelling", classification: "cancelled" });
}
return updated; return updated;
} }
createRetryRun(runId: string, maxAttempts?: number): ResearchRun { createRetryRun(runId: string, maxAttempts?: number): ResearchRun {
const run = this.getRun(runId); const run = this.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`); if (!run) throw new Error(`Research run not found: ${runId}`);
if (run.status !== "failed") { if (run.status !== "failed" && run.status !== "timed_out") {
throw new ResearchLifecycleError(`Run ${runId} is not failed`, "invalid_transition"); throw new ResearchLifecycleError(`Run ${runId} is not retryable from status ${run.status}`, "invalid_transition");
} }
const currentAttempt = run.lifecycle?.attempt ?? 1;
const configuredMaxAttempts = maxAttempts ?? run.lifecycle?.maxAttempts ?? 3;
const nextAttempt = currentAttempt + 1;
if (nextAttempt > configuredMaxAttempts) {
this.updateRun(runId, { status: "retry_exhausted" });
throw new ResearchLifecycleError(`Run ${runId} exhausted retries`, "not_retryable");
}
if (!run.lifecycle?.retryable) { if (!run.lifecycle?.retryable) {
throw new ResearchLifecycleError(`Run ${runId} is non-retryable`, "not_retryable"); throw new ResearchLifecycleError(`Run ${runId} is non-retryable`, "not_retryable");
} }
const nextAttempt = (run.lifecycle?.attempt ?? 1) + 1;
const rootRunId = run.lifecycle?.rootRunId ?? run.id; const rootRunId = run.lifecycle?.rootRunId ?? run.id;
const retryRun = this.createRun({ const retryRun = this.createRun({
query: run.query, query: run.query,
@@ -526,11 +596,17 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
metadata: run.metadata, metadata: run.metadata,
lifecycle: { lifecycle: {
attempt: nextAttempt, attempt: nextAttempt,
maxAttempts: maxAttempts ?? run.lifecycle?.maxAttempts ?? nextAttempt, maxAttempts: configuredMaxAttempts,
retryOfRunId: run.id, retryOfRunId: run.id,
rootRunId, rootRunId,
}, },
}); });
this.updateStatus(retryRun.id, "retry_waiting", {
lifecycle: {
...(retryRun.lifecycle ?? {}),
retryable: true,
},
});
this.appendLifecycleEvent(retryRun.id, { this.appendLifecycleEvent(retryRun.id, {
type: "retry_scheduled", type: "retry_scheduled",
message: `Retry scheduled from ${run.id}`, message: `Retry scheduled from ${run.id}`,
@@ -581,7 +657,7 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
id: row.id as string, id: row.id as string,
query: row.query as string, query: row.query as string,
topic: (row.topic as string | null) ?? undefined, topic: (row.topic as string | null) ?? undefined,
status: row.status as ResearchRunStatus, status: normalizeStatus((row.status as ResearchRunStatus | "pending") ?? "queued"),
projectId: (row.projectId as string | null) ?? undefined, projectId: (row.projectId as string | null) ?? undefined,
trigger: (row.trigger as string | null) ?? undefined, trigger: (row.trigger as string | null) ?? undefined,
providerConfig: fromJson<Record<string, unknown>>(row.providerConfig as string | null), providerConfig: fromJson<Record<string, unknown>>(row.providerConfig as string | null),

View File

@@ -5,11 +5,15 @@
*/ */
export const RESEARCH_RUN_STATUSES = [ export const RESEARCH_RUN_STATUSES = [
"pending", "queued",
"running", "running",
"cancelling",
"retry_waiting",
"completed", "completed",
"failed", "failed",
"cancelled", "cancelled",
"timed_out",
"retry_exhausted",
] as const; ] as const;
export type ResearchRunStatus = typeof RESEARCH_RUN_STATUSES[number]; export type ResearchRunStatus = typeof RESEARCH_RUN_STATUSES[number];
@@ -53,13 +57,30 @@ export const RESEARCH_RUN_FAILURE_CLASSES = [
"non_retryable", "non_retryable",
] as const; ] as const;
export const RESEARCH_ERROR_CODES = [
"FEATURE_DISABLED",
"MISSING_CREDENTIALS",
"PROVIDER_UNAVAILABLE",
"RATE_LIMITED",
"PROVIDER_TIMEOUT",
"RUN_CANCELLED",
"RETRY_EXHAUSTED",
"INVALID_TRANSITION",
"NON_RETRYABLE_PROVIDER_ERROR",
"INTERNAL_ERROR",
] as const;
export type ResearchErrorCode = typeof RESEARCH_ERROR_CODES[number];
export type ResearchRunFailureClass = typeof RESEARCH_RUN_FAILURE_CLASSES[number]; export type ResearchRunFailureClass = typeof RESEARCH_RUN_FAILURE_CLASSES[number];
export interface ResearchRunLifecycle { export interface ResearchRunLifecycle {
terminalReason?: "completed" | "cancelled" | "failed" | "timed_out"; terminalReason?: "completed" | "cancelled" | "failed" | "timed_out" | "retry_exhausted";
terminalCause?: string; terminalCause?: string;
failureClass?: ResearchRunFailureClass; failureClass?: ResearchRunFailureClass;
errorCode?: ResearchErrorCode;
retryable?: boolean; retryable?: boolean;
retryAfterMs?: number;
cancellationRequestedAt?: string; cancellationRequestedAt?: string;
timeoutAt?: string; timeoutAt?: string;
retryOfRunId?: string; retryOfRunId?: string;
@@ -206,6 +227,7 @@ export interface ResearchStoreEvents {
"run:completed": [ResearchRun]; "run:completed": [ResearchRun];
"run:failed": [ResearchRun]; "run:failed": [ResearchRun];
"run:cancelled": [ResearchRun]; "run:cancelled": [ResearchRun];
"run:timed_out": [ResearchRun];
"event:added": [{ runId: string; event: ResearchEvent }]; "event:added": [{ runId: string; event: ResearchEvent }];
"source:added": [{ runId: string; source: ResearchSource }]; "source:added": [{ runId: string; source: ResearchSource }];
} }

View File

@@ -108,7 +108,7 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
const statusDotClass = useMemo(() => { const statusDotClass = useMemo(() => {
if (!selectedRun) return "status-dot"; if (!selectedRun) return "status-dot";
if (selectedRun.status === "pending") return "status-dot status-dot--pending"; if (selectedRun.status === "queued" || selectedRun.status === "retry_waiting") return "status-dot status-dot--pending";
if (selectedRun.status === "running") return "status-dot status-dot--connecting"; if (selectedRun.status === "running") return "status-dot status-dot--connecting";
if (selectedRun.status === "completed") return "status-dot status-dot--online"; if (selectedRun.status === "completed") return "status-dot status-dot--online";
if (selectedRun.status === "failed" || selectedRun.status === "cancelled") return "status-dot status-dot--error"; if (selectedRun.status === "failed" || selectedRun.status === "cancelled") return "status-dot status-dot--error";

View File

@@ -163,7 +163,17 @@ export function useResearch(options?: { projectId?: string }) {
acc[run.status] += 1; acc[run.status] += 1;
return acc; return acc;
}, },
{ pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 }, {
queued: 0,
running: 0,
cancelling: 0,
retry_waiting: 0,
completed: 0,
failed: 0,
cancelled: 0,
timed_out: 0,
retry_exhausted: 0,
},
), ),
}; };
} }

View File

@@ -17,7 +17,7 @@ function createMockStore(options?: {
id: options?.runId ?? "RR-1", id: options?.runId ?? "RR-1",
query: "test", query: "test",
topic: "test", topic: "test",
status: "pending", status: "queued",
sources: [], sources: [],
events: [], events: [],
tags: [], tags: [],
@@ -46,6 +46,8 @@ function createMockStore(options?: {
getRun: vi.fn(() => (options?.missingRun ? null : run)), getRun: vi.fn(() => (options?.missingRun ? null : run)),
updateStatus: vi.fn(), updateStatus: vi.fn(),
updateRun: vi.fn(), updateRun: vi.fn(),
requestCancellation: vi.fn(() => ({ ...run, status: "cancelling" })),
createRetryRun: vi.fn(() => ({ ...run, id: "RR-2", status: "retry_waiting" })),
appendEvent: vi.fn(), appendEvent: vi.fn(),
addSource: vi.fn(), addSource: vi.fn(),
searchRuns: vi.fn(() => []), searchRuns: vi.fn(() => []),
@@ -99,11 +101,11 @@ describe("research-routes", () => {
const cancel = await performRequest(app, "POST", "/runs/RR-1/cancel"); const cancel = await performRequest(app, "POST", "/runs/RR-1/cancel");
expect(cancel.status).toBe(200); expect(cancel.status).toBe(200);
expect(cancel.body.run.status).toBe("pending"); expect(cancel.body.run.status).toBe("cancelling");
const retry = await performRequest(app, "POST", "/runs/RR-1/retry"); const retry = await performRequest(app, "POST", "/runs/RR-1/retry");
expect(retry.status).toBe(200); expect(retry.status).toBe(200);
expect(retry.body.run.status).toBe("pending"); expect(retry.body.run.status).toBe("retry_waiting");
const markdownExport = await performGet(app, "/runs/RR-1/export?format=markdown"); const markdownExport = await performGet(app, "/runs/RR-1/export?format=markdown");
expect(markdownExport.status).toBe(200); expect(markdownExport.status).toBe(200);
@@ -117,8 +119,8 @@ describe("research-routes", () => {
expect(htmlExport.status).toBe(200); expect(htmlExport.status).toBe(200);
expect(htmlExport.body.format).toBe("html"); expect(htmlExport.body.format).toBe("html");
expect(store.getResearchStore().updateStatus).toHaveBeenCalledWith("RR-1", "cancelled"); expect(store.getResearchStore().requestCancellation).toHaveBeenCalledWith("RR-1");
expect(store.getResearchStore().updateStatus).toHaveBeenCalledWith("RR-1", "pending"); expect(store.getResearchStore().createRetryRun).toHaveBeenCalledWith("RR-1");
}); });
it("creates task from finding with research provenance", async () => { it("creates task from finding with research provenance", async () => {
const store = createMockStore(); const store = createMockStore();

View File

@@ -36,9 +36,14 @@ class MockResponse extends EventEmitter {
} }
function createMockStore(): TaskStore { function createMockStore(): TaskStore {
const researchStore = {
on: vi.fn(),
off: vi.fn(),
};
return { return {
on: vi.fn(), on: vi.fn(),
off: vi.fn(), off: vi.fn(),
getResearchStore: vi.fn(() => researchStore),
} as unknown as TaskStore; } as unknown as TaskStore;
} }
@@ -284,6 +289,7 @@ describe("createSSE client cleanup", () => {
if (event === "task:created") onCreated = handler; if (event === "task:created") onCreated = handler;
}), }),
off: vi.fn(), off: vi.fn(),
getResearchStore: vi.fn(() => ({ on: vi.fn(), off: vi.fn() })),
} as unknown as TaskStore; } as unknown as TaskStore;
const baseline = getActiveSSEConnections(); const baseline = getActiveSSEConnections();

View File

@@ -7,6 +7,7 @@ import {
RESEARCH_SOURCE_TYPES, RESEARCH_SOURCE_TYPES,
RESEARCH_SOURCE_STATUSES, RESEARCH_SOURCE_STATUSES,
RESEARCH_EVENT_TYPES, RESEARCH_EVENT_TYPES,
ResearchLifecycleError,
buildResearchDocumentKey, buildResearchDocumentKey,
type ResearchRunListOptions, type ResearchRunListOptions,
type ResearchRunStatus, type ResearchRunStatus,
@@ -21,6 +22,10 @@ const DEFAULT_AVAILABILITY = {
function rethrowAsApiError(error: unknown, fallback = "Internal server error"): never { function rethrowAsApiError(error: unknown, fallback = "Internal server error"): never {
if (error instanceof ApiError) throw error; if (error instanceof ApiError) throw error;
if (error instanceof ResearchLifecycleError) {
const status = error.code === "invalid_transition" || error.code === "active_run_conflict" ? 409 : 400;
throw new ApiError(status, error.message, { code: error.code.toUpperCase() });
}
if (error instanceof Error) throw new ApiError(500, error.message); if (error instanceof Error) throw new ApiError(500, error.message);
throw new ApiError(500, fallback); throw new ApiError(500, fallback);
} }
@@ -202,9 +207,7 @@ export function createResearchRouter(store: TaskStore): Router {
router.post("/runs/:id/cancel", (req, res) => { router.post("/runs/:id/cancel", (req, res) => {
try { try {
getStore().updateStatus(req.params.id, "cancelled"); const run = getStore().requestCancellation(req.params.id);
const run = getStore().getRun(req.params.id);
if (!run) throw notFound(`Run not found: ${req.params.id}`);
res.json({ run: toRunDetail(run) }); res.json({ run: toRunDetail(run) });
} catch (error) { } catch (error) {
rethrowAsApiError(error, "Failed to cancel research run"); rethrowAsApiError(error, "Failed to cancel research run");
@@ -213,11 +216,8 @@ export function createResearchRouter(store: TaskStore): Router {
router.post("/runs/:id/retry", (req, res) => { router.post("/runs/:id/retry", (req, res) => {
try { try {
getStore().updateRun(req.params.id, { error: null }); const retryRun = getStore().createRetryRun(req.params.id);
getStore().updateStatus(req.params.id, "pending"); res.json({ run: toRunDetail(retryRun) });
const run = getStore().getRun(req.params.id);
if (!run) throw notFound(`Run not found: ${req.params.id}`);
res.json({ run: toRunDetail(run) });
} catch (error) { } catch (error) {
rethrowAsApiError(error, "Failed to retry research run"); rethrowAsApiError(error, "Failed to retry research run");
} }

View File

@@ -327,6 +327,7 @@ export function createSSE(
const connectionId = nextConnectionId++; const connectionId = nextConnectionId++;
const clientId = normalizeSSEClientId(_req.query?.clientId); const clientId = normalizeSSEClientId(_req.query?.clientId);
const socket = res.socket ?? _req.socket; const socket = res.socket ?? _req.socket;
const researchStore = store.getResearchStore();
res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache"); res.setHeader("Cache-Control", "no-cache");
@@ -380,6 +381,25 @@ export function createSSE(
send(`event: task:merged\ndata: ${JSON.stringify(stripTaskEventHeavyFields(result))}\n\n`); send(`event: task:merged\ndata: ${JSON.stringify(stripTaskEventHeavyFields(result))}\n\n`);
}; };
const onResearchRunCreated = (run: unknown) => {
send(`event: research:run:created\ndata: ${JSON.stringify(run)}\n\n`);
};
const onResearchRunUpdated = (run: unknown) => {
send(`event: research:run:updated\ndata: ${JSON.stringify(run)}\n\n`);
};
const onResearchRunCompleted = (run: unknown) => {
send(`event: research:run:completed\ndata: ${JSON.stringify(run)}\n\n`);
};
const onResearchRunFailed = (run: unknown) => {
send(`event: research:run:failed\ndata: ${JSON.stringify(run)}\n\n`);
};
const onResearchRunCancelled = (run: unknown) => {
send(`event: research:run:cancelled\ndata: ${JSON.stringify(run)}\n\n`);
};
const onResearchRunTimedOut = (run: unknown) => {
send(`event: research:run:timed_out\ndata: ${JSON.stringify(run)}\n\n`);
};
const onMissionCreated = (data: unknown) => { const onMissionCreated = (data: unknown) => {
send(`event: mission:created\ndata: ${JSON.stringify(data)}\n\n`); send(`event: mission:created\ndata: ${JSON.stringify(data)}\n\n`);
}; };
@@ -661,6 +681,12 @@ export function createSSE(
automationStore.off("schedule:deleted", onScheduleDeleted); automationStore.off("schedule:deleted", onScheduleDeleted);
automationStore.off("schedule:run", onScheduleRun); automationStore.off("schedule:run", onScheduleRun);
} }
researchStore.off("run:created", onResearchRunCreated);
researchStore.off("run:updated", onResearchRunUpdated);
researchStore.off("run:completed", onResearchRunCompleted);
researchStore.off("run:failed", onResearchRunFailed);
researchStore.off("run:cancelled", onResearchRunCancelled);
researchStore.off("run:timed_out", onResearchRunTimedOut);
} }
function closeConnection(reason: SSECloseReason): void { function closeConnection(reason: SSECloseReason): void {
@@ -759,6 +785,13 @@ export function createSSE(
automationStore.on("schedule:run", onScheduleRun); automationStore.on("schedule:run", onScheduleRun);
} }
researchStore.on("run:created", onResearchRunCreated);
researchStore.on("run:updated", onResearchRunUpdated);
researchStore.on("run:completed", onResearchRunCompleted);
researchStore.on("run:failed", onResearchRunFailed);
researchStore.on("run:cancelled", onResearchRunCancelled);
researchStore.on("run:timed_out", onResearchRunTimedOut);
// Heartbeat every 30s to keep connection alive. // Heartbeat every 30s to keep connection alive.
// Sent as a named event so the client's EventSource can detect it // Sent as a named event so the client's EventSource can detect it
// (SSE comments starting with ":" are silently consumed and never // (SSE comments starting with ":" are silently consumed and never

View File

@@ -13,7 +13,7 @@ function createHarness() {
const run: ResearchRun = { const run: ResearchRun = {
id, id,
query: input.query, query: input.query,
status: "pending", status: "queued",
providerConfig: input.providerConfig, providerConfig: input.providerConfig,
sources: [], sources: [],
events: [], events: [],
@@ -67,6 +67,13 @@ function createHarness() {
if (!run) throw new Error("missing run"); if (!run) throw new Error("missing run");
runs.set(id, { ...run, ...extra, status }); runs.set(id, { ...run, ...extra, status });
}), }),
requestCancellation: vi.fn((id: string) => {
const run = runs.get(id);
if (!run) throw new Error("missing run");
const next = { ...run, status: "cancelling" as ResearchRun["status"] };
runs.set(id, next);
return next;
}),
}; };
const stepRunner = { const stepRunner = {
@@ -135,7 +142,7 @@ describe("ResearchOrchestrator", () => {
expect(orchestrator.cancelRun(runId)).toBe(true); expect(orchestrator.cancelRun(runId)).toBe(true);
const run = await runPromise; const run = await runPromise;
expect(run.status).toBe("cancelled"); expect(["cancelling", "cancelled"]).toContain(run.status);
}); });
it("records step failures and continues when later providers succeed", async () => { it("records step failures and continues when later providers succeed", async () => {

View File

@@ -40,8 +40,11 @@ interface ActiveRunState {
stepIndex: number; stepIndex: number;
totalSteps: number; totalSteps: number;
config: ResearchOrchestrationConfig; config: ResearchOrchestrationConfig;
cancellationTimer?: NodeJS.Timeout;
} }
const CANCELLATION_GRACE_MS = 2_000;
export class ResearchOrchestrator { export class ResearchOrchestrator {
private readonly store: ResearchStore; private readonly store: ResearchStore;
private readonly stepRunner: ResearchStepRunnerApi; private readonly stepRunner: ResearchStepRunnerApi;
@@ -89,8 +92,14 @@ export class ResearchOrchestrator {
config, config,
}); });
const queued = this.store.getRun(runId);
if (queued?.status === "retry_waiting") {
this.store.updateStatus(runId, "queued");
}
await this.semaphore.run(async () => { await this.semaphore.run(async () => {
this.store.updateRun(runId, { query, status: "running", startedAt: new Date().toISOString(), error: null }); this.store.updateRun(runId, { query, startedAt: new Date().toISOString(), error: null });
this.store.updateStatus(runId, "running");
await this.runPhases(runId, query, config, controller.signal); await this.runPhases(runId, query, config, controller.signal);
}); });
@@ -101,7 +110,16 @@ export class ResearchOrchestrator {
cancelRun(runId: string): boolean { cancelRun(runId: string): boolean {
const active = this.activeRuns.get(runId); const active = this.activeRuns.get(runId);
if (!active) return false; const run = this.store.getRun(runId);
if (!run) return false;
this.store.requestCancellation(runId);
if (!active) {
this.store.updateStatus(runId, "cancelled", { error: "Cancelled by user" });
return true;
}
if (active.cancellationTimer) return true;
const state: ResearchCancellationState = { const state: ResearchCancellationState = {
runId, runId,
@@ -112,6 +130,9 @@ export class ResearchOrchestrator {
}; };
this.cancellation.set(runId, state); this.cancellation.set(runId, state);
active.controller.abort(new Error("Research run cancelled")); active.controller.abort(new Error("Research run cancelled"));
active.cancellationTimer = setTimeout(() => {
this.onCancelled(runId);
}, CANCELLATION_GRACE_MS);
return true; return true;
} }
@@ -190,6 +211,10 @@ export class ResearchOrchestrator {
this.transitionPhase(runId, "failed", "Research run failed", { error: message }); this.transitionPhase(runId, "failed", "Research run failed", { error: message });
} }
} finally { } finally {
const active = this.activeRuns.get(runId);
if (active?.cancellationTimer) {
clearTimeout(active.cancellationTimer);
}
this.activeRuns.delete(runId); this.activeRuns.delete(runId);
this.cancellation.delete(runId); this.cancellation.delete(runId);
} }
@@ -357,6 +382,7 @@ export class ResearchOrchestrator {
): Promise<void> { ): Promise<void> {
this.throwIfAborted(signal); this.throwIfAborted(signal);
this.transitionPhase(runId, "finalizing", "Finalizing research results"); this.transitionPhase(runId, "finalizing", "Finalizing research results");
if (!this.canWriteRunData(runId)) return;
this.store.setResults(runId, { this.store.setResults(runId, {
summary: output, summary: output,
findings: [ findings: [
@@ -373,6 +399,8 @@ export class ResearchOrchestrator {
} }
private onCancelled(runId: string): void { private onCancelled(runId: string): void {
const run = this.store.getRun(runId);
if (!run || run.status === "cancelled") return;
const cancellation = this.cancellation.get(runId); const cancellation = this.cancellation.get(runId);
this.store.addEvent(runId, { this.store.addEvent(runId, {
type: "warning", type: "warning",
@@ -395,6 +423,7 @@ export class ResearchOrchestrator {
message: string, message: string,
metadata?: Record<string, unknown>, metadata?: Record<string, unknown>,
): void { ): void {
if (!this.canWriteRunData(runId) && phase !== "cancelled" && phase !== "completed" && phase !== "failed") return;
const active = this.activeRuns.get(runId); const active = this.activeRuns.get(runId);
if (active) { if (active) {
active.phase = phase; active.phase = phase;
@@ -421,6 +450,7 @@ export class ResearchOrchestrator {
} }
private stepStarted(runId: string, step: ResearchOrchestrationStep): void { private stepStarted(runId: string, step: ResearchOrchestrationStep): void {
if (!this.canWriteRunData(runId)) return;
this.bumpStep(runId, step.order); this.bumpStep(runId, step.order);
this.store.addEvent(runId, { this.store.addEvent(runId, {
type: "progress", type: "progress",
@@ -433,6 +463,7 @@ export class ResearchOrchestrator {
} }
private stepCompleted(runId: string, stepId: string, output?: Record<string, unknown>): void { private stepCompleted(runId: string, stepId: string, output?: Record<string, unknown>): void {
if (!this.canWriteRunData(runId)) return;
this.store.addEvent(runId, { this.store.addEvent(runId, {
type: "progress", type: "progress",
message: `${stepId} completed`, message: `${stepId} completed`,
@@ -450,6 +481,7 @@ export class ResearchOrchestrator {
errorMessage: string, errorMessage: string,
errorMeta?: Record<string, unknown>, errorMeta?: Record<string, unknown>,
): void { ): void {
if (!this.canWriteRunData(runId)) return;
this.store.addEvent(runId, { this.store.addEvent(runId, {
type: "error", type: "error",
message: `${stepId} failed: ${errorMessage}`, message: `${stepId} failed: ${errorMessage}`,
@@ -504,8 +536,8 @@ export class ResearchOrchestrator {
private statusToPhase(status: ResearchRun["status"]): ResearchOrchestrationPhase { private statusToPhase(status: ResearchRun["status"]): ResearchOrchestrationPhase {
if (status === "completed") return "completed"; if (status === "completed") return "completed";
if (status === "failed") return "failed"; if (status === "failed" || status === "timed_out" || status === "retry_exhausted") return "failed";
if (status === "cancelled") return "cancelled"; if (status === "cancelled" || status === "cancelling") return "cancelled";
return "planning"; return "planning";
} }
@@ -514,4 +546,10 @@ export class ResearchOrchestrator {
throw signal.reason ?? new Error("Research run aborted"); throw signal.reason ?? new Error("Research run aborted");
} }
} }
private canWriteRunData(runId: string): boolean {
const run = this.store.getRun(runId);
if (!run) return false;
return !["cancelled", "completed", "failed", "timed_out", "retry_exhausted"].includes(run.status);
}
} }