feat: configure executor models
This commit is contained in:
+56
-8
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
ExecutorConfig,
|
||||
@@ -18,6 +19,10 @@ import { EXECUTOR_KINDS, VCS_KINDS } from "./workflow-types.js";
|
||||
|
||||
const WORKFLOW_CONFIG_FILE = "agent-workflow.json";
|
||||
|
||||
function globalConfigPath(): string {
|
||||
return path.join(os.homedir(), ".agent-workflow", "config.json");
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
executor: "reasonix" as ExecutorKind,
|
||||
maxCycles: 5,
|
||||
@@ -44,6 +49,19 @@ export function loadProjectWorkflowConfig(repoRoot: string): WorkflowProjectConf
|
||||
return validateProjectConfig(raw, filePath);
|
||||
}
|
||||
|
||||
/** Load and parse ~/.agent-workflow/config.json. */
|
||||
export function loadGlobalWorkflowConfig(): WorkflowProjectConfig | undefined {
|
||||
const filePath = globalConfigPath();
|
||||
if (!fs.existsSync(filePath)) return undefined;
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch (err) {
|
||||
throw new WorkflowConfigError(`Failed to parse global config: ${(err as Error).message}`);
|
||||
}
|
||||
return validateProjectConfig(raw, filePath);
|
||||
}
|
||||
|
||||
function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectConfig {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
throw new WorkflowConfigError(`${filePath}: must be a JSON object`);
|
||||
@@ -67,6 +85,20 @@ function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectC
|
||||
if (obj.timeoutSeconds !== undefined && (typeof obj.timeoutSeconds !== "number" || !Number.isInteger(obj.timeoutSeconds) || obj.timeoutSeconds < 1)) {
|
||||
throw new WorkflowConfigError(`${filePath}: timeoutSeconds must be a positive integer`);
|
||||
}
|
||||
if (obj.executors !== undefined) {
|
||||
if (!obj.executors || typeof obj.executors !== "object") {
|
||||
throw new WorkflowConfigError(`${filePath}: executors must be an object`);
|
||||
}
|
||||
for (const kind of EXECUTOR_KINDS) {
|
||||
const exec = (obj.executors as Record<string, unknown>)[kind];
|
||||
if (exec && typeof exec === "object") {
|
||||
const model = (exec as Record<string, unknown>).model;
|
||||
if (model !== undefined && (typeof model !== "string" || !model.trim())) {
|
||||
throw new WorkflowConfigError(`${filePath}: executors.${kind}.model must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prohibit secrets in config
|
||||
const forbidden = ["apikey", "api_key", "token", "secret", "password", "credential"];
|
||||
const lower = JSON.stringify(raw).toLowerCase();
|
||||
@@ -82,42 +114,58 @@ export interface WorkflowCliOverrides {
|
||||
executor?: ExecutorKind;
|
||||
vcs?: VcsKind;
|
||||
maxCycles?: number;
|
||||
timeoutSeconds?: number;
|
||||
}
|
||||
|
||||
/** Merge CLI > project config > defaults into a single resolved config. */
|
||||
/** Merge CLI > project config > global config > defaults into a single resolved config. */
|
||||
export function resolveWorkflowConfig(
|
||||
repoRoot: string,
|
||||
cli: WorkflowCliOverrides = {},
|
||||
): ResolvedWorkflowConfig {
|
||||
const project = loadProjectWorkflowConfig(repoRoot);
|
||||
const globalConfig = loadGlobalWorkflowConfig();
|
||||
|
||||
const executor: ExecutorKind = cli.executor ?? (project?.defaultExecutor) ?? DEFAULTS.executor;
|
||||
const executor: ExecutorKind = cli.executor ?? project?.defaultExecutor ?? globalConfig?.defaultExecutor ?? DEFAULTS.executor;
|
||||
if (!EXECUTOR_KINDS.includes(executor)) {
|
||||
throw new WorkflowConfigError(`Unknown executor: ${executor}. Must be one of: ${EXECUTOR_KINDS.join(", ")}`);
|
||||
}
|
||||
|
||||
// VCS selection precedence: CLI --vcs, then project defaultVcs (unless "auto"), then auto-detect
|
||||
// VCS selection precedence: CLI --vcs, then project defaultVcs, then global defaultVcs.
|
||||
// An explicit project value of "auto" overrides a global value; "auto" means fall through to detection.
|
||||
let vcs: VcsKind | undefined;
|
||||
if (cli.vcs) {
|
||||
if (!VCS_KINDS.includes(cli.vcs)) {
|
||||
throw new WorkflowConfigError(`Unknown VCS: ${cli.vcs}. Must be one of: ${VCS_KINDS.join(", ")}`);
|
||||
}
|
||||
vcs = cli.vcs;
|
||||
} else if (project?.defaultVcs && project.defaultVcs !== "auto") {
|
||||
vcs = project.defaultVcs as VcsKind;
|
||||
} else if (project?.defaultVcs !== undefined) {
|
||||
if (project.defaultVcs !== "auto") {
|
||||
vcs = project.defaultVcs as VcsKind;
|
||||
}
|
||||
} else if (globalConfig?.defaultVcs && globalConfig.defaultVcs !== "auto") {
|
||||
vcs = globalConfig.defaultVcs as VcsKind;
|
||||
}
|
||||
// If vcs is still undefined, caller will auto-detect
|
||||
|
||||
const maxCycles = cli.maxCycles ?? project?.maxCycles ?? DEFAULTS.maxCycles;
|
||||
const maxCycles = cli.maxCycles ?? project?.maxCycles ?? globalConfig?.maxCycles ?? DEFAULTS.maxCycles;
|
||||
if (!Number.isInteger(maxCycles) || maxCycles < 1) {
|
||||
throw new WorkflowConfigError(`maxCycles must be a positive integer, got: ${maxCycles}`);
|
||||
}
|
||||
|
||||
const timeoutSeconds = project?.timeoutSeconds ?? DEFAULTS.timeoutSeconds;
|
||||
const timeoutSeconds = cli.timeoutSeconds ?? project?.timeoutSeconds ?? globalConfig?.timeoutSeconds ?? DEFAULTS.timeoutSeconds;
|
||||
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1) {
|
||||
throw new WorkflowConfigError(`timeoutSeconds must be a positive integer, got: ${timeoutSeconds}`);
|
||||
}
|
||||
|
||||
const globalExecutorConfig = (globalConfig?.executors as Record<string, ExecutorConfig> | undefined)?.[executor] ?? {};
|
||||
const projectExecutorConfig = (project?.executors as Record<string, ExecutorConfig> | undefined)?.[executor] ?? {};
|
||||
const executorConfig: ExecutorConfig = {
|
||||
...((project?.executors as Record<string, ExecutorConfig> | undefined)?.[executor] ?? {}),
|
||||
...globalExecutorConfig,
|
||||
...projectExecutorConfig,
|
||||
};
|
||||
if (executorConfig.model !== undefined && (typeof executorConfig.model !== "string" || !executorConfig.model.trim())) {
|
||||
throw new WorkflowConfigError(`Executor "${executor}" model must be a non-empty string`);
|
||||
}
|
||||
|
||||
return {
|
||||
executor,
|
||||
|
||||
+65
-1
@@ -564,6 +564,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
});
|
||||
state.executorConfig = config.executorConfig;
|
||||
state.timeoutSeconds = config.timeoutSeconds;
|
||||
state.configSourceRoot = configSourceRoot ?? repoRoot;
|
||||
state.enginePid = process.pid;
|
||||
state.status = "executing";
|
||||
if (codexThreadId) {
|
||||
@@ -595,6 +596,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
maxCycles: config.maxCycles,
|
||||
vcsKind,
|
||||
vcsBaseline,
|
||||
...(config.executorConfig.model ? { model: config.executorConfig.model } : {}),
|
||||
...(state.taskId ? { taskId: state.taskId, taskSessionReused: state.taskSessionReused } : {}),
|
||||
},
|
||||
});
|
||||
@@ -630,7 +632,11 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
runId,
|
||||
timestamp: nowIso(),
|
||||
cycleIndex,
|
||||
data: { executor: config.executor, sessionMode: taskSessionHandle ? "task_reuse" : "new" },
|
||||
data: {
|
||||
executor: config.executor,
|
||||
sessionMode: taskSessionHandle ? "task_reuse" : "new",
|
||||
...(config.executorConfig.model ? { model: config.executorConfig.model } : {}),
|
||||
},
|
||||
});
|
||||
if (taskSessionHandle) {
|
||||
appendEvent(dir, {
|
||||
@@ -698,6 +704,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
exitCode: execResult.exitCode,
|
||||
durationMs: execResult.durationMs,
|
||||
failed: execResult.failed,
|
||||
...(state.executorConfig?.model ? { model: state.executorConfig.model } : {}),
|
||||
...(execResult.failureReason ? { failureReason: execResult.failureReason } : {}),
|
||||
},
|
||||
});
|
||||
@@ -1069,9 +1076,48 @@ export async function decideWorkflow(opts: DecideWorkflowOpts): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
applyReloadedExecutorModel(state, dir, nextCycle);
|
||||
await resumeExecutorCycle(state, dir, nextCycleRecord, acceptedFindings);
|
||||
}
|
||||
|
||||
function reloadExecutorModel(state: WorkflowState): string | undefined {
|
||||
// Fix the executor kind to the one frozen at run start so a changed defaultExecutor
|
||||
// in the config cannot redirect the model lookup to a different executor.
|
||||
const config = resolveWorkflowConfig(state.configSourceRoot ?? state.repoRoot, { executor: state.executor });
|
||||
return config.executorConfig.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the executor model from the latest config and apply it to the run state.
|
||||
* Other executor settings remain the snapshot taken at run start.
|
||||
* On invalid config, transition to blocked before the executor launches.
|
||||
*/
|
||||
function applyReloadedExecutorModel(
|
||||
state: WorkflowState,
|
||||
dir: string,
|
||||
cycleIndex: number,
|
||||
): void {
|
||||
let reloadedModel: string | undefined;
|
||||
try {
|
||||
reloadedModel = reloadExecutorModel(state);
|
||||
} catch (err) {
|
||||
transitionToTerminal(
|
||||
state,
|
||||
dir,
|
||||
"blocked",
|
||||
`Executor configuration is invalid: ${(err as Error).message}`,
|
||||
{ cycleIndex },
|
||||
);
|
||||
throw new WorkflowEngineError(`Cannot resume: ${(err as Error).message}`);
|
||||
}
|
||||
if (reloadedModel !== undefined) {
|
||||
state.executorConfig = { ...(state.executorConfig ?? {}), model: reloadedModel };
|
||||
} else {
|
||||
state.executorConfig = { ...(state.executorConfig ?? {}) };
|
||||
delete state.executorConfig.model;
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeExecutorCycle(
|
||||
state: WorkflowState,
|
||||
dir: string,
|
||||
@@ -1098,6 +1144,18 @@ async function resumeExecutorCycle(
|
||||
registerAttemptLog(dir, cycleRecord, execLogPath);
|
||||
writeState(dir, state); // persist so retry can discover the log
|
||||
|
||||
appendEvent(dir, {
|
||||
kind: "executor_started",
|
||||
runId: state.runId,
|
||||
timestamp: nowIso(),
|
||||
cycleIndex,
|
||||
data: {
|
||||
executor: state.executor,
|
||||
sessionMode: "resume",
|
||||
...(state.executorConfig?.model ? { model: state.executorConfig.model } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
let execResult;
|
||||
try {
|
||||
execResult = await adapter.resume({
|
||||
@@ -1145,6 +1203,7 @@ async function resumeExecutorCycle(
|
||||
exitCode: execResult.exitCode,
|
||||
durationMs: execResult.durationMs,
|
||||
failed: execResult.failed,
|
||||
...(state.executorConfig?.model ? { model: state.executorConfig.model } : {}),
|
||||
...(execResult.failureReason ? { failureReason: execResult.failureReason } : {}),
|
||||
},
|
||||
});
|
||||
@@ -1404,6 +1463,7 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
|
||||
&& (state.stopDescription?.startsWith("Executor exited with failure:") === true
|
||||
|| state.stopDescription?.startsWith("Executor failed to resume:") === true
|
||||
|| state.stopDescription?.startsWith("Executor resume failed:") === true
|
||||
|| state.stopDescription?.startsWith("Executor configuration is invalid:") === true
|
||||
|| state.stopDescription === "No session handle available for resume."));
|
||||
if (!retryableFailure) {
|
||||
throw new WorkflowEngineError(
|
||||
@@ -1519,6 +1579,8 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
|
||||
state.updatedAt = nowIso();
|
||||
writeState(dir, state);
|
||||
|
||||
applyReloadedExecutorModel(state, dir, state.currentCycle);
|
||||
|
||||
if (opts.sessionId) {
|
||||
appendEvent(dir, {
|
||||
kind: "executor_session_rebound",
|
||||
@@ -1555,6 +1617,7 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
|
||||
data: {
|
||||
executor: state.executor,
|
||||
recoveryMode,
|
||||
...(state.executorConfig?.model ? { model: state.executorConfig.model } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1730,6 +1793,7 @@ export async function extendWorkflow(opts: ExtendWorkflowOpts): Promise<void> {
|
||||
|
||||
// 11. Resume the executor session immediately (lock released by resumeExecutorCycle on completion)
|
||||
try {
|
||||
applyReloadedExecutorModel(state, dir, nextCycle);
|
||||
await resumeExecutorCycle(state, dir, nextCycleRecord, acceptedFindings);
|
||||
} catch (err) {
|
||||
// resumeExecutorCycle already handles errors and releases lock
|
||||
|
||||
@@ -162,6 +162,46 @@ touch '${sessionFile}.meta'
|
||||
expect(result.sessionHandle?.kind).toBe("reasonix");
|
||||
});
|
||||
|
||||
it("start passes --model when configured", async () => {
|
||||
const argsFile = path.join(tmpDir, "args.txt");
|
||||
const fakeBin = writeFakeCli(tmpDir, { argsFile });
|
||||
|
||||
const adapter = createExecutorAdapter("reasonix");
|
||||
await adapter.start({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 1,
|
||||
config: { binary: fakeBin, model: "custom-reasonix-model" },
|
||||
});
|
||||
|
||||
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
|
||||
expect(capturedArgs).toContain("--model");
|
||||
expect(capturedArgs).toContain("custom-reasonix-model");
|
||||
});
|
||||
|
||||
it("resume passes --model when configured", async () => {
|
||||
const argsFile = path.join(tmpDir, "args.txt");
|
||||
const fakeBin = writeFakeCli(tmpDir, { argsFile });
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
fs.writeFileSync(sessionFile, "{}\n");
|
||||
|
||||
const adapter = createExecutorAdapter("reasonix");
|
||||
await adapter.resume({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
acceptedFindings: "Fix null pointer at src/index.ts",
|
||||
handle: { kind: "reasonix", sessionFilePath: sessionFile },
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 2,
|
||||
config: { binary: fakeBin, model: "resume-model" },
|
||||
});
|
||||
|
||||
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
|
||||
expect(capturedArgs).toContain("--model");
|
||||
expect(capturedArgs).toContain("resume-model");
|
||||
});
|
||||
|
||||
it("resume sends a complete continued plan for task-level reuse", async () => {
|
||||
const argsFile = path.join(tmpDir, "args.txt");
|
||||
const fakeBin = writeFakeCli(tmpDir, { argsFile });
|
||||
|
||||
@@ -568,7 +568,12 @@ class ReasonixAdapter implements ExecutorAdapter {
|
||||
const snapshotDirs = [projectsDir];
|
||||
const beforeSnapshot = new Set(listJsonlFiles(projectsDir, opts.repoRoot));
|
||||
|
||||
const args: string[] = ["run", "--dir", opts.repoRoot, prompt];
|
||||
const args: string[] = [
|
||||
"run",
|
||||
"--dir", opts.repoRoot,
|
||||
...(opts.config.model ? ["--model", opts.config.model] : []),
|
||||
prompt,
|
||||
];
|
||||
|
||||
const startMs = Date.now();
|
||||
const logFile = opts.logFilePath;
|
||||
@@ -619,9 +624,11 @@ class ReasonixAdapter implements ExecutorAdapter {
|
||||
"run",
|
||||
"--dir", opts.repoRoot,
|
||||
"--resume", handle.sessionFilePath,
|
||||
...(opts.config.model ? ["--model", opts.config.model] : []),
|
||||
prompt,
|
||||
];
|
||||
|
||||
|
||||
const startMs = Date.now();
|
||||
const logFile = opts.logFilePath;
|
||||
const attemptIndex = deriveAttemptIndex(logFile);
|
||||
|
||||
+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");
|
||||
|
||||
@@ -204,6 +204,8 @@ export interface WorkflowState {
|
||||
vcsBaseline?: VcsBaseline;
|
||||
/** VCS kind detected or specified at start. */
|
||||
vcsKind?: VcsKind;
|
||||
/** Directory used to load agent-workflow.json; persisted for per-cycle model reloads. */
|
||||
configSourceRoot?: string;
|
||||
executor: ExecutorKind;
|
||||
executorConfig?: ExecutorConfig;
|
||||
/** Consecutive executor inactivity allowed before the host aborts the attempt. */
|
||||
|
||||
Reference in New Issue
Block a user