feat: initialize standalone agent-workflow

This commit is contained in:
liujing
2026-07-16 20:56:24 +08:00
commit 35e8fa3717
31 changed files with 6972 additions and 0 deletions
+264
View File
@@ -0,0 +1,264 @@
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 } 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("Retry-execute recovery", () => {
let tmpDir: string;
let repoRoot: string;
let stateRoot: string;
let originalAgentDir: 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;
});
afterEach(() => {
if (originalAgentDir === undefined) delete process.env.AGENT_WORKFLOW_DIR;
else process.env.AGENT_WORKFLOW_DIR = originalAgentDir;
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");
});
});