Files
PaperClipAI/server/src/services/plugin-job-scheduler.ts
T
DottaandPaperclip 96727b3cfe fix(server): schedule plugin jobs from saved deadlines (#17518)
## 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>
2026-10-27 21:07:10 -05:00

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 }),
};
}