diff --git a/.changeset/fix-fn-8064-proactive-step-chat.md b/.changeset/fix-fn-8064-proactive-step-chat.md new file mode 100644 index 0000000000..e8dcbd787a --- /dev/null +++ b/.changeset/fix-fn-8064-proactive-step-chat.md @@ -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. diff --git a/packages/core/src/__tests__/postgres/proactive-step-status.pg.test.ts b/packages/core/src/__tests__/postgres/proactive-step-status.pg.test.ts new file mode 100644 index 0000000000..9b4a821144 --- /dev/null +++ b/packages/core/src/__tests__/postgres/proactive-step-status.pg.test.ts @@ -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; diff --git a/packages/core/src/task-store/merge-queue-ops.ts b/packages/core/src/task-store/merge-queue-ops.ts index 516126b384..adacfc7c2c 100644 --- a/packages/core/src/task-store/merge-queue-ops.ts +++ b/packages/core/src/task-store/merge-queue-ops.ts @@ -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 { // 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 { 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,