feat: configure executor models
This commit is contained in:
+210
-1
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user