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",