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:
|
// #1409 — flag ON→OFF evacuation:
|
||||||
// * toggling workflowColumns OFF with a card in a custom column re-homes it
|
// * 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.
|
// 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 { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||||
|
import { TaskStore } from "../store.js";
|
||||||
import type { WorkflowIr } from "../workflow-ir-types.js";
|
import type { WorkflowIr } from "../workflow-ir-types.js";
|
||||||
import { makeTransitionPending, serializeTransitionPending } from "../transition-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");
|
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 () => {
|
it("evacuateCustomColumnsToLegacy is idempotent (a second run is a no-op)", async () => {
|
||||||
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
|
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
|
||||||
const wf = await store.createWorkflowDefinition({ name: "simple-custom-2", ir: simpleCustomIr() });
|
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`;
|
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: {
|
export function buildMeshReplicatedTaskCreatePayload(input: {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
reservationId: string;
|
reservationId: string;
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ import {
|
|||||||
reconcileHooksRemaining,
|
reconcileHooksRemaining,
|
||||||
} from "./transition-pending.js";
|
} from "./transition-pending.js";
|
||||||
import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.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 { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js";
|
||||||
import type { WorkflowMovePolicyInput } from "./workflow-extension-types.js";
|
import type { WorkflowMovePolicyInput } from "./workflow-extension-types.js";
|
||||||
import {
|
import {
|
||||||
@@ -211,6 +211,7 @@ import {
|
|||||||
} from "./task-id-integrity.js";
|
} from "./task-id-integrity.js";
|
||||||
import {
|
import {
|
||||||
buildBootstrapPrompt,
|
buildBootstrapPrompt,
|
||||||
|
buildRefinementSeedPrompt,
|
||||||
replicationCollisionError,
|
replicationCollisionError,
|
||||||
taskMatchesReplicatedCreate,
|
taskMatchesReplicatedCreate,
|
||||||
} from "./mesh-task-replication.js";
|
} 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
|
// U12: workflow-columns integrity pass. Audit + re-home any task whose
|
||||||
// any task whose stored column is no longer valid in its resolved workflow
|
// stored column is no longer valid in its resolved workflow (KTD-1
|
||||||
// (KTD-1 guarantees zero rewrites for healthy legacy rows, so this is a
|
// guarantees zero rewrites for healthy legacy rows, so this is a no-op for
|
||||||
// no-op for the common case). Idempotent; non-fatal — never blocks startup.
|
// 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 {
|
try {
|
||||||
const settings = await this.getSettingsFast();
|
|
||||||
if (isWorkflowColumnsCompatibilityFlagEnabled(settings)) {
|
|
||||||
await this.runWorkflowColumnsIntegrityPass();
|
await this.runWorkflowColumnsIntegrityPass();
|
||||||
// #1401: recover any transitionPending markers stranded by a crash
|
// #1401: recover any transitionPending markers stranded by a crash
|
||||||
// between the in-txn write and the post-commit clear (they otherwise
|
// between the in-txn write and the post-commit clear (they otherwise
|
||||||
// permanently inflate capacity counts for their target column).
|
// permanently inflate capacity counts for their target column).
|
||||||
await this.recoverStaleTransitionPending();
|
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");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
storeLog.warn("workflowColumns integrity pass failed during init", {
|
storeLog.warn("workflowColumns integrity pass failed during init", {
|
||||||
phase: "init:workflow-columns-integrity",
|
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.
|
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);
|
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);
|
const sanitizedPrompt = sanitizeFileScopeInPromptContent(prompt);
|
||||||
await mkdir(newDir, { recursive: true });
|
await mkdir(newDir, { recursive: true });
|
||||||
await writeFile(join(newDir, "PROMPT.md"), sanitizedPrompt.sanitized);
|
await writeFile(join(newDir, "PROMPT.md"), sanitizedPrompt.sanitized);
|
||||||
@@ -7825,7 +7832,21 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
|||||||
options?.recoveryRehome === true &&
|
options?.recoveryRehome === true &&
|
||||||
sourceIsLegacy &&
|
sourceIsLegacy &&
|
||||||
(COLUMNS as readonly string[]).includes(toColumn);
|
(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:
|
FNXC:WorkflowColumns 2026-07-05-19:30:
|
||||||
Workflow columns graduated to always-on (no experimental flag emitted), so this "flag-OFF"
|
Workflow columns graduated to always-on (no experimental flag emitted), so this "flag-OFF"
|
||||||
@@ -16403,18 +16424,63 @@ ${stepsSection}`;
|
|||||||
let rehomed = 0;
|
let rehomed = 0;
|
||||||
let skippedTerminal = 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
|
const rows = this.db
|
||||||
.prepare(`SELECT id FROM tasks WHERE "deletedAt" IS NULL`)
|
.prepare(`SELECT id, "column" AS col FROM tasks WHERE "deletedAt" IS NULL`)
|
||||||
.all() as Array<{ id: string }>;
|
.all() as Array<{ id: string; col: string }>;
|
||||||
|
|
||||||
const registry = getTraitRegistry();
|
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;
|
scanned += 1;
|
||||||
const task = this.readTaskFromDb(id, { includeDeleted: false });
|
const selection = this.getTaskWorkflowSelection(id);
|
||||||
if (!task) continue;
|
const ir = resolveIrCached(selection?.workflowId);
|
||||||
const ir = this.resolveTaskWorkflowIrSync(id);
|
|
||||||
const currentColumn = task.column;
|
|
||||||
|
|
||||||
// Already valid in its resolved workflow — nothing to do (the common case;
|
// 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).
|
// this is why the pass is idempotent and a no-op for healthy DBs).
|
||||||
@@ -16438,6 +16504,41 @@ ${stepsSection}`;
|
|||||||
continue;
|
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);
|
const targetColumn = resolveEntryColumnId(ir);
|
||||||
if (!targetColumn) continue; // non-reconcilable IR — leave the card put.
|
if (!targetColumn) continue; // non-reconcilable IR — leave the card put.
|
||||||
|
|
||||||
@@ -16862,8 +16963,10 @@ ${stepsSection}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveTaskWorkflowIrSync(taskId: string): WorkflowIr {
|
private resolveTaskWorkflowIrSync(taskId: string): WorkflowIr {
|
||||||
const selection = this.getTaskWorkflowSelection(taskId);
|
return this.resolveWorkflowIrByIdSync(this.getTaskWorkflowSelection(taskId)?.workflowId);
|
||||||
const workflowId = selection?.workflowId;
|
}
|
||||||
|
|
||||||
|
private resolveWorkflowIrByIdSync(workflowId: string | undefined): WorkflowIr {
|
||||||
/*
|
/*
|
||||||
* FNXC:WorkflowBuiltins 2026-06-29-02:18:
|
* 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.
|
* 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;
|
tasksRef.current = tasks;
|
||||||
const lastUnmappedTaskSignatureRef = useRef<string | null>(null);
|
const lastUnmappedTaskSignatureRef = useRef<string | null>(null);
|
||||||
const unmappedRefetchTimerRef = useRef<ReturnType<typeof setTimeout> | 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(() => {
|
useEffect(() => {
|
||||||
if (!boardWorkflows || !workflowMode) return;
|
if (!boardWorkflows || !workflowMode) return;
|
||||||
const unmapped = tasks
|
const unmapped = tasks
|
||||||
.filter((task) => boardWorkflows.taskWorkflowIds[task.id] === undefined)
|
.filter((task) => isTaskWorkflowMappingSuspect(boardWorkflows, task))
|
||||||
.map((task) => task.id)
|
.map((task) => task.id)
|
||||||
.sort();
|
.sort();
|
||||||
if (unmapped.length === 0) {
|
if (unmapped.length === 0) {
|
||||||
@@ -491,10 +513,10 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
unmappedRefetchTimerRef.current = null;
|
unmappedRefetchTimerRef.current = null;
|
||||||
const latestWorkflows = boardWorkflowsRef.current;
|
const latestWorkflows = boardWorkflowsRef.current;
|
||||||
if (!latestWorkflows) return;
|
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 });
|
if (stillUnmapped) refreshBoardWorkflows({ forceFresh: true });
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [boardWorkflows, refreshBoardWorkflows, tasks, workflowMode]);
|
}, [boardWorkflows, isTaskWorkflowMappingSuspect, refreshBoardWorkflows, tasks, workflowMode]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current);
|
if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current);
|
||||||
@@ -782,6 +804,16 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
const aggregateTasksByColumn = useMemo(() => {
|
const aggregateTasksByColumn = useMemo(() => {
|
||||||
const grouped: Record<string, Task[]> = {};
|
const grouped: Record<string, Task[]> = {};
|
||||||
for (const column of aggregateBoardColumns) grouped[column.id] = [];
|
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) {
|
for (const task of tasks) {
|
||||||
const workflowId = getEffectiveTaskWorkflowId(task);
|
const workflowId = getEffectiveTaskWorkflowId(task);
|
||||||
const workflowColumn = workflowId ? workflowColumnsByWorkflowId.get(workflowId)?.get(task.column) : null;
|
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:
|
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.
|
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);
|
(grouped[task.column] ??= []).push(task);
|
||||||
}
|
}
|
||||||
for (const column of aggregateBoardColumns) {
|
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);
|
: sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType, doneSortMode, column.flags.archived === true);
|
||||||
}
|
}
|
||||||
return grouped;
|
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.
|
// Drag pre-check (R17): adjacency + capacity from the lane's column metadata.
|
||||||
// Cross-lane drag → workflow-mismatch. Deterministic rejections return a
|
// 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);
|
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 () => {
|
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.
|
// 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 }));
|
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");
|
setColumn(store, plannedTask.id, "in-progress");
|
||||||
expect(await isUnplannedForExecution(store, (await store.getTask(plannedTask.id))!, ir.ir)).toBe(false);
|
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);
|
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 () => {
|
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 { 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 { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
|
||||||
import { mergeEffectiveSettings } from "./effective-settings.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 type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
buildWorkflowObservationFromTask,
|
buildWorkflowObservationFromTask,
|
||||||
@@ -4486,15 +4487,20 @@ export class TaskExecutor {
|
|||||||
`Plan Review requested a planning revision before execution.\n\nStatus: ${info.status}\nFeedback:\n${feedback}`,
|
`Plan Review requested a planning revision before execution.\n\nStatus: ${info.status}\nFeedback:\n${feedback}`,
|
||||||
this.getRunContextFor(taskId),
|
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(
|
await this.store.logEntry(
|
||||||
taskId,
|
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),
|
optionalStepRevisionLogOutcome(feedback, revisionKey),
|
||||||
this.getRunContextFor(taskId),
|
this.getRunContextFor(taskId),
|
||||||
);
|
);
|
||||||
if (liveTask.column !== "triage") {
|
await moveTaskToReplanColumn(this.store, { id: taskId, column: liveTask.column }, replanColumn);
|
||||||
await this.store.moveTask(taskId, "triage");
|
|
||||||
}
|
|
||||||
await this.store.updateTask(taskId, {
|
await this.store.updateTask(taskId, {
|
||||||
status: "needs-replan",
|
status: "needs-replan",
|
||||||
error: null,
|
error: null,
|
||||||
@@ -9629,8 +9635,9 @@ export class TaskExecutor {
|
|||||||
const staleness = await evaluateSpecStaleness({ settings, promptPath, task });
|
const staleness = await evaluateSpecStaleness({ settings, promptPath, task });
|
||||||
if (staleness.isStale) {
|
if (staleness.isStale) {
|
||||||
executorLog.warn(`Task ${task.id} specification is stale — ${staleness.reason}`);
|
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
|
// Move to the workflow-aware replan column first, then set status so the task
|
||||||
await this.store.moveTask(task.id, "triage");
|
// 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.updateTask(task.id, { status: "needs-replan" });
|
||||||
await this.store.logEntry(task.id, staleness.reason, undefined, this.getRunContextFor(task.id));
|
await this.store.logEntry(task.id, staleness.reason, undefined, this.getRunContextFor(task.id));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
DEFAULT_WORKFLOW_POOL_ID,
|
DEFAULT_WORKFLOW_POOL_ID,
|
||||||
TransitionRejectionError,
|
TransitionRejectionError,
|
||||||
resolveWorkflowIrForTask,
|
resolveWorkflowIrForTask,
|
||||||
buildBootstrapPrompt,
|
isUnplannedSeedPrompt,
|
||||||
type TaskStore,
|
type TaskStore,
|
||||||
type Task,
|
type Task,
|
||||||
type WorkflowIr,
|
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> {
|
export async function isUnplannedForExecution(store: TaskStore, task: Task, ir: WorkflowIr): Promise<boolean> {
|
||||||
if (task.status === "planning") return true;
|
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 isLegacyTodoColumn = task.column === "todo";
|
||||||
const isIntakeColumn = columnHasIntakeTrait(ir, task.column);
|
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;
|
if (typeof store.getTasksDir !== "function") return false;
|
||||||
try {
|
try {
|
||||||
const promptContent = await readFile(getPromptPath(store.getTasksDir(), task.id), "utf-8");
|
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 {
|
} catch {
|
||||||
// Missing prompt is handled by filesystem validation elsewhere; do not block on it here.
|
// Missing prompt is handled by filesystem validation elsewhere; do not block on it here.
|
||||||
return false;
|
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 { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||||
import { isWorkflowColumnsEnabled, DEFAULT_WORKFLOW_POOL_ID, resolveWorkflowIrForTask } from "@fusion/core";
|
import { isWorkflowColumnsEnabled, DEFAULT_WORKFLOW_POOL_ID, resolveWorkflowIrForTask } from "@fusion/core";
|
||||||
import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } from "./hold-release.js";
|
import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } from "./hold-release.js";
|
||||||
|
import { moveTaskToReplanColumn } from "./replan-target.js";
|
||||||
import { evaluateParkedAgentTaskLink } from "./task-agent-sync.js";
|
import { evaluateParkedAgentTaskLink } from "./task-agent-sync.js";
|
||||||
|
|
||||||
function shouldRunWorkflowColumnScheduler(_settings: Settings): boolean {
|
function shouldRunWorkflowColumnScheduler(_settings: Settings): boolean {
|
||||||
@@ -1654,20 +1655,30 @@ export class Scheduler {
|
|||||||
const validation = await this.validateTaskFilesystem(task.id);
|
const validation = await this.validateTaskFilesystem(task.id);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
schedulerLog.warn(`Task ${task.id} filesystem validation failed: ${validation.reason}`);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stale spec enforcement: check if PROMPT.md has aged beyond the configured threshold.
|
// 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"
|
// When enabled, stale tasks are rebounded to the workflow-aware replan column with
|
||||||
// so they receive fresh specification before execution. This guard runs after
|
// status "needs-replan" so they receive fresh specification before execution
|
||||||
// filesystem validation so missing/unreadable files skip staleness checks entirely.
|
// (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 promptPath = getPromptPath(this.store.getTasksDir(), task.id);
|
||||||
const staleness = await evaluateSpecStaleness({ settings, promptPath, task });
|
const staleness = await evaluateSpecStaleness({ settings, promptPath, task });
|
||||||
if (staleness.isStale) {
|
if (staleness.isStale) {
|
||||||
schedulerLog.warn(`Task ${task.id} specification is stale — ${staleness.reason}`);
|
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.updateTask(task.id, { status: "needs-replan" });
|
||||||
await this.store.logEntry(task.id, staleness.reason);
|
await this.store.logEntry(task.id, staleness.reason);
|
||||||
continue;
|
continue;
|
||||||
@@ -2306,8 +2317,12 @@ export class Scheduler {
|
|||||||
const validation = await this.validateTaskFilesystem(task.id);
|
const validation = await this.validateTaskFilesystem(task.id);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
schedulerLog.warn(`Task ${task.id} filesystem validation failed: ${validation.reason}`);
|
schedulerLog.warn(`Task ${task.id} filesystem validation failed: ${validation.reason}`);
|
||||||
await this.store.moveTask(task.id, "triage");
|
// See the FNXC:WorkflowScheduling 2026-07-13-11:25 note in the legacy loop: the
|
||||||
await this.store.logEntry(task.id, "Task moved to triage — filesystem validation failed", validation.reason);
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2316,7 +2331,7 @@ export class Scheduler {
|
|||||||
const staleness = await evaluateSpecStaleness({ settings, promptPath, task });
|
const staleness = await evaluateSpecStaleness({ settings, promptPath, task });
|
||||||
if (staleness.isStale) {
|
if (staleness.isStale) {
|
||||||
schedulerLog.warn(`Task ${task.id} specification is stale — ${staleness.reason}`);
|
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.updateTask(task.id, { status: "needs-replan" });
|
||||||
await this.store.logEntry(task.id, staleness.reason);
|
await this.store.logEntry(task.id, staleness.reason);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
PLAN_REVIEW_GROUP_ID,
|
PLAN_REVIEW_GROUP_ID,
|
||||||
TaskDeletedError,
|
TaskDeletedError,
|
||||||
buildTriageMemoryInstructions,
|
buildTriageMemoryInstructions,
|
||||||
buildBootstrapPrompt,
|
isUnplannedSeedPrompt,
|
||||||
getTaskDuplicateLineage,
|
getTaskDuplicateLineage,
|
||||||
parseExplicitDuplicateMarker,
|
parseExplicitDuplicateMarker,
|
||||||
resolveAgentPrompt,
|
resolveAgentPrompt,
|
||||||
@@ -798,7 +798,12 @@ export class TriageProcessor {
|
|||||||
&& !(t.nextRecoveryAt && new Date(t.nextRecoveryAt).getTime() > now),
|
&& !(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(
|
const eligibleTodoTasksRaw = allTasks.filter(
|
||||||
(t) => t.column === "todo" && !this.processing.has(t.id) && !t.paused
|
(t) => t.column === "todo" && !this.processing.has(t.id) && !t.paused
|
||||||
@@ -810,10 +815,14 @@ export class TriageProcessor {
|
|||||||
);
|
);
|
||||||
const eligibleTodoTasks: Task[] = [];
|
const eligibleTodoTasks: Task[] = [];
|
||||||
for (const todoTask of eligibleTodoTasksRaw) {
|
for (const todoTask of eligibleTodoTasksRaw) {
|
||||||
|
if (todoTask.status === "needs-replan") {
|
||||||
|
eligibleTodoTasks.push(todoTask);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const promptPath = join(this.rootDir, ".fusion", "tasks", todoTask.id, "PROMPT.md");
|
const promptPath = join(this.rootDir, ".fusion", "tasks", todoTask.id, "PROMPT.md");
|
||||||
const content = await readFile(promptPath, "utf-8");
|
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);
|
eligibleTodoTasks.push(todoTask);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user