fix(FN-8064): narrate all task step transitions
Fusion-Task-Id: FN-8064
This commit is contained in:
7
.changeset/fix-fn-8064-proactive-step-chat.md
Normal file
7
.changeset/fix-fn-8064-proactive-step-chat.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep Task chat updated for every completed, resumed, or reset task step.
|
||||
category: fix
|
||||
dev: Centralizes lifecycle narration in TaskStore.updateStep while retaining detailed executor and reviewer failures.
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createTaskStoreForTest,
|
||||
pgDescribe,
|
||||
type PgTestHarness,
|
||||
} from "../../__test-utils__/pg-test-harness.js";
|
||||
|
||||
const pgTest = pgDescribe;
|
||||
|
||||
pgTest("proactive step-status chat entries (PostgreSQL)", () => {
|
||||
let harness: PgTestHarness | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.teardown();
|
||||
harness = undefined;
|
||||
});
|
||||
|
||||
it("narrates every accepted lifecycle transition once, including recovery to pending", async () => {
|
||||
harness = await createTaskStoreForTest({ prefix: "fusion_proactive_step_status" });
|
||||
const task = await harness.store.createTask({
|
||||
title: "Narrate step lifecycle",
|
||||
description: "A task with a persisted step.",
|
||||
});
|
||||
await harness.store.updateTask(task.id, {
|
||||
steps: [{ name: "Implement the change", status: "pending" }],
|
||||
});
|
||||
|
||||
await harness.store.updateStep(task.id, 0, "in-progress");
|
||||
await harness.store.updateStep(task.id, 0, "done");
|
||||
await harness.store.updateStep(task.id, 0, "pending");
|
||||
// An identical write is not a new lifecycle event and must not duplicate chat rows.
|
||||
await harness.store.updateStep(task.id, 0, "pending");
|
||||
|
||||
const statuses = (await harness.store.getAgentLogs(task.id, { type: "status" })).map((entry) => entry.text);
|
||||
expect(statuses).toEqual([
|
||||
"Starting Step 0: Implement the change",
|
||||
"Step 0 finished — Implement the change.",
|
||||
"Step 0 was returned to pending — Implement the change.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Keep `describe` referenced if the PostgreSQL reachability guard skips this suite.
|
||||
void describe;
|
||||
@@ -18,6 +18,40 @@ import {assertSafeGitBranchName, assertSafeAbsolutePath} from "../task-store/she
|
||||
import {acquireMergeQueueLease as acquireMergeQueueLeaseAsync} from "../task-store/async-merge-coordination.js";
|
||||
import type {MergeQueueRow} from "../task-store/row-types.js";
|
||||
|
||||
/**
|
||||
* Step state is written from more places than an agent's explicit
|
||||
* `fn_task_update` call: workflow projection, review auto-approval, restart
|
||||
* reconciliation, and self-healing all use the same store mutation. Keep the
|
||||
* baseline task-chat narration here so those legitimate transitions cannot be
|
||||
* invisible just because they took a different execution path.
|
||||
*/
|
||||
function proactiveStepStatusMessage(
|
||||
stepIndex: number,
|
||||
stepName: string,
|
||||
previousStatus: import("../types.js").StepStatus,
|
||||
status: import("../types.js").StepStatus,
|
||||
): string | null {
|
||||
if (previousStatus === status) return null;
|
||||
const label = stepName.trim() || `Step ${stepIndex}`;
|
||||
switch (status) {
|
||||
case "in-progress":
|
||||
return `Starting Step ${stepIndex}: ${label}`;
|
||||
case "done":
|
||||
return `Step ${stepIndex} finished — ${label}.`;
|
||||
case "skipped":
|
||||
return `Step ${stepIndex} was skipped — ${label}.`;
|
||||
case "pending":
|
||||
return `Step ${stepIndex} was returned to pending — ${label}.`;
|
||||
}
|
||||
}
|
||||
|
||||
function appendProactiveStepStatus(store: TaskStore, taskId: string, message: string | null): void {
|
||||
if (!message) return;
|
||||
// The task mutation is authoritative. Chat narration is an observational,
|
||||
// best-effort companion and must never make a lifecycle transition fail.
|
||||
void store.appendAgentLog(taskId, message, "status", undefined, "executor").catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function updateStepImpl(store: TaskStore, id: string, stepIndex: number, status: import("../types.js").StepStatus, options?: { source?: "graph" },): Promise<Task> {
|
||||
// Step-inversion projection discipline (U6/KTD-7). A `source: "graph"` write
|
||||
// is the workflow-graph executor projecting a foreach instance's lifecycle
|
||||
@@ -200,6 +234,11 @@ export async function updateStepImpl(store: TaskStore, id: string, stepIndex: nu
|
||||
if (store.isWatching) store.taskCache.set(id, { ...task });
|
||||
|
||||
store.emit("task:updated", task);
|
||||
appendProactiveStepStatus(
|
||||
store,
|
||||
id,
|
||||
proactiveStepStatusMessage(stepIndex, task.steps[stepIndex].name, currentStatus, status),
|
||||
);
|
||||
return task;
|
||||
});
|
||||
}
|
||||
@@ -482,4 +521,3 @@ export async function mergeTaskImpl(store: TaskStore, id: string): Promise<Merge
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -69,9 +69,6 @@ import {
|
||||
buildReviewRollbackFailureMessage,
|
||||
buildReviewVerdictMessage,
|
||||
buildStepFailureMessage,
|
||||
buildStepSkippedMessage,
|
||||
buildStepStartMessage,
|
||||
buildStepSuccessMessage,
|
||||
emitProactiveStatus,
|
||||
sanitizeFailureReason,
|
||||
} from "./proactive-status.js";
|
||||
@@ -11057,7 +11054,6 @@ export class TaskExecutor {
|
||||
this.store.updateStep(task.id, stepIndex, "in-progress", stepProjectionOptions).catch((err) => {
|
||||
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status to in-progress: ${err}`);
|
||||
});
|
||||
void emitProactiveStatus(this.store, task.id, buildStepStartMessage(stepIndex, detail.steps[stepIndex]?.name), "executor");
|
||||
} catch (err) {
|
||||
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status to in-progress: ${err}`);
|
||||
}
|
||||
@@ -11068,12 +11064,16 @@ export class TaskExecutor {
|
||||
this.store.updateStep(task.id, stepIndex, result.success ? "done" : "skipped", stepProjectionOptions).catch((err) => {
|
||||
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status: ${err}`);
|
||||
});
|
||||
const stepName = detail.steps[stepIndex]?.name;
|
||||
const safeReason = result.success ? undefined : sanitizeFailureReason(result.error);
|
||||
const message = result.success
|
||||
? buildStepSuccessMessage(stepIndex, stepName)
|
||||
: buildStepFailureMessage(stepIndex, stepName, safeReason!);
|
||||
void emitProactiveStatus(this.store, task.id, message, "executor", safeReason);
|
||||
if (!result.success) {
|
||||
void emitProactiveStatus(
|
||||
this.store,
|
||||
task.id,
|
||||
buildStepFailureMessage(stepIndex, detail.steps[stepIndex]?.name, safeReason!),
|
||||
"executor",
|
||||
safeReason,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status: ${err}`);
|
||||
}
|
||||
@@ -13819,18 +13819,6 @@ export class TaskExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
// FNXC:ProactiveChatStatus 2026-07-16-12:45:
|
||||
// Only store-accepted transitions narrate progress. A skipped step is terminal work too,
|
||||
// so it needs its own status row rather than silently looking like an ignored no-op.
|
||||
const narration = status === "in-progress"
|
||||
? buildStepStartMessage(stepIndex, stepInfo.name)
|
||||
: status === "done"
|
||||
? buildStepSuccessMessage(stepIndex, stepInfo.name)
|
||||
: status === "skipped"
|
||||
? buildStepSkippedMessage(stepIndex, stepInfo.name)
|
||||
: null;
|
||||
void emitProactiveStatus(this.store, taskId, narration, "executor");
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
|
||||
Reference in New Issue
Block a user