refactor: moderator uses per-rejector limits instead of shared coderCount

- coder self-iterations (done=false): max 5
- reviewer rejections: max 3
- tester rejections: max 3
- committer rejections: max 2
Each budget is independent, no longer starved by coder's own passes.
This commit is contained in:
小橘 2026-04-28 14:22:04 +00:00
parent fbcc1ff30c
commit bbcaf1eba5
3 changed files with 62 additions and 29 deletions

View File

@ -1,6 +1,5 @@
import { END } from "@uncaged/nerve-core"; import { END } from "@uncaged/nerve-core";
import type { Moderator } from "@uncaged/nerve-core"; import type { Moderator } from "@uncaged/nerve-core";
import { TESTER_MAX_ATTEMPTS } from "./lib/constants.js";
import type { IntakeMeta } from "./roles/intake/index.js"; import type { IntakeMeta } from "./roles/intake/index.js";
import type { IssueReaderMeta } from "./roles/issue-reader/index.js"; import type { IssueReaderMeta } from "./roles/issue-reader/index.js";
import type { PlannerMeta } from "./roles/planner/index.js"; import type { PlannerMeta } from "./roles/planner/index.js";
@ -19,12 +18,25 @@ export type WorkflowMeta = {
"pr-publisher": PrPublisherMeta; "pr-publisher": PrPublisherMeta;
}; };
const MAX_IMPLEMENTER_SELF_ITERATIONS = 5;
const MAX_REVIEWER_REJECTIONS = 3;
const MAX_TESTER_REJECTIONS = 3;
function countRejections(steps: { role: string; meta: unknown }[], rejector: string, metaKey: string): number {
return steps.filter((s) => s.role === rejector && !(s.meta as Record<string, boolean>)[metaKey]).length;
}
function countImplementerSelfIterations(steps: { role: string; meta: unknown }[]): number {
return steps.filter((s) => s.role === "implementer" && !(s.meta as Record<string, boolean>).implementationOk).length;
}
export const moderator: Moderator<WorkflowMeta> = (context) => { export const moderator: Moderator<WorkflowMeta> = (context) => {
if (context.steps.length === 0) { if (context.steps.length === 0) {
return "intake"; return "intake";
} }
const last = context.steps[context.steps.length - 1]; const last = context.steps[context.steps.length - 1];
if (last.role === "intake") { if (last.role === "intake") {
const meta = last.meta as WorkflowMeta["intake"]; const meta = last.meta as WorkflowMeta["intake"];
return meta.valid ? "issue-reader" : END; return meta.valid ? "issue-reader" : END;
@ -32,10 +44,7 @@ export const moderator: Moderator<WorkflowMeta> = (context) => {
if (last.role === "issue-reader") { if (last.role === "issue-reader") {
const meta = last.meta as WorkflowMeta["issue-reader"]; const meta = last.meta as WorkflowMeta["issue-reader"];
if (meta.fetchOk) { return meta.fetchOk ? "planner" : END;
return "planner";
}
return END;
} }
if (last.role === "planner") { if (last.role === "planner") {
@ -44,21 +53,20 @@ export const moderator: Moderator<WorkflowMeta> = (context) => {
} }
if (last.role === "implementer") { if (last.role === "implementer") {
return "reviewer"; const meta = last.meta as WorkflowMeta["implementer"];
if (meta.implementationOk) return "reviewer";
return countImplementerSelfIterations(context.steps) < MAX_IMPLEMENTER_SELF_ITERATIONS ? "implementer" : END;
} }
if (last.role === "reviewer") { if (last.role === "reviewer") {
const meta = last.meta as WorkflowMeta["reviewer"]; if (last.meta.approved) return "tester";
if (meta.approved) return "tester"; return countRejections(context.steps, "reviewer", "approved") < MAX_REVIEWER_REJECTIONS ? "implementer" : END;
return "implementer";
} }
if (last.role === "tester") { if (last.role === "tester") {
const meta = last.meta as WorkflowMeta["tester"]; const meta = last.meta as WorkflowMeta["tester"];
if (meta.passed) { if (meta.passed) return "pr-publisher";
return "pr-publisher"; return countRejections(context.steps, "tester", "passed") < MAX_TESTER_REJECTIONS ? "implementer" : END;
}
return meta.attempt < TESTER_MAX_ATTEMPTS ? "implementer" : END;
} }
if (last.role === "pr-publisher") { if (last.role === "pr-publisher") {

View File

@ -14,32 +14,45 @@ export type SenseMeta = {
committer: CommitterMeta; committer: CommitterMeta;
}; };
const MAX_CODER_ITERATIONS = 5; const MAX_CODER_SELF_ITERATIONS = 5;
const MAX_REVIEWER_REJECTIONS = 3;
const MAX_TESTER_REJECTIONS = 3;
const MAX_COMMITTER_REJECTIONS = 2;
function countRole(steps: { role: string }[], name: string): number { function countRejections(steps: { role: string; meta: unknown }[], rejector: string, metaKey: string): number {
return steps.filter((s) => s.role === name).length; return steps.filter((s) => s.role === rejector && !(s.meta as Record<string, boolean>)[metaKey]).length;
}
function countCoderSelfIterations(steps: { role: string; meta: unknown }[]): number {
return steps.filter((s) => s.role === "coder" && !(s.meta as Record<string, boolean>).done).length;
} }
export const moderator: Moderator<SenseMeta> = (context) => { export const moderator: Moderator<SenseMeta> = (context) => {
if (context.steps.length === 0) return "planner"; if (context.steps.length === 0) return "planner";
const last = context.steps[context.steps.length - 1]; const last = context.steps[context.steps.length - 1];
const coderCount = context.steps.filter((s) => s.role === "coder").length;
if (last.role === "planner") return "coder"; if (last.role === "planner") return "coder";
if (last.role === "coder") return "reviewer";
if (last.role === "coder") {
if (last.meta.done) return "reviewer";
return countCoderSelfIterations(context.steps) < MAX_CODER_SELF_ITERATIONS ? "coder" : END;
}
if (last.role === "reviewer") { if (last.role === "reviewer") {
if (last.meta.approved) return "tester"; if (last.meta.approved) return "tester";
return coderCount < MAX_CODER_ITERATIONS ? "coder" : END; return countRejections(context.steps, "reviewer", "approved") < MAX_REVIEWER_REJECTIONS ? "coder" : END;
} }
if (last.role === "tester") { if (last.role === "tester") {
if (last.meta.passed) return "committer"; if (last.meta.passed) return "committer";
const testerCount = countRole(context.steps, "tester"); return countRejections(context.steps, "tester", "passed") < MAX_TESTER_REJECTIONS ? "coder" : END;
if (testerCount < 3 && coderCount < MAX_CODER_ITERATIONS) return "coder";
return END;
} }
if (last.role === "committer") { if (last.role === "committer") {
if (last.meta.success) return END; if (last.meta.success) return END;
return coderCount < MAX_CODER_ITERATIONS ? "coder" : END; return countRejections(context.steps, "committer", "success") < MAX_COMMITTER_REJECTIONS ? "coder" : END;
} }
return END; return END;
}; };

View File

@ -14,13 +14,24 @@ export type WorkflowMeta = {
committer: CommitterMeta; committer: CommitterMeta;
}; };
const MAX_CODER_ITERATIONS = 5; const MAX_CODER_SELF_ITERATIONS = 5;
const MAX_REVIEWER_REJECTIONS = 3;
const MAX_TESTER_REJECTIONS = 3;
const MAX_COMMITTER_REJECTIONS = 2;
function countRejections(steps: { role: string; meta: unknown }[], rejector: string, metaKey: string): number {
return steps.filter((s) => s.role === rejector && !(s.meta as Record<string, boolean>)[metaKey]).length;
}
function countCoderSelfIterations(steps: { role: string; meta: unknown }[]): number {
// coder rounds where done=false (self-iteration, not a rejection retry)
return steps.filter((s) => s.role === "coder" && !(s.meta as Record<string, boolean>).done).length;
}
export const moderator: Moderator<WorkflowMeta> = (context) => { export const moderator: Moderator<WorkflowMeta> = (context) => {
if (context.steps.length === 0) return "planner"; if (context.steps.length === 0) return "planner";
const last = context.steps[context.steps.length - 1]; const last = context.steps[context.steps.length - 1];
const coderCount = context.steps.filter((s) => s.role === "coder").length;
if (last.role === "planner") { if (last.role === "planner") {
return last.meta.ready ? "coder" : END; return last.meta.ready ? "coder" : END;
@ -28,22 +39,23 @@ export const moderator: Moderator<WorkflowMeta> = (context) => {
if (last.role === "coder") { if (last.role === "coder") {
if (last.meta.done) return "reviewer"; if (last.meta.done) return "reviewer";
return coderCount < MAX_CODER_ITERATIONS ? "coder" : END; // coder says not done yet — allow self-iteration
return countCoderSelfIterations(context.steps) < MAX_CODER_SELF_ITERATIONS ? "coder" : END;
} }
if (last.role === "reviewer") { if (last.role === "reviewer") {
if (last.meta.approved) return "tester"; if (last.meta.approved) return "tester";
return coderCount < MAX_CODER_ITERATIONS ? "coder" : END; return countRejections(context.steps, "reviewer", "approved") < MAX_REVIEWER_REJECTIONS ? "coder" : END;
} }
if (last.role === "tester") { if (last.role === "tester") {
if (last.meta.passed) return "committer"; if (last.meta.passed) return "committer";
return coderCount < MAX_CODER_ITERATIONS ? "coder" : END; return countRejections(context.steps, "tester", "passed") < MAX_TESTER_REJECTIONS ? "coder" : END;
} }
if (last.role === "committer") { if (last.role === "committer") {
if (last.meta.success) return END; if (last.meta.success) return END;
return coderCount < MAX_CODER_ITERATIONS ? "coder" : END; return countRejections(context.steps, "committer", "success") < MAX_COMMITTER_REJECTIONS ? "coder" : END;
} }
return END; return END;