SPB Git

spb/autoscholar

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

Theme