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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user