Files
PaperClipAI/server/src/services/plugin-job-coordinator.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

261 lines
8.1 KiB
TypeScript

/**
* PluginJobCoordinator — bridges the plugin lifecycle manager with the
* job scheduler and job store.
*
* This service listens to lifecycle events and performs the corresponding
* scheduler and job store operations:
*
* - **plugin.loaded** → sync job declarations from manifest, then register
* the plugin with the scheduler (computes `nextRunAt` for active jobs).
*
* - **plugin.disabled / plugin.unloaded** → unregister the plugin from the
* scheduler (cancels in-flight runs, clears tracking state).
*
* ## Why a separate coordinator?
*
* The lifecycle manager, scheduler, and job store are independent services
* with clean single-responsibility boundaries. The coordinator provides
* the "glue" between them without adding coupling. This pattern is used
* throughout Paperclip (e.g. heartbeat service coordinates timers + runs).
*
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
* @see ./plugin-job-scheduler.ts — Scheduler service
* @see ./plugin-job-store.ts — Persistence layer
* @see ./plugin-lifecycle.ts — Plugin state machine
*/
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
import type { PluginJobScheduler } from "./plugin-job-scheduler.js";
import type { PluginJobStore } from "./plugin-job-store.js";
import { pluginRegistryService } from "./plugin-registry.js";
import type { Db } from "@paperclipai/db";
import { logger } from "../middleware/logger.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Options for creating a PluginJobCoordinator.
*/
export interface PluginJobCoordinatorOptions {
/** Drizzle database instance. */
db: Db;
/** The plugin lifecycle manager to listen to. */
lifecycle: PluginLifecycleManager;
/** The job scheduler to register/unregister plugins with. */
scheduler: PluginJobScheduler;
/** The job store for syncing declarations. */
jobStore: PluginJobStore;
}
/**
* The public interface of the job coordinator.
*/
export interface PluginJobCoordinator {
/**
* Start listening to lifecycle events.
*
* This wires up the `plugin.loaded`, `plugin.disabled`, and
* `plugin.unloaded` event handlers.
*/
start(): void;
/**
* Stop listening to lifecycle events.
*
* Removes all event subscriptions added by `start()`.
*/
stop(): void;
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
/**
* Create a PluginJobCoordinator.
*
* @example
* ```ts
* const coordinator = createPluginJobCoordinator({
* db,
* lifecycle,
* scheduler,
* jobStore,
* });
*
* // Start listening to lifecycle events
* coordinator.start();
*
* // On server shutdown
* coordinator.stop();
* ```
*/
export function createPluginJobCoordinator(
options: PluginJobCoordinatorOptions,
): PluginJobCoordinator {
const { db, lifecycle, scheduler, jobStore } = options;
const log = logger.child({ service: "plugin-job-coordinator" });
const registry = pluginRegistryService(db);
// -----------------------------------------------------------------------
// Event handlers
// -----------------------------------------------------------------------
/**
* When a plugin is loaded (transitions to `ready`):
* 1. Look up the manifest from the registry
* 2. Sync job declarations from the manifest into the DB
* 3. Register the plugin with the scheduler (computes nextRunAt)
*/
async function onPluginLoaded(payload: { pluginId: string; pluginKey: string }): Promise<void> {
const { pluginId, pluginKey } = payload;
log.info({ pluginId, pluginKey }, "plugin loaded — syncing jobs and registering with scheduler");
try {
// Get the manifest from the registry
const plugin = await registry.getById(pluginId);
if (!plugin?.manifestJson) {
log.warn({ pluginId, pluginKey }, "plugin loaded but no manifest found — skipping job sync");
return;
}
// Sync job declarations from the manifest
const manifest = plugin.manifestJson;
const jobDeclarations = manifest.jobs ?? [];
if (jobDeclarations.length > 0) {
log.info(
{ pluginId, pluginKey, jobCount: jobDeclarations.length },
"syncing job declarations from manifest",
);
}
await jobStore.syncJobDeclarations(pluginId, jobDeclarations);
// Register with the scheduler (computes nextRunAt for active jobs)
await scheduler.registerPlugin(pluginId);
} catch (err) {
log.error(
{
pluginId,
pluginKey,
err: err instanceof Error ? err.message : String(err),
},
"failed to sync jobs or register plugin with scheduler",
);
}
}
/**
* When a plugin is disabled (transitions to `error` with "disabled by
* operator" or genuine error): unregister from the scheduler.
*/
async function onPluginDisabled(payload: {
pluginId: string;
pluginKey: string;
reason?: string;
}): Promise<void> {
const { pluginId, pluginKey, reason } = payload;
log.info(
{ pluginId, pluginKey, reason },
"plugin disabled — unregistering from scheduler",
);
try {
await scheduler.unregisterPlugin(pluginId);
} catch (err) {
log.error(
{
pluginId,
pluginKey,
err: err instanceof Error ? err.message : String(err),
},
"failed to unregister plugin from scheduler",
);
}
}
/**
* When a plugin is unloaded (uninstalled): unregister from the scheduler.
*/
async function onPluginUnloaded(payload: {
pluginId: string;
pluginKey: string;
removeData: boolean;
}): Promise<void> {
const { pluginId, pluginKey, removeData } = payload;
log.info(
{ pluginId, pluginKey, removeData },
"plugin unloaded — unregistering from scheduler",
);
try {
await scheduler.unregisterPlugin(pluginId);
// If data is being purged, also delete all job definitions and runs
if (removeData) {
log.info({ pluginId, pluginKey }, "purging job data for uninstalled plugin");
await jobStore.deleteAllJobs(pluginId);
}
} catch (err) {
log.error(
{
pluginId,
pluginKey,
err: err instanceof Error ? err.message : String(err),
},
"failed to unregister plugin from scheduler during unload",
);
}
}
// -----------------------------------------------------------------------
// State
// -----------------------------------------------------------------------
let attached = false;
// We need stable references for on/off since the lifecycle manager
// uses them for matching. We wrap the async handlers in sync wrappers
// that fire-and-forget (swallowing unhandled rejections via the try/catch
// inside each handler).
const boundOnLoaded = (payload: { pluginId: string; pluginKey: string }) => {
void onPluginLoaded(payload);
};
const boundOnDisabled = (payload: { pluginId: string; pluginKey: string; reason?: string }) => {
void onPluginDisabled(payload);
};
const boundOnUnloaded = (payload: { pluginId: string; pluginKey: string; removeData: boolean }) => {
void onPluginUnloaded(payload);
};
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
return {
start(): void {
if (attached) return;
attached = true;
lifecycle.on("plugin.loaded", boundOnLoaded);
lifecycle.on("plugin.disabled", boundOnDisabled);
lifecycle.on("plugin.unloaded", boundOnUnloaded);
log.info("plugin job coordinator started — listening to lifecycle events");
},
stop(): void {
if (!attached) return;
attached = false;
lifecycle.off("plugin.loaded", boundOnLoaded);
lifecycle.off("plugin.disabled", boundOnDisabled);
lifecycle.off("plugin.unloaded", boundOnUnloaded);
log.info("plugin job coordinator stopped");
},
};
}