mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 12:29:37 +01:00
Reviewed and verified at 9b4acc456d.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
125 lines
6.6 KiB
TypeScript
125 lines
6.6 KiB
TypeScript
import { pluginIdleManifestGeneration } from "../services/plugin-idle-catalog.js";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createRequire } from "node:module";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { createPluginWorkerHandle, createPluginWorkerManager } from "../services/plugin-worker-manager.js";
|
|
import { startTaskDrain, stopTaskDrain } from "../services/task-admission.js";
|
|
|
|
const manifest = { id: "test.idle", apiVersion: 1 as const, version: "1.0.0", displayName: "Idle fixture",
|
|
description: "Idle fixture", author: "Paperclip", categories: ["automation" as const], capabilities: [], entrypoints: { worker: "worker.cjs" } };
|
|
function worker(write = false, hostHandler = async () => undefined) {
|
|
return createPluginWorkerHandle("test.idle", {
|
|
entrypointPath: fileURLToPath(new URL("./fixtures/plugin-worker-idle.cjs", import.meta.url)),
|
|
execArgv: ["--import", createRequire(import.meta.url).resolve("tsx")],
|
|
manifest, config: {}, instanceInfo: { instanceId: "fixture", hostVersion: "1.0.0" }, apiVersion: 1,
|
|
env: { IDLE_TEST_WRITE: write ? "1" : "0" },
|
|
hostHandlers: { "state.set": hostHandler, "events.subscribe": async () => ({}) },
|
|
});
|
|
}
|
|
function hold() {
|
|
const lease = startTaskDrain({ purpose: "idle", ttlMs: 30_000 });
|
|
return { ownerId: lease.ownerId!, expiresAt: lease.expiresAt!.getTime() };
|
|
}
|
|
|
|
describe("plugin manager idle receipts", () => {
|
|
afterEach(() => stopTaskDrain());
|
|
it("binds a quiet catalog to the actual started manifest and replacement worker", async () => {
|
|
const manager = createPluginWorkerManager();
|
|
const options = (version: string) => ({
|
|
entrypointPath: fileURLToPath(new URL("./fixtures/plugin-worker-idle.cjs", import.meta.url)),
|
|
execArgv: ["--import", createRequire(import.meta.url).resolve("tsx")],
|
|
manifest: { ...manifest, version }, config: {}, instanceInfo: { instanceId: "fixture", hostVersion: "1.0.0" }, apiVersion: 1,
|
|
hostHandlers: { "events.subscribe": async () => ({}) },
|
|
});
|
|
try {
|
|
await manager.startWorker("test.idle", options("1.0.0"));
|
|
const lease = hold();
|
|
const first = await manager.inspectIdleSleep!(lease);
|
|
expect(first.backgroundWork).toBe("none");
|
|
expect(first.catalog).toEqual([{ pluginId: "test.idle", manifestGeneration: pluginIdleManifestGeneration(manifest) }]);
|
|
expect((await manager.inspectIdleSleep!(lease)).catalogGeneration).toBe(first.catalogGeneration);
|
|
stopTaskDrain(); manager.releaseIdleSleep!();
|
|
await manager.stopWorker("test.idle");
|
|
await manager.startWorker("test.idle", options("1.0.1"));
|
|
const replacement = await manager.inspectIdleSleep!(hold());
|
|
expect(replacement.backgroundWork).toBe("none");
|
|
expect(replacement.catalogGeneration).not.toBe(first.catalogGeneration);
|
|
expect(replacement.catalog?.[0].manifestGeneration).not.toBe(first.catalog?.[0].manifestGeneration);
|
|
} finally { stopTaskDrain(); await manager.stopAll(); }
|
|
});
|
|
|
|
it("retains timed-out worker operations and admits work after the owned hold releases", async () => {
|
|
const handle = worker();
|
|
await handle.start();
|
|
try {
|
|
await expect(handle.call("environmentProbe", { driverKey: "fixture", companyId: "fixture", environmentId: "fixture", config: { delayMs: 200 } }, 10)).rejects.toThrow("timed out");
|
|
const lease = hold();
|
|
expect(await handle.prepareIdleSleep!(lease)).toBe("present");
|
|
await expect.poll(() => handle.prepareIdleSleep!(lease)).toBe("none");
|
|
await expect(handle.call("health", {})).rejects.toThrow("held");
|
|
expect(await handle.prepareIdleSleep!({ ...lease, ownerId: "stale" })).toBe("unknown");
|
|
stopTaskDrain(); handle.releaseIdleSleep!();
|
|
expect(await handle.call("health", {})).toMatchObject({ status: "ok" });
|
|
expect(await handle.prepareIdleSleep!(hold())).toBe("none");
|
|
stopTaskDrain();
|
|
// No intervening request or explicit worker release: the next controller
|
|
// attempt must discard the worker manager's previous ownership record.
|
|
expect(await handle.prepareIdleSleep!(hold())).toBe("none");
|
|
} finally { stopTaskDrain(); await handle.stop(); }
|
|
});
|
|
|
|
it("keeps a host write counted after the caller stops waiting", async () => {
|
|
let finish!: () => void;
|
|
const handle = worker(true, () => new Promise<void>((resolve) => { finish = resolve; }));
|
|
await handle.start();
|
|
try {
|
|
await expect(handle.call("health", {}, 30)).rejects.toThrow("timed out");
|
|
const lease = hold();
|
|
expect(await handle.prepareIdleSleep!(lease)).toBe("present");
|
|
finish();
|
|
await expect.poll(() => handle.prepareIdleSleep!(lease)).toBe("none");
|
|
} finally { stopTaskDrain(); await handle.stop(); }
|
|
});
|
|
|
|
it("lets a queued notification finish its write while idle preparation is in flight", async () => {
|
|
let finish!: () => void;
|
|
let writes = 0;
|
|
const handle = worker(false, () => new Promise<void>((resolve) => {
|
|
writes++;
|
|
finish = resolve;
|
|
}));
|
|
await handle.start();
|
|
try {
|
|
// Finish setup and its subscription receipt before queuing the event.
|
|
await handle.call("health", {});
|
|
handle.notify("onEvent", { event: { eventType: "fixture.write" } });
|
|
// No await: the host has set its hold before it can read the event's
|
|
// worker-to-host write. The worker must still be allowed to complete it.
|
|
const lease = hold();
|
|
const preparation = handle.prepareIdleSleep!(lease);
|
|
expect(await preparation).not.toBe("none");
|
|
await expect.poll(() => writes).toBe(1);
|
|
expect(await handle.prepareIdleSleep!(lease)).toBe("present");
|
|
finish();
|
|
await expect.poll(() => handle.prepareIdleSleep!(lease)).toBe("none");
|
|
} finally { finish?.(); stopTaskDrain(); await handle.stop(); }
|
|
});
|
|
|
|
it("resumes crash recovery after an idle hold without discarding the restart", async () => {
|
|
const handle = worker();
|
|
await handle.start();
|
|
handle.on("crash", () => { hold(); });
|
|
try {
|
|
await expect(handle.call("environmentProbe", { driverKey: "fixture", companyId: "fixture", environmentId: "fixture", config: { crash: true } })).rejects.toThrow();
|
|
await delay(1_600);
|
|
expect(handle.status).toBe("backoff");
|
|
expect(handle.diagnostics().nextRestartAt).not.toBeNull();
|
|
stopTaskDrain();
|
|
await expect.poll(() => handle.status, { timeout: 5_000 }).toBe("running");
|
|
// Recovery restores service, but lost completion receipts remain unsafe.
|
|
expect(await handle.prepareIdleSleep!(hold())).toBe("unknown");
|
|
} finally { stopTaskDrain(); await handle.stop(); }
|
|
});
|
|
});
|