fix(fsm): retry/replan budget 축소 + 테스트 검증 가드 추가

자기야 발견: 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 모두 정상.
This commit is contained in:
2026-04-11 04:12:42 +09:00
parent 6c6a0a50dc
commit 185320f1b9
4 changed files with 66 additions and 21 deletions

View File

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

View File

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

View File

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

View File

@@ -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<Record<string, unknown>> = [
{ 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",