fix(FN-7228): record workflow completion summaries

This commit is contained in:
gsxdsm
2026-06-29 10:32:51 -07:00
parent 2b73a0a238
commit 2135bd653d
5 changed files with 230 additions and 1 deletions

View File

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

View File

@@ -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<string, unknown> }> = [];
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({

View File

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

View File

@@ -0,0 +1,73 @@
import type { TaskDetail } from "@fusion/core";
export interface WorkflowCompletionSummaryStore {
updateTask?: (taskId: string, updates: { summary: string }) => Promise<unknown> | unknown;
logEntry?: (taskId: string, action: string, detail?: string) => Promise<unknown> | 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<TaskDetail, "id" | "title" | "steps" | "modifiedFiles" | "workflowStepResults">,
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<void> {
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,
}),
);
}

View File

@@ -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<WorkflowGraphExecutorDeps,
store: WorkflowIrResolverStore & {
getTask?: (taskId: string) => Promise<TaskDetail>;
getTaskDocument?: (taskId: string, key: string) => Promise<unknown | null>;
updateTask?: (taskId: string, updates: { summary: string }) => Promise<unknown> | unknown;
logEntry?: (taskId: string, action: string, detail?: string) => Promise<unknown> | 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") {