docs: pin the hybrid step-storage contract (the "steps: [] no-op" is not a bug)

An investigation reported that under PostgreSQL `updateTask(id, { steps: [] })`
silently no-ops while a non-empty array writes fine, and worked around it in
fixtures. Reproduced and traced: the write is literal and correct — the row and
task.json both hold `[]`. What actually happens is that an empty array means
"plan not parsed yet", not "this task has no steps", so all four read paths
re-derive steps from PROMPT.md when the stored array is empty: getTaskImpl, the
two list hydrations (reads.ts), and updateStep's auto-init, whose range error
already says outright that "its steps are defined in PROMPT.md".

No product change: removing the re-derivation would strand every task whose plan
lives only in PROMPT.md. Instead both halves of the contract are now pinned by a
PG test and documented at the write site, so the next reader sees the mechanism
instead of re-diagnosing it as a lost write. To make a task genuinely stepless,
remove the step headings from PROMPT.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-23 14:58:49 -07:00
parent e37ebd5af9
commit 3533fc8a47
2 changed files with 63 additions and 0 deletions

View File

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

View File

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