feat: configure executor models

This commit is contained in:
liujing
2026-07-20 13:54:11 +08:00
parent c1a982a179
commit 4026f04b93
8 changed files with 396 additions and 14 deletions
+210 -1
View File
@@ -3,7 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadProjectWorkflowConfig } from "./workflow-config.js";
import { loadProjectWorkflowConfig, resolveWorkflowConfig } from "./workflow-config.js";
import {
WorkflowLockError,
acquireLock,
@@ -97,6 +97,104 @@ describe("Project workflow config", () => {
});
});
describe("Resolved workflow config", () => {
let tmpDir: string;
let repoRoot: string;
let originalHome: string | undefined;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-resolve-test-"));
repoRoot = path.join(tmpDir, "repo");
fs.mkdirSync(repoRoot, { recursive: true });
originalHome = process.env.HOME;
process.env.HOME = tmpDir;
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeGlobalConfig(config: unknown): void {
const globalDir = path.join(tmpDir, ".agent-workflow");
fs.mkdirSync(globalDir, { recursive: true });
fs.writeFileSync(path.join(globalDir, "config.json"), JSON.stringify(config));
}
function writeProjectConfig(config: unknown): void {
fs.writeFileSync(path.join(repoRoot, "agent-workflow.json"), JSON.stringify(config));
}
it("merges global < project < CLI per field", () => {
writeGlobalConfig({
version: "1",
defaultExecutor: "agy",
defaultVcs: "none",
maxCycles: 10,
timeoutSeconds: 999,
executors: {
reasonix: { binary: "global-reasonix", model: "global-model" },
},
});
writeProjectConfig({
version: "1",
defaultExecutor: "reasonix",
maxCycles: 3,
executors: {
reasonix: { model: "project-model" },
},
});
const config = resolveWorkflowConfig(repoRoot, { maxCycles: 4 });
expect(config.executor).toBe("reasonix");
expect(config.maxCycles).toBe(4);
expect(config.timeoutSeconds).toBe(999);
expect(config.vcs).toBe("none");
expect(config.executorConfig).toMatchObject({
binary: "global-reasonix",
model: "project-model",
});
});
it("treats explicit project defaultVcs auto as an override that suppresses global defaultVcs", () => {
writeGlobalConfig({
version: "1",
defaultVcs: "git",
});
writeProjectConfig({
version: "1",
defaultVcs: "auto",
});
const config = resolveWorkflowConfig(repoRoot, {});
expect(config.vcs).toBeUndefined();
});
it("uses global defaults when project config is absent", () => {
writeGlobalConfig({
version: "1",
defaultExecutor: "claude",
executors: {
claude: { model: "claude-sonnet" },
},
});
const config = resolveWorkflowConfig(repoRoot, {});
expect(config.executor).toBe("claude");
expect(config.executorConfig.model).toBe("claude-sonnet");
});
it("rejects invalid model values", () => {
writeProjectConfig({
version: "1",
defaultExecutor: "reasonix",
executors: { reasonix: { model: "" } },
});
expect(() => resolveWorkflowConfig(repoRoot, {})).toThrow(/non-empty string/);
});
});
describe("Retry-execute recovery", () => {
let tmpDir: string;
let repoRoot: string;
@@ -267,6 +365,117 @@ echo '{"session_id":"fake-session-123","conversation_id":"fake-conv-456"}'
.toBe("initial_continuation");
});
it("blocks before executor launch when reloaded model config is invalid, then recovers after correction", async () => {
const { retryExecuteWorkflow } = await import("./workflow-engine.js");
const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js");
const runId = "invalid-model-retry";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const sessionFile = path.join(dir, "session.jsonl");
fs.writeFileSync(sessionFile, '{"type":"session"}');
const configRoot = path.join(tmpDir, "config-root");
fs.mkdirSync(configRoot, { recursive: true });
fs.writeFileSync(
path.join(configRoot, "agent-workflow.json"),
JSON.stringify({ version: "1", executors: { reasonix: { model: "" } } }),
);
const state = initWorkflowState({
runId,
repoRoot,
baselineHead: gitHead(repoRoot),
plan: plan(),
executor: "reasonix",
maxCycles: 3,
});
state.status = "blocked";
state.stopDescription = "Executor exited with failure: test error";
state.configSourceRoot = configRoot;
state.cycles = [{
cycleIndex: 1,
startedAt: new Date().toISOString(),
executorLogFile: "cycle-1-exec.log",
executorAttemptLogs: ["cycle-1-exec.log"],
}];
state.sessionHandle = { kind: "reasonix", sessionFilePath: sessionFile };
state.executorConfig = { binary: writeFakeExecutor(sessionFile) };
fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "initial attempt failed");
writeState(dir, state);
await expect(retryExecuteWorkflow({ runId })).rejects.toThrow(/non-empty string/);
let recovered = readState(dir)!;
expect(recovered.status).toBe("blocked");
expect(recovered.stopDescription).toMatch(/Executor configuration is invalid:/);
// Fix the config and retry again.
fs.writeFileSync(
path.join(configRoot, "agent-workflow.json"),
JSON.stringify({ version: "1", executors: { reasonix: { model: "fixed-model" } } }),
);
await retryExecuteWorkflow({ runId });
recovered = readState(dir)!;
expect(recovered.status).toBe("awaiting_review");
expect(recovered.executorConfig?.model).toBe("fixed-model");
const events = fs.readFileSync(path.join(dir, "events.jsonl"), "utf8")
.trim().split("\n").map((line) => JSON.parse(line));
expect(events.find((event) => event.kind === "executor_started")?.data.model)
.toBe("fixed-model");
expect(events.find((event) => event.kind === "executor_retried")?.data.model)
.toBe("fixed-model");
});
it("reloads model for the run's frozen executor even when defaultExecutor changes", async () => {
const { retryExecuteWorkflow } = await import("./workflow-engine.js");
const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js");
const runId = "frozen-executor-model";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const sessionFile = path.join(dir, "session.jsonl");
fs.writeFileSync(sessionFile, '{"type":"session"}');
const configRoot = path.join(tmpDir, "frozen-config-root");
fs.mkdirSync(configRoot, { recursive: true });
fs.writeFileSync(
path.join(configRoot, "agent-workflow.json"),
JSON.stringify({
version: "1",
defaultExecutor: "claude",
executors: {
claude: { model: "claude-other" },
reasonix: { model: "frozen-reasonix-model" },
},
}),
);
const state = initWorkflowState({
runId,
repoRoot,
baselineHead: gitHead(repoRoot),
plan: plan(),
executor: "reasonix",
maxCycles: 3,
});
state.status = "blocked";
state.stopDescription = "Executor exited with failure: test error";
state.configSourceRoot = configRoot;
state.cycles = [{
cycleIndex: 1,
startedAt: new Date().toISOString(),
executorLogFile: "cycle-1-exec.log",
executorAttemptLogs: ["cycle-1-exec.log"],
}];
state.sessionHandle = { kind: "reasonix", sessionFilePath: sessionFile };
state.executorConfig = { binary: writeFakeExecutor(sessionFile) };
fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "initial attempt failed");
writeState(dir, state);
await retryExecuteWorkflow({ runId });
const recovered = readState(dir)!;
expect(recovered.status).toBe("awaiting_review");
expect(recovered.executor).toBe("reasonix");
expect(recovered.executorConfig?.model).toBe("frozen-reasonix-model");
});
it("recovers a missing Reasonix handle for a blocked fix cycle", async () => {
const { retryExecuteWorkflow } = await import("./workflow-engine.js");
const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js");