fix(FN-7228): record workflow completion summaries
This commit is contained in:
7
.changeset/fn-workflow-completion-summary.md
Normal file
7
.changeset/fn-workflow-completion-summary.md
Normal 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.
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
73
packages/engine/src/workflow-completion-summary.ts
Normal file
73
packages/engine/src/workflow-completion-summary.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user