diff --git a/.changeset/fn-workflow-completion-summary.md b/.changeset/fn-workflow-completion-summary.md new file mode 100644 index 0000000000..6b0ae46ee6 --- /dev/null +++ b/.changeset/fn-workflow-completion-summary.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Record completion summaries for workflow-driven tasks. +category: fix +dev: Workflow graph completions and resumed workflow merge work items now backfill task.summary when no agent summary exists. diff --git a/packages/engine/src/__tests__/workflow-task-runtime.test.ts b/packages/engine/src/__tests__/workflow-task-runtime.test.ts index 03dfa53175..00caaf7dd3 100644 --- a/packages/engine/src/__tests__/workflow-task-runtime.test.ts +++ b/packages/engine/src/__tests__/workflow-task-runtime.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { Settings, TaskDetail, WorkflowIr, WorkflowWorkItem, WorkflowWorkItemState } from "@fusion/core"; import { WorkflowTaskRuntime, type WorkflowTaskRuntimeDeps } from "../workflow-task-runtime.js"; @@ -180,6 +180,72 @@ describe("WorkflowTaskRuntime", () => { expect(workflowSelectionReads).toBe(1); }); + it("records a completion summary when a workflow run completes without fn_task_done", async () => { + const updates: Array<{ taskId: string; summary: string }> = []; + const logs: Array<{ taskId: string; action: string; detail?: string }> = []; + const completedTask = { + ...task, + title: "Ship workflow summaries", + steps: [ + { title: "Implement summary persistence", status: "done" }, + { title: "Verify workflow completion", status: "done" }, + ], + modifiedFiles: ["packages/engine/src/workflow-task-runtime.ts"], + } as TaskDetail; + const runtime = new WorkflowTaskRuntime({ + store: { + getTask: async () => completedTask, + getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }), + getWorkflowDefinition: async () => ({ ir: selectedIr() }), + updateTask: async (taskId, update) => { + updates.push({ taskId, summary: update.summary }); + }, + logEntry: async (taskId, action, detail) => { + logs.push({ taskId, action, detail }); + }, + }, + primitives: recordingPrimitives([]), + runCustomNode: async () => ({ outcome: "success" }), + parseStepsDeps, + }); + + const result = await runtime.run(completedTask, flagOff); + + expect(result.disposition).toBe("completed"); + expect(updates).toEqual([ + { + taskId: task.id, + summary: expect.stringContaining("Workflow completed: Ship workflow summaries."), + }, + ]); + expect(updates[0]?.summary).toContain("Completed 2/2 task steps."); + expect(updates[0]?.summary).toContain("Changed files: packages/engine/src/workflow-task-runtime.ts."); + expect(logs).toEqual([ + expect.objectContaining({ taskId: task.id, action: "Workflow completion summary recorded" }), + ]); + }); + + it("preserves an existing workflow completion summary", async () => { + const updateTask = vi.fn(); + const summarizedTask = { ...task, summary: "Agent-authored completion summary." } as TaskDetail; + const runtime = new WorkflowTaskRuntime({ + store: { + getTask: async () => summarizedTask, + getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }), + getWorkflowDefinition: async () => ({ ir: selectedIr() }), + updateTask, + }, + primitives: recordingPrimitives([]), + runCustomNode: async () => ({ outcome: "success" }), + parseStepsDeps, + }); + + const result = await runtime.run(summarizedTask, flagOff); + + expect(result.disposition).toBe("completed"); + expect(updateTask).not.toHaveBeenCalled(); + }); + it("preserves attachments through selected workflow execution", async () => { const calls: string[] = []; const attachments = [ @@ -721,6 +787,63 @@ describe("WorkflowTaskRuntime", () => { ]); }); + it("backfills a workflow completion summary before resumed merge work items run", async () => { + const observed: { mergeAttempt?: number; mergeRunId?: string; mergeWorkflowId?: string } = {}; + const updates: Array<{ taskId: string; summary: string }> = []; + const transitions: Array<{ id: string; state: WorkflowWorkItemState; patch?: Record }> = []; + const workItem = { + id: "work-merge-summary", + runId: "run-merge-summary", + taskId: task.id, + nodeId: "merge-attempt", + kind: "merge", + state: "running", + attempt: 0, + retryAfter: null, + leaseOwner: "scheduler-a", + leaseExpiresAt: "2026-06-09T00:01:00.000Z", + lastError: null, + blockedReason: null, + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + } satisfies WorkflowWorkItem; + const runtime = new WorkflowTaskRuntime({ + store: { + getTask: async () => ({ + ...task, + title: "Resume merge with summary", + steps: [{ title: "Finish work", status: "done" }], + } as TaskDetail), + getTaskWorkflowSelection: () => undefined, + getWorkflowDefinition: async () => undefined, + updateTask: async (taskId, update) => { + updates.push({ taskId, summary: update.summary }); + }, + transitionWorkflowWorkItem: (id, state, patch) => { + transitions.push({ id, state, patch }); + return { ...workItem, state }; + }, + }, + primitives: recordingPrimitives([], {}, observed), + runCustomNode: async () => ({ outcome: "success" }), + }); + + const result = await runtime.runWorkItem(workItem, flagOff); + + expect(result.disposition).toBe("completed"); + expect(updates).toEqual([ + { + taskId: task.id, + summary: expect.stringContaining("Workflow completed: Resume merge with summary."), + }, + ]); + expect(updates[0]?.summary).toContain("Completion source: workflow-work-item:merge (builtin:coding)."); + expect(observed.mergeRunId).toBe("run-merge-summary"); + expect(transitions).toEqual([ + expect.objectContaining({ id: "work-merge-summary", state: "succeeded" }), + ]); + }); + it("uses the built-in workflow id in the default run id for unselected tasks", async () => { const observedRunIds: string[] = []; const runtime = new WorkflowTaskRuntime({ diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 4406aeaaa8..3d5ca90487 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -22,6 +22,7 @@ import { type WorkflowRunObservation, } from "@fusion/core"; import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult } from "./workflow-graph-task-runner.js"; +import { ensureWorkflowCompletionSummary } from "./workflow-completion-summary.js"; import { createCodeNodeRunner } from "./code-node-runner.js"; import { getActiveNotificationService } from "./notifier.js"; import type { ParseStepsHandlerDeps, CodeNodeRunner } from "./workflow-node-handlers.js"; @@ -2018,6 +2019,14 @@ export class TaskExecutor { */ private async handoffTaskToReview(task: Task, reason: string, runId = this.getRunContextFor(task.id)?.runId): Promise { const agentId = this.getRunContextFor(task.id)?.agentId; + if (reason.startsWith("workflow-")) { + await ensureWorkflowCompletionSummary(this.store, task as TaskDetail, { + reason, + runId, + }).catch((error: unknown) => { + executorLog.warn(`${task.id}: failed to record workflow completion summary: ${error instanceof Error ? error.message : String(error)}`); + }); + } const handedOff = await this.store.handoffToReview(task.id, { ownerAgentId: agentId ?? null, evidence: { diff --git a/packages/engine/src/workflow-completion-summary.ts b/packages/engine/src/workflow-completion-summary.ts new file mode 100644 index 0000000000..021d6b06ab --- /dev/null +++ b/packages/engine/src/workflow-completion-summary.ts @@ -0,0 +1,73 @@ +import type { TaskDetail } from "@fusion/core"; + +export interface WorkflowCompletionSummaryStore { + updateTask?: (taskId: string, updates: { summary: string }) => Promise | unknown; + logEntry?: (taskId: string, action: string, detail?: string) => Promise | unknown; +} + +export interface WorkflowCompletionSummaryInput { + reason: string; + workflowId?: string; + runId?: string; +} + +function truncateList(values: string[], limit: number): string { + const head = values.slice(0, limit); + const remaining = values.length - head.length; + return remaining > 0 ? `${head.join(", ")} and ${remaining} more` : head.join(", "); +} + +export function buildWorkflowCompletionSummary( + task: Pick, + input: WorkflowCompletionSummaryInput, +): string { + const title = task.title?.trim() || task.id; + const steps = task.steps ?? []; + const doneSteps = steps.filter((step) => step.status === "done" || step.status === "skipped").length; + const workflowResults = task.workflowStepResults ?? []; + const passedWorkflowSteps = workflowResults.filter((step) => step.status === "passed" || step.status === "skipped").length; + const files = (task.modifiedFiles ?? []).filter((file) => file.trim().length > 0); + + const parts = [`Workflow completed: ${title}.`]; + if (steps.length > 0) { + parts.push(`Completed ${doneSteps}/${steps.length} task step${steps.length === 1 ? "" : "s"}.`); + } + if (workflowResults.length > 0) { + parts.push(`Recorded ${passedWorkflowSteps}/${workflowResults.length} workflow check${workflowResults.length === 1 ? "" : "s"} as passed or skipped.`); + } + if (files.length > 0) { + parts.push(`Changed files: ${truncateList(files, 6)}.`); + } + parts.push(`Completion source: ${input.reason}${input.workflowId ? ` (${input.workflowId})` : ""}.`); + return parts.join(" "); +} + +export async function ensureWorkflowCompletionSummary( + store: WorkflowCompletionSummaryStore, + task: TaskDetail, + input: WorkflowCompletionSummaryInput, +): Promise { + if (task.summary?.trim()) return; + if (!store.updateTask) return; + + /* + * FNXC:WorkflowCompletion 2026-06-29-10:58: + * Workflow-owned tasks can finish through graph nodes and resumable merge work + * items without an agent calling `fn_task_done`. Persist a deterministic + * completion summary at the workflow lifecycle boundary so Done/Review cards, + * GitHub tracking, evals, and archival views see the same `task.summary` + * contract as legacy executor completions. Existing agent-authored summaries + * remain authoritative. + */ + const summary = buildWorkflowCompletionSummary(task, input); + await store.updateTask(task.id, { summary }); + await store.logEntry?.( + task.id, + "Workflow completion summary recorded", + JSON.stringify({ + reason: input.reason, + workflowId: input.workflowId, + runId: input.runId, + }), + ); +} diff --git a/packages/engine/src/workflow-task-runtime.ts b/packages/engine/src/workflow-task-runtime.ts index 6f81994521..26cdb74925 100644 --- a/packages/engine/src/workflow-task-runtime.ts +++ b/packages/engine/src/workflow-task-runtime.ts @@ -20,6 +20,7 @@ import { type WorkflowCustomNodeRunner, } from "./workflow-node-handlers.js"; import type { WorkflowRuntimePrimitives } from "./runtime-primitives.js"; +import { ensureWorkflowCompletionSummary } from "./workflow-completion-summary.js"; export type WorkflowTaskRuntimeDisposition = "completed" | "failed" | "manual-required"; @@ -35,6 +36,8 @@ export interface WorkflowTaskRuntimeDeps extends Omit Promise; getTaskDocument?: (taskId: string, key: string) => Promise; + updateTask?: (taskId: string, updates: { summary: string }) => Promise | unknown; + logEntry?: (taskId: string, action: string, detail?: string) => Promise | unknown; transitionWorkflowWorkItem?: ( id: string, state: WorkflowWorkItemState, @@ -130,6 +133,12 @@ export class WorkflowTaskRuntime { reason, }; } + const latestTask = await this.deps.store.getTask?.(task.id).catch(() => undefined); + await ensureWorkflowCompletionSummary(this.deps.store, latestTask ?? task, { + reason: "workflow-runtime-completed", + workflowId: target.workflowId, + runId: this.deps.runId ?? `${task.id}:${target.workflowId}`, + }).catch(() => undefined); } const disposition: WorkflowTaskRuntimeDisposition = result.outcome === "success" ? "completed" : "failed"; @@ -180,6 +189,14 @@ export class WorkflowTaskRuntime { return this.failWorkItem(workItem, `workflow-work-item-node-missing:${workItem.nodeId}`); } + if (workItem.kind === "merge" || workItem.kind === "manual-hold") { + await ensureWorkflowCompletionSummary(this.deps.store, task, { + reason: `workflow-work-item:${workItem.kind}`, + workflowId: target.workflowId, + runId: workItem.runId, + }).catch(() => undefined); + } + const invoked: string[] = []; const handler = this.recordingHandlers(invoked)[node.kind]; if (!handler && node.kind !== "start" && node.kind !== "end") {