diff --git a/CHANGELOG.md b/CHANGELOG.md index 84e0cc6bc9f..9d4a2b28a43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/plugins/contracts/run-context-lifecycle.contract.test.ts b/src/plugins/contracts/run-context-lifecycle.contract.test.ts index ceb8b56ffd1..b6bf48f8d96 100644 --- a/src/plugins/contracts/run-context-lifecycle.contract.test.ts +++ b/src/plugins/contracts/run-context-lifecycle.contract.test.ts @@ -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((resolve) => { + markCleanupStarted = resolve; + }); + const cleanupRelease = new Promise((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; diff --git a/src/plugins/host-hook-cleanup.ts b/src/plugins/host-hook-cleanup.ts index b558c61eece..1ee90dac7f0 100644 --- a/src/plugins/host-hook-cleanup.ts +++ b/src/plugins/host-hook-cleanup.ts @@ -114,10 +114,15 @@ export async function runPluginHostCleanup(params: { sessionKey?: string; runId?: string; preserveSchedulerJobIds?: ReadonlySet; + shouldCleanup?: () => boolean; }): Promise { 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 { 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); diff --git a/src/plugins/host-hook-runtime.ts b/src/plugins/host-hook-runtime.ts index e764e985d8f..fbd78333d89 100644 --- a/src/plugins/host-hook-runtime.ts +++ b/src/plugins/host-hook-runtime.ts @@ -449,11 +449,19 @@ export async function cleanupPluginSessionSchedulerJobs(params: { }[]; preserveJobIds?: ReadonlySet; excludeJobKeys?: ReadonlySet; + shouldCleanup?: () => boolean; }): Promise> { 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) { diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index c354ccc448c..1c8bd9c9e09 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -65,16 +65,24 @@ function registryHasPluginHostCleanupWork(registry: PluginRegistry | null): bool async function cleanupPreviousPluginHostRegistry(params: { previousRegistry: PluginRegistry; - nextRegistry: PluginRegistry; }): Promise { 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)}`); });