diff --git a/.changeset/fn-6952-core-lifecycle-regressions.md b/.changeset/fn-6952-core-lifecycle-regressions.md new file mode 100644 index 0000000000..969f256780 --- /dev/null +++ b/.changeset/fn-6952-core-lifecycle-regressions.md @@ -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. diff --git a/packages/core/src/__tests__/store-create.test.ts b/packages/core/src/__tests__/store-create.test.ts index b55ab812c1..fccb27bfa0 100644 --- a/packages/core/src/__tests__/store-create.test.ts +++ b/packages/core/src/__tests__/store-create.test.ts @@ -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 = ?`) diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 1d21bda1ae..91f5a67382 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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 | 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 { // 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, 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 { 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 { - return isWorkflowColumnsEnabled(await this.getSettingsFast()); + return isWorkflowColumnsCompatibilityFlagEnabled(await this.getSettingsFast()); } /** The active (non-deleted) task ids currently selecting `workflowId`. A diff --git a/packages/core/src/workflow-columns-settings.ts b/packages/core/src/workflow-columns-settings.ts index facf62f73e..4107980439 100644 --- a/packages/core/src/workflow-columns-settings.ts +++ b/packages/core/src/workflow-columns-settings.ts @@ -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 | undefined, diff --git a/packages/engine/src/hold-release.ts b/packages/engine/src/hold-release.ts index c00481d9b2..ef18ff13c4 100644 --- a/packages/engine/src/hold-release.ts +++ b/packages/engine/src/hold-release.ts @@ -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 }); diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index aa67f43071..1aaff91d2c 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -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();