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
+119
View File
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import { Writable } from "node:stream";
import { test } from "vitest";
import { spawnBufferedChild, spawnStreamingChild } from "./child-process.js";
function collectSink(): { stream: Writable; chunks: string[] } {
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _enc, cb) {
chunks.push(String(chunk));
cb();
},
});
return { stream, chunks };
}
/** Resolve once the collected chunks match `pattern`, with a generous timeout
* so we never hang forever if the child never emits. */
function waitForOutput(chunks: string[], pattern: RegExp, timeoutMs = 2000): Promise<void> {
return new Promise((resolve, reject) => {
const start = Date.now();
const tick = () => {
if (pattern.test(chunks.join(""))) return resolve();
if (Date.now() - start > timeoutMs) return reject(new Error(`timed out waiting for ${pattern}`));
setTimeout(tick, 5);
};
tick();
});
}
test("spawnBufferedChild captures stdout", () => {
const r = spawnBufferedChild(process.execPath, ["-e", "console.log('buf')"], {});
assert.equal(r.status, 0);
assert.match(r.stdout, /buf/);
});
test("spawnStreamingChild reports signal when child is killed", async () => {
const { stream } = collectSink();
const r = await spawnStreamingChild(
process.execPath,
["-e", "process.kill(process.pid, 'SIGTERM')"],
{ stdoutSink: stream, stderrSink: stream },
);
assert.equal(r.status, null);
assert.equal(r.signal, "SIGTERM");
});
test("spawnStreamingChild forwards chunks and collects full stdout", async () => {
const script = [
"console.log('line1');",
"setTimeout(() => console.log('line2'), 30);",
].join("");
const { stream, chunks } = collectSink();
const r = await spawnStreamingChild(process.execPath, ["-e", script], {
stdoutSink: stream,
stderrSink: stream,
});
assert.equal(r.status, 0);
assert.match(r.stdout, /line1/);
assert.match(r.stdout, /line2/);
const forwarded = chunks.join("");
assert.match(forwarded, /line1/);
assert.match(forwarded, /line2/);
});
test("spawnStreamingChild keeps captured stdout bounded while forwarding all output", async () => {
const script = "process.stdout.write('abcde'); process.stdout.write('fghij');";
const { stream, chunks } = collectSink();
const r = await spawnStreamingChild(process.execPath, ["-e", script], {
stdoutSink: stream,
stderrSink: stream,
maxCaptureChars: 6,
});
assert.equal(r.status, 0);
assert.equal(r.stdout, "efghij");
assert.equal(chunks.join(""), "abcdefghij");
});
test("spawnStreamingChild terminates an opted-in process group when aborted", async () => {
const controller = new AbortController();
const { stream } = collectSink();
const result = spawnStreamingChild(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
stdoutSink: stream,
stderrSink: stream,
signal: controller.signal,
processGroup: true,
});
setTimeout(() => controller.abort(), 30);
const child = await result;
assert.equal(child.signal, "SIGTERM");
});
test("spawnStreamingChild escalates ignored SIGTERM to SIGKILL after the abort grace period", async () => {
const controller = new AbortController();
const { stream, chunks } = collectSink();
// Register the ignore handler first, then stay alive until SIGKILL.
const script = [
"process.on('SIGTERM', () => { process.stdout.write('ignored\\n'); });",
"process.stdout.write('ready\\n');",
"setInterval(() => {}, 1000);",
].join("");
const result = spawnStreamingChild(process.execPath, ["-e", script], {
stdoutSink: stream,
stderrSink: stream,
signal: controller.signal,
processGroup: false,
abortKillGraceMs: 50,
});
// Wait until the child has actually registered its SIGTERM handler and
// signalled readiness, rather than a fixed delay — under parallel load the
// child can take longer to start, and aborting before the handler is
// installed would let SIGTERM kill it (observed as SIGTERM instead of SIGKILL).
await waitForOutput(chunks, /ready/);
controller.abort();
const child = await result;
assert.equal(child.signal, "SIGKILL");
assert.match(chunks.join("") + child.stdout, /ready/);
});
+151
View File
@@ -0,0 +1,151 @@
import { spawn, spawnSync, type SpawnOptions } from "node:child_process";
export type ChildRunResult = {
status: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
error?: Error;
};
export const DEFAULT_STREAM_CAPTURE_LIMIT = 50 * 1024 * 1024;
type SpawnCommon = {
cwd?: string;
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
/** Start a separate process group so abort can terminate descendants too. */
processGroup?: boolean;
};
function appendBoundedTail(current: string, chunk: string, limit: number): string {
if (limit <= 0) return "";
const next = current + chunk;
return next.length > limit ? next.slice(next.length - limit) : next;
}
export function spawnBufferedChild(
command: string,
argv: string[],
options: SpawnCommon,
): ChildRunResult {
const child = spawnSync(command, argv, {
cwd: options.cwd,
env: options.env,
encoding: "utf8",
maxBuffer: 50 * 1024 * 1024,
});
return {
status: child.status,
signal: child.signal,
stdout: child.stdout || "",
stderr: child.stderr || "",
error: child.error,
};
}
export const DEFAULT_ABORT_KILL_GRACE_MS = 2_000;
export async function spawnStreamingChild(
command: string,
argv: string[],
options: SpawnCommon & {
stdoutSink?: NodeJS.WritableStream;
stderrSink?: NodeJS.WritableStream;
maxCaptureChars?: number;
/** After SIGTERM, escalate to SIGKILL if the process is still alive. */
abortKillGraceMs?: number;
},
): Promise<ChildRunResult> {
const stdoutSink = options.stdoutSink ?? process.stdout;
const stderrSink = options.stderrSink ?? process.stderr;
const maxCaptureChars = options.maxCaptureChars ?? DEFAULT_STREAM_CAPTURE_LIMIT;
const abortKillGraceMs = options.abortKillGraceMs ?? DEFAULT_ABORT_KILL_GRACE_MS;
return new Promise((resolve) => {
const spawnOpts: SpawnOptions = {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
// Preserve terminal signal delivery for ordinary streaming children.
// Workflow callers opt into a group so their abort signal reaches executor descendants.
detached: options.processGroup === true && process.platform !== "win32",
};
const child = spawn(command, argv, spawnOpts);
let stdout = "";
let stderr = "";
let settled = false;
let killTimer: NodeJS.Timeout | undefined;
const finish = (result: ChildRunResult) => {
if (settled) return;
settled = true;
if (killTimer) clearTimeout(killTimer);
options.signal?.removeEventListener("abort", abort);
resolve(result);
};
const sendSignal = (signal: NodeJS.Signals) => {
if (child.pid === undefined) return;
try {
if (options.processGroup && process.platform === "win32") {
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
} else if (options.processGroup) {
process.kill(-child.pid, signal);
} else {
child.kill(signal);
}
} catch {
try {
child.kill(signal);
} catch {
// Process already gone.
}
}
};
const abort = () => {
if (child.pid === undefined || settled) return;
sendSignal("SIGTERM");
if (process.platform === "win32") return;
killTimer = setTimeout(() => {
if (!settled) sendSignal("SIGKILL");
}, Math.max(0, abortKillGraceMs));
killTimer.unref?.();
};
if (options.signal?.aborted) abort();
else options.signal?.addEventListener("abort", abort, { once: true });
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
stdout = appendBoundedTail(stdout, chunk, maxCaptureChars);
stdoutSink.write(chunk);
});
child.stderr?.on("data", (chunk: string) => {
stderr = appendBoundedTail(stderr, chunk, maxCaptureChars);
stderrSink.write(chunk);
});
child.on("error", (error) => {
finish({
status: null,
signal: null,
stdout,
stderr,
error,
});
});
child.on("close", (status, signal) => {
finish({
status,
signal,
stdout,
stderr,
});
});
});
}
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
* agent-workflow CLI entry point.
*
* Supports direct subcommands: start, review, retry-review, retry-execute, decide, status, abort.
*/
import {
parseWorkflowArgs,
workflowUsage,
type WorkflowArgs,
} from "./workflow-args.js";
import {
startWorkflow,
reviewWorkflow,
decideWorkflow,
statusWorkflow,
abortWorkflow,
retryReviewWorkflow,
retryExecuteWorkflow,
WorkflowEngineError,
} from "./workflow-engine.js";
import { WorkflowConfigError } from "./workflow-config.js";
import { WorkflowLockError } from "./workflow-state.js";
async function main() {
let args: WorkflowArgs;
try {
args = parseWorkflowArgs(process.argv.slice(2));
} catch (err) {
if (err instanceof Error && err.name === "WorkflowArgsError") {
process.stderr.write(`Error: ${err.message}\n\n`);
workflowUsage(1);
}
throw err;
}
try {
switch (args.sub) {
case "start":
await startWorkflow({
planInput: args.planInput,
executor: args.executor,
maxCycles: args.maxCycles,
});
break;
case "review":
await reviewWorkflow({ runId: args.runId });
break;
case "retry-review":
retryReviewWorkflow({ runId: args.runId });
break;
case "retry-execute":
await retryExecuteWorkflow({ runId: args.runId });
break;
case "decide":
await decideWorkflow({
runId: args.runId,
decisionInput: args.decisionInput,
});
break;
case "status":
statusWorkflow({ runId: args.runId, json: args.json });
break;
case "abort":
abortWorkflow({ runId: args.runId, reason: args.reason });
break;
default: {
const _exhaustive: never = args;
throw new Error(`Unknown subcommand: ${(_exhaustive as any).sub}`);
}
}
} catch (err) {
if (err instanceof WorkflowEngineError) {
process.stderr.write(`Error: ${err.message}\n`);
process.exit(err.exitCode);
}
if (err instanceof WorkflowConfigError) {
process.stderr.write(`Configuration error: ${err.message}\n`);
process.exit(4);
}
if (err instanceof WorkflowLockError) {
process.stderr.write(`Lock error: ${err.message}\n`);
process.exit(4);
}
throw err;
}
}
main().catch((err) => {
process.stderr.write(`Unexpected error: ${err.message}\n`);
if (err.stack) {
process.stderr.write(err.stack + "\n");
}
process.exit(1);
});
+152
View File
@@ -0,0 +1,152 @@
/**
* agent-workflow — Codex-hosted autonomous implementation loop.
*
* Public API exports for programmatic use.
*/
export {
startWorkflow,
reviewWorkflow,
decideWorkflow,
statusWorkflow,
abortWorkflow,
retryReviewWorkflow,
retryExecuteWorkflow,
WorkflowEngineError,
emitWorkflowMeta,
} from "./workflow-engine.js";
export type {
StartWorkflowOpts,
ReviewWorkflowOpts,
DecideWorkflowOpts,
StatusWorkflowOpts,
AbortWorkflowOpts,
RetryReviewWorkflowOpts,
RetryExecuteWorkflowOpts,
} from "./workflow-engine.js";
export {
WorkflowConfigError,
loadProjectWorkflowConfig,
resolveWorkflowConfig,
validateWorkflowPlan,
validateHostDecision,
} from "./workflow-config.js";
export type {
WorkflowCliOverrides,
} from "./workflow-config.js";
export {
workflowsRoot,
repoHash,
runDir,
lockFilePath,
stateFilePath,
eventsFilePath,
cycleDiffFile,
cycleExecLogFile,
cycleHostReviewFile,
cycleDecisionFile,
generateRunId,
writeState,
readState,
appendEvent,
WorkflowLockError,
acquireLock,
releaseLock,
readLock,
GitError,
gitRepoRoot,
gitHead,
gitIsClean,
gitDiffFromHead,
checkFilesInScope,
nowIso,
initWorkflowState,
findRunDir,
} from "./workflow-state.js";
export {
checkConvergence,
decisionSignatures,
} from "./workflow-convergence.js";
export type {
FindingSignature,
ConvergenceCheckResult,
} from "./workflow-convergence.js";
export {
createExecutorAdapter,
probeExecutor,
} from "./workflow-executor.js";
export type {
ExecutorAdapter,
ExecutorResult,
ExecutorStartOpts,
ExecutorResumeOpts,
} from "./workflow-types.js";
export {
parseWorkflowArgs,
workflowUsage,
} from "./workflow-args.js";
export type {
WorkflowSubcommand,
WorkflowArgs,
WorkflowStartArgs,
WorkflowReviewArgs,
WorkflowRetryReviewArgs,
WorkflowRetryExecuteArgs,
WorkflowDecideArgs,
WorkflowStatusArgs,
WorkflowAbortArgs,
} from "./workflow-args.js";
export type {
WorkflowPlanV1,
WorkflowState,
WorkflowStatus,
WorkflowActiveStatus,
WorkflowTerminalStatus,
WorkflowStatusOutput,
HostDecisionV1,
HostDecisionOutcome,
FindingDecision,
HostReviewBundleV1,
WorkflowProjectConfig,
ResolvedWorkflowConfig,
ExecutorConfig,
ExecutorKind,
ExecutorSessionHandle,
ReasonixSessionHandle,
ClaudeSessionHandle,
AgySessionHandle,
CycleRecord,
ScopeViolation,
WorkflowEvent,
WorkflowEventKind,
} from "./workflow-types.js";
export {
EXECUTOR_KINDS,
WORKFLOW_STATUSES,
WORKFLOW_ACTIVE_STATUSES,
WORKFLOW_TERMINAL_STATUSES,
HOST_DECISION_OUTCOMES,
} from "./workflow-types.js";
export {
spawnBufferedChild,
spawnStreamingChild,
DEFAULT_STREAM_CAPTURE_LIMIT,
DEFAULT_ABORT_KILL_GRACE_MS,
} from "./child-process.js";
export type {
ChildRunResult,
} from "./child-process.js";
+258
View File
@@ -0,0 +1,258 @@
/**
* Argument parsing for agent-workflow subcommands.
*/
export type WorkflowSubcommand = "start" | "review" | "retry-review" | "retry-execute" | "decide" | "status" | "abort";
export interface WorkflowStartArgs {
sub: "start";
executor?: string;
planInput: string; // file path or "-"
maxCycles?: number;
}
export interface WorkflowReviewArgs {
sub: "review";
runId: string;
}
export interface WorkflowRetryReviewArgs {
sub: "retry-review";
runId: string;
}
export interface WorkflowRetryExecuteArgs {
sub: "retry-execute";
runId: string;
}
export interface WorkflowDecideArgs {
sub: "decide";
runId: string;
decisionInput: string; // file path or "-"
}
export interface WorkflowStatusArgs {
sub: "status";
runId: string;
json: boolean;
}
export interface WorkflowAbortArgs {
sub: "abort";
runId: string;
reason: string;
}
export type WorkflowArgs =
| WorkflowStartArgs
| WorkflowReviewArgs
| WorkflowRetryReviewArgs
| WorkflowRetryExecuteArgs
| WorkflowDecideArgs
| WorkflowStatusArgs
| WorkflowAbortArgs;
export class WorkflowArgsError extends Error {
constructor(message: string) {
super(message);
this.name = "WorkflowArgsError";
}
}
function requireValue(flag: string, argv: string[]): string {
if (argv.length === 0 || argv[0].startsWith("--")) {
throw new WorkflowArgsError(`${flag} requires a value`);
}
return argv.shift()!;
}
function parsePositiveInt(flag: string, value: string): number {
const n = Number(value);
if (!Number.isInteger(n) || n < 1) {
throw new WorkflowArgsError(`${flag} must be a positive integer`);
}
return n;
}
const WORKFLOW_USAGE = `
Usage:
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--max-cycles <n>]
agent-workflow review --run <run-id>
agent-workflow retry-review --run <run-id>
agent-workflow retry-execute --run <run-id>
agent-workflow decide --run <run-id> --input <decision.json|->
agent-workflow status --run <run-id> [--json]
agent-workflow abort --run <run-id> --reason <text>
Outputs AGENT_WORKFLOW_META_JSON: to stderr (set AGENT_WORKFLOW_META_STDOUT=1 for stdout).
Examples:
agent-workflow start --plan plan.json --executor reasonix
agent-workflow start --plan - --executor claude --max-cycles 3 # read plan from stdin
agent-workflow review --run 2026-01-01T00-00-00_01_abc123
agent-workflow retry-review --run 2026-01-01T00-00-00_01_abc123
agent-workflow retry-execute --run 2026-01-01T00-00-00_01_abc123
agent-workflow decide --run <id> --input decision.json
agent-workflow decide --run <id> --input - # read decision from stdin
agent-workflow status --run <id>
agent-workflow status --run <id> --json
agent-workflow abort --run <id> --reason "changed requirements"
`.trimStart();
export function workflowUsage(exitCode = 0): never {
(exitCode === 0 ? process.stdout : process.stderr).write(WORKFLOW_USAGE);
process.exit(exitCode);
}
export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
workflowUsage(0);
}
const sub = argv.shift()!;
const rest = [...argv];
switch (sub) {
case "start": {
let executor: string | undefined;
let planInput: string | undefined;
let maxCycles: number | undefined;
while (rest.length > 0) {
const arg = rest.shift()!;
switch (arg) {
case "--executor":
executor = requireValue(arg, rest);
break;
case "--plan":
planInput = requireValue(arg, rest);
break;
case "--max-cycles":
maxCycles = parsePositiveInt(arg, requireValue(arg, rest));
break;
case "-h":
case "--help":
workflowUsage(0);
break;
default:
throw new WorkflowArgsError(`Unknown option: ${arg}`);
}
}
if (!planInput) throw new WorkflowArgsError("--plan is required for workflow start");
return { sub: "start", executor, planInput, maxCycles };
}
case "review": {
let runId: string | undefined;
while (rest.length > 0) {
const arg = rest.shift()!;
if (arg === "--run") {
runId = requireValue(arg, rest);
} else if (arg === "-h" || arg === "--help") {
workflowUsage(0);
} else {
throw new WorkflowArgsError(`Unknown option: ${arg}`);
}
}
if (!runId) throw new WorkflowArgsError("--run is required for workflow review");
return { sub: "review", runId };
}
case "retry-review": {
let runId: string | undefined;
while (rest.length > 0) {
const arg = rest.shift()!;
if (arg === "--run") {
runId = requireValue(arg, rest);
} else if (arg === "-h" || arg === "--help") {
workflowUsage(0);
} else {
throw new WorkflowArgsError(`Unknown option: ${arg}`);
}
}
if (!runId) throw new WorkflowArgsError("--run is required for workflow retry-review");
return { sub: "retry-review", runId };
}
case "retry-execute": {
let runId: string | undefined;
while (rest.length > 0) {
const arg = rest.shift()!;
if (arg === "--run") {
runId = requireValue(arg, rest);
} else if (arg === "-h" || arg === "--help") {
workflowUsage(0);
} else {
throw new WorkflowArgsError(`Unknown option: ${arg}`);
}
}
if (!runId) throw new WorkflowArgsError("--run is required for workflow retry-execute");
return { sub: "retry-execute", runId };
}
case "decide": {
let runId: string | undefined;
let decisionInput: string | undefined;
while (rest.length > 0) {
const arg = rest.shift()!;
if (arg === "--run") {
runId = requireValue(arg, rest);
} else if (arg === "--input") {
decisionInput = requireValue(arg, rest);
} else if (arg === "-h" || arg === "--help") {
workflowUsage(0);
} else {
throw new WorkflowArgsError(`Unknown option: ${arg}`);
}
}
if (!runId) throw new WorkflowArgsError("--run is required for workflow decide");
if (!decisionInput) throw new WorkflowArgsError("--input is required for workflow decide");
return { sub: "decide", runId, decisionInput };
}
case "status": {
let runId: string | undefined;
let json = false;
while (rest.length > 0) {
const arg = rest.shift()!;
if (arg === "--run") {
runId = requireValue(arg, rest);
} else if (arg === "--json") {
json = true;
} else if (arg === "-h" || arg === "--help") {
workflowUsage(0);
} else {
throw new WorkflowArgsError(`Unknown option: ${arg}`);
}
}
if (!runId) throw new WorkflowArgsError("--run is required for workflow status");
return { sub: "status", runId, json };
}
case "abort": {
let runId: string | undefined;
let reason: string | undefined;
while (rest.length > 0) {
const arg = rest.shift()!;
if (arg === "--run") {
runId = requireValue(arg, rest);
} else if (arg === "--reason") {
reason = requireValue(arg, rest);
} else if (arg === "-h" || arg === "--help") {
workflowUsage(0);
} else {
throw new WorkflowArgsError(`Unknown option: ${arg}`);
}
}
if (!runId) throw new WorkflowArgsError("--run is required for workflow abort");
if (!reason) throw new WorkflowArgsError("--reason is required for workflow abort");
return { sub: "abort", runId, reason };
}
default:
throw new WorkflowArgsError(`Unknown workflow subcommand: ${sub}\n\n${WORKFLOW_USAGE}`);
}
}
+172
View File
@@ -0,0 +1,172 @@
/**
* Workflow configuration resolution.
*
* Priority: CLI flags > agent-workflow.json (project root) > built-in defaults.
* Keys are never secrets; no API keys stored in config.
*/
import fs from "node:fs";
import path from "node:path";
import type {
ExecutorConfig,
ExecutorKind,
ResolvedWorkflowConfig,
WorkflowProjectConfig,
} from "./workflow-types.js";
import { EXECUTOR_KINDS } from "./workflow-types.js";
const WORKFLOW_CONFIG_FILE = "agent-workflow.json";
const DEFAULTS = {
executor: "reasonix" as ExecutorKind,
maxCycles: 5,
timeoutSeconds: 1800,
};
export class WorkflowConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "WorkflowConfigError";
}
}
/** Load and parse agent-workflow.json from repoRoot. */
export function loadProjectWorkflowConfig(repoRoot: string): WorkflowProjectConfig | undefined {
const filePath = path.join(repoRoot, WORKFLOW_CONFIG_FILE);
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 ${WORKFLOW_CONFIG_FILE}: ${(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`);
}
const obj = raw as Record<string, unknown>;
if (obj.version !== "1") {
throw new WorkflowConfigError(`${filePath}: version must be "1"`);
}
if (obj.defaultExecutor !== undefined && !EXECUTOR_KINDS.includes(obj.defaultExecutor as ExecutorKind)) {
throw new WorkflowConfigError(`${filePath}: defaultExecutor must be one of ${EXECUTOR_KINDS.join(", ")}`);
}
if (obj.maxCycles !== undefined && (typeof obj.maxCycles !== "number" || !Number.isInteger(obj.maxCycles) || obj.maxCycles < 1)) {
throw new WorkflowConfigError(`${filePath}: maxCycles must be a positive integer`);
}
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`);
}
// Prohibit secrets in config
const forbidden = ["apikey", "api_key", "token", "secret", "password", "credential"];
const lower = JSON.stringify(raw).toLowerCase();
for (const key of forbidden) {
if (lower.includes(`"${key}"`)) {
throw new WorkflowConfigError(`${filePath}: config must not contain secrets (found "${key}")`);
}
}
return raw as WorkflowProjectConfig;
}
export interface WorkflowCliOverrides {
executor?: ExecutorKind;
maxCycles?: number;
}
/** Merge CLI > project config > defaults into a single resolved config. */
export function resolveWorkflowConfig(
repoRoot: string,
cli: WorkflowCliOverrides = {},
): ResolvedWorkflowConfig {
const project = loadProjectWorkflowConfig(repoRoot);
const executor: ExecutorKind = cli.executor ?? (project?.defaultExecutor) ?? DEFAULTS.executor;
if (!EXECUTOR_KINDS.includes(executor)) {
throw new WorkflowConfigError(`Unknown executor: ${executor}. Must be one of: ${EXECUTOR_KINDS.join(", ")}`);
}
const maxCycles = cli.maxCycles ?? project?.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 executorConfig: ExecutorConfig = {
...((project?.executors as Record<string, ExecutorConfig> | undefined)?.[executor] ?? {}),
};
return {
executor,
maxCycles,
timeoutSeconds,
executorConfig,
};
}
/** Validate a WorkflowPlanV1 object and throw if invalid. */
export function validateWorkflowPlan(raw: unknown): asserts raw is import("./workflow-types.js").WorkflowPlanV1 {
if (!raw || typeof raw !== "object") throw new WorkflowConfigError("Plan must be a JSON object");
const obj = raw as Record<string, unknown>;
if (obj.version !== "1") throw new WorkflowConfigError('Plan version must be "1"');
if (typeof obj.title !== "string" || !obj.title.trim()) throw new WorkflowConfigError("Plan must have a non-empty title");
if (typeof obj.planMarkdown !== "string" || !obj.planMarkdown.trim()) throw new WorkflowConfigError("Plan must have a non-empty planMarkdown");
if (!Array.isArray(obj.scope) || obj.scope.length === 0) throw new WorkflowConfigError("Plan scope must be a non-empty array");
for (const s of obj.scope) {
if (typeof s !== "string") throw new WorkflowConfigError("Plan scope entries must be strings");
}
if (!Array.isArray(obj.acceptanceCriteria) || obj.acceptanceCriteria.length === 0) {
throw new WorkflowConfigError("Plan acceptanceCriteria must be a non-empty array");
}
if (!Array.isArray(obj.verificationCommands) || obj.verificationCommands.length === 0) {
throw new WorkflowConfigError("Plan verificationCommands must be a non-empty array");
}
for (const cmd of obj.verificationCommands as unknown[]) {
if (!Array.isArray(cmd) || cmd.some((t) => typeof t !== "string")) {
throw new WorkflowConfigError("Plan verificationCommands entries must be string arrays");
}
if (cmd.length === 0) {
throw new WorkflowConfigError("Plan verificationCommands entries must not be empty arrays");
}
if (typeof cmd[0] !== "string" || !cmd[0].trim()) {
throw new WorkflowConfigError("Plan verificationCommands must specify a non-empty executable command");
}
}
}
/** Validate a HostDecisionV1 and throw if invalid. */
export function validateHostDecision(raw: unknown): asserts raw is import("./workflow-types.js").HostDecisionV1 {
if (!raw || typeof raw !== "object") throw new WorkflowConfigError("Decision must be a JSON object");
const obj = raw as Record<string, unknown>;
if (obj.version !== "1") throw new WorkflowConfigError('Decision version must be "1"');
const validOutcomes = ["fix", "accept", "needs_human"] as const;
if (!validOutcomes.includes(obj.outcome as never)) {
throw new WorkflowConfigError(`Decision outcome must be one of: ${validOutcomes.join(", ")}`);
}
if (!Array.isArray(obj.findingDecisions)) {
throw new WorkflowConfigError("Decision findingDecisions must be an array");
}
for (const finding of obj.findingDecisions) {
if (!finding || typeof finding !== "object") {
throw new WorkflowConfigError("Decision findingDecisions entries must be objects");
}
const item = finding as Record<string, unknown>;
if (typeof item.findingId !== "string" || !item.findingId.trim()) {
throw new WorkflowConfigError("Decision findingDecisions entries require a non-empty findingId");
}
if (!["accept", "reject", "followup"].includes(String(item.disposition))) {
throw new WorkflowConfigError("Decision finding disposition must be accept, reject, or followup");
}
for (const key of ["summary", "path", "rationale"] as const) {
if (item[key] !== undefined && typeof item[key] !== "string") {
throw new WorkflowConfigError(`Decision finding ${key} must be a string`);
}
}
}
if (typeof obj.decidedAt !== "string") {
throw new WorkflowConfigError("Decision decidedAt must be a string");
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Convergence detector for the workflow loop.
*
* Detects two stop conditions:
* 1. The same finding (same path + normalized summary) persists for 2+ consecutive cycles.
* 2. Findings oscillate between cycles (a finding disappears then reappears).
*
* Both conditions produce needs_human.
*/
import type { FindingDecision } from "./workflow-types.js";
export interface FindingSignature {
path?: string;
normalizedSummary: string;
}
/** Normalize a finding summary for stable comparison. */
function normalizeSummary(summary: string): string {
return summary
.toLowerCase()
.replace(/[^\w\s]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
/** Build convergence signatures from the issues Codex accepted for fixing. */
export function decisionSignatures(findings: FindingDecision[]): FindingSignature[] {
return findings
.filter((finding) => finding.disposition === "accept")
.map((finding) => ({
...(finding.path ? { path: finding.path } : {}),
normalizedSummary: normalizeSummary(finding.summary || finding.rationale || finding.findingId),
}));
}
function sigKey(sig: FindingSignature): string {
return `${sig.path ?? ""}::${sig.normalizedSummary}`;
}
export interface ConvergenceCheckResult {
converging: boolean;
/** Set when the same finding persists for 2 consecutive cycles. */
persistentFindings?: string[];
/** Set when findings oscillate (disappeared then reappeared). */
oscillatingFindings?: string[];
}
/**
* Check the last few rounds' signatures for convergence.
*
* @param history Array of per-cycle finding signatures, oldest first.
*/
export function checkConvergence(history: FindingSignature[][]): ConvergenceCheckResult {
if (history.length < 2) return { converging: true };
const last = history[history.length - 1]!;
const prev = history[history.length - 2]!;
const lastKeys = new Set(last.map(sigKey));
const prevKeys = new Set(prev.map(sigKey));
// Persistent: same key in both consecutive cycles
const persistent = [...lastKeys].filter((k) => prevKeys.has(k));
if (persistent.length > 0) {
return { converging: false, persistentFindings: persistent };
}
// Oscillating: check 3+ cycles (a key was present, absent, then present again)
if (history.length >= 3) {
const earlier = history[history.length - 3]!;
const earlierKeys = new Set(earlier.map(sigKey));
const oscillating = [...lastKeys].filter((k) => earlierKeys.has(k) && !prevKeys.has(k));
if (oscillating.length > 0) {
return { converging: false, oscillatingFindings: oscillating };
}
}
return { converging: true };
}
File diff suppressed because it is too large Load Diff
+471
View File
@@ -0,0 +1,471 @@
/**
* Unit tests for executor adapters (Sprint 2).
*
* Uses fake CLI binaries to test argument escaping, timeout/cancel behavior,
* session handle detection, and degraded-resume logic.
*
* These tests mock out the actual executors and verify the adapter logic
* without running real AI agents.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createExecutorAdapter } from "./workflow-executor.js";
import type { WorkflowPlanV1 } from "./workflow-types.js";
function makeTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-executor-test-"));
}
function cleanupDir(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function makeValidPlan(): WorkflowPlanV1 {
return {
version: "1",
title: "Test plan",
planMarkdown: "Do something.",
scope: ["src/"],
acceptanceCriteria: ["Tests pass"],
verificationCommands: [["npm", "test"]],
};
}
// ---------------------------------------------------------------------------
// Fake CLI builder
// ---------------------------------------------------------------------------
/**
* Creates a fake CLI script that:
* 1. Logs its arguments to a file
* 2. Exits with the given code
* 3. Optionally writes JSON with a session_id/conversation_id to stdout
*/
function writeFakeCli(dir: string, opts: {
exitCode?: number;
stdout?: string;
argsFile?: string;
}): string {
const scriptPath = path.join(dir, "fake-cli.sh");
const argsFile = opts.argsFile ?? path.join(dir, "args.txt");
const exitCode = opts.exitCode ?? 0;
const stdout = opts.stdout ?? "";
const script = `#!/bin/bash
echo "$@" >> "${argsFile}"
echo '${stdout.replace(/'/g, "'\\''")}'
exit ${exitCode}
`;
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
return scriptPath;
}
// ---------------------------------------------------------------------------
// Reasonix adapter tests
// ---------------------------------------------------------------------------
describe("ReasonixAdapter", () => {
let tmpDir: string;
beforeEach(() => { tmpDir = makeTmpDir(); });
afterEach(() => { cleanupDir(tmpDir); });
it("probe returns false when binary not found", async () => {
const adapter = createExecutorAdapter("reasonix");
// Override with a non-existent binary
const result = await adapter.probe();
// We expect false since 'reasonix' is not installed in CI
expect(typeof result).toBe("boolean");
});
it("start builds args with --dir and no --file flag", async () => {
const argsFile = path.join(tmpDir, "args.txt");
const fakeBin = writeFakeCli(tmpDir, { argsFile });
const adapter = createExecutorAdapter("reasonix");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
// Should have been called with 'run --dir ... <promptFile>'
expect(capturedArgs).toContain("run");
expect(capturedArgs).toContain("--dir");
expect(capturedArgs).not.toContain("--file");
expect(result.exitCode).toBe(0);
expect(result.failed).toBe(false);
});
it("resume builds args with --resume", 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");
const result = 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 },
});
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
expect(capturedArgs).toContain("--resume");
expect(capturedArgs).toContain(sessionFile);
expect(result.exitCode).toBe(0);
expect(result.sessionHandle?.kind).toBe("reasonix");
});
it("reports failure when binary exits with non-zero", async () => {
const fakeBin = writeFakeCli(tmpDir, { exitCode: 1 });
const adapter = createExecutorAdapter("reasonix");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Claude adapter tests
// ---------------------------------------------------------------------------
describe("ClaudeAdapter", () => {
let tmpDir: string;
beforeEach(() => { tmpDir = makeTmpDir(); });
afterEach(() => { cleanupDir(tmpDir); });
it("start includes --session-id and -p flags", async () => {
const argsFile = path.join(tmpDir, "args.txt");
const fakeBin = writeFakeCli(tmpDir, {
argsFile,
stdout: '{"session_id": "test-session-uuid"}',
});
const adapter = createExecutorAdapter("claude");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
expect(capturedArgs).toContain("-p");
expect(capturedArgs).toContain("--output-format");
expect(capturedArgs).toContain("--session-id");
expect(result.sessionHandle?.kind).toBe("claude");
});
it("resume uses --resume with captured session ID", async () => {
const argsFile = path.join(tmpDir, "args.txt");
const fakeBin = writeFakeCli(tmpDir, { argsFile });
const adapter = createExecutorAdapter("claude");
const result = await adapter.resume({
repoRoot: tmpDir,
plan: makeValidPlan(),
acceptedFindings: "Fix auth bypass",
handle: { kind: "claude", sessionId: "captured-session-id" },
runDir: tmpDir,
cycleIndex: 2,
config: { binary: fakeBin },
});
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
expect(capturedArgs).toContain("--resume");
expect(capturedArgs).toContain("captured-session-id");
expect(result.sessionHandle?.kind).toBe("claude");
});
it("extracts session_id from JSON output", async () => {
const fakeBin = writeFakeCli(tmpDir, {
stdout: '{"session_id": "extracted-id-123", "result": "done"}',
});
const adapter = createExecutorAdapter("claude");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
if (result.sessionHandle?.kind === "claude") {
expect(result.sessionHandle.sessionId).toBe("extracted-id-123");
}
});
});
// ---------------------------------------------------------------------------
// Agy adapter tests
// ---------------------------------------------------------------------------
describe("AgyAdapter", () => {
let tmpDir: string;
beforeEach(() => { tmpDir = makeTmpDir(); });
afterEach(() => { cleanupDir(tmpDir); });
it("start uses --print and --mode accept-edits", async () => {
const argsFile = path.join(tmpDir, "args.txt");
const fakeBin = writeFakeCli(tmpDir, {
argsFile,
stdout: '{"conversation_id": "agy-conv-id-456"}',
});
const adapter = createExecutorAdapter("agy");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
expect(capturedArgs).toContain("--print");
expect(capturedArgs).toContain("--mode accept-edits");
if (result.sessionHandle?.kind === "agy") {
expect(result.sessionHandle.conversationId).toBe("agy-conv-id-456");
expect(result.sessionHandle.degraded).toBe(false);
}
});
it("falls back to degraded mode when no conversation_id", async () => {
const fakeBin = writeFakeCli(tmpDir, { stdout: "Agy output without JSON" });
const adapter = createExecutorAdapter("agy");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
if (result.sessionHandle?.kind === "agy") {
expect(result.sessionHandle.conversationId).toBeUndefined();
expect(result.sessionHandle.degraded).toBe(true);
}
});
it("resume with conversation_id uses --conversation flag", async () => {
const argsFile = path.join(tmpDir, "args.txt");
const fakeBin = writeFakeCli(tmpDir, { argsFile });
const adapter = createExecutorAdapter("agy");
await adapter.resume({
repoRoot: tmpDir,
plan: makeValidPlan(),
acceptedFindings: "Fix the bug",
handle: { kind: "agy", conversationId: "known-conv-id", degraded: false },
runDir: tmpDir,
cycleIndex: 2,
config: { binary: fakeBin },
});
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
expect(capturedArgs).toContain("--conversation");
expect(capturedArgs).toContain("known-conv-id");
});
it("degraded resume uses --continue flag", async () => {
const argsFile = path.join(tmpDir, "args.txt");
const fakeBin = writeFakeCli(tmpDir, { argsFile });
const adapter = createExecutorAdapter("agy");
await adapter.resume({
repoRoot: tmpDir,
plan: makeValidPlan(),
acceptedFindings: "Fix the bug",
handle: { kind: "agy", degraded: true },
runDir: tmpDir,
cycleIndex: 2,
config: { binary: fakeBin },
});
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
expect(capturedArgs).toContain("--continue");
});
});
// ---------------------------------------------------------------------------
// Safety constraints in prompts
// ---------------------------------------------------------------------------
describe("Safety constraints in executor prompts", () => {
let tmpDir: string;
beforeEach(() => { tmpDir = makeTmpDir(); });
afterEach(() => { cleanupDir(tmpDir); });
it("start prompt file contains no-commit constraint", async () => {
const fakeBin = writeFakeCli(tmpDir, {});
const adapter = createExecutorAdapter("reasonix");
await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
// Find the generated prompt file
const promptFile = path.join(tmpDir, "cycle-1-prompt.txt");
if (fs.existsSync(promptFile)) {
const content = fs.readFileSync(promptFile, "utf8");
expect(content).toContain("DO NOT run git commit");
expect(content).toContain("mktemp");
expect(content).toContain("-wal, -shm, and -journal");
expect(content).toContain("git status --porcelain --untracked-files=all");
expect(content).toContain("DO NOT");
}
});
it("resume prompt contains only accepted findings, not all findings", async () => {
const fakeBin = writeFakeCli(tmpDir, {});
const sessionFile = path.join(tmpDir, "session.jsonl");
fs.writeFileSync(sessionFile, "{}");
const adapter = createExecutorAdapter("reasonix");
await adapter.resume({
repoRoot: tmpDir,
plan: makeValidPlan(),
acceptedFindings: "Finding C1: SQL injection in src/db.ts",
handle: { kind: "reasonix", sessionFilePath: sessionFile },
runDir: tmpDir,
cycleIndex: 2,
config: { binary: fakeBin },
});
const promptFile = path.join(tmpDir, "cycle-2-prompt.txt");
if (fs.existsSync(promptFile)) {
const content = fs.readFileSync(promptFile, "utf8");
expect(content).toContain("SQL injection");
expect(content).toContain("DO NOT run git commit");
}
});
it("scope remediation prompt authorizes only listed cleanup paths", async () => {
const fakeBin = writeFakeCli(tmpDir, {});
const sessionFile = path.join(tmpDir, "session.jsonl");
fs.writeFileSync(sessionFile, "{}");
const adapter = createExecutorAdapter("reasonix");
await adapter.resume({
repoRoot: tmpDir,
plan: makeValidPlan(),
acceptedFindings: [
"# Scope Remediation",
"",
"- scope-1: tmp_oc.db-shm",
"- scope-2: tmp_oc.db-wal",
].join("\n"),
handle: { kind: "reasonix", sessionFilePath: sessionFile },
runDir: tmpDir,
cycleIndex: 2,
config: { binary: fakeBin },
});
const content = fs.readFileSync(path.join(tmpDir, "cycle-2-prompt.txt"), "utf8");
expect(content).toContain("# Scope Fix Request");
expect(content).toContain("scope-1: tmp_oc.db-shm");
expect(content).toContain("only the exact out-of-scope paths it lists");
expect(content).toContain("Do not modify any other out-of-scope path");
});
it("Claude adapter passes --safe-mode and --permission-mode bypassPermissions", async () => {
const fakeBin = path.join(tmpDir, "claude");
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"session_id":"test-session"}'
echo "$@" > ${path.join(tmpDir, "args.txt")}
`, { mode: 0o755 });
const adapter = createExecutorAdapter("claude");
await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
const args = fs.readFileSync(path.join(tmpDir, "args.txt"), "utf8");
expect(args).toContain("--safe-mode");
expect(args).toContain("--permission-mode");
expect(args).toContain("bypassPermissions");
});
it("Claude adapter extracts errors from JSON result", async () => {
const fakeBin = path.join(tmpDir, "claude");
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"session_id":"test-session","errors":["Permission denied","File not found"]}'
exit 1
`, { mode: 0o755 });
const adapter = createExecutorAdapter("claude");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.failed).toBe(true);
expect(result.failureReason).toContain("Permission denied");
expect(result.failureReason).toContain("File not found");
});
it("Claude adapter extracts is_error result messages", async () => {
const fakeBin = path.join(tmpDir, "claude");
fs.writeFileSync(fakeBin, `#!/bin/bash
echo '{"session_id":"test-session","is_error":true,"result":"Session already active"}'
exit 1
`, { mode: 0o755 });
const adapter = createExecutorAdapter("claude");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.failed).toBe(true);
expect(result.failureReason).toBe("Session already active");
});
it("probe failure directs users to agent-workflow.json", async () => {
const { probeExecutor } = await import("./workflow-executor.js");
// Use a valid executor kind but with a bad binary path
await expect(probeExecutor("reasonix", { binary: "/nonexistent/path/reasonix" }))
.rejects.toThrow(/agent-workflow\.json/);
});
});
+650
View File
@@ -0,0 +1,650 @@
/**
* Executor adapters for Reasonix, Claude Code, and Agy.
*
* All adapters:
* - Spawn processes with argument arrays (no shell interpolation)
* - Include fixed safety constraints in every prompt (no commit/push/reset)
* - Return ExecutorResult with session handle for future resume
*/
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawnStreamingChild } from "./child-process.js";
import type {
AgySessionHandle,
ClaudeSessionHandle,
ExecutorAdapter,
ExecutorConfig,
ExecutorKind,
ExecutorResumeOpts,
ExecutorResult,
ExecutorSessionHandle,
ExecutorStartOpts,
ReasonixSessionHandle,
} from "./workflow-types.js";
import { nowIso } from "./workflow-state.js";
// ---------------------------------------------------------------------------
// Safety prompt footer (appended to every executor instruction)
// ---------------------------------------------------------------------------
const SAFETY_CONSTRAINTS = `
## CRITICAL CONSTRAINTS — DO NOT VIOLATE
- DO NOT run git commit, git push, or git reset under any circumstances.
- DO NOT rewrite git history or delete branches.
- DO NOT create or modify files outside the scope listed above.
- Exception: a Scope Remediation prompt may authorize removing or restoring only the exact out-of-scope paths it lists.
- Put all temporary files in an OS temporary directory outside the repository (for example, one created with mktemp).
- If you accidentally create a temporary repository file, remove only that artifact before exiting.
- SQLite cleanup must include the database and its -wal, -shm, and -journal sidecars.
- Before exiting, inspect git status --porcelain --untracked-files=all and leave no artifacts outside the allowed scope.
- You may read, edit, and test code, but never commit or push changes.
- If you encounter an error you cannot resolve, stop and report it clearly.
`.trimStart();
// ---------------------------------------------------------------------------
// Prompt builders
// ---------------------------------------------------------------------------
function buildStartPrompt(opts: ExecutorStartOpts): string {
const { plan } = opts;
const scopeList = plan.scope.map((s) => ` - ${s}`).join("\n");
const criteriaList = plan.acceptanceCriteria.map((c, i) => ` ${i + 1}. ${c}`).join("\n");
const cmdsText = plan.verificationCommands
.map((cmd) => ` ${cmd.join(" ")}`)
.join("\n");
return `# Implementation Task: ${plan.title}
## Plan
${plan.planMarkdown}
## Allowed Scope
Only modify files within these paths (relative to repository root):
${scopeList}
## Acceptance Criteria
${criteriaList}
## Verification Commands
After implementation, ensure these commands pass:
${cmdsText}
${SAFETY_CONSTRAINTS}`;
}
function buildFixPrompt(opts: ExecutorResumeOpts): string {
const { plan, acceptedFindings } = opts;
if (acceptedFindings.startsWith("# Scope Remediation")) {
return `# Scope Fix Request: ${plan.title}
${acceptedFindings}
Do not modify any other out-of-scope path. If a listed path is not clearly an executor-created artifact, stop and report it instead of deleting it.
## Frozen Scope
The implementation itself must remain within:
${plan.scope.map((s) => ` - ${s}`).join("\n")}
## Verification
After scope cleanup, run:
${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")}
${SAFETY_CONSTRAINTS}`;
}
return `# Fix Request: ${plan.title}
The code review found the following issues that must be fixed:
${acceptedFindings}
## Scope Reminder
Only modify files within:
${plan.scope.map((s) => ` - ${s}`).join("\n")}
## Verification
After applying fixes, run:
${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")}
${SAFETY_CONSTRAINTS}`;
}
// ---------------------------------------------------------------------------
// Child env (inherit + extra PATH entries for executors)
// ---------------------------------------------------------------------------
function executorEnv(): NodeJS.ProcessEnv {
const homeDir = process.env.HOME || os.homedir();
const extras = [
path.join(homeDir, ".local", "bin"),
path.join(homeDir, ".bun", "bin"),
path.join(homeDir, ".n", "bin"),
"/usr/local/bin",
"/usr/bin",
"/bin",
process.env.PATH || "",
];
return {
...process.env,
PATH: [...new Set(extras.join(":").split(":").filter(Boolean))].join(":"),
};
}
function resolveExecutable(config: ExecutorConfig, defaultBin: string): string {
return config.binary || defaultBin;
}
// ---------------------------------------------------------------------------
// Reasonix adapter
// ---------------------------------------------------------------------------
function readFirstLine(filePath: string): string | undefined {
const CHUNK_SIZE = 65536;
const fd = fs.openSync(filePath, "r");
let buffer = Buffer.alloc(CHUNK_SIZE);
let totalData = "";
let newlineIndex = -1;
let position = 0;
try {
while (true) {
const bytesRead = fs.readSync(fd, buffer, 0, CHUNK_SIZE, position);
if (bytesRead === 0) break;
totalData += buffer.toString("utf8", 0, bytesRead);
newlineIndex = totalData.indexOf("\n");
if (newlineIndex !== -1) {
return totalData.slice(0, newlineIndex);
}
position += bytesRead;
if (position > 1024 * 1024) break; // 1MB safety limit
}
return totalData;
} finally {
fs.closeSync(fd);
}
}
function getWorkspaceFromJsonl(jsonlPath: string): string | undefined {
try {
const firstLine = readFirstLine(jsonlPath);
if (!firstLine) return undefined;
const obj = JSON.parse(firstLine);
if (obj && obj.role === "system" && typeof obj.content === "string") {
let match = obj.content.match(/Current workspace:\s*"([^"\n\r]+)"/i);
if (!match) {
match = obj.content.match(/Current workspace:\s*([^"\n\r]+)/i);
}
if (match) {
return match[1].trim();
}
}
} catch {
// ignore
}
return undefined;
}
function listJsonlFiles(dir: string, repoRoot?: string): string[] {
if (!fs.existsSync(dir)) return [];
const result: string[] = [];
function recurse(current: string) {
if (!fs.existsSync(current)) return;
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
const lowerDir = entry.name.toLowerCase();
if (lowerDir !== "archive" && lowerDir !== "subagents") {
recurse(full);
}
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
const lowerName = entry.name.toLowerCase();
if (
!lowerName.includes("events") &&
!lowerName.includes("archive") &&
!lowerName.includes("conflict") &&
!lowerName.includes("subagents")
) {
// Check companion file name: <session>.jsonl.meta
const metaFile = full + ".meta";
if (fs.existsSync(metaFile)) {
if (repoRoot) {
const ws = getWorkspaceFromJsonl(full);
if (ws === repoRoot) {
result.push(full);
}
} else {
result.push(full);
}
}
}
}
}
}
recurse(dir);
return result;
}
function detectNewSessionFile(beforeSnapshot: Set<string>, searchDirs: string[], repoRoot: string): string | undefined {
const candidates: string[] = [];
for (const dir of searchDirs) {
for (const file of listJsonlFiles(dir, repoRoot)) {
if (!beforeSnapshot.has(file)) {
candidates.push(file);
}
}
}
if (candidates.length === 0) return undefined;
if (candidates.length === 1) return candidates[0];
candidates.sort((a, b) => {
try {
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
} catch {
return 0;
}
});
return candidates[0];
}
class ReasonixAdapter implements ExecutorAdapter {
readonly kind: ExecutorKind = "reasonix";
async probe(config: ExecutorConfig = {}): Promise<boolean> {
try {
const res = spawnSync(resolveExecutable(config, "reasonix"), ["--version"], {
stdio: "ignore",
env: executorEnv(),
});
return res.status === 0 && !res.error;
} catch {
return false;
}
}
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
const bin = resolveExecutable(opts.config, "reasonix");
const prompt = buildStartPrompt(opts);
const promptFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-prompt.txt`);
fs.writeFileSync(promptFile, prompt, "utf8");
// Snapshot JSONL files before run to detect the session file created by reasonix
const homeDir = process.env.HOME || os.homedir();
const projectsDir = path.join(homeDir, ".reasonix", "projects");
fs.mkdirSync(projectsDir, { recursive: true });
const snapshotDirs = [projectsDir];
const beforeSnapshot = new Set(listJsonlFiles(projectsDir, opts.repoRoot));
const args: string[] = ["run", "--dir", opts.repoRoot, prompt];
const startMs = Date.now();
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
});
const durationMs = Date.now() - startMs;
const newSession = detectNewSessionFile(beforeSnapshot, snapshotDirs, opts.repoRoot);
const handle: ReasonixSessionHandle | undefined = newSession
? { kind: "reasonix", sessionFilePath: newSession }
: undefined;
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
const failed = child.status !== 0 || !!child.error;
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed,
...(child.error ? { failureReason: child.error.message } : {}),
};
}
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
const handle = opts.handle as ReasonixSessionHandle;
const bin = resolveExecutable(opts.config, "reasonix");
const prompt = buildFixPrompt(opts);
const promptFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-prompt.txt`);
fs.writeFileSync(promptFile, prompt, "utf8");
const args: string[] = [
"run",
"--dir", opts.repoRoot,
"--resume", handle.sessionFilePath,
prompt,
];
const startMs = Date.now();
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
});
const durationMs = Date.now() - startMs;
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle, // Reasonix handle stays the same file
failed: child.status !== 0 || !!child.error,
...(child.error ? { failureReason: child.error.message } : {}),
};
}
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
// Reasonix does not expose a cancel API in v1; no-op.
}
}
// ---------------------------------------------------------------------------
// Claude Code adapter
// ---------------------------------------------------------------------------
function extractClaudeSessionId(output: string): string | undefined {
// Claude Code outputs JSON with session_id field when using --output-format json
try {
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (typeof parsed.session_id === "string") return parsed.session_id;
}
} catch {
// fall through
}
// Also try a simple regex
const m = output.match(/"session_id"\s*:\s*"([^"]+)"/);
return m?.[1];
}
function extractClaudeFailureReason(output: string): string | undefined {
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (Array.isArray(parsed.errors)) {
const errors = parsed.errors.filter((error): error is string => typeof error === "string" && error.trim().length > 0);
if (errors.length > 0) return errors.join("; ");
}
if (parsed.is_error === true && typeof parsed.result === "string" && parsed.result.trim()) {
return parsed.result.trim();
}
} catch {
// Ignore non-JSON output lines.
}
}
return undefined;
}
import { randomUUID } from "node:crypto";
class ClaudeAdapter implements ExecutorAdapter {
readonly kind: ExecutorKind = "claude";
async probe(config: ExecutorConfig = {}): Promise<boolean> {
try {
const res = spawnSync(resolveExecutable(config, "claude"), ["--version"], {
stdio: "ignore",
env: executorEnv(),
});
return res.status === 0 && !res.error;
} catch {
return false;
}
}
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
const bin = resolveExecutable(opts.config, "claude");
const sessionId = randomUUID();
const prompt = buildStartPrompt(opts);
const args: string[] = [
"-p",
"--safe-mode",
"--permission-mode", "bypassPermissions",
"--output-format", "json",
"--session-id", sessionId,
...(opts.config.model ? ["--model", opts.config.model] : []),
prompt,
];
const startMs = Date.now();
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
});
const durationMs = Date.now() - startMs;
const capturedId = extractClaudeSessionId(child.stdout) ?? sessionId;
const handle: ClaudeSessionHandle = { kind: "claude", sessionId: capturedId };
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
const failureReason = child.error?.message
?? extractClaudeFailureReason(child.stdout)
?? extractClaudeFailureReason(child.stderr);
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed: child.status !== 0 || !!child.error,
...(failureReason ? { failureReason } : {}),
};
}
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
const handle = opts.handle as ClaudeSessionHandle;
const bin = resolveExecutable(opts.config, "claude");
const prompt = buildFixPrompt(opts);
const args: string[] = [
"-p",
"--safe-mode",
"--permission-mode", "bypassPermissions",
"--output-format", "json",
"--resume", handle.sessionId,
...(opts.config.model ? ["--model", opts.config.model] : []),
prompt,
];
const startMs = Date.now();
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
});
const durationMs = Date.now() - startMs;
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
const failureReason = child.error?.message
?? extractClaudeFailureReason(child.stdout)
?? extractClaudeFailureReason(child.stderr);
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed: child.status !== 0 || !!child.error,
...(failureReason ? { failureReason } : {}),
};
}
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
// Claude Code: no explicit cancel API in v1.
}
}
// ---------------------------------------------------------------------------
// Agy adapter
// ---------------------------------------------------------------------------
function extractAgyConversationId(output: string): string | undefined {
// Try to parse conversation_id from JSON output lines
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (typeof parsed.conversation_id === "string") return parsed.conversation_id;
if (typeof parsed.conversationId === "string") return parsed.conversationId;
} catch { /* skip */ }
}
return undefined;
}
class AgyAdapter implements ExecutorAdapter {
readonly kind: ExecutorKind = "agy";
async probe(config: ExecutorConfig = {}): Promise<boolean> {
try {
const res = spawnSync(resolveExecutable(config, "agy"), ["--version"], {
stdio: "ignore",
env: executorEnv(),
});
return res.status === 0 && !res.error;
} catch {
return false;
}
}
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
const bin = resolveExecutable(opts.config, "agy");
const prompt = buildStartPrompt(opts);
const args: string[] = [
...(opts.config.agent ? ["--agent", opts.config.agent] : []),
...(opts.config.model ? ["--model", opts.config.model] : []),
"--print", "--mode", "accept-edits", "--", prompt,
];
const startMs = Date.now();
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
});
const durationMs = Date.now() - startMs;
const conversationId = extractAgyConversationId(child.stdout);
const handle: AgySessionHandle = {
kind: "agy",
...(conversationId ? { conversationId } : {}),
degraded: !conversationId,
};
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed: child.status !== 0 || !!child.error,
...(child.error ? { failureReason: child.error.message } : {}),
};
}
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
const handle = opts.handle as AgySessionHandle;
const bin = resolveExecutable(opts.config, "agy");
const prompt = buildFixPrompt(opts);
let args: string[];
if (handle.conversationId) {
args = [
"--conversation", handle.conversationId,
...(opts.config.agent ? ["--agent", opts.config.agent] : []),
...(opts.config.model ? ["--model", opts.config.model] : []),
"--print", "--mode", "accept-edits", "--", prompt,
];
} else {
// Degraded mode: use --continue (best effort, may pick wrong session in concurrent setups)
args = [
"--continue",
...(opts.config.agent ? ["--agent", opts.config.agent] : []),
...(opts.config.model ? ["--model", opts.config.model] : []),
"--print", "--mode", "accept-edits", "--", prompt,
];
}
const startMs = Date.now();
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
});
const durationMs = Date.now() - startMs;
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed: child.status !== 0 || !!child.error,
...(child.error ? { failureReason: child.error.message } : {}),
};
}
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
// Agy: no explicit cancel API in v1.
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createExecutorAdapter(kind: ExecutorKind): ExecutorAdapter {
switch (kind) {
case "reasonix":
return new ReasonixAdapter();
case "claude":
return new ClaudeAdapter();
case "agy":
return new AgyAdapter();
default: {
const _exhaustive: never = kind;
throw new Error(`Unknown executor kind: ${_exhaustive}`);
}
}
}
/** Check that the executor binary is available and throw a descriptive error if not. */
export async function probeExecutor(kind: ExecutorKind, config: ExecutorConfig = {}): Promise<void> {
const adapter = createExecutorAdapter(kind);
const available = await adapter.probe(config);
if (!available) {
const bin = config.binary || kind;
throw new Error(
`Executor '${kind}' is not available. ` +
`Make sure '${bin}' is installed and on PATH. ` +
`You can override the binary path with the 'binary' field in agent-workflow.json.`,
);
}
}
+273
View File
@@ -0,0 +1,273 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { startWorkflow, reviewWorkflow, decideWorkflow, retryExecuteWorkflow } from "./workflow-engine.js";
import { readState, runDir } from "./workflow-state.js";
import type { HostDecisionV1 } from "./workflow-types.js";
// Helper to create a fake CLI script
function writeFakeCli(dir: string, name: string, scriptBody: string): string {
const p = path.join(dir, name);
fs.writeFileSync(p, `#!/bin/bash\n${scriptBody}`, { mode: 0o755 });
return p;
}
describe("Workflow Integration (start -> review -> fix -> review -> accept)", () => {
let tmpDir: string;
let baseTmp: string;
let homeDir: string;
let workflowDir: string;
let originalHome: string | undefined;
let originalExit: typeof process.exit;
let originalWorkflowDir: string | undefined;
beforeEach(() => {
originalExit = process.exit;
(process as any).exit = (code?: number) => {
if (code === 0) throw new Error("MockExit0");
originalExit(code);
};
delete process.env.GIT_DIR;
delete process.env.GIT_WORK_TREE;
baseTmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-integration-")));
tmpDir = path.join(baseTmp, "John's 项目 空格+中文");
fs.mkdirSync(tmpDir);
homeDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-home-")));
workflowDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-workflows-")));
originalHome = process.env.HOME;
originalWorkflowDir = process.env.AGENT_WORKFLOW_DIR;
process.env.HOME = homeDir;
process.env.AGENT_WORKFLOW_DIR = workflowDir;
// Init real git repo for baselineHead
execFileSync("git", ["init"], { cwd: tmpDir });
execFileSync("git", ["config", "user.name", "Test"], { cwd: tmpDir });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tmpDir });
fs.writeFileSync(path.join(tmpDir, "index.ts"), "const x = 1;\n");
execFileSync("git", ["add", "index.ts"], { cwd: tmpDir });
execFileSync("git", ["commit", "-m", "init"], { cwd: tmpDir });
});
afterEach(() => {
fs.rmSync(baseTmp, { recursive: true, force: true });
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(workflowDir, { recursive: true, force: true });
if (originalHome !== undefined) {
process.env.HOME = originalHome;
} else {
delete process.env.HOME;
}
if (originalWorkflowDir !== undefined) {
process.env.AGENT_WORKFLOW_DIR = originalWorkflowDir;
} else {
delete process.env.AGENT_WORKFLOW_DIR;
}
process.exit = originalExit;
});
it("completes a full loop with reasonix", async () => {
// 1. Fake Executor CLI (Reasonix)
const executorScript = `
if [[ "$*" == *"--version"* ]]; then
echo "v1.0.0"
exit 0
fi
if [[ "$*" == *"--resume"* ]]; then
if [[ "$*" == *"# Scope Remediation"* ]]; then
if [[ ! -f "$HOME/scope-retry-ready" ]]; then
: > "$HOME/scope-retry-ready"
echo "session busy" >&2
exit 1
fi
# Cycle 2: remove only the listed SQLite sidecars.
rm -f "$3/tmp_oc.db-shm" "$3/tmp_oc.db-wal"
echo "Scope cleaned!"
else
# Cycle 3: fix the Codex finding.
echo "const x = 3;" > "$3/index.ts"
echo "Fixed!"
fi
exit 0
else
# Cycle 1: implement with a bug and accidentally leave SQLite sidecars.
echo "const x = 2; // bug" > "$3/index.ts"
: > "$3/tmp_oc.db-shm"
: > "$3/tmp_oc.db-wal"
echo "Done!"
# Create fake session file for reasonix
mkdir -p "$HOME/.reasonix/projects/mock-project/sessions"
echo '{"role": "system", "content": "Current workspace: \\"'"$3"'\\""}' > "$HOME/.reasonix/projects/mock-project/sessions/session_1.jsonl"
echo "{}" > "$HOME/.reasonix/projects/mock-project/sessions/session_1.jsonl.meta"
# Create a newer subagent session to verify it gets correctly ignored
mkdir -p "$HOME/.reasonix/projects/mock-project/sessions/subagents"
sleep 1
echo '{"role": "system", "content": "Current workspace: '"$3"'"}' > "$HOME/.reasonix/projects/mock-project/sessions/subagents/sub_session.jsonl"
echo "{}" > "$HOME/.reasonix/projects/mock-project/sessions/subagents/sub_session.jsonl.meta"
fi
`;
const executorBin = writeFakeCli(tmpDir, "fake-executor.sh", executorScript);
fs.writeFileSync(path.join(tmpDir, "agent-workflow.json"), JSON.stringify({
version: "1",
maxCycles: 3,
executors: {
reasonix: { binary: executorBin }
}
}));
const plan = {
version: "1",
title: "Add a file",
planMarkdown: "Add an index.ts file.",
scope: ["index.ts"],
acceptanceCriteria: ["x is 3"],
verificationCommands: [["grep", "const x = 3;", "index.ts"]],
} as const;
const planPath = path.join(tmpDir, "plan.json");
fs.writeFileSync(planPath, JSON.stringify(plan));
execFileSync("git", ["add", "."], { cwd: tmpDir });
execFileSync("git", ["commit", "-m", "config"], { cwd: tmpDir });
// --- STEP 1: START ---
const { runId } = await startWorkflow({
cwd: tmpDir,
executor: "reasonix",
planInput: planPath,
});
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: tmpDir, encoding: "utf8" }).trim();
let state = readState(runDir(repoRoot, runId))!;
expect(state.status).toBe("awaiting_review");
expect(state.currentCycle).toBe(1);
// --- STEP 2: SCOPE GATE (Cycle 1, Codex review bundle is not ready yet) ---
await reviewWorkflow({ runId, cwd: tmpDir });
let state2 = readState(runDir(repoRoot, runId))!;
expect(state2.status).toBe("awaiting_scope_resolution");
expect(state2.cycles[0].scopeViolations).toEqual([
{ id: "scope-1", path: "tmp_oc.db-shm", kind: "out_of_scope" },
{ id: "scope-2", path: "tmp_oc.db-wal", kind: "out_of_scope" },
]);
expect(state2.cycles[0].hostReviewFile).toBeUndefined();
const incompleteDecision: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [{ findingId: "scope-1", disposition: "accept" }],
decidedAt: new Date().toISOString(),
};
const incompleteDecisionPath = path.join(process.env.HOME!, "decision-incomplete.json");
fs.writeFileSync(incompleteDecisionPath, JSON.stringify(incompleteDecision));
await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: incompleteDecisionPath }))
.rejects.toThrow(/accept every current violation exactly once/);
expect(readState(runDir(repoRoot, runId))?.status).toBe("awaiting_scope_resolution");
// --- STEP 3: DECIDE (Scope fix using the same Reasonix session) ---
const scopeDecision: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [
{ findingId: "scope-1", disposition: "accept" },
{ findingId: "scope-2", disposition: "accept" },
],
decidedAt: new Date().toISOString()
};
const scopeDecisionPath = path.join(process.env.HOME!, "decision-scope.json");
fs.writeFileSync(scopeDecisionPath, JSON.stringify(scopeDecision));
await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: scopeDecisionPath }))
.rejects.toThrow(/Executor failed/);
expect(readState(runDir(repoRoot, runId))?.status).toBe("blocked");
await retryExecuteWorkflow({ runId });
let state3 = readState(runDir(repoRoot, runId))!;
expect(state3.status).toBe("awaiting_review");
expect(state3.currentCycle).toBe(2);
expect(fs.existsSync(path.join(tmpDir, "tmp_oc.db-shm"))).toBe(false);
expect(fs.existsSync(path.join(tmpDir, "tmp_oc.db-wal"))).toBe(false);
// --- STEP 4: PREPARE CODEX REVIEW (Cycle 2) ---
await reviewWorkflow({ runId, cwd: tmpDir });
let state4 = readState(runDir(repoRoot, runId))!;
expect(state4.status).toBe("awaiting_host");
expect(state4.cycles[1].hostReviewFile).toBe("cycle-2-host-review.json");
const reviewBundle = JSON.parse(fs.readFileSync(
path.join(runDir(repoRoot, runId), state4.cycles[1].hostReviewFile!),
"utf8",
));
expect(reviewBundle.repoRoot).toBe(repoRoot);
expect(reviewBundle.diffFile).toBe(path.join(runDir(repoRoot, runId), "cycle-2.diff"));
expect(reviewBundle.executorLogFile).toBe(path.join(runDir(repoRoot, runId), "cycle-2-exec-retry-1.log"));
expect(state4.cycles[1].executorAttemptLogs).toEqual([
"cycle-2-exec.log",
"cycle-2-exec-retry-1.log",
]);
// --- STEP 5: CODEX DECIDES FIX ---
const incompleteFix: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [{ findingId: "codex-1", disposition: "accept" }],
decidedAt: new Date().toISOString(),
};
const incompleteFixPath = path.join(process.env.HOME!, "decision-fix-incomplete.json");
fs.writeFileSync(incompleteFixPath, JSON.stringify(incompleteFix));
await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: incompleteFixPath }))
.rejects.toThrow(/require a non-empty summary/);
expect(readState(runDir(repoRoot, runId))?.status).toBe("awaiting_host");
const fixDecision: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [{
findingId: "codex-1",
disposition: "accept",
summary: "index.ts sets x to 2 instead of the required value 3",
path: "index.ts",
}],
decidedAt: new Date().toISOString()
};
const fixDecisionPath = path.join(process.env.HOME!, "decision-fix.json");
fs.writeFileSync(fixDecisionPath, JSON.stringify(fixDecision));
await decideWorkflow({ runId, cwd: tmpDir, decisionInput: fixDecisionPath });
let state5 = readState(runDir(repoRoot, runId))!;
expect(state5.status).toBe("awaiting_review");
expect(state5.currentCycle).toBe(3);
// --- STEP 6: PREPARE CODEX RE-REVIEW (Cycle 3) ---
await reviewWorkflow({ runId, cwd: tmpDir });
let state6 = readState(runDir(repoRoot, runId))!;
expect(state6.status).toBe("awaiting_host");
expect(state6.cycles[2].hostReviewFile).toBe("cycle-3-host-review.json");
// --- STEP 7: DECIDE (Accept) ---
const acceptDecision: HostDecisionV1 = {
version: "1",
outcome: "accept",
findingDecisions: [],
verificationEvidence: ["verified output"],
decidedAt: new Date().toISOString()
};
const acceptDecisionPath = path.join(process.env.HOME!, "decision2.json");
fs.writeFileSync(acceptDecisionPath, JSON.stringify(acceptDecision));
try {
await decideWorkflow({ runId, cwd: tmpDir, decisionInput: acceptDecisionPath });
} catch (err: any) {
if (err.message !== "MockExit0") throw err;
}
let state7 = readState(runDir(repoRoot, runId))!;
expect(state7.status).toBe("completed");
});
});
+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");
});
});
+342
View File
@@ -0,0 +1,342 @@
/**
* Workflow state persistence.
*
* State files live at:
* ~/.agent-workflow/workflows/<repo-hash>/<run-id>/
* state.json — current machine state (atomic write via temp file)
* events.jsonl — append-only event log
* cycle-<N>.diff — diff relative to baseline after cycle N
* cycle-<N>-exec.log — executor stdout/stderr log
* cycle-<N>-host-review.json — evidence bundle for Codex review
* cycle-<N>-decision.json — Codex HostDecisionV1
*
* Only one active workflow per working-tree (keyed by repo-hash + lock file).
*/
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync, spawnSync } from "node:child_process";
import type { WorkflowEvent, WorkflowState } from "./workflow-types.js";
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const DEFAULT_WORKFLOWS_ROOT = path.join(os.homedir(), ".agent-workflow", "workflows");
/** Root directory for workflow state. */
export function workflowsRoot(): string {
return process.env.AGENT_WORKFLOW_DIR || DEFAULT_WORKFLOWS_ROOT;
}
/** Compute a stable 8-char hex hash for a repo root path. */
export function repoHash(repoRoot: string): string {
return crypto.createHash("sha256").update(path.resolve(repoRoot)).digest("hex").slice(0, 8);
}
/** Run directory for a specific run. */
export function runDir(repoRoot: string, runId: string): string {
return path.join(workflowsRoot(), repoHash(repoRoot), runId);
}
/** Lock file path for a repo (one active workflow at a time). */
export function lockFilePath(repoRoot: string): string {
return path.join(workflowsRoot(), repoHash(repoRoot), "active.lock");
}
export function stateFilePath(dir: string): string {
return path.join(dir, "state.json");
}
export function eventsFilePath(dir: string): string {
return path.join(dir, "events.jsonl");
}
export function cycleDiffFile(dir: string, cycleIndex: number): string {
return path.join(dir, `cycle-${cycleIndex}.diff`);
}
export function cycleExecLogFile(dir: string, cycleIndex: number): string {
return path.join(dir, `cycle-${cycleIndex}-exec.log`);
}
export function cycleHostReviewFile(dir: string, cycleIndex: number): string {
return path.join(dir, `cycle-${cycleIndex}-host-review.json`);
}
export function cycleDecisionFile(dir: string, cycleIndex: number): string {
return path.join(dir, `cycle-${cycleIndex}-decision.json`);
}
// ---------------------------------------------------------------------------
// Run ID
// ---------------------------------------------------------------------------
let _runIdSeq = 0;
export function generateRunId(): string {
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const seq = String(++_runIdSeq).padStart(2, "0");
const rand = crypto.randomBytes(3).toString("hex");
return `${ts}_${seq}_${rand}`;
}
// ---------------------------------------------------------------------------
// Atomic state write
// ---------------------------------------------------------------------------
/** Write state.json atomically via a temp file + rename. */
export function writeState(dir: string, state: WorkflowState): void {
const filePath = stateFilePath(dir);
const tmp = `${filePath}.tmp.${process.pid}`;
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
fs.renameSync(tmp, filePath);
}
/** Read and parse state.json from a run dir. Returns undefined if not found. */
export function readState(dir: string): WorkflowState | undefined {
const filePath = stateFilePath(dir);
if (!fs.existsSync(filePath)) return undefined;
try {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as WorkflowState;
} catch {
return undefined;
}
}
/** Append a single event to events.jsonl. */
export function appendEvent(dir: string, event: WorkflowEvent): void {
const filePath = eventsFilePath(dir);
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(filePath, JSON.stringify(event) + "\n", "utf8");
}
// ---------------------------------------------------------------------------
// Lock management
// ---------------------------------------------------------------------------
export class WorkflowLockError extends Error {
constructor(
message: string,
readonly existingRunId?: string,
) {
super(message);
this.name = "WorkflowLockError";
}
}
/** Acquire the repo-level lock. Reusing the same run's lock is idempotent. */
export function acquireLock(repoRoot: string, runId: string): void {
const lockPath = lockFilePath(repoRoot);
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
if (fs.existsSync(lockPath)) {
const existing = fs.readFileSync(lockPath, "utf8").trim();
if (existing === runId) return;
throw new WorkflowLockError(
`Another workflow is active for this repository (run-id: ${existing}). ` +
`Use 'agent-workflow abort --run ${existing}' to cancel it first.`,
existing,
);
}
fs.writeFileSync(lockPath, runId, "utf8");
}
/** Release the repo-level lock only when it belongs to runId. */
export function releaseLock(repoRoot: string, runId: string): void {
const lockPath = lockFilePath(repoRoot);
if (!fs.existsSync(lockPath)) return;
const existing = fs.readFileSync(lockPath, "utf8").trim();
if (existing === runId) fs.unlinkSync(lockPath);
}
/** Read the active run-id, or undefined if no lock exists. */
export function readLock(repoRoot: string): string | undefined {
const lockPath = lockFilePath(repoRoot);
if (!fs.existsSync(lockPath)) return undefined;
return fs.readFileSync(lockPath, "utf8").trim() || undefined;
}
// ---------------------------------------------------------------------------
// Git helpers
// ---------------------------------------------------------------------------
export class GitError extends Error {
constructor(message: string) {
super(message);
this.name = "GitError";
}
}
/** Return the absolute repo root (throws if not in a git repo). */
export function gitRepoRoot(cwd: string = process.cwd()): string {
try {
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
} catch {
throw new GitError("Not in a git repository (or git not found).");
}
}
/** Return HEAD SHA. */
export function gitHead(repoRoot: string): string {
try {
return execFileSync("git", ["rev-parse", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
} catch {
throw new GitError("Failed to read HEAD.");
}
}
/** Return true when the working tree is clean (no uncommitted changes). */
export function gitIsClean(repoRoot: string): boolean {
try {
const status = execFileSync("git", ["status", "--porcelain"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return status === "";
} catch {
throw new GitError("Failed to check git status.");
}
}
/** Generate a diff from baseline HEAD to the current working tree. */
export function gitDiffFromHead(repoRoot: string, baselineHead: string): string {
try {
const trackedDiff = execFileSync("git", ["diff", baselineHead], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
const untrackedStr = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const untrackedFiles = untrackedStr
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
let finalDiff = trackedDiff;
for (const file of untrackedFiles) {
const res = spawnSync("git", ["diff", "--no-index", "/dev/null", file], {
cwd: repoRoot,
encoding: "utf8",
});
const untrackedDiff = (res.stdout || "").trim();
if (untrackedDiff) {
if (finalDiff) {
finalDiff += "\n" + untrackedDiff;
} else {
finalDiff = untrackedDiff;
}
}
}
return finalDiff;
} catch {
throw new GitError("Failed to compute diff from baseline HEAD.");
}
}
/** Check that all modified files are within the allowed scope. */
export function checkFilesInScope(repoRoot: string, baselineHead: string, scope: string[]): string[] {
let diff: string;
let untracked: string;
try {
diff = execFileSync("git", ["diff", "--name-only", baselineHead], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch {
throw new GitError("Failed to check modified files.");
}
const modifiedFiles = (diff + "\n" + untracked)
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
const outOfScope: string[] = [];
for (const file of modifiedFiles) {
const inScope = scope.some((s) => {
const normalized = s.replace(/\/+$/, "");
return file === normalized || file.startsWith(normalized + "/");
});
if (!inScope) outOfScope.push(file);
}
return outOfScope;
}
// ---------------------------------------------------------------------------
// State transition helpers
// ---------------------------------------------------------------------------
export function nowIso(): string {
return new Date().toISOString();
}
/** Create the initial WorkflowState for a new run. */
export function initWorkflowState(opts: {
runId: string;
repoRoot: string;
baselineHead: string;
executor: import("./workflow-types.js").ExecutorKind;
plan: import("./workflow-types.js").WorkflowPlanV1;
maxCycles: number;
timeoutSeconds?: number;
}): WorkflowState {
const now = nowIso();
return {
version: "1",
runId: opts.runId,
repoHash: repoHash(opts.repoRoot),
repoRoot: opts.repoRoot,
baselineHead: opts.baselineHead,
executor: opts.executor,
plan: opts.plan,
status: "executing",
maxCycles: opts.maxCycles,
currentCycle: 1,
cycles: [],
createdAt: now,
updatedAt: now,
...(opts.timeoutSeconds !== undefined ? { timeoutSeconds: opts.timeoutSeconds } : {}),
};
}
/** Find a run directory by runId within the configured workflow root. */
export function findRunDir(runId: string, repoRoot?: string): string | undefined {
const root = workflowsRoot();
if (repoRoot) {
const hash = repoHash(repoRoot);
const dir = path.join(root, hash, runId);
if (fs.existsSync(stateFilePath(dir))) return dir;
return undefined;
}
if (fs.existsSync(root)) {
for (const repoHashDir of fs.readdirSync(root)) {
const dir = path.join(root, repoHashDir, runId);
if (fs.existsSync(stateFilePath(dir))) return dir;
}
}
return undefined;
}
+350
View File
@@ -0,0 +1,350 @@
/**
* Workflow types for the agent-workflow host orchestration loop.
*
* WorkflowPlanV1 — input from Codex describing what to implement
* WorkflowState — durable run state (persisted as state.json)
* HostDecisionV1 — Codex decision after reviewing findings
* ExecutorKind — the three supported executor adapters
* WorkflowStatus — machine-readable status summary
*/
// ---------------------------------------------------------------------------
// Executors
// ---------------------------------------------------------------------------
export const EXECUTOR_KINDS = ["reasonix", "claude", "agy"] as const;
export type ExecutorKind = (typeof EXECUTOR_KINDS)[number];
// ---------------------------------------------------------------------------
// Plan (input from Codex)
// ---------------------------------------------------------------------------
export interface WorkflowPlanV1 {
/** Schema version — always "1". */
version: "1";
title: string;
planMarkdown: string;
/** Allowed repository-relative paths (files and/or directories). */
scope: string[];
acceptanceCriteria: string[];
/** Shell commands used to verify the implementation (run without shell expansion). */
verificationCommands: string[][];
}
// ---------------------------------------------------------------------------
// State machine
// ---------------------------------------------------------------------------
export const WORKFLOW_ACTIVE_STATUSES = [
"executing",
"awaiting_review",
"awaiting_scope_resolution",
"awaiting_host",
] as const;
export const WORKFLOW_TERMINAL_STATUSES = [
"completed",
"needs_human",
"blocked",
"budget_exhausted",
"aborted",
] as const;
export const WORKFLOW_STATUSES = [
...WORKFLOW_ACTIVE_STATUSES,
...WORKFLOW_TERMINAL_STATUSES,
] as const;
export type WorkflowActiveStatus = (typeof WORKFLOW_ACTIVE_STATUSES)[number];
export type WorkflowTerminalStatus = (typeof WORKFLOW_TERMINAL_STATUSES)[number];
export type WorkflowStatus = (typeof WORKFLOW_STATUSES)[number];
// ---------------------------------------------------------------------------
// Session handles per executor kind
// ---------------------------------------------------------------------------
export interface ReasonixSessionHandle {
kind: "reasonix";
/** Absolute path to the .jsonl session file detected after the first run. */
sessionFilePath: string;
}
export interface ClaudeSessionHandle {
kind: "claude";
sessionId: string;
}
export interface AgySessionHandle {
kind: "agy";
/** Conversation ID from agy output, if available. */
conversationId?: string;
/** True when we had to fall back to --continue because no ID was captured. */
degraded: boolean;
}
export type ExecutorSessionHandle =
| ReasonixSessionHandle
| ClaudeSessionHandle
| AgySessionHandle;
export interface ScopeViolation {
id: string;
path: string;
kind: "out_of_scope";
}
// ---------------------------------------------------------------------------
// Per-cycle record
// ---------------------------------------------------------------------------
export interface CycleRecord {
cycleIndex: number;
startedAt: string;
completedAt?: string;
/** Exit code returned by the executor process. */
executorExitCode?: number;
/** Executor's text report (truncated summary). */
executorReport?: string;
/** Path to the complete executor log (relative to run dir). */
executorLogFile?: string;
/** All executor attempt logs for this cycle (including retries). */
executorAttemptLogs?: string[];
/** Path to the diff file generated after this cycle (relative to run dir). */
diffFile?: string;
/** Path to the Codex host review bundle (relative to run dir). */
hostReviewFile?: string;
/** Time at which the review bundle became ready for Codex. */
hostReviewReadyAt?: string;
/** Files that failed the frozen scope gate before Codex review. */
scopeViolations?: ScopeViolation[];
/** Path to the Codex decision file (relative to run dir). */
decisionFile?: string;
/** Codex decision outcome for this cycle. */
decisionOutcome?: HostDecisionOutcome;
/** Cycle-level stop reason when this cycle triggered termination. */
stopReason?: WorkflowTerminalStatus;
}
// ---------------------------------------------------------------------------
// Workflow state (persisted)
// ---------------------------------------------------------------------------
export interface WorkflowState {
/** Schema version — always "1". */
version: "1";
runId: string;
repoHash: string;
/** Absolute path to the repository root. */
repoRoot: string;
/** Git HEAD SHA at workflow start. */
baselineHead: string;
executor: ExecutorKind;
executorConfig?: ExecutorConfig;
timeoutSeconds?: number;
enginePid?: number;
plan: WorkflowPlanV1;
status: WorkflowStatus;
/** When the run reached a terminal status, the stop reason (same as status for terminals). */
stopReason?: WorkflowTerminalStatus;
/** Human-readable stop description. */
stopDescription?: string;
maxCycles: number;
currentCycle: number;
cycles: CycleRecord[];
sessionHandle?: ExecutorSessionHandle;
createdAt: string;
updatedAt: string;
/** True when --agy-degraded-continue is in effect. */
agyDegraded?: boolean;
}
// ---------------------------------------------------------------------------
// Host decision (from Codex)
// ---------------------------------------------------------------------------
export const HOST_DECISION_OUTCOMES = ["fix", "accept", "needs_human"] as const;
export type HostDecisionOutcome = (typeof HOST_DECISION_OUTCOMES)[number];
export interface FindingDecision {
/** Stable id assigned by Codex, or scope-* for a scope violation. */
findingId: string;
/** "accept" = fix it, "reject" = acknowledged but out of scope, "followup" = record and move on. */
disposition: "accept" | "reject" | "followup";
/** Concrete issue description. Required for accepted Codex findings. */
summary?: string;
/** Repository-relative location when known. */
path?: string;
rationale?: string;
}
export interface HostDecisionV1 {
/** Schema version — always "1". */
version: "1";
outcome: HostDecisionOutcome;
/** Findings Codex decided on. */
findingDecisions: FindingDecision[];
/** Reason for needs_human or rejection rationale. */
reason?: string;
/** Follow-up items to record (not to fix now). */
followUps?: string[];
/** Codex-supplied verification evidence (command outputs, test results). */
verificationEvidence?: string[];
/** ISO timestamp when Codex submitted this decision. */
decidedAt: string;
}
/** Persisted evidence package that Codex must inspect before deciding. */
export interface HostReviewBundleV1 {
version: "1";
runId: string;
cycleIndex: number;
repoRoot: string;
baselineHead: string;
generatedAt: string;
plan: WorkflowPlanV1;
diffFile: string;
executorLogFile?: string;
executorReport?: string;
verificationCommands: string[][];
}
// ---------------------------------------------------------------------------
// Workflow config (agent-workflow.json)
// ---------------------------------------------------------------------------
export interface ExecutorConfig {
binary?: string;
model?: string;
agent?: string;
profile?: string;
/** Token budget (executor-specific interpretation). */
budget?: number;
}
export interface WorkflowProjectConfig {
version: "1";
defaultExecutor?: ExecutorKind;
maxCycles?: number;
timeoutSeconds?: number;
executors?: {
reasonix?: ExecutorConfig;
claude?: ExecutorConfig;
agy?: ExecutorConfig;
};
}
// ---------------------------------------------------------------------------
// Resolved workflow config (after merging CLI + project + defaults)
// ---------------------------------------------------------------------------
export interface ResolvedWorkflowConfig {
executor: ExecutorKind;
maxCycles: number;
timeoutSeconds: number;
executorConfig: ExecutorConfig;
}
// ---------------------------------------------------------------------------
// Status output
// ---------------------------------------------------------------------------
export interface WorkflowStatusOutput {
runId: string;
status: WorkflowStatus;
executor: ExecutorKind;
currentCycle: number;
maxCycles: number;
remainingCycles: number;
sessionHandleKind?: string;
degradedResume?: boolean;
runDir: string;
diffFile?: string;
executorLogFile?: string;
hostReviewFile?: string;
scopeViolations?: ScopeViolation[];
stopReason?: WorkflowTerminalStatus;
stopDescription?: string;
baselineHead: string;
repoRoot: string;
createdAt: string;
updatedAt: string;
}
// ---------------------------------------------------------------------------
// Event log (events.jsonl — append-only audit trail)
// ---------------------------------------------------------------------------
export type WorkflowEventKind =
| "workflow_started"
| "cycle_started"
| "executor_started"
| "executor_retried"
| "executor_completed"
| "review_started"
| "review_completed"
| "scope_violation_detected"
| "scope_resolution_started"
| "review_retried"
| "decision_received"
| "cycle_completed"
| "workflow_stopped"
| "workflow_aborted"
| "blocked";
export interface WorkflowEvent {
kind: WorkflowEventKind;
runId: string;
timestamp: string;
cycleIndex?: number;
data?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Executor adapter interface
// ---------------------------------------------------------------------------
export interface ExecutorResult {
/** Process exit code. */
exitCode: number | null;
/** Combined text report from the executor. */
report: string;
durationMs: number;
/** Updated session handle after this run. */
sessionHandle?: ExecutorSessionHandle;
/** True when the executor process terminated abnormally. */
failed: boolean;
failureReason?: string;
}
export interface ExecutorAdapter {
kind: ExecutorKind;
/** Check if the executor binary is available. */
probe(config?: ExecutorConfig): Promise<boolean>;
/** Start a new implementation session. Returns session handle + result. */
start(opts: ExecutorStartOpts): Promise<ExecutorResult>;
/** Resume an existing session for a fix cycle. */
resume(opts: ExecutorResumeOpts): Promise<ExecutorResult>;
/** Cancel a running session. */
cancel(handle: ExecutorSessionHandle): Promise<void>;
}
export interface ExecutorStartOpts {
repoRoot: string;
plan: WorkflowPlanV1;
runDir: string;
cycleIndex: number;
config: ExecutorConfig;
signal?: AbortSignal;
}
export interface ExecutorResumeOpts {
repoRoot: string;
plan: WorkflowPlanV1;
/** Only the accepted (actionable) findings sent to the executor. */
acceptedFindings: string;
handle: ExecutorSessionHandle;
runDir: string;
cycleIndex: number;
config: ExecutorConfig;
signal?: AbortSignal;
}