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 type { Settings, TaskDetail, WorkflowIr, WorkflowWorkItem, WorkflowWorkItemState } from "@fusion/core";
|
||||||
|
|
||||||
import { WorkflowTaskRuntime, type WorkflowTaskRuntimeDeps } from "../workflow-task-runtime.js";
|
import { WorkflowTaskRuntime, type WorkflowTaskRuntimeDeps } from "../workflow-task-runtime.js";
|
||||||
@@ -180,6 +180,72 @@ describe("WorkflowTaskRuntime", () => {
|
|||||||
expect(workflowSelectionReads).toBe(1);
|
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 () => {
|
it("preserves attachments through selected workflow execution", async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const attachments = [
|
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 () => {
|
it("uses the built-in workflow id in the default run id for unselected tasks", async () => {
|
||||||
const observedRunIds: string[] = [];
|
const observedRunIds: string[] = [];
|
||||||
const runtime = new WorkflowTaskRuntime({
|
const runtime = new WorkflowTaskRuntime({
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
type WorkflowRunObservation,
|
type WorkflowRunObservation,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult } from "./workflow-graph-task-runner.js";
|
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 { createCodeNodeRunner } from "./code-node-runner.js";
|
||||||
import { getActiveNotificationService } from "./notifier.js";
|
import { getActiveNotificationService } from "./notifier.js";
|
||||||
import type { ParseStepsHandlerDeps, CodeNodeRunner } from "./workflow-node-handlers.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> {
|
private async handoffTaskToReview(task: Task, reason: string, runId = this.getRunContextFor(task.id)?.runId): Promise<Task> {
|
||||||
const agentId = this.getRunContextFor(task.id)?.agentId;
|
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, {
|
const handedOff = await this.store.handoffToReview(task.id, {
|
||||||
ownerAgentId: agentId ?? null,
|
ownerAgentId: agentId ?? null,
|
||||||
evidence: {
|
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,
|
type WorkflowCustomNodeRunner,
|
||||||
} from "./workflow-node-handlers.js";
|
} from "./workflow-node-handlers.js";
|
||||||
import type { WorkflowRuntimePrimitives } from "./runtime-primitives.js";
|
import type { WorkflowRuntimePrimitives } from "./runtime-primitives.js";
|
||||||
|
import { ensureWorkflowCompletionSummary } from "./workflow-completion-summary.js";
|
||||||
|
|
||||||
export type WorkflowTaskRuntimeDisposition = "completed" | "failed" | "manual-required";
|
export type WorkflowTaskRuntimeDisposition = "completed" | "failed" | "manual-required";
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ export interface WorkflowTaskRuntimeDeps extends Omit<WorkflowGraphExecutorDeps,
|
|||||||
store: WorkflowIrResolverStore & {
|
store: WorkflowIrResolverStore & {
|
||||||
getTask?: (taskId: string) => Promise<TaskDetail>;
|
getTask?: (taskId: string) => Promise<TaskDetail>;
|
||||||
getTaskDocument?: (taskId: string, key: string) => Promise<unknown | null>;
|
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?: (
|
transitionWorkflowWorkItem?: (
|
||||||
id: string,
|
id: string,
|
||||||
state: WorkflowWorkItemState,
|
state: WorkflowWorkItemState,
|
||||||
@@ -130,6 +133,12 @@ export class WorkflowTaskRuntime {
|
|||||||
reason,
|
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";
|
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}`);
|
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 invoked: string[] = [];
|
||||||
const handler = this.recordingHandlers(invoked)[node.kind];
|
const handler = this.recordingHandlers(invoked)[node.kind];
|
||||||
if (!handler && node.kind !== "start" && node.kind !== "end") {
|
if (!handler && node.kind !== "start" && node.kind !== "end") {
|
||||||
|
|||||||
Reference in New Issue
Block a user