Files
PaperClipAI/server/src/services/bundled-plugins.ts
T
Jannes StubbemannandClaude Fable 5.1 5752d6bd93 fix(heartbeat): block runs on a stuck sandbox plugin and re-enable errored bundled plugins at boot (#12957)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents run inside environments. A sandbox environment gets its
sandbox from a provider plugin (for example the bundled
`paperclip.kubernetes-sandbox-provider`), and every run starts by
acquiring a lease through that plugin.
> - When a plugin activation fails once (on a hosted deployment: one
`RPC call "initialize" timed out after 15000ms`), the loader calls
`markError`. That persists `status = error` on the plugin row and
switches off worker auto-restart. Boot activation (`loadAll`), the
bundled-plugin bootstrap and the lazy worker recovery all consider only
`ready` plugins, so the plugin stays in `error` across restarts until an
operator enables it by hand.
> - Every run that needs the provider then fails before dispatch with
`Sandbox provider "kubernetes" is installed via plugin "...", but that
plugin is currently error.` That message matches neither the retryable
classifier (`... but its worker is not running`) nor any configuration
classifier, so the run is recorded as a plain `setup_failed`, the issue
is released, and the scheduler dispatches the same failing run again on
the next tick. On the hosted deployment one company produced about
11,300 identical failed runs, one every 30 seconds, for a week (#12953
is a customer's report of the same condition).
> - Two gaps cause this: the heartbeat treats a condition that only an
operator can change as a transient setup failure, and the bundled-plugin
bootstrap never gives a plugin in `error` another chance even though the
bundle ships with the release image.
> - This pull request classifies the "installed but not ready" lease
failure as `configuration_incomplete`, so the existing recovery path
moves the issue to `blocked` with one recovery action and an actionable
notice; and it re-enables a bundled plugin found in `error` once per
boot, so the next server restart heals the plugin.
> - The benefit is that a stuck provider plugin surfaces as one blocked
issue per task with clear next steps, instead of an endless stream of
identical failed runs, and a restart repairs the plugin without an
operator having to know the plugin API.

## Linked Issues or Issue Description

- Refs #12953 — hosted report: "that plugin is currently error" on every
run for six days, including runs that were retried by hand. This PR
stops the retry loop (issue goes to `blocked`) and makes a server
restart re-activate the bundled plugin. It does not change how a managed
Kubernetes environment is provisioned for a company, which the same
report also mentions.
- Related PR: #9760 pauses the agent for the permanent `Adapter "..." is
not in the configured adapter registry` setup failure. This PR handles a
different permanent condition (plugin not `ready`) and routes it through
the existing `configuration_incomplete` recovery path (issue-level block
with a recovery action) rather than an agent-level pause, because the
gap is on the plugin, not on the agent. The two do not overlap in code
paths.
- No existing issue covers the bundled-plugin re-enable. Bug
description:

**What happened**

A bundled sandbox provider plugin went to `status = error` after one
failed activation. It stayed in `error` across every later server
restart. Every run for every agent on that provider failed lease
acquisition in under a second with `... but that plugin is currently
error.` (`setup_failed`), and the heartbeat kept dispatching new runs
that failed the same way.

**Expected behavior**

A run that fails because its provider plugin is not `ready` is recorded
as a configuration gap and the issue is moved to `blocked` with a notice
that names the plugin and its status, so no further runs are dispatched
until an operator acts. A bundled plugin left in `error` gets a fresh
activation attempt on the next boot.

**Steps to reproduce**

1. Install a sandbox provider plugin and create a sandbox environment
that uses it; make it an agent's default environment.
2. Set the plugin row's status to `error` (or make its worker fail
`initialize` once so the loader does it).
3. Assign an issue to the agent and let the heartbeat run it.
4. Observe: the run fails with `... but that plugin is currently error.`
as `setup_failed`, the issue is released, and the next tick dispatches
another run that fails the same way. Restart the server: the plugin is
still `error`.

**Paperclip version**

master at 856813ba3 (`fix(connections): distinguish local setup from
provider handoff (#12947)`).

**Deployment mode**

Hosted (Kubernetes, bundled kubernetes sandbox provider plugin). The
heartbeat behavior is the same in self-hosted mode.

## What Changed

- `server/src/services/heartbeat.ts`
- New exported `parseSandboxProviderPluginNotReadyFailureMessage()`
recognises environment-runtime's `not_ready` lease message (`... is
installed via plugin "<key>", but that plugin is currently
error|disabled|upgrade_pending`) and returns the provider, plugin key
and status. It does not match the transient `... but its worker is not
running` message (still retried) or the permanent "not installed"
message (unchanged).
- In the setup-failure catch, a matched message sets `errorCode =
configuration_incomplete` and records (independently of whether the
agent lookup succeeded) a `configurationIncomplete` payload with
`reason: "sandbox_provider_plugin_not_ready"`, the provider,
`pluginKey`, `pluginStatus`, and a `fingerprint` of
`sandbox_provider_plugin:<key>:<status>`, so repeated failures on the
same stuck plugin reuse one recovery action. The existing recovery flow
then blocks the issue, skips the infra retry, and posts one notice.
- The two places that build the configuration-incomplete notice now pass
the run's payload so the notice can name the specific gap.
- `server/src/services/recovery/stranded-notice.ts`:
`buildConfigurationIncompleteRecoveryNoticeSeed` takes the optional
payload. For `sandbox_provider_plugin_not_ready` the body names the
plugin and its status and gives status-specific guidance
(`sandboxProviderPluginRemedy`): review and approve the upgraded
capabilities before enabling for `upgrade_pending`, enable again for an
operator `disabled`, enable or restart for `error`. Other reasons keep
the secret/env-binding wording. Exports
`SANDBOX_PROVIDER_PLUGIN_NOT_READY_REASON`.
- `server/src/services/recovery/service.ts`: the recovery action's
`nextAction` for this reason uses the same status-specific guidance
instead of "bind the missing secret(s)". Small refactor:
`readConfigurationIncompletePayload` backs the existing fingerprint
reader.
- `server/src/services/bundled-plugins.ts`
- `ensureBundledPlugins` no longer skips a present bundled plugin whose
status is `error`. It logs at `warn` with the row's `lastError`, resets
the row to `ready` with `lastError` cleared through
`registry.updateStatus` (a plain status reset, not `lifecycle.enable()`,
so no `plugin.enabled` event fires before the worker runs; the startup
`loadAll()` that follows does the activation and its events), and
continues boot on failure. This runs once per boot by construction; if
activation fails again the loader marks `error` again and nothing
retries until the next boot.
- `installed`, `ready`, `disabled` and `upgrade_pending` rows are still
skipped, so an operator's `disabled` stays untouched.
- `BundledPluginProvisionerDeps` gains `registry.updateStatus` and
`logger.warn`; `app.ts` already passes objects that have both.
- `doc/plugins/PLUGIN_SPEC.md`: one bullet in 12.4 Failure Policy about
the once-per-boot re-enable of bundled plugins.
- Tests
- `server/src/__tests__/bundled-plugins.test.ts`: re-enables an `error`
row exactly once with the `lastError` in the warn log and no reinstall;
continues boot and provisions later entries when `enable` throws; still
skips `installed`/`ready`/`disabled`/`upgrade_pending` without calling
`enable`.
- `server/src/__tests__/heartbeat-process-recovery.test.ts` (embedded
PostgreSQL): a plugin row in `error` plus a sandbox environment produce
a run with `errorCode = configuration_incomplete` and the expected
payload, the adapter is never dispatched, no retry or second run is
created, the issue is `blocked`, the recovery action is
`configuration_validation` with the plugin next action, and the notice
names the plugin key and status. Plus a unit case for the message parser
(positive for the three statuses and a wrapped message, negative for
both other sandbox messages).
- `server/src/services/recovery/stranded-notice.test.ts`: the
plugin-specific body, and the unchanged secret-binding body for other
reasons.

## Verification

- `cd server && pnpm typecheck` — passes.
- `cd server && pnpm exec vitest run
src/__tests__/bundled-plugins.test.ts` — 29 tests pass.
- `cd server && pnpm exec vitest run src/services/recovery/` — 77 tests
pass (includes the stranded-notice and classification suites).
- `cd server && pnpm exec vitest run
src/__tests__/heartbeat-process-recovery.test.ts -t "sandbox
provider|retryable pattern|secret ref has no binding"` — 5 tests pass:
the two new cases, the existing transient worker-restart retry, the
existing non-retryable "not installed" escalation, and the existing
secret-binding `configuration_incomplete` block (embedded PostgreSQL).
- Manual check for a reviewer: set a sandbox provider plugin row to
`status = 'error'`, run an agent on that provider, and confirm the issue
moves to `blocked` with a "Configuration incomplete" notice that names
the plugin, and that no second run appears. Restart the server and
confirm the boot log shows `bundled plugin is in error status from a
previous activation; re-enabling it for this boot` followed by normal
activation.

## Risks

- Behavior change: a run against a plugin in `error`, `disabled` or
`upgrade_pending` now blocks the issue instead of failing as
`setup_failed` and being re-picked. For `disabled` this is deliberate:
an operator switched the plugin off, and re-dispatching cannot help. The
block is reversible from the issue (retry or reassign) like every other
`configuration_incomplete` block.
- The classifier is anchored on the exact `... but that plugin is
currently <status>` phrase from `environment-runtime.ts`. If that
message changes, the run falls back to the previous `setup_failed`
behavior (no worse than today). A unit test pins the phrase.
- Bundled re-enable: a bundled plugin whose activation fails on every
boot now costs one activation attempt (the `initialize` timeout, 15 s by
default) per boot instead of none. It runs inside the existing
non-awaited bootstrap chain, so boot time is unaffected. Non-bundled
plugins are untouched.
- No migration, no schema change. The `configurationIncomplete` payload
is JSON in `heartbeat_runs.result_json`, read only by the recovery
service.

## Model Used

- Claude Fable 5.1 (`claude-fable-5-1`) via Claude Code, extended
thinking, tool use (file edits, shell, test runs). The change was
produced with the model and reviewed by the submitting human.

## 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
- [ ] 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)

https://claude.ai/code/session_014t3bi2beVNVVHAxK36dmXm

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 09:00:10 -07:00

422 lines
16 KiB
TypeScript

import path from "node:path";
import fs from "node:fs";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
/**
* Bundled plugin auto-provisioning.
*
* Managed-cloud instances receive a `plugins.autoInstall` key list through
* `PAPERCLIP_MANAGED_CONFIG` (parsed fail-closed at startup — see
* `managed-config.ts`). Each key maps to a plugin bundled into
* the release image under the bundled catalog root. Nobody "installs" on a
* managed instance: the control plane provisions, tenants use.
*
* Two distinct failure postures, deliberately split:
*
* 1. **Resolution (this file, `resolveBundledPluginInstalls`) fails to
* start.** An unknown key or a path that escapes the bundled catalog
* root is a configuration/security violation — a positive allowlist,
* not a lookup. Throwing here happens
* synchronously inside `createApp`, before the server listens, so a bad
* document refuses to start rather than silently widening what code can
* be loaded into the host.
*
* 2. **Installation (`ensureBundledPlugins`) is fail-safe.**
* Missing bundle on disk, install error, load error: caught, logged,
* and swallowed per entry so the server ALWAYS finishes booting. A
* degraded boot (one provider unavailable) is strictly preferable to a
* crash loop across a fleet.
*
* Removal of a key from `autoInstall` stops future installs but never
* auto-uninstalls: there is intentionally no uninstall
* path anywhere in this module.
*/
/** Default location of the bundled plugin catalog inside the release image. */
export const DEFAULT_BUNDLED_CATALOG_ROOT = "/app/packages/plugins";
/**
* Env var that relocates the bundled catalog root (dev images, tests).
*/
export const BUNDLED_CATALOG_ROOT_ENV_VAR = "PAPERCLIP_BUNDLED_PLUGIN_ROOT";
export interface BundledPluginCatalogEntry {
/** Key the managed config's `plugins.autoInstall` list uses. */
key: string;
/** Manifest id / registry `pluginKey` the bundle installs as. */
pluginKey: string;
/** Bundle location relative to the bundled catalog root. */
relativePath: string;
/**
* Legacy absolute-path override honored for compatibility (the kubernetes
* bundle predates the catalog). Overrides are still subject to catalog
* containment when enforcement is on.
*/
pathOverrideEnvVar?: string;
}
/**
* The positive allowlist of plugins the control plane may auto-provision.
* Keys outside this table can never be installed through this path,
* regardless of what the managed config document says.
*/
export const BUNDLED_PLUGIN_CATALOG: readonly BundledPluginCatalogEntry[] = [
{
key: "cloudflare",
pluginKey: "paperclip.cloudflare-sandbox-provider",
relativePath: "sandbox-providers/cloudflare",
},
{
key: "daytona",
pluginKey: "paperclip.daytona-sandbox-provider",
relativePath: "sandbox-providers/daytona",
},
{
key: "e2b",
pluginKey: "paperclip.e2b-sandbox-provider",
relativePath: "sandbox-providers/e2b",
},
{
key: "exe-dev",
pluginKey: "paperclip.exe-dev-sandbox-provider",
relativePath: "sandbox-providers/exe-dev",
},
{
key: "kubernetes",
pluginKey: "paperclip.kubernetes-sandbox-provider",
relativePath: "sandbox-providers/kubernetes",
pathOverrideEnvVar: "PAPERCLIP_KUBERNETES_PLUGIN_PATH",
},
{
key: "modal",
pluginKey: "paperclip.modal-sandbox-provider",
relativePath: "sandbox-providers/modal",
},
{
key: "novita",
pluginKey: "paperclip.novita-sandbox-provider",
relativePath: "sandbox-providers/novita",
},
];
/**
* Keys ensured on a self-hosted instance (no managed config present).
* Exactly the pre-refactor behavior: the kubernetes sandbox provider is
* auto-installed when its bundle is present, nothing else.
*/
export const SELF_HOSTED_AUTO_INSTALL_KEYS: readonly string[] = ["kubernetes"];
export function resolveBundledCatalogRoot(
env: Record<string, string | undefined>,
): string {
const override = env[BUNDLED_CATALOG_ROOT_ENV_VAR]?.trim();
return override ? override : DEFAULT_BUNDLED_CATALOG_ROOT;
}
export interface ResolvedBundledPlugin {
key: string;
pluginKey: string;
/** Absolute path handed to `loader.installPlugin({ localPath })`. */
localPath: string;
}
/**
* Canonicalize a path for containment comparison. Symlinks are resolved
* when the path exists so a link inside the catalog cannot point install
* resolution at a directory outside it; nonexistent paths fall back to a
* lexical resolve (`..` segments still collapse).
*/
function canonicalize(p: string): string {
const resolved = path.resolve(p);
try {
return fs.realpathSync(resolved);
} catch {
return resolved;
}
}
function isInsideRoot(candidate: string, root: string): boolean {
const rel = path.relative(root, candidate);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
/**
* Resolve auto-install keys to concrete bundle paths.
*
* Throws — and the instance must refuse to start — when a key is not in
* the bundled catalog, or when `enforceCatalogRoot` is set and the
* resolved path escapes the catalog root. Callers pass
* `enforceCatalogRoot: true` for managed (control-plane-driven) key lists
* and `false` for the self-hosted built-in list, where the legacy
* kubernetes path override may point anywhere (unchanged behavior).
*/
export function resolveBundledPluginInstalls(
keys: readonly string[],
opts: {
catalogRoot: string;
env: Record<string, string | undefined>;
enforceCatalogRoot: boolean;
},
): ResolvedBundledPlugin[] {
const resolved: ResolvedBundledPlugin[] = [];
const seen = new Set<string>();
const canonicalRoot = canonicalize(opts.catalogRoot);
for (const key of keys) {
if (seen.has(key)) continue;
seen.add(key);
const entry = BUNDLED_PLUGIN_CATALOG.find((candidate) => candidate.key === key);
if (!entry) {
const known = BUNDLED_PLUGIN_CATALOG.map((candidate) => candidate.key).join(", ");
throw new Error(
`bundled plugin auto-install key "${key}" is not in the bundled catalog (known keys: ${known}); refusing to start`,
);
}
const override = entry.pathOverrideEnvVar
? opts.env[entry.pathOverrideEnvVar]?.trim()
: undefined;
const localPath = override
? path.resolve(override)
: path.resolve(opts.catalogRoot, entry.relativePath);
if (opts.enforceCatalogRoot && !isInsideRoot(canonicalize(localPath), canonicalRoot)) {
throw new Error(
`bundled plugin "${key}" resolves to "${localPath}", outside the bundled catalog root "${opts.catalogRoot}"; refusing to start`,
);
}
resolved.push({ key: entry.key, pluginKey: entry.pluginKey, localPath });
}
return resolved;
}
interface RegistryPluginRow {
id: string;
pluginKey: string;
status: string;
version: string;
manifestJson: PaperclipPluginManifestV1;
lastError?: string | null;
}
export interface BundledPluginProvisionerDeps {
registry: {
getByKey(pluginKey: string): Promise<RegistryPluginRow | null>;
update(
id: string,
data: { version?: string; manifest?: PaperclipPluginManifestV1 },
): Promise<unknown>;
updateStatus(id: string, input: { status: "ready"; lastError: string | null }): Promise<unknown>;
};
loader: {
installPlugin(options: { localPath: string }): Promise<{
manifest: { id: string } | null;
}>;
loadManifest(packagePath: string): Promise<PaperclipPluginManifestV1 | null>;
};
lifecycle: {
load(pluginId: string): Promise<unknown>;
};
logger: {
info(obj: unknown, msg?: string): void;
warn(obj: unknown, msg?: string): void;
error(obj: unknown, msg?: string): void;
};
/** Overridable for tests; defaults to checking `dist/manifest.js`. */
bundleManifestExists?: (localPath: string) => boolean;
}
function defaultBundleManifestExists(localPath: string): boolean {
return fs.existsSync(path.join(localPath, "dist", "manifest.js"));
}
/**
* Reconcile a present bundled plugin's persisted manifest with the shipped
* bundle. The bundle is part of the release image, so its manifest is the
* source of truth. When the bundle declares a version that differs from the
* persisted version, update the stored manifest and version. This propagates
* a manifest change (for example a new driver capability) to an existing
* install that the auto-install path skips.
*
* The step is fail-safe. A missing bundle, a manifest read error, or a
* database error is caught, logged, and swallowed, so boot always completes.
* The step updates only the stored manifest row; it never restarts the worker.
*/
async function reconcileBundledPluginManifest(
existing: RegistryPluginRow,
install: ResolvedBundledPlugin,
deps: BundledPluginProvisionerDeps,
bundleManifestExists: (localPath: string) => boolean,
): Promise<void> {
try {
if (!bundleManifestExists(install.localPath)) return;
const bundleManifest = await deps.loader.loadManifest(install.localPath);
if (!bundleManifest) return;
if (bundleManifest.version === existing.version) return;
await deps.registry.update(existing.id, {
version: bundleManifest.version,
manifest: bundleManifest,
});
deps.logger.info(
{
pluginKey: install.pluginKey,
fromVersion: existing.version,
toVersion: bundleManifest.version,
},
"reconciled bundled plugin manifest to the shipped bundle version",
);
} catch (err) {
deps.logger.error(
{ err, pluginKey: install.pluginKey },
"Failed to reconcile bundled plugin manifest; continuing boot with the stored manifest",
);
}
}
/**
* Re-enable a bundled plugin that a previous boot left in `error`.
*
* `error` is not an operator choice: the loader records it when activation
* fails (for example the worker's `initialize` RPC timed out once) and it
* also switches off the worker's auto-restart. Every automatic path
* afterwards (`loadAll()`, the lazy worker recovery, the run lease) only
* considers `ready` plugins, so a bundled plugin in `error` stays unusable
* across restarts until an operator enables it by hand, and every run that
* needs its provider fails with "that plugin is currently error". The bundle
* ships with the release image and is expected to work, so one fresh attempt
* per boot is the right default: the row goes back to `ready` (with its
* `lastError` cleared), and the startup `loadAll()` activates it. If
* activation fails again the loader marks `error` again and nothing retries
* until the next boot, so this cannot loop within one process.
*
* This is a plain registry status reset, not `lifecycle.enable()`: the
* lifecycle call would emit `plugin.enabled` before `loadAll()` has started
* the worker, and a consumer of that event (the dev watcher, activity
* listeners) would act on a plugin that may still fail to activate.
* Activation, and its own events, stay with `loadAll()`.
*
* Fail-safe like the rest of the provisioner: a failed status reset is
* logged and boot continues with the plugin unavailable.
*/
async function reenableErroredBundledPlugin(
existing: RegistryPluginRow,
install: ResolvedBundledPlugin,
deps: BundledPluginProvisionerDeps,
): Promise<void> {
deps.logger.warn(
{
pluginId: existing.id,
pluginKey: install.pluginKey,
lastError: existing.lastError ?? null,
},
"bundled plugin is in error status from a previous activation; re-enabling it for this boot",
);
try {
await deps.registry.updateStatus(existing.id, { status: "ready", lastError: null });
deps.logger.info(
{ pluginId: existing.id, pluginKey: install.pluginKey },
"bundled plugin reset to ready; the startup loader will activate it",
);
} catch (err) {
deps.logger.error(
{ err, pluginId: existing.id, pluginKey: install.pluginKey },
"Failed to re-enable errored bundled plugin; continuing boot (degraded: plugin unavailable)",
);
}
}
/**
* Ensure each resolved bundled plugin is installed and loaded.
*
* Same mechanism the kubernetes bundle has always used: in-process
* `loader.installPlugin({ localPath })` at boot — no HTTP, no user, no
* role. Fully fail-safe per entry: any disk/install/load
* failure is caught, logged, and swallowed so boot always completes.
*
* Skip semantics:
* - A plugin present in any non-uninstalled state is skipped, so an
* operator-disabled plugin is not silently re-enabled on reboot. Before the
* skip, the persisted manifest is reconciled to the shipped bundle version
* (see `reconcileBundledPluginManifest`).
* - The one exception is `error`, which the loader sets when an activation
* fails and which no automatic path ever clears. A bundled plugin in
* `error` is moved back to `ready` once per boot so `loadAll()` gets a
* fresh attempt (see `reenableErroredBundledPlugin`).
* - A soft-uninstalled plugin is reinstalled only when
* `reinstallUninstalled` is set (managed mode, where the control plane
* owns provisioning). Self-hosted keeps the pre-refactor behavior of
* leaving an operator's uninstall alone.
*/
export async function ensureBundledPlugins(
installs: readonly ResolvedBundledPlugin[],
deps: BundledPluginProvisionerDeps,
opts: { reinstallUninstalled: boolean },
): Promise<void> {
const bundleManifestExists = deps.bundleManifestExists ?? defaultBundleManifestExists;
for (const install of installs) {
try {
const existing = await deps.registry.getByKey(install.pluginKey);
if (existing && (existing.status !== "uninstalled" || !opts.reinstallUninstalled)) {
// The bundle ships with the release image, so its manifest is the
// source of truth for a present plugin. Reconcile the persisted
// manifest when the shipped bundle declares a newer version. Without
// this step a manifest capability added to a bundle never reaches an
// existing install, because the auto-install below skips a present
// plugin. The reconcile updates only the stored manifest row; the
// running worker already runs the shipped code.
await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists);
if (existing.status === "error") {
await reenableErroredBundledPlugin(existing, install, deps);
continue;
}
deps.logger.info(
{ pluginKey: install.pluginKey, status: existing.status },
"bundled plugin already present; skipping auto-install",
);
continue;
}
// Skip silently when the bundle is absent (e.g. local dev or an image
// built without the plugin). Not an error condition.
if (!bundleManifestExists(install.localPath)) {
deps.logger.info(
{ pluginKey: install.pluginKey, pluginPath: install.localPath },
"bundled plugin bundle not present; skipping auto-install",
);
continue;
}
deps.logger.info(
{ pluginKey: install.pluginKey, pluginPath: install.localPath },
"auto-installing bundled plugin",
);
const discovered = await deps.loader.installPlugin({ localPath: install.localPath });
if (!discovered.manifest) {
deps.logger.error(
{ pluginKey: install.pluginKey },
"bundled plugin installed but manifest is missing",
);
continue;
}
// Transition installed -> ready. Whether this also starts the worker
// depends on the injected lifecycle manager: one built with a
// runtime-capable loader activates here; the boot-time manager in
// app.ts is not, so at startup this only records `ready` and the
// worker is started exactly once by the subsequent loader.loadAll().
const installed = await deps.registry.getByKey(discovered.manifest.id);
if (installed) {
await deps.lifecycle.load(installed.id);
deps.logger.info(
{ pluginId: installed.id, pluginKey: installed.pluginKey },
"bundled plugin auto-installed and loaded",
);
} else {
deps.logger.error(
{ pluginKey: install.pluginKey },
"bundled plugin installed but not found in registry",
);
}
} catch (err) {
deps.logger.error(
{ err, pluginKey: install.pluginKey },
"Failed to auto-install bundled plugin; continuing boot (degraded: plugin unavailable)",
);
}
}
}