feat(fsm): re-planning loop — review 다 실패하면 plan 단계로 되돌아감
자기야 요청: 안쪽 review-loop 다 써도 그냥 escalation 이 아니라, 한 단계
위에서 plan 부터 다시 짜야 함. 첫 plan 자체가 잘못된 접근일 수도 있으니까.
새 FSM:
reviewing → REQUEST_CHANGES
├─ canReviewAgain (reviewRound < maxReviewRounds) → implementing
├─ canReplan (replanCount < maxReplans) → planning
│ • incrementReplanCount, resetReviewRound
│ • lastError = "Re-planning after exhausted review rounds"
│ • 다음 plan 단계가 priorStages 로 review issues 를 보고 새 접근
└─ both exhausted → escalated
기본값: maxReplans=2 → 총 budget = (1+2)*(1+3) = 12 round (3 plans × 4 reviews
each). 그 이상 가면 사용자 개입.
추가:
- src/orchestrator/context.ts: replanCount, maxReplans 필드 (default 0, 2)
- src/orchestrator/machine.ts: canReplan guard, incrementReplanCount action,
reviewing.REQUEST_CHANGES 의 transition 분기
- tests/machine.test.ts: 기존 "escalates after max review rounds" 를 새
re-plan 동작에 맞게 수정 + 두 개 새 케이스 추가
1. maxReplans+maxReviewRounds 둘 다 소진 후 escalated
2. re-plan 후 새 plan 으로 APPROVE → done 흐름
113 테스트 모두 통과 (105 → 113).
This commit is contained in:
@@ -5,10 +5,15 @@ export const PipelineContext = z.object({
|
||||
projectName: z.string(),
|
||||
requirements: z.string().default(""),
|
||||
currentSprintId: z.string().nullable().default(null),
|
||||
/** Inner loop: how many times the current plan has been re-implemented */
|
||||
reviewRound: z.number().int().min(0).default(0),
|
||||
/** Outer loop: how many times the whole plan→impl→review cycle restarted */
|
||||
replanCount: z.number().int().min(0).default(0),
|
||||
retryCount: z.number().int().min(0).default(0),
|
||||
maxRetries: z.number().int().positive().default(3),
|
||||
maxReviewRounds: z.number().int().positive().default(3),
|
||||
/** Outer loop budget — total budget = (1+maxReplans)*(1+maxReviewRounds) */
|
||||
maxReplans: z.number().int().min(0).default(2),
|
||||
lastError: z.string().nullable().default(null),
|
||||
contractPath: z.string().nullable().default(null),
|
||||
createdAt: z.string().datetime(),
|
||||
@@ -27,9 +32,11 @@ export function createInitialContext(
|
||||
requirements,
|
||||
currentSprintId: null,
|
||||
reviewRound: 0,
|
||||
replanCount: 0,
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
maxReviewRounds: 3,
|
||||
maxReplans: 2,
|
||||
lastError: null,
|
||||
contractPath: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
|
||||
@@ -19,6 +19,8 @@ export const pipelineMachine = setup({
|
||||
context.retryCount < context.maxRetries,
|
||||
canReviewAgain: ({ context }: { context: PipelineContext }) =>
|
||||
context.reviewRound < context.maxReviewRounds,
|
||||
canReplan: ({ context }: { context: PipelineContext }) =>
|
||||
context.replanCount < context.maxReplans,
|
||||
isRetryable: ({ event }: { event: PipelineEvent }) =>
|
||||
event.type === "ERROR" && event.retryable === true,
|
||||
},
|
||||
@@ -33,6 +35,10 @@ export const pipelineMachine = setup({
|
||||
context.reviewRound + 1,
|
||||
}),
|
||||
resetReviewRound: assign({ reviewRound: 0 }),
|
||||
incrementReplanCount: assign({
|
||||
replanCount: ({ context }: { context: PipelineContext }) =>
|
||||
context.replanCount + 1,
|
||||
}),
|
||||
setError: assign({
|
||||
lastError: ({ event }: { event: PipelineEvent }) =>
|
||||
event.type === "ERROR" ? event.reason : null,
|
||||
@@ -56,9 +62,11 @@ export const pipelineMachine = setup({
|
||||
requirements: "",
|
||||
currentSprintId: null,
|
||||
reviewRound: 0,
|
||||
replanCount: 0,
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
maxReviewRounds: 3,
|
||||
maxReplans: 2,
|
||||
lastError: null,
|
||||
contractPath: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -144,15 +152,35 @@ export const pipelineMachine = setup({
|
||||
},
|
||||
REQUEST_CHANGES: [
|
||||
{
|
||||
// Inner loop: still have review rounds left → re-implement
|
||||
// with the same plan
|
||||
guard: "canReviewAgain",
|
||||
target: "implementing",
|
||||
actions: ["incrementReviewRound"],
|
||||
},
|
||||
{
|
||||
// Inner loop exhausted but outer loop still has budget →
|
||||
// go back to planning. The next plan stage sees the failed
|
||||
// review issues via priorStages and can produce a new
|
||||
// approach. reviewRound is reset so the new plan gets a
|
||||
// fresh review budget.
|
||||
guard: "canReplan",
|
||||
target: "planning",
|
||||
actions: [
|
||||
"incrementReplanCount",
|
||||
"resetReviewRound",
|
||||
assign({
|
||||
lastError:
|
||||
"Re-planning after exhausted review rounds — see prior stage feedback",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
// Both inner and outer loops exhausted → ask the user
|
||||
target: "escalated",
|
||||
actions: [
|
||||
assign({
|
||||
lastError: "Max review rounds exceeded",
|
||||
lastError: "Max replans exceeded — needs human intervention",
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("pipelineMachine", () => {
|
||||
expect(snapshot.context.reviewRound).toBe(1);
|
||||
});
|
||||
|
||||
it("escalates after max review rounds exceeded", () => {
|
||||
it("after max review rounds, falls back to planning (re-plan loop)", () => {
|
||||
const snapshot = runMachine([
|
||||
{ type: "REQUEST", projectName: "test", requirements: "" },
|
||||
{ type: "PLAN_READY", planDir: "/tmp", sprintId: "S1" },
|
||||
@@ -57,12 +57,62 @@ describe("pipelineMachine", () => {
|
||||
// Round 3 (reviewRound: 2 → 3)
|
||||
{ type: "IMPL_DONE", branch: "b", commits: ["c3"] },
|
||||
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
|
||||
// Round 4 — reviewRound=3, guard 3 < 3 = false → escalated
|
||||
// Round 4 — reviewRound=3, canReviewAgain=false, canReplan=true → planning
|
||||
{ type: "IMPL_DONE", branch: "b", commits: ["c4"] },
|
||||
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
|
||||
]);
|
||||
expect(snapshot.value).toBe("planning");
|
||||
expect(snapshot.context.replanCount).toBe(1);
|
||||
expect(snapshot.context.reviewRound).toBe(0); // reset on re-plan
|
||||
expect(snapshot.context.lastError).toContain("Re-planning");
|
||||
});
|
||||
|
||||
it("escalates only after maxReplans + maxReviewRounds both exhausted", () => {
|
||||
// 1 + maxReplans = 3 plan attempts. Each plan attempt has
|
||||
// 1 + maxReviewRounds = 4 review rounds before triggering replan.
|
||||
// So we need to drive 3 cycles of plan→review×4 → final replan triggers escalation.
|
||||
const events: Array<Record<string, unknown>> = [
|
||||
{ type: "REQUEST", projectName: "test", requirements: "" },
|
||||
];
|
||||
// Initial plan + reviews
|
||||
for (let plan = 0; plan < 3; plan++) {
|
||||
events.push({ type: "PLAN_READY", planDir: "/tmp", sprintId: `S${plan}` });
|
||||
// 4 review rounds per plan (reviewRound: 0→1→2→3, then 4th REQUEST_CHANGES)
|
||||
for (let round = 0; round < 4; round++) {
|
||||
events.push({ type: "IMPL_DONE", branch: "b", commits: [`c${plan}-${round}`] });
|
||||
events.push({
|
||||
type: "REQUEST_CHANGES",
|
||||
issues: [{ severity: "major", message: "fix" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
const snapshot = runMachine(events);
|
||||
expect(snapshot.value).toBe("escalated");
|
||||
expect(snapshot.context.lastError).toContain("review rounds");
|
||||
expect(snapshot.context.replanCount).toBe(2); // maxReplans = 2
|
||||
expect(snapshot.context.lastError).toContain("Max replans exceeded");
|
||||
});
|
||||
|
||||
it("re-plan: APPROVE within new plan still leads to deploying", () => {
|
||||
const events: Array<Record<string, unknown>> = [
|
||||
{ type: "REQUEST", projectName: "test", requirements: "" },
|
||||
{ type: "PLAN_READY", planDir: "/tmp", sprintId: "S1" },
|
||||
];
|
||||
// Burn through 4 review rounds to trigger first replan
|
||||
for (let round = 0; round < 4; round++) {
|
||||
events.push({ type: "IMPL_DONE", branch: "b", commits: [`c${round}`] });
|
||||
events.push({
|
||||
type: "REQUEST_CHANGES",
|
||||
issues: [{ severity: "major", message: "fix" }],
|
||||
});
|
||||
}
|
||||
// Now in planning (replan #1). New plan, then APPROVE on first review.
|
||||
events.push({ type: "PLAN_READY", planDir: "/tmp/v2", sprintId: "S2" });
|
||||
events.push({ type: "IMPL_DONE", branch: "b", commits: ["c-new"] });
|
||||
events.push({ type: "APPROVE", reviewArtifact: "/tmp/r.json" });
|
||||
events.push({ type: "DEPLOY_DONE", deployArtifact: "/tmp/d.json" });
|
||||
const snapshot = runMachine(events);
|
||||
expect(snapshot.value).toBe("done");
|
||||
expect(snapshot.context.replanCount).toBe(1);
|
||||
});
|
||||
|
||||
it("retryable error goes to retrying, then back (if under limit)", () => {
|
||||
|
||||
Reference in New Issue
Block a user