Files
UHD_Media_Tool/apps/server/src/modules/schedules/scheduler.ts
T
rootandClaude Opus 5.5 4480fc346d refactor(copy): 术语统一——收录版本、模型服务、智能体等(DEAI-2)
- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示)
- Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话
- 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别
- 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务;
  哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS
- 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:35:43 +08:00

327 lines
12 KiB
TypeScript

import { SCHEDULE_CHANGED_EVENT } from "@umt/contracts";
import { type DB, type Kysely, type ScheduledJobRow, sql } from "@umt/db";
import type { Logger } from "pino";
import type { EventBusPort } from "../../http/sse.ts";
import type { ServerContext } from "../types.ts";
import { runAgentCheck } from "./agent-checks.ts";
import type { JobRunFn, JobRunResult, ScheduleRegistry } from "./registry.ts";
import { computeNext, LOCK_NS, parseSpec, slotOf, syncDefinitions, trimRuns } from "./store.ts";
// 调度器:只在 io worker(或 all)运行;多个实例用 pg_try_advisory_lock 选出唯一执行者(锁在一条专用连接上,
// 连接断开即释放,其它实例下一轮接手)。执行者每 tickMs 查一次到期(enabled 且 next_run_at 已到)或被请求
// 立即执行(run_requested_at)的任务,逐个异步执行(不同任务并行,同一任务不重入),结果写回表与运行历史。
// interval 任务的下次时间 = 本次结束 + 周期(与原来的自调度循环一致);cron 任务取下一个匹配时间。
// 立即执行不改下次运行时间。
export interface SchedulerOptions {
/** 查到期任务的间隔(缺省 1s) */
tickMs?: number;
/** 抢锁失败后的重试间隔(缺省 5s) */
lockRetryMs?: number;
/** 回填巡检 Agent 结果的间隔(缺省 15s) */
harvestMs?: number;
/** 停止时等待进行中任务的上限(缺省 10s) */
stopWaitMs?: number;
now?: () => Date;
}
const TERMINAL_AGENT = ["succeeded", "failed", "cancelled", "budget_exceeded"];
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err)).slice(0, 2000);
export class Scheduler {
private stopped = false;
private wake: (() => void) | null = null;
private loop: Promise<void> | null = null;
private inflight = new Map<string, Promise<void>>();
private syncedVersion = -1;
private lastHarvest = 0;
leader = false;
private readonly db: Kysely<DB>;
private readonly log: Logger;
private readonly events: EventBusPort;
private readonly now: () => Date;
constructor(
private readonly ctx: ServerContext,
private readonly registry: ScheduleRegistry,
private readonly opts: SchedulerOptions = {},
) {
this.db = ctx.db;
this.log = ctx.logger.child({ component: "scheduler" });
this.events = ctx.events;
this.now = opts.now ?? (() => new Date());
}
start(): void {
if (this.loop) return;
this.stopped = false;
this.loop = this.leaderLoop();
}
async stop(): Promise<void> {
this.stopped = true;
this.wake?.();
await this.loop;
this.loop = null;
const pending = [...this.inflight.values()];
if (pending.length) {
await Promise.race([Promise.allSettled(pending), this.sleep(this.opts.stopWaitMs ?? 10_000, true)]);
}
}
/** 可被 stop 打断的等待 */
private sleep(ms: number, ignoreStop = false): Promise<void> {
return new Promise((resolve) => {
const t = setTimeout(done, ms);
function done() {
clearTimeout(t);
resolve();
}
if (!ignoreStop) this.wake = done;
});
}
private async leaderLoop(): Promise<void> {
const retry = this.opts.lockRetryMs ?? 5000;
while (!this.stopped) {
try {
await this.db.connection().execute(async (conn) => {
while (!this.stopped) {
const r = await sql<{ ok: boolean }>`
select pg_try_advisory_lock(${LOCK_NS}::int4, hashtext(current_schema())) as ok`.execute(conn);
if (r.rows[0]?.ok) break;
await this.sleep(retry);
}
if (this.stopped) return;
this.leader = true;
this.log.info("调度器取得执行权");
try {
await this.recoverInterrupted();
while (!this.stopped) {
await this.tick();
await this.sleep(this.opts.tickMs ?? 1000);
// 探活:专用连接断了锁也就没了,抛错后重新抢锁
await sql`select 1`.execute(conn);
}
} finally {
this.leader = false;
await sql`select pg_advisory_unlock(${LOCK_NS}::int4, hashtext(current_schema()))`
.execute(conn)
.catch(() => undefined);
}
});
} catch (err) {
this.leader = false;
this.log.warn({ err: { message: errText(err) } }, "调度器循环出错,稍后重试");
if (!this.stopped) await this.sleep(retry);
}
}
}
/** 上一个执行者中断时留下的 running 状态:标为失败 */
private async recoverInterrupted(): Promise<void> {
const msg = "调度器重启,上次运行中断";
await this.db
.updateTable("scheduled_job_run")
.set({ status: "failed", error: msg, finished_at: this.now() })
.where("status", "=", "running")
.execute();
await this.db
.updateTable("scheduled_job")
.set({ running_since: null, last_status: "failed", last_error: msg })
.where("running_since", "is not", null)
.execute();
}
/** 一轮:同步定义 → 找到期任务 → 异步执行;定期回填巡检结果。测试可直接调用 */
async tick(): Promise<string[]> {
const now = this.now();
if (this.registry.version !== this.syncedVersion) {
const v = this.registry.version;
await syncDefinitions(this.db, this.registry.list(), now);
this.syncedVersion = v;
}
const busy = [...this.inflight.keys()];
const due = await this.db
.selectFrom("scheduled_job")
.selectAll()
.where("available", "=", true)
.where((eb) =>
eb.or([
eb("run_requested_at", "is not", null),
eb.and([eb("enabled", "=", true), eb("next_run_at", "<=", now)]),
]),
)
.$if(busy.length > 0, (q) => q.where("id", "not in", busy))
.orderBy("next_run_at")
.limit(50)
.execute();
const started: string[] = [];
for (const job of due) {
const fn = this.runFor(job);
if (!fn) continue;
const p = this.execute(job, fn).finally(() => this.inflight.delete(job.id));
this.inflight.set(job.id, p);
started.push(job.id);
}
if (Date.now() - this.lastHarvest >= (this.opts.harvestMs ?? 15_000)) {
this.lastHarvest = Date.now();
await this.harvest().catch((err) => this.log.warn({ err: { message: errText(err) } }, "巡检结果回填失败"));
}
return started;
}
/** 等进行中的任务全部结束(测试用) */
async idle(): Promise<void> {
while (this.inflight.size) await Promise.allSettled([...this.inflight.values()]);
}
private runFor(job: ScheduledJobRow): JobRunFn | null {
const def = this.registry.get(job.id);
if (def) return def.run;
// 自定义巡检不在代码里登记,共用巡检执行体
if (job.kind === "agent_check") return (c) => runAgentCheck(this.ctx, c);
return null;
}
private publish(jobId: string, status: string | null) {
void this.events.publish(SCHEDULE_CHANGED_EVENT, { jobId, status });
}
private async execute(job: ScheduledJobRow, fn: JobRunFn): Promise<void> {
const manual = job.run_requested_at !== null;
const spec = parseSpec(job.schedule);
const startedAt = this.now();
const t0 = performance.now();
// 认领:清掉请求标志、标运行中(条件更新,别的执行者抢先认领过就放弃)
const claimed = await this.db
.updateTable("scheduled_job")
.set({ running_since: startedAt, last_status: "running", run_requested_at: null })
.where("id", "=", job.id)
.where("running_since", "is", null)
.returning("id")
.executeTakeFirst();
if (!claimed) return;
const run = await this.db
.insertInto("scheduled_job_run")
.values({ job_id: job.id, trigger: manual ? "manual" : "schedule", status: "running", started_at: startedAt })
.returning("id")
.executeTakeFirstOrThrow();
if (manual || job.last_status !== "ok") this.publish(job.id, "running");
let status: "ok" | "skipped" | "failed" = "ok";
let error: string | null = null;
let result: JobRunResult | undefined;
try {
result =
(await fn({
jobId: job.id,
runId: run.id,
trigger: manual ? "manual" : "schedule",
now: startedAt,
config: (job.config ?? {}) as Record<string, unknown>,
slot: manual ? `manual:${run.id}` : slotOf(spec, startedAt, job.next_run_at),
})) ?? undefined;
status = result?.status ?? "ok";
} catch (err) {
status = "failed";
error = errText(err);
this.log.error({ err: { message: error }, job: job.id }, "定时任务执行失败");
}
const finishedAt = this.now();
const durationMs = Math.max(0, Math.round(performance.now() - t0));
// 计划触发(或从未排过)才推进下次时间;立即执行保持原计划
const advance = !manual || job.next_run_at === null || job.next_run_at <= startedAt;
try {
await this.db.transaction().execute(async (trx) => {
const cur = await trx
.selectFrom("scheduled_job")
.select(["schedule"])
.where("id", "=", job.id)
.executeTakeFirst();
if (!cur) return; // 运行期间被删除
const liveSpec = parseSpec(cur.schedule);
await trx
.updateTable("scheduled_job")
.set({
running_since: null,
last_run_at: startedAt,
last_status: status,
last_duration_ms: durationMs,
last_error: error,
run_count: sql`run_count + 1`,
...(status === "failed" ? { fail_count: sql`fail_count + 1` } : {}),
...(advance
? { next_run_at: computeNext(liveSpec, liveSpec.type === "interval" ? finishedAt : startedAt) }
: {}),
})
.where("id", "=", job.id)
.execute();
await trx
.updateTable("scheduled_job_run")
.set({
status,
finished_at: finishedAt,
duration_ms: durationMs,
error,
summary: result?.summary ?? null,
agent_run_id: result?.agentRunId ?? null,
})
.where("id", "=", run.id)
.execute();
await trimRuns(trx, job.id);
});
} catch (err) {
this.log.error({ err: { message: errText(err) }, job: job.id }, "定时任务结果写回失败");
}
if (manual || status !== job.last_status || status === "failed") this.publish(job.id, status);
}
/** 巡检入队的 agent_run 结束后:回填终态与摘要;配置了 reportAsDecision 的写一条 info 待处理 */
async harvest(): Promise<number> {
const rows = await sql<{
id: string;
job_id: string;
job_name: string;
config: Record<string, unknown>;
agent_run_id: string;
status: string;
summary: string | null;
}>`
select r.id, r.job_id, j.name as job_name, j.config, r.agent_run_id, a.status, a.summary
from scheduled_job_run r
join scheduled_job j on j.id = r.job_id
join agent_run a on a.id = r.agent_run_id
where r.agent_status is null and a.status = any(${TERMINAL_AGENT})
limit 50`.execute(this.db);
for (const r of rows.rows) {
const summary = (r.summary ?? "").trim() || `智能体运行结束:${r.status}`;
await this.db
.updateTable("scheduled_job_run")
.set({ agent_status: r.status, summary: summary.slice(0, 4000) })
.where("id", "=", r.id)
.execute();
if (r.config?.reportAsDecision === true && r.status === "succeeded") {
const ins = await sql<{ id: string }>`
insert into decision (kind, severity, title, summary, subject_type, subject_id, dedupe_key, evidence, agent_run_id)
values ('other', 'info', ${`巡检报告:${r.job_name}`}, ${summary.slice(0, 4000)}, 'agent_run', ${r.agent_run_id},
${`schedule-report:${r.id}`}, ${JSON.stringify({ scheduledJobId: r.job_id, scheduledRunId: r.id })}::jsonb,
${r.agent_run_id})
on conflict do nothing
returning id`.execute(this.db);
const d = ins.rows[0];
if (d)
await this.ctx.outbox.emit(
this.db,
"decision.created",
{ decisionId: d.id, kind: "other", severity: "info", title: `巡检报告:${r.job_name}` },
{ idempotencyKey: `decision.created:${d.id}` },
);
}
this.publish(r.job_id, null);
}
return rows.rows.length;
}
}