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