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