diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index 593e43ce5bd..ab679ccd9ee 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -309,19 +309,33 @@ describe("server-channels auto restart", () => { it("force-retires a hung channel task so recovery can start a fresh lifecycle", async () => { const statusSetters: Array<(next: ChannelAccountSnapshot) => void> = []; - const startAccount = vi.fn(async ({ setStatus }: ChannelGatewayContext) => { - statusSetters.push(setStatus); - await new Promise(() => {}); - }); + const channelRuntime = createRuntimeChannel(); + const contextKey = { + channelId: "discord", + accountId: DEFAULT_ACCOUNT_ID, + capability: "test-lifecycle", + }; + const startAccount = vi.fn( + async ({ setStatus, channelRuntime }: ChannelGatewayContext) => { + const lifecycle = statusSetters.length + 1; + statusSetters.push(setStatus); + channelRuntime?.runtimeContexts.register({ + ...contextKey, + context: { lifecycle }, + }); + await new Promise(() => {}); + }, + ); installTestRegistry( createTestPlugin({ startAccount, }), ); - const manager = createManager(); + const manager = createManager({ channelRuntime }); await manager.startChannels(); await Promise.resolve(); + expect(channelRuntime.runtimeContexts.get(contextKey)).toEqual({ lifecycle: 1 }); const stopTask = manager.stopChannel("discord", DEFAULT_ACCOUNT_ID, { forceRetireOnTimeout: true, @@ -335,9 +349,11 @@ describe("server-channels auto restart", () => { expect(account?.connected).toBe(false); expect(account?.activeRuns).toBe(0); expect(account?.lastError).toContain("stale lifecycle force-retired"); + expect(channelRuntime.runtimeContexts.get(contextKey)).toBeUndefined(); await manager.startChannel("discord", DEFAULT_ACCOUNT_ID); await Promise.resolve(); + expect(channelRuntime.runtimeContexts.get(contextKey)).toEqual({ lifecycle: 2 }); expect(startAccount).toHaveBeenCalledTimes(2); statusSetters[1]?.({ diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index 9115aaa0c69..54ad02b6735 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -39,6 +39,7 @@ type ChannelRuntimeStore = { aborts: Map; starting: Map>; tasks: Map>; + taskCleanups: Map Promise>; runtimes: Map; }; @@ -61,6 +62,7 @@ function createRuntimeStore(): ChannelRuntimeStore { aborts: new Map(), starting: new Map(), tasks: new Map(), + taskCleanups: new Map(), runtimes: new Map(), }; } @@ -423,6 +425,10 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage log.error?.(`[${id}] ${label}: ${formatErrorMessage(error)}`); } }; + const cleanupRetiredLifecycle = async () => { + await cleanupTaskScopedApprovalRuntime("channel lifecycle retirement cleanup failed"); + }; + store.taskCleanups.set(id, cleanupRetiredLifecycle); try { const account = plugin.config.resolveAccount(cfg, id); @@ -542,6 +548,9 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage }) .finally(async () => { await cleanupTaskScopedApprovalRuntime("channel cleanup failed"); + if (store.taskCleanups.get(id) === cleanupRetiredLifecycle) { + store.taskCleanups.delete(id); + } if (isCurrentLifecycle()) { setRuntime(channelId, id, { accountId: id, @@ -627,6 +636,9 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage if (!handedOffTask && store.aborts.get(id) === abort) { store.aborts.delete(id); } + if (!handedOffTask && store.taskCleanups.get(id) === cleanupRetiredLifecycle) { + store.taskCleanups.delete(id); + } } }), ); @@ -700,6 +712,11 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage if (store.tasks.get(id) === task) { store.tasks.delete(id); } + const cleanupRetiredTask = store.taskCleanups.get(id); + if (cleanupRetiredTask) { + store.taskCleanups.delete(id); + await cleanupRetiredTask(); + } setRuntime(channelId, id, { accountId: id, running: false, @@ -722,6 +739,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage } store.aborts.delete(id); store.tasks.delete(id); + store.taskCleanups.delete(id); setRuntime(channelId, id, { accountId: id, running: false,