Files
hanarang-rails/tests/machine.test.ts
이랑이 185320f1b9 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 모두 정상.
2026-04-11 04:12:42 +09:00

161 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from "vitest";
import { createActor } from "xstate";
import { pipelineMachine } from "../src/orchestrator/machine.js";
function runMachine(events: Array<Record<string, unknown>>) {
const actor = createActor(pipelineMachine);
actor.start();
for (const event of events) {
actor.send(event as any);
}
const snapshot = actor.getSnapshot();
actor.stop();
return snapshot;
}
describe("pipelineMachine", () => {
it("starts in idle", () => {
const actor = createActor(pipelineMachine);
actor.start();
expect(actor.getSnapshot().value).toBe("idle");
actor.stop();
});
it("happy path: idle → planning → implementing → reviewing → deploying → done", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "test", requirements: "build something" },
{ type: "PLAN_READY", planDir: "/tmp/plans", sprintId: "SPRINT-001" },
{ type: "IMPL_DONE", branch: "feature/sprint-001", commits: ["abc1234"] },
{ type: "APPROVE", reviewArtifact: "/tmp/review.json" },
{ type: "DEPLOY_DONE", deployArtifact: "/tmp/deploy.json" },
]);
expect(snapshot.value).toBe("done");
expect(snapshot.status).toBe("done");
});
it("REQUEST_CHANGES loops back to implementing (up to maxReviewRounds)", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "test", requirements: "" },
{ type: "PLAN_READY", planDir: "/tmp", sprintId: "S1" },
{ type: "IMPL_DONE", branch: "b", commits: ["c1"] },
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix it" }] },
]);
expect(snapshot.value).toBe("implementing");
expect(snapshot.context.reviewRound).toBe(1);
});
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" },
// Round 1 (reviewRound: 0 → 1)
{ type: "IMPL_DONE", branch: "b", commits: ["c1"] },
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
// Round 2 (reviewRound: 1 → 2)
{ type: "IMPL_DONE", branch: "b", commits: ["c2"] },
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
// 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" }] },
]);
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", () => {
// 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: "" },
];
for (let plan = 0; plan < 2; plan++) {
events.push({ type: "PLAN_READY", planDir: "/tmp", sprintId: `S${plan}` });
for (let round = 0; round < 3; 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.replanCount).toBe(1); // maxReplans = 1
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 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",
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)", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "test", requirements: "" },
{ type: "ERROR", actor: "planner", reason: "timeout", retryable: true },
]);
// retrying has an always transition — if canRetry, goes to idle
expect(snapshot.value).toBe("idle");
expect(snapshot.context.retryCount).toBe(1);
});
it("non-retryable error goes to escalated", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "test", requirements: "" },
{ type: "ERROR", actor: "planner", reason: "permission denied", retryable: false },
]);
expect(snapshot.value).toBe("escalated");
expect(snapshot.context.lastError).toBe("permission denied");
});
it("escalated → RESUME goes back to idle", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "test", requirements: "" },
{ type: "ERROR", actor: "planner", reason: "fail", retryable: false },
{ type: "RESUME" },
]);
expect(snapshot.value).toBe("idle");
expect(snapshot.context.lastError).toBeNull();
});
it("ABORT from any active state goes to aborted", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "test", requirements: "" },
{ type: "ABORT", reason: "user cancelled" },
]);
expect(snapshot.value).toBe("aborted");
expect(snapshot.context.lastError).toBe("user cancelled");
});
it("context tracks projectName and requirements from REQUEST", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "arang", requirements: "Live2D avatar" },
]);
expect(snapshot.context.projectName).toBe("arang");
expect(snapshot.context.requirements).toBe("Live2D avatar");
});
});