Files

298 lines
11 KiB
TypeScript

/**
* One eval cell: extract skills/<name> from a git ref and run the same
* prompt on the host CLIs that are installed.
*
* bun run test:skill-eval-cell -- --skill ce-debug --task "mode:pipeline …"
*
* Does not run in default `bun test` / CI. Missing CLIs skip.
*/
import { spawn, spawnSync } from "node:child_process"
import fs from "node:fs"
import path from "node:path"
import { arg, flag } from "./cli"
import { WORKTREE_REF, extractSkill, mintCellDir } from "./extract"
import { HOSTS, planHost, resolveRunHosts, wrapPrompt, type Host, type HostPlan } from "./hosts"
import { installPathShims, type PathShim } from "./path-shim"
function parseHosts(): Host[] | undefined {
const raw = arg("--hosts")
if (raw === undefined) return undefined
if (!raw) {
console.error("usage: --hosts claude,codex,grok")
process.exit(2)
}
const wanted = raw.split(",").map((s) => s.trim()).filter(Boolean) as Host[]
for (const host of wanted) {
if (!HOSTS.includes(host)) {
console.error(`unknown host ${host} (want ${HOSTS.join(", ")})`)
process.exit(2)
}
}
return wanted
}
function copyFixture(src: string, dest: string) {
fs.cpSync(src, dest, { recursive: true })
}
function snapshotWorkspace(hostDir: string, workspace: string, seedSha: string) {
const git = (args: string[]) =>
spawnSync("git", args, { cwd: workspace, encoding: "utf8" })
const status = git(["status", "--porcelain"])
const log = git(["log", "--oneline", "-5"])
fs.writeFileSync(
path.join(hostDir, "git-status.txt"),
status.status === 0 ? status.stdout : `(not a git repo)\n${status.stderr}`,
)
fs.writeFileSync(
path.join(hostDir, "git-log.txt"),
log.status === 0 ? log.stdout : `(no git log)\n${log.stderr}`,
)
const list = spawnSync("find", [".", "-path", "./.git", "-prune", "-o", "-type", "f", "-print"], {
cwd: workspace,
encoding: "utf8",
})
fs.writeFileSync(path.join(hostDir, "files.txt"), list.stdout)
// What the run itself committed, across however many commits it made -- not what
// the seed already tracked, and not merely HEAD's own changeset.
// Every commit in the range, not the endpoint diff: a secret committed and then
// removed in a follow-up commit is still in history and still pushable.
const headFiles = seedSha
? git(["log", "--name-only", "--pretty=format:", `${seedSha}..HEAD`])
: git(["ls-tree", "-r", "--name-only", "HEAD"])
fs.writeFileSync(
path.join(hostDir, "git-head-files.txt"),
headFiles.status === 0 ? headFiles.stdout : "",
)
}
/** Detached children outlive the parent's own SIGINT/SIGHUP, so the cell kills them itself. */
const liveHosts = new Set<number>()
function killGroup(pid: number) {
try {
process.kill(-pid, "SIGKILL")
} catch {
// group already gone
}
}
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
process.on(signal, () => {
for (const pid of liveHosts) killGroup(pid)
process.exit(130)
})
}
process.on("exit", () => {
for (const pid of liveHosts) killGroup(pid)
})
async function runPlan(
plan: HostPlan,
cwd: string,
timeoutMs: number,
): Promise<{ exitCode: number | null; stdout: string; stderr: string; timedOut: boolean }> {
return new Promise((resolve) => {
const stdin = fs.openSync("/dev/null", "r")
// detached makes the child a process-group leader so a timeout can take down
// everything it spawned -- a host CLI's own subagents and detached peer jobs
// would otherwise keep running, billing, and writing after the cell reported.
const child = spawn(plan.argv[0], plan.argv.slice(1), {
cwd,
env: plan.env,
stdio: [stdin, "pipe", "pipe"],
detached: true,
})
if (child.pid) liveHosts.add(child.pid)
let timedOut = false
const killTree = () => {
if (child.pid) killGroup(child.pid)
try {
child.kill("SIGKILL")
} catch {
// already gone
}
}
let stdout = ""
let stderr = ""
child.stdout.on("data", (c) => {
stdout += c.toString()
})
child.stderr.on("data", (c) => {
stderr += c.toString()
})
let backstop: ReturnType<typeof setTimeout> | undefined
const timer = setTimeout(() => {
timedOut = true
killTree()
// Resolve on close so the snapshot runs after the tree is gone; the backstop
// covers a child whose pipes never close.
backstop = setTimeout(() => {
killTree()
if (child.pid) liveHosts.delete(child.pid)
resolve({ exitCode: null, stdout, stderr, timedOut: true })
}, 5000)
}, timeoutMs)
child.on("close", (code) => {
clearTimeout(timer)
if (backstop) clearTimeout(backstop)
if (child.pid) liveHosts.delete(child.pid)
try {
fs.closeSync(stdin)
} catch {
// already closed
}
resolve({ exitCode: timedOut ? null : code, stdout, stderr, timedOut })
})
child.on("error", (err) => {
clearTimeout(timer)
if (backstop) clearTimeout(backstop)
if (child.pid) liveHosts.delete(child.pid)
resolve({ exitCode: null, stdout, stderr: `${stderr}\n${String(err)}`, timedOut })
})
})
}
async function main() {
const skill = arg("--skill")
const task = arg("--task")
const taskFile = arg("--task-file")
if (!skill) {
console.error(
"usage: bun run test:skill-eval-cell -- --skill <name> --task \"...\" [--task-file p] [--ref WORKTREE|<git-ref>] [--hosts claude,codex,grok] [--fixture dir] [--out dir] [--timeout-secs 600] [--read-only] [--git-init] [--git-untracked p,p] [--git-staged p,p] [--shim-git-push] [--shim-gh-pr]\n default --hosts is the other two harnesses from this session; missing CLIs warn and continue",
)
process.exit(2)
}
const taskText = taskFile ? fs.readFileSync(taskFile, "utf8") : task
if (!taskText) {
console.error("pass --task or --task-file")
process.exit(2)
}
const ref = arg("--ref", WORKTREE_REF) ?? WORKTREE_REF
const timeoutMs = Number(arg("--timeout-secs", "600")) * 1000
const readOnly = flag("--read-only")
const resolution = resolveRunHosts({ explicit: parseHosts() })
for (const line of resolution.warnings) console.error(line)
const hosts = resolution.run
if (hosts.length === 0) {
console.error(`error: no harness CLIs on PATH (wanted ${resolution.wanted.join(", ")})`)
process.exit(2)
}
const out = arg("--out") ?? mintCellDir()
fs.mkdirSync(out, { recursive: true })
const { skillDir } = extractSkill({ skill, ref, dest: path.join(out, "extract") })
const workspace = path.join(out, "workspace")
// A reused --out otherwise keeps the previous run's files, commits, and mutations,
// and --git-init skips reseeding because the old .git is still there.
fs.rmSync(workspace, { recursive: true, force: true })
fs.rmSync(path.join(out, "hosts"), { recursive: true, force: true })
const fixture = arg("--fixture")
if (fixture) copyFixture(fixture, workspace)
else fs.mkdirSync(workspace, { recursive: true })
if (flag("--git-init") && !fs.existsSync(path.join(workspace, ".git"))) {
spawnSync("git", ["init", "-b", "main"], { cwd: workspace })
spawnSync("git", ["config", "user.name", "CE skill-eval-cell"], { cwd: workspace })
spawnSync("git", ["config", "user.email", "skill-eval-cell@example.test"], { cwd: workspace })
const untracked = (arg("--git-untracked") ?? "")
.split(",")
.map((p) => p.trim())
.filter(Boolean)
// Staged paths are held out of the seed commit, then re-added, so they land as a
// staged-but-uncommitted change. A cell whose skill scopes a diff needs this:
// untracked paths are out of scope unless staged, so --git-untracked alone
// produces an empty reviewed set.
const staged = (arg("--git-staged") ?? "")
.split(",")
.map((p) => p.trim())
.filter(Boolean)
spawnSync("git", ["add", "."], { cwd: workspace })
for (const rel of [...untracked, ...staged]) {
spawnSync("git", ["rm", "-f", "--cached", "--ignore-unmatch", "--", rel], { cwd: workspace })
}
spawnSync("git", ["commit", "-m", "seed", "--allow-empty"], { cwd: workspace })
for (const rel of staged) {
spawnSync("git", ["add", "--", rel], { cwd: workspace })
}
}
const seedRev = spawnSync("git", ["rev-parse", "HEAD"], { cwd: workspace, encoding: "utf8" })
const seedSha = seedRev.status === 0 ? seedRev.stdout.trim() : ""
if (flag("--git-remote") && seedSha) {
// A fake origin whose main is the seed commit: the shipping tail sees no pre-existing
// unpushed commits and takes the push/PR path, where the push shim then fails.
spawnSync("git", ["remote", "add", "origin", "https://example.invalid/eval.git"], { cwd: workspace })
spawnSync("git", ["update-ref", "refs/remotes/origin/main", seedSha], { cwd: workspace })
spawnSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"], { cwd: workspace })
}
const summary: Record<string, unknown> = {
skill,
ref,
out,
skillDir,
workspace,
current_harness: resolution.current,
hosts_wanted: resolution.wanted,
hosts_run: hosts,
hosts_skipped: resolution.skipped,
own_eval_only: resolution.ownEvalOnly,
warnings: resolution.warnings,
read_only: readOnly,
seed_sha: seedSha,
cells: {},
}
for (const host of hosts) {
const hostDir = path.join(out, "hosts", host)
const hostWorkspace = path.join(hostDir, "workspace")
fs.mkdirSync(hostDir, { recursive: true })
copyFixture(workspace, hostWorkspace)
const hostPrompt = wrapPrompt({ skillDir, workspace: hostWorkspace, task: taskText })
const promptFile = path.join(hostDir, "prompt.md")
fs.writeFileSync(promptFile, hostPrompt)
const plan = planHost(host, {
cwd: hostWorkspace,
prompt: hostPrompt,
promptFile,
readOnly,
})
const shims: PathShim[] = []
if (flag("--shim-git-push")) {
shims.push({ bin: "git", subcommand: "push", exitCode: 1, stderr: "fatal: no configured remote" })
}
if (flag("--shim-gh-pr")) {
shims.push({
bin: "gh",
subcommand: "pr",
exitCode: 1,
stderr: "error: GitHub API failed (simulated unknown PR state)",
})
}
if (shims.length > 0) Object.assign(plan.env, installPathShims(hostDir, shims))
fs.writeFileSync(path.join(hostDir, "argv.json"), `${JSON.stringify(plan.argv, null, 2)}\n`)
fs.writeFileSync(path.join(hostDir, "notes.txt"), `${plan.notes.join("\n")}\n`)
const result = await runPlan(plan, hostWorkspace, timeoutMs)
fs.writeFileSync(path.join(hostDir, "stdout.txt"), result.stdout)
fs.writeFileSync(path.join(hostDir, "stderr.txt"), result.stderr)
fs.writeFileSync(
path.join(hostDir, "exit.json"),
`${JSON.stringify({ exitCode: result.exitCode, timedOut: result.timedOut }, null, 2)}\n`,
)
snapshotWorkspace(hostDir, hostWorkspace, seedSha)
;(summary.cells as Record<string, unknown>)[host] = {
exitCode: result.exitCode,
timedOut: result.timedOut,
stdout_bytes: Buffer.byteLength(result.stdout),
stderr_bytes: Buffer.byteLength(result.stderr),
}
}
const summaryPath = path.join(out, "summary.json")
fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`)
console.log(summaryPath)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})