Merge pull request 'refactor: replace maxRounds with supervisor check interval' (#186) from refactor/185-remove-max-rounds into main

This commit is contained in:
2026-05-11 09:01:24 +00:00
32 changed files with 88 additions and 217 deletions
@@ -45,7 +45,7 @@ describe("gc cli and garbageCollectCas", () => {
{
name: "demo",
hash: bundleHash,
maxRounds: 5,
depth: 0,
},
promptHash,
@@ -100,7 +100,7 @@ describe("gc cli and garbageCollectCas", () => {
{
name: "demo",
hash: bundleHash,
maxRounds: 5,
depth: 0,
},
promptHash,
@@ -135,7 +135,7 @@ describe("gc cli and garbageCollectCas", () => {
{
name: "demo",
hash: bundleHash,
maxRounds: 5,
depth: 0,
},
promptHash,
@@ -156,13 +156,12 @@ export function createThreadRoutes(storageRoot: string): Hono {
const name = body.workflow;
const prompt = body.prompt;
const maxRounds = typeof body.maxRounds === "number" ? body.maxRounds : 10;
if (typeof name !== "string" || typeof prompt !== "string") {
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
}
const result = await cmdRun(storageRoot, name, prompt, maxRounds);
const result = await cmdRun(storageRoot, name, prompt);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
@@ -26,12 +26,7 @@ export async function dispatchRun(storageRoot: string, argv: string[]): Promise<
return 1;
}
const result = await cmdRun(
storageRoot,
parsed.value.name,
parsed.value.prompt,
parsed.value.maxRounds,
);
const result = await cmdRun(storageRoot, parsed.value.name, parsed.value.prompt);
if (!result.ok) {
printCliError(result.error);
return 1;
@@ -166,7 +161,7 @@ export async function dispatchFork(storageRoot: string, argv: string[]): Promise
export const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
run: {
handler: dispatchRun,
args: "<name> [--prompt <text>] [--max-rounds N]",
args: "<name> [--prompt <text>]",
description: "Start a new thread executing a workflow",
},
list: {
@@ -10,7 +10,6 @@ export async function cmdRun(
storageRoot: string,
name: string,
prompt: string,
maxRounds: number,
): Promise<Result<{ threadId: string }, string>> {
const nameOk = validateCliWorkflowName(name);
if (!nameOk.ok) {
@@ -41,7 +40,7 @@ export async function cmdRun(
threadId,
workflowName: name,
prompt,
options: { maxRounds, depth: 0 },
options: { depth: 0 },
},
{ awaitResponseLine: false },
);
+6 -23
View File
@@ -3,12 +3,12 @@ import { err, ok, type Result } from "@uncaged/workflow-protocol";
export type ParsedRunArgv = {
name: string;
prompt: string;
maxRounds: number;
};
type FlagOk = { kind: "prompt"; value: string } | { kind: "max-rounds"; value: number };
function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | null {
function parseFlagAt(
argv: string[],
index: number,
): Result<{ kind: "prompt"; value: string }, string> | null {
const flag = argv[index];
if (flag === "--prompt") {
const value = argv[index + 1];
@@ -17,24 +17,12 @@ function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | nu
}
return ok({ kind: "prompt", value });
}
if (flag === "--max-rounds") {
const value = argv[index + 1];
if (value === undefined) {
return err("missing value for --max-rounds");
}
const n = Number(value);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
return err("--max-rounds must be a non-negative integer");
}
return ok({ kind: "max-rounds", value: n });
}
return null;
}
export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
let name: string | undefined;
let prompt = "";
let maxRounds = 10;
let i = 0;
const first = argv[0];
@@ -54,12 +42,7 @@ export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
}
const flag = parsed.value;
if (flag.kind === "prompt") {
prompt = flag.value;
i += 2;
continue;
}
maxRounds = flag.value;
prompt = flag.value;
i += 2;
}
@@ -67,5 +50,5 @@ export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
return err("run requires <name>");
}
return ok({ name, prompt, maxRounds });
return ok({ name, prompt });
}
-6
View File
@@ -107,12 +107,6 @@ ${commandSections.join("\n\n")}
| \`completed\` | Finished with \`returnCode === 0\` (has \`__end__\` frame in CAS) |
| \`failed\` | Finished with non-zero return code, or worker crashed (dead PID / no ctl) |
## Defaults
| Setting | CLI | HTTP API |
|---------|-----|----------|
| \`maxRounds\` | 10 | 10 |
## Exit Codes
| Code | Meaning |
@@ -8,7 +8,7 @@ function makeCtx(userContent: string): AgentContext {
start: {
role: START,
content: userContent,
meta: { maxRounds: 10 },
meta: {},
timestamp: 1,
},
depth: 0,
-1
View File
@@ -21,7 +21,6 @@ function isStartPayload(value: unknown): value is StartNodePayload {
return (
typeof value.name === "string" &&
typeof value.hash === "string" &&
typeof value.maxRounds === "number" &&
typeof value.depth === "number"
);
}
+1 -2
View File
@@ -133,9 +133,8 @@ export function runThread(
agent: string,
workflow: string,
prompt: string,
maxRounds: number = 10,
): Promise<{ threadId: string }> {
return postJson(agentBase(agent), "/threads", { workflow, prompt, maxRounds });
return postJson(agentBase(agent), "/threads", { workflow, prompt });
}
export function killThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
+1 -1
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { hasApiKey, clearApiKey } from "./api.ts";
import { clearApiKey, hasApiKey } from "./api.ts";
import { LoginPage } from "./components/login.tsx";
import { RunDialog } from "./components/run-dialog.tsx";
import { Sidebar } from "./components/sidebar.tsx";
@@ -1,10 +1,10 @@
import ReactMarkdown from "react-markdown";
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import {
createHighlighter,
type HighlighterGeneric,
type BundledLanguage,
type BundledTheme,
createHighlighter,
type HighlighterGeneric,
} from "shiki";
let highlighterPromise: Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> | null = null;
@@ -1,4 +1,4 @@
import type { ThreadStartRecord, RoleRecord, WorkflowResultRecord, ThreadRecord } from "../api.ts";
import type { RoleRecord, ThreadRecord, ThreadStartRecord, WorkflowResultRecord } from "../api.ts";
import { Markdown } from "./markdown.tsx";
const ROLE_COLORS: Record<string, string> = {
@@ -12,7 +12,6 @@ export function RunDialog({ agent, onClose, onCreated }: Props) {
const workflows = useFetch(() => listWorkflows(agent), [agent]);
const [workflow, setWorkflow] = useState("");
const [prompt, setPrompt] = useState("");
const [maxRounds, setMaxRounds] = useState(10);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -22,7 +21,7 @@ export function RunDialog({ agent, onClose, onCreated }: Props) {
setSubmitting(true);
setError(null);
try {
const result = await runThread(agent, workflow, prompt, maxRounds);
const result = await runThread(agent, workflow, prompt);
onCreated(result.threadId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -91,29 +90,6 @@ export function RunDialog({ agent, onClose, onCreated }: Props) {
placeholder="Enter the task prompt..."
/>
</div>
<div>
<label
htmlFor="run-max-rounds"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Max Rounds
</label>
<input
id="run-max-rounds"
type="number"
value={maxRounds}
onChange={(e) => setMaxRounds(Number(e.target.value))}
min={1}
max={100}
className="w-24 px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
/>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--color-error)" }}>
{error}
@@ -34,7 +34,6 @@ function noLogger(): (tag: string, content: string) => void {
function makeOptions(overrides: Partial<ExecuteThreadOptions>): ExecuteThreadOptions {
return {
maxRounds: 5,
depth: 0,
signal: new AbortController().signal,
awaitAfterEachYield: async () => {},
@@ -107,7 +106,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
wf,
"demo",
{ prompt: "hello", steps: [] },
makeOptions({ storageRoot, maxRounds: 5 }),
makeOptions({ storageRoot }),
io,
noLogger(),
);
@@ -127,7 +126,6 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
expect(startNode.type).toBe("start");
expect((startNode.payload as Record<string, unknown>).name).toBe("demo");
expect((startNode.payload as Record<string, unknown>).hash).toBe(bundleHash);
expect((startNode.payload as Record<string, unknown>).maxRounds).toBe(5);
const refs = startNode.refs as string[];
expect(refs.length).toBe(1);
@@ -164,7 +162,6 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
const opts = makeOptions({
storageRoot,
maxRounds: 5,
awaitAfterEachYield: async () => {
const text = await readFile(join(bundleDir, "threads.json"), "utf8");
const parsed = JSON.parse(text) as Record<string, { head: string }>;
@@ -228,7 +225,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
wf,
"demo",
{ prompt: "p", steps: [] },
makeOptions({ storageRoot, maxRounds: 5 }),
makeOptions({ storageRoot }),
io,
noLogger(),
);
@@ -279,7 +276,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
wf,
"demo",
{ prompt: "p", steps: [] },
makeOptions({ storageRoot, maxRounds: 5 }),
makeOptions({ storageRoot }),
io,
noLogger(),
);
@@ -45,7 +45,6 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
{
name: "demo",
hash: bundleHash,
maxRounds: 5,
depth: 0,
},
promptHash,
+2 -33
View File
@@ -284,21 +284,6 @@ async function driveWorkflowGenerator(params: {
});
}
if (written >= executeOptions.maxRounds) {
logger("R3CW7YBQ", `thread ${threadId} stopped at maxRounds=${executeOptions.maxRounds}`);
return await finalizeThread({
cas,
bundleDir,
threadId,
startHash,
chain,
completion: {
returnCode: 0,
summary: `completed: reached maxRounds (${executeOptions.maxRounds})`,
},
});
}
const iterResult = await gen.next();
if (iterResult.done) {
@@ -383,7 +368,7 @@ async function driveWorkflowGenerator(params: {
* Persistence layout (RFC v3 — CAS-based thread storage):
* - Thread chain is written as immutable CAS blobs: a single {@link StartNode}
* plus one {@link StateNode} per role step (including a final `__end__`
* state on completion / abort / `maxRounds`).
* state on completion / abort).
* - The active thread head is published in `<bundleDir>/threads.json`; on
* completion it is removed and a record is appended to
* `<bundleDir>/history/{YYYY-MM-DD}.jsonl`.
@@ -433,7 +418,6 @@ export async function executeThread(
{
name: workflowName,
hash: io.hash,
maxRounds: options.maxRounds,
depth: options.depth,
},
promptHash,
@@ -475,21 +459,6 @@ export async function executeThread(
const nowMs = Date.now();
if (options.maxRounds <= 0) {
logger("R3CW7YBQ", `thread ${io.threadId} stopped at maxRounds=${options.maxRounds}`);
return await finalizeThread({
cas: io.cas,
bundleDir,
threadId: io.threadId,
startHash,
chain,
completion: {
returnCode: 0,
summary: `completed: reached maxRounds (${options.maxRounds})`,
},
});
}
const registryRuntime = await resolveEngineRegistryRuntime(options.storageRoot, io.cas);
if (!registryRuntime.ok) {
throw new Error(registryRuntime.error);
@@ -501,7 +470,7 @@ export async function executeThread(
start: {
role: START,
content: input.prompt,
meta: { maxRounds: options.maxRounds },
meta: {},
timestamp: nowMs,
},
steps: input.steps.map((out, i) => ({
@@ -104,9 +104,7 @@ async function readPromptText(cas: CasStore, promptHash: string): Promise<Result
async function readStartWorkflowIdentity(params: {
cas: CasStore;
startHash: string;
}): Promise<
Result<{ workflowName: string; maxRounds: number; depth: number; prompt: string }, string>
> {
}): Promise<Result<{ workflowName: string; depth: number; prompt: string }, string>> {
const yamlText = await params.cas.get(params.startHash);
if (yamlText === null) {
return err(`start node missing in CAS: ${params.startHash}`);
@@ -127,7 +125,6 @@ async function readStartWorkflowIdentity(params: {
const p = parsed.node.payload;
return ok({
workflowName: p.name,
maxRounds: p.maxRounds,
depth: p.depth,
prompt: prompt.value,
});
@@ -317,7 +314,7 @@ export async function prepareCasFork(params: {
hash: params.bundleHash,
sourceThreadId: params.sourceThreadId,
prompt: id.value.prompt,
runOptions: { maxRounds: id.value.maxRounds, depth: id.value.depth },
runOptions: { depth: id.value.depth },
steps,
stepTimestamps,
forkContinuation: cont.value,
@@ -39,7 +39,6 @@ export type PrefilledDiskStep = {
};
export type ExecuteThreadOptions = {
maxRounds: number;
/** Passed to the bundle thread context as `ThreadContext.depth`. */
depth: number;
signal: AbortSignal;
@@ -68,7 +67,7 @@ export type CasForkPlan = {
hash: string;
sourceThreadId: string;
prompt: string;
runOptions: { maxRounds: number; depth: number };
runOptions: { depth: number };
steps: RoleOutput[];
stepTimestamps: number[];
forkContinuation: ForkContinuationOptions;
@@ -32,7 +32,7 @@ type RunCommand = {
threadId: string;
workflowName: string;
prompt: string;
options: { maxRounds: number; depth: number };
options: { depth: number };
steps: RoleOutput[];
/** Timestamps aligned with `steps` for replay / fork restore; length must match `steps` when steps are non-empty. */
stepTimestamps: number[] | null;
@@ -185,10 +185,6 @@ function parseRunControlPayload(rec: Record<string, unknown>): RunCommand | null
return null;
}
const optRec = options as Record<string, unknown>;
const maxRounds = optRec.maxRounds;
if (typeof maxRounds !== "number") {
return null;
}
const depthRaw = optRec.depth;
const depth =
typeof depthRaw === "number" && Number.isFinite(depthRaw) ? Math.trunc(depthRaw) : 0;
@@ -210,7 +206,7 @@ function parseRunControlPayload(rec: Record<string, unknown>): RunCommand | null
threadId,
workflowName,
prompt,
options: { maxRounds, depth },
options: { depth },
steps: parsedSteps.steps,
stepTimestamps: parsedSteps.stepTimestamps,
forkSourceThreadId,
@@ -1,10 +1,6 @@
import { type CasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type {
ExtractFn,
ExtractResult,
LlmProvider,
} from "@uncaged/workflow-runtime";
import type { ExtractFn, ExtractResult, LlmProvider } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
@@ -95,7 +95,6 @@ export function workflowAsAgent(
workflowName,
input,
{
maxRounds: ctx.start.meta.maxRounds,
depth: nextDepth,
signal: signalNever.signal,
awaitAfterEachYield: async () => {},
@@ -24,7 +24,7 @@ function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext<TestMeta>
start: {
role: START,
content: "test",
meta: { maxRounds: 10 },
meta: {},
timestamp: Date.now(),
} as StartStep,
steps,
@@ -3,7 +3,6 @@
export type StartNodePayload = {
name: string;
hash: string;
maxRounds: number;
depth: number;
};
+1 -1
View File
@@ -46,7 +46,7 @@ export type RoleOutput = {
export type StartStep = {
role: typeof START;
content: string;
meta: { maxRounds: number };
meta: Record<string, never>;
timestamp: number;
};
@@ -27,7 +27,7 @@ describe("buildThreadContext", () => {
const bundleHash = "BHAAAAAAAAAAA";
const startHash = await putStartNode(
cas,
{ name: "demo", hash: bundleHash, maxRounds: 99, depth: 2 },
{ name: "demo", hash: bundleHash, depth: 2 },
promptHash,
);
@@ -59,7 +59,6 @@ describe("buildThreadContext", () => {
expect(ctx.depth).toBe(2);
expect(ctx.start.role).toBe(START);
expect(ctx.start.content).toBe("hello-task");
expect(ctx.start.meta.maxRounds).toBe(99);
expect(ctx.steps.map((s) => s.role)).toEqual(["planner", "coder"]);
expect(ctx.steps[0]?.refs).toEqual([art]);
expect(ctx.steps[1]?.refs).toEqual([]);
@@ -72,7 +71,7 @@ describe("buildThreadContext", () => {
const promptHash = await cas.put("only-prompt");
const startHash = await putStartNode(
cas,
{ name: "solo", hash: "BHBBBBBBBBBBB", maxRounds: 3, depth: 1 },
{ name: "solo", hash: "BHBBBBBBBBBBB", depth: 1 },
promptHash,
);
@@ -80,7 +79,6 @@ describe("buildThreadContext", () => {
expect(ctx.steps).toEqual([]);
expect(ctx.start.content).toBe("only-prompt");
expect(ctx.depth).toBe(1);
expect(ctx.start.meta.maxRounds).toBe(3);
});
test("omits __end__ states from steps", async () => {
@@ -89,7 +87,7 @@ describe("buildThreadContext", () => {
const bundleHash = "BHCCCCCCCCCCC";
const startHash = await putStartNode(
cas,
{ name: "demo", hash: bundleHash, maxRounds: 10, depth: 0 },
{ name: "demo", hash: bundleHash, depth: 0 },
promptHash,
);
@@ -57,7 +57,7 @@ async function threadFromStartHead<M extends RoleMeta>(
start: {
role: START,
content: prompt,
meta: { maxRounds: p.maxRounds },
meta: {},
timestamp: 0,
},
steps: [],
@@ -116,7 +116,7 @@ async function threadFromStateHead<M extends RoleMeta>(
start: {
role: START,
content: prompt,
meta: { maxRounds: sp.maxRounds },
meta: {},
timestamp: firstTs,
},
steps,
@@ -101,9 +101,10 @@ async function advanceOneRound<M extends RoleMeta>(
);
const artifactRefs = mergeUniqueHashes(extracted.refs, refsFromMeta);
const contentHash = artifactRefs.length === 0
? agentContentHash
: await putContentNodeWithRefs(runtime.cas, extracted.contentPayload, artifactRefs);
const contentHash =
artifactRefs.length === 0
? agentContentHash
: await putContentNodeWithRefs(runtime.cas, extracted.contentPayload, artifactRefs);
const refs = artifactRefs.includes(contentHash) ? artifactRefs : [...artifactRefs, contentHash];
const step = {
@@ -144,17 +145,9 @@ export function createWorkflow<M extends RoleMeta>(
if (thread.start.role !== START) {
throw new Error(`workflow loop expected start role to be ${START}`);
}
const maxRounds = thread.start.meta.maxRounds;
let currentThread = thread as ModeratorContext<M>;
while (true) {
if (currentThread.steps.length >= maxRounds) {
return {
returnCode: 0,
summary: `completed: reached maxRounds (${maxRounds})`,
};
}
const outcome = await advanceOneRound(def, binding, {
thread: currentThread,
runtime,
@@ -13,23 +13,20 @@ const DEFAULT_PHASES: PlannerMeta["phases"] = [
},
];
function makeStart(maxRounds: number): ModeratorContext<DevelopMeta>["start"] {
function makeStart(): ModeratorContext<DevelopMeta>["start"] {
return {
role: START,
content: "Implement the feature",
meta: { maxRounds },
meta: {},
timestamp: 0,
};
}
function makeCtx(
maxRounds: number,
steps: ModeratorContext<DevelopMeta>["steps"],
): ModeratorContext<DevelopMeta> {
function makeCtx(steps: ModeratorContext<DevelopMeta>["steps"]): ModeratorContext<DevelopMeta> {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
start: makeStart(maxRounds),
start: makeStart(),
steps,
};
}
@@ -90,20 +87,18 @@ function committerStep(meta: CommitterMeta): RoleStep<DevelopMeta> {
describe("developModerator", () => {
test("routes initial → planner → coder → reviewer → tester → committer → END", () => {
expect(developModerator(makeCtx(20, []))).toBe("planner");
expect(developModerator(makeCtx(20, [plannerStep()]))).toBe("coder");
expect(developModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer");
expect(developModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
expect(developModerator(makeCtx([]))).toBe("planner");
expect(developModerator(makeCtx([plannerStep()]))).toBe("coder");
expect(developModerator(makeCtx([plannerStep(), coderStep()]))).toBe("reviewer");
expect(developModerator(makeCtx([plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
"tester",
);
expect(
developModerator(
makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), testerStep(true)]),
),
developModerator(makeCtx([plannerStep(), coderStep(), reviewerStep(true), testerStep(true)])),
).toBe("committer");
expect(
developModerator(
makeCtx(20, [
makeCtx([
plannerStep(),
coderStep(),
reviewerStep(true),
@@ -120,16 +115,16 @@ describe("developModerator", () => {
coderStep(),
reviewerStep(false),
];
expect(developModerator(makeCtx(20, steps))).toBe("coder");
expect(developModerator(makeCtx(steps))).toBe("coder");
});
test("reviewer rejects → END when max rounds exhausted", () => {
test("reviewer rejects → coder retry (supervisor controls termination)", () => {
const steps: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(false),
];
expect(developModerator(makeCtx(4, steps))).toBe(END);
expect(developModerator(makeCtx(steps))).toBe("coder");
});
test("tester failed → coder retry when budget allows", () => {
@@ -139,17 +134,17 @@ describe("developModerator", () => {
reviewerStep(true),
testerStep(false),
];
expect(developModerator(makeCtx(20, steps))).toBe("coder");
expect(developModerator(makeCtx(steps))).toBe("coder");
});
test("tester failed → END when max rounds exhausted", () => {
test("tester failed → coder retry (supervisor controls termination)", () => {
const steps: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(true),
testerStep(false),
];
expect(developModerator(makeCtx(5, steps))).toBe(END);
expect(developModerator(makeCtx(steps))).toBe("coder");
});
test("multiple planner phases → coder until all complete, then reviewer", () => {
@@ -157,13 +152,11 @@ describe("developModerator", () => {
{ hash: "AA000001", title: "first phase" },
{ hash: "AA000002", title: "second phase" },
];
expect(developModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder");
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("AA000001")]))).toBe(
"coder",
);
expect(developModerator(makeCtx([plannerStep(phases)]))).toBe("coder");
expect(developModerator(makeCtx([plannerStep(phases), coderStep("AA000001")]))).toBe("coder");
expect(
developModerator(
makeCtx(20, [plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
makeCtx([plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
),
).toBe("reviewer");
});
@@ -175,7 +168,7 @@ describe("developModerator", () => {
{ hash: "BB000003", title: "verify" },
{ hash: "BB000004", title: "polish" },
];
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("BB000004")]))).toBe(
expect(developModerator(makeCtx([plannerStep(phases), coderStep("BB000004")]))).toBe(
"reviewer",
);
});
@@ -185,12 +178,10 @@ describe("developModerator", () => {
{ hash: "CC000001", title: "first phase" },
{ hash: "CC000002", title: "second phase" },
];
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe(
"coder",
);
expect(developModerator(makeCtx([plannerStep(phases), coderStep("all-done")]))).toBe("coder");
});
test("incomplete phases → END when max rounds exhausted", () => {
test("incomplete phases → coder retry (supervisor controls termination)", () => {
const phases: PlannerMeta["phases"] = [
{ hash: "DD000001", title: "first phase" },
{ hash: "DD000002", title: "second phase" },
@@ -199,7 +190,7 @@ describe("developModerator", () => {
plannerStep(phases),
coderStep("DD000001"),
];
expect(developModerator(makeCtx(3, steps))).toBe(END);
expect(developModerator(makeCtx(steps))).toBe("coder");
});
test("committer → END for any committer meta status", () => {
@@ -220,9 +211,9 @@ describe("developModerator", () => {
reviewerStep(true),
testerStep(true),
];
expect(developModerator(makeCtx(20, [...base, committed]))).toBe(END);
expect(developModerator(makeCtx(20, [...base, recoverable]))).toBe(END);
expect(developModerator(makeCtx(20, [...base, unrecoverable]))).toBe(END);
expect(developModerator(makeCtx([...base, committed]))).toBe(END);
expect(developModerator(makeCtx([...base, recoverable]))).toBe(END);
expect(developModerator(makeCtx([...base, unrecoverable]))).toBe(END);
});
});
@@ -50,12 +50,6 @@ const allPhasesComplete: ModeratorCondition<DevelopMeta> = {
},
};
const hasRoundsRemaining: ModeratorCondition<DevelopMeta> = {
name: "hasRoundsRemaining",
description: "There are rounds remaining before hitting maxRounds",
check: (ctx) => ctx.steps.length < ctx.start.meta.maxRounds - 1,
};
const reviewApproved: ModeratorCondition<DevelopMeta> = {
name: "reviewApproved",
description: "The last reviewer approved the changes",
@@ -81,18 +75,15 @@ const table: ModeratorTable<DevelopMeta> = {
planner: [{ condition: "FALLBACK", role: "coder" }],
coder: [
{ condition: allPhasesComplete, role: "reviewer" },
{ condition: hasRoundsRemaining, role: "coder" },
{ condition: "FALLBACK", role: END },
{ condition: "FALLBACK", role: "coder" },
],
reviewer: [
{ condition: reviewApproved, role: "tester" },
{ condition: hasRoundsRemaining, role: "coder" },
{ condition: "FALLBACK", role: END },
{ condition: "FALLBACK", role: "coder" },
],
tester: [
{ condition: testsPassed, role: "committer" },
{ condition: hasRoundsRemaining, role: "coder" },
{ condition: "FALLBACK", role: END },
{ condition: "FALLBACK", role: "coder" },
],
committer: [{ condition: "FALLBACK", role: END }],
};
@@ -2,7 +2,11 @@ import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const coderMetaSchema = z.object({
completedPhase: z.string().describe("The planner phase hash finished this round. If multiple phases were completed, use the last finished phase hash."),
completedPhase: z
.string()
.describe(
"The planner phase hash finished this round. If multiple phases were completed, use the last finished phase hash.",
),
filesChanged: z.array(z.string()),
summary: z.string(),
});
@@ -98,23 +98,22 @@ function installMockToolCallCompletions(
};
}
function makeStart(maxRounds: number): ModeratorContext<SolveIssueMeta>["start"] {
function makeStart(): ModeratorContext<SolveIssueMeta>["start"] {
return {
role: START,
content: "Fix the flaky login test",
meta: { maxRounds },
meta: {},
timestamp: 0,
};
}
function makeCtx(
maxRounds: number,
steps: ModeratorContext<SolveIssueMeta>["steps"],
): ModeratorContext<SolveIssueMeta> {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
start: makeStart(maxRounds),
start: makeStart(),
steps,
};
}
@@ -182,7 +181,7 @@ function makeThread(prompt: string) {
start: {
role: START,
content: prompt,
meta: { maxRounds: 20 },
meta: {},
timestamp: Date.now(),
},
steps: [],
@@ -191,12 +190,12 @@ function makeThread(prompt: string) {
describe("solveIssueModerator", () => {
test("routes initial → preparer → developer → submitter → END", () => {
expect(solveIssueModerator(makeCtx(20, []))).toBe("preparer");
expect(solveIssueModerator(makeCtx(20, [preparerStep()]))).toBe("developer");
expect(solveIssueModerator(makeCtx(20, [preparerStep(), developerStep()]))).toBe("submitter");
expect(solveIssueModerator(makeCtx([]))).toBe("preparer");
expect(solveIssueModerator(makeCtx([preparerStep()]))).toBe("developer");
expect(solveIssueModerator(makeCtx([preparerStep(), developerStep()]))).toBe("submitter");
expect(
solveIssueModerator(
makeCtx(20, [
makeCtx([
preparerStep(),
developerStep(),
submitterStep({
@@ -211,7 +210,7 @@ describe("solveIssueModerator", () => {
test("submitter failed → END", () => {
expect(
solveIssueModerator(
makeCtx(20, [
makeCtx([
preparerStep(),
developerStep(),
submitterStep({ status: "failed", error: "gh not authenticated" }),
@@ -225,7 +224,7 @@ describe("solveIssueModerator", () => {
// routed to END, since the moderator is a closed switch over known roles.
expect(
solveIssueModerator(
makeCtx(20, [
makeCtx([
preparerStep(),
developerStep(),
submitterStep({ status: "submitted", prUrl: "https://example.com/pr/1" }),
@@ -7,7 +7,7 @@ function startTask(content: string): AgentContext["start"] {
return {
role: START,
content,
meta: { maxRounds: 5 },
meta: {},
timestamp: 1,
};
}