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:
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user