mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +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>
261 lines
8.1 KiB
TypeScript
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");
|
|
},
|
|
};
|
|
}
|