import { execFileSync } from "node:child_process"; 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, resolveWorkflowConfig } from "./workflow-config.js"; import { WorkflowLockError, acquireLock, findRunDir, lockFilePath, readLock, releaseLock, repoHash, runDir, workflowsRoot, } from "./workflow-state.js"; describe("Workflow state root and locks", () => { let tmpDir: string; let repoRoot: string; let stateRoot: string; let originalAgentDir: string | undefined; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-state-test-")); repoRoot = path.join(tmpDir, "repo"); stateRoot = path.join(tmpDir, "workflows"); fs.mkdirSync(repoRoot, { recursive: true }); originalAgentDir = process.env.AGENT_WORKFLOW_DIR; process.env.AGENT_WORKFLOW_DIR = stateRoot; }); afterEach(() => { if (originalAgentDir === undefined) delete process.env.AGENT_WORKFLOW_DIR; else process.env.AGENT_WORKFLOW_DIR = originalAgentDir; fs.rmSync(tmpDir, { recursive: true, force: true }); }); it("uses AGENT_WORKFLOW_DIR for all state", () => { expect(workflowsRoot()).toBe(stateRoot); expect(runDir(repoRoot, "run-1")).toBe(path.join(stateRoot, repoHash(repoRoot), "run-1")); expect(lockFilePath(repoRoot)).toBe(path.join(stateRoot, repoHash(repoRoot), "active.lock")); }); it("finds a run with and without a repository hint", () => { const expected = runDir(repoRoot, "run-1"); fs.mkdirSync(expected, { recursive: true }); fs.writeFileSync(path.join(expected, "state.json"), "{}\n"); expect(findRunDir("run-1", repoRoot)).toBe(expected); expect(findRunDir("run-1")).toBe(expected); expect(findRunDir("missing", repoRoot)).toBeUndefined(); }); it("reuses the same run lock and rejects a different run", () => { acquireLock(repoRoot, "run-1"); expect(readLock(repoRoot)).toBe("run-1"); expect(() => acquireLock(repoRoot, "run-1")).not.toThrow(); expect(() => acquireLock(repoRoot, "run-2")).toThrow(WorkflowLockError); }); it("releases only the owning run lock", () => { acquireLock(repoRoot, "run-1"); releaseLock(repoRoot, "run-2"); expect(readLock(repoRoot)).toBe("run-1"); releaseLock(repoRoot, "run-1"); expect(readLock(repoRoot)).toBeUndefined(); }); }); describe("Project workflow config", () => { let tmpDir: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-config-test-")); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it("loads agent-workflow.json", () => { fs.writeFileSync( path.join(tmpDir, "agent-workflow.json"), JSON.stringify({ version: "1", defaultExecutor: "claude", maxCycles: 10 }), ); expect(loadProjectWorkflowConfig(tmpDir)).toMatchObject({ defaultExecutor: "claude", maxCycles: 10, }); }); it("returns undefined when no config exists", () => { expect(loadProjectWorkflowConfig(tmpDir)).toBeUndefined(); }); }); 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; let stateRoot: string; let originalAgentDir: string | undefined; let originalHome: string | undefined; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-retry-test-")); repoRoot = path.join(tmpDir, "repo"); stateRoot = path.join(tmpDir, "workflows"); fs.mkdirSync(repoRoot, { recursive: true }); execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot }); execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot }); fs.writeFileSync(path.join(repoRoot, "test.txt"), "test"); execFileSync("git", ["add", "."], { cwd: repoRoot }); execFileSync("git", ["commit", "-m", "init"], { cwd: repoRoot }); originalAgentDir = process.env.AGENT_WORKFLOW_DIR; process.env.AGENT_WORKFLOW_DIR = stateRoot; originalHome = process.env.HOME; process.env.HOME = tmpDir; }); afterEach(() => { if (originalAgentDir === undefined) delete process.env.AGENT_WORKFLOW_DIR; else process.env.AGENT_WORKFLOW_DIR = originalAgentDir; if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; fs.rmSync(tmpDir, { recursive: true, force: true }); }); function writeFakeExecutor(sessionFilePath: string): string { const scriptPath = path.join(path.dirname(sessionFilePath), "fake-reasonix"); const script = `#!/bin/bash set -e SESSION_FILE="${sessionFilePath}" echo '{"type":"session_started"}' > "\${SESSION_FILE}" echo '{"session_id":"fake-session-123","conversation_id":"fake-conv-456"}' `; fs.writeFileSync(scriptPath, script, { mode: 0o755 }); return scriptPath; } function plan() { return { version: "1" as const, title: "Test", planMarkdown: "Test plan", scope: ["test.txt"], acceptanceCriteria: ["Recovery succeeds"], verificationCommands: [["git", "status", "--short"]], }; } it("recovers a stale executing state without changing its cycle or session", async () => { const { retryExecuteWorkflow } = await import("./workflow-engine.js"); const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js"); const runId = "stale-run-123"; const dir = runDir(repoRoot, runId); fs.mkdirSync(dir, { recursive: true }); const sessionFile = path.join(dir, "session.jsonl"); fs.writeFileSync(sessionFile, '{"type":"session"}'); const state = initWorkflowState({ runId, repoRoot, baselineHead: gitHead(repoRoot), plan: plan(), executor: "reasonix", maxCycles: 3, }); state.status = "executing"; state.enginePid = 99_999_999; state.currentCycle = 1; 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"); acquireLock(repoRoot, runId); writeState(dir, state); await retryExecuteWorkflow({ runId }); const recovered = readState(dir)!; const cycle = recovered.cycles[0]!; expect(recovered.status).toBe("awaiting_review"); expect(recovered.currentCycle).toBe(1); expect(recovered.sessionHandle).toEqual(state.sessionHandle); expect(cycle.executorAttemptLogs).toHaveLength(2); expect(cycle.executorAttemptLogs?.[0]).toBe("cycle-1-exec.log"); expect(cycle.executorLogFile).toBe(cycle.executorAttemptLogs?.[1]); const retryLog = fs.readFileSync(path.join(dir, cycle.executorLogFile!), "utf8"); expect(retryLog).not.toMatch(/Permission denied|command not found|No such file/); 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_retried")?.data.recoveryMode) .toBe("stale_execution"); }); it("rejects retry while the recorded engine process is alive", async () => { const { retryExecuteWorkflow } = await import("./workflow-engine.js"); const { gitHead, initWorkflowState, writeState } = await import("./workflow-state.js"); const runId = "live-run-456"; const dir = runDir(repoRoot, runId); const state = initWorkflowState({ runId, repoRoot, baselineHead: gitHead(repoRoot), plan: plan(), executor: "reasonix", maxCycles: 3, }); state.status = "executing"; state.enginePid = process.pid; state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString() }]; writeState(dir, state); await expect(retryExecuteWorkflow({ runId })) .rejects.toThrow(/cannot retry execution from status 'executing'/); }); it("recovers a failed initial cycle and preserves attempt logs", async () => { const { retryExecuteWorkflow } = await import("./workflow-engine.js"); const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js"); const runId = "initial-retry-789"; const dir = runDir(repoRoot, runId); fs.mkdirSync(dir, { recursive: true }); const sessionFile = path.join(dir, "session.jsonl"); fs.writeFileSync(sessionFile, '{"type":"session"}'); 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.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)!; const cycle = recovered.cycles[0]!; expect(recovered.status).toBe("awaiting_review"); expect(recovered.currentCycle).toBe(1); expect(recovered.sessionHandle).toEqual(state.sessionHandle); expect(cycle.executorAttemptLogs).toHaveLength(2); expect(cycle.executorLogFile).toBe(cycle.executorAttemptLogs?.[1]); 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_retried")?.data.recoveryMode) .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"); const runId = "reasonix-missing-handle"; const dir = runDir(repoRoot, runId); const projectKey = path.resolve(repoRoot).replaceAll(path.sep, "-"); const sessionsDir = path.join(process.env.HOME!, ".reasonix", "projects", projectKey, "sessions"); const sessionFile = path.join(sessionsDir, "matching-session.jsonl"); fs.mkdirSync(sessionsDir, { recursive: true }); fs.writeFileSync(sessionFile, [ JSON.stringify({ role: "system", content: `Current workspace: "${path.dirname(repoRoot)}"` }), JSON.stringify({ role: "user", content: "# Implementation Task: Test" }), ].join("\n")); fs.writeFileSync(`${sessionFile}.meta`, "{}"); const fakeReasonix = path.join(tmpDir, "fake-reasonix-resume"); fs.writeFileSync(fakeReasonix, "#!/bin/bash\nexit 0\n", { mode: 0o755 }); const startedAt = new Date(Date.now() - 5000).toISOString(); const state = initWorkflowState({ runId, repoRoot, baselineHead: gitHead(repoRoot), plan: plan(), executor: "reasonix", maxCycles: 3, }); state.status = "blocked"; state.stopDescription = "No session handle available for resume."; state.currentCycle = 2; state.cycles = [ { cycleIndex: 1, startedAt, completedAt: new Date().toISOString(), decisionFile: "cycle-1-decision.json", decisionOutcome: "fix", }, { cycleIndex: 2, startedAt: new Date().toISOString() }, ]; state.executorConfig = { binary: fakeReasonix }; fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, "cycle-1-decision.json"), JSON.stringify({ version: "1", outcome: "fix", findingDecisions: [], verificationEvidence: [], decidedAt: new Date().toISOString(), })); writeState(dir, state); await retryExecuteWorkflow({ runId }); const recovered = readState(dir)!; expect(recovered.status).toBe("awaiting_review"); expect(recovered.sessionHandle).toEqual({ kind: "reasonix", sessionFilePath: sessionFile }); 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_session_rebound")?.data).toEqual({ sessionFilePath: sessionFile, recovery: "reasonix_project_session", }); }); it("rebinds a validated Claude session before retrying the failed cycle", async () => { const { retryExecuteWorkflow } = await import("./workflow-engine.js"); const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js"); const runId = "claude-rebind-123"; const sessionId = "22778b47-e4f3-4645-84cd-1c6a74a912d8"; const dir = runDir(repoRoot, runId); const claudeProject = path.join(tmpDir, ".claude", "projects", "test-project"); fs.mkdirSync(claudeProject, { recursive: true }); fs.writeFileSync( path.join(claudeProject, `${sessionId}.jsonl`), `${JSON.stringify({ sessionId, cwd: repoRoot, message: { content: "# Implementation Task: Test" } })}\n`, ); const fakeClaude = path.join(tmpDir, "fake-claude"); fs.writeFileSync(fakeClaude, `#!/bin/bash printf '{"type":"system","subtype":"init","session_id":"${sessionId}"}\\n' printf '{"type":"result","result":"done","session_id":"${sessionId}"}\\n' `, { mode: 0o755 }); const state = initWorkflowState({ runId, repoRoot, baselineHead: gitHead(repoRoot), plan: plan(), executor: "claude", maxCycles: 3, }); state.status = "blocked"; state.stopDescription = "Executor failed to resume: missing session"; state.currentCycle = 1; state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString(), executorLogFile: "cycle-1-exec.log", executorAttemptLogs: ["cycle-1-exec.log"], }]; state.sessionHandle = { kind: "claude", sessionId: "97a606e8-cfa3-4996-83a8-ddb7ece4234b" }; state.executorConfig = { binary: fakeClaude }; fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "initial attempt failed"); writeState(dir, state); await retryExecuteWorkflow({ runId, sessionId }); const recovered = readState(dir)!; expect(recovered.status).toBe("awaiting_review"); expect(recovered.sessionHandle).toEqual({ kind: "claude", sessionId }); 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_session_rebound")?.data).toEqual({ previousSessionId: "97a606e8-cfa3-4996-83a8-ddb7ece4234b", sessionId, }); }); it("rejects a Claude session from another repository without changing state", async () => { const { retryExecuteWorkflow } = await import("./workflow-engine.js"); const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js"); const runId = "claude-rebind-reject"; const sessionId = "22778b47-e4f3-4645-84cd-1c6a74a912d8"; const dir = runDir(repoRoot, runId); const claudeProject = path.join(tmpDir, ".claude", "projects", "other-project"); fs.mkdirSync(claudeProject, { recursive: true }); fs.writeFileSync( path.join(claudeProject, `${sessionId}.jsonl`), `${JSON.stringify({ sessionId, cwd: path.join(tmpDir, "other"), message: { content: "Test" } })}\n`, ); const state = initWorkflowState({ runId, repoRoot, baselineHead: gitHead(repoRoot), plan: plan(), executor: "claude", maxCycles: 3, }); state.status = "blocked"; state.stopDescription = "Executor failed to resume: missing session"; state.currentCycle = 1; state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString() }]; state.sessionHandle = { kind: "claude", sessionId: "97a606e8-cfa3-4996-83a8-ddb7ece4234b" }; writeState(dir, state); await expect(retryExecuteWorkflow({ runId, sessionId })) .rejects.toThrow(/does not belong to repository/); expect(readState(dir)?.sessionHandle).toEqual(state.sessionHandle); expect(fs.existsSync(path.join(dir, "events.jsonl"))).toBe(false); }); }); // --------------------------------------------------------------------------- // Progress sidecar // --------------------------------------------------------------------------- describe("Progress sidecar", () => { let tmpDir: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-sidecar-test-")); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it("writes and reads progress sidecar", async () => { const { writeProgressSidecar, readProgressSidecar } = await import("./workflow-state.js"); const sidecar = { executor: "reasonix" as const, cycleIndex: 1, attemptIndex: 0, phase: "executing", startedAt: new Date().toISOString(), lastActivityAt: new Date().toISOString(), logFilePath: "cycle-1-exec.log", logBytes: 1024, activitySummary: "Running build", updatedAt: new Date().toISOString(), }; writeProgressSidecar(tmpDir, sidecar); const read = readProgressSidecar(tmpDir); expect(read).toBeDefined(); expect(read!.executor).toBe("reasonix"); expect(read!.phase).toBe("executing"); expect(read!.logBytes).toBe(1024); expect(read!.activitySummary).toBe("Running build"); }); it("returns undefined when sidecar file does not exist", async () => { const { readProgressSidecar } = await import("./workflow-state.js"); expect(readProgressSidecar(tmpDir)).toBeUndefined(); }); it("reads attempt log path for initial attempt and retry", async () => { const { attemptLogPath } = await import("./workflow-state.js"); const dir = tmpDir; expect(path.basename(attemptLogPath(dir, 1, 0))).toBe("cycle-1-exec.log"); expect(path.basename(attemptLogPath(dir, 2, 0))).toBe("cycle-2-exec.log"); expect(path.basename(attemptLogPath(dir, 1, 1))).toBe("cycle-1-exec-retry-1.log"); expect(path.basename(attemptLogPath(dir, 1, 3))).toBe("cycle-1-exec-retry-3.log"); }); it("nextAttemptIndex finds the next free index", async () => { const { nextAttemptIndex, attemptLogPath } = await import("./workflow-state.js"); fs.writeFileSync(attemptLogPath(tmpDir, 1, 0), "initial"); expect(nextAttemptIndex(tmpDir, 1)).toBe(1); fs.writeFileSync(attemptLogPath(tmpDir, 1, 1), "retry1"); expect(nextAttemptIndex(tmpDir, 1)).toBe(2); fs.writeFileSync(attemptLogPath(tmpDir, 1, 2), "retry2"); expect(nextAttemptIndex(tmpDir, 1)).toBe(3); // Non-existent cycle returns 0 expect(nextAttemptIndex(tmpDir, 9)).toBe(0); }); it("sidecar preserves Chinese UTF-8 in activitySummary", async () => { const { writeProgressSidecar, readProgressSidecar } = await import("./workflow-state.js"); writeProgressSidecar(tmpDir, { executor: "claude", cycleIndex: 1, attemptIndex: 0, phase: "executing", startedAt: new Date().toISOString(), lastActivityAt: new Date().toISOString(), logFilePath: "cycle-1-exec.log", logBytes: 42, activitySummary: "读取文件 src/index.ts", updatedAt: new Date().toISOString(), }); const read = readProgressSidecar(tmpDir); expect(read).toBeDefined(); expect(read!.activitySummary).toBe("读取文件 src/index.ts"); }); }); // --------------------------------------------------------------------------- // Status live fields and sidecar integration // --------------------------------------------------------------------------- describe("Status and sidecar live fields", () => { let tmpDir: string; let stateRoot: string; let repoRoot: string; let originalAgentDir: string | undefined; beforeEach(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-status-test-")); repoRoot = path.join(tmpDir, "repo"); stateRoot = path.join(tmpDir, "workflows"); fs.mkdirSync(repoRoot, { recursive: true }); const { execFileSync } = await import("node:child_process"); execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot }); execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot }); fs.writeFileSync(path.join(repoRoot, "test.txt"), "test"); execFileSync("git", ["add", "."], { cwd: repoRoot }); execFileSync("git", ["commit", "-m", "init"], { cwd: repoRoot }); originalAgentDir = process.env.AGENT_WORKFLOW_DIR; process.env.AGENT_WORKFLOW_DIR = stateRoot; }); afterEach(() => { if (originalAgentDir === undefined) delete process.env.AGENT_WORKFLOW_DIR; else process.env.AGENT_WORKFLOW_DIR = originalAgentDir; fs.rmSync(tmpDir, { recursive: true, force: true }); }); it("statusWorkflow includes live fields from sidecar when executing", async () => { const { runDir, writeState, initWorkflowState, gitHead, writeProgressSidecar, readState } = await import("./workflow-state.js"); const runId = "status-live-fields-001"; const dir = runDir(repoRoot, runId); fs.mkdirSync(dir, { recursive: true }); const state = initWorkflowState({ runId, repoRoot, baselineHead: gitHead(repoRoot), plan: { version: "1", title: "Test", planMarkdown: "Test", scope: ["src/"], acceptanceCriteria: ["Ok"], verificationCommands: [["echo"]] }, executor: "claude", maxCycles: 3, }); state.status = "executing"; state.currentCycle = 1; state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString() }]; writeState(dir, state); // Write a sidecar as if an adapter was running writeProgressSidecar(dir, { executor: "claude", cycleIndex: 1, attemptIndex: 0, pid: 12345, phase: "executing", startedAt: new Date(Date.now() - 120_000).toISOString(), // 2 min ago lastActivityAt: new Date().toISOString(), logFilePath: "cycle-1-exec.log", logBytes: 4096, activitySummary: "Reading src/index.ts", updatedAt: new Date().toISOString(), }); fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "log content"); const { statusWorkflow } = await import("./workflow-engine.js"); const chunks: string[] = []; const origWrite = process.stdout.write.bind(process.stdout); process.stdout.write = (chunk: unknown) => { chunks.push(String(chunk)); return true; }; try { statusWorkflow({ runId, json: true }); } finally { process.stdout.write = origWrite; } const output = JSON.parse(chunks.join("")); expect(output.liveLogFile).toContain("cycle-1-exec.log"); expect(output.executionElapsed).toBeDefined(); expect(output.lastActivity).toBeDefined(); expect(output.logBytes).toBe(4096); expect(output.executorPid).toBe(12345); expect(output.activitySummary).toBe("Reading src/index.ts"); }); it("status live fields absent when no sidecar", async () => { const { runDir, writeState, initWorkflowState, gitHead } = await import("./workflow-state.js"); const runId = "status-no-sidecar-002"; const dir = runDir(repoRoot, runId); fs.mkdirSync(dir, { recursive: true }); const state = initWorkflowState({ runId, repoRoot, baselineHead: gitHead(repoRoot), plan: { version: "1", title: "Test", planMarkdown: "Test", scope: ["src/"], acceptanceCriteria: ["Ok"], verificationCommands: [["echo"]] }, executor: "reasonix", maxCycles: 3, }); state.status = "completed"; state.currentCycle = 1; state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString() }]; writeState(dir, state); const { statusWorkflow } = await import("./workflow-engine.js"); const chunks: string[] = []; const origWrite = process.stdout.write.bind(process.stdout); process.stdout.write = (chunk: unknown) => { chunks.push(String(chunk)); return true; }; try { statusWorkflow({ runId, json: true }); } finally { process.stdout.write = origWrite; } const output = JSON.parse(chunks.join("")); expect(output.liveLogFile).toBeUndefined(); expect(output.executionElapsed).toBeUndefined(); expect(output.activitySummary).toBeUndefined(); }); }); // --------------------------------------------------------------------------- // logsWorkflow follow mode // --------------------------------------------------------------------------- describe("logsWorkflow", () => { let tmpDir: string; let stateRoot: string; let repoRoot: string; let originalAgentDir: string | undefined; beforeEach(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-logs-test-")); repoRoot = path.join(tmpDir, "repo"); stateRoot = path.join(tmpDir, "workflows"); fs.mkdirSync(repoRoot, { recursive: true }); const { execFileSync } = await import("node:child_process"); execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot }); execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot }); fs.writeFileSync(path.join(repoRoot, "test.txt"), "test"); execFileSync("git", ["add", "."], { cwd: repoRoot }); execFileSync("git", ["commit", "-m", "init"], { cwd: repoRoot }); originalAgentDir = process.env.AGENT_WORKFLOW_DIR; process.env.AGENT_WORKFLOW_DIR = stateRoot; }); afterEach(() => { if (originalAgentDir === undefined) delete process.env.AGENT_WORKFLOW_DIR; else process.env.AGENT_WORKFLOW_DIR = originalAgentDir; fs.rmSync(tmpDir, { recursive: true, force: true }); }); it("tail mode prints Chinese UTF-8 lines without duplication", async () => { const { runDir, writeState, initWorkflowState, gitHead, readState } = await import("./workflow-state.js"); const runId = "logs-tail-utf8-001"; const dir = runDir(repoRoot, runId); fs.mkdirSync(dir, { recursive: true }); const state = initWorkflowState({ runId, repoRoot, baselineHead: gitHead(repoRoot), plan: { version: "1", title: "Test", planMarkdown: "Test", scope: ["src/"], acceptanceCriteria: ["Ok"], verificationCommands: [["echo"]] }, executor: "claude", maxCycles: 3, }); state.status = "executing"; state.currentCycle = 1; state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString(), executorLogFile: "cycle-1-exec.log" }]; writeState(dir, state); // Write Chinese UTF-8 content to the log file const logContent = "line1\n读取文件 src/index.ts\n日本語\n🚀\nlast line"; fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), logContent); const { logsWorkflow } = await import("./workflow-engine.js"); const chunks: string[] = []; const origWrite = process.stdout.write.bind(process.stdout); process.stdout.write = (chunk: unknown) => { chunks.push(String(chunk)); return true; }; try { await logsWorkflow({ runId, follow: false, tail: 100 }); } finally { process.stdout.write = origWrite; } const output = chunks.join(""); expect(output).toContain("读取文件 src/index.ts"); expect(output).toContain("日本語"); expect(output).toContain("🚀"); // No duplication expect(output.split("读取文件 src/index.ts").length - 1).toBe(1); }); it("follow mode exits when state changes from executing to awaiting_review", async () => { const { runDir, writeState, initWorkflowState, gitHead, readState } = await import("./workflow-state.js"); const runId = "logs-follow-exit-002"; const dir = runDir(repoRoot, runId); fs.mkdirSync(dir, { recursive: true }); const state = initWorkflowState({ runId, repoRoot, baselineHead: gitHead(repoRoot), plan: { version: "1", title: "Test", planMarkdown: "Test", scope: ["src/"], acceptanceCriteria: ["Ok"], verificationCommands: [["echo"]] }, executor: "claude", maxCycles: 3, }); state.status = "executing"; state.currentCycle = 1; state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString(), executorLogFile: "cycle-1-exec.log" }]; writeState(dir, state); fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "initial log content\n"); const { logsWorkflow } = await import("./workflow-engine.js"); const chunks: string[] = []; const origWrite = process.stdout.write.bind(process.stdout); process.stdout.write = (chunk: unknown) => { chunks.push(String(chunk)); return true; }; // Start logsWorkflow in follow mode, change state after a short delay const logsPromise = logsWorkflow({ runId, follow: true, tail: 5 }); setTimeout(() => { const curState = readState(dir)!; curState.status = "awaiting_review"; writeState(dir, curState); }, 200); try { await logsPromise; } finally { process.stdout.write = origWrite; } const output = chunks.join(""); expect(output).toContain("initial log content"); }); it("rejects a second follower from the same Codex thread", async () => { const { runDir, writeState, initWorkflowState, gitHead, readState } = await import("./workflow-state.js"); const runId = "logs-follow-dedupe-004"; const dir = runDir(repoRoot, runId); fs.mkdirSync(dir, { recursive: true }); const state = initWorkflowState({ runId, repoRoot, baselineHead: gitHead(repoRoot), plan: { version: "1", title: "Test", planMarkdown: "Test", scope: ["src/"], acceptanceCriteria: ["Ok"], verificationCommands: [["echo"]] }, executor: "claude", maxCycles: 3, }); state.status = "executing"; state.currentCycle = 1; state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString(), executorLogFile: "cycle-1-exec.log" }]; writeState(dir, state); fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "initial log content\n"); const previousThread = process.env.CODEX_THREAD_ID; process.env.CODEX_THREAD_ID = "thread-for-follower-dedupe"; const { logsWorkflow } = await import("./workflow-engine.js"); const chunks: string[] = []; const origWrite = process.stdout.write.bind(process.stdout); process.stdout.write = (chunk: unknown) => { chunks.push(String(chunk)); return true; }; const firstFollower = logsWorkflow({ runId, follow: true, tail: 5 }); try { await expect(logsWorkflow({ runId, follow: true, tail: 5 })) .rejects.toThrow(/already active for this Codex thread/); const curState = readState(dir)!; curState.status = "awaiting_review"; writeState(dir, curState); await firstFollower; expect(chunks.join("")).toContain("initial log content"); } finally { process.stdout.write = origWrite; if (previousThread === undefined) delete process.env.CODEX_THREAD_ID; else process.env.CODEX_THREAD_ID = previousThread; } }); it("allows different Codex threads to hold followers for the same run", async () => { const { acquireLogsFollowLock, logsFollowLockPath, releaseLogsFollowLock, runDir, } = await import("./workflow-state.js"); const dir = runDir(repoRoot, "logs-follow-different-threads-005"); fs.mkdirSync(dir, { recursive: true }); const first = acquireLogsFollowLock(dir, "thread-a"); const second = acquireLogsFollowLock(dir, "thread-b"); try { expect(first).toBe(logsFollowLockPath(dir, "thread-a")); expect(second).toBe(logsFollowLockPath(dir, "thread-b")); expect(first).not.toBe(second); } finally { releaseLogsFollowLock(first); releaseLogsFollowLock(second); } expect(fs.existsSync(first)).toBe(false); expect(fs.existsSync(second)).toBe(false); }); it("reclaims a follower lock whose process is dead", async () => { const { acquireLogsFollowLock, logsFollowLockPath, releaseLogsFollowLock, runDir, } = await import("./workflow-state.js"); const dir = runDir(repoRoot, "logs-follow-stale-006"); fs.mkdirSync(dir, { recursive: true }); const lockPath = logsFollowLockPath(dir, "stale-thread"); fs.writeFileSync(lockPath, JSON.stringify({ pid: 999_999_999, codexThreadId: "stale-thread", startedAt: new Date(0).toISOString(), })); const acquired = acquireLogsFollowLock(dir, "stale-thread"); expect(acquired).toBe(lockPath); expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).pid).toBe(process.pid); releaseLogsFollowLock(acquired); expect(fs.existsSync(lockPath)).toBe(false); }); it("keeps manual follow behavior unchanged without CODEX_THREAD_ID", async () => { const { runDir, writeState, initWorkflowState, gitHead, readState } = await import("./workflow-state.js"); const runId = "logs-follow-manual-007"; const dir = runDir(repoRoot, runId); fs.mkdirSync(dir, { recursive: true }); const state = initWorkflowState({ runId, repoRoot, baselineHead: gitHead(repoRoot), plan: { version: "1", title: "Test", planMarkdown: "Test", scope: ["src/"], acceptanceCriteria: ["Ok"], verificationCommands: [["echo"]] }, executor: "claude", maxCycles: 3, }); state.status = "executing"; state.currentCycle = 1; state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString(), executorLogFile: "cycle-1-exec.log" }]; writeState(dir, state); fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "manual viewer\n"); const previousThread = process.env.CODEX_THREAD_ID; delete process.env.CODEX_THREAD_ID; const { logsWorkflow } = await import("./workflow-engine.js"); const origWrite = process.stdout.write.bind(process.stdout); process.stdout.write = () => true; const first = logsWorkflow({ runId, follow: true, tail: 5 }); const second = logsWorkflow({ runId, follow: true, tail: 5 }); setTimeout(() => { const curState = readState(dir)!; curState.status = "awaiting_review"; writeState(dir, curState); }, 100); try { await Promise.all([first, second]); expect(fs.readdirSync(dir).some((name) => name.startsWith(".logs-follow-"))).toBe(false); } finally { process.stdout.write = origWrite; if (previousThread === undefined) delete process.env.CODEX_THREAD_ID; else process.env.CODEX_THREAD_ID = previousThread; } }); it("follow mode switches to retry log when sidecar changes and emits new content", async () => { const { runDir, writeState, initWorkflowState, gitHead, writeProgressSidecar, readState } = await import("./workflow-state.js"); const runId = "logs-rotation-003"; const dir = runDir(repoRoot, runId); fs.mkdirSync(dir, { recursive: true }); const state = initWorkflowState({ runId, repoRoot, baselineHead: gitHead(repoRoot), plan: { version: "1", title: "Test", planMarkdown: "Test", scope: ["src/"], acceptanceCriteria: ["Ok"], verificationCommands: [["echo"]] }, executor: "claude", maxCycles: 3, }); state.status = "executing"; state.currentCycle = 1; state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString(), executorLogFile: "cycle-1-exec.log" }]; writeState(dir, state); // Create initial log fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "initial log\n"); // Write a sidecar pointing to the original log writeProgressSidecar(dir, { executor: "claude", cycleIndex: 1, attemptIndex: 0, phase: "executing", startedAt: new Date().toISOString(), lastActivityAt: new Date().toISOString(), logFilePath: "cycle-1-exec.log", logBytes: 12, activitySummary: "Running", updatedAt: new Date().toISOString(), }); const { logsWorkflow } = await import("./workflow-engine.js"); const chunks: string[] = []; const origWrite = process.stdout.write.bind(process.stdout); process.stdout.write = (chunk: unknown) => { chunks.push(String(chunk)); return true; }; const logsPromise = logsWorkflow({ runId, follow: true, tail: 5 }); setTimeout(() => { // Create retry log and update cycle record fs.writeFileSync(path.join(dir, "cycle-1-exec-retry-1.log"), "retry content\n你好世界\n"); const curState = readState(dir)!; const cycle = curState.cycles[0]!; cycle.executorLogFile = "cycle-1-exec-retry-1.log"; cycle.executorAttemptLogs = ["cycle-1-exec.log", "cycle-1-exec-retry-1.log"]; writeState(dir, curState); // Update sidecar to point to retry log writeProgressSidecar(dir, { executor: "claude", cycleIndex: 1, attemptIndex: 1, phase: "executing", startedAt: new Date().toISOString(), lastActivityAt: new Date().toISOString(), logFilePath: "cycle-1-exec-retry-1.log", logBytes: 30, activitySummary: "Retrying", updatedAt: new Date().toISOString(), }); }, 200); setTimeout(() => { const curState = readState(dir)!; curState.status = "awaiting_review"; writeState(dir, curState); }, 600); try { await logsPromise; } finally { process.stdout.write = origWrite; } const output = chunks.join(""); expect(output).toContain("initial log"); expect(output).toContain("retry content"); expect(output).toContain("你好世界"); }); });