fix(FN-8064): narrate all task step transitions

Fusion-Task-Id: FN-8064
This commit is contained in:
gsxdsm
2026-07-18 11:10:23 -07:00
parent 9debeaa951
commit b533b918fe
4 changed files with 99 additions and 22 deletions

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

View File

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

View File

@@ -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;
});
}

View File

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