자기야 발견: 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 모두 정상.
161 lines
6.8 KiB
TypeScript
161 lines
6.8 KiB
TypeScript
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");
|
||
});
|
||
});
|