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