mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +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>
159 lines
7.3 KiB
TypeScript
159 lines
7.3 KiB
TypeScript
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
|
import { createServer, request as httpRequest } from "node:http";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { installDatabaseWorkSignals } from "../../../packages/db/src/work-signals.js";
|
|
import { cloudWarmStandbyMiddleware, cloudWarmStandbyServerOptions } from "../middleware/cloud-warm-standby.js";
|
|
import { healthRoutes } from "../routes/health.js";
|
|
import { emailChannelService } from "../services/email-channels.js";
|
|
import { createPluginJobScheduler } from "../services/plugin-job-scheduler.js";
|
|
import { createPublicMcpEvents } from "../services/public-mcp/events.js";
|
|
import type { PublicMcpOAuth } from "../services/public-mcp/oauth.js";
|
|
|
|
const healthOptions = {
|
|
deploymentMode: "authenticated" as const,
|
|
deploymentExposure: "public" as const,
|
|
authReady: true,
|
|
companyDeletionEnabled: false,
|
|
runtimeEnv: { PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "test-token" },
|
|
};
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
describe("unclaimed Cloud background work", () => {
|
|
it("keeps probes and tenant requests out of SQL and auth until claim", async () => {
|
|
let standby = true;
|
|
const execute = vi.fn().mockResolvedValue([]);
|
|
const db = { execute } as unknown as Db;
|
|
const health = healthRoutes(db, { ...healthOptions, isWarmStandby: () => standby });
|
|
const app = express();
|
|
const auth = vi.fn((_req, _res, next) => next());
|
|
const ui = express.Router();
|
|
ui.get("/", (_req, res) => res.type("html").send("ready"));
|
|
ui.get("/assets/app.js", (_req, res) => res.type("js").send("ready"));
|
|
app.use(cloudWarmStandbyMiddleware(() => standby, health, ui));
|
|
app.use(auth);
|
|
app.use("/api/health", health);
|
|
app.get("/api/companies", (_req, res) => res.json([]));
|
|
for (let i = 0; i < 3; i++) {
|
|
const probe = await request(app).get("/api/health").set("authorization", "Bearer ignored-in-standby");
|
|
expect(probe.status).toBe(200);
|
|
expect(probe.body.warmStandby).toBe(true);
|
|
expect(probe.body.serverInfo).toBeUndefined();
|
|
}
|
|
expect((await request(app).head("/api/health")).status).toBe(200);
|
|
expect((await request(app).get("/api/companies")).status).toBe(503);
|
|
expect((await request(app).post("/api/health/dev-server/restart")).status).toBe(503);
|
|
for (const path of ["/", "/assets/app.js"]) {
|
|
const page = await request(app).get(path)
|
|
.set("authorization", "Bearer test-bearer")
|
|
.set("x-paperclip-cloud-tenant-token", "test-tenant")
|
|
.set("cookie", "paperclip.session_token=test-session");
|
|
expect(page.status).toBe(200);
|
|
}
|
|
expect((await request(app).post("/mcp/gateways/gw_test")).status).toBe(503);
|
|
expect((await request(app).get("/unknown-dynamic-handler")).status).toBe(503);
|
|
expect(auth).not.toHaveBeenCalled();
|
|
expect(execute).not.toHaveBeenCalled();
|
|
standby = false;
|
|
expect((await request(app).get("/api/companies")).status).toBe(200);
|
|
const claimed = await request(app).get("/api/health");
|
|
expect(claimed.status).toBe(200);
|
|
expect(claimed.body.warmStandby).toBeUndefined();
|
|
expect(execute).toHaveBeenCalledOnce();
|
|
execute.mockRejectedValueOnce(new Error("offline"));
|
|
expect((await request(app).get("/api/health")).status).toBe(503);
|
|
});
|
|
|
|
it("rejects every WebSocket upgrade before auth and admits upgrades after claim", async () => {
|
|
let standby = true;
|
|
const app = express();
|
|
const health = healthRoutes({} as Db, { ...healthOptions, isWarmStandby: () => standby });
|
|
const authenticate = vi.fn();
|
|
app.use(cloudWarmStandbyMiddleware(() => standby, health));
|
|
const server = createServer(cloudWarmStandbyServerOptions(() => standby), app);
|
|
server.on("upgrade", (_req, socket) => {
|
|
authenticate();
|
|
socket.end("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n");
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address() as { port: number };
|
|
const upgrade = (path: string) => new Promise<number>((resolve, reject) => {
|
|
const req = httpRequest({
|
|
host: "127.0.0.1", port: address.port, path,
|
|
headers: { connection: "Upgrade", upgrade: "websocket", authorization: "Bearer test-bearer" },
|
|
});
|
|
req.on("response", (res) => { res.resume(); resolve(res.statusCode!); });
|
|
req.on("upgrade", (res, socket) => { socket.destroy(); resolve(res.statusCode!); });
|
|
req.on("error", reject);
|
|
req.end();
|
|
});
|
|
try {
|
|
for (const path of ["/api/companies/company/events/ws?token=anything", "/api/health", "/prp", "/"]) {
|
|
expect(await upgrade(path)).toBe(503);
|
|
}
|
|
expect(authenticate).not.toHaveBeenCalled();
|
|
standby = false;
|
|
expect(await upgrade("/api/companies/company/events/ws?token=anything")).toBe(101);
|
|
expect(authenticate).toHaveBeenCalledOnce();
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
it("MCP event work does not read the persisted setting until claim", async () => {
|
|
vi.useFakeTimers();
|
|
let standby = true;
|
|
const isEnabled = vi.fn().mockResolvedValue(false);
|
|
const db = installDatabaseWorkSignals({ execute: vi.fn(), transaction: vi.fn() }) as unknown as Db;
|
|
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !standby, onError: vi.fn() });
|
|
const events = createPublicMcpEvents(db, { isEnabled } as PublicMcpOAuth, vi.fn(), {
|
|
isBackgroundWorkEnabled: () => !standby,
|
|
});
|
|
try {
|
|
await events.start(coordinator).ready;
|
|
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
|
await events.tick();
|
|
expect(isEnabled).not.toHaveBeenCalled();
|
|
standby = false;
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(isEnabled).toHaveBeenCalledOnce();
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
expect(isEnabled).toHaveBeenCalledOnce();
|
|
} finally {
|
|
await events.stop();
|
|
await coordinator.stop();
|
|
}
|
|
});
|
|
|
|
it("email and plugin timers leave SQL idle, then resume without restarting", async () => {
|
|
vi.useFakeTimers();
|
|
let enabled = false;
|
|
const select = vi.fn(() => { throw new Error("SQL probe"); });
|
|
const db = installDatabaseWorkSignals({ select, execute: vi.fn(), transaction: vi.fn() }) as unknown as Db;
|
|
const email = emailChannelService(db, { heartbeat: { wakeup: vi.fn() }, isBackgroundWorkEnabled: () => enabled });
|
|
const scheduler = createPluginJobScheduler({
|
|
db,
|
|
jobStore: {} as never,
|
|
workerManager: {} as never,
|
|
tickIntervalMs: 1000,
|
|
isBackgroundWorkEnabled: () => enabled,
|
|
});
|
|
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => enabled, onError: () => {} });
|
|
try {
|
|
email.start();
|
|
await scheduler.start(coordinator);
|
|
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
|
expect(select).not.toHaveBeenCalled();
|
|
enabled = true;
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(select.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
} finally {
|
|
await scheduler.stop();
|
|
await coordinator.stop();
|
|
await email.shutdown();
|
|
}
|
|
});
|
|
});
|