mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
## Thinking Path > - Paperclip manages agents and plugin work. > - Plugins can declare cron jobs with saved deadlines. > - The existing scheduler queries for due jobs every 30 seconds, even when none exist. > - Empty queries keep an otherwise idle database active. > - This PR uses the shared scheduler and committed job changes to discover work. > - Existing job and run rows preserve deadlines and execution history. ## Linked Issues or Issue Description **What happened?** The plugin scheduler on the base of this stack runs empty periodic SQL. Its registration path also advances overdue timestamps. An empty replacement manifest can leave removed schedules active. **Expected behavior** No recurring SQL when no eligible jobs exist. Configured jobs wait for their saved deadline. Changes reactivate scheduling after commit. Work remains protected through drain and shutdown. **Steps to reproduce** Start the plugin scheduler with no jobs. After startup, observe queries during a three-minute idle window. The new PostgreSQL regression failed on the baseline and passes with this change. **Paperclip version or commit** Stacked on #17510 at `4d15c08428`. Deployment: source checkout, local PostgreSQL, controlled plugin RPC. Related: #7905 covers empty-manifest sync; this migration requires that behavior on both activation paths. #11571 proposes company-scoped jobs; this PR preserves the existing instance-scoped job contract. ## What Changed - Install the concurrency test barrier before due job notifications can dispatch. - Make dispatch the sole reservation owner and preserve retained or concurrently committed results when unregistering a plugin. - Replace the private interval with the existing shared deadline worker. - Signal job definition and run changes inside their owning transactions. No new tables. - Wake from plugin registration and worker status events. Retain explicit scheduler wakes received during drain. - Preserve overdue work and initialize only missing or changed schedules. - Recheck claims under a job-row lock. Keep plugin calls outside transactions and refill capacity on completion. - Commit completion and schedule advancement together. Retry failed persistence without repeating the plugin side effect. - Track admitted calls through shutdown and synchronize empty manifests. ## Verification This PR now targets master after #17510 merged as `4e27a82a4265c01f3ab4f05aaecc010a0026de75`. The normal landed-parent merge preserves the entire verified `df78ca3b5e` tree byte for byte; only final publication docs change afterwards. The PR still differs in its original seventeen paths and has no database schema or migration change. Primary test revision: `36be0bd73363680d7666d94ff5f1dd28b27f4235`, on parent `ba80060407`. Final integration of parent `51668b8240` preserves all plugin/runtime/shutdown sources from that proof and passes all 63 cases in complete plugin, approval-seeding and scoped-deadline suites at `56ce7c1d2a`, plus server types/build. Only six parent seeding/doc files changed; this is not a rerun of all eleven suites. Only the original seventeen plugin-scheduling paths differ from that parent; all other tracked files, including migration history, match it. - **247 cases pass in eleven complete suites:** plugin scheduler, shutdown, warm standby, plugin environment-driver readiness, delivery coordinator, both retention workers, approval seeding, decision expiry, scoped deadlines and startup. Server typecheck and production build pass. - The new real PostgreSQL lock test failed before the repair: shutdown remained pending while the completion update waited for a held run row. After repair, shutdown reports the statement timeout while the lock remains held; retry after release commits the saved result with exactly one plugin invocation. All 34 scheduler cases pass. - Existing regressions preserve real committed schedule changes, saved deadlines, manual/scheduled reservation, retained success and failure results during unregister, completion/unregister lock order, independent progress, rollback/outer commit, quiet configured states, drain/resume and shutdown cleanup. - The parent's complete seeding/producer run passed 522 cases at its stated revision. Earlier duplicate reservations, overwritten retained results, hourly-clock fixture failures and combined-stack CI/setup failures remain recorded in `doc/idle-plugin-jobs.md`, its dated audit and the stack ledger; they are not erased or recounted as this candidate's tests. - The completion timeout is transaction-local and defaults to thirty seconds per PostgreSQL statement. It does not bound every shutdown/network operation or make plugin side effects safe to replay. No full local repository-suite or deployed host-sleep claim is made. Commands: server Vitest for the eleven suites above; server typecheck/build. Fresh exact-head CI, Greptile and Ponytail reviews remain required before merge. ## Risks - This uses process-local notifications. Independent SQL writers and other replicas need their own wake integration. - A run left unresolved after process loss is not automatically replayed: the external effect may have occurred. Existing lifecycle/operator reconciliation must resolve it. Live-process settlement failures retry without replay. - Job definitions remain instance-scoped. This PR does not implement company-scoped job dispatch. - Cloud's plugin sleep blockers remain. Internal deadlines do not add durable external wake coverage or prove deployed sleep. - The shared deadline helper now retains direct lifecycle wakes through drain; existing coordinator and decision deadline consumers are tested. ## Model Used OpenAI Codex, GPT-6-based agent, with repository tools, code execution, and GitHub access. The exact serving model version and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
265 lines
13 KiB
TypeScript
265 lines
13 KiB
TypeScript
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
import { pluginJobs, pluginJobRuns, type Db } from "@paperclipai/db";
|
|
import { pluginJobStore, type PluginJobStore } from "./plugin-job-store.js";
|
|
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
|
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
|
import { registerDeadlineWork } from "./deadline-work.js";
|
|
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
|
import { parseCron, nextCronTick, validateCron } from "./cron.js";
|
|
import { trackIdleWork } from "./task-admission.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
|
|
type Job = typeof pluginJobs.$inferSelect;
|
|
type Trigger = "schedule" | "manual" | "retry";
|
|
type Coordinator = ReturnType<typeof createDeliveryWorkCoordinator>;
|
|
|
|
export interface PluginJobSchedulerOptions {
|
|
isBackgroundWorkEnabled?: () => boolean;
|
|
db: Db;
|
|
jobStore: PluginJobStore;
|
|
workerManager: PluginWorkerManager;
|
|
tickIntervalMs?: number;
|
|
jobTimeoutMs?: number;
|
|
settlementTimeoutMs?: number;
|
|
maxConcurrentJobs?: number;
|
|
}
|
|
export interface TriggerJobResult { runId: string; jobId: string }
|
|
export interface SchedulerDiagnostics {
|
|
running: boolean;
|
|
activeJobCount: number;
|
|
activeJobIds: string[];
|
|
tickCount: number;
|
|
lastTickAt: string | null;
|
|
}
|
|
export interface PluginJobScheduler {
|
|
start(coordinator: Coordinator): Promise<void>;
|
|
stop(): Promise<void>;
|
|
registerPlugin(pluginId: string): Promise<void>;
|
|
unregisterPlugin(pluginId: string): Promise<void>;
|
|
triggerJob(jobId: string, trigger?: "manual" | "retry"): Promise<TriggerJobResult>;
|
|
tick(signal?: AbortSignal): Promise<void>;
|
|
diagnostics(): SchedulerDiagnostics;
|
|
}
|
|
|
|
function nextCron(schedule: string, now: Date): Date | null {
|
|
return schedule && !validateCron(schedule) ? nextCronTick(parseCron(schedule), now) : null;
|
|
}
|
|
|
|
export function createPluginJobScheduler(options: PluginJobSchedulerOptions): PluginJobScheduler {
|
|
const { db, jobStore, workerManager, tickIntervalMs = 30_000,
|
|
jobTimeoutMs = 5 * 60_000, settlementTimeoutMs = 30_000, maxConcurrentJobs = 10 } = options;
|
|
const log = logger.child({ service: "plugin-job-scheduler" });
|
|
const activeJobs = new Set<string>();
|
|
const pendingSettlements = new Map<string, { persist: () => Promise<void>; running?: Promise<void> }>();
|
|
const inFlight = new Set<Promise<unknown>>();
|
|
const subscriptions = new Map<string, () => void>();
|
|
const unregistered = new Set<string>();
|
|
let worker: ReturnType<typeof registerDeadlineWork> | undefined;
|
|
let running = false;
|
|
let next: number | null = null;
|
|
let tickCount = 0;
|
|
let lastTickAt: Date | null = null;
|
|
let ticking: Promise<void> | undefined;
|
|
const canRun = () => running && options.isBackgroundWorkEnabled?.() !== false;
|
|
|
|
function track<T>(promise: Promise<T>): Promise<T> {
|
|
const tracked = trackIdleWork(promise);
|
|
inFlight.add(tracked);
|
|
void tracked.then(() => inFlight.delete(tracked), () => inFlight.delete(tracked));
|
|
return tracked;
|
|
}
|
|
|
|
async function settle(jobId: string) {
|
|
const entry = pendingSettlements.get(jobId);
|
|
if (!entry) return;
|
|
entry.running ??= entry.persist().finally(() => { entry.running = undefined; });
|
|
await entry.running;
|
|
if (pendingSettlements.get(jobId) === entry) pendingSettlements.delete(jobId);
|
|
}
|
|
|
|
async function execute(job: Job, runId: string, trigger: Trigger) {
|
|
const startedAt = Date.now();
|
|
let error: string | undefined;
|
|
try {
|
|
await jobStore.markRunning(runId);
|
|
await workerManager.call(job.pluginId, "runJob", {
|
|
job: { jobKey: job.jobKey, runId, trigger,
|
|
scheduledAt: (trigger === "schedule" ? job.nextRunAt! : new Date()).toISOString() },
|
|
}, jobTimeoutMs);
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : String(err);
|
|
log.error({ jobId: job.id, runId, err }, "plugin job failed");
|
|
}
|
|
const finishedAt = new Date();
|
|
pendingSettlements.set(job.id, { persist: () => db.transaction(async tx => {
|
|
await tx.execute(sql`select set_config('statement_timeout', ${String(settlementTimeoutMs)}, true)`);
|
|
const store = pluginJobStore(tx);
|
|
await store.completeRun(runId, {
|
|
status: error === undefined ? "succeeded" : "failed", error,
|
|
durationMs: finishedAt.getTime() - startedAt,
|
|
});
|
|
if (trigger === "schedule") {
|
|
const [current] = await tx.select().from(pluginJobs).where(eq(pluginJobs.id, job.id)).for("update");
|
|
if (current && current.schedule === job.schedule && current.nextRunAt?.getTime() === job.nextRunAt?.getTime()) {
|
|
await store.updateRunTimestamps(job.id, finishedAt, nextCron(current.schedule, finishedAt));
|
|
}
|
|
}
|
|
}) });
|
|
try { await settle(job.id); }
|
|
finally { activeJobs.delete(job.id); worker?.wake(); }
|
|
}
|
|
|
|
async function dispatch(job: Job, trigger: Trigger, signal?: AbortSignal): Promise<TriggerJobResult | null> {
|
|
// Reserve synchronously at the one dispatch boundary. A refresh may have
|
|
// yielded since filtering its snapshot; it cannot borrow a manual run's slot.
|
|
if (activeJobs.has(job.id) || pendingSettlements.has(job.id)) {
|
|
if (trigger !== "schedule") throw new Error(`Job "${job.jobKey}" is already running`);
|
|
return null;
|
|
}
|
|
if (trigger === "schedule" && activeJobs.size >= maxConcurrentJobs) return null;
|
|
activeJobs.add(job.id);
|
|
try {
|
|
const run = await db.transaction(async tx => {
|
|
const [current] = await tx.select().from(pluginJobs).where(eq(pluginJobs.id, job.id)).for("update");
|
|
if (!current || current.status !== "active" || signal?.aborted || !canRun() || unregistered.has(job.pluginId)
|
|
|| !workerManager.isRunning(job.pluginId)) return null;
|
|
if (trigger === "schedule" && (current.schedule !== job.schedule
|
|
|| current.nextRunAt?.getTime() !== job.nextRunAt?.getTime())) return null;
|
|
const [outstanding] = await tx.select({ id: pluginJobRuns.id }).from(pluginJobRuns).where(and(
|
|
eq(pluginJobRuns.jobId, job.id), inArray(pluginJobRuns.status, ["pending", "queued", "running"]),
|
|
)).limit(1);
|
|
if (outstanding) return null;
|
|
return pluginJobStore(tx).createRun({ jobId: job.id, pluginId: job.pluginId, trigger });
|
|
});
|
|
if (!run) {
|
|
activeJobs.delete(job.id);
|
|
worker?.wake();
|
|
if (trigger !== "schedule") throw new Error(`Job "${job.jobKey}" is no longer available`);
|
|
return null;
|
|
}
|
|
const completion = track(execute(job, run.id, trigger));
|
|
void completion.catch(err => { log.error({ err, jobId: job.id }, "plugin job settlement deferred"); worker?.wake(); });
|
|
return { runId: run.id, jobId: job.id };
|
|
} catch (err) { activeJobs.delete(job.id); throw err; }
|
|
}
|
|
|
|
async function refresh(signal?: AbortSignal) {
|
|
const settlements = await Promise.allSettled([...pendingSettlements.keys()].map(settle));
|
|
const jobs = await db.select().from(pluginJobs).where(eq(pluginJobs.status, "active"));
|
|
const runnable = jobs.filter(job => !unregistered.has(job.pluginId) && workerManager.isRunning(job.pluginId)
|
|
&& !activeJobs.has(job.id) && !pendingSettlements.has(job.id) && job.schedule && !validateCron(job.schedule));
|
|
const outstanding = runnable.length === 0 ? [] : await db.select({ jobId: pluginJobRuns.jobId })
|
|
.from(pluginJobRuns).where(and(inArray(pluginJobRuns.jobId, runnable.map(job => job.id)),
|
|
inArray(pluginJobRuns.status, ["pending", "queued", "running"])));
|
|
const blocked = new Set(outstanding.map(run => run.jobId));
|
|
const now = new Date();
|
|
const dispatches: Promise<unknown>[] = [];
|
|
next = null;
|
|
for (const job of runnable) {
|
|
if (!canRun() || signal?.aborted) break;
|
|
if (blocked.has(job.id) || activeJobs.has(job.id) || pendingSettlements.has(job.id)) continue;
|
|
if (!job.nextRunAt) {
|
|
const deadline = nextCron(job.schedule, now);
|
|
if (!deadline) continue;
|
|
await db.transaction(async tx => {
|
|
const [current] = await tx.select().from(pluginJobs).where(eq(pluginJobs.id, job.id)).for("update");
|
|
if (current && current.nextRunAt === null && current.schedule === job.schedule && current.status === "active") {
|
|
await pluginJobStore(tx).updateRunTimestamps(job.id, current.lastRunAt ?? new Date(0), deadline);
|
|
}
|
|
});
|
|
job.nextRunAt = deadline;
|
|
}
|
|
const at = job.nextRunAt.getTime();
|
|
if (at <= now.getTime() && activeJobs.size < maxConcurrentJobs) {
|
|
dispatches.push(dispatch(job, "schedule", signal));
|
|
} else if (activeJobs.size < maxConcurrentJobs) {
|
|
next = next === null ? at : Math.min(next, at);
|
|
}
|
|
}
|
|
const results = await Promise.allSettled(dispatches);
|
|
const failed = [...settlements, ...results].find(result => result.status === "rejected");
|
|
if (failed?.status === "rejected") throw failed.reason;
|
|
}
|
|
|
|
function tick(signal?: AbortSignal): Promise<void> {
|
|
if (!canRun()) return Promise.resolve();
|
|
if (ticking) return ticking;
|
|
tickCount++; lastTickAt = new Date();
|
|
ticking = refresh(signal).finally(() => { ticking = undefined; });
|
|
return ticking;
|
|
}
|
|
|
|
return {
|
|
async start(coordinator) {
|
|
if (worker) { await worker.ready; return; }
|
|
running = true;
|
|
worker = registerDeadlineWork(coordinator, DELIVERY_QUEUES.pluginSchedules, {
|
|
run: tick, next: async () => next, canRun, retryMs: tickIntervalMs,
|
|
notificationCoalesceMs: tickIntervalMs,
|
|
});
|
|
await worker.ready;
|
|
},
|
|
async stop() {
|
|
running = false;
|
|
for (const unsubscribe of subscriptions.values()) unsubscribe();
|
|
subscriptions.clear();
|
|
await Promise.allSettled([ticking]);
|
|
while (inFlight.size) await Promise.allSettled([...inFlight]);
|
|
const results = await track(Promise.allSettled([...pendingSettlements.keys()].map(settle)));
|
|
const failures = results.filter(result => result.status === "rejected");
|
|
if (failures.length) throw new AggregateError(failures.map(result => result.reason), "Plugin job completion persistence failed during shutdown");
|
|
},
|
|
async registerPlugin(pluginId) {
|
|
unregistered.delete(pluginId);
|
|
subscriptions.get(pluginId)?.();
|
|
const handle = workerManager.getWorker(pluginId);
|
|
if (handle) {
|
|
const wake = () => worker?.wake();
|
|
handle.on("status", wake);
|
|
subscriptions.set(pluginId, () => handle.off("status", wake));
|
|
}
|
|
worker?.wake();
|
|
},
|
|
async unregisterPlugin(pluginId) {
|
|
unregistered.add(pluginId);
|
|
subscriptions.get(pluginId)?.(); subscriptions.delete(pluginId);
|
|
worker?.wake();
|
|
const runs = await db.select().from(pluginJobRuns).where(and(
|
|
eq(pluginJobRuns.pluginId, pluginId), inArray(pluginJobRuns.status, ["pending", "queued", "running"]),
|
|
));
|
|
for (const run of runs) {
|
|
if (activeJobs.has(run.jobId) || pendingSettlements.has(run.jobId)) continue;
|
|
await db.transaction(async tx => {
|
|
const [current] = await tx.select().from(pluginJobRuns).where(eq(pluginJobRuns.id, run.id)).for("update");
|
|
// A completion may have committed after the discovery read. Never
|
|
// overwrite its result, or the result still retained for persistence.
|
|
if (!current || !["running", "queued", "pending"].includes(current.status)
|
|
|| activeJobs.has(current.jobId) || pendingSettlements.has(current.jobId)) return;
|
|
await pluginJobStore(tx).completeRun(current.id, { status: "cancelled", error: "Plugin unregistered",
|
|
durationMs: current.startedAt ? Date.now() - current.startedAt.getTime() : null });
|
|
});
|
|
}
|
|
},
|
|
triggerJob(jobId, trigger = "manual") {
|
|
return track((async () => {
|
|
if (!canRun()) throw new Error("Plugin job scheduler is not accepting work");
|
|
const job = await jobStore.getJobById(jobId);
|
|
if (!job) throw new Error(`Job not found: ${jobId}`);
|
|
if (job.status !== "active") throw new Error(`Job "${job.jobKey}" is not active (status: ${job.status})`);
|
|
if (activeJobs.has(jobId) || pendingSettlements.has(jobId)) throw new Error(`Job "${job.jobKey}" is already running`);
|
|
const runs = await db.select({ id: pluginJobRuns.id }).from(pluginJobRuns).where(and(
|
|
eq(pluginJobRuns.jobId, jobId), inArray(pluginJobRuns.status, ["pending", "queued", "running"]),
|
|
)).limit(1);
|
|
if (runs.length) throw new Error(`Job "${job.jobKey}" already has a running execution`);
|
|
if (unregistered.has(job.pluginId) || !workerManager.isRunning(job.pluginId)) throw new Error(`Worker for plugin "${job.pluginId}" is not running`);
|
|
const result = await dispatch(job, trigger);
|
|
if (!result) throw new Error(`Job "${job.jobKey}" is no longer available`);
|
|
return result;
|
|
})());
|
|
},
|
|
tick,
|
|
diagnostics: () => ({ running, activeJobCount: activeJobs.size, activeJobIds: [...activeJobs], tickCount,
|
|
lastTickAt: lastTickAt?.toISOString() ?? null }),
|
|
};
|
|
}
|