feat(FN-000): model merge policy in builtin workflow IR

Fusion-Task-Id: FN-000
This commit is contained in:
gsxdsm
2026-06-09 17:16:21 -07:00
parent 0ec7b496ae
commit 265496d0b6
6 changed files with 168 additions and 14 deletions

View File

@@ -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`.

View File

@@ -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" }),
]),
);
});
});

View File

@@ -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.

View File

@@ -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" },

View File

@@ -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,

View File

@@ -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";