feat(server): unified scheduled-job registry and scheduler; module timers moved onto it

- modules/schedules: registerScheduledJob({key, defaults, run}) registry; defaults are
  synced into scheduled_job without overriding user-changed enabled/schedule
- scheduler runs only on the io worker, one instance via pg_try_advisory_lock held on a
  dedicated connection; due = enabled and next_run_at reached, or run requested;
  jobs run in parallel, never re-entrant; interval next = finish + period, cron next match;
  run-now does not move next_run_at and merges duplicate requests; skip advances one period;
  history trimmed to the last 50 runs; interrupted runs marked failed on takeover
- agent_check jobs: trigger condition checked first, goal template rendered, runAgent
  enqueued via startRun(trigger=schedule) with allowlist and budget, one run per time slot;
  allowRecover uses the autoInvestigate grant check; results harvested back into the run
  history and optionally reported as an info decision; 3 built-in checks, off by default
- sources-identity (feeds.poll, sites.snapshot, identity.review), acquisition-organize
  (downloads.sync, cache.threshold, downloads.orchestrate, slots.reevaluate,
  subscriptions.search, subscriptions.metadata, library.reconcile) and file-manager
  (trash.purge) register jobs instead of running their own setInterval; default
  intervals unchanged; backup registered as an unconfigured placeholder
- endpoints under /schedules (admin, console session only)

TODO: pi refreshPiTokens and auth-settings session cleanup still own their timers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 02:32:33 +08:00
1 parent f8f70d8e09
commit a68fcea13b
16 files changed
+1732 -127

No files matched your search

@@ -0,0 +1,99 @@
import type { ScheduleGroup, ScheduleKind, ScheduleSpec, ScheduleTrigger } from "@umt/contracts";
import type { ServerContext } from "../types.ts";
// 定时任务注册表(进程内):模块在 start() 里登记 {key, defaults, run},schedules 模块的调度器
// 把定义同步进 scheduled_job 表(不覆盖用户改过的开关/调度),按 next_run_at 调用 run。
// 各模块只 import 本文件(不依赖调度器实现);没装 schedules 模块时登记只是一张内存表,不会执行。
export interface ScheduledJobDefaults {
name: string;
description?: string;
group: ScheduleGroup;
schedule: ScheduleSpec;
/** 缺省 true */
enabled?: boolean;
/** 任务参数(看板可改;task 浅合并,agent_check 整体替换) */
config?: Record<string, unknown>;
/** false = 占位(如备份未配置),不执行、不可开启 */
available?: boolean;
/** 首次登记时何时第一次运行:now(缺省,尽快)/ after_interval(一个周期后) */
firstRun?: "now" | "after_interval";
}
export interface JobRunContext {
jobId: string;
/** scheduled_job_run.id */
runId: string;
trigger: Exclude<ScheduleTrigger, "skip">;
now: Date;
/** 当前任务参数(表里的 config) */
config: Record<string, unknown>;
/**
* 时间槽:按调度周期取整的稳定串(interval:floor(now/everyMs);cron:本次计划时间)。
* 入队幂等键用它拼,多实例 / 重复触发同槽只入队一次;立即执行用 manual:<runId>。
*/
slot: string;
}
export interface JobRunResult {
/** 缺省 ok;业务侧主动放弃(如只读对账模式)返回 skipped */
status?: "ok" | "skipped";
summary?: string | null;
agentRunId?: string | null;
}
export type JobRunFn = (c: JobRunContext) => Promise<JobRunResult | undefined | void>;
export interface ScheduledJobDef {
/** 稳定 key(表主键),如 feeds.poll */
key: string;
kind?: ScheduleKind;
defaults: ScheduledJobDefaults;
run: JobRunFn;
}
export interface ScheduleRegistry {
/** 每次登记 +1,调度器据此判断是否需要重新同步到表 */
readonly version: number;
get(key: string): ScheduledJobDef | undefined;
list(): ScheduledJobDef[];
register(def: ScheduledJobDef): void;
unregister(key: string): void;
}
class MemoryRegistry implements ScheduleRegistry {
version = 0;
private defs = new Map<string, ScheduledJobDef>();
get(key: string) {
return this.defs.get(key);
}
list() {
return [...this.defs.values()];
}
register(def: ScheduledJobDef) {
if (!/^[a-z][a-z0-9_.:-]{1,120}$/.test(def.key)) throw new Error(`定时任务 key 不合法:${def.key}`);
this.defs.set(def.key, def);
this.version += 1;
}
unregister(key: string) {
if (this.defs.delete(key)) this.version += 1;
}
}
const registries = new WeakMap<object, ScheduleRegistry>();
/** 取 ctx 对应的注册表(同一 ServerContext 共用一张) */
export function scheduleRegistry(ctx: Pick<ServerContext, "db">): ScheduleRegistry {
const k = ctx as object;
let r = registries.get(k);
if (!r) {
r = new MemoryRegistry();
registries.set(k, r);
}
return r;
}
/** 模块 start() 里调用:登记一个周期任务(同 key 重复登记以最后一次为准) */
export function registerScheduledJob(ctx: Pick<ServerContext, "db">, def: ScheduledJobDef): void {
scheduleRegistry(ctx).register(def);
}