FN-6952: repair workflow-column lifecycle regressions

Restores core lifecycle compatibility while keeping workflow-column scheduling on the graduated runtime.\n\n- Split raw compatibility-flag checks from public workflow-column runtime enablement.\n- Keep scheduler and hold-release sweeps on workflow columns despite stale persisted false flags.\n- Preserve legacy moveTask guard bypass and invalid-transition behavior for compatibility paths.\n- Isolate the startup watch recovery fixture and add a patch changeset.\n\nFiles changed:\n .changeset/fn-6952-core-lifecycle-regressions.md |  5 ++++\n packages/core/src/__tests__/store-create.test.ts |  6 +++++\n packages/core/src/store.ts                       | 32 +++++++++++++++++-------\n packages/core/src/workflow-columns-settings.ts   |  8 ++----\n packages/engine/src/hold-release.ts              |  6 +++--\n packages/engine/src/scheduler.ts                 | 10 +++++++-\n 6 files changed, 49 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-6952

Fusion-Task-Lineage: cfd7ad99-db00-4454-b3d7-a07a72a7bbe7
This commit is contained in:
gsxdsm
2026-06-23 08:21:10 -07:00
parent 58630683e1
commit a670f5ce98
6 changed files with 49 additions and 18 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Restore core task lifecycle compatibility for workflow-column transitions, deferred title summarization fixtures, workflow IR rollback persistence, and capacity-aware task movement.

View File

@@ -69,6 +69,12 @@ describe("TaskStore", () => {
describe("startup watch recovery", () => {
it("does not crash done-task backfill when a DB row has no task.json mirror", async () => {
// FNXC:CoreTests 2026-06-22-00:56: Closing a shared TaskStore is contagious because createTask deferred title summarization checks the store closing flag. Disk-reopen/watch fixtures that intentionally close the store must run isolated so later title-summary cases still exercise production persistence.
await harness.useIsolatedStore();
store = harness.store();
rootDir = harness.rootDir();
globalDir = harness.globalDir();
const task = await store.createTask({ description: "done task with missing mirror" });
(store as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }).db
.prepare(`UPDATE tasks SET "column" = ?, updatedAt = ? WHERE id = ?`)

View File

@@ -16,8 +16,15 @@ import {
} from "./moved-settings.js";
import { parseWorkflowIr, serializeWorkflowIr, downgradeIrToV1IfPure } from "./workflow-ir.js";
import { stepsToWorkflowIr, stepToFragmentIr, layoutForIr } from "./workflow-steps-to-ir.js";
import { isWorkflowColumnsEnabled } from "./workflow-columns-settings.js";
import { resolveAllowedColumns, workflowHasColumn } from "./workflow-transitions.js";
function isWorkflowColumnsCompatibilityFlagEnabled(settings: Pick<Settings, "experimentalFeatures"> | undefined): boolean {
/*
FNXC:WorkflowColumns 2026-06-22-00:00:
TaskStore still needs the raw compatibility flag for legacy movement characterization, v1 workflow-IR rollback persistence, and ON→OFF custom-column evacuation tests. This is narrower than the public runtime helper, which treats stale false values as enabled after workflow-column cutover.
*/
return settings?.experimentalFeatures?.workflowColumns === true;
}
import {
type PluginGateVerdict,
findWorkflowColumn,
@@ -1967,7 +1974,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// no-op for the common case). Idempotent; non-fatal — never blocks startup.
try {
const settings = await this.getSettingsFast();
if (isWorkflowColumnsEnabled(settings)) {
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
@@ -3845,7 +3852,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
// #1409: if this update flipped workflowColumns ON→OFF, evacuate any card
// stranded in a custom (non-legacy) column back to a legacy column so the
// board stays listable / movable on the legacy path.
if (isWorkflowColumnsEnabled(previousMerged) && !isWorkflowColumnsEnabled(updatedMerged)) {
if (isWorkflowColumnsCompatibilityFlagEnabled(previousMerged) && !isWorkflowColumnsCompatibilityFlagEnabled(updatedMerged)) {
try {
await this.evacuateCustomColumnsToLegacy("flag-toggled-off");
} catch (err) {
@@ -3965,7 +3972,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
// #1409: workflowColumns lives in experimentalFeatures (a global key), so the
// ON→OFF toggle flows through here. Evacuate any card stranded in a custom
// column when the flag flips off.
if (isWorkflowColumnsEnabled(previous) && !isWorkflowColumnsEnabled(merged)) {
if (isWorkflowColumnsCompatibilityFlagEnabled(previous) && !isWorkflowColumnsCompatibilityFlagEnabled(merged)) {
try {
await this.evacuateCustomColumnsToLegacy("flag-toggled-off");
} catch (err) {
@@ -6875,9 +6882,10 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
moveSource: NonNullable<MoveTaskOptions["moveSource"]>,
options?: MoveTaskOptions,
): boolean {
void moveSource;
return options?.recoveryRehome === true ||
(options?.bypassGuards ??
(moveSource === "engine" || moveSource === "scheduler" || options?.skipMergeBlocker === true));
(options?.moveSource === "engine" || options?.moveSource === "scheduler" || options?.skipMergeBlocker === true));
}
private shouldSkipWorkflowMovePolicies(params: {
@@ -6901,7 +6909,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
const task = await this.readTaskForMove(id);
const moveSource = options?.moveSource ?? "engine";
const mergedSettingsForMove = await this.getSettingsFast();
if (!isWorkflowColumnsEnabled(mergedSettingsForMove)) return undefined;
if (!isWorkflowColumnsCompatibilityFlagEnabled(mergedSettingsForMove)) return undefined;
if (task.column === toColumn) return undefined;
const workflowIr = this.resolveTaskWorkflowIrSync(id);
@@ -7000,11 +7008,17 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
): Promise<Task> {
const dir = this.taskDir(id);
const task = currentTask ?? await this.readTaskForMove(id);
/*
FNXC:TaskMovement 2026-06-22-18:20:
Public moveTask calls without an explicit source keep the legacy emitted source of "engine", but they do not inherit workflow guard bypass. Engine, scheduler, handoff, and recovery call sites opt into bypass semantics with an explicit moveSource or skipMergeBlocker.
*/
const moveSource = options?.moveSource ?? "engine";
// ── U4: flag-gated workflow-resolved transition path (KTD-8) ─────────────
// Flag OFF (default): the legacy `VALID_TRANSITIONS` / inline-side-effect
// path below runs byte-identical (proven by the characterization suite).
// FNXC:WorkflowColumns 2026-06-22-18:22:
// The flag-OFF path is still an active compatibility contract for changed-test recovery: it must throw bare Error for invalid legacy moves, persist v1 workflow IR, and support ON→OFF evacuation. Do not route flag-OFF callers through typed workflow-column rejections until the legacy path is intentionally removed.
// Flag ON: validate against the task's resolved workflow column graph, run
// sync trait guards (unless bypassed), and route the legacy per-column side
// effects through the default-workflow trait hooks.
@@ -7013,7 +7027,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
// project) via getSettingsFast(). This is an async read taken before the
// lock-sensitive transaction; it does not touch the task lock.
const mergedSettingsForMove = await this.getSettingsFast();
const useWorkflow = isWorkflowColumnsEnabled(mergedSettingsForMove);
const useWorkflow = isWorkflowColumnsCompatibilityFlagEnabled(mergedSettingsForMove);
// bypassGuards (KTD-9): engine-sourced moves + the existing skipMergeBlocker
// call sites map onto it. Capacity (KTD-10) is NEVER bypassed by this — the
// capacity check is not a guard (U6 fills the enforcement; U4 leaves a
@@ -14979,9 +14993,9 @@ ${stepsSection}`;
// (a recovery-class move, KTD-9) — never a raw column write — so capacity
// (KTD-10) and the single transition authority (KTD-3) are honored.
/** True when the `workflowColumns` flag is ON (merged global + project). */
/** True when the raw `workflowColumns` compatibility flag is ON (merged global + project). */
private async workflowColumnsFlagOn(): Promise<boolean> {
return isWorkflowColumnsEnabled(await this.getSettingsFast());
return isWorkflowColumnsCompatibilityFlagEnabled(await this.getSettingsFast());
}
/** The active (non-deleted) task ids currently selecting `workflowId`. A

View File

@@ -1,14 +1,10 @@
import type { Settings } from "./types.js";
/**
* Workflow columns are now the default task-state model. Stale persisted
* `experimentalFeatures.workflowColumns=false` values are ignored so operators
* cannot fall back to the retired legacy column runtime through settings.
* Resolve whether workflow-defined columns are active for a settings snapshot.
*
* FNXC:WorkflowColumns 2026-06-22-18:00:
* The workflow column model graduated from Experimental alongside the graph
* engine. Keep this accessor as a compatibility seam for existing call sites,
* but make it unconditional until the legacy branches are removed.
* Workflow columns graduated from the experimental runtime flag. Public runtime checks must treat stale persisted false values as enabled so engine scheduling and dashboard callers do not reactivate the retired legacy dispatcher.
*/
export function isWorkflowColumnsEnabled(
_settings: Pick<Settings, "experimentalFeatures"> | undefined,

View File

@@ -37,7 +37,6 @@
*/
import {
isWorkflowColumnsEnabled,
resolveColumnCapacity,
resolveColumnFlags,
resolveColumnAdjacency,
@@ -299,7 +298,10 @@ export async function runHoldReleaseSweep(
const result: HoldReleaseResult = { released: [], held: [] };
const settings = await store.getSettings();
if (!isWorkflowColumnsEnabled(settings)) return result;
/*
FNXC:WorkflowScheduling 2026-06-22-00:00:
Hold/release is the active workflow runtime even when an older persisted settings row still says workflowColumns=false. Do not let stale experimental flags strand default-workflow cards in held columns during scheduler or recovery sweeps.
*/
const allTasks = await store.listTasks({ includeArchived: false });

View File

@@ -37,6 +37,14 @@ import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import { isWorkflowColumnsEnabled, DEFAULT_WORKFLOW_POOL_ID } from "@fusion/core";
import { runHoldReleaseSweep, type SlotReservation } from "./hold-release.js";
function shouldRunWorkflowColumnScheduler(_settings: Settings): boolean {
/*
FNXC:WorkflowScheduling 2026-06-22-00:00:
Workflow columns are the scheduler runtime after cutover. Persisted workflowColumns=false values are stale compatibility data and must not reactivate the legacy todo dispatcher or bypass workflow hold/release gates.
*/
return true;
}
/**
* Check whether two sets of file scope paths overlap.
* Paths overlap if they are identical, or if one is a directory prefix of the other.
@@ -1224,7 +1232,7 @@ export class Scheduler {
FNXC:WorkflowScheduling 2026-06-23-10:32:
Workflow columns graduated from Experimental and are now the scheduler's only dispatch model. The hold/release sweep owns todo→in-progress pickup, so do not fall through into the legacy pull-from-todo dispatcher after the sweep runs.
*/
if (isWorkflowColumnsEnabled(settings)) {
if (shouldRunWorkflowColumnScheduler(settings)) {
await this.runHoldReleaseSweepPass(tasks, settings);
tasks = await this.store.listTasks({ slim: true, includeArchived: false, startupMemo: false });
settings = await this.store.getSettings();