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 000000000..fd11799cd --- /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 d01106ad8..47a76c76b 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 a882d2eb8..eb02e4f73 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 a51af64ea..86de8132c 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 000000000..f05a45f9c --- /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 b37b23836..22657ac61 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 b654941c1..88d0a1aa9 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 25e2d131a..a01af6665 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 000000000..a1ea4fb03 --- /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 817cfed62..7827d9b4a 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"; @@ -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; diff --git a/packages/engine/src/workflow-graph-executor.ts b/packages/engine/src/workflow-graph-executor.ts index 46e3a91e9..fb7483113 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 d25dd8a47..c5c830a3a 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 1218372ff..fafcf5372 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 8dfd80069..bc43713c1 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 6461bb080..8227c6dfe 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 c07bb4111..ff5cab35b 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 9c9f4d29c..53f4f6dbe 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 d098eedc8..c2124e908 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", diff --git a/packages/pi-claude-cli/index.ts b/packages/pi-claude-cli/index.ts index 886bd0629..c77939012 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 a312f972d..593fa3cc7 100644 --- a/packages/pi-claude-cli/package.json +++ b/packages/pi-claude-cli/package.json @@ -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" diff --git a/packages/pi-claude-cli/src/__tests__/provider.test.ts b/packages/pi-claude-cli/src/__tests__/provider.test.ts index 42d314c9d..757d2d4c7 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 ed7d89a23..4dddf21ba 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: ^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