feat(FN-000): model merge policy in builtin workflow IR
Fusion-Task-Id: FN-000
This commit is contained in:
@@ -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`.
|
||||
@@ -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" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user