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:
2026-04-11 01:47:40 +09:00
parent ae2d4b1d3e
commit 294abdbc25
3 changed files with 89 additions and 4 deletions

View File

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

View File

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

View File

@@ -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)", () => {