diff --git a/packages/core/src/__tests__/postgres/hybrid-step-storage-contract.pg.test.ts b/packages/core/src/__tests__/postgres/hybrid-step-storage-contract.pg.test.ts new file mode 100644 index 0000000000..c1aaf6f21a --- /dev/null +++ b/packages/core/src/__tests__/postgres/hybrid-step-storage-contract.pg.test.ts @@ -0,0 +1,48 @@ +/* +FNXC:HybridStepStorage 2026-08-23-20:10: +Pins the hybrid step-storage contract that an investigation mistook for a PostgreSQL persistence bug +("updateTask(id, {steps: []}) silently no-ops"). Both halves are asserted here so the next reader +sees the mechanism instead of re-diagnosing it: + 1. the WRITE is literal — an empty array really is persisted; + 2. the READ re-derives steps from PROMPT.md whenever the stored array is empty, because PROMPT.md + is the source of truth for a task's plan (`updateStep`'s range error says so outright). +Removing (2) would strand every task whose plan lives only in PROMPT.md; removing (1) would lose a +legitimate write. Change either half only with both of these updated deliberately. +*/ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; + +pgDescribe("hybrid step storage contract (PostgreSQL)", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_hybrid_steps" }); + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + it("persists an explicit empty steps array to the row and to task.json", async () => { + const task = await h.store().createTask({ description: "hybrid steps" }); + await h.store().updateTask(task.id, { steps: [{ name: "One", status: "pending" }] } as never); + await h.store().updateTask(task.id, { steps: [] } as never); + + // The write is literal: nothing swallows or reverts it at the persistence layer. + const onDisk = JSON.parse(await readFile(join(h.store().taskDir(task.id), "task.json"), "utf-8")); + expect(onDisk.steps).toEqual([]); + }); + + it("re-derives steps from PROMPT.md on read when the stored array is empty", async () => { + const task = await h.store().createTask({ description: "hybrid steps read" }); + const dir = h.store().taskDir(task.id); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "PROMPT.md"), "## Steps\n\n### Step 1: Alpha\n\n### Step 2: Beta\n", "utf-8"); + await h.store().updateTask(task.id, { steps: [] } as never); + + const read = await h.store().getTask(task.id); + expect(read!.steps.map((step) => step.name)).toEqual(["Alpha", "Beta"]); + }); +}); diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index 831aeb502e..5c565c13ab 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -371,6 +371,21 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat movedToTriage = true; } } + /* + FNXC:HybridStepStorage 2026-08-23-20:05: + AN EMPTY `steps` ARRAY MEANS "NOT PARSED YET", NOT "THIS TASK HAS NO STEPS". The write below is + literal and persists `[]` faithfully (task row and task.json both show it), but PROMPT.md is the + source of truth for a task's plan, so every read path re-derives steps from it whenever the + stored array is empty: `getTaskImpl` (reads.ts), the two list hydration paths (reads.ts), and + `updateStep`'s auto-init (merge-queue-ops.ts) — whose range error says outright that "its steps + are defined in PROMPT.md". + + Consequence for callers, measured 2026-08-23: `updateTask(id, { steps: [] })` looks like a + silent no-op through `getTask` while a PROMPT.md with step headings exists, because the read + re-populates it. That is the designed hybrid contract, NOT a lost write — an investigation + mistook it for a PostgreSQL persistence bug. To make a task genuinely stepless, remove the step + headings from PROMPT.md; clearing this array only marks the plan unparsed. + */ if (updates.steps !== undefined) task.steps = updates.steps; // U11/KTD-13: customFields writes are validated against the task's workflow // field schema through the single authority (task-fields.ts). The patch is