From 185320f1b9bfffdb9bd93972d504804c71a8fdda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=9E=91=EC=9D=B4?= Date: Sat, 11 Apr 2026 04:12:42 +0900 Subject: [PATCH] =?UTF-8?q?fix(fsm):=20retry/replan=20budget=20=EC=B6=95?= =?UTF-8?q?=EC=86=8C=20+=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B0=80=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자기야 발견: maxReplans=2, maxReviewRounds=3 budget 으로 forced REQUEST_CHANGES 검증을 돌렸는데, FSM 자체는 정상 작동했지만 (replanCount=1 정확히 카운트, plan→ review×3→implement loop 정확히 발동) 총 12 review attempts × LLM 호출 30-60s = 실 운영에서 escalation 까지 15-30분 걸림. 사용자가 "무한 루프" 라고 느낄 정도. 수정: - src/orchestrator/context.ts: maxReviewRounds: 3 → 2 maxReplans: 2 → 1 → 총 budget = (1+1)*(1+2) = 6 review attempts (이전 12 의 절반) → 실 운영 wall-clock 약 3-6 분 안에 escalation 도달 - src/orchestrator/machine.ts: 동일하게 default context 갱신 - tests/machine.test.ts: 새 budget 에 맞춰 round 수 조정 - "after max review rounds" 테스트: 4 round → 3 round - "escalates after both budgets exhausted" 테스트: 12 round → 6 round - sister-agent/src/spawn.ts: RAILS_FORCE_REVIEW_VERDICT 환경변수 가드 추가 (test-only). APPROVE / REQUEST_CHANGES / ABORT 중 하나 설정하면 LLM 우회하고 즉시 verdict 반환. darang sister 에 박아서 retry FSM E2E 검증 가능. 운영 시점엔 env 미설정 → no-op, LLM 응답 정상 사용. 테스트: 113 통과 (변경 없음). 검증 흔적: pipeline 01KNWB8WYRR11PGY8DYMNQ1BZD 가 forced REQUEST_CHANGES 로 이전 budget (12 round) 의 60% 까지 진행 후 수동 abort. transitions 18 개에 replanCount=1, reviewRound=3 정확히 보존됨 — persistence/FSM 모두 정상. --- sister-agent/src/spawn.ts | 40 +++++++++++++++++++++++++++++++++++++ src/orchestrator/context.ts | 18 ++++++++++++----- src/orchestrator/machine.ts | 4 ++-- tests/machine.test.ts | 25 ++++++++++------------- 4 files changed, 66 insertions(+), 21 deletions(-) diff --git a/sister-agent/src/spawn.ts b/sister-agent/src/spawn.ts index 6117c2f..8fb27b7 100644 --- a/sister-agent/src/spawn.ts +++ b/sister-agent/src/spawn.ts @@ -644,6 +644,46 @@ function buildSuccessResult( errorReason: "", }; case "review": { + // Test-only override: force a verdict without consulting the LLM. + // Used to verify the FSM review-loop / re-plan paths without + // depending on LLM judgement. Set RAILS_FORCE_REVIEW_VERDICT to + // APPROVE / REQUEST_CHANGES / ABORT on the darang sister-agent + // host. Empty / unset → normal LLM-parsed behavior. + const forced = process.env["RAILS_FORCE_REVIEW_VERDICT"]; + if (forced === "REQUEST_CHANGES") { + return { + stage: "review", + verdict: "REQUEST_CHANGES", + payload: { + artifactPath: "", + checklistResults: [], + issues: [ + { + severity: "major", + message: + "[forced via RAILS_FORCE_REVIEW_VERDICT] retry-loop test injection", + }, + ], + }, + abortReason: "", + }; + } + if (forced === "APPROVE") { + return { + stage: "review", + verdict: "APPROVE", + payload: { artifactPath: "", checklistResults: [], issues: [] }, + abortReason: "", + }; + } + if (forced === "ABORT") { + return { + stage: "review", + verdict: "ABORT", + payload: { artifactPath: "", checklistResults: [], issues: [] }, + abortReason: "[forced] test ABORT", + }; + } const parsed = parseReviewVerdict(summary); if (parsed.verdict === "APPROVE") { return { diff --git a/src/orchestrator/context.ts b/src/orchestrator/context.ts index c0d76db..0cae725 100644 --- a/src/orchestrator/context.ts +++ b/src/orchestrator/context.ts @@ -11,9 +11,17 @@ export const PipelineContext = z.object({ 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), + /** + * Inner-loop budget. Each round = a real LLM call (30-60s) so we keep + * this small. Total review attempts per plan = 1 + maxReviewRounds. + */ + maxReviewRounds: z.number().int().positive().default(2), + /** + * Outer-loop budget. Total review attempts across the whole pipeline = + * (1+maxReplans)*(1+maxReviewRounds). With defaults (1, 2) = 6 attempts, + * keeping total wall-clock under ~6 min before escalation. + */ + maxReplans: z.number().int().min(0).default(1), lastError: z.string().nullable().default(null), contractPath: z.string().nullable().default(null), createdAt: z.string().datetime(), @@ -35,8 +43,8 @@ export function createInitialContext( replanCount: 0, retryCount: 0, maxRetries: 3, - maxReviewRounds: 3, - maxReplans: 2, + maxReviewRounds: 2, + maxReplans: 1, lastError: null, contractPath: null, createdAt: new Date().toISOString(), diff --git a/src/orchestrator/machine.ts b/src/orchestrator/machine.ts index 5571837..c91a0d2 100644 --- a/src/orchestrator/machine.ts +++ b/src/orchestrator/machine.ts @@ -65,8 +65,8 @@ export const pipelineMachine = setup({ replanCount: 0, retryCount: 0, maxRetries: 3, - maxReviewRounds: 3, - maxReplans: 2, + maxReviewRounds: 2, + maxReplans: 1, lastError: null, contractPath: null, createdAt: new Date().toISOString(), diff --git a/tests/machine.test.ts b/tests/machine.test.ts index 7ccd10f..c96e983 100644 --- a/tests/machine.test.ts +++ b/tests/machine.test.ts @@ -45,6 +45,8 @@ describe("pipelineMachine", () => { }); it("after max review rounds, falls back to planning (re-plan loop)", () => { + // Defaults: maxReviewRounds=2, maxReplans=1. + // Burn through (1 + maxReviewRounds) = 3 review attempts to trigger replan. const snapshot = runMachine([ { type: "REQUEST", projectName: "test", requirements: "" }, { type: "PLAN_READY", planDir: "/tmp", sprintId: "S1" }, @@ -54,12 +56,9 @@ describe("pipelineMachine", () => { // Round 2 (reviewRound: 1 → 2) { type: "IMPL_DONE", branch: "b", commits: ["c2"] }, { type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] }, - // Round 3 (reviewRound: 2 → 3) + // Round 3 — reviewRound=2, canReviewAgain (2<2)=false, canReplan (0<1)=true → planning { type: "IMPL_DONE", branch: "b", commits: ["c3"] }, { type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] }, - // 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); @@ -68,17 +67,15 @@ describe("pipelineMachine", () => { }); 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. + // Defaults: maxReplans=1, maxReviewRounds=2 → + // (1+maxReplans) = 2 plan attempts × (1+maxReviewRounds) = 3 review attempts each + // = 6 total REQUEST_CHANGES events before escalation. const events: Array> = [ { type: "REQUEST", projectName: "test", requirements: "" }, ]; - // Initial plan + reviews - for (let plan = 0; plan < 3; plan++) { + for (let plan = 0; plan < 2; 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++) { + for (let round = 0; round < 3; round++) { events.push({ type: "IMPL_DONE", branch: "b", commits: [`c${plan}-${round}`] }); events.push({ type: "REQUEST_CHANGES", @@ -88,7 +85,7 @@ describe("pipelineMachine", () => { } const snapshot = runMachine(events); expect(snapshot.value).toBe("escalated"); - expect(snapshot.context.replanCount).toBe(2); // maxReplans = 2 + expect(snapshot.context.replanCount).toBe(1); // maxReplans = 1 expect(snapshot.context.lastError).toContain("Max replans exceeded"); }); @@ -97,8 +94,8 @@ describe("pipelineMachine", () => { { 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++) { + // Burn through 3 review attempts to trigger first replan + for (let round = 0; round < 3; round++) { events.push({ type: "IMPL_DONE", branch: "b", commits: [`c${round}`] }); events.push({ type: "REQUEST_CHANGES",