diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a73df3514f..cba5a7d2ffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai ### Fixes - CLI/status: show skipped fast-path memory checks as `not checked` and report active custom memory plugin runtime status from `status --json --all` without requiring built-in `agents.defaults.memorySearch`, so plugins such as memory-lancedb-pro and memory-cms no longer look unavailable when their own runtime is healthy. Fixes #56968. Thanks @Tony-ooo and @aderius. +- Gateway/channels: record and log unexpected clean channel monitor exits so channels that return without throwing no longer appear stopped with no error. Fixes #73099. Thanks @balaji1968-kingler. - Memory/LanceDB: let embedding config use provider-backed auth profiles, environment credentials, or provider config without a separate plugin `embedding.apiKey`, so OAuth-capable embedding providers can power auto-recall/capture. Fixes #68950. Thanks @malshaalan-ai. - Plugins/hooks: time out never-settling `agent_end` observation hooks after 30 seconds and log the plugin failure, so hung embedding endpoints no longer leave memory capture silently pending forever. Fixes #65544. Thanks @ghoc0099. - Gateway/config: serve runtime config schemas from the current plugin metadata snapshot and generated bundled channel schema metadata instead of rebuilding plugin channel config modules on every `config.get`/`config.schema`, preventing idle plugin-discovery CPU churn after upgrades. Fixes #73088. Thanks @sleitor and @geovansb. diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index b7a6e20ea13..2e45d6b87de 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -186,11 +186,47 @@ describe("server-channels auto restart", () => { const account = snapshot.channelAccounts.discord?.[DEFAULT_ACCOUNT_ID]; expect(account?.running).toBe(false); expect(account?.reconnectAttempts).toBe(11); + expect(account?.lastError).toBe("channel exited without an error"); await vi.advanceTimersByTimeAsync(200); expect(startAccount).toHaveBeenCalledTimes(11); }); + it("records a clean channel monitor exit before auto-restart", async () => { + const startAccount = vi.fn(async () => {}); + installTestRegistry(createTestPlugin({ startAccount })); + const manager = createManager(); + + await manager.startChannels(); + await vi.advanceTimersByTimeAsync(0); + + const snapshot = manager.getRuntimeSnapshot(); + const account = snapshot.channelAccounts.discord?.[DEFAULT_ACCOUNT_ID]; + expect(startAccount).toHaveBeenCalled(); + expect(account?.running).toBe(false); + expect(account?.restartPending).toBe(true); + expect(account?.lastError).toBe("channel exited without an error"); + }); + + it("does not record a clean-exit error for manual abort stops", async () => { + const startAccount = vi.fn( + async ({ abortSignal }: { abortSignal: AbortSignal }) => + await new Promise((resolve) => { + abortSignal.addEventListener("abort", () => resolve(), { once: true }); + }), + ); + installTestRegistry(createTestPlugin({ startAccount })); + const manager = createManager(); + + await manager.startChannels(); + await manager.stopChannel("discord", DEFAULT_ACCOUNT_ID); + + const snapshot = manager.getRuntimeSnapshot(); + const account = snapshot.channelAccounts.discord?.[DEFAULT_ACCOUNT_ID]; + expect(account?.running).toBe(false); + expect(account?.lastError).toBeNull(); + }); + it("does not auto-restart after manual stop during backoff", async () => { const startAccount = vi.fn(async () => {}); installTestRegistry( diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index 0bf74a2e5bf..c6d3ccfaa21 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -509,6 +509,14 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage ), ); const trackedPromise = task + .then(() => { + if (abort.signal.aborted || manuallyStopped.has(rKey)) { + return; + } + const message = "channel exited without an error"; + setRuntime(channelId, id, { accountId: id, lastError: message }); + log.error?.(`[${id}] ${message}`); + }) .catch((err) => { const message = formatErrorMessage(err); setRuntime(channelId, id, { accountId: id, lastError: message });