diff --git a/README.md b/README.md index 76cc6ed..f0061f1 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,12 @@ This allows continuation of the same executor session after reaching the cycle l ## 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 { @@ -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. - `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 `. + +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 diff --git a/skills/agent-workflow-host/SKILL.md b/skills/agent-workflow-host/SKILL.md index c8a7fb4..0a0a7fe 100644 --- a/skills/agent-workflow-host/SKILL.md +++ b/skills/agent-workflow-host/SKILL.md @@ -82,7 +82,9 @@ After starting, announce: Run started. Monitor live output: agent-workflow logs --run --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 @@ -165,7 +167,7 @@ After `fix`, repeat review and decision for the new cycle. Respect the configure - For `查看 `, run `agent-workflow status --run --json`, summarize it, and do not advance the workflow. - For `继续 `, 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 `agent-workflow retry-review --run ` after the approved scope artifacts have been removed. It must refuse when baseline drift or out-of-scope paths remain. -- For `执行器重试 `, run `agent-workflow retry-execute --run ` 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 `agent-workflow retry-execute --run ` 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 `agent-workflow retry-execute --run --session ` 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 `agent-workflow extend --run --additional-cycles ` 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. diff --git a/src/workflow-config.ts b/src/workflow-config.ts index a8c6ac6..c941247 100644 --- a/src/workflow-config.ts +++ b/src/workflow-config.ts @@ -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)[kind]; + if (exec && typeof exec === "object") { + const model = (exec as Record).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 | undefined)?.[executor] ?? {}; + const projectExecutorConfig = (project?.executors as Record | undefined)?.[executor] ?? {}; const executorConfig: ExecutorConfig = { - ...((project?.executors as Record | 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, diff --git a/src/workflow-engine.ts b/src/workflow-engine.ts index 3d4b303..c959855 100644 --- a/src/workflow-engine.ts +++ b/src/workflow-engine.ts @@ -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 { }); } + 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 { // 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 diff --git a/src/workflow-executor.test.ts b/src/workflow-executor.test.ts index 31853f5..ebbb895 100644 --- a/src/workflow-executor.test.ts +++ b/src/workflow-executor.test.ts @@ -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 }); diff --git a/src/workflow-executor.ts b/src/workflow-executor.ts index a76efcd..11e34b5 100644 --- a/src/workflow-executor.ts +++ b/src/workflow-executor.ts @@ -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); diff --git a/src/workflow-state.test.ts b/src/workflow-state.test.ts index 5ada9d8..52f0db4 100644 --- a/src/workflow-state.test.ts +++ b/src/workflow-state.test.ts @@ -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"); diff --git a/src/workflow-types.ts b/src/workflow-types.ts index 59a6729..8fd4e9b 100644 --- a/src/workflow-types.ts +++ b/src/workflow-types.ts @@ -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. */