U11 PR2: entry contract under the merged column + a real intake-column bug the audit surfaced (#2503)

Second small PR for **U11**. Two commits: a tests-only entry-contract
pin, then a **real present-day bug fix** the audit surfaced.

## The audit you asked for, finished — no design fork

You named four surfaces as the remaining risk. All four can take a
combined `intake` + `hold` column. One needed a code change; here it is.

| Surface | Verdict | Evidence |
|---|---|---|
| `isUnplannedForExecution` | Safe | PR1 (#2495) — passed unmodified; a
mutation now fails exactly the merged-column test |
| Capacity hold / release | Safe | PR1 — `hold-release.ts:260` already
accepts intake **or** hold |
| `start`'s column / entry contract | Safe | commit 1 — all 6 assertions
passed unmodified |
| `createTask` intake wiring | **Broken today** | commit 2 — fixed,
revert-proven |
| *(also found)* triage auto-discovery | Needs conversion |
`triage.ts:1382` — deferred to PR3, see below |

## Commit 1 — entry contract under the merged column (tests only)

All 6 new assertions passed on the first run. **Regression floor, not
evidence of a fix** — I could not make them fail and am not claiming
otherwise.

They pin one real behavioral **difference** rather than asserting
sameness everywhere: the merged shape answers `start` where the split
shape answers `plan`, because `start` becomes the first node in that
column once the columns collapse. That is equivalent *only* because
`start` reaches the specification node by a single unconditional success
edge — asserted, so if a node is ever inserted between them this fails
instead of silently admitting an unspecified card into implementation.

Also pinned: past planning both shapes agree exactly; a card past the
merged column still never resumes at a planning node (the backward drag
that fires `abort-on-exit`); and a row persisted in the **deleted**
`triage` column resolves to `undefined`, safe only while the executor's
start-node fallback exists.

## Commit 2 — a real bug, found by the audit

The intake column was resolved **only** as a by-product of materializing
workflow steps. A create supplying `enabledWorkflowSteps` without an
explicit `workflowId` takes **neither** materialization branch, so
`resolvedEntryColumn` stays `undefined` and `column:` falls through to
the hard-coded `|| "triage"`.

Today, on Coding (Ideas), that lands the card in `triage` — **a column
that workflow does not declare.** Created straight into a phantom lane.
Measured: the new test fails `expected 'triage' to be 'ideas'` against
unmodified sources.

**Why it blocks U11.** Once `triage` leaves the coding IRs this stops
being an Ideas edge case and becomes the default workflow's behavior for
every create down this path: the card lands in an undeclared column
**and** — because `isIntakeColumn` keys on the same `"triage"` literal —
gets `generateSpecifiedPrompt` instead of the bootstrap seed. Triage
admits a card for planning only when its `PROMPT.md` reads as a seed, so
a placeholder spec is classified "already planned" and never planned.
The card sits in Planning forever with no log line in any lane —
**FN-8587's exact failure mode, promoted from one edge case to every new
card.**

The fix resolves the intake column **side-effect-free** (read the IR,
ask which column carries `intake`). It deliberately does *not* call
`materializeDefaultWorkflowSteps`, which would persist step rows the
caller explicitly opted out of by supplying its own toggles.
Unresolvable workflow returns `undefined` and each call site keeps its
legacy fallback, so no path loses behavior when the IR cannot be read.

Applied to both create paths. Branch ordering preserved in both — the
explicit empty-toggle case (`length === 0` hydrating back as `[]`) still
runs, now nested rather than sequential.

**Revert check:** with `task-creation.ts` reverted, *"lands a Coding
(Ideas) task in ideas even when enabledWorkflowSteps is supplied"* fails
`expected 'triage' to be 'ideas'`. The companion bootstrap-`PROMPT.md`
assertion passes either way today — it is correct **by accident of the
`"triage"` literal** — and is kept precisely because that accident
disappears with U11.

## Verification

37 tests green across the three intake/create suites; 119 across the
entry-contract, merged-column and lifecycle suites; `pnpm test:gate`
green (307 + 10 + 71); lint and core typecheck clean. Changeset added.

## Deferred to PR3, with the line numbers

`discoverReadyPlanningTasks` has two hardcoded branches:

```ts
(t) => t.column === "triage" && isTaskStillInPlanningStage(t)   // triage.ts:1382
(t) => t.column === "todo"   && !this.processing.has(t.id) …    // triage.ts:1389
```

Delete `triage` and branch 1 matches nothing for coding cards; branch 2
then does all the work and is **narrower** (it admits only
`needs-replan` or bootstrap-stub cards). Commit 2 is what makes branch 2
sufficient — every new card now gets a real bootstrap seed. They cannot
double-fire: a card is in `todo` xor `triage`.

Two adjacent sites are already merged-shape-ready: `triage.ts:3899`
skips the redundant same-column move for a plan-in-place card, and
`triage.ts:753`'s stale-status sweep already scans both columns.

Then the ~10-line IR change, then the migration proof.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-28 17:35:14 -07:00
committed by GitHub
parent 9d3e53d0c5
commit 35b0df1838
4 changed files with 314 additions and 8 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Tasks created with custom workflow-step toggles now land in their workflow's own intake column.
category: fix
dev: `resolveDefaultWorkflowIntakeColumn` resolves the intake column side-effect-free (IR + `intake` trait) when a create supplies `enabledWorkflowSteps` without an explicit `workflowId`, so neither `materializeWorkflowSteps` branch runs. Previously `resolvedEntryColumn` stayed undefined and the card fell through to the hard-coded `|| "triage"`.

View File

@@ -44,6 +44,41 @@ pgTest("createTask intake-column wiring (Coding (Ideas))", () => {
expect(task.column).toBe("ideas");
});
/*
FNXC:MergedPlanningColumn 2026-07-28-12:40 (U11 precondition):
The intake column is resolved ONLY inside the two `materializeWorkflowSteps` branches. A create
that supplies `enabledWorkflowSteps` without an explicit `workflowId` takes NEITHER branch, so
`resolvedEntryColumn` stays undefined and `column:` falls through to the hard-coded `|| "triage"`.
Today that lands a Coding (Ideas) card in `triage` — a column that workflow does not declare —
so the card is created straight into a phantom lane. U11 makes this the DEFAULT workflow's
problem too: once `triage` is deleted, every create down this path lands in an undeclared column
and, because `isIntakeColumn` keys on the same literal, also gets `generateSpecifiedPrompt`
instead of the bootstrap seed. Triage's discovery admits a card only when its PROMPT.md reads as
a seed, so the card would sit in Planning forever with no log line in any lane — FN-8587's exact
failure mode, for every new card rather than one edge case.
*/
it("lands a Coding (Ideas) task in ideas even when enabledWorkflowSteps is supplied", async () => {
const store = h.store();
await store.setDefaultWorkflowId("builtin:coding-ideas");
const task = await store.createTask({
description: "ideas task created with explicit optional-group toggles",
enabledWorkflowSteps: [],
});
expect(task.column).toBe("ideas");
});
it("writes a bootstrap PROMPT.md for that same create (so triage can still discover it)", async () => {
const store = h.store();
await store.setDefaultWorkflowId("builtin:coding-ideas");
const task = await store.createTask({
description: "ideas task created with explicit optional-group toggles",
enabledWorkflowSteps: [],
});
const prompt = await readFile(join(store.getTasksDir(), task.id, "PROMPT.md"), "utf-8");
expect(prompt).toBe(buildBootstrapPrompt(task.id, task.title, task.description));
});
it("lands a Coding (Ideas) task in ideas when it is the project default workflow", async () => {
const store = h.store();
await store.setDefaultWorkflowId("builtin:coding-ideas");

View File

@@ -24,6 +24,8 @@ import {getErrorMessage} from "../error-message.js";
import {generateTaskLineageId} from "../task-lineage.js";
import {archiveAsSameAgentDuplicate, findSameAgentDuplicates, flagSameAgentDuplicate, type SameAgentDuplicateCandidate} from "../duplicate-intake.js";
import {buildBootstrapPrompt} from "../mesh-task-replication.js";
import {resolveWorkflowIrById} from "../workflow-ir-resolver.js";
import {columnsWithFlag} from "../workflow-lifecycle-traits.js";
import {validateFileScopeInPromptContent} from "../task-store/file-scope.js";
import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
import {withTaskBranchContextInSourceMetadata} from "../task-store/branch-context.js";
@@ -59,6 +61,36 @@ function ensureSqliteProposalClaimUniqueness(store: TaskStore): void {
);
}
/*
FNXC:MergedPlanningColumn 2026-07-28-12:55 (U11 precondition):
The intake column used to be resolved ONLY as a by-product of materializing workflow steps, so a
create that supplied `enabledWorkflowSteps` without an explicit `workflowId` took neither
materialization branch and fell through to the hard-coded `|| "triage"`. On Coding (Ideas) that
lands the card in a column the workflow does not declare — a phantom lane, today.
U11 makes it the DEFAULT workflow's problem: once `triage` is deleted, every create down this path
lands in an undeclared column AND (because `isIntakeColumn` keys on the same literal) gets
`generateSpecifiedPrompt` instead of the bootstrap seed. Triage admits a card for planning only
when its PROMPT.md reads as a seed, so the card would sit in Planning forever with no log line in
any lane — FN-8587's failure mode, for every new card.
Resolution is deliberately SIDE-EFFECT-FREE: it reads the default workflow's IR and asks which
column carries `intake`. It must not call `materializeDefaultWorkflowSteps`, which persists step
rows the caller explicitly opted out of by supplying its own `enabledWorkflowSteps`.
Unresolvable workflow returns undefined and the caller keeps its existing legacy fallback.
*/
async function resolveDefaultWorkflowIntakeColumn(store: TaskStore): Promise<string | undefined> {
try {
const workflowId = await store.getDefaultWorkflowId();
if (!workflowId) return undefined;
const ir = await resolveWorkflowIrById(store, workflowId);
return columnsWithFlag(ir, "intake")[0];
} catch {
return undefined;
}
}
export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise<string | null>; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; onProposalClaimConflict?: (task: Task) => void; },): Promise<Task> {
// U8/R6: apply the reviewLevel creation-time preset (maps level -> enabledWorkflowSteps; explicit wins).
input = applyReviewLevelPreset(input);
@@ -194,10 +226,17 @@ export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateI
});
}
}
} else if (input.enabledWorkflowSteps.length === 0) {
// FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty
// optional-step selection must hydrate back as [], not undefined.
resolvedWorkflowSteps = [];
} else {
// Caller supplied its own optional-step toggles, so no materialization branch ran and
// `resolvedEntryColumn` was left undefined — the gap that dropped the card into the
// hard-coded `|| "triage"`. The toggles are the caller's; the INTAKE COLUMN is still the
// project default workflow's, so resolve it without materializing steps.
resolvedEntryColumn = await resolveDefaultWorkflowIntakeColumn(store);
if (input.enabledWorkflowSteps.length === 0) {
// FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty
// optional-step selection must hydrate back as [], not undefined.
resolvedWorkflowSteps = [];
}
}
// FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-13:20:
@@ -671,10 +710,16 @@ export async function createTaskWithReservedIdImpl(store: TaskStore, input: Task
});
}
}
} else if (Array.isArray(input.enabledWorkflowSteps) && input.enabledWorkflowSteps.length === 0) {
// FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty
// optional-step selection must hydrate back as [], not undefined.
resolvedWorkflowSteps = [];
} else if (input.enabledWorkflowSteps !== undefined) {
// Mirror of the backend path: the caller owns the optional-step toggles, so no
// materialization branch ran and `resolvedEntryColumn` was left undefined. The INTAKE
// COLUMN is still the project default workflow's — resolve it without materializing steps.
resolvedEntryColumn = await resolveDefaultWorkflowIntakeColumn(store);
if (input.enabledWorkflowSteps.length === 0) {
// FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty
// optional-step selection must hydrate back as [], not undefined.
resolvedWorkflowSteps = [];
}
}
let createdTask: Task;

View File

@@ -170,3 +170,222 @@ describe("workflow graph entry contract — the executor honors it", () => {
expect(calls).not.toContain("planning-session");
});
});
/*
FNXC:MergedPlanningColumn 2026-07-28-14:05 (U11, PR #2503 review — greptile + coderabbit):
The entry contract under the MERGED planning column — one column carrying `intake` + `hold`,
which is what U11 leaves behind once `triage` is deleted and `todo` becomes "Planning".
REWRITTEN after review. The first cut of this block did two things wrong, and both are the
difference between proving something and appearing to:
1. It asserted against a hand-written synthetic graph. A toy graph proves the RESOLVER's
arithmetic, not that the operator's board survives — production planning/review topology could
drift away from the shape under test and nothing here would notice. `mergeTodoIntoPlanning`
below is instead the literal U11 edit expressed as a transformation, applied to the REAL
production IR, so these assertions track production topology by construction.
2. It asserted that `resolveColumnResumeNode` returns `undefined` for a card stranded in the
deleted `triage` column, and that a start node exists. Both can hold while cards strand: the
thing that actually rescues such a card is the `?? ir.nodes.find(kind === "start")` fallback in
`run()`, and neither assertion touches it. Deleting that fallback left the old test GREEN. The
stranded case now drives `executor.run()` and asserts the card really traverses — and going red
when the fallback is removed is verified, not assumed.
*/
describe("workflow graph entry contract — merged intake+hold planning column (U11)", () => {
/**
* The U11 IR edit, as a transformation of a real workflow: `triage`'s traits merge into `todo`,
* `todo` becomes "Planning", `triage` is deleted, and every node that named `triage` is repointed.
* Applying this to the production IR is what makes the assertions below track production.
*/
function mergeTodoIntoPlanning(source: WorkflowIr): WorkflowIr {
const ir = structuredClone(source) as WorkflowIr & {
columns: Array<{ id: string; name?: string; traits?: unknown[] }>;
nodes: Array<{ id: string; column?: string }>;
};
const triage = ir.columns.find((column) => column.id === "triage");
const todo = ir.columns.find((column) => column.id === "todo");
if (!triage || !todo) throw new Error("source IR is not the split-column shape this merge transforms");
todo.name = "Planning";
// intake first, then the existing hold/reset-on-entry — the union U11 declares.
todo.traits = [...(triage.traits ?? []), ...(todo.traits ?? [])];
ir.columns = ir.columns.filter((column) => column.id !== "triage");
for (const node of ir.nodes) {
if (node.column === "triage") node.column = "todo";
}
return ir as WorkflowIr;
}
const mergedCodingIr = mergeTodoIntoPlanning(codingIr);
it("is a faithful merge of the production IR (guards the transformation itself)", () => {
// If this drifts, every assertion below is measuring the wrong thing.
expect(mergedCodingIr.columns.map((column) => column.id)).not.toContain("triage");
expect(mergedCodingIr.nodes.every((node) => node.column !== "triage")).toBe(true);
const planning = mergedCodingIr.columns.find((column) => column.id === "todo")!;
const traits = (planning.traits ?? []).map((trait) => (trait as { trait: string }).trait);
expect(traits).toContain("intake");
expect(traits).toContain("hold");
// Node COUNT is unchanged: this is a column merge, not a graph edit.
expect(mergedCodingIr.nodes.length).toBe(codingIr.nodes.length);
});
it("enters the specification phase for a card in the merged planning column", () => {
const resumed = resolveColumnResumeNode(mergedCodingIr, "todo");
expect(resumed?.column).toBe("todo");
// The failure this guards: entering at an implementation node would put an unspecified card
// into implementation with no plan.
expect(resumed?.id).not.toBe("parse");
});
it("reaches the production specification node from the merged entry point in one hop", () => {
/*
The merged shape answers `start` where the split shape answers the planning node, because
`start` becomes the first node in that column once the columns collapse. That is equivalent
ONLY because `start` reaches the specification node by a single unconditional success edge.
Asserted against the PRODUCTION graph, so inserting a node between them fails here rather than
silently admitting an unspecified card into implementation.
*/
const entry = resolveColumnResumeNode(mergedCodingIr, "todo")!;
const splitAnswer = resolveColumnResumeNode(codingIr, "todo")!;
if (entry.id === splitAnswer.id) return; // no collapse happened; nothing to bridge.
const successors = mergedCodingIr.edges.filter(
(edge) => edge.from === entry.id && (edge.condition === undefined || edge.condition === "success") && edge.kind !== "rework",
);
expect(successors).toHaveLength(1);
expect(successors[0]!.to).toBe(splitAnswer.id);
});
it("NEVER re-plans a card past the merged column — the drag that aborts a live session", () => {
const resumed = resolveColumnResumeNode(mergedCodingIr, "in-progress");
expect(resumed?.id).toBe("parse");
expect(resumed?.column).toBe("in-progress");
expect(["start", "plan", "planning", "plan-review", "plan-replan"]).not.toContain(resumed?.id);
});
it("re-enters a review-column card at the production first review node, not the merge region", () => {
const resumed = resolveColumnResumeNode(mergedCodingIr, "in-review");
/*
ABSOLUTE, not merely equal-to-production. A differential assertion alone is tautological here:
if the production review lane were reordered, both sides of the comparison would move together
and the test would stay green while the merged board silently skipped a gate. The literal is
the same one the production block above pins, so a real topology change fails in both places.
*/
expect(resumed?.id).toBe("browser-verification");
expect(resumed?.column).toBe("in-review");
// …and it still agrees with production, which is the property the merge must preserve.
expect(resumed?.id).toBe(resolveColumnResumeNode(codingIr, "in-review")?.id);
});
it("produces the same answer as the production IR at every position past planning", () => {
// Absolutes first, so production drift cannot move both sides of the differential in step.
expect(resolveColumnResumeNode(mergedCodingIr, "in-progress")?.id).toBe("parse");
expect(resolveColumnResumeNode(mergedCodingIr, "in-review")?.id).toBe("browser-verification");
for (const column of ["in-progress", "in-review", "done"]) {
expect(resolveColumnResumeNode(mergedCodingIr, column)?.id)
.toBe(resolveColumnResumeNode(codingIr, column)?.id);
}
});
});
/*
FNXC:MergedPlanningColumn 2026-07-28-14:20 (U11, PR #2503 review — coderabbit):
The stranded-card case, driven through `executor.run()` rather than through the resolver.
Rows persisted in `triage` outlive the column U11 deletes. `resolveColumnResumeNode` returns
undefined for a column the IR does not declare, and the ONLY thing that then rescues the card is
`run()`'s `?? ir.nodes.find(kind === "start")` fallback. Asserting "the resolver returns undefined"
and "a start node exists" is compatible with the card stranding, which is why this drives the real
traversal and asserts the card moves.
Verified red: deleting the `?? ir.nodes.find(...)` fallback makes `run()` throw
`WorkflowIrError: Workflow IR missing start node` and this test fails.
*/
describe("workflow graph entry contract — a card stranded in a deleted column still runs (U11)", () => {
const promptWithOneStep = "# Task\n\n## Steps\n\n### Step 0: Implement\n- [ ] do it\n";
function silentPrimitives(): WorkflowRuntimePrimitives {
const ok = { outcome: "success" as const };
return {
prepareWorktree: async () => ({ outcome: "success", data: { worktreePath: "/memory/worktree" } }),
readArtifact: async (_c: unknown, _t: unknown, key: string) => (key === "PROMPT.md" ? promptWithOneStep : undefined),
writeArtifact: async (_c: unknown, _t: unknown, key: string) => ({ outcome: "success", data: { key } }),
runPlanningSession: async () => ({ outcome: "success", data: { approved: true, artifactKeys: ["PROMPT.md"] } }),
runCodingSession: async () => ({ outcome: "success", data: { taskDone: true, modifiedFiles: [] } }),
runTaskStep: async () => ({ outcome: "success", baselineSha: "b", checkpointId: "c" }),
resetTaskStep: async () => ({ ok: true }),
runReview: async () => ({ outcome: "success", data: { verdict: "APPROVE" } }),
runVerification: async () => ({ outcome: "success", data: { verdict: "skipped" } }),
updateSteps: async (_c: unknown, target: TaskDetail, steps: TaskStep[]) => {
target.steps = steps;
return { outcome: "success", data: { count: steps.length } };
},
transitionTask: async () => ok,
requestMerge: async () => ({ outcome: "success", value: "merged", data: { status: "merged" } }),
abortRun: async () => ok,
audit: () => undefined,
} as unknown as WorkflowRuntimePrimitives;
}
function mergedIrWithoutTriage(): WorkflowIr {
const ir = structuredClone(codingIr) as WorkflowIr & {
columns: Array<{ id: string; name?: string; traits?: unknown[] }>;
nodes: Array<{ id: string; column?: string }>;
};
const triage = ir.columns.find((column) => column.id === "triage")!;
const todo = ir.columns.find((column) => column.id === "todo")!;
todo.name = "Planning";
todo.traits = [...(triage.traits ?? []), ...(todo.traits ?? [])];
ir.columns = ir.columns.filter((column) => column.id !== "triage");
for (const node of ir.nodes) if (node.column === "triage") node.column = "todo";
return ir as WorkflowIr;
}
it("runs a card still stored in the DELETED triage column instead of stranding it", async () => {
const task = {
id: "FN-STRANDED",
title: "Left behind in a deleted column",
description: "",
// The migration case: the row outlived the column its workflow declared.
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: promptWithOneStep,
workflowStepResults: [],
createdAt: "2026-07-28T00:00:00.000Z",
updatedAt: "2026-07-28T00:00:00.000Z",
} as unknown as TaskDetail;
const executor = new WorkflowGraphExecutor({
primitives: silentPrimitives(),
parseStepsDeps: {
readArtifact: async (_target: unknown, key: string) => (key === "PROMPT.md" ? promptWithOneStep : undefined),
writeSteps: async (target: TaskDetail, steps: TaskStep[]) => {
target.steps = steps;
},
},
} as never);
const merged = mergedIrWithoutTriage();
expect(merged.columns.map((column) => column.id)).not.toContain("triage");
// No continuation node id — the "replay from start" path the fallback governs.
const result = await executor.run(task, { experimentalFeatures: {} } as never, merged);
// The card must actually traverse. An empty trace IS the stranding this guards.
expect(result.visitedNodeIds.length).toBeGreaterThan(0);
// …and it re-enters at the top of the pipeline, which is the only safe answer for a column
// the workflow no longer declares: it cannot be placed relative to any node.
expect(result.visitedNodeIds[0]).toBe(merged.nodes.find((node) => node.kind === "start")?.id);
});
});