fix(engine): mock provider sends 0-based steps — test mode full-task runs complete again (#3231)

Found by a live browser E2E of the coding workflow in test mode: every
scripted full-task run failed at `steps#0:step-execute` with `Step 4 out
of range (task has 4 steps)`, rebounding through recovery forever.

**Root cause:** `fn_task_update.step` has been **0-based since FN-6607**
(executor.ts FNXC:StepNumbering — the old `step - 1` conversion made
Step 0 impossible to mark). `mock-provider.ts` still sent `index + 1`,
so test mode marked steps 1..N instead of 0..N-1: Step 0 (Preflight)
never completed and step N threw out-of-range. Test mode's full-task
path has been broken since June.

**Also fixes the test that pinned the bug:** `mock-provider.test.ts`
expected `{ step: 1 }` for a fixture whose first unfinished step is
index 0 — the expectation encoded the 1-based off-by-one.

Verified: 12/12 mock-provider tests; the live E2E instance completes the
task after this patch (see follow-up screenshot in the session).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 12:54:58 -07:00
committed by GitHub
parent 7f3acf8929
commit 623581837a
6 changed files with 74 additions and 6 deletions

View File

@@ -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.

View File

@@ -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<void>");
expect(initStart).toBeGreaterThan(-1);
const initEnd = centralCoreSrc.indexOf("async close(): Promise<void>", 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");
});
});

View File

@@ -355,7 +355,13 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
private async initializeOnce(): Promise<void> { private async initializeOnce(): Promise<void> {
this.assertOpen(); this.assertOpen();
if (this.initialized) return; 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) { if (this.asyncLayer) {
await asyncCentralCore.ensureBackendBootstrap(this.asyncLayer); await asyncCentralCore.ensureBackendBootstrap(this.asyncLayer);
this.initialized = true; this.initialized = true;

View File

@@ -90,12 +90,18 @@ router.get("/activity-feed", async (req, res) => {
const typesParam = typeof req.query.types === "string" ? req.query.types.split(",") : undefined; const typesParam = typeof req.query.types === "string" ? req.query.types.split(",") : undefined;
const types = typesParam as import("@fusion/core").ActivityEventType[] | undefined; const types = typesParam as import("@fusion/core").ActivityEventType[] | undefined;
const { CentralCore } = await import("@fusion/core"); /*
const central = new CentralCore(); FNXC:ActivityFeed 2026-07-28-03:00:
await central.init(); 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 }); const entries = await central.getRecentActivity({ limit, projectId, types });
await central.close(); if (shouldClose) await central.close();
res.json(entries); res.json(entries);
} catch (err: unknown) { } catch (err: unknown) {

View File

@@ -149,7 +149,9 @@ describe("MockAgentRuntime", () => {
clearMockScript({ sessionPurpose: "executor", taskId }); clearMockScript({ sessionPurpose: "executor", taskId });
updateExecute.mockClear(); updateExecute.mockClear();
await runtime.promptWithFallback(session, "default"); 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 () => { it("treats graph-owned executor step sessions as successful without lifecycle tools", async () => {

View File

@@ -226,7 +226,16 @@ const DEFAULT_SCRIPTS: Record<MockSessionPurpose, MockScript> = {
} }
for (const [index, step] of steps.entries()) { for (const [index, step] of steps.entries()) {
if (step.status !== "done" && step.status !== "skipped") { 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."); ctx.options.onText?.("Mock executor completed scripted step updates.");