feat(FN-7039): graph is sole post-merge owner + migration 130 normalizes enable ids
Makes graph-native post-merge the default and cuts the merger's legacy post-merge path over so post-merge runs exactly once (via the graph), with migration data prep. - experimentalFeatures.graphNativePostMerge → default ON; merger hasEnabledPostMergeWorkflowSteps/runPostMergeWorkflowSteps become inert when on (no double-run; proven by a new no-double-run test). Merger code kept (U7c removes it). - Migration 130 (SCHEMA_VERSION 129→130) rewrites each task's enabledWorkflowSteps entries that are legacy built-in pre-merge workflow_steps row ids → the optional-group node id (browser-verification/code-review); dedupes; idempotent; identity-stable; leaves node-ids/compiled/custom entries untouched. Table KEPT (U7c drops it). - Investigation (real DBs): NO custom/plugin post-merge steps exist; the only post-merge step is compound-engineering's 'document' graph node — so the merger no-op strands nothing. Custom-step re-pointing was verified unnecessary and skipped. Safe-to-drop in U7c still blocked by live readers: merger post-merge fns, store CRUD, migrateLegacyWorkflowSteps/readConfig materialization (executor recovery reader is already null-safe→advisory). Plan U7b. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/workflow-post-merge-graph-cutover.md
Normal file
7
.changeset/workflow-post-merge-graph-cutover.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Post-merge workflow steps now run once via the workflow graph instead of the merger.
|
||||||
|
category: internal
|
||||||
|
dev: Flips `experimentalFeatures.graphNativePostMerge` DEFAULT-ON so the graph is the sole post-merge owner; the legacy merger post-merge path (`runPostMergeWorkflowSteps`/`hasEnabledPostMergeWorkflowSteps`) is inert under the flag (kept until U7c). DB migration 130 rewrites legacy compiled `workflow_steps` enable ids (templateId ∈ built-in optional-group ids: browser-verification, code-review) to the graph node ids in tasks' `enabledWorkflowSteps` (idempotent, de-duped). `workflow_steps` table is retained.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { SCHEMA_VERSION } from "../db.js";
|
||||||
|
import { BROWSER_VERIFICATION_GROUP_ID } from "../builtin-browser-verification-group.js";
|
||||||
|
import { CODE_REVIEW_GROUP_ID } from "../builtin-code-review-group.js";
|
||||||
|
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowPostMerge 2026-06-26-12:00:
|
||||||
|
Migration 130 (U7b post-merge graph-native cutover) — legacy enable-id normalization.
|
||||||
|
A task's enabledWorkflowSteps must reference GRAPH node ids so the graph enables the right
|
||||||
|
optional-group node now that the graph is the single post-merge owner. Legacy DBs may hold
|
||||||
|
compiled `workflow_steps` row ids (WS-xxx) whose templateId is a built-in optional-group node
|
||||||
|
id (browser-verification / code-review). This test seeds a DB at the old schema (129) with
|
||||||
|
exactly that legacy shape and asserts init() rewrites the WS-row id to the node id, de-dups,
|
||||||
|
leaves already-node-id and compiled-workflow entries untouched, and is idempotent across reopen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WS_BV = "WS-TEST-BV"; // legacy compiled row for the browser-verification optional group
|
||||||
|
const WS_DOC = "WS-TEST-DOC"; // compiled-workflow materialization row (templateId workflow:*)
|
||||||
|
|
||||||
|
function insertWorkflowStep(
|
||||||
|
db: { prepare: (sql: string) => { run: (...args: unknown[]) => unknown } },
|
||||||
|
args: { id: string; templateId: string; name: string; phase: string },
|
||||||
|
): void {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT OR REPLACE INTO workflow_steps
|
||||||
|
(id, templateId, name, description, mode, phase, prompt, gateMode, toolMode, enabled, defaultOn, createdAt, updatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, 'prompt', ?, 'x', 'advisory', 'coding', 1, 0, ?, ?)`,
|
||||||
|
).run(args.id, args.templateId, args.name, args.name, args.phase, now, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Migration 130: post-merge cutover enable-id normalization", () => {
|
||||||
|
const harness = createTaskStoreTestHarness();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.beforeEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.afterEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites legacy built-in optional-group WS ids to graph node ids, de-dups, and leaves node-id/compiled entries untouched", async () => {
|
||||||
|
await harness.reopenDiskBackedStore();
|
||||||
|
const store = harness.store();
|
||||||
|
const task = await harness.createTestTask();
|
||||||
|
const db = store.getDatabase();
|
||||||
|
|
||||||
|
// Legacy compiled row for the browser-verification optional group, plus a compiled-workflow
|
||||||
|
// materialization row (templateId workflow:*) that must NOT be rewritten.
|
||||||
|
insertWorkflowStep(db as any, { id: WS_BV, templateId: BROWSER_VERIFICATION_GROUP_ID, name: "Browser Verification", phase: "pre-merge" });
|
||||||
|
insertWorkflowStep(db as any, { id: WS_DOC, templateId: "workflow:builtin:compound-engineering", name: "Document learnings", phase: "post-merge" });
|
||||||
|
|
||||||
|
// Legacy enable set: a WS-row id to rewrite, the SAME node id already present (dedup target),
|
||||||
|
// an already-correct node id, and a compiled-workflow row id (left as-is).
|
||||||
|
const legacyEnabled = [WS_BV, BROWSER_VERIFICATION_GROUP_ID, CODE_REVIEW_GROUP_ID, WS_DOC];
|
||||||
|
db.prepare("UPDATE tasks SET enabledWorkflowSteps = ? WHERE id = ?").run(JSON.stringify(legacyEnabled), task.id);
|
||||||
|
|
||||||
|
// Roll the DB back to the pre-migration schema so init() replays migration 130.
|
||||||
|
db.prepare("UPDATE __meta SET value = '129' WHERE key = 'schemaVersion'").run();
|
||||||
|
|
||||||
|
await harness.reopenDiskBackedStore();
|
||||||
|
const migratedDb = harness.store().getDatabase();
|
||||||
|
expect(migratedDb.getSchemaVersion()).toBe(SCHEMA_VERSION);
|
||||||
|
|
||||||
|
const row = migratedDb.prepare("SELECT enabledWorkflowSteps FROM tasks WHERE id = ?").get(task.id) as { enabledWorkflowSteps: string };
|
||||||
|
const enabled = JSON.parse(row.enabledWorkflowSteps) as string[];
|
||||||
|
|
||||||
|
// WS_BV → browser-verification node id; duplicate browser-verification collapsed; code-review
|
||||||
|
// kept; compiled-workflow row id (WS_DOC) left untouched. Order preserved.
|
||||||
|
expect(enabled).toEqual([BROWSER_VERIFICATION_GROUP_ID, CODE_REVIEW_GROUP_ID, WS_DOC]);
|
||||||
|
// No raw WS-row id for a built-in optional group survives.
|
||||||
|
expect(enabled).not.toContain(WS_BV);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent: a second init() makes no further change", async () => {
|
||||||
|
await harness.reopenDiskBackedStore();
|
||||||
|
const store = harness.store();
|
||||||
|
const task = await harness.createTestTask();
|
||||||
|
const db = store.getDatabase();
|
||||||
|
|
||||||
|
insertWorkflowStep(db as any, { id: WS_BV, templateId: BROWSER_VERIFICATION_GROUP_ID, name: "Browser Verification", phase: "pre-merge" });
|
||||||
|
db.prepare("UPDATE tasks SET enabledWorkflowSteps = ? WHERE id = ?").run(JSON.stringify([WS_BV]), task.id);
|
||||||
|
db.prepare("UPDATE __meta SET value = '129' WHERE key = 'schemaVersion'").run();
|
||||||
|
|
||||||
|
await harness.reopenDiskBackedStore();
|
||||||
|
const afterFirst = JSON.parse(
|
||||||
|
(harness.store().getDatabase().prepare("SELECT enabledWorkflowSteps FROM tasks WHERE id = ?").get(task.id) as { enabledWorkflowSteps: string }).enabledWorkflowSteps,
|
||||||
|
) as string[];
|
||||||
|
expect(afterFirst).toEqual([BROWSER_VERIFICATION_GROUP_ID]);
|
||||||
|
|
||||||
|
// Reopen again (already at SCHEMA_VERSION → migration 130 does not re-run; value is stable).
|
||||||
|
await harness.reopenDiskBackedStore();
|
||||||
|
const afterSecond = JSON.parse(
|
||||||
|
(harness.store().getDatabase().prepare("SELECT enabledWorkflowSteps FROM tasks WHERE id = ?").get(task.id) as { enabledWorkflowSteps: string }).enabledWorkflowSteps,
|
||||||
|
) as string[];
|
||||||
|
expect(afterSecond).toEqual([BROWSER_VERIFICATION_GROUP_ID]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,11 @@ import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
|
|||||||
import type { PluginOnSchemaInit } from "./plugin-types.js";
|
import type { PluginOnSchemaInit } from "./plugin-types.js";
|
||||||
import type { SteeringComment, TaskComment } from "./types.js";
|
import type { SteeringComment, TaskComment } from "./types.js";
|
||||||
import { hasTitleIdDrift, normalizeTitleForTaskId } from "./task-title-id-drift.js";
|
import { hasTitleIdDrift, normalizeTitleForTaskId } from "./task-title-id-drift.js";
|
||||||
|
// FNXC:WorkflowPostMerge 2026-06-26-12:00: built-in optional-group node ids — the stable
|
||||||
|
// per-task enable keys on the graph. Migration 130 rewrites legacy WS-row ids whose
|
||||||
|
// templateId is one of these to the node id so the graph enables the right optional group.
|
||||||
|
import { BROWSER_VERIFICATION_GROUP_ID } from "./builtin-browser-verification-group.js";
|
||||||
|
import { CODE_REVIEW_GROUP_ID } from "./builtin-code-review-group.js";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -162,7 +167,7 @@ export function isFts5CorruptionError(error: unknown): boolean {
|
|||||||
|
|
||||||
// ── Schema Definition ────────────────────────────────────────────────
|
// ── Schema Definition ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 129;
|
const SCHEMA_VERSION = 130;
|
||||||
|
|
||||||
const TASKS_FTS_AUTOMERGE = 8;
|
const TASKS_FTS_AUTOMERGE = 8;
|
||||||
const TASKS_FTS_CRISISMERGE = 16;
|
const TASKS_FTS_CRISISMERGE = 16;
|
||||||
@@ -5339,6 +5344,77 @@ export class Database {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration 130: post-merge graph-native cutover (U7b) — legacy enable-id normalization.
|
||||||
|
// FNXC:WorkflowPostMerge 2026-06-26-12:00:
|
||||||
|
// Graph-native post-merge is now default-ON and the graph is the single post-merge owner.
|
||||||
|
// A task's `enabledWorkflowSteps` must reference GRAPH node ids so the graph enables the
|
||||||
|
// right optional-group node. Legacy data may still hold compiled `workflow_steps` row ids
|
||||||
|
// (WS-xxx) whose `templateId` is a built-in optional-group node id (e.g. `browser-verification`,
|
||||||
|
// `code-review`). Rewrite each such entry to that node id. Entries that are already node ids,
|
||||||
|
// compiled-workflow materialization rows (templateId `workflow:*`), and custom rows are left
|
||||||
|
// untouched. Data DML, so wrapped in a transaction; identity-stable + idempotent (a second
|
||||||
|
// run finds node ids already in place and no WS-row left to rewrite). The `workflow_steps`
|
||||||
|
// table is intentionally KEPT (dropped in U7c once all readers are gone).
|
||||||
|
if (version < 130) {
|
||||||
|
this.applyMigration(130, () => {
|
||||||
|
const optionalGroupNodeIds = new Set<string>([
|
||||||
|
BROWSER_VERIFICATION_GROUP_ID,
|
||||||
|
CODE_REVIEW_GROUP_ID,
|
||||||
|
]);
|
||||||
|
// Map every workflow_steps row id → templateId, but only retain rows whose templateId
|
||||||
|
// is a built-in optional-group node id (the only legacy ids we rewrite).
|
||||||
|
const wsRows = this.db
|
||||||
|
.prepare("SELECT id, templateId FROM workflow_steps WHERE templateId IS NOT NULL")
|
||||||
|
.all() as Array<{ id: string; templateId: string | null }>;
|
||||||
|
const wsIdToNodeId = new Map<string, string>();
|
||||||
|
for (const row of wsRows) {
|
||||||
|
if (row.templateId && optionalGroupNodeIds.has(row.templateId)) {
|
||||||
|
wsIdToNodeId.set(row.id, row.templateId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wsIdToNodeId.size === 0) return; // nothing legacy to rewrite
|
||||||
|
|
||||||
|
const taskRows = this.db
|
||||||
|
.prepare("SELECT id, enabledWorkflowSteps FROM tasks WHERE enabledWorkflowSteps IS NOT NULL AND enabledWorkflowSteps NOT IN ('', '[]')")
|
||||||
|
.all() as Array<{ id: string; enabledWorkflowSteps: string | null }>;
|
||||||
|
const update = this.db.prepare("UPDATE tasks SET enabledWorkflowSteps = ? WHERE id = ?");
|
||||||
|
|
||||||
|
this.db.exec("BEGIN");
|
||||||
|
try {
|
||||||
|
for (const task of taskRows) {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(task.enabledWorkflowSteps ?? "[]");
|
||||||
|
} catch {
|
||||||
|
continue; // corrupt JSON: leave untouched
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed)) continue;
|
||||||
|
let changed = false;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const rewritten: string[] = [];
|
||||||
|
for (const entry of parsed) {
|
||||||
|
if (typeof entry !== "string") continue;
|
||||||
|
const mapped = wsIdToNodeId.get(entry);
|
||||||
|
const next = mapped ?? entry;
|
||||||
|
if (mapped) changed = true;
|
||||||
|
if (seen.has(next)) {
|
||||||
|
// De-dup: rewriting WS-xxx → node id can collide with an already-present node id.
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(next);
|
||||||
|
rewritten.push(next);
|
||||||
|
}
|
||||||
|
if (changed) update.run(JSON.stringify(rewritten), task.id);
|
||||||
|
}
|
||||||
|
this.db.exec("COMMIT");
|
||||||
|
} catch (err) {
|
||||||
|
this.db.exec("ROLLBACK");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,7 +11,18 @@ workflowGraphExecutor and workflowColumns graduated from Experimental. Runtime g
|
|||||||
FNXC:WorkflowSettings 2026-06-23-21:55:
|
FNXC:WorkflowSettings 2026-06-23-21:55:
|
||||||
workflowInterpreterDualObserve is no longer user-controllable in Settings. Treat stale persisted true values as inert so upgraded users do not keep running hidden diagnostic shadow observation with no visible off switch.
|
workflowInterpreterDualObserve is no longer user-controllable in Settings. Treat stale persisted true values as inert so upgraded users do not keep running hidden diagnostic shadow observation with no visible off switch.
|
||||||
*/
|
*/
|
||||||
const DEFAULT_ON_EXPERIMENTAL_FEATURES = new Set<string>();
|
/*
|
||||||
|
FNXC:WorkflowPostMerge 2026-06-26-12:00:
|
||||||
|
U7b cutover — `graphNativePostMerge` is now DEFAULT-ON. The graph is the single owner
|
||||||
|
of post-merge execution: a successful merge lets traversal continue to post-merge graph
|
||||||
|
nodes (optional-group nodes wired off a merge-region success, plus the plain post-merge
|
||||||
|
nodes that follow a `seam:"merge"` prompt node — e.g. compound-engineering's `document`
|
||||||
|
step). When this flag is on the legacy merger post-merge path (`runPostMergeWorkflowSteps`
|
||||||
|
/ `hasEnabledPostMergeWorkflowSteps` in engine/merger.ts) is INERT so post-merge work runs
|
||||||
|
exactly once via the graph and never double-runs. The flag is retained (not removed) as an
|
||||||
|
explicit opt-out back to the legacy merger path until U7c deletes the legacy code + table.
|
||||||
|
*/
|
||||||
|
const DEFAULT_ON_EXPERIMENTAL_FEATURES = new Set<string>(["graphNativePostMerge"]);
|
||||||
const RETIRED_EXPERIMENTAL_FEATURES = new Set<string>([
|
const RETIRED_EXPERIMENTAL_FEATURES = new Set<string>([
|
||||||
"workflowInterpreterDualObserve",
|
"workflowInterpreterDualObserve",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -186,9 +186,14 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
|
|||||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||||
updateSettings: vi.fn().mockResolvedValue({}),
|
updateSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
// FNXC:WorkflowPostMerge 2026-06-26-12:00: U7b flipped graphNativePostMerge DEFAULT-ON,
|
||||||
|
// which makes the legacy merger post-merge path inert. These legacy-path tests opt OUT
|
||||||
|
// (flag:false) to keep exercising that code until U7c deletes it. The no-double-run test
|
||||||
|
// below asserts the default-ON behavior (merger skips post-merge entirely).
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
mergeIntegrationWorktree: "cwd-main" as const,
|
mergeIntegrationWorktree: "cwd-main" as const,
|
||||||
|
experimentalFeatures: { graphNativePostMerge: false },
|
||||||
}),
|
}),
|
||||||
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
||||||
emit: vi.fn(),
|
emit: vi.fn(),
|
||||||
@@ -482,6 +487,7 @@ describe("aiMergeTask — post-merge workflow steps", () => {
|
|||||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
mergeIntegrationWorktree: "cwd-main" as const,
|
mergeIntegrationWorktree: "cwd-main" as const,
|
||||||
|
experimentalFeatures: { graphNativePostMerge: false },
|
||||||
defaultProviderOverride: "openai",
|
defaultProviderOverride: "openai",
|
||||||
defaultModelIdOverride: "gpt-4o-mini",
|
defaultModelIdOverride: "gpt-4o-mini",
|
||||||
defaultProvider: "anthropic",
|
defaultProvider: "anthropic",
|
||||||
@@ -695,6 +701,7 @@ describe("aiMergeTask — post-merge workflow steps", () => {
|
|||||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
mergeIntegrationWorktree: "cwd-main" as const,
|
mergeIntegrationWorktree: "cwd-main" as const,
|
||||||
|
experimentalFeatures: { graphNativePostMerge: false },
|
||||||
scripts: { build: "pnpm build" },
|
scripts: { build: "pnpm build" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -783,6 +790,7 @@ describe("aiMergeTask — post-merge workflow steps", () => {
|
|||||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
mergeIntegrationWorktree: "cwd-main" as const,
|
mergeIntegrationWorktree: "cwd-main" as const,
|
||||||
|
experimentalFeatures: { graphNativePostMerge: false },
|
||||||
worktreeInitCommand: "pnpm install",
|
worktreeInitCommand: "pnpm install",
|
||||||
});
|
});
|
||||||
store.getTask = vi.fn().mockResolvedValue({
|
store.getTask = vi.fn().mockResolvedValue({
|
||||||
@@ -869,6 +877,7 @@ describe("aiMergeTask — post-merge workflow steps", () => {
|
|||||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
mergeIntegrationWorktree: "cwd-main" as const,
|
mergeIntegrationWorktree: "cwd-main" as const,
|
||||||
|
experimentalFeatures: { graphNativePostMerge: false },
|
||||||
worktreeInitCommand: "pnpm install",
|
worktreeInitCommand: "pnpm install",
|
||||||
});
|
});
|
||||||
store.getTask = vi.fn().mockResolvedValue({
|
store.getTask = vi.fn().mockResolvedValue({
|
||||||
@@ -1066,6 +1075,68 @@ describe("aiMergeTask — post-merge workflow steps", () => {
|
|||||||
);
|
);
|
||||||
expect(worktreeAddCall).toBeUndefined();
|
expect(worktreeAddCall).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowPostMerge 2026-06-26-12:00:
|
||||||
|
U7b cutover — graph is the SOLE post-merge owner. With graphNativePostMerge default-ON
|
||||||
|
(no explicit experimentalFeatures, i.e. exactly what production resolves), the merger must
|
||||||
|
NOT run any post-merge workflow step, create a post-merge worktree, or invoke a post-merge
|
||||||
|
agent — otherwise the step would DOUBLE-RUN (once here, once via the graph node). This is the
|
||||||
|
no-double-run proof.
|
||||||
|
*/
|
||||||
|
it("graph-native default-ON: merger does NOT run post-merge steps (no double-run)", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
// Default-ON: getSettings returns no experimentalFeatures override → flag resolves ON.
|
||||||
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
mergeIntegrationWorktree: "cwd-main" as const,
|
||||||
|
});
|
||||||
|
const getWorkflowStep = vi.fn().mockResolvedValue({
|
||||||
|
id: "WS-001",
|
||||||
|
name: "Post-merge Notify",
|
||||||
|
description: "Send notifications after merge",
|
||||||
|
prompt: "Check the merged code and confirm all is well.",
|
||||||
|
phase: "post-merge",
|
||||||
|
mode: "prompt",
|
||||||
|
enabled: true,
|
||||||
|
toolMode: "coding",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
(store as any).getWorkflowStep = getWorkflowStep;
|
||||||
|
|
||||||
|
const baseTask = {
|
||||||
|
id: "FN-050",
|
||||||
|
title: "Test task",
|
||||||
|
description: "Test",
|
||||||
|
column: "in-review",
|
||||||
|
dependencies: [],
|
||||||
|
worktree: "/tmp/root/.worktrees/KB-050",
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["WS-001"],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
|
||||||
|
|
||||||
|
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||||
|
|
||||||
|
// Merge still succeeds and the task completes.
|
||||||
|
expect(result.merged).toBe(true);
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||||
|
|
||||||
|
// The legacy post-merge path is fully inert: no post-merge worktree, no post-merge agent.
|
||||||
|
const worktreeAddCall = mockedExec.mock.calls.find((call: any) =>
|
||||||
|
String(call[0]).includes("git worktree add") && String(call[0]).includes("post-merge-FN-050-"),
|
||||||
|
);
|
||||||
|
expect(worktreeAddCall).toBeUndefined();
|
||||||
|
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
|
||||||
|
(c: any) => c[0]?.systemPrompt?.includes("post-merge"),
|
||||||
|
);
|
||||||
|
expect(postMergeAgentCall).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Merge Details Collection Tests ─────────────────────────────────────
|
// ── Merge Details Collection Tests ─────────────────────────────────────
|
||||||
|
|||||||
@@ -132,15 +132,20 @@ describe("WorkflowGraphExecutor graph-native post-merge steps", () => {
|
|||||||
expect(recorder.results[0].status).toBe("advisory_failure");
|
expect(recorder.results[0].status).toBe("advisory_failure");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flag OFF (default): the post-merge node is NOT run via the graph and records nothing", async () => {
|
it("flag explicitly OFF (opt-out): the post-merge node is NOT run via the graph and records nothing", async () => {
|
||||||
const recorder = makeRecorder();
|
const recorder = makeRecorder();
|
||||||
const executor = new WorkflowGraphExecutor({
|
const executor = new WorkflowGraphExecutor({
|
||||||
handlers: { prompt: handler("APPROVE") },
|
handlers: { prompt: handler("APPROVE") },
|
||||||
recordWorkflowStepResult: recorder.record,
|
recordWorkflowStepResult: recorder.record,
|
||||||
});
|
});
|
||||||
|
|
||||||
// No experimentalFeatures at all → flag defaults OFF.
|
// FNXC:WorkflowPostMerge 2026-06-26-12:00: U7b cutover flipped the DEFAULT to ON, so the
|
||||||
const result = await executor.run(taskWith([POST_MERGE_ID]), {}, postMergeIr());
|
// OFF path is now an explicit opt-out (graphNativePostMerge:false), not the default.
|
||||||
|
const result = await executor.run(
|
||||||
|
taskWith([POST_MERGE_ID]),
|
||||||
|
{ experimentalFeatures: { graphNativePostMerge: false } },
|
||||||
|
postMergeIr(),
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.outcome).toBe("success");
|
expect(result.outcome).toBe("success");
|
||||||
expect(result.visitedNodeIds).toContain("merge");
|
expect(result.visitedNodeIds).toContain("merge");
|
||||||
@@ -148,4 +153,25 @@ describe("WorkflowGraphExecutor graph-native post-merge steps", () => {
|
|||||||
expect(result.visitedNodeIds).not.toContain(POST_MERGE_ID);
|
expect(result.visitedNodeIds).not.toContain(POST_MERGE_ID);
|
||||||
expect(recorder.results).toHaveLength(0);
|
expect(recorder.results).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("flag DEFAULT (no experimentalFeatures): runs the post-merge node via the graph (default-ON, U7b)", async () => {
|
||||||
|
const recorder = makeRecorder();
|
||||||
|
const executor = new WorkflowGraphExecutor({
|
||||||
|
handlers: { prompt: handler("APPROVE") },
|
||||||
|
recordWorkflowStepResult: recorder.record,
|
||||||
|
});
|
||||||
|
|
||||||
|
// No experimentalFeatures at all → flag now defaults ON after the U7b cutover.
|
||||||
|
const result = await executor.run(taskWith([POST_MERGE_ID]), {}, postMergeIr());
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("success");
|
||||||
|
expect(result.visitedNodeIds).toContain("merge");
|
||||||
|
// Default-ON: the post-merge node IS traversed after the collapsed merge seam.
|
||||||
|
expect(result.visitedNodeIds.indexOf(POST_MERGE_ID)).toBeGreaterThan(
|
||||||
|
result.visitedNodeIds.indexOf("merge"),
|
||||||
|
);
|
||||||
|
expect(recorder.results).toHaveLength(1);
|
||||||
|
expect(recorder.results[0].phase).toBe("post-merge");
|
||||||
|
expect(recorder.results[0].status).toBe("passed");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ import {
|
|||||||
type AutostashOrphanRecord,
|
type AutostashOrphanRecord,
|
||||||
normalizeMergeAdvanceAutoSyncMode,
|
normalizeMergeAdvanceAutoSyncMode,
|
||||||
isMergeRequestContractShadowEnabled,
|
isMergeRequestContractShadowEnabled,
|
||||||
|
isExperimentalFeatureEnabled,
|
||||||
|
GRAPH_NATIVE_POST_MERGE_FLAG,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { evaluateAutoMergeFactProviders } from "./auto-merge-fact-providers.js";
|
import { evaluateAutoMergeFactProviders } from "./auto-merge-fact-providers.js";
|
||||||
import { resolveMergePolicy, type MergeFileScopeMode } from "./merge-trait.js";
|
import { resolveMergePolicy, type MergeFileScopeMode } from "./merge-trait.js";
|
||||||
@@ -10814,8 +10816,12 @@ export async function aiMergeTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Run post-merge workflow steps (in temporary worktree for isolation)
|
// 7. Run post-merge workflow steps (in temporary worktree for isolation)
|
||||||
|
// FNXC:WorkflowPostMerge 2026-06-26-12:00: U7b cutover — when graph-native post-merge
|
||||||
|
// is active (now default-ON) the GRAPH is the sole post-merge runner; this legacy merger
|
||||||
|
// path is inert so post-merge steps never double-run. `settings` (project-resolved) gates
|
||||||
|
// the no-op; an explicit opt-out (`graphNativePostMerge: false`) restores the legacy path.
|
||||||
throwIfAborted(options.signal, taskId);
|
throwIfAborted(options.signal, taskId);
|
||||||
const hasPostMergeSteps = await hasEnabledPostMergeWorkflowSteps(store, taskId, task.enabledWorkflowSteps);
|
const hasPostMergeSteps = await hasEnabledPostMergeWorkflowSteps(store, taskId, task.enabledWorkflowSteps, settings);
|
||||||
if (hasPostMergeSteps) {
|
if (hasPostMergeSteps) {
|
||||||
const postMergeWorktree = await createPostMergeWorktree(rootDir, taskId, settings);
|
const postMergeWorktree = await createPostMergeWorktree(rootDir, taskId, settings);
|
||||||
const postMergeCwd = postMergeWorktree || rootDir;
|
const postMergeCwd = postMergeWorktree || rootDir;
|
||||||
@@ -12459,7 +12465,12 @@ async function hasEnabledPostMergeWorkflowSteps(
|
|||||||
store: TaskStore,
|
store: TaskStore,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
enabledWorkflowSteps: string[] | undefined,
|
enabledWorkflowSteps: string[] | undefined,
|
||||||
|
settings?: Settings,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
// FNXC:WorkflowPostMerge 2026-06-26-12:00: U7b cutover — graph owns post-merge when the
|
||||||
|
// flag is on (default). Report "no post-merge steps" so the merger skips worktree creation
|
||||||
|
// and execution; the graph runs the equivalent post-merge graph node exactly once.
|
||||||
|
if (isExperimentalFeatureEnabled(settings, GRAPH_NATIVE_POST_MERGE_FLAG)) return false;
|
||||||
if (!enabledWorkflowSteps?.length) return false;
|
if (!enabledWorkflowSteps?.length) return false;
|
||||||
|
|
||||||
for (const wsId of enabledWorkflowSteps) {
|
for (const wsId of enabledWorkflowSteps) {
|
||||||
@@ -12495,6 +12506,11 @@ async function runPostMergeWorkflowSteps(
|
|||||||
auditor?: RunAuditor,
|
auditor?: RunAuditor,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
throwIfAborted(mergeOptions.signal, taskId);
|
throwIfAborted(mergeOptions.signal, taskId);
|
||||||
|
// FNXC:WorkflowPostMerge 2026-06-26-12:00: U7b cutover — defensive no-op (the call site's
|
||||||
|
// hasEnabledPostMergeWorkflowSteps already gates entry). When graph-native post-merge is
|
||||||
|
// active the graph is the sole runner; never execute legacy post-merge steps here to avoid
|
||||||
|
// double-running. Removed entirely in U7c.
|
||||||
|
if (isExperimentalFeatureEnabled(settings, GRAPH_NATIVE_POST_MERGE_FLAG)) return;
|
||||||
const task = await store.getTask(taskId);
|
const task = await store.getTask(taskId);
|
||||||
if (!task.enabledWorkflowSteps?.length) return;
|
if (!task.enabledWorkflowSteps?.length) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user