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
+11 -1
View File
@@ -205,7 +205,12 @@ This allows continuation of the same executor session after reaching the cycle l
## Configuration ## Configuration
Create `agent-workflow.json` in your repository root: Create `agent-workflow.json` in your repository root, and optionally create `~/.agent-workflow/config.json` for host-wide defaults. Settings are merged per field with the following precedence:
1. CLI flags (`--executor`, `--vcs`, `--max-cycles`)
2. `agent-workflow.json` in the project root
3. `~/.agent-workflow/config.json`
4. Built-in defaults
```json ```json
{ {
@@ -233,6 +238,11 @@ Create `agent-workflow.json` in your repository root:
`timeoutSeconds` is an inactivity timeout, not a total runtime limit. The host resets the timeout whenever the executor emits stdout/stderr progress, so an active task may run longer than the configured window. A continuously silent executor is terminated after the configured number of seconds. Agy receives a 24-hour internal print-mode ceiling while the host inactivity timeout remains the primary stuck-process guard. `timeoutSeconds` is an inactivity timeout, not a total runtime limit. The host resets the timeout whenever the executor emits stdout/stderr progress, so an active task may run longer than the configured window. A continuously silent executor is terminated after the configured number of seconds. Agy receives a 24-hour internal print-mode ceiling while the host inactivity timeout remains the primary stuck-process guard.
- `defaultVcs` - Default VCS kind: `"auto"` (detect), `"git"`, `"svn"`, or `"none"`. Default is `"auto"`. - `defaultVcs` - Default VCS kind: `"auto"` (detect), `"git"`, `"svn"`, or `"none"`. Default is `"auto"`.
- `model` - Executor model identifier (accepted as any non-empty string). When set, it is passed to Claude, Agy, and Reasonix. The effective model is reloaded from the latest config before each later cycle, scope remediation, cycle extension, and executor retry, so mid-run model changes take effect without starting a new run. Binary path, agent, profile, budget, timeout, and other executor settings are locked at run start.
Configuration files are forbidden from storing secrets (`apiKey`, `token`, `secret`, `password`, `credential`, etc.). If the latest config is invalid or the reloaded `model` is empty, the run enters a `blocked` state before launching the executor. After correcting the config, the same run can continue with `agent-workflow retry-execute --run <run-id>`.
The path used to load `agent-workflow.json` is persisted with the run so later model reloads use the same config source for Git, SVN, and snapshot-mode repositories.
## Environment Variables ## Environment Variables
+4 -2
View File
@@ -82,7 +82,9 @@ After starting, announce:
Run <run-id> started. Monitor live output: agent-workflow logs --run <run-id> --follow Run <run-id> started. Monitor live output: agent-workflow logs --run <run-id> --follow
``` ```
Honor an explicit `max-cycles` argument or `--vcs` flag. Otherwise use project configuration and workflow defaults. VCS is auto-detected if not specified. Honor an explicit `max-cycles` argument or `--vcs` flag. Otherwise use project configuration (`agent-workflow.json`), global configuration (`~/.agent-workflow/config.json`), and workflow defaults, in that order. VCS is auto-detected if not specified.
The executor `model` is reloaded from the latest config before each later cycle, scope remediation, cycle extension, and executor retry, while the binary path, agent, profile, budget, timeout, and other executor settings remain locked from run start. Audit events (`workflow_started`, `executor_started`, `executor_completed`, `executor_retried`) record the effective model actually used.
## Drive The State Machine ## Drive The State Machine
@@ -165,7 +167,7 @@ After `fix`, repeat review and decision for the new cycle. Respect the configure
- For `查看 <run-id>`, run `agent-workflow status --run <run-id> --json`, summarize it, and do not advance the workflow. - For `查看 <run-id>`, run `agent-workflow status --run <run-id> --json`, summarize it, and do not advance the workflow.
- For `继续 <run-id>`, read status first, then continue from `awaiting_review`, `awaiting_scope_resolution`, or `awaiting_host`. Read the persisted state and referenced cycle artifacts when earlier command output is no longer in context; do not reconstruct findings from memory. - For `继续 <run-id>`, read status first, then continue from `awaiting_review`, `awaiting_scope_resolution`, or `awaiting_host`. Read the persisted state and referenced cycle artifacts when earlier command output is no longer in context; do not reconstruct findings from memory.
- For `清理后重试 <run-id>`, run `agent-workflow retry-review --run <run-id>` after the approved scope artifacts have been removed. It must refuse when baseline drift or out-of-scope paths remain. - For `清理后重试 <run-id>`, run `agent-workflow retry-review --run <run-id>` after the approved scope artifacts have been removed. It must refuse when baseline drift or out-of-scope paths remain.
- For `执行器重试 <run-id>`, run `agent-workflow retry-execute --run <run-id>` after the external cause of an executor failure is cleared (e.g., session conflict resolved, transient error gone). It preserves failed attempt logs and reuses the same session handle. If a Reasonix handle was not captured, the CLI may recover a persisted session only when its project directory, frozen plan title, and execution time window match the run, and must audit the recovery. Initial-cycle retries continue from the original plan; later-cycle retries resume from the preceding decision. Refuses when baseline has drifted, scope violations exist, or the cycle already produced review artifacts. - For `执行器重试 <run-id>`, run `agent-workflow retry-execute --run <run-id>` after the external cause of an executor failure is cleared (e.g., session conflict resolved, transient error gone, or corrected an invalid `model` configuration). It preserves failed attempt logs and reuses the same session handle. If a Reasonix handle was not captured, the CLI may recover a persisted session only when its project directory, frozen plan title, and execution time window match the run, and must audit the recovery. Initial-cycle retries continue from the original plan; later-cycle retries resume from the preceding decision. Refuses when baseline has drifted, scope violations exist, or the cycle already produced review artifacts.
- For `执行器重试 <run-id> 使用会话 <claude-session-id>`, run `agent-workflow retry-execute --run <run-id> --session <claude-session-id>` only when a Claude run's recorded session was never created or is unavailable and a known persisted replacement session contains the same frozen plan. The CLI must validate that the session belongs to the same repository and plan before recording the rebind in the audit log and retrying. Never edit `state.json` manually or guess a session ID. - For `执行器重试 <run-id> 使用会话 <claude-session-id>`, run `agent-workflow retry-execute --run <run-id> --session <claude-session-id>` only when a Claude run's recorded session was never created or is unavailable and a known persisted replacement session contains the same frozen plan. The CLI must validate that the session belongs to the same repository and plan before recording the rebind in the audit log and retrying. Never edit `state.json` manually or guess a session ID.
- For `延长 <run-id> 添加 <n> 个周期`, run `agent-workflow extend --run <run-id> --additional-cycles <n>` to extend a `budget_exhausted` run. This requires: the run ended at `budget_exhausted`, has a valid session handle, the last decision was `fix`, baseline hasn't drifted, and scope is clean. Returns to `awaiting_host` state to continue the same executor session. - For `延长 <run-id> 添加 <n> 个周期`, run `agent-workflow extend --run <run-id> --additional-cycles <n>` to extend a `budget_exhausted` run. This requires: the run ended at `budget_exhausted`, has a valid session handle, the last decision was `fix`, baseline hasn't drifted, and scope is clean. Returns to `awaiting_host` state to continue the same executor session.
- Treat `completed`, `needs_human`, `blocked`, and `aborted` as terminal. Report them instead of attempting another cycle. - Treat `completed`, `needs_human`, `blocked`, and `aborted` as terminal. Report them instead of attempting another cycle.
+55 -7
View File
@@ -6,6 +6,7 @@
*/ */
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { import type {
ExecutorConfig, ExecutorConfig,
@@ -18,6 +19,10 @@ import { EXECUTOR_KINDS, VCS_KINDS } from "./workflow-types.js";
const WORKFLOW_CONFIG_FILE = "agent-workflow.json"; const WORKFLOW_CONFIG_FILE = "agent-workflow.json";
function globalConfigPath(): string {
return path.join(os.homedir(), ".agent-workflow", "config.json");
}
const DEFAULTS = { const DEFAULTS = {
executor: "reasonix" as ExecutorKind, executor: "reasonix" as ExecutorKind,
maxCycles: 5, maxCycles: 5,
@@ -44,6 +49,19 @@ export function loadProjectWorkflowConfig(repoRoot: string): WorkflowProjectConf
return validateProjectConfig(raw, filePath); 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 { function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectConfig {
if (!raw || typeof raw !== "object") { if (!raw || typeof raw !== "object") {
throw new WorkflowConfigError(`${filePath}: must be a JSON 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)) { 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`); 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 // Prohibit secrets in config
const forbidden = ["apikey", "api_key", "token", "secret", "password", "credential"]; const forbidden = ["apikey", "api_key", "token", "secret", "password", "credential"];
const lower = JSON.stringify(raw).toLowerCase(); const lower = JSON.stringify(raw).toLowerCase();
@@ -82,42 +114,58 @@ export interface WorkflowCliOverrides {
executor?: ExecutorKind; executor?: ExecutorKind;
vcs?: VcsKind; vcs?: VcsKind;
maxCycles?: number; 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( export function resolveWorkflowConfig(
repoRoot: string, repoRoot: string,
cli: WorkflowCliOverrides = {}, cli: WorkflowCliOverrides = {},
): ResolvedWorkflowConfig { ): ResolvedWorkflowConfig {
const project = loadProjectWorkflowConfig(repoRoot); 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)) { if (!EXECUTOR_KINDS.includes(executor)) {
throw new WorkflowConfigError(`Unknown executor: ${executor}. Must be one of: ${EXECUTOR_KINDS.join(", ")}`); 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; let vcs: VcsKind | undefined;
if (cli.vcs) { if (cli.vcs) {
if (!VCS_KINDS.includes(cli.vcs)) { if (!VCS_KINDS.includes(cli.vcs)) {
throw new WorkflowConfigError(`Unknown VCS: ${cli.vcs}. Must be one of: ${VCS_KINDS.join(", ")}`); throw new WorkflowConfigError(`Unknown VCS: ${cli.vcs}. Must be one of: ${VCS_KINDS.join(", ")}`);
} }
vcs = cli.vcs; vcs = cli.vcs;
} else if (project?.defaultVcs && project.defaultVcs !== "auto") { } else if (project?.defaultVcs !== undefined) {
if (project.defaultVcs !== "auto") {
vcs = project.defaultVcs as VcsKind; 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 // 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) { if (!Number.isInteger(maxCycles) || maxCycles < 1) {
throw new WorkflowConfigError(`maxCycles must be a positive integer, got: ${maxCycles}`); 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 = { 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 { return {
executor, executor,
+65 -1
View File
@@ -564,6 +564,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
}); });
state.executorConfig = config.executorConfig; state.executorConfig = config.executorConfig;
state.timeoutSeconds = config.timeoutSeconds; state.timeoutSeconds = config.timeoutSeconds;
state.configSourceRoot = configSourceRoot ?? repoRoot;
state.enginePid = process.pid; state.enginePid = process.pid;
state.status = "executing"; state.status = "executing";
if (codexThreadId) { if (codexThreadId) {
@@ -595,6 +596,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
maxCycles: config.maxCycles, maxCycles: config.maxCycles,
vcsKind, vcsKind,
vcsBaseline, vcsBaseline,
...(config.executorConfig.model ? { model: config.executorConfig.model } : {}),
...(state.taskId ? { taskId: state.taskId, taskSessionReused: state.taskSessionReused } : {}), ...(state.taskId ? { taskId: state.taskId, taskSessionReused: state.taskSessionReused } : {}),
}, },
}); });
@@ -630,7 +632,11 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
runId, runId,
timestamp: nowIso(), timestamp: nowIso(),
cycleIndex, 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) { if (taskSessionHandle) {
appendEvent(dir, { appendEvent(dir, {
@@ -698,6 +704,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
exitCode: execResult.exitCode, exitCode: execResult.exitCode,
durationMs: execResult.durationMs, durationMs: execResult.durationMs,
failed: execResult.failed, failed: execResult.failed,
...(state.executorConfig?.model ? { model: state.executorConfig.model } : {}),
...(execResult.failureReason ? { failureReason: execResult.failureReason } : {}), ...(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); 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( async function resumeExecutorCycle(
state: WorkflowState, state: WorkflowState,
dir: string, dir: string,
@@ -1098,6 +1144,18 @@ async function resumeExecutorCycle(
registerAttemptLog(dir, cycleRecord, execLogPath); registerAttemptLog(dir, cycleRecord, execLogPath);
writeState(dir, state); // persist so retry can discover the log 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; let execResult;
try { try {
execResult = await adapter.resume({ execResult = await adapter.resume({
@@ -1145,6 +1203,7 @@ async function resumeExecutorCycle(
exitCode: execResult.exitCode, exitCode: execResult.exitCode,
durationMs: execResult.durationMs, durationMs: execResult.durationMs,
failed: execResult.failed, failed: execResult.failed,
...(state.executorConfig?.model ? { model: state.executorConfig.model } : {}),
...(execResult.failureReason ? { failureReason: execResult.failureReason } : {}), ...(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 exited with failure:") === true
|| state.stopDescription?.startsWith("Executor failed to resume:") === true || state.stopDescription?.startsWith("Executor failed to resume:") === true
|| state.stopDescription?.startsWith("Executor resume failed:") === true || state.stopDescription?.startsWith("Executor resume failed:") === true
|| state.stopDescription?.startsWith("Executor configuration is invalid:") === true
|| state.stopDescription === "No session handle available for resume.")); || state.stopDescription === "No session handle available for resume."));
if (!retryableFailure) { if (!retryableFailure) {
throw new WorkflowEngineError( throw new WorkflowEngineError(
@@ -1519,6 +1579,8 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
state.updatedAt = nowIso(); state.updatedAt = nowIso();
writeState(dir, state); writeState(dir, state);
applyReloadedExecutorModel(state, dir, state.currentCycle);
if (opts.sessionId) { if (opts.sessionId) {
appendEvent(dir, { appendEvent(dir, {
kind: "executor_session_rebound", kind: "executor_session_rebound",
@@ -1555,6 +1617,7 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
data: { data: {
executor: state.executor, executor: state.executor,
recoveryMode, 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) // 11. Resume the executor session immediately (lock released by resumeExecutorCycle on completion)
try { try {
applyReloadedExecutorModel(state, dir, nextCycle);
await resumeExecutorCycle(state, dir, nextCycleRecord, acceptedFindings); await resumeExecutorCycle(state, dir, nextCycleRecord, acceptedFindings);
} catch (err) { } catch (err) {
// resumeExecutorCycle already handles errors and releases lock // resumeExecutorCycle already handles errors and releases lock
+40
View File
@@ -162,6 +162,46 @@ touch '${sessionFile}.meta'
expect(result.sessionHandle?.kind).toBe("reasonix"); 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 () => { it("resume sends a complete continued plan for task-level reuse", async () => {
const argsFile = path.join(tmpDir, "args.txt"); const argsFile = path.join(tmpDir, "args.txt");
const fakeBin = writeFakeCli(tmpDir, { argsFile }); const fakeBin = writeFakeCli(tmpDir, { argsFile });
+8 -1
View File
@@ -568,7 +568,12 @@ class ReasonixAdapter implements ExecutorAdapter {
const snapshotDirs = [projectsDir]; const snapshotDirs = [projectsDir];
const beforeSnapshot = new Set(listJsonlFiles(projectsDir, opts.repoRoot)); 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 startMs = Date.now();
const logFile = opts.logFilePath; const logFile = opts.logFilePath;
@@ -619,9 +624,11 @@ class ReasonixAdapter implements ExecutorAdapter {
"run", "run",
"--dir", opts.repoRoot, "--dir", opts.repoRoot,
"--resume", handle.sessionFilePath, "--resume", handle.sessionFilePath,
...(opts.config.model ? ["--model", opts.config.model] : []),
prompt, prompt,
]; ];
const startMs = Date.now(); const startMs = Date.now();
const logFile = opts.logFilePath; const logFile = opts.logFilePath;
const attemptIndex = deriveAttemptIndex(logFile); const attemptIndex = deriveAttemptIndex(logFile);
+210 -1
View File
@@ -3,7 +3,7 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadProjectWorkflowConfig } from "./workflow-config.js"; import { loadProjectWorkflowConfig, resolveWorkflowConfig } from "./workflow-config.js";
import { import {
WorkflowLockError, WorkflowLockError,
acquireLock, 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", () => { describe("Retry-execute recovery", () => {
let tmpDir: string; let tmpDir: string;
let repoRoot: string; let repoRoot: string;
@@ -267,6 +365,117 @@ echo '{"session_id":"fake-session-123","conversation_id":"fake-conv-456"}'
.toBe("initial_continuation"); .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 () => { it("recovers a missing Reasonix handle for a blocked fix cycle", async () => {
const { retryExecuteWorkflow } = await import("./workflow-engine.js"); const { retryExecuteWorkflow } = await import("./workflow-engine.js");
const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js"); const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js");
+2
View File
@@ -204,6 +204,8 @@ export interface WorkflowState {
vcsBaseline?: VcsBaseline; vcsBaseline?: VcsBaseline;
/** VCS kind detected or specified at start. */ /** VCS kind detected or specified at start. */
vcsKind?: VcsKind; vcsKind?: VcsKind;
/** Directory used to load agent-workflow.json; persisted for per-cycle model reloads. */
configSourceRoot?: string;
executor: ExecutorKind; executor: ExecutorKind;
executorConfig?: ExecutorConfig; executorConfig?: ExecutorConfig;
/** Consecutive executor inactivity allowed before the host aborts the attempt. */ /** Consecutive executor inactivity allowed before the host aborts the attempt. */