feat: initialize standalone agent-workflow

This commit is contained in:
liujing
2026-07-16 20:56:24 +08:00
commit 35e8fa3717
31 changed files with 6972 additions and 0 deletions
+650
View File
@@ -0,0 +1,650 @@
/**
* Executor adapters for Reasonix, Claude Code, and Agy.
*
* All adapters:
* - Spawn processes with argument arrays (no shell interpolation)
* - Include fixed safety constraints in every prompt (no commit/push/reset)
* - Return ExecutorResult with session handle for future resume
*/
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawnStreamingChild } from "./child-process.js";
import type {
AgySessionHandle,
ClaudeSessionHandle,
ExecutorAdapter,
ExecutorConfig,
ExecutorKind,
ExecutorResumeOpts,
ExecutorResult,
ExecutorSessionHandle,
ExecutorStartOpts,
ReasonixSessionHandle,
} from "./workflow-types.js";
import { nowIso } from "./workflow-state.js";
// ---------------------------------------------------------------------------
// Safety prompt footer (appended to every executor instruction)
// ---------------------------------------------------------------------------
const SAFETY_CONSTRAINTS = `
## CRITICAL CONSTRAINTS — DO NOT VIOLATE
- DO NOT run git commit, git push, or git reset under any circumstances.
- DO NOT rewrite git history or delete branches.
- DO NOT create or modify files outside the scope listed above.
- Exception: a Scope Remediation prompt may authorize removing or restoring only the exact out-of-scope paths it lists.
- Put all temporary files in an OS temporary directory outside the repository (for example, one created with mktemp).
- If you accidentally create a temporary repository file, remove only that artifact before exiting.
- SQLite cleanup must include the database and its -wal, -shm, and -journal sidecars.
- Before exiting, inspect git status --porcelain --untracked-files=all and leave no artifacts outside the allowed scope.
- You may read, edit, and test code, but never commit or push changes.
- If you encounter an error you cannot resolve, stop and report it clearly.
`.trimStart();
// ---------------------------------------------------------------------------
// Prompt builders
// ---------------------------------------------------------------------------
function buildStartPrompt(opts: ExecutorStartOpts): string {
const { plan } = opts;
const scopeList = plan.scope.map((s) => ` - ${s}`).join("\n");
const criteriaList = plan.acceptanceCriteria.map((c, i) => ` ${i + 1}. ${c}`).join("\n");
const cmdsText = plan.verificationCommands
.map((cmd) => ` ${cmd.join(" ")}`)
.join("\n");
return `# Implementation Task: ${plan.title}
## Plan
${plan.planMarkdown}
## Allowed Scope
Only modify files within these paths (relative to repository root):
${scopeList}
## Acceptance Criteria
${criteriaList}
## Verification Commands
After implementation, ensure these commands pass:
${cmdsText}
${SAFETY_CONSTRAINTS}`;
}
function buildFixPrompt(opts: ExecutorResumeOpts): string {
const { plan, acceptedFindings } = opts;
if (acceptedFindings.startsWith("# Scope Remediation")) {
return `# Scope Fix Request: ${plan.title}
${acceptedFindings}
Do not modify any other out-of-scope path. If a listed path is not clearly an executor-created artifact, stop and report it instead of deleting it.
## Frozen Scope
The implementation itself must remain within:
${plan.scope.map((s) => ` - ${s}`).join("\n")}
## Verification
After scope cleanup, run:
${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")}
${SAFETY_CONSTRAINTS}`;
}
return `# Fix Request: ${plan.title}
The code review found the following issues that must be fixed:
${acceptedFindings}
## Scope Reminder
Only modify files within:
${plan.scope.map((s) => ` - ${s}`).join("\n")}
## Verification
After applying fixes, run:
${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")}
${SAFETY_CONSTRAINTS}`;
}
// ---------------------------------------------------------------------------
// Child env (inherit + extra PATH entries for executors)
// ---------------------------------------------------------------------------
function executorEnv(): NodeJS.ProcessEnv {
const homeDir = process.env.HOME || os.homedir();
const extras = [
path.join(homeDir, ".local", "bin"),
path.join(homeDir, ".bun", "bin"),
path.join(homeDir, ".n", "bin"),
"/usr/local/bin",
"/usr/bin",
"/bin",
process.env.PATH || "",
];
return {
...process.env,
PATH: [...new Set(extras.join(":").split(":").filter(Boolean))].join(":"),
};
}
function resolveExecutable(config: ExecutorConfig, defaultBin: string): string {
return config.binary || defaultBin;
}
// ---------------------------------------------------------------------------
// Reasonix adapter
// ---------------------------------------------------------------------------
function readFirstLine(filePath: string): string | undefined {
const CHUNK_SIZE = 65536;
const fd = fs.openSync(filePath, "r");
let buffer = Buffer.alloc(CHUNK_SIZE);
let totalData = "";
let newlineIndex = -1;
let position = 0;
try {
while (true) {
const bytesRead = fs.readSync(fd, buffer, 0, CHUNK_SIZE, position);
if (bytesRead === 0) break;
totalData += buffer.toString("utf8", 0, bytesRead);
newlineIndex = totalData.indexOf("\n");
if (newlineIndex !== -1) {
return totalData.slice(0, newlineIndex);
}
position += bytesRead;
if (position > 1024 * 1024) break; // 1MB safety limit
}
return totalData;
} finally {
fs.closeSync(fd);
}
}
function getWorkspaceFromJsonl(jsonlPath: string): string | undefined {
try {
const firstLine = readFirstLine(jsonlPath);
if (!firstLine) return undefined;
const obj = JSON.parse(firstLine);
if (obj && obj.role === "system" && typeof obj.content === "string") {
let match = obj.content.match(/Current workspace:\s*"([^"\n\r]+)"/i);
if (!match) {
match = obj.content.match(/Current workspace:\s*([^"\n\r]+)/i);
}
if (match) {
return match[1].trim();
}
}
} catch {
// ignore
}
return undefined;
}
function listJsonlFiles(dir: string, repoRoot?: string): string[] {
if (!fs.existsSync(dir)) return [];
const result: string[] = [];
function recurse(current: string) {
if (!fs.existsSync(current)) return;
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
const lowerDir = entry.name.toLowerCase();
if (lowerDir !== "archive" && lowerDir !== "subagents") {
recurse(full);
}
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
const lowerName = entry.name.toLowerCase();
if (
!lowerName.includes("events") &&
!lowerName.includes("archive") &&
!lowerName.includes("conflict") &&
!lowerName.includes("subagents")
) {
// Check companion file name: <session>.jsonl.meta
const metaFile = full + ".meta";
if (fs.existsSync(metaFile)) {
if (repoRoot) {
const ws = getWorkspaceFromJsonl(full);
if (ws === repoRoot) {
result.push(full);
}
} else {
result.push(full);
}
}
}
}
}
}
recurse(dir);
return result;
}
function detectNewSessionFile(beforeSnapshot: Set<string>, searchDirs: string[], repoRoot: string): string | undefined {
const candidates: string[] = [];
for (const dir of searchDirs) {
for (const file of listJsonlFiles(dir, repoRoot)) {
if (!beforeSnapshot.has(file)) {
candidates.push(file);
}
}
}
if (candidates.length === 0) return undefined;
if (candidates.length === 1) return candidates[0];
candidates.sort((a, b) => {
try {
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
} catch {
return 0;
}
});
return candidates[0];
}
class ReasonixAdapter implements ExecutorAdapter {
readonly kind: ExecutorKind = "reasonix";
async probe(config: ExecutorConfig = {}): Promise<boolean> {
try {
const res = spawnSync(resolveExecutable(config, "reasonix"), ["--version"], {
stdio: "ignore",
env: executorEnv(),
});
return res.status === 0 && !res.error;
} catch {
return false;
}
}
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
const bin = resolveExecutable(opts.config, "reasonix");
const prompt = buildStartPrompt(opts);
const promptFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-prompt.txt`);
fs.writeFileSync(promptFile, prompt, "utf8");
// Snapshot JSONL files before run to detect the session file created by reasonix
const homeDir = process.env.HOME || os.homedir();
const projectsDir = path.join(homeDir, ".reasonix", "projects");
fs.mkdirSync(projectsDir, { recursive: true });
const snapshotDirs = [projectsDir];
const beforeSnapshot = new Set(listJsonlFiles(projectsDir, opts.repoRoot));
const args: string[] = ["run", "--dir", opts.repoRoot, prompt];
const startMs = Date.now();
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
});
const durationMs = Date.now() - startMs;
const newSession = detectNewSessionFile(beforeSnapshot, snapshotDirs, opts.repoRoot);
const handle: ReasonixSessionHandle | undefined = newSession
? { kind: "reasonix", sessionFilePath: newSession }
: undefined;
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
const failed = child.status !== 0 || !!child.error;
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed,
...(child.error ? { failureReason: child.error.message } : {}),
};
}
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
const handle = opts.handle as ReasonixSessionHandle;
const bin = resolveExecutable(opts.config, "reasonix");
const prompt = buildFixPrompt(opts);
const promptFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-prompt.txt`);
fs.writeFileSync(promptFile, prompt, "utf8");
const args: string[] = [
"run",
"--dir", opts.repoRoot,
"--resume", handle.sessionFilePath,
prompt,
];
const startMs = Date.now();
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
});
const durationMs = Date.now() - startMs;
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle, // Reasonix handle stays the same file
failed: child.status !== 0 || !!child.error,
...(child.error ? { failureReason: child.error.message } : {}),
};
}
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
// Reasonix does not expose a cancel API in v1; no-op.
}
}
// ---------------------------------------------------------------------------
// Claude Code adapter
// ---------------------------------------------------------------------------
function extractClaudeSessionId(output: string): string | undefined {
// Claude Code outputs JSON with session_id field when using --output-format json
try {
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (typeof parsed.session_id === "string") return parsed.session_id;
}
} catch {
// fall through
}
// Also try a simple regex
const m = output.match(/"session_id"\s*:\s*"([^"]+)"/);
return m?.[1];
}
function extractClaudeFailureReason(output: string): string | undefined {
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (Array.isArray(parsed.errors)) {
const errors = parsed.errors.filter((error): error is string => typeof error === "string" && error.trim().length > 0);
if (errors.length > 0) return errors.join("; ");
}
if (parsed.is_error === true && typeof parsed.result === "string" && parsed.result.trim()) {
return parsed.result.trim();
}
} catch {
// Ignore non-JSON output lines.
}
}
return undefined;
}
import { randomUUID } from "node:crypto";
class ClaudeAdapter implements ExecutorAdapter {
readonly kind: ExecutorKind = "claude";
async probe(config: ExecutorConfig = {}): Promise<boolean> {
try {
const res = spawnSync(resolveExecutable(config, "claude"), ["--version"], {
stdio: "ignore",
env: executorEnv(),
});
return res.status === 0 && !res.error;
} catch {
return false;
}
}
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
const bin = resolveExecutable(opts.config, "claude");
const sessionId = randomUUID();
const prompt = buildStartPrompt(opts);
const args: string[] = [
"-p",
"--safe-mode",
"--permission-mode", "bypassPermissions",
"--output-format", "json",
"--session-id", sessionId,
...(opts.config.model ? ["--model", opts.config.model] : []),
prompt,
];
const startMs = Date.now();
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
});
const durationMs = Date.now() - startMs;
const capturedId = extractClaudeSessionId(child.stdout) ?? sessionId;
const handle: ClaudeSessionHandle = { kind: "claude", sessionId: capturedId };
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
const failureReason = child.error?.message
?? extractClaudeFailureReason(child.stdout)
?? extractClaudeFailureReason(child.stderr);
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed: child.status !== 0 || !!child.error,
...(failureReason ? { failureReason } : {}),
};
}
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
const handle = opts.handle as ClaudeSessionHandle;
const bin = resolveExecutable(opts.config, "claude");
const prompt = buildFixPrompt(opts);
const args: string[] = [
"-p",
"--safe-mode",
"--permission-mode", "bypassPermissions",
"--output-format", "json",
"--resume", handle.sessionId,
...(opts.config.model ? ["--model", opts.config.model] : []),
prompt,
];
const startMs = Date.now();
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
});
const durationMs = Date.now() - startMs;
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
const failureReason = child.error?.message
?? extractClaudeFailureReason(child.stdout)
?? extractClaudeFailureReason(child.stderr);
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed: child.status !== 0 || !!child.error,
...(failureReason ? { failureReason } : {}),
};
}
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
// Claude Code: no explicit cancel API in v1.
}
}
// ---------------------------------------------------------------------------
// Agy adapter
// ---------------------------------------------------------------------------
function extractAgyConversationId(output: string): string | undefined {
// Try to parse conversation_id from JSON output lines
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (typeof parsed.conversation_id === "string") return parsed.conversation_id;
if (typeof parsed.conversationId === "string") return parsed.conversationId;
} catch { /* skip */ }
}
return undefined;
}
class AgyAdapter implements ExecutorAdapter {
readonly kind: ExecutorKind = "agy";
async probe(config: ExecutorConfig = {}): Promise<boolean> {
try {
const res = spawnSync(resolveExecutable(config, "agy"), ["--version"], {
stdio: "ignore",
env: executorEnv(),
});
return res.status === 0 && !res.error;
} catch {
return false;
}
}
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
const bin = resolveExecutable(opts.config, "agy");
const prompt = buildStartPrompt(opts);
const args: string[] = [
...(opts.config.agent ? ["--agent", opts.config.agent] : []),
...(opts.config.model ? ["--model", opts.config.model] : []),
"--print", "--mode", "accept-edits", "--", prompt,
];
const startMs = Date.now();
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
});
const durationMs = Date.now() - startMs;
const conversationId = extractAgyConversationId(child.stdout);
const handle: AgySessionHandle = {
kind: "agy",
...(conversationId ? { conversationId } : {}),
degraded: !conversationId,
};
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed: child.status !== 0 || !!child.error,
...(child.error ? { failureReason: child.error.message } : {}),
};
}
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
const handle = opts.handle as AgySessionHandle;
const bin = resolveExecutable(opts.config, "agy");
const prompt = buildFixPrompt(opts);
let args: string[];
if (handle.conversationId) {
args = [
"--conversation", handle.conversationId,
...(opts.config.agent ? ["--agent", opts.config.agent] : []),
...(opts.config.model ? ["--model", opts.config.model] : []),
"--print", "--mode", "accept-edits", "--", prompt,
];
} else {
// Degraded mode: use --continue (best effort, may pick wrong session in concurrent setups)
args = [
"--continue",
...(opts.config.agent ? ["--agent", opts.config.agent] : []),
...(opts.config.model ? ["--model", opts.config.model] : []),
"--print", "--mode", "accept-edits", "--", prompt,
];
}
const startMs = Date.now();
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
});
const durationMs = Date.now() - startMs;
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed: child.status !== 0 || !!child.error,
...(child.error ? { failureReason: child.error.message } : {}),
};
}
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
// Agy: no explicit cancel API in v1.
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createExecutorAdapter(kind: ExecutorKind): ExecutorAdapter {
switch (kind) {
case "reasonix":
return new ReasonixAdapter();
case "claude":
return new ClaudeAdapter();
case "agy":
return new AgyAdapter();
default: {
const _exhaustive: never = kind;
throw new Error(`Unknown executor kind: ${_exhaustive}`);
}
}
}
/** Check that the executor binary is available and throw a descriptive error if not. */
export async function probeExecutor(kind: ExecutorKind, config: ExecutorConfig = {}): Promise<void> {
const adapter = createExecutorAdapter(kind);
const available = await adapter.probe(config);
if (!available) {
const bin = config.binary || kind;
throw new Error(
`Executor '${kind}' is not available. ` +
`Make sure '${bin}' is installed and on PATH. ` +
`You can override the binary path with the 'binary' field in agent-workflow.json.`,
);
}
}