fix(plugins): retry a plugin activation whose worker initialize timed out (#20982)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Plugins, sandbox providers such as Daytona among them, run as worker
processes that the server activates at boot. Activation sends the worker
an `initialize` RPC with a 15-second timeout.
> - When `initialize` timed out, the loader marked the plugin `error`
for the life of the server process. Only the next boot or an operator
retried it.
> - For a sandbox provider, that means every Daytona run on that server
fails. New runs fail with `lease_acquire_failed … plugin is currently
error`. Reattachments fail with "The exact sandbox lease cannot be
resumed by its verified provider". Retry hits the same dead plugin.
> - A worker that is slow or stalled for a moment at boot is transient,
not a broken plugin.
> - This pull request retries an activation whose `initialize` timed
out, with a bounded backoff. Meanwhile the server keeps serving, and
runs that need the plugin see a restarting worker, which setup retries.
Deterministic failures stay a permanent error.
> - The benefit is that a short plugin stall at boot no longer disables
a provider until the next deploy.

## Linked Issues or Issue Description

Refs #20966, #20857

**What happened?**
Chaos wave `w-20261111T232850-s9521` (cell CR4, first version) stopped
the replacement server's Daytona plugin worker while the server was
activating it. The worker's `initialize` RPC timed out after 15 s
(`INITIALIZE_TIMEOUT_MS`). `plugin-loader.ts` then called
`lifecycleManager.markError`, and the plugin stayed `error`. Task t03's
reattachment failed in `resumeRunLease`, and its Retry did not start a
new run. Task t04 failed 18 s after launch with `lease_acquire_failed`
("…plugin is currently error"), before its own fault.

**Expected behavior**
An activation timeout is retried, and the plugin ends `ready` when the
worker answers. Runs that need the plugin meanwhile are retried within
their bounded setup budget. A bad manifest, a worker that exits during
`initialize`, or a version mismatch is still a permanent error.

**Steps to reproduce**
1. Start the server.
2. Stop (SIGSTOP) a plugin worker for more than 15 s while the server
activates it.
3. Resume the worker. The plugin stays `error` until the next boot.

**Paperclip version or commit**
master at dfd2a6efca.

## What Changed

- `plugin-worker-manager.ts`: the "Worker initialize failed" error keeps
its cause, the `JsonRpcCallError` with `PLUGIN_RPC_ERROR_CODES.TIMEOUT`.
- `plugin-loader.ts`, `activatePlugin`:
- An activation whose error chain has the TIMEOUT code is retried after
5, 15, 45 and 120 s (`activationTimeoutRetryDelaysMs`).
- Before each retry, the loader tears down the partial runtime and keeps
the plugin `ready`. It sets `lastError` to "Activation timed out;
retrying in Ns (retry n of 4): …" so operators can see the state.
- A retry stops when the plugin is no longer `ready` or its worker
already runs. Shutdown clears pending retries.
- After the last retry, the loader calls `markError` with "Activation
timed out after 4 retries: …". Any other failure calls `markError` with
"Activation failed: …" at once, as before.
- `pluginLoader.requestActivationTimeoutRecovery(plugin)`: re-activates
a plugin in `error` whose `lastError` starts with "Activation timed
out", at most once per minute.
- `app.ts` exposes it through
`workerManager.requestActivationTimeoutRecovery`.
- `environment-runtime.ts`:
- Lease acquisition, for both pinned and key-resolved providers, calls
`requestActivationTimeoutRecovery` for such a plugin and reports
`worker_unavailable` (`PluginWorkerUnavailableError`, which setup
retries) instead of the terminal "plugin is currently error".
- `resumeRunLease` reports a stopped worker as
`PluginWorkerUnavailableError` when the plugin is `ready`, or is
re-activating from a timeout, instead of the generic "cannot be resumed
by its verified provider".
- This covers every plugin kind: activation retry is in the shared
loader. The on-demand path is in the sandbox-provider resolution, which
is the path a run waits on.
- `doc/plugins/PLUGIN_SPEC.md` §12 describes the rule.

## Verification

- Red on master:
`server/src/__tests__/plugin-loader-activation-timeout.test.ts` uses a
real PostgreSQL plugin registry and a bundled image fixture, and all 3
of its tests fail on master. On master, "initialize times out once, then
succeeds" stays `error` and the worker starts only once.
- Green, all 3 pass:
- A timeout followed by success ends `ready`, with a running worker and
no `markError`.
- Timeouts past the bound end `error` with the "Activation timed out
after 2 retries" message, and an on-demand request re-activates the
plugin to `ready`.
- A worker crash during `initialize` is a permanent error, with no retry
and no on-demand re-activation.
- `environment-runtime.test.ts` has new tests:
- A sandbox plugin errored by activation timeouts gives
`PluginWorkerUnavailableError` and requests re-activation. When no
re-activation is in flight, the error stays terminal.
- `resumeRunLease` on a stopped worker of a `ready` or re-activating
plugin gives `PluginWorkerUnavailableError` and sends no provider call.
- These files pass: `environment-runtime`,
`environment-run-orchestrator`, `plugin-loader-error-retry`,
`plugin-environment-driver-ready-recovery`,
`bundled-plugin-registration`, `plugin-lifecycle-restart`,
`plugin-lifecycle-delivery` and `plugin-dev-watcher`.
- `tsc --noEmit` passes for `server`.
- Chaos: cell CR6 (a plugin stalled during activation at boot) runs
before and after this change. The results are in a comment.

## Risks

- A plugin whose `initialize` keeps timing out now takes about 3 minutes
to reach `error`, instead of 15 s. During that time runs that need it
see a restarting worker and are retried within their setup budget.
- On-demand re-activation is limited to one attempt a minute per plugin,
so a stuck plugin cannot crash-loop.
- Nothing changes for deterministic activation failures.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code
with tool use (shell, file edit, code search). Extended reasoning was
on.

## 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-11 21:18:47 -06:00
1 parent 776e900b48
commit a9a08d8169
9 files changed
+424 -6

No files matched your search

+129 -2
View File
@@ -54,6 +54,7 @@ import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
import { pluginDatabaseService } from "./plugin-database.js";
import { resolveBundledCatalogRoot } from "./bundled-plugins.js";
import { managedPluginWorkerEnvGrant } from "./managed-plugin-worker-env.js";
import { createWorkScheduler } from "./work-scheduler.js";
const execFileAsync = promisify(execFile);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -318,6 +319,29 @@ export interface PluginLoaderOptions {
* Registry support is not yet implemented; this field is reserved.
*/
registryUrl?: string;
/** Delays between retries of an activation whose worker `initialize` timed out. */
activationTimeoutRetryDelaysMs?: readonly number[];
/** Shortest interval between on-demand re-activations of a plugin errored by activation timeouts. */
activationTimeoutOnDemandIntervalMs?: number;
}
export const PLUGIN_ACTIVATION_TIMEOUT_ERROR_PREFIX = "Activation timed out";
const DEFAULT_ACTIVATION_TIMEOUT_RETRY_DELAYS_MS = [5_000, 15_000, 45_000, 120_000] as const;
const DEFAULT_ACTIVATION_TIMEOUT_ON_DEMAND_INTERVAL_MS = 60_000;
export function isPluginActivationTimeout(error: unknown): boolean {
let current: unknown = error;
for (let depth = 0; depth < 6 && current instanceof Error; depth++) {
if ((current as { code?: unknown }).code === PLUGIN_RPC_ERROR_CODES.TIMEOUT) return true;
current = current.cause;
}
return false;
}
export function isPluginActivationTimeoutError(plugin: { status: string; lastError: string | null }): boolean {
return plugin.status === "error" && plugin.lastError?.startsWith(PLUGIN_ACTIVATION_TIMEOUT_ERROR_PREFIX) === true;
}
// ---------------------------------------------------------------------------
@@ -445,6 +469,7 @@ export interface PluginLoadSingleOptions {
interface PluginActivateOptions {
markErrorOnFailure: boolean;
activationTimeoutRetry?: number;
}
/**
@@ -646,6 +671,16 @@ export interface PluginLoader {
*/
unloadSingle(pluginId: string, pluginKey: string): Promise<void>;
/**
* Re-activate a plugin that is in `error` only because its worker's
* `initialize` timed out, at most once per interval. Returns whether an
* activation is now in flight; the caller treats the plugin as starting.
*/
requestActivationTimeoutRecovery(plugin: { id: string; status: string; lastError: string | null }): boolean;
/** Cancel pending activation-timeout retries and await the ones already running. */
stopActivationTimeoutRecovery(): Promise<void>;
/**
* Stop all managed plugin workers. Called during server shutdown.
*
@@ -1168,9 +1203,45 @@ export function pluginLoader(
assertPackageActivation,
resolvePackageRoot,
prebuiltOnly = false,
activationTimeoutRetryDelaysMs = DEFAULT_ACTIVATION_TIMEOUT_RETRY_DELAYS_MS,
activationTimeoutOnDemandIntervalMs = DEFAULT_ACTIVATION_TIMEOUT_ON_DEMAND_INTERVAL_MS,
} = options;
const registry = pluginRegistryService(db);
const activationRetryScheduler = createWorkScheduler();
const activeActivationRecoveries = new Set<Promise<unknown>>();
const onDemandActivations = new Map<string, { at: number; inFlight: boolean }>();
let shuttingDown = false;
function trackActivationRecovery(work: () => Promise<unknown>, label: string, pluginId: string) {
const running = work().catch((err: unknown) => {
log.warn({ pluginId, err: err instanceof Error ? err.message : String(err) }, label);
});
activeActivationRecoveries.add(running);
void running.finally(() => activeActivationRecoveries.delete(running));
}
async function stopActivationTimeoutRecovery() {
shuttingDown = true;
activationRetryScheduler.stop();
while (activeActivationRecoveries.size > 0) await Promise.allSettled([...activeActivationRecoveries]);
}
async function activateForRecovery(plugin: PluginRecord, activationTimeoutRetry: number) {
const result = await activatePlugin(plugin, { markErrorOnFailure: true, activationTimeoutRetry });
if (!result.success) return;
const latest = (await registry.getById(plugin.id)) as PluginRecord | null;
if (latest?.status === "ready" && !shuttingDown) return;
log.info({ pluginId: plugin.id, pluginKey: plugin.pluginKey, status: latest?.status ?? null },
"plugin-loader: stopping a recovered plugin that an operator changed during its activation");
await teardownPluginRuntime(plugin.id, plugin.pluginKey);
}
async function discardFailedWorkerHandle(pluginId: string) {
const handle = runtimeServices?.workerManager.getWorker(pluginId);
if (!handle || (handle.status !== "crashed" && handle.status !== "stopped")) return;
await runtimeServices!.workerManager.stopWorker(pluginId);
}
const manifestValidator = pluginManifestValidator();
const capabilityValidator = pluginCapabilityValidator();
const log = logger.child({ service: "plugin-loader" });
@@ -2143,7 +2214,31 @@ export function pluginLoader(
// shutdownAll
// -----------------------------------------------------------------------
requestActivationTimeoutRecovery(plugin) {
if (!runtimeServices || shuttingDown || !isPluginActivationTimeoutError(plugin)) return false;
const previous = onDemandActivations.get(plugin.id);
if (previous?.inFlight) return true;
if (previous && Date.now() - previous.at < activationTimeoutOnDemandIntervalMs) return false;
const attempt = { at: Date.now(), inFlight: true };
onDemandActivations.set(plugin.id, attempt);
trackActivationRecovery(async () => {
try {
const flipped = (await registry.updateStatusIf(plugin.id,
{ status: "error", lastErrorPrefix: PLUGIN_ACTIVATION_TIMEOUT_ERROR_PREFIX }, { status: "ready" })) as PluginRecord | null;
if (!flipped || shuttingDown) return;
log.info({ pluginId: plugin.id, pluginKey: flipped.pluginKey },
"plugin-loader: re-activating a plugin errored by activation timeouts on demand");
await discardFailedWorkerHandle(plugin.id);
await activateForRecovery(flipped, activationTimeoutRetryDelaysMs.length);
} finally { attempt.inFlight = false; }
}, "plugin-loader: on-demand re-activation failed", plugin.id);
return true;
},
stopActivationTimeoutRecovery,
async shutdownAll(): Promise<void> {
await stopActivationTimeoutRecovery();
if (!runtimeServices) {
throw new Error(
"Cannot shutdownAll: no PluginRuntimeServices provided.",
@@ -2506,6 +2601,12 @@ export function pluginLoader(
"plugin-loader: plugin activated successfully",
);
if (activePlugin.lastError?.startsWith(PLUGIN_ACTIVATION_TIMEOUT_ERROR_PREFIX)) {
activePlugin = ((await registry.updateStatusIf(pluginId,
{ status: "ready", lastErrorPrefix: PLUGIN_ACTIVATION_TIMEOUT_ERROR_PREFIX }, { status: "ready", lastError: null })) as PluginRecord | null)
?? activePlugin;
}
return { plugin: activePlugin, success: true, registered };
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -2515,13 +2616,39 @@ export function pluginLoader(
"plugin-loader: failed to activate plugin",
);
if (options.markErrorOnFailure) {
const timedOut = isPluginActivationTimeout(err);
const timeoutRetry = options.activationTimeoutRetry ?? 0;
if (options.markErrorOnFailure && timedOut && timeoutRetry < activationTimeoutRetryDelaysMs.length && !shuttingDown) {
const delayMs = activationTimeoutRetryDelaysMs[timeoutRetry]!;
try {
if (registered.worker) await teardownPluginRuntime(pluginId, pluginKey);
await discardFailedWorkerHandle(pluginId);
await registry.updateStatusIf(pluginId, { status: "ready" }, { status: "ready", lastError:
`${PLUGIN_ACTIVATION_TIMEOUT_ERROR_PREFIX}; retrying in ${Math.round(delayMs / 1000)}s `
+ `(retry ${timeoutRetry + 1} of ${activationTimeoutRetryDelaysMs.length}): ${errorMessage}` });
} catch (retryErr) {
log.warn({ pluginId, pluginKey, err: retryErr instanceof Error ? retryErr.message : String(retryErr) },
"plugin-loader: could not record a timed-out activation before retrying it");
}
log.warn({ pluginId, pluginKey, delayMs, retry: timeoutRetry + 1 },
"plugin-loader: plugin activation timed out; retrying");
activationRetryScheduler.schedule(`plugin-activation:${pluginId}`, Date.now() + delayMs, () => {
trackActivationRecovery(async () => {
const current = (await registry.getById(pluginId)) as PluginRecord | null;
if (shuttingDown || !current || current.status !== "ready" || workerManager.isRunning(pluginId)) return;
await discardFailedWorkerHandle(pluginId);
await activateForRecovery(current, timeoutRetry + 1);
}, "plugin-loader: timed-out activation retry failed", pluginId);
});
} else if (options.markErrorOnFailure) {
// Mark the plugin as errored in the database. The running process
// leaves it inactive; the next boot's loadAll retries it once, and the
// lifecycle enable() path can revive it sooner by hand.
// markError also deactivates the plugin runtime in this process.
try {
await lifecycleManager.markError(pluginId, `Activation failed: ${errorMessage}`);
await lifecycleManager.markError(pluginId, timedOut
? `${PLUGIN_ACTIVATION_TIMEOUT_ERROR_PREFIX} after ${timeoutRetry} retries: ${errorMessage}`
: `Activation failed: ${errorMessage}`);
} catch (markErr) {
log.error(
{