SPB Git

spb/autoscholar

Public
12.2 KB · 352 lines typescript
1import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";2import { hostname } from "node:os";3import { join, resolve } from "node:path";4import { ControlPlaneError, WorkerClient } from "@autoscholar/maclustr-adapter";5import { createLogger, redact } from "@autoscholar/observability";6import { type RunnerHost, runResearch, simulateResearch } from "@autoscholar/research-runner";7import type { ArtifactKind, RunEventInput, StageKey } from "@autoscholar/schemas";8import { collectNodeMetadata, loadAverage, sdkVersion, workerVersion } from "./node-meta.js";9import { runSelfTest } from "./selftest.js";1011const log = createLogger("worker");1213interface Config {14  controlPlaneUrl: string;15  workerName: string;16  workerRoot: string;17  token?: string;18  enrollmentSecret?: string;19  mode: "real" | "simulated";20  maxConcurrent: number;21  profiles: string[];22  apiKey?: string;23  extraDomains: string[];24  tectonicBin?: string;25  latexTemplateDir: string;26  pollMs: number;27}2829function loadConfig(): Config {30  const e = process.env;31  const workerRoot = e.WORKER_ROOT ?? "/var/autoscholar";32  return {33    controlPlaneUrl: e.CONTROL_PLANE_URL ?? "http://127.0.0.1:8470",34    workerName: e.WORKER_NAME ?? hostname(),35    workerRoot,36    token: e.WORKER_TOKEN || undefined,37    enrollmentSecret: e.WORKER_ENROLLMENT_SECRET || undefined,38    mode: e.WORKER_MODE === "simulated" ? "simulated" : "real",39    maxConcurrent: Number(e.WORKER_MAX_CONCURRENT ?? 1),40    profiles: (e.WORKER_PROFILES ?? "standard")41      .split(",")42      .map((s) => s.trim())43      .filter(Boolean),44    apiKey: e.ANTHROPIC_API_KEY || undefined,45    extraDomains: (e.RESEARCH_ALLOWED_DOMAINS ?? "")46      .split(",")47      .map((s) => s.trim())48      .filter(Boolean),49    tectonicBin: e.TECTONIC_BIN || undefined,50    latexTemplateDir: e.LATEX_TEMPLATE_DIR ?? resolve(workerRoot, "templates/latex"),51    pollMs: Number(e.WORKER_POLL_MS ?? 4000),52  };53}5455class Worker {56  private readonly client: WorkerClient;57  private readonly running = new Map<58    string,59    { fencingToken: number; cancel: boolean; restart?: boolean; events: RunEventInput[] }60  >();61  private stopping = false;62  private draining = false;63  private tokenPath: string;6465  constructor(private readonly cfg: Config) {66    this.client = new WorkerClient({ baseUrl: cfg.controlPlaneUrl, token: cfg.token });67    this.tokenPath = join(cfg.workerRoot, "state/worker-token");68  }6970  async start() {71    await mkdir(join(this.cfg.workerRoot, "runs"), { recursive: true });72    await mkdir(join(this.cfg.workerRoot, "state"), { recursive: true });73    if (!this.cfg.token) {74      try {75        const saved = (await readFile(this.tokenPath, "utf8")).trim();76        if (saved) this.client.setToken(saved);77      } catch {78        /* none */79      }80    }81    const node = collectNodeMetadata({82      simulated: this.cfg.mode === "simulated",83      profiles: this.cfg.profiles,84    });85    if (this.cfg.mode === "real" && !node.sandboxAvailable)86      log.error(87        "sandbox-exec not available: this worker will never be dispatched research (sandboxAvailable=false)",88      );89    if (this.cfg.mode === "real" && !this.cfg.apiKey)90      log.error("ANTHROPIC_API_KEY missing: research sessions cannot start");91    // enroll if needed92    let enrolled = false;93    for (let i = 0; i < 30 && !enrolled && !this.stopping; i++) {94      try {95        await this.client.heartbeat({96          status: "online",97          capacity: this.cfg.maxConcurrent,98          running: [],99          load: loadAverage(),100          node,101          leases: [],102        });103        enrolled = true;104      } catch (e) {105        if (e instanceof ControlPlaneError && e.status === 401 && this.cfg.enrollmentSecret) {106          try {107            const r = await this.client.enroll(this.cfg.enrollmentSecret, this.cfg.workerName, node);108            this.client.setToken(r.token);109            await writeFile(this.tokenPath, r.token, { mode: 0o600 });110            await chmod(this.tokenPath, 0o600);111            log.info({ workerId: r.workerId }, "enrolled with control plane");112            enrolled = true;113          } catch (e2) {114            log.error({ err: (e2 as Error).message }, "enrollment failed");115          }116        } else log.warn({ err: (e as Error).message, attempt: i }, "control plane unreachable; retrying");117        if (!enrolled) await new Promise((r) => setTimeout(r, 5000));118      }119    }120    if (!enrolled) throw new Error("could not reach/enroll with the control plane");121    log.info(122      {123        name: this.cfg.workerName,124        mode: this.cfg.mode,125        sdk: sdkVersion(),126        worker: workerVersion(),127        controlPlane: this.cfg.controlPlaneUrl,128        sandbox: node.sandboxAvailable,129      },130      "worker online",131    );132    void this.heartbeatLoop(node);133    void this.leaseLoop();134  }135136  private async heartbeatLoop(node: ReturnType<typeof collectNodeMetadata>) {137    while (!this.stopping) {138      try {139        const leases = [...this.running.entries()].map(([runId, r]) => ({140          runId,141          fencingToken: r.fencingToken,142        }));143        const hb = await this.client.heartbeat({144          status: this.draining ? "draining" : "online",145          capacity: this.cfg.maxConcurrent,146          running: [...this.running.keys()],147          load: loadAverage(),148          leases,149          node: { tools: node.tools, sandboxAvailable: node.sandboxAvailable },150        });151        this.draining = hb.drain;152        for (const id of hb.cancel) {153          const r = this.running.get(id);154          if (r) r.cancel = true;155        }156        for (const id of hb.revokedLeases) {157          const r = this.running.get(id);158          if (r) {159            log.warn({ runId: id }, "lease revoked by control plane (fencing) — aborting local work");160            r.cancel = true;161          }162        }163        await this.flushEvents();164      } catch (e) {165        log.warn({ err: (e as Error).message }, "heartbeat failed");166      }167      await new Promise((r) => setTimeout(r, 20_000));168    }169  }170171  private async flushEvents() {172    for (const [runId, r] of this.running) {173      if (r.events.length === 0) continue;174      const batch = r.events.splice(0, 200);175      try {176        await this.client.events(177          runId,178          r.fencingToken,179          batch.map((e) => ({ ...e, level: e.level ?? "info" })),180        );181      } catch (e) {182        if (e instanceof ControlPlaneError && e.status === 409) {183          r.cancel = true;184          log.warn({ runId }, "lease lost while posting events; cancelling local work");185        } else r.events.unshift(...batch.slice(0, 50));186      }187    }188  }189190  private async leaseLoop() {191    while (!this.stopping) {192      if (!this.draining && this.running.size < this.cfg.maxConcurrent) {193        try {194          const { job } = await this.client.lease(this.cfg.profiles);195          if (job) {196            log.info({ runId: job.runId, attempt: job.attemptNo, resume: job.resumeFromStage }, "leased job");197            void this.execute(job);198          }199        } catch (e) {200          log.warn({ err: (e as Error).message }, "lease request failed");201        }202      }203      await new Promise((r) => setTimeout(r, this.cfg.pollMs));204    }205  }206207  private async execute(job: Awaited<ReturnType<WorkerClient["lease"]>>["job"] & object) {208    const state = {209      fencingToken: job.fencingToken,210      cancel: job.cancelRequested,211      events: [] as RunEventInput[],212    };213    this.running.set(job.runId, state);214    const renew = setInterval(async () => {215      try {216        const r = await this.client.renew(job.runId, job.fencingToken);217        if (r.cancelRequested) state.cancel = true;218        await this.flushEvents();219      } catch (e) {220        if (e instanceof ControlPlaneError && e.status === 409) {221          state.cancel = true;222          log.warn({ runId: job.runId }, "lease lost (stale fencing token); aborting");223        }224      }225    }, 30_000);226    const host: RunnerHost = {227      workspaceRoot: this.cfg.workerRoot,228      apiKey: this.cfg.apiKey ?? "",229      extraDomains: this.cfg.extraDomains,230      tectonicBin: this.cfg.tectonicBin,231      tectonicCache: join(this.cfg.workerRoot, "cache/tectonic"),232      latexTemplateDir: this.cfg.latexTemplateDir,233      workerName: this.cfg.workerName,234      sdkVersion: sdkVersion(),235      toolchain: collectNodeMetadata({236        simulated: this.cfg.mode === "simulated",237        profiles: this.cfg.profiles,238      }).tools,239      emit: (ev) => {240        state.events.push(redact(ev));241        if (state.events.length >= 50) void this.flushEvents();242      },243      stage: async (report) => {244        await this.flushEvents();245        await this.client.stage(job.runId, { fencingToken: job.fencingToken, ...report });246      },247      upload: async (path: string, kind: ArtifactKind, contentType: string, buf: Buffer) => {248        await this.client.uploadArtifact(249          job.runId,250          job.fencingToken,251          {252            path,253            kind,254            contentType,255            stage: (job.resumeFromStage ?? "preflight") as StageKey,256            synthetic: this.cfg.mode === "simulated",257          },258          buf,259        );260      },261      isCancelled: () => state.cancel || this.stopping,262    };263    let outcome: Awaited<ReturnType<typeof runResearch>>;264    try {265      const runnerJob = {266        runId: job.runId,267        attemptNo: job.attemptNo,268        manifest: job.manifest,269        instructionText: job.instructionText,270        resumeFromStage: job.resumeFromStage,271        checkpoint: job.checkpoint,272      };273      if (this.cfg.mode === "simulated") {274        const sim = process.env.WORKER_SIM_BEHAVIOUR ?? "";275        outcome = await simulateResearch(runnerJob, host, {276          failAtStage: sim.startsWith("fail:") ? sim.slice(5) : undefined,277          hang: sim === "hang",278          delayMs: Number(process.env.WORKER_SIM_DELAY_MS ?? 0),279        });280      } else {281        if (!this.cfg.apiKey) throw new Error("ANTHROPIC_API_KEY not configured on this worker");282        outcome = await runResearch(runnerJob, host);283      }284      if (state.restart && outcome.outcome === "cancelled") {285        outcome = {286          ...outcome,287          outcome: "failed",288          error: "WORKER_RESTART: worker daemon restarted; resume from checkpoint",289        };290      }291    } catch (e) {292      outcome = {293        outcome: "failed",294        error: `runner crashed: ${(e as Error).message}`.slice(0, 2000),295        costUsd: 0,296        turns: 0,297        stagesCompleted: [],298        resourceUsage: { wallSeconds: 0, diskBytes: 0, peakRssBytes: 0 },299      };300      log.error(301        { runId: job.runId, err: (e as Error).message, stack: (e as Error).stack?.split("\n").slice(0, 5) },302        "runner crashed",303      );304    }305    clearInterval(renew);306    await this.flushEvents();307    try {308      await this.client.complete(job.runId, {309        fencingToken: job.fencingToken,310        outcome: outcome.outcome,311        error: outcome.error,312        costUsd: outcome.costUsd,313        summary: { stagesCompleted: outcome.stagesCompleted, turns: outcome.turns },314        resourceUsage: outcome.resourceUsage,315      });316      log.info({ runId: job.runId, outcome: outcome.outcome, costUsd: outcome.costUsd }, "attempt completed");317    } catch (e) {318      log.error({ runId: job.runId, err: (e as Error).message }, "could not report completion (lease lost?)");319    }320    this.running.delete(job.runId);321  }322323  async stop() {324    this.stopping = true;325    // graceful restart: abort sessions (isCancelled() → true via `stopping`) but report a retryable failure, not a cancel326    for (const r of this.running.values()) r.restart = true;327    const t0 = Date.now();328    while (this.running.size > 0 && Date.now() - t0 < 60_000) await new Promise((r) => setTimeout(r, 500));329  }330}331332const cfg = loadConfig();333if (process.argv[2] === "selftest") {334  runSelfTest(cfg.workerRoot, cfg.apiKey, cfg.latexTemplateDir, cfg.tectonicBin)335    .then((r) => {336      console.log(JSON.stringify(r, null, 2));337      process.exit(r.ok ? 0 : 1);338    })339    .catch((e) => {340      console.error(e);341      process.exit(1);342    });343} else {344  const w = new Worker(cfg);345  process.on("SIGTERM", () => void w.stop().then(() => process.exit(0)));346  process.on("SIGINT", () => void w.stop().then(() => process.exit(0)));347  w.start().catch((e) => {348    log.fatal({ err: (e as Error).message }, "worker failed to start");349    process.exit(1);350  });351}352
Files · 0d8280a29f83e41fbeda1fded3fa94709eba2e7d
Menu

Theme