diff --git a/packages/core/src/__tests__/stranded-refinements.test.ts b/packages/core/src/__tests__/stranded-refinements.test.ts new file mode 100644 index 000000000..54e08aadf --- /dev/null +++ b/packages/core/src/__tests__/stranded-refinements.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { TaskStore } from "../store.js"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-stranded-refinements-")); +} + +describe("TaskStore.listStrandedRefinements", () => { + let rootDir: string; + let store: TaskStore; + + beforeEach(async () => { + rootDir = makeTmpDir(); + store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"), { inMemoryDb: true }); + await store.init(); + }); + + afterEach(async () => { + store.stopWatching(); + await rm(rootDir, { recursive: true, force: true }); + }); + + it("classifies stranded refinement reasons and excludes fresh/paused/non-triage", async () => { + const createRefinement = async (label: string) => { + const source = await store.createTask({ description: `source-${label}`, column: "done" }); + return store.refineTask(source.id, `refine-${label}`); + }; + + const stale = await createRefinement("stale"); + const awaiting = await createRefinement("awaiting"); + const failed = await createRefinement("failed"); + const stuck = await createRefinement("stuck"); + const backoff = await createRefinement("backoff"); + const paused = await createRefinement("paused"); + const fresh = await createRefinement("fresh"); + const nonTriage = await createRefinement("todo"); + + await store.updateTask(awaiting.id, { status: "awaiting-approval" }); + await store.updateTask(failed.id, { status: "failed" }); + await store.updateTask(stuck.id, { status: "stuck-killed" }); + await store.updateTask(backoff.id, { nextRecoveryAt: new Date(Date.now() + 60_000).toISOString() }); + await store.updateTask(paused.id, { paused: true }); + await store.moveTask(nonTriage.id, "todo"); + + const db = store.getDatabase(); + db.prepare('UPDATE tasks SET createdAt = ?, updatedAt = ? WHERE id = ?').run( + new Date(Date.now() - 11 * 60_000).toISOString(), + new Date().toISOString(), + stale.id, + ); + + const list = await store.listStrandedRefinements({ freshnessThresholdMs: 10 * 60 * 1000 }); + const byId = new Map(list.map((entry) => [entry.task.id, entry.reasons])); + + expect(byId.get(stale.id)).toContain("untriaged-stale"); + expect(byId.get(awaiting.id)).toContain("awaiting-approval"); + expect(byId.get(failed.id)).toContain("failed"); + expect(byId.get(stuck.id)).toContain("stuck-killed"); + expect(byId.get(backoff.id)).toContain("recovery-backoff"); + expect(byId.has(paused.id)).toBe(false); + expect(byId.has(fresh.id)).toBe(false); + expect(byId.has(nonTriage.id)).toBe(false); + }); + + it("returns empty when no refinement tasks are stranded", async () => { + await store.createTask({ description: "normal task" }); + const list = await store.listStrandedRefinements(); + expect(list).toEqual([]); + }); +}); diff --git a/packages/dashboard/src/__tests__/stranded-refinements-routes.test.ts b/packages/dashboard/src/__tests__/stranded-refinements-routes.test.ts new file mode 100644 index 000000000..596271b39 --- /dev/null +++ b/packages/dashboard/src/__tests__/stranded-refinements-routes.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment node + +import { describe, it, expect, vi } from "vitest"; +import express from "express"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { TaskStore, TaskDetail } from "@fusion/core"; +import { createApiRoutes } from "../routes.js"; +import { request as performRequest } from "../test-request.js"; + +function createMockStore(overrides: Partial = {}): TaskStore { + return { + getSettings: vi.fn().mockResolvedValue({}), + getRootDir: vi.fn().mockReturnValue(mkdtempSync(join(tmpdir(), "kb-stranded-route-"))), + listStrandedRefinements: vi.fn().mockResolvedValue([]), + getTask: vi.fn(), + updateTask: vi.fn(), + moveTask: vi.fn(), + logEntry: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + off: vi.fn(), + ...overrides, + } as unknown as TaskStore; +} + +function createApp(store: TaskStore) { + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store)); + return app; +} + +async function REQUEST(app: express.Express, method: string, path: string) { + return performRequest(app, method, path); +} + +const BASE_TASK: TaskDetail = { + id: "FN-100", + title: "refine", + description: "refine", + column: "triage", + sourceType: "task_refine", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date(Date.now() - 11 * 60_000).toISOString(), + updatedAt: new Date().toISOString(), + prompt: "", +}; + +describe("stranded refinement routes", () => { + it("GET /tasks/stranded-refinements returns list", async () => { + const store = createMockStore({ + listStrandedRefinements: vi.fn().mockResolvedValue([{ task: BASE_TASK, reasons: ["untriaged-stale"], ageMs: 1000 }]), + }); + const res = await REQUEST(createApp(store), "GET", "/api/tasks/stranded-refinements"); + expect(res.status).toBe(200); + expect(res.body.items).toHaveLength(1); + }); + + it("GET /tasks/stranded-refinements rejects invalid freshnessMinutes", async () => { + const store = createMockStore(); + const res = await REQUEST(createApp(store), "GET", "/api/tasks/stranded-refinements?freshnessMinutes=0"); + expect(res.status).toBe(400); + }); + + it("POST /tasks/:id/expedite-refinement keeps awaiting-approval status", async () => { + const task = { ...BASE_TASK, status: "awaiting-approval" as const }; + const store = createMockStore({ getTask: vi.fn().mockResolvedValue(task) }); + const res = await REQUEST(createApp(store), "POST", "/api/tasks/FN-100/expedite-refinement"); + expect(res.status).toBe(200); + expect(res.body.expedited).toBe(false); + expect(res.body.requiresOperatorAction).toBe("approve-plan"); + expect((store.updateTask as ReturnType).mock.calls.length).toBe(0); + }); + + it("POST /tasks/:id/expedite-refinement clears recovery backoff", async () => { + const task = { ...BASE_TASK, nextRecoveryAt: new Date(Date.now() + 60_000).toISOString() }; + const updated = { ...task, nextRecoveryAt: undefined }; + const store = createMockStore({ + getTask: vi.fn().mockResolvedValue(task), + listStrandedRefinements: vi.fn().mockResolvedValue([{ task, reasons: ["recovery-backoff"], ageMs: 1000 }]), + updateTask: vi.fn().mockResolvedValue(updated), + }); + const res = await REQUEST(createApp(store), "POST", "/api/tasks/FN-100/expedite-refinement"); + expect(res.status).toBe(200); + expect(res.body.expedited).toBe(true); + expect(res.body.task.column).toBe("triage"); + }); + + it("GET /tasks/:id/stranded-refinement returns detail", async () => { + const rootDir = mkdtempSync(join(tmpdir(), "kb-stranded-detail-")); + mkdirSync(join(rootDir, ".fusion", "tasks", "FN-100"), { recursive: true }); + writeFileSync(join(rootDir, ".fusion", "tasks", "FN-100", "PROMPT.md"), "# prompt\n"); + const store = createMockStore({ + getRootDir: vi.fn().mockReturnValue(rootDir), + getTask: vi.fn().mockResolvedValue(BASE_TASK), + listStrandedRefinements: vi.fn().mockResolvedValue([{ task: BASE_TASK, reasons: ["untriaged-stale"], ageMs: 1000 }]), + }); + const res = await REQUEST(createApp(store), "GET", "/api/tasks/FN-100/stranded-refinement"); + expect(res.status).toBe(200); + expect(res.body.promptExists).toBe(true); + }); +}); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index c88183098..644c2effd 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -991,6 +991,35 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } }); + router.get("/tasks/stranded-refinements", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const rawFreshnessMinutes = req.query.freshnessMinutes; + let freshnessThresholdMs: number | undefined; + if (rawFreshnessMinutes !== undefined) { + const value = Array.isArray(rawFreshnessMinutes) ? rawFreshnessMinutes[0] : rawFreshnessMinutes; + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1440) { + throw badRequest("freshnessMinutes must be a positive integer <= 1440"); + } + freshnessThresholdMs = parsed * 60 * 1000; + } + + const items = await scopedStore.listStrandedRefinements({ freshnessThresholdMs }); + res.json({ + items: items.map((item) => ({ + ...item, + recommendation: strandedReasonRecommendation(item.reasons), + })), + }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + // Get single task with prompt content router.get("/tasks/:id", async (req, res) => { try { @@ -1128,35 +1157,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork return "safe to expedite"; }; - router.get("/tasks/stranded-refinements", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - const rawFreshnessMinutes = req.query.freshnessMinutes; - let freshnessThresholdMs: number | undefined; - if (rawFreshnessMinutes !== undefined) { - const value = Array.isArray(rawFreshnessMinutes) ? rawFreshnessMinutes[0] : rawFreshnessMinutes; - const parsed = Number.parseInt(String(value), 10); - if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1440) { - throw badRequest("freshnessMinutes must be a positive integer <= 1440"); - } - freshnessThresholdMs = parsed * 60 * 1000; - } - - const items = await scopedStore.listStrandedRefinements({ freshnessThresholdMs }); - res.json({ - items: items.map((item) => ({ - ...item, - recommendation: strandedReasonRecommendation(item.reasons), - })), - }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - router.get("/tasks/:id/stranded-refinement", async (req, res) => { try { const { existsSync } = await import("node:fs"); @@ -1237,19 +1237,30 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork return res.json({ task, expedited: false, requiresOperatorAction: "retry-stuck" }); } - if (!task.nextRecoveryAt) { + const stranded = await scopedStore.listStrandedRefinements(); + const strandedEntry = stranded.find((item) => item.task.id === task.id); + const hasExpediteLog = (task.log ?? []).some((entry) => entry.action === "Refinement expedited"); + const canExpedite = strandedEntry?.reasons.includes("untriaged-stale") || strandedEntry?.reasons.includes("recovery-backoff"); + + if (hasExpediteLog && !task.nextRecoveryAt) { return res.json({ task, expedited: true, alreadyExpedited: true }); } + if (!canExpedite) { + return res.json({ task, expedited: false, alreadyExpedited: false, reason: "not-stranded" }); + } + const updated = await scopedStore.updateTask(task.id, { nextRecoveryAt: undefined, }); - await scopedStore.logEntry(task.id, "Refinement expedited", "Cleared nextRecoveryAt"); + await scopedStore.logEntry(task.id, "Refinement expedited", strandedEntry?.reasons.includes("recovery-backoff") + ? "Cleared nextRecoveryAt" + : "Expedite request recorded for stale refinement"); res.json({ task: updated, expedited: true, - alreadyExpedited: false, + alreadyExpedited: hasExpediteLog && !task.nextRecoveryAt, }); } catch (err: unknown) { if (err instanceof ApiError) {