feat: initialize standalone agent-workflow
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user