SPB Git

spb/autoscholar

Public
29.7 KB · 789 lines typescript
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
Files · 2fe9b3504807bb092981b60f3477155587acec66
Menu

Theme