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:
7
.changeset/coding-ideas-intake-safety.md
Normal file
7
.changeset/coding-ideas-intake-safety.md
Normal 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.
|
||||
@@ -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() });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
70
packages/engine/src/__tests__/replan-target.test.ts
Normal file
70
packages/engine/src/__tests__/replan-target.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
49
packages/engine/src/replan-target.ts
Normal file
49
packages/engine/src/replan-target.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user