diff --git a/.changeset/mock-provider-step-indexing.md b/.changeset/mock-provider-step-indexing.md new file mode 100644 index 0000000000..1f00ea0519 --- /dev/null +++ b/.changeset/mock-provider-step-indexing.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Test mode tasks complete again — the mock executor marked the wrong steps since the 0-based step change. +category: fix +dev: mock-provider.ts sent 1-based step numbers to fn_task_update (0-based since FN-6607), so scripted full-task runs never marked Step 0 and failed with "Step N out of range" at steps#0:step-execute. diff --git a/packages/core/src/__tests__/central-core-init-bootstrap-guard.test.ts b/packages/core/src/__tests__/central-core-init-bootstrap-guard.test.ts new file mode 100644 index 0000000000..2a7378563b --- /dev/null +++ b/packages/core/src/__tests__/central-core-init-bootstrap-guard.test.ts @@ -0,0 +1,38 @@ +/** + * FNXC:CentralCore 2026-07-28-03:00: + * Regression guard for GET /api/activity-feed 500 + * (`backendHandle is only available in backend mode (asyncLayer injected)`). + * #2454 left `createCentralBackendLayer` as dead code behind an early return on + * layer-less `init()`. Dashboard/CLI call sites use `new CentralCore(); await init()` + * without `attachBackendLayer`, so init must still bootstrap PG. + */ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const centralCoreSrc = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "../central-core.ts"), + "utf8", +); + +describe("CentralCore.init layer-less PG bootstrap", () => { + it("keeps createCentralBackendLayer reachable (no #2454 early-return no-op)", () => { + const initStart = centralCoreSrc.indexOf("async init(): Promise"); + expect(initStart).toBeGreaterThan(-1); + const initEnd = centralCoreSrc.indexOf("async close(): Promise", initStart); + expect(initEnd).toBeGreaterThan(initStart); + const initBody = centralCoreSrc.slice(initStart, initEnd); + + expect(initBody).toContain("createCentralBackendLayer"); + // The regression: after the asyncLayer branch, mark initialized and return + // without ever reaching createCentralBackendLayer. + expect(initBody).not.toMatch( + /if \(this\.asyncLayer\) \{[\s\S]*?\n\s*\}\s*\n\s*this\.initialized = true;\s*\n\s*return;\s*\n/, + ); + // Bootstrap must still assign the owned layer + shutdown hooks. + expect(initBody).toContain("ownedBackendShutdown"); + expect(initBody).toContain("ownedBackendReleaseConnections"); + }); +}); diff --git a/packages/core/src/central-core.ts b/packages/core/src/central-core.ts index b0ac793b90..abf75fb7d3 100644 --- a/packages/core/src/central-core.ts +++ b/packages/core/src/central-core.ts @@ -355,7 +355,13 @@ export class CentralCore extends EventEmitter { private async initializeOnce(): Promise { this.assertOpen(); if (this.initialized) return; + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:15: + CentralCore.init is PostgreSQL-only (SQLite CentralDatabase path deleted). + FNXC:CentralCore 2026-07-28-03:00: + #2454 accidentally early-returned on layer-less init and left the PG bootstrap as dead code. Dashboard routes (e.g. GET /api/activity-feed) and CLI fallbacks call `new CentralCore(); await init(); getRecentActivity()` without attachBackendLayer, so a no-op init throws backendHandle ("only available in backend mode"). Restore layer-less createCentralBackendLayer bootstrap. When a pre-injected asyncLayer exists, bootstrap that shared layer only. Runtime serve may still call attachBackendLayer later to adopt the TaskStore pool (releases any owned central-only pool). + */ if (this.asyncLayer) { await asyncCentralCore.ensureBackendBootstrap(this.asyncLayer); this.initialized = true; diff --git a/packages/dashboard/src/routes/register-setup-activity-routes.ts b/packages/dashboard/src/routes/register-setup-activity-routes.ts index a8078a6dd3..e597acc550 100644 --- a/packages/dashboard/src/routes/register-setup-activity-routes.ts +++ b/packages/dashboard/src/routes/register-setup-activity-routes.ts @@ -90,12 +90,18 @@ router.get("/activity-feed", async (req, res) => { const typesParam = typeof req.query.types === "string" ? req.query.types.split(",") : undefined; const types = typesParam as import("@fusion/core").ActivityEventType[] | undefined; - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); + /* + FNXC:ActivityFeed 2026-07-28-03:00: + Prefer the server-owned centralCore (already backend-mode with asyncLayer) over `new CentralCore()` so GET /api/activity-feed does not open a per-request pool and cannot hit backendHandle-before-attach. Mirrors global-concurrency / setup-state routes. Layer-less fallback still works once CentralCore.init restores PG bootstrap (#2454 regression). + */ + const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore(); + const shouldClose = !options?.centralCore; + if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) { + await central.init(); + } const entries = await central.getRecentActivity({ limit, projectId, types }); - await central.close(); + if (shouldClose) await central.close(); res.json(entries); } catch (err: unknown) { diff --git a/packages/engine/src/__tests__/mock-provider.test.ts b/packages/engine/src/__tests__/mock-provider.test.ts index 08d175fac6..bfb167423b 100644 --- a/packages/engine/src/__tests__/mock-provider.test.ts +++ b/packages/engine/src/__tests__/mock-provider.test.ts @@ -149,7 +149,9 @@ describe("MockAgentRuntime", () => { clearMockScript({ sessionPurpose: "executor", taskId }); updateExecute.mockClear(); await runtime.promptWithFallback(session, "default"); - expect(updateExecute).toHaveBeenCalledWith(expect.any(String), { step: 1, status: "done" }, undefined, undefined, expect.anything()); + // FNXC:MockProvider 2026-07-31-13:00: fn_task_update.step is 0-based (FN-6607); this expectation + // previously pinned the mock's 1-based off-by-one, which skipped Step 0 and overran the last step. + expect(updateExecute).toHaveBeenCalledWith(expect.any(String), { step: 0, status: "done" }, undefined, undefined, expect.anything()); }); it("treats graph-owned executor step sessions as successful without lifecycle tools", async () => { diff --git a/packages/engine/src/providers/mock-provider.ts b/packages/engine/src/providers/mock-provider.ts index 46d9185b33..b435d91e2b 100644 --- a/packages/engine/src/providers/mock-provider.ts +++ b/packages/engine/src/providers/mock-provider.ts @@ -226,7 +226,16 @@ const DEFAULT_SCRIPTS: Record = { } for (const [index, step] of steps.entries()) { if (step.status !== "done" && step.status !== "skipped") { - await ctx.invokeTool("fn_task_update", { step: index + 1, status: "done" }); + /* + FNXC:MockProvider 2026-07-31-13:00: + fn_task_update.step is 0-BASED since FN-6607 (see executor.ts FNXC:StepNumbering) — the same + number agents see in PROMPT.md, where Step 0 is Preflight. This call kept the pre-FN-6607 + 1-based convention, so under test mode the mock marked steps 1..N instead of 0..N-1: Step 0 + was never marked done and step N threw "Step N out of range", failing the graph at + steps#0:step-execute on every scripted full-task run. Found by a live browser E2E of the + coding workflow in test mode. + */ + await ctx.invokeTool("fn_task_update", { step: index, status: "done" }); } } ctx.options.onText?.("Mock executor completed scripted step updates.");