mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +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>
593 lines
19 KiB
TypeScript
593 lines
19 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
|
import { createManagedBundledPluginWorkerRecovery } from "../app.js";
|
|
import { listReadyPluginEnvironmentDrivers } from "../services/plugin-environment-driver.js";
|
|
import { pluginLoader, type PluginLoader } from "../services/plugin-loader.js";
|
|
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
|
|
|
const mockRegistry = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
list: vi.fn(),
|
|
listConfigs: vi.fn(),
|
|
update: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../services/plugin-registry.js", () => ({
|
|
pluginRegistryService: () => mockRegistry,
|
|
}));
|
|
|
|
const PLUGIN_ID = "plugin-daytona";
|
|
const PLUGIN_KEY = "paperclip.daytona-sandbox-provider";
|
|
|
|
const manifest: PaperclipPluginManifestV1 = {
|
|
id: PLUGIN_KEY,
|
|
apiVersion: 1,
|
|
version: "1.0.0",
|
|
displayName: "Daytona Sandbox Provider",
|
|
description: "Provides Daytona-backed sandboxes.",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["environment.drivers.register"],
|
|
entrypoints: { worker: "dist/worker.js" },
|
|
environmentDrivers: [
|
|
{
|
|
driverKey: "daytona",
|
|
kind: "sandbox_provider",
|
|
displayName: "Daytona",
|
|
description: "Daytona sandbox provider",
|
|
configSchema: { type: "object", properties: {} },
|
|
},
|
|
],
|
|
};
|
|
|
|
function createPlugin(status: string, options: { id?: string; pluginKey?: string } = {}) {
|
|
return {
|
|
id: options.id ?? PLUGIN_ID,
|
|
pluginKey: options.pluginKey ?? PLUGIN_KEY,
|
|
status,
|
|
manifestJson: manifest,
|
|
};
|
|
}
|
|
|
|
function createWorkerManager(options: {
|
|
running?: boolean;
|
|
hasHandle?: boolean;
|
|
} = {}) {
|
|
let running = options.running ?? false;
|
|
const workerManager = {
|
|
isRunning: vi.fn(() => running),
|
|
getWorker: vi.fn(() => (options.hasHandle ? { status: "backoff" } : undefined)),
|
|
} as unknown as PluginWorkerManager;
|
|
return {
|
|
workerManager,
|
|
markRunning: () => {
|
|
running = true;
|
|
},
|
|
};
|
|
}
|
|
|
|
function createDeferred<T = void>() {
|
|
let resolve!: (value?: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
describe("listReadyPluginEnvironmentDrivers worker recovery", () => {
|
|
beforeEach(() => {
|
|
mockRegistry.getById.mockReset();
|
|
mockRegistry.list.mockReset();
|
|
mockRegistry.listConfigs.mockReset();
|
|
mockRegistry.update.mockReset();
|
|
});
|
|
|
|
it("recovers a managed bundled provider that was installed by a sibling process after this process skipped it", async () => {
|
|
const plugin = createPlugin("installed");
|
|
mockRegistry.list.mockImplementation(async () => [plugin]);
|
|
const worker = createWorkerManager();
|
|
const startWorker = vi.fn(async () => {
|
|
worker.markRunning();
|
|
return true;
|
|
});
|
|
|
|
await expect(
|
|
listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager: worker.workerManager,
|
|
recoverMissingWorker: {
|
|
pluginKeys: [PLUGIN_KEY],
|
|
startWorker,
|
|
},
|
|
}),
|
|
).resolves.toEqual([]);
|
|
expect(startWorker).not.toHaveBeenCalled();
|
|
|
|
plugin.status = "ready";
|
|
const drivers = await listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager: worker.workerManager,
|
|
recoverMissingWorker: {
|
|
pluginKeys: [PLUGIN_KEY],
|
|
startWorker,
|
|
},
|
|
});
|
|
|
|
expect(startWorker).toHaveBeenCalledWith({
|
|
id: PLUGIN_ID,
|
|
pluginKey: PLUGIN_KEY,
|
|
});
|
|
expect(drivers).toEqual([
|
|
expect.objectContaining({
|
|
pluginId: PLUGIN_ID,
|
|
pluginKey: PLUGIN_KEY,
|
|
driverKey: "daytona",
|
|
displayName: "Daytona",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("does not lazy-start ready plugins outside the managed bundled allowlist", async () => {
|
|
const plugin = createPlugin("ready");
|
|
mockRegistry.list.mockResolvedValue([plugin]);
|
|
const worker = createWorkerManager();
|
|
const startWorker = vi.fn(async () => {
|
|
worker.markRunning();
|
|
return true;
|
|
});
|
|
|
|
const drivers = await listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager: worker.workerManager,
|
|
recoverMissingWorker: {
|
|
pluginKeys: ["paperclip.kubernetes-sandbox-provider"],
|
|
startWorker,
|
|
},
|
|
});
|
|
|
|
expect(startWorker).not.toHaveBeenCalled();
|
|
expect(drivers).toEqual([]);
|
|
});
|
|
|
|
it("does not lazy-start managed bundled plugins without sandbox provider drivers", async () => {
|
|
const plugin = {
|
|
...createPlugin("ready"),
|
|
manifestJson: {
|
|
...manifest,
|
|
capabilities: [],
|
|
environmentDrivers: undefined,
|
|
},
|
|
};
|
|
mockRegistry.list.mockResolvedValue([plugin]);
|
|
const worker = createWorkerManager();
|
|
const startWorker = vi.fn(async () => {
|
|
worker.markRunning();
|
|
return true;
|
|
});
|
|
|
|
const drivers = await listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager: worker.workerManager,
|
|
recoverMissingWorker: {
|
|
pluginKeys: [PLUGIN_KEY],
|
|
startWorker,
|
|
},
|
|
});
|
|
|
|
expect(startWorker).not.toHaveBeenCalled();
|
|
expect(drivers).toEqual([]);
|
|
});
|
|
|
|
it("leaves existing worker-manager recovery handles alone", async () => {
|
|
const plugin = createPlugin("ready");
|
|
mockRegistry.list.mockResolvedValue([plugin]);
|
|
const worker = createWorkerManager({ hasHandle: true });
|
|
const startWorker = vi.fn(async () => true);
|
|
|
|
const drivers = await listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager: worker.workerManager,
|
|
recoverMissingWorker: {
|
|
pluginKeys: [PLUGIN_KEY],
|
|
startWorker,
|
|
},
|
|
});
|
|
|
|
expect(startWorker).not.toHaveBeenCalled();
|
|
expect(drivers).toEqual([]);
|
|
});
|
|
|
|
it("single-flights concurrent managed bundled recovery starts", async () => {
|
|
const plugin = createPlugin("ready");
|
|
mockRegistry.list.mockResolvedValue([plugin]);
|
|
const worker = createWorkerManager();
|
|
const loadStarted = createDeferred();
|
|
const releaseLoad = createDeferred();
|
|
const loadSingle = vi.fn(async () => {
|
|
loadStarted.resolve();
|
|
await releaseLoad.promise;
|
|
worker.markRunning();
|
|
return { success: true };
|
|
});
|
|
const startWorker = createManagedBundledPluginWorkerRecovery({
|
|
managedBundledPluginKeys: [PLUGIN_KEY],
|
|
workerManager: worker.workerManager,
|
|
getLoader: () => ({ loadSingle }) as Pick<PluginLoader, "loadSingle">,
|
|
});
|
|
|
|
const first = listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager: worker.workerManager,
|
|
recoverMissingWorker: {
|
|
pluginKeys: [PLUGIN_KEY],
|
|
startWorker,
|
|
},
|
|
});
|
|
await loadStarted.promise;
|
|
const second = listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager: worker.workerManager,
|
|
recoverMissingWorker: {
|
|
pluginKeys: [PLUGIN_KEY],
|
|
startWorker,
|
|
},
|
|
});
|
|
|
|
releaseLoad.resolve();
|
|
const [firstDrivers, secondDrivers] = await Promise.all([first, second]);
|
|
|
|
expect(loadSingle).toHaveBeenCalledTimes(1);
|
|
expect(loadSingle).toHaveBeenCalledWith(PLUGIN_ID, {
|
|
markErrorOnFailure: false,
|
|
});
|
|
expect(firstDrivers).toEqual([
|
|
expect.objectContaining({
|
|
pluginId: PLUGIN_ID,
|
|
driverKey: "daytona",
|
|
}),
|
|
]);
|
|
expect(secondDrivers).toEqual(firstDrivers);
|
|
});
|
|
|
|
it("keeps request-time recovery failures process-local instead of marking shared plugin state errored", async () => {
|
|
const plugin = createPlugin("ready");
|
|
mockRegistry.list.mockResolvedValue([plugin]);
|
|
const worker = createWorkerManager();
|
|
const loadSingle = vi.fn(async () => ({
|
|
plugin,
|
|
success: false,
|
|
registered: { worker: false, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 },
|
|
error: "local worker spawn failed",
|
|
}));
|
|
const startWorker = createManagedBundledPluginWorkerRecovery({
|
|
managedBundledPluginKeys: [PLUGIN_KEY],
|
|
workerManager: worker.workerManager,
|
|
getLoader: () => ({ loadSingle }) as Pick<PluginLoader, "loadSingle">,
|
|
});
|
|
|
|
const drivers = await listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager: worker.workerManager,
|
|
recoverMissingWorker: {
|
|
pluginKeys: [PLUGIN_KEY],
|
|
startWorker,
|
|
},
|
|
});
|
|
|
|
expect(loadSingle).toHaveBeenCalledWith(PLUGIN_ID, {
|
|
markErrorOnFailure: false,
|
|
});
|
|
expect(drivers).toEqual([]);
|
|
});
|
|
|
|
it("discards the dead handle a failed recovery attempt leaves behind so later requests retry", async () => {
|
|
const plugin = createPlugin("ready");
|
|
mockRegistry.list.mockResolvedValue([plugin]);
|
|
|
|
// Simulates the initialize-failure path: startWorker registers a handle,
|
|
// the worker dies during initialize, and no restart is scheduled — the
|
|
// crashed handle stays registered in the worker manager.
|
|
let handle: { status: string } | undefined;
|
|
let running = false;
|
|
const stopWorker = vi.fn(async () => {
|
|
handle = undefined;
|
|
});
|
|
const workerManager = {
|
|
isRunning: vi.fn(() => running),
|
|
getWorker: vi.fn(() => handle),
|
|
stopWorker,
|
|
} as unknown as PluginWorkerManager;
|
|
|
|
const loadSingle = vi.fn<PluginLoader["loadSingle"]>()
|
|
.mockImplementationOnce(async () => {
|
|
handle = { status: "crashed" };
|
|
throw new Error("Worker initialize failed");
|
|
})
|
|
.mockImplementationOnce(async () => {
|
|
handle = { status: "running" };
|
|
running = true;
|
|
return { plugin, success: true } as never;
|
|
});
|
|
|
|
const startWorker = createManagedBundledPluginWorkerRecovery({
|
|
managedBundledPluginKeys: [PLUGIN_KEY],
|
|
workerManager,
|
|
getLoader: () => ({ loadSingle }) as Pick<PluginLoader, "loadSingle">,
|
|
});
|
|
|
|
const firstDrivers = await listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager,
|
|
recoverMissingWorker: {
|
|
pluginKeys: [PLUGIN_KEY],
|
|
startWorker,
|
|
},
|
|
});
|
|
|
|
expect(firstDrivers).toEqual([]);
|
|
expect(stopWorker).toHaveBeenCalledWith(PLUGIN_ID);
|
|
expect(handle).toBeUndefined();
|
|
|
|
const secondDrivers = await listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager,
|
|
recoverMissingWorker: {
|
|
pluginKeys: [PLUGIN_KEY],
|
|
startWorker,
|
|
},
|
|
});
|
|
|
|
expect(loadSingle).toHaveBeenCalledTimes(2);
|
|
expect(secondDrivers).toEqual([
|
|
expect.objectContaining({
|
|
pluginId: PLUGIN_ID,
|
|
driverKey: "daytona",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("lets callers suppress shared error-state writes on activation failure", async () => {
|
|
const plugin = {
|
|
...createPlugin("ready"),
|
|
packageName: "@paperclipai/missing-sandbox-provider",
|
|
packagePath: null,
|
|
version: "1.0.0",
|
|
};
|
|
mockRegistry.getById.mockResolvedValue(plugin);
|
|
const lifecycleManager = {
|
|
markError: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
const loader = pluginLoader(
|
|
{} as never,
|
|
{
|
|
enableLocalFilesystem: false,
|
|
enableNpmDiscovery: false,
|
|
localPluginDir: "__missing_plugin_dir__",
|
|
},
|
|
{
|
|
lifecycleManager,
|
|
workerManager: {},
|
|
eventBus: {},
|
|
jobScheduler: {},
|
|
jobStore: {},
|
|
toolDispatcher: {},
|
|
buildHostHandlers: vi.fn(),
|
|
instanceInfo: {
|
|
instanceId: "test",
|
|
hostVersion: "0.0.0",
|
|
},
|
|
} as never,
|
|
);
|
|
|
|
await expect(loader.loadSingle(PLUGIN_ID)).resolves.toMatchObject({
|
|
success: false,
|
|
error: expect.stringContaining("Package root not found"),
|
|
});
|
|
expect(lifecycleManager.markError).toHaveBeenCalledTimes(1);
|
|
|
|
lifecycleManager.markError.mockClear();
|
|
await expect(loader.loadSingle(PLUGIN_ID, { markErrorOnFailure: false })).resolves.toMatchObject({
|
|
success: false,
|
|
error: expect.stringContaining("Package root not found"),
|
|
});
|
|
expect(lifecycleManager.markError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("tears down partially-activated runtime state when error writes are suppressed", async () => {
|
|
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-recovery-fixture-"));
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(fixtureDir, "package.json"),
|
|
JSON.stringify({
|
|
name: "@paperclipai/daytona-sandbox-provider",
|
|
version: "1.0.0",
|
|
paperclipPlugin: { manifest: "manifest.mjs" },
|
|
}),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(fixtureDir, "manifest.mjs"),
|
|
`export default ${JSON.stringify(manifest)};`,
|
|
);
|
|
fs.mkdirSync(path.join(fixtureDir, "dist"));
|
|
fs.writeFileSync(path.join(fixtureDir, "dist", "worker.js"), "");
|
|
|
|
const plugin = {
|
|
...createPlugin("ready"),
|
|
packageName: "@paperclipai/daytona-sandbox-provider",
|
|
packagePath: fixtureDir,
|
|
version: "1.0.0",
|
|
};
|
|
mockRegistry.getById.mockResolvedValue(plugin);
|
|
mockRegistry.listConfigs.mockResolvedValue([]);
|
|
mockRegistry.update.mockResolvedValue(undefined);
|
|
|
|
const lifecycleManager = { markError: vi.fn().mockResolvedValue(undefined) };
|
|
const workerManager = {
|
|
startWorker: vi.fn().mockResolvedValue(undefined),
|
|
isRunning: vi.fn(() => true),
|
|
stopWorker: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
const eventBus = {
|
|
forPlugin: vi.fn(() => {
|
|
throw new Error("event bus offline");
|
|
}),
|
|
clearPlugin: vi.fn(),
|
|
subscriptionCount: vi.fn(() => 0),
|
|
};
|
|
const jobScheduler = { registerPlugin: vi.fn().mockResolvedValue(undefined), unregisterPlugin: vi.fn().mockResolvedValue(undefined) };
|
|
const toolDispatcher = { unregisterPluginTools: vi.fn(), registerPluginTools: vi.fn() };
|
|
|
|
const loader = pluginLoader(
|
|
{} as never,
|
|
{
|
|
enableLocalFilesystem: false,
|
|
enableNpmDiscovery: false,
|
|
localPluginDir: "__missing_plugin_dir__",
|
|
},
|
|
{
|
|
lifecycleManager,
|
|
workerManager,
|
|
eventBus,
|
|
jobScheduler,
|
|
jobStore: { syncJobDeclarations: vi.fn().mockResolvedValue(undefined) },
|
|
toolDispatcher,
|
|
buildHostHandlers: vi.fn(() => ({})),
|
|
instanceInfo: {
|
|
instanceId: "test",
|
|
hostVersion: "0.0.0",
|
|
},
|
|
} as never,
|
|
);
|
|
|
|
await expect(loader.loadSingle(PLUGIN_ID, { markErrorOnFailure: false })).resolves.toMatchObject({
|
|
success: false,
|
|
error: expect.stringContaining("event bus offline"),
|
|
});
|
|
|
|
expect(lifecycleManager.markError).not.toHaveBeenCalled();
|
|
expect(jobScheduler.unregisterPlugin).toHaveBeenCalledWith(PLUGIN_ID);
|
|
expect(eventBus.clearPlugin).toHaveBeenCalledWith(PLUGIN_KEY);
|
|
expect(toolDispatcher.unregisterPluginTools).toHaveBeenCalledWith(PLUGIN_KEY);
|
|
expect(workerManager.stopWorker).toHaveBeenCalledWith(PLUGIN_ID);
|
|
} finally {
|
|
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("bounds slow managed bundled recovery attempts without serial waits", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const plugins = [
|
|
createPlugin("ready"),
|
|
createPlugin("ready", {
|
|
id: "plugin-modal",
|
|
pluginKey: "paperclip.modal-sandbox-provider",
|
|
}),
|
|
];
|
|
mockRegistry.list.mockResolvedValue(plugins);
|
|
const worker = createWorkerManager();
|
|
const startWorker = vi.fn(() => new Promise<boolean>(() => {}));
|
|
|
|
const driversPromise = listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager: worker.workerManager,
|
|
recoverMissingWorker: {
|
|
pluginKeys: [PLUGIN_KEY, "paperclip.modal-sandbox-provider"],
|
|
startWorker,
|
|
timeoutMs: 25,
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
|
|
expect(startWorker).toHaveBeenCalledTimes(2);
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
|
|
await expect(driversPromise).resolves.toEqual([]);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("listReadyPluginEnvironmentDrivers reusable-lease method verification", () => {
|
|
beforeEach(() => {
|
|
mockRegistry.getById.mockReset();
|
|
mockRegistry.list.mockReset();
|
|
mockRegistry.listConfigs.mockReset();
|
|
mockRegistry.update.mockReset();
|
|
});
|
|
|
|
function createRunningWorker(supportedMethods: string[]) {
|
|
return {
|
|
isRunning: vi.fn(() => true),
|
|
getWorker: vi.fn(() => ({ supportedMethods })),
|
|
} as unknown as PluginWorkerManager;
|
|
}
|
|
|
|
it("verifies reusable-lease methods when the worker advertises all lifecycle methods", async () => {
|
|
mockRegistry.list.mockResolvedValue([createPlugin("ready")]);
|
|
const drivers = await listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager: createRunningWorker([
|
|
"environmentResumeLease",
|
|
"environmentReleaseLease",
|
|
"environmentDestroyLease",
|
|
"environmentExecute",
|
|
]),
|
|
});
|
|
|
|
expect(drivers).toHaveLength(1);
|
|
expect(drivers[0]?.reusableLeaseMethodsVerified).toBe(true);
|
|
});
|
|
|
|
it("does not verify reusable-lease methods when the worker omits the resume method", async () => {
|
|
mockRegistry.list.mockResolvedValue([createPlugin("ready")]);
|
|
const drivers = await listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager: createRunningWorker([
|
|
"environmentReleaseLease",
|
|
"environmentDestroyLease",
|
|
]),
|
|
});
|
|
|
|
expect(drivers).toHaveLength(1);
|
|
expect(drivers[0]?.reusableLeaseMethodsVerified).toBe(false);
|
|
});
|
|
|
|
it("does not verify reusable-lease methods when the worker omits the release method", async () => {
|
|
mockRegistry.list.mockResolvedValue([createPlugin("ready")]);
|
|
const drivers = await listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager: createRunningWorker([
|
|
"environmentResumeLease",
|
|
"environmentDestroyLease",
|
|
]),
|
|
});
|
|
|
|
expect(drivers).toHaveLength(1);
|
|
expect(drivers[0]?.reusableLeaseMethodsVerified).toBe(false);
|
|
});
|
|
|
|
it("does not verify reusable-lease methods when the worker omits the destroy method", async () => {
|
|
// A provider that resumes and releases but cannot destroy a stale lease must
|
|
// not present as reusable. The reuse path destroys the stale lease when a
|
|
// resume fails, so without destroy the runtime would strand the lease.
|
|
mockRegistry.list.mockResolvedValue([createPlugin("ready")]);
|
|
const drivers = await listReadyPluginEnvironmentDrivers({
|
|
db: {} as never,
|
|
workerManager: createRunningWorker([
|
|
"environmentResumeLease",
|
|
"environmentReleaseLease",
|
|
]),
|
|
});
|
|
|
|
expect(drivers).toHaveLength(1);
|
|
expect(drivers[0]?.reusableLeaseMethodsVerified).toBe(false);
|
|
});
|
|
});
|