fix: stop Ideas-intake cards from auto-processing and keep replans in the workflow's own planner column

Root cause of the reported incident: store init ran the retired flag-off
evacuation on every open, dumping Coding (Ideas) intake cards into triage
where they were auto-planned and executed. Init now always runs the
workflow-aware integrity pass (with a stale-selection mis-mapping guard and
per-pass IR memoization) and evacuation remains toggle-only.

Engine rebounds (Plan Review REVISE, stale-spec, fs-validation) resolve a
workflow-aware replan column instead of hardcoding triage; needs-replan now
counts as unplanned for hold-release dispatch so rejected plans cannot
re-execute; triage rediscovers needs-replan todo cards and refinement seed
prompts (shared buildRefinementSeedPrompt/isUnplannedSeedPrompt); the
fs-validation rebound sets needs-replan so unreadable-prompt tasks re-spec
instead of livelocking.

Dashboard: the All-workflows board renders column-orphaned tasks instead of
silently dropping them (hidden columns stay hidden), and the FN-7591 refetch
also fires for present-but-unrepresentable workflow mappings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-12 19:47:27 -07:00
parent 635d78248b
commit d4bbbcccc6
14 changed files with 710 additions and 55 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Ideas-intake cards no longer auto-process on restart, replan stays in Todo, and All-workflows shows every card.
category: fix
dev: Store init now always runs the workflow-aware integrity pass instead of the retired flag-off evacuation (`evacuateCustomColumnsToLegacy` remains toggle-only), with a mis-mapping guard so stale selections are never physically rehomed into auto-triaged lanes; engine replan/stale-spec/fs-validation rebounds resolve `resolveReplanTargetColumn` instead of hardcoding `triage`; `needs-replan` counts as unplanned for hold-release dispatch; triage discovers `needs-replan` todo cards and refinement seed prompts via `isUnplannedSeedPrompt`/`buildRefinementSeedPrompt`; Board's aggregate grouping renders column-orphaned tasks (hidden columns stay hidden) and the FN-7591 refetch also fires on present-but-unrepresentable mappings.

View File

@@ -12,10 +12,15 @@
// #1409 — flag ON→OFF evacuation:
// * toggling workflowColumns OFF with a card in a custom column re-homes it
// to a legacy column, the board stays listable, and legacy moves work.
// * a flag-OFF store init evacuates a card left in a custom column.
// * store init NEVER evacuates: workflow columns are graduated/always-on at
// runtime, so a custom-column card must survive a restart in place (the
// old flag-keyed init branch evacuated healthy intake columns like
// Coding (Ideas)'s "ideas" into "triage" on every open, where triage
// auto-planned deliberately-parked cards).
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
import { TaskStore } from "../store.js";
import type { WorkflowIr } from "../workflow-ir-types.js";
import { makeTransitionPending, serializeTransitionPending } from "../transition-types.js";
@@ -180,6 +185,43 @@ describe("#1409 flag ON→OFF evacuation", () => {
expect((await store.getTask(task.id)).column).toBe("in-progress");
});
it("store init leaves a custom-column card in place when the graduated flag is absent (no flag-off-init evacuation)", async () => {
// The graduated workflowColumns flag is ABSENT for virtually every install
// (no default is emitted). Init must run the workflow-aware integrity pass,
// not the evacuation: a card resting in its own workflow's intake column is
// valid and must survive a restart untouched.
const { mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join: joinPath } = await import("node:path");
const { rm: rmDir } = await import("node:fs/promises");
const rootDir = mkdtempSync(joinPath(tmpdir(), "kb-evac-init-"));
const globalDir = mkdtempSync(joinPath(tmpdir(), "kb-evac-init-global-"));
let diskStore = new TaskStore(rootDir, globalDir);
try {
await diskStore.init();
const wf = await diskStore.createWorkflowDefinition({ name: "simple-custom-init", ir: simpleCustomIr() });
const task = await diskStore.createTask({ description: "parked" });
await diskStore.selectTaskWorkflowAndReconcile(task.id, wf.id);
// Seed the card into its workflow's intake column directly (matches the
// real-world state: cards created into a custom-intake workflow rest in
// that intake column regardless of the retired flag's persisted value).
(diskStore as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db
.prepare(`UPDATE tasks SET "column" = 'intake' WHERE id = ?`)
.run(task.id);
expect((await diskStore.getTask(task.id)).column).toBe("intake");
// Restart the store (flag still absent) — the card must stay parked.
diskStore.close();
diskStore = new TaskStore(rootDir, globalDir);
await diskStore.init();
expect((await diskStore.getTask(task.id)).column).toBe("intake");
} finally {
diskStore.close();
await rmDir(rootDir, { recursive: true, force: true });
await rmDir(globalDir, { recursive: true, force: true });
}
});
it("evacuateCustomColumnsToLegacy is idempotent (a second run is a no-op)", async () => {
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
const wf = await store.createWorkflowDefinition({ name: "simple-custom-2", ir: simpleCustomIr() });

View File

@@ -5,6 +5,39 @@ export function buildBootstrapPrompt(taskId: string, title: string | undefined,
return `# ${heading}\n\n${description}\n`;
}
/*
FNXC:TaskRefinementWorkflow 2026-07-13-12:00:
The single source of truth for the refinement seed shape. TaskStore.refineTask writes this
exact content and isUnplannedSeedPrompt detects it by byte-equality — keep both on this
builder or the detector silently stops matching when the seed format changes, and unplanned
refinements release into execution again.
*/
export function buildRefinementSeedPrompt(title: string, description: string): string {
return `# ${title}\n\n${description}\n`;
}
/*
FNXC:WorkflowScheduling 2026-07-12-22:55:
"Unplanned" detection must recognize BOTH seed-prompt shapes or unplanned cards slip into
execution with a non-spec prompt:
1. The createTask bootstrap stub (`# {id}: {title}\n\n{description}\n`).
2. The refineTask seed (buildRefinementSeedPrompt — no task-id prefix), which previously
failed the strict stub-equality check, so a refinement promoted out of a manual intake
column (Coding (Ideas)) was treated as already planned and released straight into
execution carrying only the operator's feedback text.
Callers: triage todo-discovery (plan-in-place workflows) and hold-release's
isUnplannedForExecution guard.
*/
export function isUnplannedSeedPrompt(
content: string,
taskId: string,
title: string | undefined,
description: string,
): boolean {
if (content === buildBootstrapPrompt(taskId, title, description)) return true;
return title !== undefined && content === buildRefinementSeedPrompt(title, description);
}
export function buildMeshReplicatedTaskCreatePayload(input: {
taskId: string;
reservationId: string;

View File

@@ -90,7 +90,7 @@ import {
reconcileHooksRemaining,
} from "./transition-pending.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
import type { WorkflowIr, WorkflowIrColumn, WorkflowFieldDefinition, WorkflowSettingDefinition } from "./workflow-ir-types.js";
import type { WorkflowIr, WorkflowIrColumn, WorkflowIrV2, WorkflowFieldDefinition, WorkflowSettingDefinition } from "./workflow-ir-types.js";
import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js";
import type { WorkflowMovePolicyInput } from "./workflow-extension-types.js";
import {
@@ -211,6 +211,7 @@ import {
} from "./task-id-integrity.js";
import {
buildBootstrapPrompt,
buildRefinementSeedPrompt,
replicationCollisionError,
taskMatchesReplicatedCreate,
} from "./mesh-task-replication.js";
@@ -2085,24 +2086,28 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
// U12: workflow-columns integrity pass. When the flag is ON, audit + re-home
// any task whose stored column is no longer valid in its resolved workflow
// (KTD-1 guarantees zero rewrites for healthy legacy rows, so this is a
// no-op for the common case). Idempotent; non-fatal — never blocks startup.
// U12: workflow-columns integrity pass. Audit + re-home any task whose
// stored column is no longer valid in its resolved workflow (KTD-1
// guarantees zero rewrites for healthy legacy rows, so this is a no-op for
// the common case). Idempotent; non-fatal — never blocks startup.
/*
FNXC:WorkflowColumns 2026-07-12-22:40:
Workflow columns graduated to always-on at runtime (isWorkflowColumnsEnabled), so init must
ALWAYS run the workflow-aware integrity pass and must NEVER run the #1409 flag-OFF
evacuation. The retired experimental flag is absent (reads false) for virtually every
install, so the old flag-keyed branch ran evacuateCustomColumnsToLegacy("flag-off-init")
on EVERY store open: it declared healthy custom intake columns (e.g. Coding (Ideas)'s
"ideas") invalid and dumped their cards into "triage", where the triage service
auto-planned and executed work the operator had deliberately parked. The integrity pass
validates each card against its OWN resolved workflow, so custom-column cards are left
put. The evacuation now runs only on an explicit ON→OFF settings toggle.
*/
try {
const settings = await this.getSettingsFast();
if (isWorkflowColumnsCompatibilityFlagEnabled(settings)) {
await this.runWorkflowColumnsIntegrityPass();
// #1401: recover any transitionPending markers stranded by a crash
// between the in-txn write and the post-commit clear (they otherwise
// permanently inflate capacity counts for their target column).
await this.recoverStaleTransitionPending();
} else {
// #1409: flag-OFF init — evacuate any card stuck in a non-legacy column
// (e.g. the flag was toggled OFF out-of-process while a card sat in a
// custom column) so the board stays listable and moves work.
await this.evacuateCustomColumnsToLegacy("flag-off-init");
}
await this.runWorkflowColumnsIntegrityPass();
// #1401: recover any transitionPending markers stranded by a crash
// between the in-txn write and the post-commit clear (they otherwise
// permanently inflate capacity counts for their target column).
await this.recoverStaleTransitionPending();
} catch (err) {
storeLog.warn("workflowColumns integrity pass failed during init", {
phase: "init:workflow-columns-integrity",
@@ -5484,7 +5489,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
Refinements keep the source task's explicit workflow selection, reseeded from the current workflow definition, so returning from a non-default workflow does not hide the new refinement on the default board. The task row and workflow-selection row are written in one SQLite transaction so creation cannot strand a refinement without its intended board lane.
*/
await this.atomicCreateTaskJson(newDir, newTask, "refineTask", reservationCommit, inheritedWorkflowSelection);
const prompt = `# ${newTask.title}\n\n${newTask.description}\n`;
// Shared builder: isUnplannedSeedPrompt detects this exact shape so promoted
// refinements are planned instead of executing the feedback text as a spec.
const prompt = buildRefinementSeedPrompt(newTask.title ?? newId, newTask.description);
const sanitizedPrompt = sanitizeFileScopeInPromptContent(prompt);
await mkdir(newDir, { recursive: true });
await writeFile(join(newDir, "PROMPT.md"), sanitizedPrompt.sanitized);
@@ -7825,7 +7832,21 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
options?.recoveryRehome === true &&
sourceIsLegacy &&
(COLUMNS as readonly string[]).includes(toColumn);
if (!isEvacuation && !isLegacyRecoveryRehome) {
/*
FNXC:WorkflowColumns 2026-07-13-11:50:
Third recoveryRehome carve-out: a recovery move INTO a custom column the task's OWN
workflow declares (e.g. the integrity pass re-homing a workflow-edit orphan to a custom
entry column). The two carve-outs above only cover legacy targets, so on flag-absent
installs (this branch is the default path) every custom-target repair threw the legacy
"Invalid transition" Error, which rehomeOccupant swallowed as moved:false — the repair
silently no-oped on every store open. Recovery-only, so normal flag-OFF moves keep the
characterization contract byte-identical.
*/
const isWorkflowDeclaredRecoveryRehome =
options?.recoveryRehome === true &&
!(COLUMNS as readonly string[]).includes(toColumn) &&
workflowHasColumn(this.resolveTaskWorkflowIrSync(id), toColumn);
if (!isEvacuation && !isLegacyRecoveryRehome && !isWorkflowDeclaredRecoveryRehome) {
/*
FNXC:WorkflowColumns 2026-07-05-19:30:
Workflow columns graduated to always-on (no experimental flag emitted), so this "flag-OFF"
@@ -16403,18 +16424,63 @@ ${stepsSection}`;
let rehomed = 0;
let skippedTerminal = 0;
/*
FNXC:WorkflowColumns 2026-07-13-11:40:
This pass runs at EVERY store open (workflow columns are graduated/always-on), so it must
stay cheap on healthy DBs: select only id+column (the pass reads nothing else per row —
the old per-task full-row hydrate paid N JSON parses for a two-column need) and memoize
the resolved IR per selection workflow id (a board has a handful of workflows; the old
per-task resolveTaskWorkflowIrSync re-parsed/rebuilt the same IR N times).
*/
const rows = this.db
.prepare(`SELECT id FROM tasks WHERE "deletedAt" IS NULL`)
.all() as Array<{ id: string }>;
.prepare(`SELECT id, "column" AS col FROM tasks WHERE "deletedAt" IS NULL`)
.all() as Array<{ id: string; col: string }>;
const registry = getTraitRegistry();
for (const { id } of rows) {
const irCache = new Map<string, WorkflowIr>();
const resolveIrCached = (workflowId: string | undefined): WorkflowIr => {
const key = workflowId ?? "__default__";
let ir = irCache.get(key);
if (!ir) {
ir = this.resolveWorkflowIrByIdSync(workflowId);
irCache.set(key, ir);
}
return ir;
};
// Lazily built union of every column id declared by a known workflow
// (built-in catalog + custom definitions) — only needed for the rare
// invalid-column rows, never on the healthy fast path.
let knownColumnIds: Set<string> | null = null;
const knownWorkflowColumnIds = (): Set<string> => {
if (knownColumnIds) return knownColumnIds;
knownColumnIds = new Set<string>();
const collect = (ir: WorkflowIr | string | undefined): void => {
if (!ir) return;
try {
const parsed = typeof ir === "string" ? parseWorkflowIr(ir) : ir;
if (parsed.version === "v2") {
for (const column of (parsed as WorkflowIrV2).columns) knownColumnIds!.add(column.id);
}
} catch {
// A corrupt definition contributes no columns; the guard stays conservative.
}
};
for (const builtin of BUILTIN_WORKFLOWS) collect(builtin.ir);
try {
const definitionRows = this.db.prepare("SELECT ir FROM workflows").all() as Array<{ ir: string }>;
for (const definitionRow of definitionRows) collect(definitionRow.ir);
} catch {
// Missing table (older DBs) — builtins alone still cover the common case.
}
return knownColumnIds;
};
for (const { id, col: currentColumn } of rows) {
scanned += 1;
const task = this.readTaskFromDb(id, { includeDeleted: false });
if (!task) continue;
const ir = this.resolveTaskWorkflowIrSync(id);
const currentColumn = task.column;
const selection = this.getTaskWorkflowSelection(id);
const ir = resolveIrCached(selection?.workflowId);
// Already valid in its resolved workflow — nothing to do (the common case;
// this is why the pass is idempotent and a no-op for healthy DBs).
@@ -16438,6 +16504,41 @@ ${stepsSection}`;
continue;
}
/*
FNXC:WorkflowColumns 2026-07-13-11:45:
Mis-mapping guard: when the task resolved to the DEFAULT workflow only because its
selection row is missing or points at an unknown/deleted workflow, and the stored column
IS declared by some known workflow, the row is mis-mapped — not column-orphaned.
Physically re-homing it would drop a deliberately parked card (e.g. Coding (Ideas)
"ideas") into the default intake "triage", where the triage service auto-plans and
executes it. Leave the card put and audit the no-action decision; the dashboard's
suspect-mapping refetch and operators repair the selection instead.
*/
const selectionResolves = selection
? (isBuiltinWorkflowId(selection.workflowId)
? Boolean(getBuiltinWorkflow(selection.workflowId))
: Boolean(this.db.prepare("SELECT 1 FROM workflows WHERE id = ?").get(selection.workflowId)))
: false;
if (!selectionResolves && knownWorkflowColumnIds().has(currentColumn)) {
this.recordRunAuditEvent({
taskId: id,
agentId: "system",
runId: `workflow-reconcile-integrity-${id}-${Date.now()}`,
domain: "database",
mutationType: "task:workflow-reconcile",
target: id,
metadata: {
integrityPass: true,
invalidColumn: currentColumn,
misMappedSelection: true,
reason: "workflow-edit-rehome",
fromColumn: currentColumn,
moved: false,
},
});
continue;
}
const targetColumn = resolveEntryColumnId(ir);
if (!targetColumn) continue; // non-reconcilable IR — leave the card put.
@@ -16862,8 +16963,10 @@ ${stepsSection}`;
}
private resolveTaskWorkflowIrSync(taskId: string): WorkflowIr {
const selection = this.getTaskWorkflowSelection(taskId);
const workflowId = selection?.workflowId;
return this.resolveWorkflowIrByIdSync(this.getTaskWorkflowSelection(taskId)?.workflowId);
}
private resolveWorkflowIrByIdSync(workflowId: string | undefined): WorkflowIr {
/*
* FNXC:WorkflowBuiltins 2026-06-29-02:18:
* The built-in id `builtin:coding` now points at the stepwise final-review workflow. No-selection tasks must resolve through the built-in catalog, otherwise dashboard/operator defaults say "Coding" while the engine silently executes legacy coding.

View File

@@ -473,10 +473,32 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
tasksRef.current = tasks;
const lastUnmappedTaskSignatureRef = useRef<string | null>(null);
const unmappedRefetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/*
FNXC:WorkflowBoard 2026-07-12-23:40:
The FN-7591 refetch must also fire for a PRESENT-but-unrepresentable mapping, not only an
absent one. The server emits a taskWorkflowIds entry for every task (defaulting to the
default workflow), so a stale selection row makes e.g. an "ideas"-column card map to plain
Coding — an entry that exists but whose workflow does not declare the task's column. The
`=== undefined` guard alone never re-fired for those, leaving the card permanently
invisible in the aggregate view. A mapping is "suspect" when the resolved workflow's
column set does not contain the task's stored column. The signature guard still prevents
refetch loops for mappings that stay wrong after a fresh fetch.
*/
const isTaskWorkflowMappingSuspect = useCallback((
payload: NonNullable<typeof boardWorkflows>,
task: Task,
): boolean => {
const assigned = payload.taskWorkflowIds[task.id];
if (assigned === undefined) return true;
const known = payload.workflows.some((workflow) => workflow.id === assigned);
const workflowId = known ? assigned : payload.defaultWorkflowId;
const workflow = payload.workflows.find((candidate) => candidate.id === workflowId);
return workflow !== undefined && !workflow.columns.some((column) => column.id === task.column);
}, []);
useEffect(() => {
if (!boardWorkflows || !workflowMode) return;
const unmapped = tasks
.filter((task) => boardWorkflows.taskWorkflowIds[task.id] === undefined)
.filter((task) => isTaskWorkflowMappingSuspect(boardWorkflows, task))
.map((task) => task.id)
.sort();
if (unmapped.length === 0) {
@@ -491,10 +513,10 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
unmappedRefetchTimerRef.current = null;
const latestWorkflows = boardWorkflowsRef.current;
if (!latestWorkflows) return;
const stillUnmapped = tasksRef.current.some((task) => latestWorkflows.taskWorkflowIds[task.id] === undefined);
const stillUnmapped = tasksRef.current.some((task) => isTaskWorkflowMappingSuspect(latestWorkflows, task));
if (stillUnmapped) refreshBoardWorkflows({ forceFresh: true });
}, 0);
}, [boardWorkflows, refreshBoardWorkflows, tasks, workflowMode]);
}, [boardWorkflows, isTaskWorkflowMappingSuspect, refreshBoardWorkflows, tasks, workflowMode]);
useEffect(() => () => {
if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current);
@@ -782,6 +804,16 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
const aggregateTasksByColumn = useMemo(() => {
const grouped: Record<string, Task[]> = {};
for (const column of aggregateBoardColumns) grouped[column.id] = [];
// Column ids some workflow explicitly hides from the board. A column-orphaned
// task resting in one of these must stay hidden even when its (mis-)resolved
// workflow doesn't declare the column, or the fallback below would surface an
// explicitly hidden card in a visible lane.
const hiddenAnywhereColumnIds = new Set<string>();
for (const workflow of boardWorkflows?.workflows ?? []) {
for (const column of workflow.columns) {
if (column.flags.hiddenFromBoard) hiddenAnywhereColumnIds.add(column.id);
}
}
for (const task of tasks) {
const workflowId = getEffectiveTaskWorkflowId(task);
const workflowColumn = workflowId ? workflowColumnsByWorkflowId.get(workflowId)?.get(task.column) : null;
@@ -789,7 +821,32 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
FNXC:WorkflowBoard 2026-06-29-23:59:
Aggregate Board grouping must resolve the task's effective workflow before using a shared column id. If one workflow hides `qa` while another shows it, tasks assigned to the hidden `qa` column stay hidden instead of leaking into the visible aggregate lane.
*/
if (!workflowColumn || workflowColumn.flags.hiddenFromBoard) continue;
if (workflowColumn?.flags.hiddenFromBoard) continue;
if (!workflowColumn) {
/*
FNXC:WorkflowBoard 2026-07-12-23:35:
Safety net (aggregate twin of the selected-workflow display re-home below/above): a task
whose resolved workflow does NOT declare its stored column must not be continue-dropped
into invisibility. This happens when a stale/missing task_workflow_selection resolves the
task to the default workflow (e.g. an "ideas" card resolving to plain Coding), or when an
engine rebound parks a card in a legacy column its workflow never declared. Render the card
in its stored column when the aggregate union declares that lane; otherwise re-home it for
DISPLAY into the aggregate quick-create intake lane. Display-only — the stored column is
untouched.
FNXC:WorkflowBoard 2026-07-13-11:55:
Two carve-outs keep the safety net honest: a stored column that ANY workflow declares
hiddenFromBoard stays hidden (the guard above can't see the true workflow's flag when the
mapping is stale), and when no rendered fallback lane exists (no quick-create target) the
card is skipped rather than pushed into a `grouped` key the render loop never reads.
*/
const laneExists = grouped[task.column] !== undefined;
if (!laneExists && hiddenAnywhereColumnIds.has(task.column)) continue;
const fallbackColumnId = laneExists ? task.column : aggregateQuickCreateTarget?.columnId;
if (fallbackColumnId === undefined || grouped[fallbackColumnId] === undefined) continue;
grouped[fallbackColumnId].push(task);
continue;
}
(grouped[task.column] ??= []).push(task);
}
for (const column of aggregateBoardColumns) {
@@ -799,7 +856,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
: sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType, doneSortMode, column.flags.archived === true);
}
return grouped;
}, [aggregateBoardColumns, doneSortMode, getEffectiveTaskWorkflowId, tasks, workflowColumnsByWorkflowId]);
}, [aggregateBoardColumns, aggregateQuickCreateTarget, boardWorkflows, doneSortMode, getEffectiveTaskWorkflowId, tasks, workflowColumnsByWorkflowId]);
// Drag pre-check (R17): adjacency + capacity from the lane's column metadata.
// Cross-lane drag → workflow-mismatch. Deterministic rejections return a

View File

@@ -456,6 +456,110 @@ describe("workflow lane visibility for externally-arriving tasks (FN-7591 disapp
expect(fetchBoardWorkflowsMock.mock.calls.length).toBe(settled);
});
/*
FNXC:WorkflowBoard 2026-07-12-23:59:
Regression coverage for the aggregate "All workflows" twin of the disappearing-card bug.
Surface enumeration:
- Stale mapping: a task resting in "ideas" whose taskWorkflowIds entry mis-resolves to the
default workflow (which declares no "ideas" column) was continue-dropped from the
aggregate grouping — present in no lane at all. It must render in its stored column lane.
- Present-but-unrepresentable refetch: the FN-7591 refetch used to key on `=== undefined`
only; the server always emits a (possibly wrong) entry, so the refetch never fired. A
mapping whose workflow does not declare the task's column must force one refetch, and the
signature guard still bounds it when the mapping stays wrong.
*/
it("All-workflows view renders a mis-mapped ideas-column task in the ideas lane instead of dropping it", async () => {
// FN-mis maps to the DEFAULT workflow (stale selection row), but rests in "ideas",
// which only Coding (Ideas) declares. The server keeps returning the wrong mapping.
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ "FN-mis": DEFAULT_WORKFLOW.id }));
const misMapped = mkTask({ id: "FN-mis", title: "Mis-mapped ideas card", column: "ideas" });
render(<Board {...boardProps([misMapped])} />);
await screen.findByTestId("workflow-switcher");
selectWorkflow("__all_workflows__");
await waitFor(() => {
const ideasColumn = screen.getByTestId("column-ideas");
expect(within(ideasColumn).getByText("Mis-mapped ideas card")).toBeTruthy();
});
});
it("a present-but-unrepresentable mapping forces one refetch and renders once corrected", async () => {
// First fetches return the stale mapping (default workflow, no "ideas" column);
// after the forced refetch the server returns the corrected Coding (Ideas) mapping.
let corrected = false;
fetchBoardWorkflowsMock.mockImplementation((_projectId: unknown, options?: { forceFresh?: boolean }) => {
if (options?.forceFresh) corrected = true;
return Promise.resolve(workflowPayload({
"FN-stale": corrected ? CODING_IDEAS_WORKFLOW.id : DEFAULT_WORKFLOW.id,
}));
});
const staleTask = mkTask({ id: "FN-stale", title: "Stale mapping card", column: "ideas" });
render(<Board {...boardProps([staleTask])} />);
await screen.findByTestId("workflow-switcher");
selectWorkflow("__all_workflows__");
await waitFor(() => expect(corrected).toBe(true));
await waitFor(() => {
const ideasColumn = screen.getByTestId("column-ideas");
expect(within(ideasColumn).getByText("Stale mapping card")).toBeTruthy();
});
});
it("keeps a mis-mapped card whose stored column is hidden by some workflow OUT of the All-workflows view", async () => {
// FN-hid truly belongs to a workflow that hides "internal-qa" from the board, but its
// stale mapping resolves to the default workflow (which doesn't declare the column).
// The display fallback must not surface the explicitly hidden card in a visible lane.
const HIDDEN_WORKFLOW = {
id: "wf-hidden",
name: "Hidden QA",
columns: [
{ id: "intake", name: "Intake", flags: { intake: true } },
{ id: "internal-qa", name: "Internal QA", flags: { hiddenFromBoard: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
};
fetchBoardWorkflowsMock.mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: DEFAULT_WORKFLOW.id,
workflows: [DEFAULT_WORKFLOW, HIDDEN_WORKFLOW],
taskWorkflowIds: { "FN-hid": DEFAULT_WORKFLOW.id },
} as BoardWorkflowsPayload);
const hiddenCard = mkTask({ id: "FN-hid", title: "Hidden QA card", column: "internal-qa" });
render(<Board {...boardProps([hiddenCard])} />);
await screen.findByTestId("workflow-switcher");
selectWorkflow("__all_workflows__");
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
});
expect(screen.queryByText("Hidden QA card")).toBeNull();
});
it("a persistently-wrong mapping fires a bounded number of refetches (signature guard)", async () => {
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ "FN-stuck": DEFAULT_WORKFLOW.id }));
const stuckTask = mkTask({ id: "FN-stuck", title: "Stuck mapping card", column: "ideas" });
const { rerender } = render(<Board {...boardProps([stuckTask])} />);
await screen.findByTestId("workflow-switcher");
selectWorkflow("__all_workflows__");
await waitFor(() => expect(fetchBoardWorkflowsMock.mock.calls.length).toBeGreaterThanOrEqual(2));
const settled = fetchBoardWorkflowsMock.mock.calls.length;
for (let i = 0; i < 3; i++) {
await act(async () => {
rerender(<Board {...boardProps([stuckTask])} />);
});
}
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
});
expect(fetchBoardWorkflowsMock.mock.calls.length).toBe(settled);
});
it("renders a selected-workflow task whose column the workflow no longer declares in the intake lane (never dropped)", async () => {
// FN-orphan is correctly mapped to Coding (Ideas) but sits in a column the workflow does not declare.
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ "FN-orphan": CODING_IDEAS_WORKFLOW.id }));

View File

@@ -925,4 +925,49 @@ describe("hold-release sweep — FN-7648 unplanned/intake cards never enter exec
setColumn(store, plannedTask.id, "in-progress");
expect(await isUnplannedForExecution(store, (await store.getTask(plannedTask.id))!, ir.ir)).toBe(false);
});
/*
FNXC:TaskRefinementWorkflow 2026-07-12-23:58:
A refinement's seed PROMPT.md carries no task-id prefix (`# {title}\n\n{description}`), so
the old strict bootstrap-stub equality treated a promoted refinement as planned and released
it into execution with a feedback-only prompt. The predicate must hold it for planning.
*/
it("isUnplannedForExecution: true for a refinement seed prompt in an intake-trait column", async () => {
const def = await store.createWorkflowDefinition({ name: "refinement seed probe", ir: renamedIntakeCapacityWorkflowIr() });
const ir = await store.getWorkflowDefinition(def.id);
if (!ir?.ir) throw new Error("missing workflow ir");
const refineTask = await store.createTask({
title: "FN-100: tighten the header spacing",
description: "tighten the header spacing\n\nRefines: FN-100",
});
setSelection(store, refineTask.id, def.id);
setColumn(store, refineTask.id, "ideas");
const detail = await store.getTask(refineTask.id);
await store.updateTask(refineTask.id, {
prompt: `# ${detail!.title}\n\n${detail!.description}\n`,
});
expect(await isUnplannedForExecution(store, (await store.getTask(refineTask.id))!, ir.ir)).toBe(true);
});
/*
FNXC:WorkflowScheduling 2026-07-13-11:20:
`needs-replan` means Plan Review rejected the current PROMPT.md; the plan-in-place rebound
parks the card in "todo" awaiting the triage replan. The sweep must never release it — the
real (rejected) prompt would otherwise pass the seed check and execute.
*/
it("isUnplannedForExecution: true for status needs-replan even with a real (rejected) spec", async () => {
const def = await store.createWorkflowDefinition({ name: "needs-replan probe", ir: renamedIntakeCapacityWorkflowIr() });
const ir = await store.getWorkflowDefinition(def.id);
if (!ir?.ir) throw new Error("missing workflow ir");
const replanTask = await store.createTask({ description: "rejected plan" });
setSelection(store, replanTask.id, def.id);
await store.updateTask(replanTask.id, {
prompt: `# Task: ${replanTask.id} - rejected\n\n## Mission\n\nA real spec Plan Review rejected.\n`,
});
setColumn(store, replanTask.id, "ideas");
await store.updateTask(replanTask.id, { status: "needs-replan" } as Parameters<typeof store.updateTask>[1]);
expect(await isUnplannedForExecution(store, (await store.getTask(replanTask.id))!, ir.ir)).toBe(true);
});
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { moveTaskToReplanColumn, resolveReplanTargetColumn } from "../replan-target.js";
/*
FNXC:WorkflowReplan 2026-07-12-23:55:
Engine replan rebounds must target a column the task's OWN workflow declares. The default
Coding workflow replans in "triage"; Coding (Ideas) has no "triage" column and replans in
place in its merged "todo" planner column. The old hardcoded moveTask(id, "triage") orphaned
Coding (Ideas) cards in an undeclared column (rendered back in the "Ideas" intake lane).
*/
function storeWithSelection(workflowId: string | undefined): TaskStore {
return {
getTaskWorkflowSelection: vi.fn().mockReturnValue(workflowId ? { workflowId, stepIds: [] } : undefined),
getWorkflowDefinition: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
} as unknown as TaskStore;
}
describe("resolveReplanTargetColumn", () => {
it("targets triage for the default Coding workflow", async () => {
const store = storeWithSelection("builtin:coding");
await expect(resolveReplanTargetColumn(store, "FN-1")).resolves.toBe("triage");
});
it("targets triage when the task has no workflow selection", async () => {
const store = storeWithSelection(undefined);
await expect(resolveReplanTargetColumn(store, "FN-1")).resolves.toBe("triage");
});
it("targets todo for Coding (Ideas), which declares no triage column", async () => {
const store = storeWithSelection("builtin:coding-ideas");
await expect(resolveReplanTargetColumn(store, "FN-1")).resolves.toBe("todo");
});
it("falls back to triage for workflows declaring neither triage nor todo (never a custom column)", async () => {
// builtin:marketing declares ideation/backlog/drafting/... — no triage, no todo.
// A custom entry column would strand the needs-replan card (triage only scans
// "triage" and "todo") and the legacy move path throws on custom targets.
const store = storeWithSelection("builtin:marketing");
await expect(resolveReplanTargetColumn(store, "FN-1")).resolves.toBe("triage");
});
it("falls back to triage when workflow resolution throws", async () => {
const store = {
getTaskWorkflowSelection: vi.fn(() => {
throw new Error("boom");
}),
getWorkflowDefinition: vi.fn().mockRejectedValue(new Error("boom")),
} as unknown as TaskStore;
await expect(resolveReplanTargetColumn(store, "FN-1")).resolves.toBe("triage");
});
});
describe("moveTaskToReplanColumn", () => {
it("moves a Coding (Ideas) card to todo, not triage", async () => {
const store = storeWithSelection("builtin:coding-ideas");
const target = await moveTaskToReplanColumn(store, { id: "FN-1", column: "in-progress" });
expect(target).toBe("todo");
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "todo");
});
it("skips the move when the card is already in the replan column (plan-in-place)", async () => {
const store = storeWithSelection("builtin:coding-ideas");
const target = await moveTaskToReplanColumn(store, { id: "FN-1", column: "todo" });
expect(target).toBe("todo");
expect(store.moveTask).not.toHaveBeenCalled();
});
});

View File

@@ -2507,6 +2507,107 @@ describe("TriageProcessor", () => {
await cleanupTriageFixtureRoot(tempRoot);
}
});
/*
FNXC:CodingIdeasWorkflow 2026-07-12-23:50:
Plan-in-place workflows replan in "todo" (the workflow-aware replan rebound keeps them
there instead of orphaning them in an undeclared "triage" column), so a `needs-replan`
todo card must be rediscovered even though its PROMPT.md is a real failed plan, not a
seed.
*/
it("discovers a needs-replan todo-column task even though its PROMPT.md is a real spec", async () => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-ideas-replan-");
const replanId = "FN-IDEAS-REPLAN";
try {
const replanTask = createTriageTask({
id: replanId,
title: "Replanning in place",
description: "Plan Review sent this back for revision",
column: "todo",
status: "needs-replan",
priority: "urgent",
});
await mkdir(join(tempRoot, ".fusion", "tasks", replanId), { recursive: true });
await writeFile(
join(tempRoot, ".fusion", "tasks", replanId, "PROMPT.md"),
`# Task: ${replanId} - Replanning in place\n\n## Mission\n\nA real spec under revision.\n`,
"utf-8",
);
const triageStore = createMockStore({
listTasks: vi.fn().mockResolvedValue([replanTask]),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 10,
maxTriageConcurrent: 10,
pollIntervalMs: 10_000,
groupOverlappingFiles: false,
autoMerge: true,
}),
});
const triageProcessor = new TriageProcessor(triageStore, tempRoot);
const specifySpy = vi
.spyOn(triageProcessor, "specifyTask")
.mockResolvedValue(undefined);
(triageProcessor as any).running = true;
await (triageProcessor as any).poll();
expect(specifySpy).toHaveBeenCalledTimes(1);
expect(specifySpy).toHaveBeenCalledWith(expect.objectContaining({ id: replanId }));
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
/*
FNXC:TaskRefinementWorkflow 2026-07-12-23:50:
A refinement's seed PROMPT.md has no task-id prefix (`# {title}\n\n{description}`), so the
strict bootstrap-stub equality used to treat a promoted refinement as already planned and
skip specification; isUnplannedSeedPrompt must accept the refinement seed shape.
*/
it("discovers a promoted refinement whose PROMPT.md is the refinement seed (no id prefix)", async () => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-ideas-refine-");
const refineId = "FN-IDEAS-REFINE";
try {
const refineTask = createTriageTask({
id: refineId,
title: "FN-100: tighten the header spacing",
description: "tighten the header spacing\n\nRefines: FN-100",
column: "todo",
sourceType: "task_refine",
priority: "urgent",
});
await mkdir(join(tempRoot, ".fusion", "tasks", refineId), { recursive: true });
await writeFile(
join(tempRoot, ".fusion", "tasks", refineId, "PROMPT.md"),
`# ${refineTask.title}\n\n${refineTask.description}\n`,
"utf-8",
);
const triageStore = createMockStore({
listTasks: vi.fn().mockResolvedValue([refineTask]),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 10,
maxTriageConcurrent: 10,
pollIntervalMs: 10_000,
groupOverlappingFiles: false,
autoMerge: true,
}),
});
const triageProcessor = new TriageProcessor(triageStore, tempRoot);
const specifySpy = vi
.spyOn(triageProcessor, "specifyTask")
.mockResolvedValue(undefined);
(triageProcessor as any).running = true;
await (triageProcessor as any).poll();
expect(specifySpy).toHaveBeenCalledTimes(1);
expect(specifySpy).toHaveBeenCalledWith(expect.objectContaining({ id: refineId }));
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
});
it("runs deterministic validation without calling the spec reviewer", async () => {

View File

@@ -16,6 +16,7 @@ import { getUnmetSchedulingDependencies } from "./scheduler.js";
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore } from "@fusion/core";
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
import { mergeEffectiveSettings } from "./effective-settings.js";
import { moveTaskToReplanColumn, resolveReplanTargetColumn } from "./replan-target.js";
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
import {
buildWorkflowObservationFromTask,
@@ -4486,15 +4487,20 @@ export class TaskExecutor {
`Plan Review requested a planning revision before execution.\n\nStatus: ${info.status}\nFeedback:\n${feedback}`,
this.getRunContextFor(taskId),
);
/*
FNXC:PlanReviewReplan 2026-07-12-23:20:
The replan rebound is workflow-aware: workflows without a "triage" column (Coding
(Ideas)) replan in place in their planner column ("todo") instead of being orphaned
in an undeclared "triage" column, which the board rendered back in the intake lane.
*/
const replanColumn = await resolveReplanTargetColumn(this.store, taskId);
await this.store.logEntry(
taskId,
`Plan Review failed — moved to triage for automatic replan (attempt ${nextCount}/${budgetLabel})`,
`Plan Review failed — moved to ${replanColumn} for automatic replan (attempt ${nextCount}/${budgetLabel})`,
optionalStepRevisionLogOutcome(feedback, revisionKey),
this.getRunContextFor(taskId),
);
if (liveTask.column !== "triage") {
await this.store.moveTask(taskId, "triage");
}
await moveTaskToReplanColumn(this.store, { id: taskId, column: liveTask.column }, replanColumn);
await this.store.updateTask(taskId, {
status: "needs-replan",
error: null,
@@ -9629,8 +9635,9 @@ export class TaskExecutor {
const staleness = await evaluateSpecStaleness({ settings, promptPath, task });
if (staleness.isStale) {
executorLog.warn(`Task ${task.id} specification is stale — ${staleness.reason}`);
// Move to triage first, then set status so the task enters triage with needs-replan
await this.store.moveTask(task.id, "triage");
// Move to the workflow-aware replan column first, then set status so the task
// enters it with needs-replan (workflows without "triage" replan in place in todo).
await moveTaskToReplanColumn(this.store, task);
await this.store.updateTask(task.id, { status: "needs-replan" });
await this.store.logEntry(task.id, staleness.reason, undefined, this.getRunContextFor(task.id));
return;

View File

@@ -43,7 +43,7 @@ import {
DEFAULT_WORKFLOW_POOL_ID,
TransitionRejectionError,
resolveWorkflowIrForTask,
buildBootstrapPrompt,
isUnplannedSeedPrompt,
type TaskStore,
type Task,
type WorkflowIr,
@@ -150,6 +150,16 @@ function columnHasIntakeTrait(ir: WorkflowIr, columnId: string): boolean {
*/
export async function isUnplannedForExecution(store: TaskStore, task: Task, ir: WorkflowIr): Promise<boolean> {
if (task.status === "planning") return true;
/*
FNXC:WorkflowScheduling 2026-07-13-11:20:
`needs-replan` is unplanned-by-decree: Plan Review rejected the current PROMPT.md and the
plan-in-place rebound parks the card in "todo" awaiting the triage service's replan. Without
this check the capacity-hold sweep read the real (rejected) prompt, judged the card planned,
and released it into execution — re-running the plan the reviewer just rejected and racing
triage, which only flips the dispatch-blocking `planning` status after acquiring its
semaphore slot.
*/
if (task.status === "needs-replan") return true;
const isLegacyTodoColumn = task.column === "todo";
const isIntakeColumn = columnHasIntakeTrait(ir, task.column);
@@ -158,7 +168,10 @@ export async function isUnplannedForExecution(store: TaskStore, task: Task, ir:
if (typeof store.getTasksDir !== "function") return false;
try {
const promptContent = await readFile(getPromptPath(store.getTasksDir(), task.id), "utf-8");
return promptContent === buildBootstrapPrompt(task.id, task.title, task.description);
// isUnplannedSeedPrompt also matches the refineTask seed shape (no task-id prefix),
// so an unplanned refinement promoted out of a manual intake is held for planning
// instead of releasing into execution with a feedback-only prompt.
return isUnplannedSeedPrompt(promptContent, task.id, task.title, task.description);
} catch {
// Missing prompt is handled by filesystem validation elsewhere; do not block on it here.
return false;

View File

@@ -0,0 +1,49 @@
import type { Task, TaskStore } from "@fusion/core";
import { resolveWorkflowIrForTask, workflowHasColumn } from "@fusion/core";
/*
FNXC:WorkflowReplan 2026-07-12-23:15:
Engine rebounds that send a task back for (re)planning — Plan Review REVISE, stale-spec
enforcement, filesystem-validation failures — used to hardcode moveTask(id, "triage").
Workflows without a "triage" column (Coding (Ideas) merges the planner into "todo") ended up
with a column-orphaned card: the board rendered it back in the intake lane ("Ideas") and the
aggregate All-workflows view dropped it entirely. The replan target must be resolved against
the task's OWN workflow: "triage" when declared, otherwise the plan-in-place planner column
("todo"). Triage's todo-discovery picks up `needs-replan` todo cards so plan-in-place replans
still run.
FNXC:WorkflowReplan 2026-07-13-11:30:
The final fallback is "triage", NEVER the workflow's entry column. Workflows that declare
neither "triage" nor "todo" (builtin marketing, arbitrary customs) have no column the triage
service scans, so parking a needs-replan card in their custom entry column strands it forever
— and the legacy move path throws on custom targets, aborting the replan before the status
write. "triage" preserves the pre-workflow-aware behavior for these workflows: the move is
legal from every legacy column and eligibleTriageTasks re-specifies unconditionally.
*/
export async function resolveReplanTargetColumn(store: TaskStore, taskId: string): Promise<string> {
try {
const ir = await resolveWorkflowIrForTask(store, taskId);
if (workflowHasColumn(ir, "triage")) return "triage";
if (workflowHasColumn(ir, "todo")) return "todo";
return "triage";
} catch {
return "triage";
}
}
/**
* Move `task` to its workflow-aware replan column unless it is already there.
* Pass `target` when the caller already resolved it (e.g. to log the target
* first) so the resolve/compare/move contract still lives in one place.
*/
export async function moveTaskToReplanColumn(
store: TaskStore,
task: Pick<Task, "id" | "column">,
target?: string,
): Promise<string> {
const replanColumn = target ?? await resolveReplanTargetColumn(store, task.id);
if (task.column !== replanColumn) {
await store.moveTask(task.id, replanColumn);
}
return replanColumn;
}

View File

@@ -37,6 +37,7 @@ import { UnlinkedMissionsAdvisoryReporter } from "./unlinked-missions-advisory-r
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import { isWorkflowColumnsEnabled, DEFAULT_WORKFLOW_POOL_ID, resolveWorkflowIrForTask } from "@fusion/core";
import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } from "./hold-release.js";
import { moveTaskToReplanColumn } from "./replan-target.js";
import { evaluateParkedAgentTaskLink } from "./task-agent-sync.js";
function shouldRunWorkflowColumnScheduler(_settings: Settings): boolean {
@@ -1654,20 +1655,30 @@ export class Scheduler {
const validation = await this.validateTaskFilesystem(task.id);
if (!validation.valid) {
schedulerLog.warn(`Task ${task.id} filesystem validation failed: ${validation.reason}`);
await this.store.moveTask(task.id, "triage");
await this.store.logEntry(task.id, "Task moved to triage — filesystem validation failed", validation.reason);
/*
FNXC:WorkflowScheduling 2026-07-13-11:25:
The filesystem-validation rebound must set `needs-replan`, not just move. For a
plan-in-place workflow the replan column IS "todo", so the move is a no-op — without
the status write triage cannot rediscover the card (its PROMPT.md is missing or
unreadable, so the seed check throws and skips it) and this branch re-fires every
scheduler tick forever, appending a misleading log line each time.
*/
const replanColumn = await moveTaskToReplanColumn(this.store, task);
await this.store.updateTask(task.id, { status: "needs-replan" });
await this.store.logEntry(task.id, `Task rebounded to ${replanColumn} for re-specification — filesystem validation failed`, validation.reason);
continue;
}
// Stale spec enforcement: check if PROMPT.md has aged beyond the configured threshold.
// When enabled, stale tasks are moved back to triage with status "needs-replan"
// so they receive fresh specification before execution. This guard runs after
// filesystem validation so missing/unreadable files skip staleness checks entirely.
// When enabled, stale tasks are rebounded to the workflow-aware replan column with
// status "needs-replan" so they receive fresh specification before execution
// (workflows without a "triage" column replan in place in todo). This guard runs
// after filesystem validation so missing/unreadable files skip staleness checks entirely.
const promptPath = getPromptPath(this.store.getTasksDir(), task.id);
const staleness = await evaluateSpecStaleness({ settings, promptPath, task });
if (staleness.isStale) {
schedulerLog.warn(`Task ${task.id} specification is stale — ${staleness.reason}`);
await this.store.moveTask(task.id, "triage");
await moveTaskToReplanColumn(this.store, task);
await this.store.updateTask(task.id, { status: "needs-replan" });
await this.store.logEntry(task.id, staleness.reason);
continue;
@@ -2306,8 +2317,12 @@ export class Scheduler {
const validation = await this.validateTaskFilesystem(task.id);
if (!validation.valid) {
schedulerLog.warn(`Task ${task.id} filesystem validation failed: ${validation.reason}`);
await this.store.moveTask(task.id, "triage");
await this.store.logEntry(task.id, "Task moved to triage — filesystem validation failed", validation.reason);
// See the FNXC:WorkflowScheduling 2026-07-13-11:25 note in the legacy loop: the
// status write is what makes triage rediscover a card whose replan column equals
// its current column.
const replanColumn = await moveTaskToReplanColumn(this.store, task);
await this.store.updateTask(task.id, { status: "needs-replan" });
await this.store.logEntry(task.id, `Task rebounded to ${replanColumn} for re-specification — filesystem validation failed`, validation.reason);
return null;
}
@@ -2316,7 +2331,7 @@ export class Scheduler {
const staleness = await evaluateSpecStaleness({ settings, promptPath, task });
if (staleness.isStale) {
schedulerLog.warn(`Task ${task.id} specification is stale — ${staleness.reason}`);
await this.store.moveTask(task.id, "triage");
await moveTaskToReplanColumn(this.store, task);
await this.store.updateTask(task.id, { status: "needs-replan" });
await this.store.logEntry(task.id, staleness.reason);
return null;

View File

@@ -14,7 +14,7 @@ import {
PLAN_REVIEW_GROUP_ID,
TaskDeletedError,
buildTriageMemoryInstructions,
buildBootstrapPrompt,
isUnplannedSeedPrompt,
getTaskDuplicateLineage,
parseExplicitDuplicateMarker,
resolveAgentPrompt,
@@ -798,7 +798,12 @@ export class TriageProcessor {
&& !(t.nextRecoveryAt && new Date(t.nextRecoveryAt).getTime() > now),
);
/*
Workflows with a manual intake (e.g. Coding (Ideas)) merge the planner and capacity-hold stages into a single "todo" column. The triage service must also discover "todo" tasks whose PROMPT.md is still the bootstrap stub — they have been promoted out of the manual intake but not yet planned in place. Planned todo tasks carry a real spec and are left for the scheduler. The bootstrap-prompt file check is the ground-truth unplanned signal; it is false for every normal-workflow todo task because triage writes a real spec before it ever moves a card into todo.
Workflows with a manual intake (e.g. Coding (Ideas)) merge the planner and capacity-hold stages into a single "todo" column. The triage service must also discover "todo" tasks whose PROMPT.md is still an unplanned seed — they have been promoted out of the manual intake but not yet planned in place. Planned todo tasks carry a real spec and are left for the scheduler. The seed-prompt file check is the ground-truth unplanned signal; it is false for every normal-workflow todo task because triage writes a real spec before it ever moves a card into todo.
FNXC:CodingIdeasWorkflow 2026-07-12-23:05:
Two discovery gaps let plan-in-place workflow cards strand or misexecute in "todo":
1. `needs-replan` todo tasks carry a REAL PROMPT.md (the failed plan under revision), so the seed check alone never rediscovers them. Workflows without a "triage" column keep replanning tasks in "todo" (the executor's workflow-aware replan rebound targets the planner column), so triage must pick up `needs-replan` todo cards regardless of prompt content — processTask already routes them through the isReplan path.
2. Refinement seeds (`# {title}\n\n{description}`, no id prefix) previously failed the strict bootstrap-stub equality, so a promoted refinement skipped planning entirely; isUnplannedSeedPrompt accepts both seed shapes.
*/
const eligibleTodoTasksRaw = allTasks.filter(
(t) => t.column === "todo" && !this.processing.has(t.id) && !t.paused
@@ -810,10 +815,14 @@ export class TriageProcessor {
);
const eligibleTodoTasks: Task[] = [];
for (const todoTask of eligibleTodoTasksRaw) {
if (todoTask.status === "needs-replan") {
eligibleTodoTasks.push(todoTask);
continue;
}
try {
const promptPath = join(this.rootDir, ".fusion", "tasks", todoTask.id, "PROMPT.md");
const content = await readFile(promptPath, "utf-8");
if (content === buildBootstrapPrompt(todoTask.id, todoTask.title, todoTask.description)) {
if (isUnplannedSeedPrompt(content, todoTask.id, todoTask.title, todoTask.description)) {
eligibleTodoTasks.push(todoTask);
}
} catch {