test(FN-4663): add stranded refinement route and store coverage

Fusion-Task-Id: FN-4663
Fusion-Task-Lineage: 80975e9f-51e2-42ee-9f61-5e065a3fed24
This commit is contained in:
Fusion (runfusion.ai)
2026-05-15 23:22:33 -07:00
committed by gsxdsm
parent 52b167879b
commit 2626f61708
3 changed files with 223 additions and 32 deletions

View File

@@ -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([]);
});
});

View File

@@ -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> = {}): 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<typeof vi.fn>).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);
});
});

View File

@@ -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) {