P0: a fresh project created every task into a column its workflow no longer declares (#2589)

Highest-severity finding of the post-merge audit, and it is the
**out-of-the-box** shape rather than an edge case.

## The defect

`createTask` resolves the intake column only as a by-product of
materializing the project's default workflow. A project that has never
**explicitly** set a default workflow has no persisted default row — so
that materialization returns nothing, `resolvedEntryColumn` stays
`undefined`, and the row falls through to the hard-coded `|| "triage"`.

Post-merge, that column does not exist in the default workflow.
Measured, three creates on one store:

| create | column |
|---|---|
| no default row persisted | **`triage`** ← broken |
| default explicitly `builtin:coding` | `todo` |
| explicit `workflowId` | `todo` |

`builtin:coding` is the **implicit** default via `DEFAULT_WORKFLOW_ID`,
and nothing writes a default-workflow row until an operator picks one.
So this was **every new task on a fresh project.**

## What it costs

Triage discovery resolves intake **by trait**, so `isAtIntakeColumn` is
false for a card sitting in `triage` while its workflow says `todo` —
**the card is never admitted for planning.** It isn't in the hold column
either, so hold-release ignores it. Only
`reconcileUndeclaredTaskColumns` eventually re-homes it.

A newly created task is invisible to planning until that sweep runs. Not
a permanent stall, but the first thing an operator does on a new project
is create a task.

## The fix — three parts, and missing any one leaves it half-fixed

1. `resolveDefaultWorkflowIntakeColumn` falls back to
`DEFAULT_WORKFLOW_ID` when no default row is persisted — the implicit
default every other resolver already assumes.
2. Both create paths consult it as a **last** resort before the literal,
so any path that already has an explicit column or a resolved entry
column is untouched.
3. **`isIntakeColumn` honours the same fallback.** Without this the card
lands in the right column but is classified *not*-intake and receives
`generateSpecifiedPrompt` instead of the bootstrap seed — and triage
admits a card only when its `PROMPT.md` reads as a seed, so it would sit
in Planning already looking "planned". FN-8587's failure mode by another
route.

`workflowId: null` ("No workflow") is excluded and asserted — there is
no workflow whose intake could be resolved, so that path keeps the
literal.

## Fixture drift, fixed with intent preserved

Seven tests asserted a created card lands in `triage`. None had their
assertion merely retargeted:

- **`move-task-if-planning`, `delete-task-if-planning`** — the mechanism
under test is the **live predicate**, not the column. Predicates and the
"advanced" column now name where the card actually rests.
- **`task-lifecycle-e2e`, `activity-log-parity`, `mission-store`** —
first-column and first-transition expectations.
- **`workflow-reconciliation-production-shape`** — the subtle one. Its
filler must occupy the **target** workflow's capped `triage` entry
column, but was created *before* the switch and so landed in the
**project default's** intake. It now names its column explicitly, which
makes the fixture independent of the project default — exactly the
coupling that let it drift.
- **`store-create-intake-column`** — the "lands in triage" guard now
names the invariant (the default workflow's *own* intake column) and
keeps a `not.toBe("triage")` so a regression back to the literal still
fails.

## Measured

Core package, against the 47-failure post-merge main baseline: **47
failed / 4413 passed — zero new failures.**

Three engine triage tests are red and are **not from this change**:
verified by stashing these edits and re-running against clean main,
where they fail identically. They arrived with #2515 and belong to the
triage-fixture owner.

Gate 414 + 10 + 71 green. Lint clean.

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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* New tasks now consistently start in the default workflow’s `todo`
intake column, including fresh projects without persisted workflow
settings.
* Bootstrap `PROMPT.md` content is now created consistently for all
supported task-creation paths.
* Task movement and deletion behavior now correctly respects current
columns and avoids acting on stale task data.
* Workflow reconciliation and activity tracking now reflect the updated
default task lifecycle.

* **Tests**
* Expanded coverage for intake-column resolution, task lifecycle
transitions, stale candidates, and workflow edge cases.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-29 19:03:03 -07:00
committed by GitHub
parent beb33b5dd1
commit ad3dc202f8
8 changed files with 209 additions and 30 deletions

View File

@@ -8,6 +8,13 @@ FN-8361 verifies conditional deletion through the real supported TaskStore
storage path. SQLite runtime support was removed (VAL-REMOVAL-005), so this
uses the PostgreSQL harness instead of a hand-built delete-helper mock.
*/
/*
FNXC:MergedPlanningColumn 2026-07-29-15:15 (U11 post-merge audit):
Same fixture drift as move-task-if-planning: a freshly created default-workflow card now rests in
the merged planning column `todo` rather than `triage`, so the live predicates and the "advanced"
column are updated to match. The mechanism under test — deleteTaskIf honouring a live predicate and
refusing an advanced stale candidate — is unchanged.
*/
pgDescribe("deleteTaskIf live storage path", () => {
const harness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_delete_task_if" });
beforeAll(harness.beforeAll);
@@ -18,7 +25,7 @@ pgDescribe("deleteTaskIf live storage path", () => {
it("soft-deletes only after its live predicate passes", async () => {
const store = harness.store();
const task = await store.createTask({ description: "conditional delete" });
const result = await store.deleteTaskIf(task.id, (live) => live.column === "triage");
const result = await store.deleteTaskIf(task.id, (live) => live.column === "todo");
expect(result.deleted).toBe(true);
expect(result.task).toMatchObject({ column: "archived" });
@@ -32,11 +39,11 @@ pgDescribe("deleteTaskIf live storage path", () => {
expect((await store.getTask(falseTask.id))?.deletedAt).toBeFalsy();
const staleTask = await store.createTask({ description: "advanced conditional delete" });
await store.getTask(staleTask.id); // Caller captured a stale triage candidate.
await store.moveTask(staleTask.id, "todo");
const stale = await store.deleteTaskIf(staleTask.id, (live) => live.column === "triage");
expect(stale).toMatchObject({ deleted: false, task: { column: "todo" } });
expect(await store.getTask(staleTask.id)).toMatchObject({ column: "todo", deletedAt: undefined });
await store.getTask(staleTask.id); // Caller captured a stale planning-column candidate.
await store.moveTask(staleTask.id, "in-progress");
const stale = await store.deleteTaskIf(staleTask.id, (live) => live.column === "todo");
expect(stale).toMatchObject({ deleted: false, task: { column: "in-progress" } });
expect(await store.getTask(staleTask.id)).toMatchObject({ column: "in-progress", deletedAt: undefined });
});
it("preserves self-delete and lineage lifecycle guards", async () => {

View File

@@ -7,6 +7,14 @@ FN-8361 exercises the live TaskStore persistence path. SQLite runtime support
was removed (VAL-REMOVAL-005), so the PostgreSQL harness is the real supported
storage path rather than a mocked moveTaskInternal seam.
*/
/*
FNXC:MergedPlanningColumn 2026-07-29-15:15 (U11 post-merge audit):
These fixtures create a task and then assert the LIVE-PREDICATE mechanism. U11 merged Todo into
Planning, so a freshly created default-workflow card now rests in `todo` rather than `triage` —
the predicates and the intermediate column are updated to match where the card actually is. What is
under test (moveTaskIf honouring a live predicate, skipping false ones, no-opping same-column) is
unchanged; only the column vocabulary moved underneath it.
*/
pgDescribe("moveTaskIf live storage path", () => {
const harness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_move_task_if" });
beforeAll(harness.beforeAll);
@@ -17,27 +25,27 @@ pgDescribe("moveTaskIf live storage path", () => {
it("moves only when the live predicate permits a real transition", async () => {
const store = harness.store();
const task = await store.createTask({ description: "conditional move" });
const result = await store.moveTaskIf(task.id, "todo", (live) => live.column === "triage");
const result = await store.moveTaskIf(task.id, "in-progress", (live) => live.column === "todo");
expect(result.moved).toBe(true);
expect(result.task.column).toBe("todo");
expect((await store.getTask(task.id))?.column).toBe("todo");
expect(result.task.column).toBe("in-progress");
expect((await store.getTask(task.id))?.column).toBe("in-progress");
});
it("skips false predicates, advanced stale candidates, and same-column no-ops", async () => {
const store = harness.store();
const falseTask = await store.createTask({ description: "false conditional move" });
expect((await store.moveTaskIf(falseTask.id, "todo", () => false)).moved).toBe(false);
expect((await store.getTask(falseTask.id))?.column).toBe("triage");
expect((await store.moveTaskIf(falseTask.id, "in-progress", () => false)).moved).toBe(false);
expect((await store.getTask(falseTask.id))?.column).toBe("todo");
const staleTask = await store.createTask({ description: "stale conditional move" });
await store.getTask(staleTask.id); // Caller captured a stale triage candidate.
await store.moveTask(staleTask.id, "todo");
const stale = await store.moveTaskIf(staleTask.id, "todo", (live) => live.column === "triage");
expect(stale).toMatchObject({ moved: false, task: { column: "todo" } });
await store.getTask(staleTask.id); // Caller captured a stale planning-column candidate.
await store.moveTask(staleTask.id, "in-progress");
const stale = await store.moveTaskIf(staleTask.id, "in-progress", (live) => live.column === "todo");
expect(stale).toMatchObject({ moved: false, task: { column: "in-progress" } });
const sameColumn = await store.moveTaskIf(staleTask.id, "todo", () => true);
const sameColumn = await store.moveTaskIf(staleTask.id, "in-progress", () => true);
expect(sameColumn.moved).toBe(false);
expect(sameColumn.task.column).toBe("todo");
expect(sameColumn.task.column).toBe("in-progress");
});
});

View File

@@ -42,7 +42,7 @@ pgDescribe("activity log parity (PostgreSQL)", () => {
title: "Backend lifecycle activity",
description: "Verify PostgreSQL lifecycle activity logging",
});
await store.moveTask(task.id, "todo", { moveSource: "user" });
await store.moveTask(task.id, "in-progress", { moveSource: "user" });
await vi.waitFor(async () => {
const entries = await store.getActivityLog({ limit: 10 });
@@ -55,7 +55,9 @@ pgDescribe("activity log parity (PostgreSQL)", () => {
expect.objectContaining({
type: "task:moved",
taskId: task.id,
metadata: { from: "triage", to: "todo" },
// FNXC:MergedPlanningColumn 2026-07-29-15:25 (U11): the default lineage's first column
// is now `todo`, so the first recorded transition leaves it rather than `triage`.
metadata: { from: "todo", to: "in-progress" },
}),
]));
});

View File

@@ -385,7 +385,10 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
});
/* FNXC:MissionAdmission 2026-07-23-21:10: a late same-fingerprint task claimed by another feature is not a duplicate eligible for archival. */
expect(await taskStore.getTask(siblingTask.id)).toMatchObject({ id: siblingTask.id, column: "triage" });
/* FNXC:MergedPlanningColumn 2026-07-29-15:30 (U11): the assertion is "not archived" — the card
stays where it was created, which for the default lineage is now the merged planning column
`todo` rather than `triage`. */
expect(await taskStore.getTask(siblingTask.id)).toMatchObject({ id: siblingTask.id, column: "todo" });
expect(await m.getFeature(siblingFeature.id)).toMatchObject({ taskId: siblingTask.id, status: "triaged" });
expect(await m.getFeature(firstFeature.id)).toMatchObject({ taskId: claimedTask.id, status: "triaged" });
});

View File

@@ -3,7 +3,7 @@
* VAL-CROSS-001 — End-to-end task lifecycle (create → move columns → archive)
*
* Validates that the full task lifecycle works against PostgreSQL backend mode,
* covering: create, move through columns (triage → todo → in-progress → in-review → done),
* covering: create, move through columns (todo → in-progress → in-review → done),
* archive, and unarchive. This is the critical cross-area flow that must work
* after SQLite removal.
*/
@@ -30,7 +30,13 @@ pgTest("VAL-CROSS-001: End-to-end task lifecycle (PostgreSQL)", () => {
const store = h.store();
const task = await store.createTask({ description: "E2E lifecycle task" });
expect(task.id).toBeTruthy();
expect(task.column).toBe("triage");
/*
FNXC:MergedPlanningColumn 2026-07-29-15:25 (U11 post-merge audit):
A freshly created default-workflow card now rests in the merged planning column `todo` — U11
removed `triage` from the default lineage. The lifecycle being exercised is unchanged; only its
first column's id moved.
*/
expect(task.column).toBe("todo");
const fetched = await store.getTask(task.id);
expect(fetched.id).toBe(task.id);

View File

@@ -179,8 +179,18 @@ pgDescribe("U5 workflow reconciliation guards — production shape (no workflowC
entry.traits = [...entry.traits, { trait: "wip", config: { limit: 1 } }];
const target = await store.createWorkflowDefinition({ name: "Capped target", ir: targetIr, layout: {} });
// Occupy the single slot in the target workflow's entry column.
const filler = await store.createTask({ description: "fills the cap" });
/*
Occupy the single slot in the TARGET workflow's entry column.
FNXC:MergedPlanningColumn 2026-07-29-15:40 (U11 post-merge audit):
The filler's column must be explicit. It is created before the workflow switch, so it lands in
the PROJECT DEFAULT's intake column — which U11 merged to `todo`. The cap under test is on the
TARGET workflow's `triage` entry column (the target clones legacy-coding, which keeps the split
shape), so a filler in `todo` occupies nothing and the switch is no longer refused. Naming the
column here makes the fixture independent of whatever the project default's intake happens to
be, which is what let this drift in the first place.
*/
const filler = await store.createTask({ description: "fills the cap", column: "triage" });
await store.selectTaskWorkflow(filler.id, target.id);
expect((await store.getTask(filler.id)).column).toBe("triage");

View File

@@ -29,10 +29,22 @@ pgTest("createTask intake-column wiring (Coding (Ideas))", () => {
await h.afterEach();
});
it("lands a default-workflow task in triage (byte-identical regression guard)", async () => {
/*
FNXC:MergedPlanningColumn 2026-07-29-14:55 (U11 post-merge audit):
This guarded "a default-workflow create lands in triage" and was byte-identical for as long as
the default workflow declared a `triage` column. U11 merged Todo into Planning, so the default's
intake column IS `todo` — the create landing there is the change working, and the assertion is
updated to name the invariant (the DEFAULT WORKFLOW'S OWN intake column) rather than the id that
used to hold it.
Kept as a guard rather than deleted: it is the assertion that catches a regression back to the
hard-coded `"triage"` fallback, which is exactly the defect this commit fixes.
*/
it("lands a default-workflow task in the default workflow's intake column", async () => {
const store = h.store();
const task = await store.createTask({ description: "default workflow task" });
expect(task.column).toBe("triage");
expect(task.column).toBe("todo");
expect(task.column).not.toBe("triage");
});
it("lands a Coding (Ideas) task in the ideas intake column when selected explicitly", async () => {
@@ -58,6 +70,61 @@ pgTest("createTask intake-column wiring (Coding (Ideas))", () => {
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.
*/
/*
FNXC:MergedPlanningColumn 2026-07-29-14:20 (U11 post-merge audit):
A project that has never explicitly set a default workflow has no persisted default row, so
`materializeDefaultWorkflowSteps()` returns nothing, `resolvedEntryColumn` stays undefined, and
the create falls through to the hard-coded `|| "triage"`.
That column no longer exists in the default workflow. Measured post-merge: a plain
`createTask({ description })` on such a project lands in `triage` while the same project's
default workflow declares intake as `todo`. Triage discovery resolves intake by trait, so
`isAtIntakeColumn` is false for that card and it is never admitted for planning; it is not in
the hold column either, so hold-release ignores it too. The card is only rescued when
`reconcileUndeclaredTaskColumns` re-homes it.
This is the out-of-the-box state for a fresh project — `builtin:coding` is the IMPLICIT default
via DEFAULT_WORKFLOW_ID, and nothing writes a default-workflow row until an operator picks one.
*/
it("lands a plain create in the default workflow's intake column when no default row is persisted", async () => {
const store = h.store();
// Deliberately NO setDefaultWorkflowId — the implicit-default, fresh-project shape.
const task = await store.createTask({ description: "plain create, no default workflow row" });
expect(task.column).toBe("todo");
expect(task.column).not.toBe("triage");
});
/*
FNXC:MergedPlanningColumn 2026-07-29-17:05 (PR #2589 review — greptile):
`_createTaskInternalImpl` (the reserved-id / legacy path) had the same gap the backend path did:
it assigns `fallbackIntakeColumn` to the task's column but omitted it from `isIntakeColumn`, so a
create down that path lands in the resolved intake column and is then classified NOT-intake —
receiving `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 rest in Planning already
looking "planned" and never be planned.
Exercised through `createTaskWithReservedId`, which is the path the engine and mesh replication
use; the backend path is covered by the test above. Both paths need the assertion because they
are two independent copies of the same predicate.
*/
it("writes a bootstrap PROMPT.md on the RESERVED-ID path too (both create paths)", async () => {
const store = h.store();
const task = await store.createTaskWithReservedId(
{ description: "reserved-id create, no default workflow row" },
{ taskId: "FN-RSV-1" },
);
expect(task.column).toBe("todo");
const prompt = await readFile(join(store.getTasksDir(), task.id, "PROMPT.md"), "utf-8");
expect(prompt).toBe(buildBootstrapPrompt(task.id, task.title, task.description));
});
it("still writes a bootstrap PROMPT.md for that create, so triage can discover it", async () => {
const store = h.store();
const task = await store.createTask({ description: "plain create, no default workflow row" });
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 even when enabledWorkflowSteps is supplied", async () => {
const store = h.store();
await store.setDefaultWorkflowId("builtin:coding-ideas");

View File

@@ -25,6 +25,7 @@ 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 {DEFAULT_WORKFLOW_ID} from "../builtin-workflows.js";
import {columnsWithFlag} from "../workflow-lifecycle-traits.js";
import {validateFileScopeInPromptContent} from "../task-store/file-scope.js";
import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
@@ -82,8 +83,14 @@ Unresolvable workflow returns undefined and the caller keeps its existing legacy
*/
async function resolveDefaultWorkflowIntakeColumn(store: TaskStore): Promise<string | undefined> {
try {
const workflowId = await store.getDefaultWorkflowId();
if (!workflowId) return undefined;
/*
FNXC:MergedPlanningColumn 2026-07-29-14:40 (U11 post-merge audit):
Fall back to DEFAULT_WORKFLOW_ID when no default row is persisted. A fresh project has none —
`builtin:coding` is the IMPLICIT default that every other resolver already uses — so returning
undefined here sent the create to the hard-coded `"triage"` literal, a column the merged
default workflow does not declare.
*/
const workflowId = (await store.getDefaultWorkflowId()) ?? DEFAULT_WORKFLOW_ID;
const ir = await resolveWorkflowIrById(store, workflowId);
return columnsWithFlag(ir, "intake")[0];
} catch {
@@ -349,6 +356,28 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta
const layer = store.asyncLayer!;
const now = options?.createdAt ?? new Date().toISOString();
const normalizedTitle = normalizeTitleForTaskId(title, id);
/*
FNXC:MergedPlanningColumn 2026-07-29-14:30 (U11 post-merge audit):
A project that has never explicitly set a default workflow has no persisted default row, so
`materializeDefaultWorkflowSteps()` returns nothing, `resolvedEntryColumn` stays undefined, and
the row below fell through to the hard-coded `|| "triage"`. That column no longer exists in the
default workflow, so the card landed in a lane its own workflow does not declare: triage
discovery resolves intake BY TRAIT and never admits it, hold-release ignores it, and only
`reconcileUndeclaredTaskColumns` eventually re-homes it.
This is the OUT-OF-THE-BOX shape — `builtin:coding` is the IMPLICIT default via
DEFAULT_WORKFLOW_ID and nothing writes a default-workflow row until an operator picks one — so
it affected every new task on a fresh project rather than an edge case.
Resolved side-effect-free and ONLY as a last resort before the literal, so every path that
already has an explicit column or a resolved entry column is untouched. The literal survives as
the final fallback for a store that cannot resolve any workflow at all.
*/
// `workflowId: null` is an explicit "No workflow" opt-out — there is no workflow whose intake
// column could be resolved, so that path keeps the legacy literal.
const fallbackIntakeColumn = (input.column || options?.resolvedEntryColumn || input.workflowId === null)
? undefined
: await resolveDefaultWorkflowIntakeColumn(store);
const declaredSymbols = resolveCreateDeclaredSymbols(input, options?.promptOverride);
const task: Task = {
id,
@@ -375,7 +404,7 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta
// FNXC:CodingIdeasWorkflow 2026-07-05-19:45: land the task in its
// workflow's manual intake column (e.g. Coding (Ideas) → "ideas") when
// no explicit column is given (main FN-7591 parity).
column: input.column || options?.resolvedEntryColumn || "triage",
column: input.column || options?.resolvedEntryColumn || fallbackIntakeColumn || "triage",
dependencies: input.dependencies || [],
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,
noCommitsExpected: input.noCommitsExpected === true ? true : undefined,
@@ -490,8 +519,17 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta
const isUnplannedStartCreate = options?.resolvedEntryColumn !== undefined
&& options.resolvedEntryColumn !== "triage"
&& task.column === "todo";
/*
FNXC:MergedPlanningColumn 2026-07-29-14:50 (U11 post-merge audit):
`fallbackIntakeColumn` must be honoured here too. Without it the card lands in the resolved
intake column (correct) but is classified NOT-intake and receives `generateSpecifiedPrompt`
instead of the bootstrap seed — and triage admits a card for planning only when its PROMPT.md
reads as a seed, so it would sit in Planning already looking "planned". That is FN-8587's
failure mode, reached by a different route.
*/
const isIntakeColumn = task.column === "triage"
|| (options?.resolvedEntryColumn !== undefined && task.column === options.resolvedEntryColumn)
|| (fallbackIntakeColumn !== undefined && task.column === fallbackIntakeColumn)
|| isUnplannedStartCreate;
const usedBootstrapPrompt = !options?.promptOverride && isIntakeColumn;
const prompt = options?.promptOverride
@@ -761,6 +799,28 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat
const now = options?.createdAt ?? new Date().toISOString();
// FN-5077: null normalized titles are treated as "no title" and allow standard fallback/summarization behavior.
const normalizedTitle = normalizeTitleForTaskId(title, id);
/*
FNXC:MergedPlanningColumn 2026-07-29-14:30 (U11 post-merge audit):
A project that has never explicitly set a default workflow has no persisted default row, so
`materializeDefaultWorkflowSteps()` returns nothing, `resolvedEntryColumn` stays undefined, and
the row below fell through to the hard-coded `|| "triage"`. That column no longer exists in the
default workflow, so the card landed in a lane its own workflow does not declare: triage
discovery resolves intake BY TRAIT and never admits it, hold-release ignores it, and only
`reconcileUndeclaredTaskColumns` eventually re-homes it.
This is the OUT-OF-THE-BOX shape — `builtin:coding` is the IMPLICIT default via
DEFAULT_WORKFLOW_ID and nothing writes a default-workflow row until an operator picks one — so
it affected every new task on a fresh project rather than an edge case.
Resolved side-effect-free and ONLY as a last resort before the literal, so every path that
already has an explicit column or a resolved entry column is untouched. The literal survives as
the final fallback for a store that cannot resolve any workflow at all.
*/
// `workflowId: null` is an explicit "No workflow" opt-out — there is no workflow whose intake
// column could be resolved, so that path keeps the legacy literal.
const fallbackIntakeColumn = (input.column || options?.resolvedEntryColumn || input.workflowId === null)
? undefined
: await resolveDefaultWorkflowIntakeColumn(store);
const declaredSymbols = resolveCreateDeclaredSymbols(input, options?.promptOverride);
const task: Task = {
id,
@@ -787,7 +847,7 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat
// FNXC:CodingIdeasWorkflow 2026-07-05-19:45: land the task in its
// workflow's manual intake column (e.g. Coding (Ideas) → "ideas") when
// no explicit column is given (main FN-7591 parity).
column: input.column || options?.resolvedEntryColumn || "triage",
column: input.column || options?.resolvedEntryColumn || fallbackIntakeColumn || "triage",
dependencies: input.dependencies || [],
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,
noCommitsExpected: input.noCommitsExpected === true ? true : undefined,
@@ -860,8 +920,24 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat
const isUnplannedStartCreate = options?.resolvedEntryColumn !== undefined
&& options.resolvedEntryColumn !== "triage"
&& task.column === "todo";
/*
FNXC:MergedPlanningColumn 2026-07-29-17:15 (PR #2589 review — greptile):
`fallbackIntakeColumn` must appear here, not only in the `column:` assignment above. Otherwise
this path lands the card in the resolved intake column and then classifies it NOT-intake,
writing `generateSpecifiedPrompt` instead of the bootstrap seed — and triage admits a card for
planning only when its PROMPT.md reads as a seed, so the card would rest in Planning already
looking "planned" and never be planned.
HONEST SCOPE: I could not construct a failing test for this through a public API. The only
in-tree caller of `_createTaskInternal` is `createTaskWithReservedIdImpl`, which passes its own
`resolvedEntryColumn` through options, so the second disjunct already matches and this path's
own fallback never decides. The fix is for the DIVERGENCE, which is a latent bug: two copies of
one predicate that disagree, where the backend copy needed exactly this clause. A direct
`_createTaskInternal` call — which the signature invites — would hit it.
*/
const isIntakeColumn = task.column === "triage"
|| (options?.resolvedEntryColumn !== undefined && task.column === options.resolvedEntryColumn)
|| (fallbackIntakeColumn !== undefined && task.column === fallbackIntakeColumn)
|| isUnplannedStartCreate;
const usedBootstrapPrompt = !options?.promptOverride && isIntakeColumn;
const prompt = options?.promptOverride