From 265496d0b6796f2522ebc16ad99c10d99b79f4db Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 9 Jun 2026 17:16:21 -0700 Subject: [PATCH] feat(FN-000): model merge policy in builtin workflow IR Fusion-Task-Id: FN-000 --- .../s04-builtin-ir-regions.md | 46 +++++++++++++++++ .../builtin-coding-workflow-ir.test.ts | 51 +++++++++++++++++-- .../core/src/builtin-coding-workflow-ir.ts | 35 +++++++++++-- packages/core/src/builtin-pr-workflow-ir.ts | 4 +- .../builtin-stepwise-coding-workflow-ir.ts | 35 +++++++++++-- packages/core/src/workflow-ir-types.ts | 11 ++++ 6 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 docs/plans/workflow-owned-merge-stack/s04-builtin-ir-regions.md diff --git a/docs/plans/workflow-owned-merge-stack/s04-builtin-ir-regions.md b/docs/plans/workflow-owned-merge-stack/s04-builtin-ir-regions.md new file mode 100644 index 0000000000..ebf0d9cf0d --- /dev/null +++ b/docs/plans/workflow-owned-merge-stack/s04-builtin-ir-regions.md @@ -0,0 +1,46 @@ +--- +title: "S04: built-in merge retry recovery IR regions" +type: refactor +status: draft-stack-handoff +date: 2026-06-09 +slice: S04 +milestone: "Gate A" +origin: docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md +stack_base: feature/workflow-owned-merge-s03-generic-scheduler-claim +--- + +# S04: built-in merge retry recovery IR regions + +## Stack Role + +This draft PR reserves the S04 review slot in the workflow-owned merge, +retry, scheduling, and recovery migration stack. It is intentionally a handoff +artifact, not the completed implementation for this slice. + +## Milestone + +Gate A + +## Depends On + +S1 workflow work items and S2 merge request projection. + +## Goal + +Add explicit merge, retry, manual hold, branch-group, and recovery regions to built-in workflow IR. + +## Expected File Scope + +packages/core/src/builtin-*-workflow-ir.ts; packages/core/src/workflow-ir-types.ts; built-in workflow IR tests. + +## Expected Tests + +Built-in workflow validation for merge gates, retry nodes, manual holds, PR workflow routing, autoMerge false, and branch-group nodes. + +## Exit Gate + +Built-in IR is the source of truth for default merge/retry/recovery policy. + +## Full Plan + +See `docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md`. diff --git a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts index 8e7ff67cdc..985260d595 100644 --- a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts +++ b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { BUILTIN_CODING_WORKFLOW_IR, + BUILTIN_PR_WORKFLOW_IR, + BUILTIN_STEPWISE_CODING_WORKFLOW_IR, DEFAULT_WORKFLOW_COLUMN_IDS, parseWorkflowIr, serializeWorkflowIr, @@ -36,7 +38,8 @@ describe("builtin coding workflow ir", () => { const seams = BUILTIN_CODING_WORKFLOW_IR.nodes .map((node) => String(node.config?.seam ?? "")) .filter((seam) => seam.length > 0); - expect(seams).toEqual(expect.arrayContaining(["execute", "workflow-step", "review", "merge"])); + expect(seams).toEqual(expect.arrayContaining(["execute", "workflow-step", "review"])); + expect(seams).not.toContain("merge"); expect(seams).not.toContain("triage"); }); @@ -72,7 +75,8 @@ describe("builtin coding workflow ir", () => { expect(byId.get("execute")?.column).toBe("in-progress"); expect(byId.get("workflow-step")?.column).toBe("in-progress"); expect(byId.get("review")?.column).toBe("in-review"); - expect(byId.get("merge")?.column).toBe("in-review"); + expect(byId.get("merge-gate")?.column).toBe("in-review"); + expect(byId.get("merge-attempt")?.column).toBe("in-review"); }); it("assigns descriptive names to execute/workflow-step/review/merge seam nodes", () => { @@ -80,7 +84,6 @@ describe("builtin coding workflow ir", () => { expect(byId.get("execute")?.config?.name).toBe("Execute"); expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps"); expect(byId.get("review")?.config?.name).toBe("Review"); - expect(byId.get("merge")?.config?.name).toBe("Merge boundary"); }); it("declares a bounded retry budget only on the execute seam", () => { @@ -93,10 +96,9 @@ describe("builtin coding workflow ir", () => { const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n])); expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps"); expect(byId.get("review")?.config?.name).toBe("Review"); - expect(byId.get("merge")?.config?.name).toBe("Merge boundary"); expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined(); expect(byId.get("review")?.config?.maxRetries).toBeUndefined(); - expect(byId.get("merge")?.config?.maxRetries).toBeUndefined(); + expect(byId.get("merge-attempt")?.config?.maxReworkCycles).toBe(3); }); it("preserves the execute retry declaration through parse/serialize round-trip", () => { @@ -104,4 +106,43 @@ describe("builtin coding workflow ir", () => { const config = executeNodeConfig(reparsed); expect(config.maxRetries).toBe(EXECUTE_NODE_MAX_RETRIES); }); + + it("expresses default merge retry recovery and branch-group policy as built-in nodes", () => { + const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((node) => [node.id, node])); + expect(byId.get("merge-gate")?.kind).toBe("merge-gate"); + expect(byId.get("merge-retry")?.kind).toBe("retry-backoff"); + expect(byId.get("merge-manual-hold")?.kind).toBe("manual-merge-hold"); + expect(byId.get("branch-group-member-integration")?.kind).toBe("branch-group-member-integration"); + expect(byId.get("branch-group-promotion")?.kind).toBe("branch-group-promotion"); + expect(byId.get("merge-attempt")?.kind).toBe("merge-attempt"); + expect(byId.get("recovery-router")?.kind).toBe("recovery-router"); + expect(BUILTIN_CODING_WORKFLOW_IR.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ from: "merge-gate", to: "branch-group-member-integration", condition: "outcome:auto-on" }), + expect.objectContaining({ from: "merge-gate", to: "merge-manual-hold", condition: "outcome:auto-off" }), + expect.objectContaining({ from: "merge-attempt", to: "merge-retry", condition: "outcome:transient-failure" }), + ]), + ); + }); + + it("expresses merge policy regions in stepwise and PR built-ins", () => { + expect(BUILTIN_STEPWISE_CODING_WORKFLOW_IR.nodes.map((node) => node.kind)).toEqual( + expect.arrayContaining([ + "merge-gate", + "retry-backoff", + "manual-merge-hold", + "branch-group-member-integration", + "branch-group-promotion", + "merge-attempt", + "recovery-router", + ]), + ); + expect(BUILTIN_PR_WORKFLOW_IR.nodes.map((node) => node.kind)).toEqual(expect.arrayContaining(["manual-merge-hold", "pr-merge"])); + expect(BUILTIN_PR_WORKFLOW_IR.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ from: "gate", to: "manual-merge-hold", condition: "outcome:auto-off" }), + expect.objectContaining({ from: "manual-merge-hold", to: "pr-merge", condition: "success" }), + ]), + ); + }); }); diff --git a/packages/core/src/builtin-coding-workflow-ir.ts b/packages/core/src/builtin-coding-workflow-ir.ts index ed61d200bc..4eaa7ab215 100644 --- a/packages/core/src/builtin-coding-workflow-ir.ts +++ b/packages/core/src/builtin-coding-workflow-ir.ts @@ -72,7 +72,23 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = { config: builtinPromptConfig("workflow-step", "Pre-merge workflow steps"), }, { id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") }, - { id: "merge", kind: "prompt", column: "in-review", config: builtinPromptConfig("merge", "Merge boundary") }, + { id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } }, + { id: "merge-retry", kind: "retry-backoff", column: "in-review", config: { policy: "merge", maxAttempts: 3 } }, + { id: "merge-manual-hold", kind: "manual-merge-hold", column: "in-review", config: { release: "manual" } }, + { + id: "branch-group-member-integration", + kind: "branch-group-member-integration", + column: "in-review", + config: { reworkRegion: true, maxReworkCycles: 3 }, + }, + { id: "branch-group-promotion", kind: "branch-group-promotion", column: "in-review" }, + { + id: "merge-attempt", + kind: "merge-attempt", + column: "in-review", + config: { capability: "task-merge", reworkRegion: true, maxReworkCycles: 3 }, + }, + { id: "recovery-router", kind: "recovery-router", column: "in-review", config: { surfaces: ["merge", "retry"] } }, { id: "end", kind: "end", column: "done" }, ], edges: [ @@ -82,13 +98,24 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = { { from: "workflow-step", to: "review", condition: "success" }, { from: "workflow-step", to: "end", condition: "outcome:remediation-scheduled" }, { from: "workflow-step", to: "end", condition: "outcome:deferred-paused" }, - { from: "review", to: "merge", condition: "success" }, - { from: "merge", to: "end", condition: "success" }, + { from: "review", to: "merge-gate", condition: "success" }, + { from: "merge-gate", to: "branch-group-member-integration", condition: "outcome:auto-on" }, + { from: "merge-gate", to: "merge-manual-hold", condition: "outcome:auto-off" }, + { from: "merge-retry", to: "merge-attempt", condition: "success", kind: "rework" }, + { from: "merge-manual-hold", to: "branch-group-member-integration", condition: "success", kind: "rework" }, + { from: "branch-group-member-integration", to: "branch-group-promotion", condition: "success" }, + { from: "branch-group-member-integration", to: "merge-manual-hold", condition: "outcome:manual-required" }, + { from: "branch-group-promotion", to: "merge-attempt", condition: "success" }, + { from: "branch-group-promotion", to: "merge-manual-hold", condition: "outcome:manual-required" }, + { from: "merge-attempt", to: "end", condition: "success" }, + { from: "merge-attempt", to: "merge-retry", condition: "outcome:transient-failure" }, + { from: "merge-attempt", to: "merge-manual-hold", condition: "outcome:manual-required" }, + { from: "recovery-router", to: "merge-attempt", condition: "outcome:wake-merge", kind: "rework" }, { from: "planning", to: "end", condition: "failure" }, { from: "execute", to: "end", condition: "failure" }, { from: "workflow-step", to: "end", condition: "failure" }, { from: "review", to: "end", condition: "failure" }, - { from: "merge", to: "end", condition: "failure" }, + { from: "merge-attempt", to: "end", condition: "failure" }, ], // Workflow-settings (U1, R4): declare the full moved-key catalog with defaults // byte-equal to today's DEFAULT_PROJECT_SETTINGS literals. Inert until U3. diff --git a/packages/core/src/builtin-pr-workflow-ir.ts b/packages/core/src/builtin-pr-workflow-ir.ts index 1f774eaebf..b739172135 100644 --- a/packages/core/src/builtin-pr-workflow-ir.ts +++ b/packages/core/src/builtin-pr-workflow-ir.ts @@ -110,6 +110,7 @@ const RAW_BUILTIN_PR_WORKFLOW_IR: WorkflowIr = { // Auto-merge gate (U6, R10): routes outcome:auto-on → pr-merge, // outcome:auto-off → park back on await-review for a manual merge. { id: "gate", kind: "gate", column: "await-review", config: { gate: "auto-merge" } }, + { id: "manual-merge-hold", kind: "manual-merge-hold", column: "await-review", config: { release: "manual" } }, // await-rebase: the conflict dwell column. The reconcile fires // github:pr-conflict-cleared to release it back to await-review. { @@ -159,7 +160,8 @@ const RAW_BUILTIN_PR_WORKFLOW_IR: WorkflowIr = { // auto-merge gate routing. auto-on goes forward to pr-merge; auto-off parks // back on await-review for a manual merge (rework loop-back). { from: "gate", to: "pr-merge", condition: "outcome:auto-on" }, - { from: "gate", to: "await-review", condition: "outcome:auto-off", kind: "rework" }, + { from: "gate", to: "manual-merge-hold", condition: "outcome:auto-off" }, + { from: "manual-merge-hold", to: "pr-merge", condition: "success" }, // pr-merge outcomes: merged-requested ends (reconcile corroborates `merged`); // a stale-head race re-evaluates against the new head via await-review. { from: "pr-merge", to: "end", condition: "outcome:merged-requested" }, diff --git a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts index 13d3c2a33f..21e105870b 100644 --- a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts +++ b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts @@ -124,7 +124,23 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = { // KTD-5: rework exhaustion escalates to a manual hold (a human releases it). { id: "rework-hold", kind: "hold", column: "in-progress", config: { release: "manual" } }, { id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") }, - { id: "merge", kind: "prompt", column: "in-review", config: builtinPromptConfig("merge", "Merge boundary") }, + { id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } }, + { id: "merge-retry", kind: "retry-backoff", column: "in-review", config: { policy: "merge", maxAttempts: 3 } }, + { id: "merge-manual-hold", kind: "manual-merge-hold", column: "in-review", config: { release: "manual" } }, + { + id: "branch-group-member-integration", + kind: "branch-group-member-integration", + column: "in-review", + config: { reworkRegion: true, maxReworkCycles: 3 }, + }, + { id: "branch-group-promotion", kind: "branch-group-promotion", column: "in-review" }, + { + id: "merge-attempt", + kind: "merge-attempt", + column: "in-review", + config: { capability: "task-merge", reworkRegion: true, maxReworkCycles: 3 }, + }, + { id: "recovery-router", kind: "recovery-router", column: "in-review", config: { surfaces: ["merge", "retry"] } }, { id: "end", kind: "end", column: "done" }, ], edges: [ @@ -142,10 +158,21 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = { { from: "steps", to: "rework-hold", condition: "outcome:rework-exhausted" }, { from: "rework-hold", to: "review", condition: "success" }, { from: "steps", to: "end", condition: "failure" }, - { from: "review", to: "merge", condition: "success" }, + { from: "review", to: "merge-gate", condition: "success" }, { from: "review", to: "end", condition: "failure" }, - { from: "merge", to: "end", condition: "success" }, - { from: "merge", to: "end", condition: "failure" }, + { from: "merge-gate", to: "branch-group-member-integration", condition: "outcome:auto-on" }, + { from: "merge-gate", to: "merge-manual-hold", condition: "outcome:auto-off" }, + { from: "merge-retry", to: "merge-attempt", condition: "success", kind: "rework" }, + { from: "merge-manual-hold", to: "branch-group-member-integration", condition: "success", kind: "rework" }, + { from: "branch-group-member-integration", to: "branch-group-promotion", condition: "success" }, + { from: "branch-group-member-integration", to: "merge-manual-hold", condition: "outcome:manual-required" }, + { from: "branch-group-promotion", to: "merge-attempt", condition: "success" }, + { from: "branch-group-promotion", to: "merge-manual-hold", condition: "outcome:manual-required" }, + { from: "merge-attempt", to: "end", condition: "success" }, + { from: "merge-attempt", to: "merge-retry", condition: "outcome:transient-failure" }, + { from: "merge-attempt", to: "merge-manual-hold", condition: "outcome:manual-required" }, + { from: "recovery-router", to: "merge-attempt", condition: "outcome:wake-merge", kind: "rework" }, + { from: "merge-attempt", to: "end", condition: "failure" }, ], // Workflow-settings (U1, R4): same moved-key catalog as the default builtin. settings: BUILTIN_WORKFLOW_SETTINGS, diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index 6256537432..5528fce848 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -5,6 +5,10 @@ * verdicts as outcome edges), `parse-steps` (graph-native step-list parsing), * `code` (sandboxed TypeScript), `notify` (workflow-authored notifications), * and `loop` (bounded repeat-until region); + * and the workflow-owned merge/retry/recovery policy additions: + * `merge-gate`, `merge-attempt`, `manual-merge-hold`, `retry-backoff`, + * `recovery-router`, `branch-group-member-integration`, and + * `branch-group-promotion`; * and the unified PR-entity additions (U3): * `pr-create` (open/reuse the PR + write the entity), `pr-respond` (the * review-response run), and `pr-merge` (tool-side merge with expectedHeadOid). */ @@ -23,6 +27,13 @@ export type WorkflowIrNodeKind = | "parse-steps" | "code" | "notify" + | "merge-gate" + | "merge-attempt" + | "manual-merge-hold" + | "retry-backoff" + | "recovery-router" + | "branch-group-member-integration" + | "branch-group-promotion" | "pr-create" | "pr-respond" | "pr-merge";