feat: support codex and claude reviewers

This commit is contained in:
2026-07-22 02:47:34 +08:00
parent b5618e1b0c
commit d38151ad78
13 changed files with 400 additions and 102 deletions
+4 -2
View File
@@ -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;
+57
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
}
// ---------------------------------------------------------------------------
+128 -9
View File
@@ -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!
});
});
+2 -2
View File
@@ -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");
+2
View File
@@ -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
View File
@@ -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;
+6 -1
View File
@@ -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;
-1
View File
@@ -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);