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:
7
.changeset/mock-provider-step-indexing.md
Normal file
7
.changeset/mock-provider-step-indexing.md
Normal 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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -355,7 +355,13 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
private async initializeOnce(): Promise<void> {
|
||||
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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -226,7 +226,16 @@ const DEFAULT_SCRIPTS: Record<MockSessionPurpose, MockScript> = {
|
||||
}
|
||||
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.");
|
||||
|
||||
Reference in New Issue
Block a user