feat: support codex and claude reviewers
This commit is contained in:
+4
-2
@@ -43,6 +43,7 @@ async function main() {
|
||||
case "start":
|
||||
await startWorkflow({
|
||||
planInput: args.planInput,
|
||||
reviewer: args.reviewer,
|
||||
executor: args.executor,
|
||||
vcs: args.vcs,
|
||||
maxCycles: args.maxCycles,
|
||||
@@ -51,11 +52,11 @@ async function main() {
|
||||
break;
|
||||
|
||||
case "review":
|
||||
await reviewWorkflow({ runId: args.runId });
|
||||
await reviewWorkflow({ runId: args.runId, reviewer: args.reviewer });
|
||||
break;
|
||||
|
||||
case "retry-review":
|
||||
retryReviewWorkflow({ runId: args.runId });
|
||||
retryReviewWorkflow({ runId: args.runId, reviewer: args.reviewer });
|
||||
break;
|
||||
|
||||
case "retry-execute":
|
||||
@@ -65,6 +66,7 @@ async function main() {
|
||||
case "decide":
|
||||
await decideWorkflow({
|
||||
runId: args.runId,
|
||||
reviewer: args.reviewer,
|
||||
decisionInput: args.decisionInput,
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -15,6 +15,63 @@ describe("workflow start arguments", () => {
|
||||
vcs: undefined,
|
||||
maxCycles: undefined,
|
||||
newTask: true,
|
||||
reviewer: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a valid reviewer", () => {
|
||||
expect(parseWorkflowArgs([
|
||||
"start",
|
||||
"--plan", "plan.json",
|
||||
"--executor", "reasonix",
|
||||
"--reviewer", "claude"
|
||||
])).toEqual({
|
||||
sub: "start",
|
||||
planInput: "plan.json",
|
||||
executor: "reasonix",
|
||||
vcs: undefined,
|
||||
maxCycles: undefined,
|
||||
newTask: false,
|
||||
reviewer: "claude",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on invalid reviewer", () => {
|
||||
expect(() => parseWorkflowArgs([
|
||||
"start",
|
||||
"--plan", "plan.json",
|
||||
"--executor", "reasonix",
|
||||
"--reviewer", "invalid"
|
||||
])).toThrow(/must be codex or claude/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow advancing arguments", () => {
|
||||
it("review requires reviewer", () => {
|
||||
expect(() => parseWorkflowArgs(["review", "--run", "run-123"])).toThrow(/--reviewer is required/);
|
||||
expect(parseWorkflowArgs(["review", "--run", "run-123", "--reviewer", "codex"])).toEqual({
|
||||
sub: "review",
|
||||
runId: "run-123",
|
||||
reviewer: "codex"
|
||||
});
|
||||
});
|
||||
|
||||
it("decide requires reviewer", () => {
|
||||
expect(() => parseWorkflowArgs(["decide", "--run", "run-123", "--input", "decision.json"])).toThrow(/--reviewer is required/);
|
||||
expect(parseWorkflowArgs(["decide", "--run", "run-123", "--input", "decision.json", "--reviewer", "claude"])).toEqual({
|
||||
sub: "decide",
|
||||
runId: "run-123",
|
||||
decisionInput: "decision.json",
|
||||
reviewer: "claude"
|
||||
});
|
||||
});
|
||||
|
||||
it("retry-review requires reviewer", () => {
|
||||
expect(() => parseWorkflowArgs(["retry-review", "--run", "run-123"])).toThrow(/--reviewer is required/);
|
||||
expect(parseWorkflowArgs(["retry-review", "--run", "run-123", "--reviewer", "codex"])).toEqual({
|
||||
sub: "retry-review",
|
||||
runId: "run-123",
|
||||
reviewer: "codex"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+44
-12
@@ -6,6 +6,7 @@ export type WorkflowSubcommand = "start" | "review" | "retry-review" | "retry-ex
|
||||
|
||||
export interface WorkflowStartArgs {
|
||||
sub: "start";
|
||||
reviewer?: string;
|
||||
executor?: string;
|
||||
vcs?: string;
|
||||
planInput: string; // file path or "-"
|
||||
@@ -16,11 +17,13 @@ export interface WorkflowStartArgs {
|
||||
export interface WorkflowReviewArgs {
|
||||
sub: "review";
|
||||
runId: string;
|
||||
reviewer: string;
|
||||
}
|
||||
|
||||
export interface WorkflowRetryReviewArgs {
|
||||
sub: "retry-review";
|
||||
runId: string;
|
||||
reviewer: string;
|
||||
}
|
||||
|
||||
export interface WorkflowRetryExecuteArgs {
|
||||
@@ -32,6 +35,7 @@ export interface WorkflowRetryExecuteArgs {
|
||||
export interface WorkflowDecideArgs {
|
||||
sub: "decide";
|
||||
runId: string;
|
||||
reviewer: string;
|
||||
decisionInput: string; // file path or "-"
|
||||
}
|
||||
|
||||
@@ -101,11 +105,11 @@ function parsePositiveInt(flag: string, value: string): number {
|
||||
|
||||
export const WORKFLOW_USAGE = `
|
||||
Usage:
|
||||
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--vcs <git|svn|none>] [--max-cycles <n>] [--new-task]
|
||||
agent-workflow review --run <run-id>
|
||||
agent-workflow retry-review --run <run-id>
|
||||
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--reviewer <codex|claude>] [--vcs <git|svn|none>] [--max-cycles <n>] [--new-task]
|
||||
agent-workflow review --run <run-id> --reviewer <codex|claude>
|
||||
agent-workflow retry-review --run <run-id> --reviewer <codex|claude>
|
||||
agent-workflow retry-execute --run <run-id> [--session <claude-session-id>]
|
||||
agent-workflow decide --run <run-id> --input <decision.json|->
|
||||
agent-workflow decide --run <run-id> --input <decision.json|-> --reviewer <codex|claude>
|
||||
agent-workflow extend --run <run-id> --additional-cycles <n>
|
||||
agent-workflow status --run <run-id> [--json]
|
||||
agent-workflow abort --run <run-id> --reason <text>
|
||||
@@ -119,12 +123,12 @@ Examples:
|
||||
agent-workflow start --plan plan.json --executor reasonix --vcs none
|
||||
agent-workflow start --plan plan.json --executor reasonix --vcs svn
|
||||
agent-workflow start --plan - --executor claude --max-cycles 3 # read plan from stdin
|
||||
agent-workflow review --run 2026-01-01T00-00-00_01_abc123
|
||||
agent-workflow retry-review --run 2026-01-01T00-00-00_01_abc123
|
||||
agent-workflow review --run 2026-01-01T00-00-00_01_abc123 --reviewer codex
|
||||
agent-workflow retry-review --run 2026-01-01T00-00-00_01_abc123 --reviewer codex
|
||||
agent-workflow retry-execute --run 2026-01-01T00-00-00_01_abc123
|
||||
agent-workflow retry-execute --run 2026-01-01T00-00-00_01_abc123 --session 12345678-1234-1234-1234-123456789abc
|
||||
agent-workflow decide --run <id> --input decision.json
|
||||
agent-workflow decide --run <id> --input - # read decision from stdin
|
||||
agent-workflow decide --run <id> --input decision.json --reviewer codex
|
||||
agent-workflow decide --run <id> --input - --reviewer codex # read decision from stdin
|
||||
agent-workflow extend --run <id> --additional-cycles 3
|
||||
agent-workflow status --run <id>
|
||||
agent-workflow status --run <id> --json
|
||||
@@ -149,6 +153,7 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
switch (sub) {
|
||||
case "start": {
|
||||
let executor: string | undefined;
|
||||
let reviewer: string | undefined;
|
||||
let vcs: string | undefined;
|
||||
let planInput: string | undefined;
|
||||
let maxCycles: number | undefined;
|
||||
@@ -160,6 +165,12 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
case "--executor":
|
||||
executor = requireValue(arg, rest);
|
||||
break;
|
||||
case "--reviewer":
|
||||
reviewer = requireValue(arg, rest);
|
||||
if (reviewer !== "codex" && reviewer !== "claude") {
|
||||
throw new WorkflowArgsError("--reviewer must be codex or claude");
|
||||
}
|
||||
break;
|
||||
case "--vcs":
|
||||
vcs = requireValue(arg, rest);
|
||||
break;
|
||||
@@ -183,15 +194,21 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
|
||||
if (!planInput) throw new WorkflowArgsError("--plan is required for workflow start");
|
||||
|
||||
return { sub: "start", executor, vcs, planInput, maxCycles, newTask };
|
||||
return { sub: "start", reviewer, executor, vcs, planInput, maxCycles, newTask };
|
||||
}
|
||||
|
||||
case "review": {
|
||||
let runId: string | undefined;
|
||||
let reviewer: string | undefined;
|
||||
while (rest.length > 0) {
|
||||
const arg = rest.shift()!;
|
||||
if (arg === "--run") {
|
||||
runId = requireValue(arg, rest);
|
||||
} else if (arg === "--reviewer") {
|
||||
reviewer = requireValue(arg, rest);
|
||||
if (reviewer !== "codex" && reviewer !== "claude") {
|
||||
throw new WorkflowArgsError("--reviewer must be codex or claude");
|
||||
}
|
||||
} else if (arg === "-h" || arg === "--help") {
|
||||
workflowUsage(0);
|
||||
} else {
|
||||
@@ -199,15 +216,22 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
}
|
||||
}
|
||||
if (!runId) throw new WorkflowArgsError("--run is required for workflow review");
|
||||
return { sub: "review", runId };
|
||||
if (!reviewer) throw new WorkflowArgsError("--reviewer is required for workflow review");
|
||||
return { sub: "review", runId, reviewer };
|
||||
}
|
||||
|
||||
case "retry-review": {
|
||||
let runId: string | undefined;
|
||||
let reviewer: string | undefined;
|
||||
while (rest.length > 0) {
|
||||
const arg = rest.shift()!;
|
||||
if (arg === "--run") {
|
||||
runId = requireValue(arg, rest);
|
||||
} else if (arg === "--reviewer") {
|
||||
reviewer = requireValue(arg, rest);
|
||||
if (reviewer !== "codex" && reviewer !== "claude") {
|
||||
throw new WorkflowArgsError("--reviewer must be codex or claude");
|
||||
}
|
||||
} else if (arg === "-h" || arg === "--help") {
|
||||
workflowUsage(0);
|
||||
} else {
|
||||
@@ -215,7 +239,8 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
}
|
||||
}
|
||||
if (!runId) throw new WorkflowArgsError("--run is required for workflow retry-review");
|
||||
return { sub: "retry-review", runId };
|
||||
if (!reviewer) throw new WorkflowArgsError("--reviewer is required for workflow retry-review");
|
||||
return { sub: "retry-review", runId, reviewer };
|
||||
}
|
||||
|
||||
case "retry-execute": {
|
||||
@@ -239,11 +264,17 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
|
||||
case "decide": {
|
||||
let runId: string | undefined;
|
||||
let reviewer: string | undefined;
|
||||
let decisionInput: string | undefined;
|
||||
while (rest.length > 0) {
|
||||
const arg = rest.shift()!;
|
||||
if (arg === "--run") {
|
||||
runId = requireValue(arg, rest);
|
||||
} else if (arg === "--reviewer") {
|
||||
reviewer = requireValue(arg, rest);
|
||||
if (reviewer !== "codex" && reviewer !== "claude") {
|
||||
throw new WorkflowArgsError("--reviewer must be codex or claude");
|
||||
}
|
||||
} else if (arg === "--input") {
|
||||
decisionInput = requireValue(arg, rest);
|
||||
} else if (arg === "-h" || arg === "--help") {
|
||||
@@ -253,8 +284,9 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
}
|
||||
}
|
||||
if (!runId) throw new WorkflowArgsError("--run is required for workflow decide");
|
||||
if (!reviewer) throw new WorkflowArgsError("--reviewer is required for workflow decide");
|
||||
if (!decisionInput) throw new WorkflowArgsError("--input is required for workflow decide");
|
||||
return { sub: "decide", runId, decisionInput };
|
||||
return { sub: "decide", runId, reviewer, decisionInput };
|
||||
}
|
||||
|
||||
case "status": {
|
||||
|
||||
+79
-30
@@ -2,9 +2,9 @@
|
||||
* Workflow engine — implements the workflow CLI subcommands:
|
||||
*
|
||||
* start — validates plan, acquires lock, starts first execution cycle
|
||||
* review — generates a review bundle for the Codex host
|
||||
* review — generates a review bundle for the host
|
||||
* retry-review — resumes a cleaned scope-gate review
|
||||
* decide — receives Codex HostDecisionV1, drives state transitions
|
||||
* decide — receives host HostDecisionV1, drives state transitions
|
||||
* extend — extends a budget_exhausted run with additional cycles
|
||||
* status — prints current run state
|
||||
* abort — terminates a run with a reason
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
extractBaselineHead,
|
||||
VcsError,
|
||||
} from "./vcs-provider.js";
|
||||
import { REVIEWER_KINDS } from "./workflow-types.js";
|
||||
import type {
|
||||
CycleRecord,
|
||||
ExecutorKind,
|
||||
@@ -72,6 +73,7 @@ import type {
|
||||
ExecutorSessionHandle,
|
||||
HostDecisionV1,
|
||||
HostReviewBundleV1,
|
||||
ReviewerKind,
|
||||
ScopeViolation,
|
||||
VcsBaseline,
|
||||
VcsKind,
|
||||
@@ -89,7 +91,7 @@ const activeAbortControllers = new Set<AbortController>();
|
||||
|
||||
/**
|
||||
* When the executor cycle that is *about to start* reaches this value, the host
|
||||
* review bundle asks Codex for a more detailed implementation plan so the
|
||||
* review bundle asks the host for a more detailed implementation plan so the
|
||||
* executor can converge faster. Compared against (cycleIndex + 1) in reviewWorkflow.
|
||||
*/
|
||||
export const DETAILED_GUIDANCE_CYCLE_THRESHOLD = 3;
|
||||
@@ -276,6 +278,7 @@ function persistExecutorAttempt(
|
||||
|
||||
export interface StartWorkflowOpts {
|
||||
planInput: string; // file path or "-" for stdin
|
||||
reviewer?: string;
|
||||
executor?: string;
|
||||
vcs?: string;
|
||||
maxCycles?: number;
|
||||
@@ -487,6 +490,14 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
...(opts.maxCycles !== undefined ? { maxCycles: opts.maxCycles } : {}),
|
||||
});
|
||||
|
||||
const reviewer = (opts.reviewer ?? "codex") as ReviewerKind;
|
||||
if (!REVIEWER_KINDS.includes(reviewer)) {
|
||||
throw new WorkflowEngineError(`Invalid reviewer: ${opts.reviewer}. Supported: ${REVIEWER_KINDS.join(", ")}`);
|
||||
}
|
||||
if (reviewer === "claude" && config.executor === "claude") {
|
||||
throw new WorkflowEngineError("Reviewer 'claude' cannot be used with executor 'claude'. Claude cannot self-review.", 4);
|
||||
}
|
||||
|
||||
const vcsProvider = createVcsProvider(vcsKind);
|
||||
const codexThreadId = (opts.codexThreadId ?? process.env.CODEX_THREAD_ID)?.trim();
|
||||
if (opts.newTask && !codexThreadId) {
|
||||
@@ -565,6 +576,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
vcsBaseline,
|
||||
vcsKind,
|
||||
executor: config.executor,
|
||||
reviewer,
|
||||
plan: typedPlan,
|
||||
maxCycles: config.maxCycles,
|
||||
timeoutSeconds: config.timeoutSeconds,
|
||||
@@ -599,6 +611,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
runId,
|
||||
timestamp: state.createdAt,
|
||||
data: {
|
||||
reviewer: reviewer,
|
||||
executor: config.executor,
|
||||
maxCycles: config.maxCycles,
|
||||
vcsKind,
|
||||
@@ -611,6 +624,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
process.stdout.write(`workflow started: ${runId}\n`);
|
||||
process.stdout.write(` vcs: ${vcsKind}\n`);
|
||||
process.stdout.write(` executor: ${config.executor}\n`);
|
||||
process.stdout.write(` reviewer: ${reviewer}\n`);
|
||||
process.stdout.write(` max-cycles: ${config.maxCycles}\n`);
|
||||
if (state.taskId) {
|
||||
process.stdout.write(` task: ${state.taskId}${state.taskSessionReused ? " (session reused)" : ""}\n`);
|
||||
@@ -727,11 +741,12 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
state.updatedAt = nowIso();
|
||||
writeStateSafety(dir, state);
|
||||
|
||||
const effectiveReviewer = state.reviewer ?? "codex";
|
||||
process.stdout.write(`cycle ${cycleIndex} execution completed\n`);
|
||||
process.stdout.write(`status: awaiting_review\n`);
|
||||
process.stdout.write(`next: agent-workflow review --run ${runId}\n`);
|
||||
process.stdout.write(`next: agent-workflow review --run ${runId} --reviewer ${effectiveReviewer}\n`);
|
||||
|
||||
emitWorkflowMeta({ runId, status: state.status, cycleIndex, executor: config.executor });
|
||||
emitWorkflowMeta({ reviewer: effectiveReviewer, runId, status: state.status, cycleIndex, executor: config.executor });
|
||||
return { runId };
|
||||
}
|
||||
|
||||
@@ -741,12 +756,18 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
|
||||
export interface ReviewWorkflowOpts {
|
||||
runId: string;
|
||||
reviewer: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
|
||||
const { state, dir } = requireState(opts.runId);
|
||||
|
||||
const effectiveReviewer = state.reviewer ?? "codex";
|
||||
if (opts.reviewer !== effectiveReviewer) {
|
||||
throw new WorkflowEngineError(`Caller reviewer '${opts.reviewer}' does not match the run reviewer '${effectiveReviewer}'.`, 4);
|
||||
}
|
||||
|
||||
if (state.status !== "awaiting_review") {
|
||||
throw new WorkflowEngineError(
|
||||
`Run '${opts.runId}' is in status '${state.status}', not awaiting_review.`,
|
||||
@@ -821,7 +842,7 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
|
||||
`Scope remediation did not converge: ${outOfScope.sort().join(", ")}`,
|
||||
{ cycleIndex, scopeViolations },
|
||||
);
|
||||
emitWorkflowMeta({ runId: state.runId, status: "needs_human", cycleIndex, scopeViolations });
|
||||
emitWorkflowMeta({ reviewer: effectiveReviewer, runId: state.runId, status: "needs_human", cycleIndex, scopeViolations });
|
||||
throw new WorkflowEngineError("Scope remediation did not converge.", 3);
|
||||
}
|
||||
|
||||
@@ -842,8 +863,9 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
|
||||
for (const violation of scopeViolations) {
|
||||
process.stdout.write(` ${violation.id}: ${violation.path}\n`);
|
||||
}
|
||||
process.stdout.write(`next: agent-workflow decide --run ${opts.runId} --input <decision.json|->\n`);
|
||||
process.stdout.write(`next: agent-workflow decide --run ${opts.runId} --input <decision.json|-> --reviewer ${effectiveReviewer}\n`);
|
||||
emitWorkflowMeta({
|
||||
reviewer: effectiveReviewer,
|
||||
runId: state.runId,
|
||||
status: state.status,
|
||||
cycleIndex,
|
||||
@@ -855,13 +877,14 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
|
||||
const generatedAt = nowIso();
|
||||
appendEvent(dir, { kind: "review_started", runId: state.runId, timestamp: generatedAt, cycleIndex });
|
||||
|
||||
// 4. Persist all evidence needed by the Codex host. The CLI does not invoke
|
||||
// another reviewer: Codex owns review and final acceptance.
|
||||
// 4. Persist all evidence needed by the host. The CLI does not invoke
|
||||
// another reviewer: the host owns review and final acceptance.
|
||||
const hostReviewFile = cycleHostReviewFile(dir, cycleIndex);
|
||||
const nextExecutorCycle = cycleIndex + 1;
|
||||
const detailedGuidanceRequested = nextExecutorCycle >= DETAILED_GUIDANCE_CYCLE_THRESHOLD;
|
||||
const bundle: HostReviewBundleV1 = {
|
||||
version: "1",
|
||||
reviewer: effectiveReviewer,
|
||||
runId: state.runId,
|
||||
cycleIndex,
|
||||
repoRoot: state.repoRoot,
|
||||
@@ -897,7 +920,7 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
|
||||
runId: state.runId,
|
||||
timestamp: generatedAt,
|
||||
cycleIndex,
|
||||
data: { hostReviewFile, diffFile },
|
||||
data: { reviewer: effectiveReviewer, hostReviewFile, diffFile },
|
||||
});
|
||||
|
||||
state.status = "awaiting_host";
|
||||
@@ -905,11 +928,12 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
|
||||
state.updatedAt = generatedAt;
|
||||
writeStateSafety(dir, state);
|
||||
|
||||
process.stdout.write(`Codex review bundle ready (cycle ${cycleIndex})\n`);
|
||||
process.stdout.write(`Review bundle ready for ${effectiveReviewer} (cycle ${cycleIndex})\n`);
|
||||
process.stdout.write(` bundle: ${hostReviewFile}\n`);
|
||||
process.stdout.write(` diff: ${diffFile}\n`);
|
||||
process.stdout.write(`next: agent-workflow decide --run ${opts.runId} --input <decision.json|->\n`);
|
||||
process.stdout.write(`next: agent-workflow decide --run ${opts.runId} --input <decision.json|-> --reviewer ${effectiveReviewer}\n`);
|
||||
emitWorkflowMeta({
|
||||
reviewer: effectiveReviewer,
|
||||
runId: state.runId,
|
||||
status: state.status,
|
||||
cycleIndex,
|
||||
@@ -924,6 +948,7 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
|
||||
|
||||
export interface DecideWorkflowOpts {
|
||||
runId: string;
|
||||
reviewer: string;
|
||||
decisionInput: string; // file path or "-" for stdin
|
||||
cwd?: string;
|
||||
}
|
||||
@@ -931,6 +956,11 @@ export interface DecideWorkflowOpts {
|
||||
export async function decideWorkflow(opts: DecideWorkflowOpts): Promise<void> {
|
||||
const { state, dir } = requireState(opts.runId);
|
||||
|
||||
const effectiveReviewer = state.reviewer ?? "codex";
|
||||
if (opts.reviewer !== effectiveReviewer) {
|
||||
throw new WorkflowEngineError(`Caller reviewer '${opts.reviewer}' does not match the run reviewer '${effectiveReviewer}'.`, 4);
|
||||
}
|
||||
|
||||
const resolvingScope = state.status === "awaiting_scope_resolution";
|
||||
if (state.status !== "awaiting_host" && !resolvingScope) {
|
||||
throw new WorkflowEngineError(
|
||||
@@ -959,6 +989,14 @@ export async function decideWorkflow(opts: DecideWorkflowOpts): Promise<void> {
|
||||
validateHostDecision(decisionObj);
|
||||
const decision = decisionObj as HostDecisionV1;
|
||||
|
||||
if (!decision.reviewer && state.reviewer === undefined) {
|
||||
decision.reviewer = "codex";
|
||||
}
|
||||
|
||||
if (decision.reviewer !== effectiveReviewer) {
|
||||
throw new WorkflowEngineError(`Decision reviewer '${decision.reviewer}' does not match the run reviewer '${effectiveReviewer}'.`, 4);
|
||||
}
|
||||
|
||||
const cycleIndex = state.currentCycle;
|
||||
const cycleRecord = state.cycles.find((c) => c.cycleIndex === cycleIndex)!;
|
||||
|
||||
@@ -988,14 +1026,14 @@ export async function decideWorkflow(opts: DecideWorkflowOpts): Promise<void> {
|
||||
if (!resolvingScope && decision.outcome === "fix") {
|
||||
const accepted = decision.findingDecisions.filter((finding) => finding.disposition === "accept");
|
||||
if (accepted.length === 0) {
|
||||
throw new WorkflowEngineError("Fix decision must accept at least one Codex finding.", 4);
|
||||
throw new WorkflowEngineError("Fix decision must accept at least one reviewer finding.", 4);
|
||||
}
|
||||
const missingSummary = accepted
|
||||
.filter((finding) => !finding.summary?.trim())
|
||||
.map((finding) => finding.findingId);
|
||||
if (missingSummary.length > 0) {
|
||||
throw new WorkflowEngineError(
|
||||
`Accepted Codex findings require a non-empty summary: ${missingSummary.join(", ")}.`,
|
||||
`Accepted reviewer findings require a non-empty summary: ${missingSummary.join(", ")}.`,
|
||||
4,
|
||||
);
|
||||
}
|
||||
@@ -1017,7 +1055,7 @@ export async function decideWorkflow(opts: DecideWorkflowOpts): Promise<void> {
|
||||
runId: state.runId,
|
||||
timestamp: nowIso(),
|
||||
cycleIndex,
|
||||
data: { outcome: decision.outcome, reason: decision.reason },
|
||||
data: { reviewer: effectiveReviewer, outcome: decision.outcome, reason: decision.reason },
|
||||
});
|
||||
|
||||
// Handle decision outcomes
|
||||
@@ -1026,12 +1064,12 @@ export async function decideWorkflow(opts: DecideWorkflowOpts): Promise<void> {
|
||||
state,
|
||||
dir,
|
||||
"needs_human",
|
||||
decision.reason ?? "Codex determined human intervention is required.",
|
||||
decision.reason ?? "Reviewer determined human intervention is required.",
|
||||
{ cycleIndex },
|
||||
);
|
||||
process.stdout.write(`workflow stopped: needs_human\n`);
|
||||
process.stdout.write(`reason: ${decision.reason ?? "human intervention required"}\n`);
|
||||
emitWorkflowMeta({ runId: state.runId, status: "needs_human", cycleIndex });
|
||||
emitWorkflowMeta({ reviewer: effectiveReviewer, runId: state.runId, status: "needs_human", cycleIndex });
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
@@ -1041,9 +1079,9 @@ export async function decideWorkflow(opts: DecideWorkflowOpts): Promise<void> {
|
||||
if (acceptError) {
|
||||
throw new WorkflowEngineError(`Cannot accept: ${acceptError}`, 4);
|
||||
}
|
||||
transitionToTerminal(state, dir, "completed", "Codex accepted — all conditions met.", { cycleIndex });
|
||||
transitionToTerminal(state, dir, "completed", "Reviewer accepted — all conditions met.", { cycleIndex });
|
||||
process.stdout.write(`workflow completed: accepted\n`);
|
||||
emitWorkflowMeta({ runId: state.runId, status: "completed", cycleIndex });
|
||||
emitWorkflowMeta({ reviewer: effectiveReviewer, runId: state.runId, status: "completed", cycleIndex });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -1057,7 +1095,7 @@ export async function decideWorkflow(opts: DecideWorkflowOpts): Promise<void> {
|
||||
`Reached maximum cycles (${state.maxCycles}). Further execution not allowed.`,
|
||||
);
|
||||
process.stdout.write(`workflow stopped: budget_exhausted (max cycles: ${state.maxCycles})\n`);
|
||||
emitWorkflowMeta({ runId: state.runId, status: "budget_exhausted", cycleIndex });
|
||||
emitWorkflowMeta({ reviewer: effectiveReviewer, runId: state.runId, status: "budget_exhausted", cycleIndex });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1067,7 +1105,7 @@ export async function decideWorkflow(opts: DecideWorkflowOpts): Promise<void> {
|
||||
transitionToTerminal(state, dir, "needs_human", convergenceError, { cycleIndex });
|
||||
process.stdout.write(`workflow stopped: needs_human (non-convergence detected)\n`);
|
||||
process.stdout.write(`reason: ${convergenceError}\n`);
|
||||
emitWorkflowMeta({ runId: state.runId, status: "needs_human", cycleIndex });
|
||||
emitWorkflowMeta({ reviewer: effectiveReviewer, runId: state.runId, status: "needs_human", cycleIndex });
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
@@ -1244,18 +1282,19 @@ async function resumeExecutorCycle(
|
||||
state.updatedAt = nowIso();
|
||||
writeStateSafety(dir, state);
|
||||
|
||||
const effectiveReviewer = state.reviewer ?? "codex";
|
||||
process.stdout.write(`cycle ${cycleIndex} execution completed\n`);
|
||||
process.stdout.write(`status: awaiting_review\n`);
|
||||
process.stdout.write(`next: agent-workflow review --run ${state.runId}\n`);
|
||||
emitWorkflowMeta({ runId: state.runId, status: state.status, cycleIndex });
|
||||
process.stdout.write(`next: agent-workflow review --run ${state.runId} --reviewer ${effectiveReviewer}\n`);
|
||||
emitWorkflowMeta({ reviewer: effectiveReviewer, runId: state.runId, status: state.status, cycleIndex });
|
||||
}
|
||||
|
||||
function validateAcceptConditions(state: WorkflowState, dir: string, cycleIndex: number, decision: HostDecisionV1): string | undefined {
|
||||
// 1. Codex must have received a persisted review bundle for this cycle.
|
||||
// 1. The reviewer must have received a persisted review bundle for this cycle.
|
||||
const cycle = state.cycles.find((c) => c.cycleIndex === cycleIndex);
|
||||
if (!cycle?.hostReviewFile) return "No Codex review bundle for this cycle.";
|
||||
if (!cycle?.hostReviewFile) return "No host review bundle for this cycle.";
|
||||
if (!fs.existsSync(path.join(dir, cycle.hostReviewFile))) {
|
||||
return "Codex review bundle is missing from disk.";
|
||||
return "Host review bundle is missing from disk.";
|
||||
}
|
||||
|
||||
// 2. Baseline must not have changed
|
||||
@@ -1345,7 +1384,8 @@ function buildAcceptedFindingsText(
|
||||
): string {
|
||||
const accepted = decision.findingDecisions.filter((d) => d.disposition === "accept");
|
||||
|
||||
const lines = ["# Codex Review Findings", "", "Fix only the following issues accepted by the Codex host:"];
|
||||
const runReviewer = _state.reviewer ?? "codex";
|
||||
const lines = [`# ${runReviewer} Review Findings`, "", `Fix only the following issues accepted by the reviewer:`];
|
||||
for (const d of accepted) {
|
||||
lines.push(`\n### Finding ${d.findingId}`);
|
||||
if (d.path) lines.push(`- Path: ${d.path}`);
|
||||
@@ -1391,10 +1431,17 @@ function buildScopeResolutionText(violations: ScopeViolation[]): string {
|
||||
|
||||
export interface RetryReviewWorkflowOpts {
|
||||
runId: string;
|
||||
reviewer: string;
|
||||
}
|
||||
|
||||
export function retryReviewWorkflow(opts: RetryReviewWorkflowOpts): void {
|
||||
const { state, dir } = requireState(opts.runId);
|
||||
|
||||
const effectiveReviewer = state.reviewer ?? "codex";
|
||||
if (opts.reviewer !== effectiveReviewer) {
|
||||
throw new WorkflowEngineError(`Caller reviewer '${opts.reviewer}' does not match the run reviewer '${effectiveReviewer}'.`, 4);
|
||||
}
|
||||
|
||||
if (state.status !== "awaiting_scope_resolution") {
|
||||
throw new WorkflowEngineError(
|
||||
`Run '${opts.runId}' cannot retry review from status '${state.status}'. Only scope-resolution runs are eligible.`,
|
||||
@@ -1459,8 +1506,8 @@ export function retryReviewWorkflow(opts: RetryReviewWorkflowOpts): void {
|
||||
|
||||
process.stdout.write(`workflow review retry enabled: ${state.runId}\n`);
|
||||
process.stdout.write(`status: awaiting_review\n`);
|
||||
process.stdout.write(`next: agent-workflow review --run ${state.runId}\n`);
|
||||
emitWorkflowMeta({ runId: state.runId, status: state.status, cycleIndex: state.currentCycle });
|
||||
process.stdout.write(`next: agent-workflow review --run ${state.runId} --reviewer ${effectiveReviewer}\n`);
|
||||
emitWorkflowMeta({ reviewer: effectiveReviewer, runId: state.runId, status: state.status, cycleIndex: state.currentCycle });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1867,6 +1914,7 @@ export function statusWorkflow(opts: StatusWorkflowOpts): void {
|
||||
const output: WorkflowStatusOutput = {
|
||||
runId: state.runId,
|
||||
status: state.status,
|
||||
reviewer: state.reviewer ?? "codex",
|
||||
executor: state.executor,
|
||||
currentCycle: state.currentCycle,
|
||||
maxCycles: state.maxCycles,
|
||||
@@ -1909,6 +1957,7 @@ export function statusWorkflow(opts: StatusWorkflowOpts): void {
|
||||
"── agent-workflow ─────────────────────",
|
||||
p("Run ID", state.runId),
|
||||
p("Status", state.status.toUpperCase()),
|
||||
p("Reviewer", state.reviewer ?? "codex"),
|
||||
p("Executor", state.executor),
|
||||
p("Cycle", `${state.currentCycle} / ${state.maxCycles} (${output.remainingCycles} remaining)`),
|
||||
];
|
||||
@@ -1986,7 +2035,7 @@ export function abortWorkflow(opts: AbortWorkflowOpts): void {
|
||||
}
|
||||
|
||||
process.stdout.write(`workflow aborted: ${opts.runId}\nreason: ${opts.reason}\n`);
|
||||
emitWorkflowMeta({ runId: opts.runId, status: "aborted", reason: opts.reason });
|
||||
emitWorkflowMeta({ reviewer: state.reviewer ?? "codex", runId: opts.runId, status: "aborted", reason: opts.reason });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -105,7 +105,7 @@ else
|
||||
mkdir -p "$HOME/.reasonix/projects/mock-project/sessions"
|
||||
echo '{"role": "system", "content": "Current workspace: \\"'"$3"'\\""}' > "$HOME/.reasonix/projects/mock-project/sessions/session_1.jsonl"
|
||||
echo "{}" > "$HOME/.reasonix/projects/mock-project/sessions/session_1.jsonl.meta"
|
||||
|
||||
|
||||
# Create a newer subagent session to verify it gets correctly ignored
|
||||
mkdir -p "$HOME/.reasonix/projects/mock-project/sessions/subagents"
|
||||
sleep 1
|
||||
@@ -151,7 +151,7 @@ fi
|
||||
expect(state.currentCycle).toBe(1);
|
||||
|
||||
// --- STEP 2: SCOPE GATE (Cycle 1, Codex review bundle is not ready yet) ---
|
||||
await reviewWorkflow({ runId, cwd: tmpDir });
|
||||
await reviewWorkflow({ runId, cwd: tmpDir, reviewer: "codex" });
|
||||
let state2 = readState(runDir(repoRoot, runId))!;
|
||||
expect(state2.status).toBe("awaiting_scope_resolution");
|
||||
expect(state2.cycles[0].scopeViolations).toEqual([
|
||||
@@ -160,21 +160,27 @@ fi
|
||||
]);
|
||||
expect(state2.cycles[0].hostReviewFile).toBeUndefined();
|
||||
|
||||
const events2 = fs.readFileSync(path.join(runDir(repoRoot, runId), "events.jsonl"), "utf8").trim().split("\n").map(l => JSON.parse(l));
|
||||
const startedEvent = events2.find(e => e.kind === "workflow_started");
|
||||
expect(startedEvent?.data?.reviewer).toBe("codex");
|
||||
|
||||
const incompleteDecision: HostDecisionV1 = {
|
||||
version: "1",
|
||||
reviewer: "codex",
|
||||
outcome: "fix",
|
||||
findingDecisions: [{ findingId: "scope-1", disposition: "accept" }],
|
||||
decidedAt: new Date().toISOString(),
|
||||
};
|
||||
const incompleteDecisionPath = path.join(process.env.HOME!, "decision-incomplete.json");
|
||||
fs.writeFileSync(incompleteDecisionPath, JSON.stringify(incompleteDecision));
|
||||
await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: incompleteDecisionPath }))
|
||||
await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: incompleteDecisionPath, reviewer: "codex" }))
|
||||
.rejects.toThrow(/accept every current violation exactly once/);
|
||||
expect(readState(runDir(repoRoot, runId))?.status).toBe("awaiting_scope_resolution");
|
||||
|
||||
// --- STEP 3: DECIDE (Scope fix using the same Reasonix session) ---
|
||||
const scopeDecision: HostDecisionV1 = {
|
||||
version: "1",
|
||||
reviewer: "codex",
|
||||
outcome: "fix",
|
||||
findingDecisions: [
|
||||
{ findingId: "scope-1", disposition: "accept" },
|
||||
@@ -184,7 +190,7 @@ fi
|
||||
};
|
||||
const scopeDecisionPath = path.join(process.env.HOME!, "decision-scope.json");
|
||||
fs.writeFileSync(scopeDecisionPath, JSON.stringify(scopeDecision));
|
||||
await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: scopeDecisionPath }))
|
||||
await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: scopeDecisionPath, reviewer: "codex" }))
|
||||
.rejects.toThrow(/Executor failed/);
|
||||
expect(readState(runDir(repoRoot, runId))?.status).toBe("blocked");
|
||||
|
||||
@@ -196,8 +202,15 @@ fi
|
||||
expect(fs.existsSync(path.join(tmpDir, "tmp_oc.db-shm"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpDir, "tmp_oc.db-wal"))).toBe(false);
|
||||
|
||||
const events3 = fs.readFileSync(path.join(runDir(repoRoot, runId), "events.jsonl"), "utf8").trim().split("\n").map(l => JSON.parse(l));
|
||||
const decisionEvent = events3.find(e => e.kind === "decision_received" && e.cycleIndex === 1);
|
||||
expect(decisionEvent?.data?.reviewer).toBe("codex");
|
||||
|
||||
const savedDecision1 = JSON.parse(fs.readFileSync(path.join(runDir(repoRoot, runId), "cycle-1-decision.json"), "utf8"));
|
||||
expect(savedDecision1.reviewer).toBe("codex");
|
||||
|
||||
// --- STEP 4: PREPARE CODEX REVIEW (Cycle 2) ---
|
||||
await reviewWorkflow({ runId, cwd: tmpDir });
|
||||
await reviewWorkflow({ runId, cwd: tmpDir, reviewer: "codex" });
|
||||
let state4 = readState(runDir(repoRoot, runId))!;
|
||||
expect(state4.status).toBe("awaiting_host");
|
||||
expect(state4.cycles[1].hostReviewFile).toBe("cycle-2-host-review.json");
|
||||
@@ -205,6 +218,11 @@ fi
|
||||
path.join(runDir(repoRoot, runId), state4.cycles[1].hostReviewFile!),
|
||||
"utf8",
|
||||
));
|
||||
expect(reviewBundle.reviewer).toBe("codex");
|
||||
|
||||
const events4 = fs.readFileSync(path.join(runDir(repoRoot, runId), "events.jsonl"), "utf8").trim().split("\n").map(l => JSON.parse(l));
|
||||
const reviewCompletedEvent = events4.find(e => e.kind === "review_completed" && e.cycleIndex === 2);
|
||||
expect(reviewCompletedEvent?.data?.reviewer).toBe("codex");
|
||||
expect(reviewBundle.repoRoot).toBe(repoRoot);
|
||||
expect(reviewBundle.diffFile).toBe(path.join(runDir(repoRoot, runId), "cycle-2.diff"));
|
||||
expect(reviewBundle.executorLogFile).toBe(path.join(runDir(repoRoot, runId), "cycle-2-exec-retry-1.log"));
|
||||
@@ -223,19 +241,21 @@ fi
|
||||
// --- STEP 5: CODEX DECIDES FIX ---
|
||||
const incompleteFix: HostDecisionV1 = {
|
||||
version: "1",
|
||||
reviewer: "codex",
|
||||
outcome: "fix",
|
||||
findingDecisions: [{ findingId: "codex-1", disposition: "accept" }],
|
||||
decidedAt: new Date().toISOString(),
|
||||
};
|
||||
const incompleteFixPath = path.join(process.env.HOME!, "decision-fix-incomplete.json");
|
||||
fs.writeFileSync(incompleteFixPath, JSON.stringify(incompleteFix));
|
||||
await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: incompleteFixPath }))
|
||||
await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: incompleteFixPath, reviewer: "codex" }))
|
||||
.rejects.toThrow(/require a non-empty summary/);
|
||||
expect(readState(runDir(repoRoot, runId))?.status).toBe("awaiting_host");
|
||||
|
||||
const guidanceMarkerText = "Replace `const x = 2;` in index.ts with `const x = 3;`";
|
||||
const fixDecision: HostDecisionV1 = {
|
||||
version: "1",
|
||||
reviewer: "codex",
|
||||
outcome: "fix",
|
||||
findingDecisions: [{
|
||||
findingId: "codex-1",
|
||||
@@ -248,7 +268,7 @@ fi
|
||||
};
|
||||
const fixDecisionPath = path.join(process.env.HOME!, "decision-fix.json");
|
||||
fs.writeFileSync(fixDecisionPath, JSON.stringify(fixDecision));
|
||||
await decideWorkflow({ runId, cwd: tmpDir, decisionInput: fixDecisionPath });
|
||||
await decideWorkflow({ runId, cwd: tmpDir, decisionInput: fixDecisionPath, reviewer: "codex" });
|
||||
|
||||
let state5 = readState(runDir(repoRoot, runId))!;
|
||||
expect(state5.status).toBe("awaiting_review");
|
||||
@@ -266,7 +286,7 @@ fi
|
||||
expect(cycle3Prompt).toContain("Add an index.ts file.");
|
||||
|
||||
// --- STEP 6: PREPARE CODEX RE-REVIEW (Cycle 3) ---
|
||||
await reviewWorkflow({ runId, cwd: tmpDir });
|
||||
await reviewWorkflow({ runId, cwd: tmpDir, reviewer: "codex" });
|
||||
let state6 = readState(runDir(repoRoot, runId))!;
|
||||
expect(state6.status).toBe("awaiting_host");
|
||||
expect(state6.cycles[2].hostReviewFile).toBe("cycle-3-host-review.json");
|
||||
@@ -274,6 +294,7 @@ fi
|
||||
// --- STEP 7: DECIDE (Accept) ---
|
||||
const acceptDecision: HostDecisionV1 = {
|
||||
version: "1",
|
||||
reviewer: "codex",
|
||||
outcome: "accept",
|
||||
findingDecisions: [],
|
||||
verificationEvidence: ["verified output"],
|
||||
@@ -282,12 +303,110 @@ fi
|
||||
const acceptDecisionPath = path.join(process.env.HOME!, "decision2.json");
|
||||
fs.writeFileSync(acceptDecisionPath, JSON.stringify(acceptDecision));
|
||||
try {
|
||||
await decideWorkflow({ runId, cwd: tmpDir, decisionInput: acceptDecisionPath });
|
||||
await decideWorkflow({ runId, cwd: tmpDir, decisionInput: acceptDecisionPath, reviewer: "codex" });
|
||||
} catch (err: any) {
|
||||
if (err.message !== "MockExit0") throw err;
|
||||
}
|
||||
|
||||
let state7 = readState(runDir(repoRoot, runId))!;
|
||||
expect(state7.status).toBe("completed");
|
||||
|
||||
});
|
||||
it("rejects claude executor with claude reviewer", async () => {
|
||||
const executorScript = `echo "v1.0.0"
|
||||
exit 0`;
|
||||
const executorBin = writeFakeCli(tmpDir, "fake-executor.sh", executorScript);
|
||||
fs.writeFileSync(path.join(tmpDir, "agent-workflow.json"), JSON.stringify({
|
||||
version: "1",
|
||||
executors: { claude: { binary: executorBin } }
|
||||
}));
|
||||
const planPath = path.join(tmpDir, "plan.json");
|
||||
fs.writeFileSync(planPath, JSON.stringify({
|
||||
version: "1",
|
||||
title: "Title",
|
||||
planMarkdown: "Plan",
|
||||
scope: ["index.ts"],
|
||||
acceptanceCriteria: ["ac"],
|
||||
verificationCommands: [["cmd"]],
|
||||
}));
|
||||
require("child_process").execFileSync("git", ["add", "."], { cwd: tmpDir });
|
||||
require("child_process").execFileSync("git", ["commit", "-m", "test1"], { cwd: tmpDir });
|
||||
await expect(startWorkflow({
|
||||
cwd: tmpDir,
|
||||
executor: "claude",
|
||||
reviewer: "claude",
|
||||
planInput: planPath,
|
||||
})).rejects.toThrow(/Claude cannot self-review/i);
|
||||
});
|
||||
|
||||
it("enforces frozen reviewer identity and checks legacy fallback", async () => {
|
||||
const executorScript = `
|
||||
if [[ "$*" == *"--version"* ]]; then
|
||||
echo "v1.0.0"
|
||||
exit 0
|
||||
fi
|
||||
echo "const x = 2;" > "$3/index.ts"
|
||||
exit 0
|
||||
`;
|
||||
const executorBin = writeFakeCli(tmpDir, "fake-executor.sh", executorScript);
|
||||
fs.writeFileSync(path.join(tmpDir, "agent-workflow.json"), JSON.stringify({
|
||||
version: "1",
|
||||
executors: { reasonix: { binary: executorBin } }
|
||||
}));
|
||||
const planPath = path.join(tmpDir, "plan.json");
|
||||
fs.writeFileSync(planPath, JSON.stringify({
|
||||
version: "1",
|
||||
title: "Title",
|
||||
planMarkdown: "Plan",
|
||||
scope: ["index.ts"],
|
||||
acceptanceCriteria: ["ac"],
|
||||
verificationCommands: [["cmd"]],
|
||||
}));
|
||||
|
||||
require("child_process").execFileSync("git", ["add", "."], { cwd: tmpDir });
|
||||
require("child_process").execFileSync("git", ["commit", "-m", "test2"], { cwd: tmpDir });
|
||||
// Start with claude reviewer
|
||||
const { runId } = await startWorkflow({
|
||||
cwd: tmpDir,
|
||||
executor: "reasonix",
|
||||
reviewer: "claude",
|
||||
planInput: planPath,
|
||||
});
|
||||
|
||||
// Mismatched reviewer
|
||||
await expect(reviewWorkflow({ runId, cwd: tmpDir, reviewer: "codex" }))
|
||||
.rejects.toThrow(/does not match/i);
|
||||
|
||||
// Correct reviewer
|
||||
await reviewWorkflow({ runId, cwd: tmpDir, reviewer: "claude" });
|
||||
|
||||
// Test legacy fallback: overwrite state to remove reviewer
|
||||
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: tmpDir, encoding: "utf8" }).trim();
|
||||
const sPath = path.join(runDir(repoRoot, runId), "state.json");
|
||||
const stateObj = JSON.parse(fs.readFileSync(sPath, "utf8"));
|
||||
delete stateObj.reviewer;
|
||||
fs.writeFileSync(sPath, JSON.stringify(stateObj));
|
||||
|
||||
// After removing reviewer, claude should be rejected
|
||||
await expect(reviewWorkflow({ runId, cwd: tmpDir, reviewer: "claude" }))
|
||||
.rejects.toThrow(/does not match/i);
|
||||
|
||||
// codex should be allowed (legacy fallback)
|
||||
// To do this we must reset the cycle or test a decide/retry-review
|
||||
const decisionPath = path.join(process.env.HOME!, "decision.json");
|
||||
fs.writeFileSync(decisionPath, JSON.stringify({
|
||||
version: "1",
|
||||
reviewer: "codex",
|
||||
outcome: "fix",
|
||||
findingDecisions: [{ findingId: "codex-1", disposition: "accept", summary: "sum" }],
|
||||
decidedAt: new Date().toISOString()
|
||||
}));
|
||||
await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: decisionPath, reviewer: "claude" }))
|
||||
.rejects.toThrow(/does not match/i);
|
||||
|
||||
// Works with codex because legacy defaults to codex
|
||||
// But since it's in awaiting_host, we need a valid hostReviewFile. We skipped some mock setup so decideWorkflow might fail on executor start, but it passes the reviewer gate!
|
||||
await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: decisionPath, reviewer: "codex" }))
|
||||
.rejects.toThrow(/Cannot resume: no session handle/); // Means it passed the reviewer gate!
|
||||
});
|
||||
});
|
||||
|
||||
@@ -521,7 +521,7 @@ describe("None VCS Provider (Snapshot Mode)", () => {
|
||||
fs.writeFileSync(path.join(tmpDir, "src", "code.txt"), "console.log(2)\n", "utf8");
|
||||
|
||||
// reviewWorkflow must succeed (generates diff, validates scope)
|
||||
await reviewWorkflow({ runId, cwd: tmpDir });
|
||||
await reviewWorkflow({ runId, cwd: tmpDir, reviewer: "codex" });
|
||||
const updatedState = readState(wDir!);
|
||||
expect(updatedState!.status).toBe("awaiting_host");
|
||||
|
||||
@@ -533,7 +533,7 @@ describe("None VCS Provider (Snapshot Mode)", () => {
|
||||
fs.writeFileSync(path.join(wDir!, "state.json"), JSON.stringify(updatedState, null, 2), "utf8");
|
||||
|
||||
// reviewWorkflow must transition run to awaiting_scope_resolution due to out-of-scope files
|
||||
await reviewWorkflow({ runId, cwd: tmpDir });
|
||||
await reviewWorkflow({ runId, cwd: tmpDir, reviewer: "codex" });
|
||||
const blockedState = readState(wDir!);
|
||||
expect(blockedState!.status).toBe("awaiting_scope_resolution");
|
||||
|
||||
|
||||
@@ -311,6 +311,7 @@ export function initWorkflowState(opts: {
|
||||
vcsBaseline?: VcsBaseline;
|
||||
vcsKind?: import("./workflow-types.js").VcsKind;
|
||||
executor: import("./workflow-types.js").ExecutorKind;
|
||||
reviewer: import("./workflow-types.js").ReviewerKind;
|
||||
plan: import("./workflow-types.js").WorkflowPlanV1;
|
||||
maxCycles: number;
|
||||
timeoutSeconds?: number;
|
||||
@@ -325,6 +326,7 @@ export function initWorkflowState(opts: {
|
||||
...(opts.vcsBaseline ? { vcsBaseline: opts.vcsBaseline } : {}),
|
||||
...(opts.vcsKind ? { vcsKind: opts.vcsKind } : {}),
|
||||
executor: opts.executor,
|
||||
reviewer: opts.reviewer,
|
||||
plan: opts.plan,
|
||||
status: "executing",
|
||||
maxCycles: opts.maxCycles,
|
||||
|
||||
+28
-17
@@ -1,13 +1,20 @@
|
||||
/**
|
||||
* Workflow types for the agent-workflow host orchestration loop.
|
||||
*
|
||||
* WorkflowPlanV1 — input from Codex describing what to implement
|
||||
* WorkflowPlanV1 — input from the reviewer describing what to implement
|
||||
* WorkflowState — durable run state (persisted as state.json)
|
||||
* HostDecisionV1 — Codex decision after reviewing findings
|
||||
* HostDecisionV1 — reviewer decision after reviewing findings
|
||||
* ExecutorKind — the three supported executor adapters
|
||||
* WorkflowStatus — machine-readable status summary
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reviewers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const REVIEWER_KINDS = ["codex", "claude"] as const;
|
||||
export type ReviewerKind = (typeof REVIEWER_KINDS)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Executors
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -78,7 +85,7 @@ export interface ProgressSidecar {
|
||||
export type ProgressSidecarUpdate = Partial<Omit<ProgressSidecar, "executor" | "cycleIndex" | "attemptIndex" | "startedAt">>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan (input from Codex)
|
||||
// Plan (input from reviewer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorkflowPlanV1 {
|
||||
@@ -173,15 +180,15 @@ export interface CycleRecord {
|
||||
executorAttemptLogs?: string[];
|
||||
/** Path to the diff file generated after this cycle (relative to run dir). */
|
||||
diffFile?: string;
|
||||
/** Path to the Codex host review bundle (relative to run dir). */
|
||||
/** Path to the host review bundle (relative to run dir). */
|
||||
hostReviewFile?: string;
|
||||
/** Time at which the review bundle became ready for Codex. */
|
||||
/** Time at which the review bundle became ready for the reviewer. */
|
||||
hostReviewReadyAt?: string;
|
||||
/** Files that failed the frozen scope gate before Codex review. */
|
||||
/** Files that failed the frozen scope gate before reviewer review. */
|
||||
scopeViolations?: ScopeViolation[];
|
||||
/** Path to the Codex decision file (relative to run dir). */
|
||||
/** Path to the reviewer decision file (relative to run dir). */
|
||||
decisionFile?: string;
|
||||
/** Codex decision outcome for this cycle. */
|
||||
/** Reviewer decision outcome for this cycle. */
|
||||
decisionOutcome?: HostDecisionOutcome;
|
||||
/** Cycle-level stop reason when this cycle triggered termination. */
|
||||
stopReason?: WorkflowTerminalStatus;
|
||||
@@ -206,6 +213,7 @@ export interface WorkflowState {
|
||||
vcsKind?: VcsKind;
|
||||
/** Directory used to load agent-workflow.json; persisted for per-cycle model reloads. */
|
||||
configSourceRoot?: string;
|
||||
reviewer?: ReviewerKind;
|
||||
executor: ExecutorKind;
|
||||
executorConfig?: ExecutorConfig;
|
||||
/** Consecutive executor inactivity allowed before the host aborts the attempt. */
|
||||
@@ -244,18 +252,18 @@ export interface ExtensionRecord {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host decision (from Codex)
|
||||
// Host decision (from reviewer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const HOST_DECISION_OUTCOMES = ["fix", "accept", "needs_human"] as const;
|
||||
export type HostDecisionOutcome = (typeof HOST_DECISION_OUTCOMES)[number];
|
||||
|
||||
export interface FindingDecision {
|
||||
/** Stable id assigned by Codex, or scope-* for a scope violation. */
|
||||
/** Stable id assigned by the reviewer, or scope-* for a scope violation. */
|
||||
findingId: string;
|
||||
/** "accept" = fix it, "reject" = acknowledged but out of scope, "followup" = record and move on. */
|
||||
disposition: "accept" | "reject" | "followup";
|
||||
/** Concrete issue description. Required for accepted Codex findings. */
|
||||
/** Concrete issue description. Required for accepted findings. */
|
||||
summary?: string;
|
||||
/** Repository-relative location when known. */
|
||||
path?: string;
|
||||
@@ -265,14 +273,15 @@ export interface FindingDecision {
|
||||
export interface HostDecisionV1 {
|
||||
/** Schema version — always "1". */
|
||||
version: "1";
|
||||
reviewer: ReviewerKind;
|
||||
outcome: HostDecisionOutcome;
|
||||
/** Findings Codex decided on. */
|
||||
/** Findings the reviewer decided on. */
|
||||
findingDecisions: FindingDecision[];
|
||||
/** Reason for needs_human or rejection rationale. */
|
||||
reason?: string;
|
||||
/** Follow-up items to record (not to fix now). */
|
||||
followUps?: string[];
|
||||
/** Codex-supplied verification evidence (command outputs, test results). */
|
||||
/** Reviewer-supplied verification evidence (command outputs, test results). */
|
||||
verificationEvidence?: string[];
|
||||
/**
|
||||
* Optional detailed, step-by-step implementation plan the host provides so the
|
||||
@@ -281,13 +290,14 @@ export interface HostDecisionV1 {
|
||||
* detailedGuidanceRequested (the run is approaching/at the guidance threshold).
|
||||
*/
|
||||
implementationGuidance?: string;
|
||||
/** ISO timestamp when Codex submitted this decision. */
|
||||
/** ISO timestamp when the reviewer submitted this decision. */
|
||||
decidedAt: string;
|
||||
}
|
||||
|
||||
/** Persisted evidence package that Codex must inspect before deciding. */
|
||||
/** Persisted evidence package that the reviewer must inspect before deciding. */
|
||||
export interface HostReviewBundleV1 {
|
||||
version: "1";
|
||||
reviewer: ReviewerKind;
|
||||
runId: string;
|
||||
cycleIndex: number;
|
||||
repoRoot: string;
|
||||
@@ -302,12 +312,12 @@ export interface HostReviewBundleV1 {
|
||||
verificationCommands: string[][];
|
||||
/**
|
||||
* True when the run is at or past the guidance threshold (the executor cycle
|
||||
* about to start >= DETAILED_GUIDANCE_CYCLE_THRESHOLD). Signals Codex to return
|
||||
* about to start >= DETAILED_GUIDANCE_CYCLE_THRESHOLD). Signals the reviewer to return
|
||||
* a more detailed implementation plan if it decides "fix".
|
||||
*/
|
||||
detailedGuidanceRequested?: boolean;
|
||||
/**
|
||||
* Human-meaningful instructions telling Codex what extra detail to supply.
|
||||
* Human-meaningful instructions telling the reviewer what extra detail to supply.
|
||||
* Only set when detailedGuidanceRequested is true.
|
||||
*/
|
||||
reviewerInstructions?: string;
|
||||
@@ -358,6 +368,7 @@ export interface ResolvedWorkflowConfig {
|
||||
export interface WorkflowStatusOutput {
|
||||
runId: string;
|
||||
status: WorkflowStatus;
|
||||
reviewer: ReviewerKind;
|
||||
executor: ExecutorKind;
|
||||
currentCycle: number;
|
||||
maxCycles: number;
|
||||
|
||||
@@ -843,7 +843,11 @@ export function getAssetsHtml(): string {
|
||||
<div class="meta-list">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">执行器</span>
|
||||
<span class="meta-value" id="view-executor">claude</span>
|
||||
<span class="meta-value" id="view-executor" style="text-transform: capitalize;">claude</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">审查者</span>
|
||||
<span class="meta-value" id="view-reviewer" style="text-transform: capitalize;">codex</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">版本控制</span>
|
||||
@@ -1316,6 +1320,7 @@ export function getAssetsHtml(): string {
|
||||
});
|
||||
|
||||
document.getElementById('view-executor').textContent = activeRun.executor;
|
||||
document.getElementById('view-reviewer').textContent = activeRun.reviewer || 'codex';
|
||||
document.getElementById('view-vcs').textContent = activeRun.vcsKind || 'none';
|
||||
document.getElementById('view-max-cycles').textContent = activeRun.maxCycles;
|
||||
document.getElementById('view-current-cycle').textContent = activeRun.currentCycle;
|
||||
|
||||
@@ -141,7 +141,6 @@ printf '%s\\n' '{"providers":[{"name":"test-provider","models":["test-model"]}]}
|
||||
const res = await makeRequest(`${baseUrl}/api/runs`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toContain("application/json");
|
||||
|
||||
const runsList = JSON.parse(res.body);
|
||||
expect(runsList).toHaveLength(1);
|
||||
expect(runsList[0].runId).toBe(runId);
|
||||
|
||||
Reference in New Issue
Block a user