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 {acquireMergeQueueLease as acquireMergeQueueLeaseAsync} from "../task-store/async-merge-coordination.js";
|
||||||
import type {MergeQueueRow} from "../task-store/row-types.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> {
|
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
|
// Step-inversion projection discipline (U6/KTD-7). A `source: "graph"` write
|
||||||
// is the workflow-graph executor projecting a foreach instance's lifecycle
|
// 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 });
|
if (store.isWatching) store.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
store.emit("task:updated", task);
|
store.emit("task:updated", task);
|
||||||
|
appendProactiveStepStatus(
|
||||||
|
store,
|
||||||
|
id,
|
||||||
|
proactiveStepStatusMessage(stepIndex, task.steps[stepIndex].name, currentStatus, status),
|
||||||
|
);
|
||||||
return task;
|
return task;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -482,4 +521,3 @@ export async function mergeTaskImpl(store: TaskStore, id: string): Promise<Merge
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,6 @@ import {
|
|||||||
buildReviewRollbackFailureMessage,
|
buildReviewRollbackFailureMessage,
|
||||||
buildReviewVerdictMessage,
|
buildReviewVerdictMessage,
|
||||||
buildStepFailureMessage,
|
buildStepFailureMessage,
|
||||||
buildStepSkippedMessage,
|
|
||||||
buildStepStartMessage,
|
|
||||||
buildStepSuccessMessage,
|
|
||||||
emitProactiveStatus,
|
emitProactiveStatus,
|
||||||
sanitizeFailureReason,
|
sanitizeFailureReason,
|
||||||
} from "./proactive-status.js";
|
} from "./proactive-status.js";
|
||||||
@@ -11057,7 +11054,6 @@ export class TaskExecutor {
|
|||||||
this.store.updateStep(task.id, stepIndex, "in-progress", stepProjectionOptions).catch((err) => {
|
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}`);
|
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) {
|
} catch (err) {
|
||||||
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status to in-progress: ${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) => {
|
this.store.updateStep(task.id, stepIndex, result.success ? "done" : "skipped", stepProjectionOptions).catch((err) => {
|
||||||
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status: ${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 safeReason = result.success ? undefined : sanitizeFailureReason(result.error);
|
||||||
const message = result.success
|
if (!result.success) {
|
||||||
? buildStepSuccessMessage(stepIndex, stepName)
|
void emitProactiveStatus(
|
||||||
: buildStepFailureMessage(stepIndex, stepName, safeReason!);
|
this.store,
|
||||||
void emitProactiveStatus(this.store, task.id, message, "executor", safeReason);
|
task.id,
|
||||||
|
buildStepFailureMessage(stepIndex, detail.steps[stepIndex]?.name, safeReason!),
|
||||||
|
"executor",
|
||||||
|
safeReason,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status: ${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 {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
|
|||||||
Reference in New Issue
Block a user