Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-stats-execution-mode-object-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---

summary: Fix tasks looping through triage at the completion-summary node, plus Stats/Routing/Node labels crashing.
category: fix
dev: Issue #1863 (v0.52.0 regression). (1) The best-effort completion-summary graph node is wired with a success-only edge, so a thrown handler exception or a failed summary projection write terminated the graph and the in-review→todo resume router bounced the task forever. The graph executor now degrades a completion-summary node failure to success (ensureWorkflowCompletionSummary still backfills task.summary), with a routeGraphFailureToExecutionResume backstop. (2) Three views called t() with keys that resolve to nested objects (taskDetail.executionMode, routing.source, nodes.dockerHost); added leaf label keys across all locales and a dashboard invariant test that scans t("literal") callers against the real en/app.json.
15 changes: 15 additions & 0 deletions packages/core/src/builtin-completion-summary-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@ import type { WorkflowIrNode } from "./workflow-ir-types.js";

export const COMPLETION_SUMMARY_NODE_ID = "completion-summary";

/*
* FNXC:WorkflowCompletion 2026-07-01-16:20:
* Single source of truth for "is this the advisory completion-summary projection
* node?". The engine, graph executor, and lifecycle validation all key summary
* behavior off `summaryTarget: "task"` (built-in id `completion-summary`). This
* node is BEST-EFFORT: `ensureWorkflowCompletionSummary` deterministically
* backfills `task.summary` at the review/done boundary, and every built-in
* workflow wires it with a success-only edge (no failure edge — see
* `builtin-workflows.ts` `linear()` and the `*-workflow-ir.ts` graphs). So a
* summary-node failure must never terminate or loop the graph (issue #1863).
*/
export function isCompletionSummaryNode(node: { id?: string; config?: Record<string, unknown> }): boolean {
return node.config?.summaryTarget === "task" || node.id === COMPLETION_SUMMARY_NODE_ID;
}

const COMPLETION_SUMMARY_PROMPT = `Generate the final completion summary for this task.

Use the task description, executed workflow context, changed files/diff, verification notes, and any produced artifacts.
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,11 @@ export {
isBuiltinWorkflowId,
isBuiltinWorkflowPluginGated,
} from "./builtin-workflows.js";
export {
COMPLETION_SUMMARY_NODE_ID,
completionSummaryNode,
isCompletionSummaryNode,
} from "./builtin-completion-summary-node.js";
export {
resolveWorkflowIrForTask,
resolveWorkflowIrById,
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/workflow-lifecycle-validation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { WorkflowDefinitionKind } from "./workflow-definition-types.js";
import type { WorkflowIr, WorkflowIrEdge, WorkflowIrNode } from "./workflow-ir-types.js";
import { isCompletionSummaryNode } from "./builtin-completion-summary-node.js";

/**
* FNXC:WorkflowLifecycle 2026-07-01-00:00:
Expand Down Expand Up @@ -36,7 +37,7 @@ export interface AnalyzeWorkflowLifecycleOptions {
}

function isSummaryNode(node: WorkflowIrNode): boolean {
return node.config?.summaryTarget === "task" || node.id === "completion-summary";
return isCompletionSummaryNode(node);
}

function isMergeNode(node: WorkflowIrNode): boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
import realEnApp from "../../../i18n/locales/en/app.json";

/*
* FNXC:TaskStats 2026-07-01-00:00:
* Invariant guard for issue #1863. A dashboard component that calls
* `t("some.key")` where `some.key` resolves to a NESTED OBJECT (not a leaf
* string) makes i18next return "key 'some.key (en)' returned an object instead
* of string" and crashes that view's render. Three surfaces hit this class:
* the Task Stats "Execution mode" row, the Routing tab source label, and the
* Node Detail Docker host label. The bug is invisible to component tests
* because the shared test i18n bundle only carries a handful of keys, so calls
* fall through to their inline English defaults instead of resolving the object.
*
* This test scans the real dashboard source against the real en/app.json so a
* new object-key-as-string caller fails here regardless of test-bundle gaps.
*/

// Dotted paths under the `app` namespace whose value is an object (non-leaf).
function collectObjectKeyPaths(obj: unknown, prefix: string, out: Set<string>): void {
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return;
if (prefix) out.add(prefix);
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
collectObjectKeyPaths(value, prefix ? `${prefix}.${key}` : key, out);
}
}

function collectSourceFiles(dir: string, out: string[]): void {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name === "__tests__" || entry.name.startsWith(".")) continue;
const full = resolve(dir, entry.name);
if (entry.isDirectory()) {
collectSourceFiles(full, out);
} else if (/\.(ts|tsx)$/.test(entry.name) && !/\.test\.(ts|tsx)$/.test(entry.name)) {
out.push(full);
}
}
}

describe("dashboard i18n string-context callers", () => {
it("never calls t() with a key that resolves to a nested object", () => {
const objectKeyPaths = new Set<string>();
collectObjectKeyPaths(realEnApp, "", objectKeyPaths);

const appDir = resolve(__dirname, "..");
const files: string[] = [];
collectSourceFiles(appDir, files);

// Match `t("dotted.key"` where the leading char is not part of another
// identifier (so `getT(`, `handleSort(`, `params.set(` are excluded).
const callRe = /(?<![\w$.])t\(\s*["']([\w.]+)["']/g;
const violations: string[] = [];

for (const file of files) {
const text = readFileSync(file, "utf8");
let match: RegExpExecArray | null;
while ((match = callRe.exec(text)) !== null) {
const key = match[1];
if (!objectKeyPaths.has(key)) continue;
// `t(key, opts)` with returnObjects:true intentionally reads the object.
const tail = text.slice(match.index, match.index + 200);
if (/returnObjects\s*:\s*true/.test(tail)) continue;
const line = text.slice(0, match.index).split("\n").length;
violations.push(`${file.slice(appDir.length + 1)}:${line} → t("${key}") resolves to an object`);
}
}

expect(violations, `Object-valued i18n keys used in string context:\n${violations.join("\n")}`).toEqual([]);
});
});
3 changes: 2 additions & 1 deletion packages/dashboard/app/components/NodeDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,8 @@ export function NodeDetailModal({
<div className="node-detail-modal__grid docker-management__info-grid">
<div className="node-detail-modal__field"><span>{t("nodes.dockerFieldImage", "Image")}</span><strong><code>{managedDockerNode.imageName}:{managedDockerNode.imageTag}</code></strong></div>
<div className="node-detail-modal__field"><span>{t("nodes.dockerContainerId", "Container ID")}</span><strong><code>{managedDockerNode.containerId ? managedDockerNode.containerId.slice(0, 12) : "—"}</code></strong></div>
<div className="node-detail-modal__field"><span>{t("nodes.dockerHost", "Host")}</span><strong>{dockerHost}</strong></div>
{/* FNXC:Nodes 2026-07-01-00:00: `nodes.dockerHost` is a nested object (local/remote); read the `nodes.dockerHostLabel` leaf so i18next returns a string instead of crashing (issue #1863 bug class). */}
<div className="node-detail-modal__field"><span>{t("nodes.dockerHostLabel", "Host")}</span><strong>{dockerHost}</strong></div>
<div className="node-detail-modal__field"><span>{t("nodes.dockerPersistentStorage", "Persistent Storage")}</span><strong>{managedDockerNode.persistentStorage ? t("nodes.yes", "Yes") : t("nodes.no", "No")}</strong></div>
<div className="node-detail-modal__field"><span>{t("nodes.dockerPort", "Port")}</span><strong>{parsePortFromReachableUrl(managedDockerNode.reachableUrl)}</strong></div>
<div className="node-detail-modal__field"><span>{t("nodes.dockerResourceSizing", "Resource Sizing")}</span><strong>{dockerResourceSizing}</strong></div>
Expand Down
3 changes: 2 additions & 1 deletion packages/dashboard/app/components/RoutingTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
</span>
</div>
<div className="routing-summary-row" role="listitem">
<span className="routing-summary-label">{t("routing.source", "Routing source")}</span>
{/* FNXC:Routing 2026-07-01-00:00: `routing.source` is a nested object (noRouting/override/projectDefault); the label must read the `routing.sourceLabel` leaf or i18next returns "returned an object instead of string" and crashes this tab (issue #1863 bug class). */}
<span className="routing-summary-label">{t("routing.sourceLabel", "Routing source")}</span>
<span className="routing-summary-value">{routingSource}</span>
</div>
<div className="routing-summary-row" role="listitem">
Expand Down
6 changes: 5 additions & 1 deletion packages/dashboard/app/components/TaskTokenStatsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,11 @@ export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStat
<h5>{t("taskDetail.executionDetails", "Execution Details")}</h5>
<dl className="task-token-stats-panel__details">
<div className="task-token-stats-panel__detail-row">
<dt>{t("taskDetail.executionMode", "Execution mode")}</dt>
{/*
FNXC:TaskStats 2026-07-01-00:00:
Use the leaf `taskDetail.executionModeLabel`, not `taskDetail.executionMode`, which is a nested object (ariaLabel/fast/standard/replan* copy for the inline mode toggle). Calling `t()` on the object key makes i18next return "key 'taskDetail.executionMode (en)' returned an object instead of string" and crashes the Stats tab render (issue #1863).
*/}
<dt>{t("taskDetail.executionModeLabel", "Execution mode")}</dt>
<dd>{task?.executionMode === "fast" ? t("taskDetail.executionModeFast", "Fast") : t("taskDetail.executionModeStandard", "Standard")}</dd>
</div>
<div className="task-token-stats-panel__detail-row">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { createInstance } from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import { TaskTokenStatsPanel } from "../TaskTokenStatsPanel";
import type { Task } from "@fusion/core";
import realEnApp from "../../../../i18n/locales/en/app.json";

function makeTask(overrides: Partial<Task> = {}): Task {
return {
Expand Down Expand Up @@ -271,4 +274,47 @@ describe("TaskTokenStatsPanel", () => {
const metric = screen.getByText("Total execution time").closest(".task-token-stats-panel__metric");
expect(metric).toHaveTextContent("3m 0s");
});

/*
* FNXC:TaskStats 2026-07-01-00:00:
* Regression for issue #1863. The shared component-test i18n bundle only carries
* a handful of `app` keys, so every other key falls through to its inline default
* and this panel rendered fine in tests while crashing in production. Load the
* REAL en/app.json — where `taskDetail.executionMode` is a nested object (the
* inline-toggle copy) — behind an I18nextProvider that mirrors production init
* options, and assert the Execution Details label resolves to the leaf string
* without i18next's "returned an object instead of string" fallback.
*/
it("renders the execution-mode label against the real locale bundle without an object-key crash", async () => {
const instance = createInstance();
await instance.use(initReactI18next).init({
lng: "en",
fallbackLng: "en",
ns: ["app"],
defaultNS: "app",
// Mirror production so a key that resolves to an object surfaces the same
// way it does at runtime instead of being silently swallowed.
returnNull: false,
returnEmptyString: false,
returnObjects: false,
react: { useSuspense: false },
interpolation: { escapeValue: false },
resources: { en: { app: realEnApp } },
});

// Sanity-check the fixture still encodes the collision this test guards.
expect(typeof (realEnApp as { taskDetail: { executionMode: unknown } }).taskDetail.executionMode).toBe("object");

render(
<I18nextProvider i18n={instance}>
<TaskTokenStatsPanel loading={false} tokenUsage={undefined} task={makeTask({ executionMode: "fast" })} />
</I18nextProvider>,
);

const label = screen.getByText("Execution mode");
expect(label.tagName).toBe("DT");
expect(label.textContent).not.toMatch(/returned an object/i);
// The value cell resolves the fast/standard leaf, proving the row rendered.
expect(label.nextElementSibling?.textContent).toBe("Fast");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";
import {
BUILTIN_CODING_WORKFLOW_IR,
BUILTIN_MARKETING_WORKFLOW_IR,
BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR,
BUILTIN_LEAD_GENERATION_WORKFLOW_IR,
COMPLETION_SUMMARY_NODE_ID,
} from "@fusion/core";
import type { TaskDetail, WorkflowIr } from "@fusion/core";

import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";

/*
* FNXC:WorkflowCompletion 2026-07-01-16:30:
* Regression for issue #1863 (v0.52.0 triage loop). The built-in
* completion-summary node is best-effort — a deterministic fallback backfills
* task.summary — and every built-in workflow wires it with a success-only edge.
* A thrown handler exception or a failed summary projection write bypasses the
* advisory `!blocking → success` coercion and, absent a failure edge, terminates
* the graph at 'completion-summary'; the in-review→todo resume router then bounces
* the task back to execution forever. These tests assert the invariant across BOTH
* failure modes: a failing completion-summary node never terminates or loops the
* graph, while a non-summary node still fails normally.
*/

const task = { id: "FN-1863" } as TaskDetail;

function settingsOn() {
return { experimentalFeatures: { workflowGraphExecutor: true } };
}

function summaryGraph(): WorkflowIr {
// Mirror the built-in wiring: completion-summary has a success-only outgoing
// edge and no failure edge.
return {
version: "v1",
name: "summary-nonfatal",
nodes: [
{ id: "start", kind: "start" },
{ id: COMPLETION_SUMMARY_NODE_ID, kind: "prompt", config: { summaryTarget: "task" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: COMPLETION_SUMMARY_NODE_ID },
{ from: COMPLETION_SUMMARY_NODE_ID, to: "end", condition: "success" },
],
};
}

describe("completion-summary node is non-fatal (issue #1863)", () => {
it("advances past a completion-summary node whose handler throws", async () => {
const prompt = vi.fn(async () => {
throw new Error("worktree missing during summary");
});
const executor = new WorkflowGraphExecutor({ handlers: { prompt }, maxRetriesPerNode: 1 });

const result = await executor.run(task, settingsOn(), summaryGraph());

// Success (not a terminal graph failure) proves the node was degraded and
// the graph advanced instead of terminating at completion-summary. (`end`
// nodes are not recorded in visitedNodeIds, so assert on outcome.)
expect(result.outcome).toBe("success");
expect(result.visitedNodeIds).toContain(COMPLETION_SUMMARY_NODE_ID);
expect(prompt).toHaveBeenCalled();
});

it("advances past a completion-summary node whose summary projection write fails", async () => {
const prompt = vi.fn(async () => ({
outcome: "success" as const,
contextPatch: { summary: "Did the thing." },
}));
const publishTaskProjection = vi.fn(async () => {
throw new Error("db write failed");
});
const executor = new WorkflowGraphExecutor({
handlers: { prompt },
publishTaskProjection,
maxRetriesPerNode: 1,
});

const result = await executor.run(task, settingsOn(), summaryGraph());

expect(result.outcome).toBe("success");
expect(result.visitedNodeIds).toContain(COMPLETION_SUMMARY_NODE_ID);
// The write was attempted (and swallowed) — not skipped.
expect(publishTaskProjection).toHaveBeenCalled();
});

it("still fails the graph when a NON-summary node throws (degrade is summary-scoped)", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "plain-throws",
nodes: [
{ id: "start", kind: "start" },
{ id: "work", kind: "prompt" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "work" },
{ from: "work", to: "end", condition: "success" },
],
};
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async () => {
throw new Error("boom");
},
},
maxRetriesPerNode: 1,
});

const result = await executor.run(task, settingsOn(), ir);

expect(result.outcome).toBe("failure");
});

// Surface enumeration: the fix matters because NONE of the built-in workflows
// give completion-summary a failure edge — so any failure there terminates the
// graph unless the executor degrades it.
const builtins: Array<[string, WorkflowIr]> = [
["coding", BUILTIN_CODING_WORKFLOW_IR],
["stepwise-coding", BUILTIN_STEPWISE_CODING_WORKFLOW_IR],
["stepwise-final-review-coding", BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR],
["marketing", BUILTIN_MARKETING_WORKFLOW_IR],
["lead-generation", BUILTIN_LEAD_GENERATION_WORKFLOW_IR],
];

it.each(builtins)("built-in %s workflow wires completion-summary with no failure edge", (_name, ir) => {
const node = ir.nodes.find((n) => n.id === COMPLETION_SUMMARY_NODE_ID);
expect(node, "built-in workflow should contain a completion-summary node").toBeTruthy();
const outgoing = ir.edges.filter((e) => e.from === COMPLETION_SUMMARY_NODE_ID);
expect(outgoing.length).toBeGreaterThan(0);
expect(outgoing.some((e) => e.condition === "failure")).toBe(false);
});
});
Loading
Loading