Fixes #1863. The issue conflated **two distinct v0.52.0 regressions** (the reporter's i18n root-cause guess was only half of it). ## 1. The real triage loop — engine (primary fix) The `completion-summary` graph node was added to every built-in workflow in v0.52.0 (FN-7228/FN-7233), wired with a **success-only edge — no failure edge**. It's a *best-effort* node (`ensureWorkflowCompletionSummary` already backfills `task.summary`), but two failure paths bypass its advisory `!blocking → success` coercion: - a **thrown handler exception** (e.g. missing worktree) → `executeNodeWithRetries` returns `value:"exception"` - a **failed summary projection write** → `publishTaskProjectionFromResult` returns `value:"projection-error"` With no failure edge, either **terminates the graph at `completion-summary`**. `routeGraphFailureToExecutionResume` then treats the in-review failure as recoverable and moves the task back to `todo`, which replays the already-passed nodes (0 new tokens) and fails again — the **infinite `triage → in-progress → todo` loop** in the report (token usage 0, execution NOT STARTED). **Fix:** the graph executor now degrades a `completion-summary` node failure to `success` so the graph always advances, with a `routeGraphFailureToExecutionResume` backstop that parks `failed` instead of looping. Shared `isCompletionSummaryNode` predicate exported from `@fusion/core`. ## 2. The i18n crash the reporter saw — dashboard `t("taskDetail.executionMode")` resolves to a **nested object** (the inline mode-toggle copy), so i18next returns *"key 'taskDetail.executionMode (en)' returned an object instead of string"* and crashes the Stats tab — the exact error in the report. Surface enumeration found **two more** callers of the same class: `routing.source` and `nodes.dockerHost`. Added leaf label keys across all 6 locales and switched the callers. ## Tests - `workflow-graph-completion-summary-nonfatal.test.ts` — a failing summary node (both modes) never terminates/loops the graph, a non-summary node still fails, and all 5 built-ins have no failure edge. **Fails without the engine fix.** - `i18n-string-keys-not-objects.test.ts` — invariant guard scanning every `t("literal")` caller against the real `en/app.json`. **Catches a reverted caller.** - `TaskTokenStatsPanel.test.tsx` — reproduces the exact i18next error against the real bundle. ## Verification - Merge gate: **317 engine-core + 63 CI-shape pass** - Core/engine typecheck clean; i18n parity + typecheck pass; changeset validates - All touched dashboard/core/engine suites green 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Fixed an issue where completion-summary failures could cause tasks to loop indefinitely during resume. * Made completion-summary behavior best-effort so workflows keep progressing even when summary handling fails. * Prevented dashboard crashes by ensuring UI labels use leaf translation keys (not nested objects). * **New Features** * Added missing i18n label keys for Docker host, routing source, and execution mode across multiple languages. * **Tests** * Added regression coverage for completion-summary non-fatal handling and translation-key validation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
.changeset/fix-stats-execution-mode-object-key.md
Normal file
7
.changeset/fix-stats-execution-mode-object-key.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { existsSync, lstatSync, realpathSync } from "node:fs";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult } from "@fusion/core";
|
||||
import { getUnmetSchedulingDependencies } from "./scheduler.js";
|
||||
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, isSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget } from "@fusion/core";
|
||||
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, isSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID } from "@fusion/core";
|
||||
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
|
||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
|
||||
@@ -8484,6 +8484,17 @@ export class TaskExecutor {
|
||||
if (live.deletedAt) return false;
|
||||
if (live.paused || live.userPaused === true) return false;
|
||||
if (live.column === "done" || live.column === "archived") return false;
|
||||
/*
|
||||
* FNXC:WorkflowCompletion 2026-07-01-16:26:
|
||||
* Backstop for issue #1863. The advisory completion-summary node must never
|
||||
* drive the in-review→todo resume loop: it has no failure edge, so a failure
|
||||
* here would bounce the task back to execution every run and never stick.
|
||||
* The graph executor now degrades summary-node failures to success, so this
|
||||
* should be unreachable — but if a summary failure ever reaches this router,
|
||||
* let the caller park the task `failed` (a visible terminal state) instead of
|
||||
* looping it forever.
|
||||
*/
|
||||
if (failedNode === COMPLETION_SUMMARY_NODE_ID) return false;
|
||||
const incompleteSteps = hasNonTerminalWorkflowSteps(live);
|
||||
const prematureMergeWithIncompleteSteps = failedNode === "merge" && failureValue === "implementation-incomplete" && incompleteSteps;
|
||||
if (live.column !== "in-review" && !(incompleteSteps && live.column === "todo") && !(prematureMergeWithIncompleteSteps && live.column === "in-progress")) return false;
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
WorkflowNodeExtensionResult,
|
||||
WorkflowStepResult,
|
||||
} from "@fusion/core";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR, PLAN_REVIEW_GROUP_ID, WorkflowIrError, getWorkflowExtensionRegistry, resolveMaxReworkCycles, isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG } from "@fusion/core";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR, PLAN_REVIEW_GROUP_ID, WorkflowIrError, getWorkflowExtensionRegistry, resolveMaxReworkCycles, isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG, isCompletionSummaryNode } from "@fusion/core";
|
||||
|
||||
import {
|
||||
createDefaultNodeHandlers,
|
||||
@@ -1207,6 +1207,30 @@ export class WorkflowGraphExecutor {
|
||||
return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" });
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:WorkflowCompletion 2026-07-01-16:24:
|
||||
* A thrown handler exception (missing/pruned worktree, model/provider error,
|
||||
* etc.) bypasses `runGraphCustomNode`'s advisory `!blocking → success`
|
||||
* coercion and lands here as a hard `exception` failure. For the best-effort
|
||||
* completion-summary node that failure has nowhere to go (success-only edge),
|
||||
* so it terminates the graph and loops the in-review task back to todo forever
|
||||
* (issue #1863). Degrade the summary node's exhausted-retry failure to success
|
||||
* — the deterministic `ensureWorkflowCompletionSummary` fallback still fills
|
||||
* `task.summary` — so the graph always advances past it.
|
||||
*/
|
||||
if (isCompletionSummaryNode(node)) {
|
||||
const degraded: WorkflowNodeResult = {
|
||||
outcome: "success",
|
||||
value: "summary-unavailable",
|
||||
contextPatch: {
|
||||
[`node:${node.id}:error`]: lastError instanceof Error ? lastError.message : String(lastError),
|
||||
},
|
||||
};
|
||||
if (recordProgress && this.shouldRecordNodeProgress(node)) {
|
||||
await this.recordNodeProgressFinish(task.id, node, null, degraded);
|
||||
}
|
||||
return degraded;
|
||||
}
|
||||
const failureResult: WorkflowNodeResult = {
|
||||
outcome: "failure",
|
||||
value: "exception",
|
||||
@@ -1327,6 +1351,25 @@ export class WorkflowGraphExecutor {
|
||||
try {
|
||||
await this.deps.publishTaskProjection?.(taskId, patch, source);
|
||||
} catch (error) {
|
||||
/*
|
||||
* FNXC:WorkflowCompletion 2026-07-01-16:24:
|
||||
* The advisory completion-summary node persists its text via a `summary`
|
||||
* projection patch. A failed projection write here must NOT become a
|
||||
* `projection-error` graph failure: that node has no failure edge, so it
|
||||
* would terminate the graph and `routeGraphFailureToExecutionResume` would
|
||||
* bounce the in-review task back to todo forever (issue #1863 v0.52.0
|
||||
* triage loop). Keep the node's success outcome — `ensureWorkflowCompletionSummary`
|
||||
* still backfills `task.summary` deterministically at the review/done boundary.
|
||||
*/
|
||||
if (isCompletionSummaryNode(node)) {
|
||||
return {
|
||||
...result,
|
||||
contextPatch: {
|
||||
...(result.contextPatch ?? {}),
|
||||
[`node:${node.id}:projectionError`]: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
outcome: "failure",
|
||||
value: "projection-error",
|
||||
|
||||
@@ -4163,6 +4163,7 @@
|
||||
"local": "Local Docker",
|
||||
"remote": "Remote: {{host}}"
|
||||
},
|
||||
"dockerHostLabel": "Host",
|
||||
"dockerHostConfig": "Host Config",
|
||||
"dockerHostPath": "Host path",
|
||||
"dockerHostUrl": "Docker host URL",
|
||||
@@ -5234,6 +5235,7 @@
|
||||
"override": "Per-task override",
|
||||
"projectDefault": "Project default"
|
||||
},
|
||||
"sourceLabel": "Routing source",
|
||||
"summarySection": "Routing Summary",
|
||||
"title": "Task Routing",
|
||||
"unavailablePolicy": "Unavailable-node policy",
|
||||
@@ -7325,6 +7327,7 @@
|
||||
"standard": "Standard",
|
||||
"updated": "Execution mode updated to {{mode}}"
|
||||
},
|
||||
"executionModeLabel": "Execution mode",
|
||||
"executionModeFast": "Fast",
|
||||
"executionModeStandard": "Standard",
|
||||
"executionStatsAria": "Task execution statistics",
|
||||
|
||||
@@ -4153,6 +4153,7 @@
|
||||
"local": "Docker local",
|
||||
"remote": "Remoto: {{host}}"
|
||||
},
|
||||
"dockerHostLabel": "Host",
|
||||
"dockerHostConfig": "Configuración del host",
|
||||
"dockerHostPath": "Ruta del host",
|
||||
"dockerHostUrl": "URL del host de Docker",
|
||||
@@ -5224,6 +5225,7 @@
|
||||
"override": "Anulación por tarea",
|
||||
"projectDefault": "Predeterminado del proyecto"
|
||||
},
|
||||
"sourceLabel": "Origen de enrutamiento",
|
||||
"summarySection": "Resumen de enrutamiento",
|
||||
"title": "Enrutamiento de tareas",
|
||||
"unavailablePolicy": "Política de nodo no disponible",
|
||||
@@ -7315,6 +7317,7 @@
|
||||
"replanTitle": "¿Cambiar el modo de ejecución y replanificar?",
|
||||
"replanning": "Modo de ejecución actualizado a {{mode}} — {{id}} volvió a Planificación para replanificación"
|
||||
},
|
||||
"executionModeLabel": "Modo de ejecución",
|
||||
"executionModeFast": "Rápido",
|
||||
"executionModeStandard": "Estándar",
|
||||
"executionStatsAria": "Estadísticas de ejecución de la tarea",
|
||||
|
||||
@@ -4153,6 +4153,7 @@
|
||||
"local": "Docker local",
|
||||
"remote": "Distant : {{host}}"
|
||||
},
|
||||
"dockerHostLabel": "Hôte",
|
||||
"dockerHostConfig": "Config hôte",
|
||||
"dockerHostPath": "Chemin hôte",
|
||||
"dockerHostUrl": "URL de l'hôte Docker",
|
||||
@@ -5224,6 +5225,7 @@
|
||||
"override": "Substitution par tâche",
|
||||
"projectDefault": "Défaut du projet"
|
||||
},
|
||||
"sourceLabel": "Source de routage",
|
||||
"summarySection": "Résumé du routage",
|
||||
"title": "Routage des tâches",
|
||||
"unavailablePolicy": "Politique en cas de nœud indisponible",
|
||||
@@ -7315,6 +7317,7 @@
|
||||
"replanTitle": "Changer le mode d'exécution et replanifier ?",
|
||||
"replanning": "Mode d'exécution mis à jour vers {{mode}} — {{id}} est revenue en planification pour replanification"
|
||||
},
|
||||
"executionModeLabel": "Mode d'exécution",
|
||||
"executionModeFast": "Rapide",
|
||||
"executionModeStandard": "Standard",
|
||||
"executionStatsAria": "Statistiques d'exécution de la tâche",
|
||||
|
||||
@@ -4153,6 +4153,7 @@
|
||||
"local": "로컬 Docker",
|
||||
"remote": "원격: {{host}}"
|
||||
},
|
||||
"dockerHostLabel": "호스트",
|
||||
"dockerHostConfig": "호스트 구성",
|
||||
"dockerHostPath": "호스트 경로",
|
||||
"dockerHostUrl": "Docker 호스트 URL",
|
||||
@@ -5224,6 +5225,7 @@
|
||||
"override": "작업별 재정의",
|
||||
"projectDefault": "프로젝트 기본값"
|
||||
},
|
||||
"sourceLabel": "라우팅 소스",
|
||||
"summarySection": "라우팅 요약",
|
||||
"title": "작업 라우팅",
|
||||
"unavailablePolicy": "사용 불가 노드 정책",
|
||||
@@ -7315,6 +7317,7 @@
|
||||
"replanTitle": "실행 모드를 변경하고 다시 계획할까요?",
|
||||
"replanning": "실행 모드가 {{mode}}로 업데이트되었습니다 — {{id}}가 재계획을 위해 계획으로 돌아갔습니다"
|
||||
},
|
||||
"executionModeLabel": "실행 모드",
|
||||
"executionModeFast": "빠름",
|
||||
"executionModeStandard": "표준",
|
||||
"executionStatsAria": "작업 실행 통계",
|
||||
|
||||
@@ -4153,6 +4153,7 @@
|
||||
"local": "本地 Docker",
|
||||
"remote": "远程: {{host}}"
|
||||
},
|
||||
"dockerHostLabel": "主机",
|
||||
"dockerHostConfig": "主机配置",
|
||||
"dockerHostPath": "主机路径",
|
||||
"dockerHostUrl": "Docker 主机地址",
|
||||
@@ -5224,6 +5225,7 @@
|
||||
"override": "每个任务的覆盖",
|
||||
"projectDefault": "项目默认值"
|
||||
},
|
||||
"sourceLabel": "路由来源",
|
||||
"summarySection": "路由摘要",
|
||||
"title": "任务路由",
|
||||
"unavailablePolicy": "不可用节点策略",
|
||||
@@ -7315,6 +7317,7 @@
|
||||
"replanTitle": "更改执行模式并重新规划?",
|
||||
"replanning": "执行模式已更新为 {{mode}} — {{id}} 已返回规划以重新规划"
|
||||
},
|
||||
"executionModeLabel": "执行模式",
|
||||
"executionModeFast": "快速",
|
||||
"executionModeStandard": "标准",
|
||||
"executionStatsAria": "任务执行统计",
|
||||
|
||||
@@ -4153,6 +4153,7 @@
|
||||
"local": "本地 Docker",
|
||||
"remote": "遠端: {{host}}"
|
||||
},
|
||||
"dockerHostLabel": "主機",
|
||||
"dockerHostConfig": "主機設定",
|
||||
"dockerHostPath": "主機路徑",
|
||||
"dockerHostUrl": "Docker 主機網址",
|
||||
@@ -5224,6 +5225,7 @@
|
||||
"override": "每個工作的覆寫",
|
||||
"projectDefault": "專案預設值"
|
||||
},
|
||||
"sourceLabel": "路由來源",
|
||||
"summarySection": "路由摘要",
|
||||
"title": "任務路由",
|
||||
"unavailablePolicy": "不可用節點政策",
|
||||
@@ -7315,6 +7317,7 @@
|
||||
"replanTitle": "變更執行模式並重新規劃?",
|
||||
"replanning": "執行模式已更新為 {{mode}} — {{id}} 已返回規劃以重新規劃"
|
||||
},
|
||||
"executionModeLabel": "執行模式",
|
||||
"executionModeFast": "快速",
|
||||
"executionModeStandard": "標準",
|
||||
"executionStatsAria": "任務執行統計",
|
||||
|
||||
3
packages/i18n/src/resources.d.ts
vendored
3
packages/i18n/src/resources.d.ts
vendored
@@ -4164,6 +4164,7 @@ export default interface Resources {
|
||||
"local": "Local Docker",
|
||||
"remote": "Remote: {{host}}"
|
||||
},
|
||||
"dockerHostLabel": "Host",
|
||||
"dockerHostConfig": "Host Config",
|
||||
"dockerHostPath": "Host path",
|
||||
"dockerHostUrl": "Docker host URL",
|
||||
@@ -5217,6 +5218,7 @@ export default interface Resources {
|
||||
"override": "Per-task override",
|
||||
"projectDefault": "Project default"
|
||||
},
|
||||
"sourceLabel": "Routing source",
|
||||
"summarySection": "Routing Summary",
|
||||
"title": "Task Routing",
|
||||
"unavailablePolicy": "Unavailable-node policy",
|
||||
@@ -7351,6 +7353,7 @@ export default interface Resources {
|
||||
"standard": "Standard",
|
||||
"updated": "Execution mode updated to {{mode}}"
|
||||
},
|
||||
"executionModeLabel": "Execution mode",
|
||||
"executionModeFast": "Fast",
|
||||
"executionModeStandard": "Standard",
|
||||
"executionStatsAria": "Task execution statistics",
|
||||
|
||||
@@ -5,7 +5,16 @@
|
||||
* subprocess using stream-json NDJSON protocol.
|
||||
*/
|
||||
|
||||
import { getModels } from "@earendil-works/pi-ai";
|
||||
/*
|
||||
* FNXC:ModelCatalog 2026-07-01-17:30:
|
||||
* pi-ai 0.80 restructured its static catalog API: the top-level `getModels`
|
||||
* export moved to the deprecated `/compat` shim, and the canonical accessor is
|
||||
* `getBuiltinModels` from `@earendil-works/pi-ai/providers/all`. pi-claude-cli
|
||||
* now pins `@earendil-works/pi-ai` and `@earendil-works/pi-coding-agent` to
|
||||
* `^0.80.3` (matching cli/engine) so the whole extension resolves one pi-ai
|
||||
* version and the ExtensionAPI stream types stay compatible.
|
||||
*/
|
||||
import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { streamViaCli } from "./src/provider.js";
|
||||
import { streamViaAcp } from "./src/acp-driver.js";
|
||||
@@ -175,7 +184,7 @@ export default function (pi: ExtensionAPI) {
|
||||
// `claude` subprocess in streamViaCli still reports hard errors on send.
|
||||
void runCliValidationOnce();
|
||||
|
||||
const catalogModels = getModels("anthropic").map((model) => ({
|
||||
const catalogModels = getBuiltinModels("anthropic").map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
reasoning: model.reasoning,
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
"@earendil-works/pi-coding-agent": "^0.80.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@earendil-works/pi-ai": "^0.80.3",
|
||||
"@earendil-works/pi-coding-agent": "^0.80.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.0"
|
||||
|
||||
@@ -59,13 +59,18 @@ const { MockAssistantMessageEventStream } = vi.hoisted(() => {
|
||||
});
|
||||
|
||||
vi.mock("@earendil-works/pi-ai", () => ({
|
||||
getModels: vi.fn(() => mockModels),
|
||||
AssistantMessageEventStream: MockAssistantMessageEventStream,
|
||||
calculateCost: vi.fn(),
|
||||
}));
|
||||
|
||||
// pi-ai 0.80 moved the static catalog read to `getBuiltinModels` in the
|
||||
// `/providers/all` subpath (see index.ts). Mock it there.
|
||||
vi.mock("@earendil-works/pi-ai/providers/all", () => ({
|
||||
getBuiltinModels: vi.fn(() => mockModels),
|
||||
}));
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { getModels } from "@earendil-works/pi-ai";
|
||||
import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
|
||||
import { streamViaCli } from "../provider";
|
||||
|
||||
describe("provider registration (default export)", () => {
|
||||
@@ -148,7 +153,7 @@ describe("provider registration (default export)", () => {
|
||||
it("deduplicates extra models when catalog already includes them", async () => {
|
||||
const registerProvider = vi.fn();
|
||||
const mockPi = { registerProvider, on: vi.fn() } as any;
|
||||
const getModelsMock = vi.mocked(getModels);
|
||||
const getModelsMock = vi.mocked(getBuiltinModels);
|
||||
const upstreamSonnet5 = {
|
||||
id: "claude-sonnet-5",
|
||||
name: "Claude Sonnet 5 Upstream",
|
||||
|
||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -609,13 +609,13 @@ importers:
|
||||
'@agentclientprotocol/sdk':
|
||||
specifier: 0.24.0
|
||||
version: 0.24.0(zod@4.3.6)
|
||||
devDependencies:
|
||||
'@earendil-works/pi-ai':
|
||||
specifier: ^0.80.3
|
||||
version: 0.80.3(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
'@earendil-works/pi-coding-agent':
|
||||
specifier: ^0.80.3
|
||||
version: 0.80.3(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^25.5.2
|
||||
version: 25.5.2
|
||||
|
||||
Reference in New Issue
Block a user