FN-6971: retire dual-observe cutover gating
Remove stale dual-observe prerequisites from workflow-authoritative readiness while preserving parity-summary safeguards. - Require the authoritative flag plus clean populated parity summaries for interpreter cutover readiness. - Keep persisted workflowInterpreterDualObserve values inert in runtime tests and documentation. - Update cutover, parity, and graph-executor tests to reflect retired shadow observation behavior. - Add a formatted patch changeset for the operator-facing cutover readiness fix. Files changed: .changeset/fn-6971-workflow-cutover-readiness.md | 7 ++ docs/architecture.md | 2 +- docs/settings-reference.md | 4 +- docs/workflow-steps.md | 19 +++-- .../core/src/__tests__/workflow-cutover.test.ts | 19 +++-- packages/core/src/workflow-cutover.ts | 9 +-- .../workflow-interpreter-cutover.test.ts | 87 +++++++++++++++++++--- .../workflow-interpreter-dual-observe.test.ts | 76 ++++++------------- .../src/__tests__/stepwise-workflow-parity.test.ts | 25 ++++--- .../engine/src/workflow-authoritative-driver.ts | 10 +-- 10 files changed, 152 insertions(+), 106 deletions(-) Fusion-Task-Id: FN-6971 Fusion-Task-Lineage: 363a441d-62e5-403c-9389-75fcf788352a
This commit is contained in:
7
.changeset/fn-6971-workflow-cutover-readiness.md
Normal file
7
.changeset/fn-6971-workflow-cutover-readiness.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<WorkflowParitySummary, "observed" | "drift" | "recentDrift"> | 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");
|
||||
|
||||
@@ -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<TaskDetail>,
|
||||
getTaskWorkflowSelection: () => undefined,
|
||||
getWorkflowParitySummary: () => readyParity,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user