SPB Git

spb/autoscholar

Public
5.0 KB · 150 lines typescript
1import {2  appendEvent,3  expireStaleLeases,4  getEnv,5  markOfflineWorkers,6  requeueRetrying,7  transition,8} from "@autoscholar/core";9import { type Db, researchRuns } from "@autoscholar/db";10import type { Logger } from "@autoscholar/observability";11import { publishRun } from "@autoscholar/publisher";12import { evaluateRun } from "@autoscholar/qa";13import type { FilesystemStorage } from "@autoscholar/storage";14import { and, eq, inArray, lt, sql } from "drizzle-orm";1516export interface TickReport {17  expiredLeases: string[];18  requeued: number;19  offlineWorkers: number;20  evaluated: string[];21  published: string[];22  timedOut: string[];23  errors: Array<{ runId?: string; step: string; error: string }>;24}2526/** One scheduler pass. Every step is idempotent, so overlapping or restarted schedulers are safe. */27export async function schedulerTick(db: Db, storage: FilesystemStorage, log: Logger): Promise<TickReport> {28  const env = getEnv();29  const report: TickReport = {30    expiredLeases: [],31    requeued: 0,32    offlineWorkers: 0,33    evaluated: [],34    published: [],35    timedOut: [],36    errors: [],37  };38  try {39    report.expiredLeases = await expireStaleLeases(db);40  } catch (e) {41    report.errors.push({ step: "expireStaleLeases", error: (e as Error).message });42  }43  try {44    report.requeued = await requeueRetrying(db);45  } catch (e) {46    report.errors.push({ step: "requeueRetrying", error: (e as Error).message });47  }48  try {49    report.offlineWorkers = await markOfflineWorkers(db);50  } catch (e) {51    report.errors.push({ step: "markOfflineWorkers", error: (e as Error).message });52  }53  // runtime budget enforced outside the LLM: request cancellation when wall clock exceeds the cap (+10% grace)54  try {55    const running = await db56      .select()57      .from(researchRuns)58      .where(59        and(60          inArray(researchRuns.status, ["running", "provisioning"]),61          eq(researchRuns.cancelRequested, false),62        ),63      );64    for (const r of running) {65      if (!r.startedAt) continue;66      const limitMs = r.maxRuntimeMinutes * 60_000 * 1.1;67      if (Date.now() - r.startedAt.getTime() > limitMs) {68        await db.transaction(async (tx) => {69          await tx70            .update(researchRuns)71            .set({ cancelRequested: true, updatedAt: new Date() })72            .where(eq(researchRuns.id, r.id));73          await transition(tx, r.id, "cancelling", {74            actor: "scheduler",75            reason: `runtime cap ${r.maxRuntimeMinutes} min exceeded`,76          });77        });78        report.timedOut.push(r.id);79      }80    }81  } catch (e) {82    report.errors.push({ step: "runtimeCap", error: (e as Error).message });83  }84  // Stage 9: quality decisions85  const toEvaluate = await db86    .select({ id: researchRuns.id })87    .from(researchRuns)88    .where(eq(researchRuns.status, "quality_check"));89  for (const r of toEvaluate) {90    try {91      const { nextStatus } = await evaluateRun(db, storage, r.id);92      report.evaluated.push(`${r.id}:${nextStatus}`);93    } catch (e) {94      report.errors.push({ runId: r.id, step: "evaluate", error: (e as Error).message });95      log.error({ runId: r.id, err: (e as Error).message }, "quality evaluation crashed");96      try {97        await db.transaction((tx) =>98          appendEvent(tx, r.id, {99            kind: "qa.error",100            level: "error",101            message: `quality evaluation error: ${(e as Error).message}`.slice(0, 2000),102            at: new Date().toISOString(),103          }),104        );105      } catch {106        /* ignore */107      }108    }109  }110  // Stage 10: publication for runs in `publishing` (auto-publish or admin-approved); retried every tick until done111  if (env.PUBLISHING_ENABLED === 1) {112    const toPublish = await db113      .select({ id: researchRuns.id })114      .from(researchRuns)115      .where(eq(researchRuns.status, "publishing"));116    for (const r of toPublish) {117      try {118        const res = await publishRun(db, storage, r.id, { actor: { type: "system" } });119        report.published.push(`${r.id}:${res.publicId}`);120      } catch (e) {121        report.errors.push({ runId: r.id, step: "publish", error: (e as Error).message });122        log.error({ runId: r.id, err: (e as Error).message }, "publication failed; will retry");123      }124    }125  }126  // stuck cancelling runs without lease for too long → cancelled127  try {128    const stuck = await db129      .select({ id: researchRuns.id })130      .from(researchRuns)131      .where(132        and(133          eq(researchRuns.status, "cancelling"),134          lt(researchRuns.updatedAt, new Date(Date.now() - 3 * env.LEASE_TTL_SECONDS * 1000)),135          sql`not exists (select 1 from job_leases l where l.run_id = ${researchRuns.id})`,136        ),137      );138    for (const r of stuck)139      await db.transaction((tx) =>140        transition(tx, r.id, "cancelled", {141          actor: "scheduler",142          reason: "no lease held; cancellation finalized",143        }),144      );145  } catch (e) {146    report.errors.push({ step: "finalizeCancel", error: (e as Error).message });147  }148  return report;149}150
Files · 0d8280a29f83e41fbeda1fded3fa94709eba2e7d
Menu

Theme