From 38841695c8b01b4f4c25d733f85f5e13fc1f4936 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 16:43:08 -0700 Subject: [PATCH 1/2] fix: stop triage loop at completion-summary node and i18n object-key crashes (#1863) Two distinct v0.52.0 regressions reported in issue #1863. 1. Triage loop (engine): the best-effort completion-summary graph node is wired into every built-in workflow with a success-only edge. A thrown handler exception or a failed summary projection write bypassed the advisory `!blocking -> success` coercion, terminated the graph at 'completion-summary', and routeGraphFailureToExecutionResume bounced the in-review task back to todo forever (token usage 0, execution NOT STARTED). The graph executor now degrades a completion-summary node failure to success (ensureWorkflowCompletionSummary still backfills task.summary), with a routeGraphFailureToExecutionResume backstop. Shared isCompletionSummaryNode predicate exported from @fusion/core. 2. i18n object-key crashes (dashboard): three views called t() with keys that resolve to nested objects (taskDetail.executionMode, routing.source, nodes.dockerHost), so i18next returned "returned an object instead of string" and crashed the render. Added leaf label keys across all locales and switched the callers. Tests: engine non-fatal completion-summary regression (fails without the fix), dashboard invariant guard scanning t("literal") callers against real en/app.json, and a Stats-panel reproduction against the real bundle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fix-stats-execution-mode-object-key.md | 7 + .../src/builtin-completion-summary-node.ts | 15 ++ packages/core/src/index.ts | 5 + .../core/src/workflow-lifecycle-validation.ts | 3 +- .../i18n-string-keys-not-objects.test.ts | 72 ++++++++++ .../app/components/NodeDetailModal.tsx | 3 +- .../dashboard/app/components/RoutingTab.tsx | 3 +- .../app/components/TaskTokenStatsPanel.tsx | 6 +- .../__tests__/TaskTokenStatsPanel.test.tsx | 46 ++++++ ...-graph-completion-summary-nonfatal.test.ts | 136 ++++++++++++++++++ packages/engine/src/executor.ts | 13 +- .../engine/src/workflow-graph-executor.ts | 45 +++++- packages/i18n/locales/en/app.json | 3 + packages/i18n/locales/es/app.json | 3 + packages/i18n/locales/fr/app.json | 3 + packages/i18n/locales/ko/app.json | 3 + packages/i18n/locales/zh-CN/app.json | 3 + packages/i18n/locales/zh-TW/app.json | 3 + packages/i18n/src/resources.d.ts | 3 + 19 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-stats-execution-mode-object-key.md create mode 100644 packages/dashboard/app/__tests__/i18n-string-keys-not-objects.test.ts create mode 100644 packages/engine/src/__tests__/workflow-graph-completion-summary-nonfatal.test.ts diff --git a/.changeset/fix-stats-execution-mode-object-key.md b/.changeset/fix-stats-execution-mode-object-key.md new file mode 100644 index 0000000000..fd11799cd4 --- /dev/null +++ b/.changeset/fix-stats-execution-mode-object-key.md @@ -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. diff --git a/packages/core/src/builtin-completion-summary-node.ts b/packages/core/src/builtin-completion-summary-node.ts index d01106ad8d..47a76c76be 100644 --- a/packages/core/src/builtin-completion-summary-node.ts +++ b/packages/core/src/builtin-completion-summary-node.ts @@ -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 }): 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. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a882d2eb8b..eb02e4f73d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/workflow-lifecycle-validation.ts b/packages/core/src/workflow-lifecycle-validation.ts index a51af64ea6..86de8132c6 100644 --- a/packages/core/src/workflow-lifecycle-validation.ts +++ b/packages/core/src/workflow-lifecycle-validation.ts @@ -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 { diff --git a/packages/dashboard/app/__tests__/i18n-string-keys-not-objects.test.ts b/packages/dashboard/app/__tests__/i18n-string-keys-not-objects.test.ts new file mode 100644 index 0000000000..f05a45f9c7 --- /dev/null +++ b/packages/dashboard/app/__tests__/i18n-string-keys-not-objects.test.ts @@ -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): 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)) { + 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(); + 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 = /(?
{t("nodes.dockerFieldImage", "Image")}{managedDockerNode.imageName}:{managedDockerNode.imageTag}
{t("nodes.dockerContainerId", "Container ID")}{managedDockerNode.containerId ? managedDockerNode.containerId.slice(0, 12) : "—"}
-
{t("nodes.dockerHost", "Host")}{dockerHost}
+ {/* 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). */} +
{t("nodes.dockerHostLabel", "Host")}{dockerHost}
{t("nodes.dockerPersistentStorage", "Persistent Storage")}{managedDockerNode.persistentStorage ? t("nodes.yes", "Yes") : t("nodes.no", "No")}
{t("nodes.dockerPort", "Port")}{parsePortFromReachableUrl(managedDockerNode.reachableUrl)}
{t("nodes.dockerResourceSizing", "Resource Sizing")}{dockerResourceSizing}
diff --git a/packages/dashboard/app/components/RoutingTab.tsx b/packages/dashboard/app/components/RoutingTab.tsx index b37b238362..22657ac61b 100644 --- a/packages/dashboard/app/components/RoutingTab.tsx +++ b/packages/dashboard/app/components/RoutingTab.tsx @@ -142,7 +142,8 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
- {t("routing.source", "Routing source")} + {/* 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). */} + {t("routing.sourceLabel", "Routing source")} {routingSource}
diff --git a/packages/dashboard/app/components/TaskTokenStatsPanel.tsx b/packages/dashboard/app/components/TaskTokenStatsPanel.tsx index b654941c11..88d0a1aa9c 100644 --- a/packages/dashboard/app/components/TaskTokenStatsPanel.tsx +++ b/packages/dashboard/app/components/TaskTokenStatsPanel.tsx @@ -214,7 +214,11 @@ export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStat
{t("taskDetail.executionDetails", "Execution Details")}
-
{t("taskDetail.executionMode", "Execution mode")}
+ {/* + 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). + */} +
{t("taskDetail.executionModeLabel", "Execution mode")}
{task?.executionMode === "fast" ? t("taskDetail.executionModeFast", "Fast") : t("taskDetail.executionModeStandard", "Standard")}
diff --git a/packages/dashboard/app/components/__tests__/TaskTokenStatsPanel.test.tsx b/packages/dashboard/app/components/__tests__/TaskTokenStatsPanel.test.tsx index 25e2d131a9..a01af6665f 100644 --- a/packages/dashboard/app/components/__tests__/TaskTokenStatsPanel.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskTokenStatsPanel.test.tsx @@ -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 { 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( + + + , + ); + + 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"); + }); }); diff --git a/packages/engine/src/__tests__/workflow-graph-completion-summary-nonfatal.test.ts b/packages/engine/src/__tests__/workflow-graph-completion-summary-nonfatal.test.ts new file mode 100644 index 0000000000..a1ea4fb03d --- /dev/null +++ b/packages/engine/src/__tests__/workflow-graph-completion-summary-nonfatal.test.ts @@ -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); + }); +}); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index c85547ebaf..a70d70334e 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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"; @@ -8463,6 +8463,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; diff --git a/packages/engine/src/workflow-graph-executor.ts b/packages/engine/src/workflow-graph-executor.ts index 46e3a91e90..fb7483113f 100644 --- a/packages/engine/src/workflow-graph-executor.ts +++ b/packages/engine/src/workflow-graph-executor.ts @@ -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", diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index d25dd8a47c..c5c830a3a1 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 1218372ff1..fafcf53720 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -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", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 8dfd800698..bc43713c1f 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -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", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 6461bb0805..8227c6dfe7 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -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": "작업 실행 통계", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index c07bb41114..ff5cab35b3 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -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": "任务执行统计", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 9c9f4d29cb..53f4f6dbe8 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -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": "任務執行統計", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index d098eedc85..c2124e9085 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -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", From 98db2874ccbff2597717c5d420a7ab7e1c375142 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 17:05:23 -0700 Subject: [PATCH 2/2] fix(pi-claude-cli): pin pi-ai/pi-coding-agent to ^0.80.3 and migrate to getBuiltinModels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Typecheck CI gate was failing because pi-claude-cli declared pi-ai and pi-coding-agent as unpinned "*" peers. pi-ai 0.80 was published and, via hoisting/non-frozen resolution, the bare `@earendil-works/pi-ai` import floated to 0.80.3 — which moved the top-level `getModels` export to the deprecated `/compat` shim ("has no exported member named 'getModels'"). Migrate forward to the latest, consistent with cli/engine which already pin ^0.80.3: - Pin pi-ai and pi-coding-agent to ^0.80.3 (peer + dev) so the whole extension resolves one pi-ai version; pinning pi-coding-agent too avoids the AssistantMessageEventStream type skew that a pi-ai-only bump reintroduced. - Import the canonical `getBuiltinModels` from `@earendil-works/pi-ai/providers/all` (identical signature to the old `getModels`; the top-level export is now the deprecated compat alias). - Update the provider test mock to the new subpath. Behavior-preserving: getBuiltinModels === getModels. Typecheck, the full recursive typecheck, and all 347 pi-claude-cli tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/pi-claude-cli/index.ts | 13 +++++++++++-- packages/pi-claude-cli/package.json | 6 ++++-- .../pi-claude-cli/src/__tests__/provider.test.ts | 11 ++++++++--- pnpm-lock.yaml | 10 +++++----- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/pi-claude-cli/index.ts b/packages/pi-claude-cli/index.ts index 886bd06293..c77939012c 100644 --- a/packages/pi-claude-cli/index.ts +++ b/packages/pi-claude-cli/index.ts @@ -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, diff --git a/packages/pi-claude-cli/package.json b/packages/pi-claude-cli/package.json index cc09247ffa..593fa3cc75 100644 --- a/packages/pi-claude-cli/package.json +++ b/packages/pi-claude-cli/package.json @@ -23,10 +23,12 @@ "@agentclientprotocol/sdk": "0.24.0" }, "peerDependencies": { - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*" + "@earendil-works/pi-ai": "^0.80.3", + "@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" diff --git a/packages/pi-claude-cli/src/__tests__/provider.test.ts b/packages/pi-claude-cli/src/__tests__/provider.test.ts index 42d314c9d8..757d2d4c77 100644 --- a/packages/pi-claude-cli/src/__tests__/provider.test.ts +++ b/packages/pi-claude-cli/src/__tests__/provider.test.ts @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95c8033f6f..4dddf21bad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + 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: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - devDependencies: + 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) '@types/node': specifier: ^25.5.2 version: 25.5.2