fix: guard stale plugin cleanup

This commit is contained in:
Josh Lehman
2026-05-01 23:36:28 -07:00
parent 956bd2f94b
commit 9549439230
5 changed files with 132 additions and 7 deletions

View File

@@ -37,6 +37,8 @@ Docs: https://docs.openclaw.ai
- Plugins/update: clean stale bundled load paths for already-externalized pinned npm and ClawHub plugin installs, so release-channel sync does not leave removed bundled paths ahead of the installed external package. Thanks @vincentkoc.
- Telegram: accept plugin-owned numeric forum-topic targets in the agent message tool and keep reply-dispatch provider chunks behind a real stable runtime alias during in-place package updates. Fixes #77137. Thanks @richardmqq.
- Google Meet: preserve `realtime.introMessage: ""` so realtime Chrome joins can stay silent instead of restoring the default spoken intro. Thanks @vincentkoc.
- Plugins/SDK: add bounded `before_agent_finalize` retry instructions so workflow plugins can request one more model pass. Thanks @100yenadmin.
- Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant.
- Providers/OpenRouter: add opt-in response caching params that send OpenRouter's `X-OpenRouter-Cache`, `X-OpenRouter-Cache-TTL`, and cache-clear headers only on verified OpenRouter routes. Thanks @vincentkoc.
- Providers/OpenRouter: expand app-attribution categories so OpenClaw advertises coding, programming, writing, chat, and personal-agent usage on verified OpenRouter routes. Thanks @vincentkoc.
- Plugins/runtime state: add `registerIfAbsent` for atomic keyed-store dedupe claims that return whether a plugin successfully claimed a key without overwriting an existing live value. Thanks @amknight.
@@ -465,6 +467,9 @@ Docs: https://docs.openclaw.ai
- Gateway/CLI: make `openclaw gateway start` repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting.
- Heartbeat/scheduler: make heartbeat phase scheduling active-hours-aware so the scheduler seeks forward to the first in-window phase slot instead of arming timers for quiet-hours slots and relying solely on the runtime guard. Non-UTC `activeHours.timezone` values (e.g. `Asia/Shanghai`) now correctly influence when the next heartbeat timer fires, avoiding wasted quiet-hours ticks and long dormant gaps after gateway restarts. Fixes #75487. Thanks @amknight.
- Providers/Arcee AI: mark Trinity Large Thinking as tool-incompatible so main-session runs use the same text-only request shape that made subagent runs recover, avoiding the remaining main-session response-shape mismatch after the #62848 transport failover fix. Fixes #62851 and #62847; carries forward #62848. Thanks @Adam-Researchh.
- Plugins/SDK: harden run-scoped plugin context cleanup so finalized workflow runs do not leak per-run state. Thanks @100yenadmin.
- Plugins/SDK: keep stale async registry cleanup from clearing restored plugin run context and scheduler state after a plugin registry is reactivated. (#75600) Thanks @100yenadmin.
- Plugins/SDK: preserve restored plugin scheduler state when earlier delayed replacement cleanup finishes after reactivation. Thanks @100yenadmin.
- Status: show the `openai-codex` OAuth profile for `openai/gpt-*` sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky.
- Gateway: avoid repeated plugin tool descriptor config hashing so large runtime configs do not block reply startup and trigger reconnect/timeouts. (#75944) Thanks @joshavant.
- Plugins/externalization: keep diagnostics ClawHub packages and persisted bundled-plugin relocation on npm-first install metadata for launch, and omit Discord from the core package now that its external package is published. Thanks @vincentkoc.

View File

@@ -118,6 +118,76 @@ describe("plugin run context lifecycle", () => {
).toEqual({ restored: true });
});
it("keeps restored active registry state after stale async cleanup finishes", async () => {
let releaseCleanup: (() => void) | undefined;
let markCleanupStarted: (() => void) | undefined;
let capturedApi: OpenClawPluginApi | undefined;
const cleanupStarted = new Promise<void>((resolve) => {
markCleanupStarted = resolve;
});
const cleanupRelease = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const schedulerCleanup = vi.fn();
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "delayed-restored-registry-plugin",
name: "Delayed Restored Registry Plugin",
}),
register(api) {
capturedApi = api;
api.registerRuntimeLifecycle({
id: "delayed-cleanup",
async cleanup() {
markCleanupStarted?.();
await cleanupRelease;
},
});
api.registerSessionSchedulerJob({
id: "live-job",
sessionKey: "agent:main:main",
kind: "session-turn",
cleanup: schedulerCleanup,
});
},
});
setActivePluginRegistry(registry.registry);
setActivePluginRegistry(createEmptyPluginRegistry());
await cleanupStarted;
setActivePluginRegistry(registry.registry);
expect(
capturedApi?.setRunContext({
runId: "restored-after-cleanup-started",
namespace: "state",
value: { restored: true },
}),
).toBe(true);
releaseCleanup?.();
await waitForPluginEventHandlers();
await waitForPluginEventHandlers();
expect(
getPluginRunContext({
pluginId: "delayed-restored-registry-plugin",
get: { runId: "restored-after-cleanup-started", namespace: "state" },
}),
).toEqual({ restored: true });
expect(schedulerCleanup).not.toHaveBeenCalled();
expect(listPluginSessionSchedulerJobs("delayed-restored-registry-plugin")).toEqual([
{
id: "live-job",
pluginId: "delayed-restored-registry-plugin",
sessionKey: "agent:main:main",
kind: "session-turn",
},
]);
});
it("does not let delayed non-terminal subscriptions resurrect closed run context", async () => {
let releaseToolHandler: (() => void) | undefined;
let delayedToolHandlerSawContext: unknown;

View File

@@ -114,10 +114,15 @@ export async function runPluginHostCleanup(params: {
sessionKey?: string;
runId?: string;
preserveSchedulerJobIds?: ReadonlySet<string>;
shouldCleanup?: () => boolean;
}): Promise<PluginHostCleanupResult> {
const failures: PluginHostCleanupFailure[] = [];
const shouldCleanup = params.shouldCleanup ?? (() => true);
if (!shouldCleanup()) {
return { cleanupCount: 0, failures };
}
let persistentCleanupCount = 0;
if (params.reason !== "restart") {
if (params.reason !== "restart" && shouldCleanup()) {
try {
persistentCleanupCount = await clearPluginOwnedSessionStores({
cfg: params.cfg ?? getRuntimeConfig(),
@@ -136,6 +141,9 @@ export async function runPluginHostCleanup(params: {
let cleanupCount = persistentCleanupCount;
if (registry) {
for (const registration of registry.sessionExtensions ?? []) {
if (!shouldCleanup()) {
return { cleanupCount, failures };
}
if (!shouldCleanPlugin(registration.pluginId, params.pluginId)) {
continue;
}
@@ -161,6 +169,9 @@ export async function runPluginHostCleanup(params: {
}
}
for (const registration of registry.runtimeLifecycles ?? []) {
if (!shouldCleanup()) {
return { cleanupCount, failures };
}
if (!shouldCleanPlugin(registration.pluginId, params.pluginId)) {
continue;
}
@@ -192,12 +203,13 @@ export async function runPluginHostCleanup(params: {
sessionKey: params.sessionKey,
records: registry.sessionSchedulerJobs,
preserveJobIds: params.preserveSchedulerJobIds,
shouldCleanup,
});
for (const failure of schedulerFailures) {
failures.push(failure);
}
}
if (params.reason !== "restart") {
if (params.reason !== "restart" && shouldCleanup()) {
const registrySchedulerJobKeys = new Set(
(registry?.sessionSchedulerJobs ?? [])
.filter((record) => !params.pluginId || record.pluginId === params.pluginId)
@@ -214,12 +226,17 @@ export async function runPluginHostCleanup(params: {
sessionKey: params.sessionKey,
preserveJobIds: params.preserveSchedulerJobIds,
excludeJobKeys: registrySchedulerJobKeys,
shouldCleanup,
});
for (const failure of runtimeSchedulerFailures) {
failures.push(failure);
}
}
if ((params.pluginId || params.runId) && (params.reason !== "restart" || params.runId)) {
if (
shouldCleanup() &&
(params.pluginId || params.runId) &&
(params.reason !== "restart" || params.runId)
) {
clearPluginRunContext({ pluginId: params.pluginId, runId: params.runId });
}
return { cleanupCount, failures };
@@ -266,9 +283,11 @@ export async function cleanupReplacedPluginHostRegistry(params: {
cfg: OpenClawConfig;
previousRegistry?: PluginRegistry | null;
nextRegistry?: PluginRegistry | null;
shouldCleanup?: () => boolean;
}): Promise<PluginHostCleanupResult> {
const previousRegistry = params.previousRegistry;
if (!previousRegistry || previousRegistry === params.nextRegistry) {
const shouldCleanup = params.shouldCleanup ?? (() => true);
if (!previousRegistry || previousRegistry === params.nextRegistry || !shouldCleanup()) {
return { cleanupCount: 0, failures: [] };
}
const nextPluginIds = params.nextRegistry
@@ -281,6 +300,9 @@ export async function cleanupReplacedPluginHostRegistry(params: {
const failures: PluginHostCleanupFailure[] = [];
let cleanupCount = 0;
for (const pluginId of previousPluginIds) {
if (!shouldCleanup()) {
break;
}
const restarted = nextPluginIds.has(pluginId);
const result = await runPluginHostCleanup({
cfg: params.cfg,
@@ -290,6 +312,7 @@ export async function cleanupReplacedPluginHostRegistry(params: {
preserveSchedulerJobIds: restarted
? collectSchedulerJobIds(params.nextRegistry, pluginId)
: undefined,
shouldCleanup,
});
cleanupCount += result.cleanupCount;
failures.push(...result.failures);

View File

@@ -449,11 +449,19 @@ export async function cleanupPluginSessionSchedulerJobs(params: {
}[];
preserveJobIds?: ReadonlySet<string>;
excludeJobKeys?: ReadonlySet<string>;
shouldCleanup?: () => boolean;
}): Promise<Array<{ pluginId: string; hookId: string; error: unknown }>> {
const state = getPluginHostRuntimeState();
const failures: Array<{ pluginId: string; hookId: string; error: unknown }> = [];
const shouldCleanup = params.shouldCleanup ?? (() => true);
if (!shouldCleanup()) {
return failures;
}
if (params.records) {
for (const record of params.records) {
if (!shouldCleanup()) {
return failures;
}
if (params.pluginId && record.pluginId !== params.pluginId) {
continue;
}
@@ -512,6 +520,9 @@ export async function cleanupPluginSessionSchedulerJobs(params: {
});
continue;
}
if (!shouldCleanup()) {
continue;
}
deletePluginSessionSchedulerJob({
pluginId: record.pluginId,
jobId,
@@ -523,11 +534,17 @@ export async function cleanupPluginSessionSchedulerJobs(params: {
}
const pluginIds = params.pluginId ? [params.pluginId] : [...state.schedulerJobsByPlugin.keys()];
for (const pluginId of pluginIds) {
if (!shouldCleanup()) {
return failures;
}
const jobs = state.schedulerJobsByPlugin.get(pluginId);
if (!jobs) {
continue;
}
for (const [jobId, record] of jobs.entries()) {
if (!shouldCleanup()) {
return failures;
}
if (params.sessionKey && record.job.sessionKey !== params.sessionKey) {
continue;
}
@@ -554,6 +571,9 @@ export async function cleanupPluginSessionSchedulerJobs(params: {
});
continue;
}
if (!shouldCleanup()) {
continue;
}
jobs.delete(jobId);
}
if (jobs.size === 0) {

View File

@@ -65,16 +65,24 @@ function registryHasPluginHostCleanupWork(registry: PluginRegistry | null): bool
async function cleanupPreviousPluginHostRegistry(params: {
previousRegistry: PluginRegistry;
nextRegistry: PluginRegistry;
}): Promise<void> {
const [{ getRuntimeConfig }, { cleanupReplacedPluginHostRegistry }] = await Promise.all([
import("../config/config.js"),
import("./host-hook-cleanup.js"),
]);
const nextRegistry = asPluginRegistry(state.activeRegistry);
if (!nextRegistry || nextRegistry === params.previousRegistry) {
return;
}
const cleanupActiveVersion = state.activeVersion;
// Async cleanup must not clear state after another registry becomes live.
const shouldCleanup = () =>
state.activeVersion === cleanupActiveVersion && state.activeRegistry === nextRegistry;
await cleanupReplacedPluginHostRegistry({
cfg: getRuntimeConfig(),
previousRegistry: params.previousRegistry,
nextRegistry: params.nextRegistry,
nextRegistry,
shouldCleanup,
});
}
@@ -151,7 +159,6 @@ export function setActivePluginRegistry(
}
void cleanupPreviousPluginHostRegistry({
previousRegistry,
nextRegistry: registry,
}).catch((error) => {
log.warn(`plugin host registry cleanup failed: ${String(error)}`);
});