Files
ci/src/agents/executor.ts
T

184 lines
5.5 KiB
TypeScript

import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { execSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
export interface ExecutorResult {
success: boolean;
tasksExecuted: number;
tasksCommitted: number;
testsPassing: boolean;
mustHavesChecked: { name: string; passed: boolean }[];
error?: string;
}
interface MustHaveItem {
name: string;
passed: boolean;
}
export class ExecutorAgent extends BaseAgent {
readonly name = "executor";
readonly description = "Executes plan tasks autonomously. Never pauses for checkpoints.";
readonly workflow = "execute";
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Executing tasks...");
if (context.backend) {
const taskPrompt = await this.buildBackendTaskPrompt(context);
const backendResult = await this.executeViaBackend(context, taskPrompt);
const verification = await this.verifyExecution(context);
return {
...backendResult,
output: `${backendResult.output}\nVerification: tests=${verification.testsPassing ? "passing" : "failing"}, must-haves checked=${verification.mustHavesChecked.length}`,
duration_ms: Date.now() - start,
};
}
return {
success: false,
output: "Executor requires intelligence backend for code implementation",
artifacts_created: [],
decisions: 0,
escalations: 0,
duration_ms: Date.now() - start,
error: "Executor requires intelligence backend for code implementation",
};
}
private async buildBackendTaskPrompt(context: AgentContext): Promise<string> {
const parts: string[] = [
`Execute implementation for stage ${context.stage}, phase ${context.phase}.`,
"",
"## Specification",
context.specification || "No specification provided",
];
const planContent = this.readPlanFile(context);
if (planContent) {
parts.push("", "## Plan", planContent);
}
const ciDir = path.join(context.project_path, ".ciagent");
const roadmapPath = path.join(ciDir, "ROADMAP.md");
const archPath = path.join(ciDir, "ARCHITECTURE.md");
if (fs.existsSync(roadmapPath)) {
try {
const roadmap = fs.readFileSync(roadmapPath, "utf-8");
parts.push("", "## Roadmap Context", roadmap.slice(0, 2000));
} catch {}
}
if (fs.existsSync(archPath)) {
try {
const arch = fs.readFileSync(archPath, "utf-8");
parts.push("", "## Architecture Boundaries", arch.slice(0, 2000));
} catch {}
}
parts.push("", "## Execution Rules");
parts.push("- Execute one task at a time");
parts.push("- Commit after each task with ---ci--- block");
parts.push("- Never pause for checkpoints");
parts.push("- Create automated verification for traditionally human tasks");
return parts.join("\n");
}
private readPlanFile(context: AgentContext): string | null {
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
try {
if (fs.existsSync(planPath)) {
return fs.readFileSync(planPath, "utf-8");
}
} catch {}
return null;
}
private async verifyExecution(context: AgentContext): Promise<ExecutorResult> {
const mustHavesChecked: MustHaveItem[] = this.checkMustHaves(context);
let testsPassing = false;
let tasksExecuted = 0;
let tasksCommitted = 0;
try {
const logOutput = execSync("git log --max-count=20 --oneline", {
cwd: context.project_path,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
const commitLines = logOutput.split("\n").filter(Boolean);
tasksCommitted = commitLines.filter((l) => /feat|fix|test/.test(l)).length;
tasksExecuted = tasksCommitted;
} catch {}
try {
execSync("npm test", {
cwd: context.project_path,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 120000,
});
testsPassing = true;
} catch {
testsPassing = false;
}
return {
success: mustHavesChecked.every((m) => m.passed) && testsPassing,
tasksExecuted,
tasksCommitted,
testsPassing,
mustHavesChecked,
};
}
private checkMustHaves(context: AgentContext): MustHaveItem[] {
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
const results: MustHaveItem[] = [];
try {
if (!fs.existsSync(planPath)) return results;
const planContent = fs.readFileSync(planPath, "utf-8");
const mustHaveRegex = /-\s*\[x\]\s*(.+)/g;
let match;
while ((match = mustHaveRegex.exec(planContent)) !== null) {
const name = match[1].trim();
const passed = this.verifyMustHaveItem(name, context);
results.push({ name, passed });
}
} catch {}
return results;
}
private verifyMustHaveItem(item: string, context: AgentContext): boolean {
const fileMatch = item.match(/(?:exists|created?|present).*?[\s:]+([^\s]+\.(ts|js|json|md))/i);
if (fileMatch) {
const filePath = path.join(context.project_path, fileMatch[1]);
return fs.existsSync(filePath);
}
const testMatch = item.match(/(?:test|tests?)\s+(?:pass|passing)/i);
if (testMatch) {
try {
execSync("npm test", {
cwd: context.project_path,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 120000,
});
return true;
} catch {
return false;
}
}
return true;
}
}