diff --git a/.changeset/fn-6971-workflow-cutover-readiness.md b/.changeset/fn-6971-workflow-cutover-readiness.md new file mode 100644 index 0000000000..40f8016925 --- /dev/null +++ b/.changeset/fn-6971-workflow-cutover-readiness.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Retire dual-observe as a workflow-authoritative cutover prerequisite. +category: fix +dev: Cutover readiness now uses the authoritative flag plus clean populated parity summaries; stale dual-observe settings remain inert. diff --git a/docs/architecture.md b/docs/architecture.md index 6658f7ba97..37f5c46a9b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1866,7 +1866,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in - FN-5741 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-shadow-handoff.test.ts` guards Phase-1 merge-request contract shadow writes: flag OFF is a no-op, flag ON writes marker/record strictly after legacy handoff, and `autoMerge:false` remains `manual-required` without shadow running transitions. - FN-5742 backstop: `packages/engine/src/__tests__/reliability-interactions/dual-observe-merge-seam.test.ts` guards Phase-2 dual-observe invariants: legacy dependency satisfaction remains authoritative while parity diffs emit, and shadow dequeue selection never advances `manual-required` rows. - FN-5743 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-cancel-on-hard-cancel.test.ts` and `packages/core/src/__tests__/merge-request-record.test.ts` guard Phase-3 cutover invariants: transient merge retries mutate merge-request state (no column rebound), user hard-cancel after accepted handoff cancels pending merge requests, and non-user rebounds preserve legacy fail-soft semantics. -- FN-5770 backstop: `packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-cutover.test.ts` guards the interpreter-authoritative lifecycle seam. The cutover remains opt-in (`workflowInterpreterAuthoritative` default OFF), readiness-gated by dual-observe parity evidence, reversible by flipping one flag back OFF, and must preserve file-scope, squash-overlap, `autoMerge:false`, hard-cancel, and self-healing interaction invariants. +- FN-5770 backstop: `packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-cutover.test.ts` guards the interpreter-authoritative lifecycle seam. The cutover remains opt-in (`workflowInterpreterAuthoritative` default OFF), readiness-gated by clean populated parity summary evidence while retired `workflowInterpreterDualObserve` settings stay inert, reversible by flipping one flag back OFF, and must preserve file-scope, squash-overlap, `autoMerge:false`, hard-cancel, and self-healing interaction invariants. - FN-5337 backstop: `packages/engine/src/__tests__/reliability-interactions/orphan-detected-no-requeue.test.ts` locks observation-only orphan detection across FN-5279 repro metadata desync, worktree-present and worktree-missing candidates, FN-5219 ordering, FN-5147 in-review isolation, FN-5083 branch-cleared composition, lease-manager non-invocation, and per-sweep idempotent audit emission. - FN-5256 backstop: `packages/engine/src/__tests__/reliability-interactions/dependency-cycle-reconcile.test.ts` covers persisted dependency-cycle detection via `reconcileDependencyCycles`, bounded umbrella-back-edge auto-repair, ambiguous-cycle observe-only behavior, composition ordering with `reconcileSelfDefeatingDependencies`, and the post-sweep write-time guard invariant. Core write-boundary regressions (FN-5240/5241/5242 signature, indirect cycle, umbrella back-edge rejection) live in `packages/core/src/__tests__/store-dependency-cycle.test.ts`. - FN-5223 backstop: `packages/engine/src/__tests__/reliability-interactions/engine-active-since-floor.test.ts` covers engine-activation floor + grace composition across startup, pause/unpause, global-pause gating, and StuckTaskDetector lifecycle interactions. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index c7676be5c2..28f96089b7 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1306,8 +1306,8 @@ Common built-in dashboard/runtime flags include: - `researchView` - `evalsView` (gates Evals dashboard view, Settings → Scheduled Evals section, and scheduled-eval cron execution) - `workflowGraphExecutor` (enables the workflow-IR interpreter path) -- `workflowInterpreterDualObserve` (observe-only parity instrumentation for interpreter rollout) -- `workflowInterpreterAuthoritative` (readiness-gated authoritative interpreter lifecycle cutover; legacy remains default/fallback when OFF) +- `workflowInterpreterDualObserve` (retired/inert; stale persisted `true` values are forced OFF and must not reactivate hidden shadow observation) +- `workflowInterpreterAuthoritative` (readiness-gated authoritative interpreter lifecycle cutover; requires clean populated parity summary evidence and legacy remains default/fallback when OFF) - `remoteAccess` - `agentOnboarding` (enables the **AI Interview** option inside the New Agent dialog) diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 369eecc21d..530b612129 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -501,23 +501,22 @@ For pre-merge workflow hard failures, executor behavior is (gate-mode steps): Tasks are not parked in `in-review` for this remediable path unless additional terminal failures occur. -## Workflow Interpreter Dual-Observe (parity instrumentation) +## Workflow Interpreter Dual-Observe (retired parity instrumentation) -Fusion now enables the workflow interpreter parity seam by default as part of the workflow rollout. +The workflow interpreter dual-observe seam is retired. `experimentalFeatures.workflowInterpreterDualObserve` is now inert: runtime feature helpers force it OFF even when stale persisted settings contain `true`, and Fusion must not invisibly re-enable shadow interpreter observation. -- **Flag:** `experimentalFeatures.workflowInterpreterDualObserve` (default ON) -- **Mode:** observe-only shadow run; legacy executor/reviewer/merger/scheduler path remains authoritative unless the authoritative cutover guard passes -- **Behavior when OFF:** strict no-op (no shadow run, no parity audit records) -- **Behavior when ON (default):** compare legacy and interpreter observations plus comparable run-audit slices +- **Flag:** `experimentalFeatures.workflowInterpreterDualObserve` (retired; forced OFF) +- **Mode:** strict no-op (no shadow run, no parity audit records) +- **Historical behavior:** earlier rollout builds compared legacy and interpreter observations plus comparable run-audit slices and emitted the parity audit records below -Run-audit events emitted in `database` domain: +Historical run-audit events in the `database` domain: -- `workflow:parity-observed` — always emitted for an enabled parity check with `metadata.agree` -- `workflow:parity-drift` — emitted when parity differs (or shadow execution fails), carrying `metadata.diffs` +- `workflow:parity-observed` — emitted for an enabled parity check with `metadata.agree` +- `workflow:parity-drift` — emitted when parity differed (or shadow execution failed), carrying `metadata.diffs` The parity contract is exported from `@fusion/core` (`compareWorkflowRunObservations`, `compareWorkflowRunAudits`) and produces deterministic drift reports shaped as `{ agree, diffs[] }`, where each diff includes field name, legacy/interpreter values, category, and severity. -Dual-observe remains the rollout evidence path for the later authoritative cutover: the interpreter may only become authoritative when the separate `experimentalFeatures.workflowInterpreterAuthoritative` flag is ON **and** the cutover-readiness guard reports zero unresolved parity drift. +Authoritative cutover now depends on existing/current parity summary evidence, not on re-enabling dual-observe. The interpreter may become authoritative only when the separate `experimentalFeatures.workflowInterpreterAuthoritative` flag is ON and the cutover-readiness guard sees a populated parity summary with enough observed runs, zero summary drift, and zero unresolved parity reports. #### Self-healing recovery for parked review tasks diff --git a/packages/core/src/__tests__/workflow-cutover.test.ts b/packages/core/src/__tests__/workflow-cutover.test.ts index 246bc7192d..7816dfdde3 100644 --- a/packages/core/src/__tests__/workflow-cutover.test.ts +++ b/packages/core/src/__tests__/workflow-cutover.test.ts @@ -3,10 +3,9 @@ import { describe, expect, it } from "vitest"; import { evaluateInterpreterCutoverReadiness } from "../workflow-cutover.js"; describe("workflow interpreter authoritative cutover readiness", () => { - it("is ready when the cutover flag, dual-observe, and clean parity evidence are present", () => { + it("is ready when the cutover flag and clean parity evidence are present", () => { const result = evaluateInterpreterCutoverReadiness({ authoritativeFlagEnabled: true, - dualObserveEnabled: true, paritySummary: { observed: 5, drift: 0, recentDrift: [] }, minimumObservedRuns: 3, }); @@ -14,25 +13,32 @@ describe("workflow interpreter authoritative cutover readiness", () => { expect(result).toEqual({ ready: true, reasons: [] }); }); - it("enumerates every failed criterion deterministically", () => { + it("enumerates every failed active criterion deterministically", () => { const result = evaluateInterpreterCutoverReadiness({ authoritativeFlagEnabled: false, - dualObserveEnabled: false, paritySummary: null, }); expect(result.ready).toBe(false); expect(result.reasons).toEqual([ "experimentalFeatures.workflowInterpreterAuthoritative is disabled", - "experimentalFeatures.workflowInterpreterDualObserve is disabled", "workflow parity summary unavailable", ]); }); + it("does not require the retired dual-observe flag when clean parity evidence exists", () => { + const result = evaluateInterpreterCutoverReadiness({ + authoritativeFlagEnabled: true, + paritySummary: { observed: 3, drift: 0, recentDrift: [] }, + minimumObservedRuns: 3, + }); + + expect(result).toEqual({ ready: true, reasons: [] }); + }); + it("blocks when parity drift is present in the summary or unresolved drift reports", () => { const result = evaluateInterpreterCutoverReadiness({ authoritativeFlagEnabled: true, - dualObserveEnabled: true, paritySummary: { observed: 8, drift: 2, @@ -55,7 +61,6 @@ describe("workflow interpreter authoritative cutover readiness", () => { it("normalizes minimum observed runs to at least one", () => { const result = evaluateInterpreterCutoverReadiness({ authoritativeFlagEnabled: true, - dualObserveEnabled: true, paritySummary: { observed: 0, drift: 0, recentDrift: [] }, minimumObservedRuns: 0, }); diff --git a/packages/core/src/workflow-cutover.ts b/packages/core/src/workflow-cutover.ts index 8b793eed74..2326aa38d0 100644 --- a/packages/core/src/workflow-cutover.ts +++ b/packages/core/src/workflow-cutover.ts @@ -10,8 +10,6 @@ export const WORKFLOW_INTERPRETER_AUTHORITATIVE_FLAG = "workflowInterpreterAutho export interface InterpreterCutoverReadinessInput { /** Explicit operator opt-in; default runtime remains legacy when false. */ authoritativeFlagEnabled: boolean; - /** The dual-observe rollout must stay enabled/proven before cutover. */ - dualObserveEnabled: boolean; /** Aggregated parity signal from the audit trail (for example `store.getWorkflowParitySummary()`). */ paritySummary?: Pick | null; /** Optional unresolved drift reports surfaced directly by the caller. */ @@ -40,6 +38,9 @@ function countUnresolvedDriftReports( /** * Pure rollout-readiness guard for the interpreter-authoritative cutover. * Callers supply explicit parity evidence; this function performs no I/O. + * + * FNXC:WorkflowInterpreterCutover 2026-06-23-21:58: + * workflowInterpreterDualObserve is retired and inert. Authoritative cutover must use the explicit authoritative flag plus clean populated parity summaries as evidence, without reactivating hidden shadow observation. */ export function evaluateInterpreterCutoverReadiness( input: InterpreterCutoverReadinessInput, @@ -51,10 +52,6 @@ export function evaluateInterpreterCutoverReadiness( reasons.push("experimentalFeatures.workflowInterpreterAuthoritative is disabled"); } - if (!input.dualObserveEnabled) { - reasons.push("experimentalFeatures.workflowInterpreterDualObserve is disabled"); - } - const paritySummary = input.paritySummary; if (!paritySummary) { reasons.push("workflow parity summary unavailable"); diff --git a/packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-cutover.test.ts b/packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-cutover.test.ts index 74d3a45e76..f1c9b65759 100644 --- a/packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-cutover.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-cutover.test.ts @@ -1,12 +1,18 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { DEFAULT_SETTINGS, type Settings, type TaskDetail } from "@fusion/core"; +import { + DEFAULT_SETTINGS, + isExperimentalFeatureEnabled, + type Settings, + type TaskDetail, +} from "@fusion/core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { assertSquashOverlapsFileScope, FileScopeViolationError } from "../../merger.js"; import type { WorkflowLegacySeams } from "../../workflow-node-handlers.js"; import { WorkflowAuthoritativeDriver } from "../../workflow-authoritative-driver.js"; +import { observeWorkflowParity } from "../../workflow-parity-observer.js"; import { git, hasGit, makeReliabilityFixture } from "./_helpers.js"; const readyParity = { @@ -41,13 +47,15 @@ function createStore(options: { settings?: Settings; selection?: { workflowId: string; stepIds: string[] } | undefined; task?: TaskDetail; - paritySummary?: typeof readyParity | undefined; + paritySummary?: typeof readyParity | null; } = {}) { return { - getSettings: vi.fn(async () => options.settings ?? settingsWith({ workflowInterpreterAuthoritative: true, workflowInterpreterDualObserve: true })), + getSettings: vi.fn(async () => options.settings ?? settingsWith({ workflowInterpreterAuthoritative: true })), getTask: vi.fn(async () => options.task ?? baseTask), getTaskWorkflowSelection: vi.fn(() => options.selection), - getWorkflowParitySummary: vi.fn(() => options.paritySummary ?? readyParity), + getWorkflowParitySummary: vi.fn(() => ( + options.paritySummary === null ? undefined : options.paritySummary ?? readyParity + )), }; } @@ -60,7 +68,7 @@ function createExecutor(seams: WorkflowLegacySeams) { describe("workflow interpreter authoritative cutover", () => { it("is a strict no-op when the cutover flag is off", async () => { const store = createStore({ - settings: settingsWith({ workflowInterpreterAuthoritative: false, workflowInterpreterDualObserve: true }), + settings: settingsWith({ workflowInterpreterAuthoritative: false }), }); const executor = createExecutor({ planning: vi.fn(async () => ({ outcome: "success" as const })), @@ -77,6 +85,25 @@ describe("workflow interpreter authoritative cutover", () => { expect(executor.createAuthoritativeWorkflowSeams).not.toHaveBeenCalled(); }); + it("falls back when parity summary is missing even if the cutover flag is on", async () => { + const store = createStore({ + paritySummary: null, + }); + const executor = createExecutor({ + planning: vi.fn(async () => ({ outcome: "success" as const })), + execute: vi.fn(async () => ({ outcome: "success" as const })), + review: vi.fn(async () => ({ outcome: "success" as const })), + merge: vi.fn(async () => ({ outcome: "success" as const })), + schedule: vi.fn(async () => ({ outcome: "success" as const })), + }); + + const result = await new WorkflowAuthoritativeDriver({ store, executor }).maybeRun(baseTask as any); + + expect(result.handled).toBe(false); + expect(result.reason).toContain("workflow parity summary unavailable"); + expect(executor.createAuthoritativeWorkflowSeams).not.toHaveBeenCalled(); + }); + it("falls back when readiness fails even if the cutover flag is on", async () => { const store = createStore({ paritySummary: { ...readyParity, observed: 4, drift: 1 }, @@ -122,6 +149,46 @@ describe("workflow interpreter authoritative cutover", () => { expect(calls).toEqual(["execute", "review", "merge"]); }); + it("keeps stale dual-observe settings inert while clean parity authorizes seams", async () => { + const runShadow = vi.fn(async () => ({ observation: {} as any, auditEvents: [] })); + await observeWorkflowParity({ + settings: settingsWith({ workflowInterpreterDualObserve: true }), + store: { recordRunAuditEvent: vi.fn() }, + agentId: "agent-test", + legacy: { taskId: baseTask.id, observation: {} as any, auditEvents: [] }, + runShadow, + }); + expect( + isExperimentalFeatureEnabled( + settingsWith({ workflowInterpreterDualObserve: true }), + "workflowInterpreterDualObserve", + ), + ).toBe(false); + expect(runShadow).not.toHaveBeenCalled(); + + const execute = vi.fn(async () => ({ outcome: "success" as const })); + const executor = createExecutor({ + planning: async () => ({ outcome: "success" as const }), + execute, + review: async () => ({ outcome: "success" as const }), + merge: async () => ({ outcome: "success" as const }), + schedule: async () => ({ outcome: "success" as const }), + }); + + const result = await new WorkflowAuthoritativeDriver({ + store: createStore({ + settings: settingsWith({ + workflowInterpreterAuthoritative: true, + workflowInterpreterDualObserve: true, + }), + }), + executor, + }).maybeRun(baseTask as any); + + expect(result.handled).toBe(true); + expect(execute).toHaveBeenCalledTimes(1); + }); + it("keeps autoMerge:false tasks terminal in review by stopping before merge", async () => { const merge = vi.fn(async () => ({ outcome: "success" as const })); const executor = createExecutor({ @@ -133,7 +200,9 @@ describe("workflow interpreter authoritative cutover", () => { }); const result = await new WorkflowAuthoritativeDriver({ - store: createStore({ settings: settingsWith({ workflowInterpreterAuthoritative: true, workflowInterpreterDualObserve: true }, { autoMerge: false }) }), + store: createStore({ + settings: settingsWith({ workflowInterpreterAuthoritative: true }, { autoMerge: false }), + }), executor, }).maybeRun(baseTask as any); @@ -186,7 +255,7 @@ describe("workflow interpreter authoritative cutover", () => { }); it("immediately rolls back to legacy when the cutover flag is flipped back off", async () => { - let settings = settingsWith({ workflowInterpreterAuthoritative: true, workflowInterpreterDualObserve: true }); + let settings = settingsWith({ workflowInterpreterAuthoritative: true }); const store = createStore(); store.getSettings.mockImplementation(async () => settings); const executor = createExecutor({ @@ -199,7 +268,7 @@ describe("workflow interpreter authoritative cutover", () => { const driver = new WorkflowAuthoritativeDriver({ store, executor }); const first = await driver.maybeRun(baseTask as any); - settings = settingsWith({ workflowInterpreterAuthoritative: false, workflowInterpreterDualObserve: true }); + settings = settingsWith({ workflowInterpreterAuthoritative: false }); const second = await driver.maybeRun(baseTask as any); expect(first.handled).toBe(true); @@ -251,7 +320,7 @@ describeIfGit("workflow interpreter authoritative cutover + file-scope invariant const driver = new WorkflowAuthoritativeDriver({ store: { - getSettings: async () => settingsWith({ workflowInterpreterAuthoritative: true, workflowInterpreterDualObserve: true }), + getSettings: async () => settingsWith({ workflowInterpreterAuthoritative: true }), getTask: (taskId) => fx.store.getTask(taskId) as Promise, getTaskWorkflowSelection: () => undefined, getWorkflowParitySummary: () => readyParity, diff --git a/packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-dual-observe.test.ts b/packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-dual-observe.test.ts index e2c491f0c8..fcc57f3793 100644 --- a/packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-dual-observe.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-dual-observe.test.ts @@ -40,8 +40,9 @@ describe("FN-5768 workflow interpreter dual-observe", () => { expect(recordRunAuditEvent).not.toHaveBeenCalled(); }); - it("records parity-observed agree=true when observations match", async () => { + it("keeps retired persisted true values inert instead of shadow-observing", async () => { const recordRunAuditEvent = vi.fn().mockResolvedValue(undefined); + const runShadow = vi.fn(async () => ({ observation: baseObservation, auditEvents: [] })); await observeWorkflowParity({ settings: { experimentalFeatures: { [WORKFLOW_INTERPRETER_DUAL_OBSERVE_FLAG]: true } }, @@ -52,21 +53,23 @@ describe("FN-5768 workflow interpreter dual-observe", () => { observation: baseObservation, auditEvents: [], }, - runShadow: async () => ({ observation: baseObservation, auditEvents: [] }), + runShadow, }); - expect(recordRunAuditEvent).toHaveBeenCalledTimes(1); - expect(recordRunAuditEvent).toHaveBeenCalledWith( - expect.objectContaining({ - mutationType: "workflow:parity-observed", - metadata: expect.objectContaining({ agree: true }), - }), - ); + expect(runShadow).not.toHaveBeenCalled(); + expect(recordRunAuditEvent).not.toHaveBeenCalled(); }); - it("records parity drift and keeps authoritative result unchanged", async () => { + it("does not run a drift shadow when the retired flag is stale true", async () => { const recordRunAuditEvent = vi.fn().mockResolvedValue(undefined); const legacyResult = { authoritative: true }; + const runShadow = vi.fn(async () => ({ + observation: { + ...baseObservation, + terminalColumn: "in-review", + }, + auditEvents: [], + })); await observeWorkflowParity({ settings: { experimentalFeatures: { [WORKFLOW_INTERPRETER_DUAL_OBSERVE_FLAG]: true } }, @@ -77,35 +80,19 @@ describe("FN-5768 workflow interpreter dual-observe", () => { observation: baseObservation, auditEvents: [], }, - runShadow: async () => ({ - observation: { - ...baseObservation, - terminalColumn: "in-review", - }, - auditEvents: [], - }), + runShadow, }); expect(legacyResult).toEqual({ authoritative: true }); - expect(recordRunAuditEvent).toHaveBeenCalledTimes(2); - expect(recordRunAuditEvent).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ mutationType: "workflow:parity-observed" }), - ); - expect(recordRunAuditEvent).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - mutationType: "workflow:parity-drift", - metadata: expect.objectContaining({ - agree: false, - diffs: expect.arrayContaining([expect.objectContaining({ field: "terminalColumn" })]), - }), - }), - ); + expect(runShadow).not.toHaveBeenCalled(); + expect(recordRunAuditEvent).not.toHaveBeenCalled(); }); - it("captures shadow errors fail-soft without rethrow", async () => { + it("does not execute stale shadow callbacks that would fail", async () => { const recordRunAuditEvent = vi.fn().mockResolvedValue(undefined); + const runShadow = vi.fn(async () => { + throw new Error("shadow exploded"); + }); await expect( observeWorkflowParity({ @@ -117,28 +104,11 @@ describe("FN-5768 workflow interpreter dual-observe", () => { observation: baseObservation, auditEvents: [], }, - runShadow: async () => { - throw new Error("shadow exploded"); - }, + runShadow, }), ).resolves.toBeUndefined(); - expect(recordRunAuditEvent).toHaveBeenCalledTimes(2); - expect(recordRunAuditEvent).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - mutationType: "workflow:parity-observed", - metadata: expect.objectContaining({ agree: false }), - }), - ); - expect(recordRunAuditEvent).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - mutationType: "workflow:parity-drift", - metadata: expect.objectContaining({ - diffs: expect.arrayContaining([expect.objectContaining({ field: "shadow.error" })]), - }), - }), - ); + expect(runShadow).not.toHaveBeenCalled(); + expect(recordRunAuditEvent).not.toHaveBeenCalled(); }); }); diff --git a/packages/engine/src/__tests__/stepwise-workflow-parity.test.ts b/packages/engine/src/__tests__/stepwise-workflow-parity.test.ts index ca881442e6..5dd23ea289 100644 --- a/packages/engine/src/__tests__/stepwise-workflow-parity.test.ts +++ b/packages/engine/src/__tests__/stepwise-workflow-parity.test.ts @@ -513,10 +513,12 @@ describe("stepwise workflow parity (U7 / KTD-9)", () => { // ── Flag posture (R10) ───────────────────────────────────────────────────── - it("flag pinned-at-dispatch: flag OFF → graph executor is a strict no-op (legacy path owns the run)", async () => { - // With the flag OFF at dispatch, the graph executor does not run at all — the - // legacy step-session path owns the task. Toggling the flag mid-run cannot - // switch paths because the run never entered the graph. + it("graduated graph executor ignores stale false experimental flag", async () => { + /* + FNXC:WorkflowSettings 2026-06-23-22:28: + workflowGraphExecutor graduated from Experimental; stale persisted false values are ignored so the graph attempts authoritative execution rather than silently rolling back to the legacy path. + This fixture still lacks parse-step dependencies, so the graph fails before step execution. + */ const task = taskWithSteps(2); let stepExecuteCalls = 0; const seams: WorkflowLegacySeams = { @@ -533,24 +535,23 @@ describe("stepwise workflow parity (U7 / KTD-9)", () => { const executor = new WorkflowGraphExecutor({ seams }); const result = await executor.run(task, settingsOff(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR); - expect(result.executed).toBe(false); + expect(result.executed).toBe(true); expect(result.outcome).toBe("failure"); expect(stepExecuteCalls).toBe(0); }); - it("OFF-rollback: a stepwise run with the flag OFF leaves steps[] (git-reconcilable) as surviving truth", async () => { - // KTD-8 OFF-rollback: instance rows are swept and steps[] — always - // git-reconcilable — is the surviving truth that legacy resume reconciles - // from. With the flag OFF the graph never writes, so the pre-existing steps[] - // projection (legacy's truth) is untouched; legacy resume then completes. + it("graduated graph executor preserves steps[] truth while running with stale false flag", async () => { + /* + FNXC:WorkflowSettings 2026-06-23-22:28: + Stale workflowGraphExecutor=false no longer disables the graph. If required graph dependencies are absent, rollback safety means the existing git-reconcilable steps[] projection remains the surviving truth. + */ const task = taskWithSteps(2); // Simulate a partially-progressed legacy projection (step 0 done by legacy). (task.steps as TaskStep[])[0] = { name: "Step 1", status: "done" }; const executor = new WorkflowGraphExecutor({ seams: undefined }); const result = await executor.run(task, settingsOff(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR); - expect(result.executed).toBe(false); - // steps[] is untouched by the (no-op) graph — legacy's projection survives. + expect(result.executed).toBe(true); expect((task.steps as TaskStep[])[0].status).toBe("done"); expect((task.steps as TaskStep[])[1].status).toBe("pending"); }); diff --git a/packages/engine/src/workflow-authoritative-driver.ts b/packages/engine/src/workflow-authoritative-driver.ts index b9851e1030..e22f6f7f49 100644 --- a/packages/engine/src/workflow-authoritative-driver.ts +++ b/packages/engine/src/workflow-authoritative-driver.ts @@ -12,7 +12,6 @@ import { import type { TaskExecutor } from "./executor.js"; import { executorLog } from "./logger.js"; -import { WORKFLOW_INTERPRETER_DUAL_OBSERVE_FLAG } from "./workflow-parity-observer.js"; import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult } from "./workflow-graph-task-runner.js"; import type { StepReviewSeamResult, WorkflowLegacySeams } from "./workflow-node-handlers.js"; import type { PreparedWorktree, WorkflowRuntimePrimitives } from "./runtime-primitives.js"; @@ -148,13 +147,12 @@ export class WorkflowAuthoritativeDriver { settings, WORKFLOW_INTERPRETER_AUTHORITATIVE_FLAG, ); - const dualObserveEnabled = isExperimentalFeatureEnabled( - settings, - WORKFLOW_INTERPRETER_DUAL_OBSERVE_FLAG, - ); + /* + FNXC:WorkflowInterpreterCutover 2026-06-23-21:59: + The retired workflowInterpreterDualObserve flag must remain inert in the authoritative driver. Historical/current parity summaries from the store are the readiness evidence source; do not probe or re-enable the hidden shadow observer here. + */ const readiness = evaluateInterpreterCutoverReadiness({ authoritativeFlagEnabled, - dualObserveEnabled, paritySummary, minimumObservedRuns: this.deps.minimumObservedRuns, });