Files
PaperClipAI/server/src/__tests__/plugin-environment-driver-ready-recovery.test.ts
T
Devin FoleyandPaperclip 78f8c6c3d4 Recover managed bundled plugin workers on demand (#10429)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Managed deployments can auto-provision bundled sandbox-provider
plugins so cloud or remote execution environments appear in the board UI
> - In a multi-service deployment, several server processes can share
one database and boot concurrently
> - A sibling process can create a bundled plugin row while the web
process sees it before it reaches `ready`
> - The web process correctly avoids clobbering the existing row, but
its startup `loadAll()` can miss the plugin and never start that worker
locally
> - The environments capabilities route then filters out the sandbox
provider because the plugin is ready in the database but not running in
the web process
> - This pull request adds a narrow managed-bundle recovery path that
lazily starts the missing worker when the capabilities route sees a
ready managed bundled plugin
> - The benefit is that the sandbox provider becomes visible after the
install finishes, without requiring a web-process restart

## Linked Issues or Issue Description

- No public GitHub issue found for this exact deployment race.
- Related broad plugin runtime context: Refs #432.

Bug description:

- What happened: in a managed multi-service deployment with shared
database state and bundled plugin auto-install enabled, the API-serving
process can skip a plugin row while it is still `installed`, run startup
plugin loading before that row becomes `ready`, and then permanently
omit the sandbox provider from environment capabilities.
- Expected behavior: once the managed bundled plugin row reaches
`ready`, the API-serving process should be able to start the plugin
worker and include its sandbox provider without a restart.
- Steps to reproduce: boot a web process and a sibling worker process
concurrently; have the sibling create the bundled plugin row and
transition it to `ready` after the web process has already skipped
auto-install and run `loadAll()`.
- Deployment mode: managed multi-service deployment with shared database
state and `plugins.autoInstall` configured.

## What Changed

- Added a managed bundled plugin worker recovery helper that
single-flights lazy `loadSingle()` starts and only allows configured
managed bundled plugin keys.
- Passed the managed recovery hook into the environments capabilities
route.
- Updated `listReadyPluginEnvironmentDrivers()` to attempt bounded
recovery for ready managed bundled plugins whose worker is missing in
the current process, and only for plugins that actually declare a
`sandbox_provider` environment driver.
- Made request-time recovery use `loadSingle(id, { markErrorOnFailure:
false })` so a local activation failure in one process never transitions
the shared plugin row to `error` (a sibling process may be running the
plugin successfully).
- When error writes are suppressed and activation fails after the worker
was spawned, the loader now tears down the partially-registered local
runtime (scheduler registration, event subscriptions, agent tools,
worker process) instead of leaving a half-activated worker lingering;
the teardown steps are factored out of `unloadSingle()` into a shared
helper.
- A failed recovery attempt now discards the crashed/stopped handle it
left registered in the worker manager (a worker that dies during
initialize is killed without a scheduled restart), so later capability
requests can retry recovery instead of being blocked by the
handle-presence gate until a process restart. Handles in
starting/running/backoff states are left to the worker manager's own
lifecycle; recovery only ever starts when no handle existed, so no
pre-existing worker can be affected.
- Added a regression test suite covering the installed-to-ready race,
allowlist behavior, the driver-kind gate, existing worker handles,
concurrent single-flight recovery, bounded slow recovery attempts,
suppressed shared error-state writes, partial-runtime teardown on late
activation failure, and retry after a dead handle is discarded.

## Verification

- `pnpm vitest run
src/__tests__/plugin-environment-driver-ready-recovery.test.ts` (in
`server/`) passed: 10 tests.
- `pnpm --filter @paperclipai/server typecheck` passed.

## Risks

- Low risk for self-hosted single-process deployments because lazy
recovery is only wired when managed plugin auto-install config is
present; with no managed config the capabilities route takes the exact
pre-change code path.
- The capabilities route can wait briefly while attempting recovery; the
attempt is bounded and defaults to 2 seconds.
- Failed recovery keeps the prior behavior of omitting the provider
until a later successful worker start, and now also cleans up any
partially-started local worker so retries begin from a clean slate.

## Model Used

- Initial implementation: OpenAI GPT-5 via Codex local coding agent,
with repository tool use and command execution.
- Review-feedback follow-ups (driver-kind gate, partial-runtime
teardown, expanded regression tests): Claude Fable 5 (claude-fable-5)
via Claude Code, with repository tool use and command execution.

## 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
- [x] All Paperclip CI gates are green
- [x] 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-07-29 12:50:16 -07:00

516 lines
16 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 = { 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: {},
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();
}
});
});