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