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:
5
.changeset/fn-6952-core-lifecycle-regressions.md
Normal file
5
.changeset/fn-6952-core-lifecycle-regressions.md
Normal 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.
|
||||||
@@ -69,6 +69,12 @@ describe("TaskStore", () => {
|
|||||||
|
|
||||||
describe("startup watch recovery", () => {
|
describe("startup watch recovery", () => {
|
||||||
it("does not crash done-task backfill when a DB row has no task.json mirror", async () => {
|
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" });
|
const task = await store.createTask({ description: "done task with missing mirror" });
|
||||||
(store as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }).db
|
(store as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }).db
|
||||||
.prepare(`UPDATE tasks SET "column" = ?, updatedAt = ? WHERE id = ?`)
|
.prepare(`UPDATE tasks SET "column" = ?, updatedAt = ? WHERE id = ?`)
|
||||||
|
|||||||
@@ -16,8 +16,15 @@ import {
|
|||||||
} from "./moved-settings.js";
|
} from "./moved-settings.js";
|
||||||
import { parseWorkflowIr, serializeWorkflowIr, downgradeIrToV1IfPure } from "./workflow-ir.js";
|
import { parseWorkflowIr, serializeWorkflowIr, downgradeIrToV1IfPure } from "./workflow-ir.js";
|
||||||
import { stepsToWorkflowIr, stepToFragmentIr, layoutForIr } from "./workflow-steps-to-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";
|
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 {
|
import {
|
||||||
type PluginGateVerdict,
|
type PluginGateVerdict,
|
||||||
findWorkflowColumn,
|
findWorkflowColumn,
|
||||||
@@ -1967,7 +1974,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
// no-op for the common case). Idempotent; non-fatal — never blocks startup.
|
// no-op for the common case). Idempotent; non-fatal — never blocks startup.
|
||||||
try {
|
try {
|
||||||
const settings = await this.getSettingsFast();
|
const settings = await this.getSettingsFast();
|
||||||
if (isWorkflowColumnsEnabled(settings)) {
|
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
|
||||||
@@ -3845,7 +3852,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
|||||||
// #1409: if this update flipped workflowColumns ON→OFF, evacuate any card
|
// #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
|
// stranded in a custom (non-legacy) column back to a legacy column so the
|
||||||
// board stays listable / movable on the legacy path.
|
// board stays listable / movable on the legacy path.
|
||||||
if (isWorkflowColumnsEnabled(previousMerged) && !isWorkflowColumnsEnabled(updatedMerged)) {
|
if (isWorkflowColumnsCompatibilityFlagEnabled(previousMerged) && !isWorkflowColumnsCompatibilityFlagEnabled(updatedMerged)) {
|
||||||
try {
|
try {
|
||||||
await this.evacuateCustomColumnsToLegacy("flag-toggled-off");
|
await this.evacuateCustomColumnsToLegacy("flag-toggled-off");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -3965,7 +3972,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
|||||||
// #1409: workflowColumns lives in experimentalFeatures (a global key), so the
|
// #1409: workflowColumns lives in experimentalFeatures (a global key), so the
|
||||||
// ON→OFF toggle flows through here. Evacuate any card stranded in a custom
|
// ON→OFF toggle flows through here. Evacuate any card stranded in a custom
|
||||||
// column when the flag flips off.
|
// column when the flag flips off.
|
||||||
if (isWorkflowColumnsEnabled(previous) && !isWorkflowColumnsEnabled(merged)) {
|
if (isWorkflowColumnsCompatibilityFlagEnabled(previous) && !isWorkflowColumnsCompatibilityFlagEnabled(merged)) {
|
||||||
try {
|
try {
|
||||||
await this.evacuateCustomColumnsToLegacy("flag-toggled-off");
|
await this.evacuateCustomColumnsToLegacy("flag-toggled-off");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -6875,9 +6882,10 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
|||||||
moveSource: NonNullable<MoveTaskOptions["moveSource"]>,
|
moveSource: NonNullable<MoveTaskOptions["moveSource"]>,
|
||||||
options?: MoveTaskOptions,
|
options?: MoveTaskOptions,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
void moveSource;
|
||||||
return options?.recoveryRehome === true ||
|
return options?.recoveryRehome === true ||
|
||||||
(options?.bypassGuards ??
|
(options?.bypassGuards ??
|
||||||
(moveSource === "engine" || moveSource === "scheduler" || options?.skipMergeBlocker === true));
|
(options?.moveSource === "engine" || options?.moveSource === "scheduler" || options?.skipMergeBlocker === true));
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldSkipWorkflowMovePolicies(params: {
|
private shouldSkipWorkflowMovePolicies(params: {
|
||||||
@@ -6901,7 +6909,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
|||||||
const task = await this.readTaskForMove(id);
|
const task = await this.readTaskForMove(id);
|
||||||
const moveSource = options?.moveSource ?? "engine";
|
const moveSource = options?.moveSource ?? "engine";
|
||||||
const mergedSettingsForMove = await this.getSettingsFast();
|
const mergedSettingsForMove = await this.getSettingsFast();
|
||||||
if (!isWorkflowColumnsEnabled(mergedSettingsForMove)) return undefined;
|
if (!isWorkflowColumnsCompatibilityFlagEnabled(mergedSettingsForMove)) return undefined;
|
||||||
if (task.column === toColumn) return undefined;
|
if (task.column === toColumn) return undefined;
|
||||||
|
|
||||||
const workflowIr = this.resolveTaskWorkflowIrSync(id);
|
const workflowIr = this.resolveTaskWorkflowIrSync(id);
|
||||||
@@ -7000,11 +7008,17 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
|||||||
): Promise<Task> {
|
): Promise<Task> {
|
||||||
const dir = this.taskDir(id);
|
const dir = this.taskDir(id);
|
||||||
const task = currentTask ?? await this.readTaskForMove(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";
|
const moveSource = options?.moveSource ?? "engine";
|
||||||
|
|
||||||
// ── U4: flag-gated workflow-resolved transition path (KTD-8) ─────────────
|
// ── U4: flag-gated workflow-resolved transition path (KTD-8) ─────────────
|
||||||
// Flag OFF (default): the legacy `VALID_TRANSITIONS` / inline-side-effect
|
// Flag OFF (default): the legacy `VALID_TRANSITIONS` / inline-side-effect
|
||||||
// path below runs byte-identical (proven by the characterization suite).
|
// 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
|
// Flag ON: validate against the task's resolved workflow column graph, run
|
||||||
// sync trait guards (unless bypassed), and route the legacy per-column side
|
// sync trait guards (unless bypassed), and route the legacy per-column side
|
||||||
// effects through the default-workflow trait hooks.
|
// 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
|
// project) via getSettingsFast(). This is an async read taken before the
|
||||||
// lock-sensitive transaction; it does not touch the task lock.
|
// lock-sensitive transaction; it does not touch the task lock.
|
||||||
const mergedSettingsForMove = await this.getSettingsFast();
|
const mergedSettingsForMove = await this.getSettingsFast();
|
||||||
const useWorkflow = isWorkflowColumnsEnabled(mergedSettingsForMove);
|
const useWorkflow = isWorkflowColumnsCompatibilityFlagEnabled(mergedSettingsForMove);
|
||||||
// bypassGuards (KTD-9): engine-sourced moves + the existing skipMergeBlocker
|
// bypassGuards (KTD-9): engine-sourced moves + the existing skipMergeBlocker
|
||||||
// call sites map onto it. Capacity (KTD-10) is NEVER bypassed by this — the
|
// 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
|
// 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
|
// (a recovery-class move, KTD-9) — never a raw column write — so capacity
|
||||||
// (KTD-10) and the single transition authority (KTD-3) are honored.
|
// (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> {
|
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
|
/** The active (non-deleted) task ids currently selecting `workflowId`. A
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import type { Settings } from "./types.js";
|
import type { Settings } from "./types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workflow columns are now the default task-state model. Stale persisted
|
* Resolve whether workflow-defined columns are active for a settings snapshot.
|
||||||
* `experimentalFeatures.workflowColumns=false` values are ignored so operators
|
|
||||||
* cannot fall back to the retired legacy column runtime through settings.
|
|
||||||
*
|
*
|
||||||
* FNXC:WorkflowColumns 2026-06-22-18:00:
|
* FNXC:WorkflowColumns 2026-06-22-18:00:
|
||||||
* The workflow column model graduated from Experimental alongside the graph
|
* 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.
|
||||||
* engine. Keep this accessor as a compatibility seam for existing call sites,
|
|
||||||
* but make it unconditional until the legacy branches are removed.
|
|
||||||
*/
|
*/
|
||||||
export function isWorkflowColumnsEnabled(
|
export function isWorkflowColumnsEnabled(
|
||||||
_settings: Pick<Settings, "experimentalFeatures"> | undefined,
|
_settings: Pick<Settings, "experimentalFeatures"> | undefined,
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isWorkflowColumnsEnabled,
|
|
||||||
resolveColumnCapacity,
|
resolveColumnCapacity,
|
||||||
resolveColumnFlags,
|
resolveColumnFlags,
|
||||||
resolveColumnAdjacency,
|
resolveColumnAdjacency,
|
||||||
@@ -299,7 +298,10 @@ export async function runHoldReleaseSweep(
|
|||||||
const result: HoldReleaseResult = { released: [], held: [] };
|
const result: HoldReleaseResult = { released: [], held: [] };
|
||||||
|
|
||||||
const settings = await store.getSettings();
|
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 });
|
const allTasks = await store.listTasks({ includeArchived: false });
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
|||||||
import { isWorkflowColumnsEnabled, DEFAULT_WORKFLOW_POOL_ID } from "@fusion/core";
|
import { isWorkflowColumnsEnabled, DEFAULT_WORKFLOW_POOL_ID } from "@fusion/core";
|
||||||
import { runHoldReleaseSweep, type SlotReservation } from "./hold-release.js";
|
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.
|
* 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.
|
* 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:
|
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.
|
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);
|
await this.runHoldReleaseSweepPass(tasks, settings);
|
||||||
tasks = await this.store.listTasks({ slim: true, includeArchived: false, startupMemo: false });
|
tasks = await this.store.listTasks({ slim: true, includeArchived: false, startupMemo: false });
|
||||||
settings = await this.store.getSettings();
|
settings = await this.store.getSettings();
|
||||||
|
|||||||
Reference in New Issue
Block a user