fix: stop triage loop at completion-summary node + i18n object-key crashes (#1863) (#1866)

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:
gsxdsm
2026-07-01 17:31:43 -07:00
committed by GitHub
23 changed files with 391 additions and 12 deletions

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

View File

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

View File

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

View File

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

View File

@@ -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([]);
});
});

View File

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

View File

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

View File

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

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "작업 실행 통계",

View File

@@ -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": "任务执行统计",

View File

@@ -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": "任務執行統計",

View File

@@ -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",

View File

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

View File

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

View File

@@ -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
View File

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