SPB Git

spb/autoscholar

Public
30.6 KB · 806 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  /** 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
Files · bc25fcca6ef80397d231f256956ca5f0c907288f
Menu

Theme