diff --git a/.changeset/coding-ideas-intake-safety.md b/.changeset/coding-ideas-intake-safety.md new file mode 100644 index 0000000000..537f17b0bd --- /dev/null +++ b/.changeset/coding-ideas-intake-safety.md @@ -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. diff --git a/packages/core/src/__tests__/transition-pending-recovery.test.ts b/packages/core/src/__tests__/transition-pending-recovery.test.ts index 064393f800..5c79b37ada 100644 --- a/packages/core/src/__tests__/transition-pending-recovery.test.ts +++ b/packages/core/src/__tests__/transition-pending-recovery.test.ts @@ -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() }); diff --git a/packages/core/src/mesh-task-replication.ts b/packages/core/src/mesh-task-replication.ts index 24aa64c696..7db85d2a04 100644 --- a/packages/core/src/mesh-task-replication.ts +++ b/packages/core/src/mesh-task-replication.ts @@ -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; diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 2e32c4d2e1..4630455abe 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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 { }); } - // 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(); + 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 | null = null; + const knownWorkflowColumnIds = (): Set => { + if (knownColumnIds) return knownColumnIds; + knownColumnIds = new Set(); + 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. diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 96eda900c1..6760b598bc 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -473,10 +473,32 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o tasksRef.current = tasks; const lastUnmappedTaskSignatureRef = useRef(null); const unmappedRefetchTimerRef = useRef | 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, + 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 = {}; 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(); + 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 diff --git a/packages/dashboard/app/components/__tests__/board-quickcreate-workflow-lane-visibility.test.tsx b/packages/dashboard/app/components/__tests__/board-quickcreate-workflow-lane-visibility.test.tsx index b6c8167549..6fc0b80990 100644 --- a/packages/dashboard/app/components/__tests__/board-quickcreate-workflow-lane-visibility.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-quickcreate-workflow-lane-visibility.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + }); + } + 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 })); diff --git a/packages/engine/src/__tests__/hold-release.test.ts b/packages/engine/src/__tests__/hold-release.test.ts index 708d6bf0c8..5fb27d4925 100644 --- a/packages/engine/src/__tests__/hold-release.test.ts +++ b/packages/engine/src/__tests__/hold-release.test.ts @@ -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[1]); + expect(await isUnplannedForExecution(store, (await store.getTask(replanTask.id))!, ir.ir)).toBe(true); + }); }); diff --git a/packages/engine/src/__tests__/replan-target.test.ts b/packages/engine/src/__tests__/replan-target.test.ts new file mode 100644 index 0000000000..da38c75454 --- /dev/null +++ b/packages/engine/src/__tests__/replan-target.test.ts @@ -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(); + }); +}); diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 481bbb7f44..316954f5dc 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -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 () => { diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index f46f108bbf..63cc821da0 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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; diff --git a/packages/engine/src/hold-release.ts b/packages/engine/src/hold-release.ts index 53a5470cf3..f5d1816ba8 100644 --- a/packages/engine/src/hold-release.ts +++ b/packages/engine/src/hold-release.ts @@ -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 { 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; diff --git a/packages/engine/src/replan-target.ts b/packages/engine/src/replan-target.ts new file mode 100644 index 0000000000..2704a7af65 --- /dev/null +++ b/packages/engine/src/replan-target.ts @@ -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 { + 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, + target?: string, +): Promise { + const replanColumn = target ?? await resolveReplanTargetColumn(store, task.id); + if (task.column !== replanColumn) { + await store.moveTask(task.id, replanColumn); + } + return replanColumn; +} diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index 1255856461..3a7d8748db 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -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; diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 19a93cd41e..5cc74ef8af 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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 {