spb/autoscholar
Public
1import { spawn } from "node:child_process";2import { createHash } from "node:crypto";3import {4 appendFile,5 chmod,6 copyFile,7 cp,8 mkdir,9 readdir,10 readFile,11 realpath,12 rm,13 stat,14 writeFile,15} from "node:fs/promises";16import { join } from "node:path";17import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";18import type { ArtifactKind, RunEventInput, RunManifest, StageKey } from "@autoscholar/schemas";19import { MetricsFile } from "@autoscholar/schemas";20import { syncArtifacts, writeChecksums } from "./artifacts.js";21import { buildLatex, listFiles, renderPages } from "./latex.js";22import {23 createPolicyHook,24 PLATFORM_SYSTEM_APPEND,25 RESEARCH_ALLOWED_TOOLS,26 RESEARCH_DISALLOWED_TOOLS,27 sandboxSettings,28} from "./policy.js";29import { STAGE_SPECS, type StageContext, type StageSpec } from "./stages.js";30import { createResearchTools } from "./tools.js";3132export interface RunnerJob {33 runId: string;34 attemptNo: number;35 manifest: RunManifest;36 instructionText: string;37 resumeFromStage: StageKey | null;38 checkpoint: Record<string, unknown> | null;39}4041export interface RunnerHost {42 workspaceRoot: string;43 apiKey: string;44 extraDomains: string[];45 tectonicBin?: string;46 tectonicCache?: string;47 latexTemplateDir: string;48 workerName: string;49 sdkVersion: string;50 toolchain: Record<string, string>;51 emit: (ev: RunEventInput) => void;52 stage: (report: {53 stage: StageKey;54 status: "running" | "completed" | "failed" | "skipped";55 costUsd: number;56 turns: number;57 usage?: Record<string, unknown>;58 error?: string;59 checkpoint?: Record<string, unknown>;60 }) => Promise<void>;61 upload: (path: string, kind: ArtifactKind, contentType: string, buf: Buffer) => Promise<void>;62 isCancelled: () => boolean;63 /** Optional: fetch the latest artifacts of this run from the control plane (resume on a different node). */64 fetchArtifacts?: () => Promise<Array<{ path: string; read: () => Promise<Buffer> }>>;65}6667export interface RunOutcome {68 outcome: "completed" | "failed" | "cancelled" | "budget_exceeded" | "timeout";69 error?: string;70 costUsd: number;71 turns: number;72 stagesCompleted: StageKey[];73 resourceUsage: { wallSeconds: number; diskBytes: number; peakRssBytes: number };74}7576interface SessionResult {77 costUsd: number;78 turns: number;79 subtype: string;80 resultText: string;81 usage: Record<string, unknown>;82 denied: number;83}8485async function dirSize(dir: string): Promise<number> {86 let total = 0;87 for (const f of await listFiles(dir)) {88 try {89 total += (await stat(join(dir, f))).size;90 } catch {91 /* ignore */92 }93 }94 return total;95}9697export class ResearchRunner {98 private readonly ws: string;99 private spent = 0;100 private turns = 0;101 private readonly started = Date.now();102 private completed: StageKey[] = [];103 private notes: string[] = [];104 private peakRss = 0;105106 constructor(107 private readonly job: RunnerJob,108 private readonly host: RunnerHost,109 ) {110 this.ws = join(host.workspaceRoot, "runs", job.runId);111 }112113 private emit(114 kind: string,115 message: string,116 data?: Record<string, unknown>,117 level: RunEventInput["level"] = "info",118 stage?: StageKey,119 ) {120 const ev: RunEventInput = {121 kind,122 message: message.slice(0, 4000),123 data,124 level,125 stage,126 at: new Date().toISOString(),127 };128 this.host.emit(ev);129 appendFile(130 join(this.ws, "provenance/events.jsonl"),131 `${JSON.stringify({ ...ev, runId: this.job.runId, attemptNo: this.job.attemptNo })}\n`,132 ).catch(() => {});133 const rss = process.memoryUsage().rss;134 if (rss > this.peakRss) this.peakRss = rss;135 }136137 private remainingBudget(): number {138 return this.job.manifest.maxBudgetUsd - this.spent;139 }140 private remainingMinutes(): number {141 return this.job.manifest.maxRuntimeMinutes - (Date.now() - this.started) / 60_000;142 }143144 /** Creates the isolated workspace: frozen CLAUDE.md as project instructions, contract directories, LaTeX class. */145 async prepareWorkspace(): Promise<{ resumed: boolean; startIndex: number }> {146 const ws = this.ws;147 let resumed = false;148 let startIndex = 0;149 const existed = await stat(ws)150 .then((s) => s.isDirectory())151 .catch(() => false);152 const cpStage = (this.job.checkpoint?.stage as StageKey | undefined) ?? this.job.resumeFromStage;153 if (existed && cpStage && STAGE_SPECS.some((s) => s.key === cpStage)) {154 const idx = STAGE_SPECS.findIndex((s) => s.key === cpStage);155 // verify the frozen instruction is intact; otherwise restart from scratch156 const frozen = await readFile(join(ws, "CLAUDE.md"), "utf8").catch(() => "");157 if (createHash("sha256").update(frozen).digest("hex") === this.job.manifest.instructionSha256) {158 resumed = true;159 startIndex = idx + 1;160 this.completed =161 (this.job.checkpoint?.completedStages as StageKey[] | undefined) ??162 STAGE_SPECS.slice(0, idx + 1).map((s) => s.key);163 this.spent = Number(this.job.checkpoint?.spentUsd ?? 0);164 }165 }166 if (!resumed) {167 if (existed) {168 await chmod(join(ws, "input"), 0o755).catch(() => {});169 for (const f of ["CLAUDE.md", "input/CLAUDE.md", "input/run.json"])170 await chmod(join(ws, f), 0o644).catch(() => {});171 await rm(ws, { recursive: true, force: true });172 }173 for (const d of [174 "input",175 "plan",176 "sources",177 "data",178 "code",179 "results/tables",180 "results/figures",181 "manuscript",182 "review",183 "provenance",184 "home/.claude",185 ".autoscholar",186 ".cache/uv",187 ".cache/tmp",188 ])189 await mkdir(join(ws, d), { recursive: true });190 await writeFile(join(ws, "CLAUDE.md"), this.job.instructionText);191 await writeFile(join(ws, "input/CLAUDE.md"), this.job.instructionText);192 await chmod(join(ws, "CLAUDE.md"), 0o444);193 await chmod(join(ws, "input/CLAUDE.md"), 0o444);194 await writeFile(195 join(ws, "input/run.json"),196 `${JSON.stringify({ ...this.job.manifest, workerName: this.host.workerName, attemptNo: this.job.attemptNo, frozenAt: new Date().toISOString() }, null, 2)}\n`,197 );198 await chmod(join(ws, "input/run.json"), 0o444);199 await chmod(join(ws, "input"), 0o555);200 await writeFile(201 join(ws, "home/.claude/settings.json"),202 JSON.stringify({ env: {}, includeCoAuthoredBy: false }, null, 2),203 );204 for (const f of await readdir(this.host.latexTemplateDir).catch(() => [] as string[])) {205 if (/\.(cls|sty|bst|mplstyle)$/.test(f))206 await copyFile(join(this.host.latexTemplateDir, f), join(ws, "manuscript", f));207 }208 await writeFile(join(ws, ".gitignore"), "home/\n.cache/\n.autoscholar/\n");209 }210 this.emit(211 "workspace.ready",212 resumed ? `resumed workspace at stage index ${startIndex}` : "fresh isolated workspace prepared",213 { workspace: ws, resumed, instructionSha256: this.job.manifest.instructionSha256 },214 );215 return { resumed, startIndex };216 }217218 private buildOptions(219 spec: StageSpec,220 maxBudgetUsd: number,221 maxTurns: number,222 abort: AbortController,223 denied: { n: number },224 ) {225 const ws = this.ws;226 const reviewer = !!spec.reviewer;227 const hook = createPolicyHook({228 workspace: ws,229 frozenPaths: ["CLAUDE.md", "input", "provenance", ".gitignore"],230 writableOnly: reviewer ? ["review/"] : undefined,231 onDenied: (tool, reason, input) => {232 denied.n++;233 this.emit("policy.denied", `${tool} denied: ${reason}`, { tool, reason, input }, "warn", spec.key);234 },235 });236 const tools = createResearchTools({237 workspace: ws,238 tectonicBin: this.host.tectonicBin,239 tectonicCache: this.host.tectonicCache,240 emit: (k, m, d) => this.emit(k, m, d, "info", spec.key),241 });242 const reviewerAppend = reviewer243 ? "\n\n# Reviewer session\nYou are the independent referee for this manuscript. You did not author it. You may only write files under review/. Be rigorous, specific and fair; blockers only for fabricated/unverifiable/invalid science."244 : "";245 return {246 cwd: ws,247 model: reviewer ? this.job.manifest.reviewerModel : this.job.manifest.model,248 systemPrompt: {249 type: "preset" as const,250 preset: "claude_code" as const,251 append: PLATFORM_SYSTEM_APPEND + reviewerAppend,252 },253 settingSources: ["project" as const],254 permissionMode: "dontAsk" as const,255 allowedTools: RESEARCH_ALLOWED_TOOLS,256 disallowedTools: RESEARCH_DISALLOWED_TOOLS,257 hooks: { PreToolUse: [{ hooks: [hook] }] },258 sandbox: sandboxSettings(ws, this.host.extraDomains),259 mcpServers: { autoscholar: tools },260 maxBudgetUsd,261 maxTurns,262 abortController: abort,263 persistSession: false,264 includePartialMessages: false,265 env: {266 PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",267 HOME: join(ws, "home"),268 CLAUDE_CONFIG_DIR: join(ws, "home/.claude"),269 ANTHROPIC_API_KEY: this.host.apiKey,270 TMPDIR: join(ws, ".cache/tmp"),271 UV_CACHE_DIR: join(ws, ".cache/uv"),272 TECTONIC_CACHE_DIR: this.host.tectonicCache ?? join(ws, ".cache/tectonic"),273 LANG: "en_US.UTF-8",274 LC_ALL: "en_US.UTF-8",275 SOURCE_DATE_EPOCH: "1700000000",276 MPLBACKEND: "Agg",277 PYTHONDONTWRITEBYTECODE: "1",278 CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",279 DISABLE_TELEMETRY: "1",280 DISABLE_AUTOUPDATER: "1",281 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",282 AUTOSCHOLAR_RUN_ID: this.job.runId,283 },284 stderr: (d: string) => {285 if (/error|fatal|sandbox/i.test(d))286 this.emit("sdk.stderr", d.slice(0, 500), undefined, "debug", spec.key);287 },288 };289 }290291 private async session(292 spec: StageSpec,293 prompt: string,294 limits: { maxBudgetUsd: number; maxTurns: number; timeoutMs: number },295 ): Promise<SessionResult> {296 const abort = new AbortController();297 const denied = { n: 0 };298 const timer = setTimeout(() => abort.abort(new Error("stage wall-clock limit")), limits.timeoutMs);299 const cancelPoll = setInterval(() => {300 if (this.host.isCancelled()) abort.abort(new Error("cancelled"));301 }, 5000);302 const result: SessionResult = {303 costUsd: 0,304 turns: 0,305 subtype: "unknown",306 resultText: "",307 usage: {},308 denied: 0,309 };310 const t0 = Date.now();311 try {312 const q = query({313 prompt,314 options: this.buildOptions(spec, limits.maxBudgetUsd, limits.maxTurns, abort, denied),315 });316 let toolCalls = 0;317 for await (const msg of q as AsyncIterable<SDKMessage>) {318 if (msg.type === "system" && (msg as { subtype?: string }).subtype === "init") {319 const m = msg as unknown as {320 model?: string;321 claude_code_version?: string;322 tools?: string[];323 mcp_servers?: Array<{ name: string; status: string }>;324 };325 this.emit(326 "session.init",327 `session started (${m.model ?? "model"}; claude code ${m.claude_code_version ?? "?"})`,328 { tools: m.tools?.length, mcp: m.mcp_servers },329 "debug",330 spec.key,331 );332 } else if (msg.type === "assistant") {333 const content =334 (335 msg as unknown as {336 message: {337 content: Array<{338 type: string;339 text?: string;340 name?: string;341 input?: Record<string, unknown>;342 }>;343 };344 }345 ).message.content ?? [];346 for (const c of content) {347 if (c.type === "text" && c.text?.trim())348 this.emit("assistant.text", c.text.slice(0, 600), undefined, "debug", spec.key);349 if (c.type === "tool_use") {350 toolCalls++;351 const summary =352 c.name === "Bash"353 ? String(c.input?.command ?? "").slice(0, 200)354 : c.name === "Write" || c.name === "Edit" || c.name === "Read"355 ? String(c.input?.file_path ?? "")356 : c.name?.startsWith("mcp__")357 ? JSON.stringify(c.input).slice(0, 200)358 : "";359 this.emit("tool.use", `${c.name}: ${summary}`, { tool: c.name }, "debug", spec.key);360 }361 }362 } else if (msg.type === "result") {363 const r = msg as unknown as {364 subtype: string;365 total_cost_usd?: number;366 num_turns?: number;367 result?: string;368 usage?: Record<string, unknown>;369 modelUsage?: Record<string, unknown>;370 errors?: string[];371 };372 result.subtype = r.subtype;373 result.costUsd = Number(r.total_cost_usd ?? 0);374 result.turns = Number(r.num_turns ?? 0);375 result.resultText = (r.result ?? (r.errors ?? []).join("; ")).slice(0, 4000);376 const u = (r.usage ?? {}) as Record<string, number>;377 result.usage = {378 model: spec.reviewer ? this.job.manifest.reviewerModel : this.job.manifest.model,379 inputTokens: u.input_tokens ?? 0,380 outputTokens: u.output_tokens ?? 0,381 cacheReadTokens: u.cache_read_input_tokens ?? 0,382 cacheCreateTokens: u.cache_creation_input_tokens ?? 0,383 modelUsage: r.modelUsage,384 toolCalls,385 };386 }387 }388 } catch (e) {389 result.subtype = abort.signal.aborted390 ? `aborted:${(abort.signal.reason as Error)?.message ?? "abort"}`391 : `error:${(e as Error).message.slice(0, 300)}`;392 } finally {393 clearTimeout(timer);394 clearInterval(cancelPoll);395 }396 result.denied = denied.n;397 this.spent += result.costUsd;398 this.turns += result.turns;399 this.emit(400 "session.end",401 `${spec.key} session ${result.subtype}: ${result.turns} turns, $${result.costUsd.toFixed(3)}, ${Math.round((Date.now() - t0) / 1000)}s, ${denied.n} policy denials`,402 { subtype: result.subtype, costUsd: result.costUsd, turns: result.turns, denied: denied.n },403 "info",404 spec.key,405 );406 return result;407 }408409 private async stageContext(extra: Partial<StageContext> = {}): Promise<StageContext> {410 return {411 filesPresent: await listFiles(this.ws),412 previousStageNotes: this.notes.slice(-6).join("\n"),413 ...extra,414 };415 }416417 private async syncAndCheckpoint(418 stage: StageKey,419 status: "completed" | "failed",420 cost: number,421 turns: number,422 usage?: Record<string, unknown>,423 error?: string,424 ) {425 await syncArtifacts(this.ws, stage, this.host.upload, (k, m, d) => this.emit(k, m, d, "info", stage));426 if (status === "completed") this.completed.push(stage);427 await this.host.stage({428 stage,429 status,430 costUsd: cost,431 turns,432 usage,433 error,434 checkpoint:435 status === "completed"436 ? { stage, completedStages: this.completed, spentUsd: this.spent, at: new Date().toISOString() }437 : undefined,438 });439 }440441 /** Platform-side reproduction (stage 8, before the agent's revision session). */442 private async reproduce(): Promise<Record<string, unknown>> {443 const out: Record<string, unknown> = { at: new Date().toISOString() };444 const manuscript = join(this.ws, "manuscript");445 const cleanDir = join(this.ws, "_repro/manuscript");446 await rm(join(this.ws, "_repro"), { recursive: true, force: true });447 await mkdir(cleanDir, { recursive: true });448 for (const f of await readdir(manuscript))449 if (/\.(tex|bib|cls|sty|bst|pdf|png|jpg|csv)$/.test(f) && f !== "main.pdf")450 await cp(join(manuscript, f), join(cleanDir, f), { recursive: true });451 for (const d of ["results"])452 if (await stat(join(this.ws, d)).catch(() => null))453 await cp(join(this.ws, d), join(this.ws, "_repro", d), { recursive: true });454 const build = await buildLatex(cleanDir, {455 tectonicBin: this.host.tectonicBin,456 cacheDir: this.host.tectonicCache,457 });458 out.cleanPdfBuild = {459 ok: build.ok,460 pages: build.pages,461 durationMs: build.durationMs,462 logTail: build.log.slice(-1500),463 };464 if (build.ok && build.pdfPath) {465 const a = createHash("sha256")466 .update(await readFile(build.pdfPath))467 .digest("hex");468 out.cleanPdfSha256 = a;469 }470 const script = join(this.ws, "code/reproduce.sh");471 if (await stat(script).catch(() => null)) {472 const reproDir = join(this.ws, "_repro/work");473 await mkdir(reproDir, { recursive: true });474 for (const d of ["code", "data"])475 if (await stat(join(this.ws, d)).catch(() => null))476 await cp(join(this.ws, d), join(reproDir, d), {477 recursive: true,478 filter: (s) => !/\/(\.venv|node_modules|__pycache__)(\/|$)/.test(s),479 });480 await mkdir(join(reproDir, "results/tables"), { recursive: true });481 await mkdir(join(reproDir, "results/figures"), { recursive: true });482 await mkdir(join(reproDir, "home"), { recursive: true });483 // Seatbelt matches canonical paths (/private/var/...): resolve every path and allow both spellings484 const real = async (p: string) => realpath(p).catch(() => p);485 const reproReal = await real(reproDir);486 const wsReal = await real(this.ws);487 const cacheDir = this.host.tectonicCache ?? join(this.ws, ".cache/tectonic");488 const cacheReal = await real(cacheDir);489 const both = (p: string, q: string) => [...new Set([p, q])].map((x) => `(subpath "${x}")`).join(" ");490 const profile = [491 "(version 1)",492 "(allow default)",493 "(deny file-write*)",494 `(allow file-write* ${both(reproDir, reproReal)} (subpath "/private/tmp") (subpath "/tmp") (subpath "/private/var/folders") (literal "/dev/null") (regex #"^/dev/tty") ${both(cacheDir, cacheReal)} ${both(join(this.ws, ".cache"), join(wsReal, ".cache"))})`,495 `(deny file-read* (subpath "/Users") (subpath "/etc/autoscholar") (subpath "/private/etc/autoscholar") (subpath "/var/autoscholar/runs") (subpath "/private/var/autoscholar/runs"))`,496 `(allow file-read* ${both(reproDir, reproReal)} ${both(join(this.ws, ".cache"), join(wsReal, ".cache"))} ${both(cacheDir, cacheReal)})`,497 "",498 ].join("\n");499 const profilePath = join(this.ws, "_repro/repro.sb");500 await writeFile(profilePath, profile);501 const t0 = Date.now();502 const res = await new Promise<{ code: number | null; log: string }>((resolve) => {503 const child = spawn("/usr/bin/sandbox-exec", ["-f", profilePath, "/bin/bash", "code/reproduce.sh"], {504 cwd: reproDir,505 env: {506 PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",507 HOME: join(reproDir, "home"),508 TMPDIR: "/tmp",509 UV_CACHE_DIR: join(this.ws, ".cache/uv"),510 MPLBACKEND: "Agg",511 LANG: "en_US.UTF-8",512 PYTHONDONTWRITEBYTECODE: "1",513 },514 stdio: ["ignore", "pipe", "pipe"],515 });516 let log = "";517 const cap = (d: Buffer) => {518 log += d.toString();519 if (log.length > 200_000) log = log.slice(-150_000);520 };521 child.stdout.on("data", cap);522 child.stderr.on("data", cap);523 const timer = setTimeout(() => child.kill("SIGKILL"), 40 * 60_000);524 child.on("close", (code) => {525 clearTimeout(timer);526 resolve({ code, log });527 });528 child.on("error", (e) => resolve({ code: -1, log: `spawn error: ${e.message}` }));529 });530 out.reproduceScript = {531 ran: res.code !== -1,532 exitCode: res.code,533 durationMs: Date.now() - t0,534 logTail: res.log.slice(-3000),535 };536 try {537 const orig = MetricsFile.parse(538 JSON.parse(await readFile(join(this.ws, "results/metrics.json"), "utf8")),539 );540 const regen = MetricsFile.parse(541 JSON.parse(await readFile(join(reproDir, "results/metrics.json"), "utf8")),542 );543 const compared: Array<{ key: string; original: unknown; reproduced: unknown; match: boolean }> = [];544 for (const [k, v] of Object.entries(orig.metrics)) {545 const r = regen.metrics[k];546 const match =547 typeof v === "number" && typeof r === "number"548 ? Math.abs(v - r) <= Math.max(1e-9, Math.abs(v) * 1e-6)549 : JSON.stringify(v) === JSON.stringify(r);550 compared.push({ key: k, original: v, reproduced: r, match });551 }552 out.metricsCompared = compared.length;553 out.metricsMismatched = compared.filter((c) => !c.match).slice(0, 25);554 out.metricsMissingInRerun = compared.filter((c) => c.reproduced === undefined).length;555 } catch (e) {556 out.metricsComparison = `not compared: ${(e as Error).message.slice(0, 200)}`;557 }558 } else out.reproduceScript = { ran: false, reason: "code/reproduce.sh not present" };559 await mkdir(join(this.ws, "results"), { recursive: true });560 await writeFile(join(this.ws, "results/reproduction.json"), `${JSON.stringify(out, null, 2)}\n`);561 return out;562 }563564 async run(): Promise<RunOutcome> {565 const { startIndex } = await this.prepareWorkspace();566 const finish = async (outcome: RunOutcome["outcome"], error?: string): Promise<RunOutcome> => {567 await this.finalize(outcome, error);568 return {569 outcome,570 error,571 costUsd: this.spent,572 turns: this.turns,573 stagesCompleted: this.completed,574 resourceUsage: {575 wallSeconds: (Date.now() - this.started) / 1000,576 diskBytes: await dirSize(this.ws),577 peakRssBytes: this.peakRss,578 },579 };580 };581 const total = this.job.manifest.maxBudgetUsd;582 for (let i = startIndex; i < STAGE_SPECS.length; i++) {583 const spec = STAGE_SPECS[i]!;584 if (this.host.isCancelled()) return finish("cancelled", "cancellation requested");585 if (this.remainingBudget() < Math.max(0.25, total * 0.02))586 return finish(587 "budget_exceeded",588 `budget exhausted before ${spec.key} ($${this.spent.toFixed(2)} of $${total})`,589 );590 if (this.remainingMinutes() < 2) return finish("timeout", `runtime cap reached before ${spec.key}`);591 await this.host.stage({ stage: spec.key, status: "running", costUsd: 0, turns: 0 });592 this.emit("stage.start", `stage ${i}: ${spec.key}`, undefined, "info", spec.key);593 const stageBudget = Math.min(this.remainingBudget() * 0.9, Math.max(1, total * spec.budgetShare * 1.6));594 const stageMinutes = Math.min(595 this.remainingMinutes() * 0.9,596 Math.max(5, this.job.manifest.maxRuntimeMinutes * spec.wallMinutesShare * 1.8),597 );598 let ctx: StageContext;599 if (spec.key === "reproduction") {600 this.emit(601 "reproduction.start",602 "platform re-executing analysis and clean LaTeX build",603 undefined,604 "info",605 spec.key,606 );607 const repro = await this.reproduce();608 const pdf = join(this.ws, "manuscript/main.pdf");609 let previews: string[] = [];610 if (await stat(pdf).catch(() => null)) {611 const pages = Number((repro.cleanPdfBuild as { pages?: number })?.pages ?? 0) || 4;612 const picks = [...new Set([1, 2, Math.max(1, Math.floor(pages / 2)), pages])].filter(613 (p) => p >= 1 && p <= pages,614 );615 previews = await renderPages(pdf, join(this.ws, "review/previews"), picks);616 this.emit(617 "reproduction.previews",618 previews.length619 ? `rendered ${previews.length} page previews`620 : "page previews unavailable (pdftoppm missing)",621 { previews: previews.map((p) => p.replace(`${this.ws}/`, "")) },622 "info",623 spec.key,624 );625 }626 ctx = await this.stageContext({627 reproduction: repro,628 previewFiles: previews.map((p) => p.replace(`${this.ws}/`, "")),629 });630 } else ctx = await this.stageContext();631 let res = await this.session(spec, spec.prompt(this.job.manifest, ctx), {632 maxBudgetUsd: stageBudget,633 maxTurns: spec.maxTurns,634 timeoutMs: stageMinutes * 60_000,635 });636 if (res.subtype.startsWith("aborted:cancelled"))637 return finish("cancelled", `cancelled during ${spec.key}`);638 if (res.subtype === "error_max_budget_usd" && this.remainingBudget() < 1) {639 await this.syncAndCheckpoint(640 spec.key,641 "failed",642 res.costUsd,643 res.turns,644 res.usage,645 "budget exhausted",646 );647 return finish("budget_exceeded", `budget exhausted during ${spec.key}`);648 }649 // manuscript stage: the platform guarantees a PDF build attempt650 if (spec.key === "manuscript" || spec.key === "reproduction") {651 const b = await buildLatex(join(this.ws, "manuscript"), {652 tectonicBin: this.host.tectonicBin,653 cacheDir: this.host.tectonicCache,654 });655 this.emit(656 "latex.build",657 b.ok ? `platform build ok: ${b.pages} pages` : "platform build failed",658 { ok: b.ok, pages: b.pages, log: b.log.slice(-1200) },659 b.ok ? "info" : "warn",660 spec.key,661 );662 if (!b.ok && this.remainingBudget() > 1 && !this.host.isCancelled()) {663 const fix = await this.session(664 spec,665 `${spec.prompt(this.job.manifest, await this.stageContext())}\n\n# BUILD FAILED — fix it\nThe platform's clean tectonic build of manuscript/main.tex failed. Log:\n\`\`\`\n${b.log.slice(-6000)}\n\`\`\`\nFix the LaTeX (or bibliography) and call latex_build until it succeeds. Do not remove scientific content to make it compile.`,666 { maxBudgetUsd: Math.min(this.remainingBudget() * 0.5, 4), maxTurns: 60, timeoutMs: 20 * 60_000 },667 );668 res = {669 ...res,670 costUsd: res.costUsd + fix.costUsd,671 turns: res.turns + fix.turns,672 denied: res.denied + fix.denied,673 };674 const b2 = await buildLatex(join(this.ws, "manuscript"), {675 tectonicBin: this.host.tectonicBin,676 cacheDir: this.host.tectonicCache,677 });678 this.emit(679 "latex.build",680 b2.ok ? `rebuild ok: ${b2.pages} pages` : "rebuild failed",681 { ok: b2.ok, pages: b2.pages },682 b2.ok ? "info" : "error",683 spec.key,684 );685 if (!b2.ok) {686 await this.syncAndCheckpoint(687 spec.key,688 "failed",689 res.costUsd,690 res.turns,691 res.usage,692 "LaTeX build failed after fix attempt",693 );694 return finish("failed", `LaTeX build failed in ${spec.key}`);695 }696 } else if (!b.ok) {697 await this.syncAndCheckpoint(698 spec.key,699 "failed",700 res.costUsd,701 res.turns,702 res.usage,703 "LaTeX build failed",704 );705 return finish("failed", `LaTeX build failed in ${spec.key}`);706 }707 }708 let problems = await spec.check(this.ws, this.job.manifest);709 if (problems.some((p) => p.startsWith("INFEASIBLE"))) {710 await this.syncAndCheckpoint(711 spec.key,712 "failed",713 res.costUsd,714 res.turns,715 res.usage,716 problems.join("; "),717 );718 return finish(719 "failed",720 problems.find((p) => p.startsWith("INFEASIBLE")),721 );722 }723 if (problems.length && this.remainingBudget() > 1 && !this.host.isCancelled()) {724 this.emit(725 "stage.check",726 `post-conditions not met, running a fix session: ${problems.join("; ")}`,727 { problems },728 "warn",729 spec.key,730 );731 const fix = await this.session(732 spec,733 `${spec.prompt(this.job.manifest, await this.stageContext())}\n\n# INCOMPLETE — the deterministic post-conditions of this stage failed:\n- ${problems.join("\n- ")}\nComplete exactly what is missing (do not redo finished work).`,734 {735 maxBudgetUsd: Math.min(this.remainingBudget() * 0.5, Math.max(1, stageBudget * 0.5)),736 maxTurns: Math.ceil(spec.maxTurns / 2),737 timeoutMs: Math.max(5, stageMinutes * 0.5) * 60_000,738 },739 );740 res = {741 ...res,742 costUsd: res.costUsd + fix.costUsd,743 turns: res.turns + fix.turns,744 denied: res.denied + fix.denied,745 };746 if (fix.subtype.startsWith("aborted:cancelled"))747 return finish("cancelled", `cancelled during ${spec.key}`);748 problems = await spec.check(this.ws, this.job.manifest);749 }750 if (problems.length) {751 await this.syncAndCheckpoint(752 spec.key,753 "failed",754 res.costUsd,755 res.turns,756 res.usage,757 problems.join("; "),758 );759 return finish("failed", `${spec.key}: ${problems.join("; ")}`);760 }761 this.notes.push(`[${spec.key}] ${res.resultText.replace(/\s+/g, " ").slice(0, 400)}`);762 await this.syncAndCheckpoint(spec.key, "completed", res.costUsd, res.turns, res.usage);763 }764 return finish("completed");765 }766767 private async finalize(outcome: RunOutcome["outcome"], error?: string) {768 try {769 const summary = {770 runId: this.job.runId,771 attemptNo: this.job.attemptNo,772 researchMode: this.job.manifest.researchMode,773 stagesCompleted: this.completed,774 costUsd: Number(this.spent.toFixed(4)),775 turns: this.turns,776 model: this.job.manifest.model,777 reviewerModel: this.job.manifest.reviewerModel,778 workerName: this.host.workerName,779 startedAt: new Date(this.started).toISOString(),780 endedAt: new Date().toISOString(),781 sdkVersion: this.host.sdkVersion,782 toolchain: this.host.toolchain,783 synthetic: false,784 outcome,785 error: error ?? null,786 };787 await mkdir(join(this.ws, "provenance"), { recursive: true });788 await writeFile(join(this.ws, "provenance/run-summary.json"), `${JSON.stringify(summary, null, 2)}\n`);789 await rm(join(this.ws, "_repro"), { recursive: true, force: true });790 await writeChecksums(this.ws);791 await syncArtifacts(this.ws, "reproduction", this.host.upload, (k, m, d) => this.emit(k, m, d));792 this.emit(793 "run.finalized",794 `run ${outcome}${error ? `: ${error}` : ""} — $${this.spent.toFixed(3)}, ${this.turns} turns, ${this.completed.length} stages`,795 { outcome, costUsd: this.spent, turns: this.turns },796 outcome === "completed" ? "info" : "warn",797 );798 } catch (e) {799 this.emit("run.finalize_error", (e as Error).message, undefined, "error");800 }801 }802}803804export async function runResearch(job: RunnerJob, host: RunnerHost): Promise<RunOutcome> {805 return new ResearchRunner(job, host).run();806}807