diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index 27ed53c34224d..9e74590af8ca0 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -21,7 +21,6 @@ import { dismissBackgroundProcess, groupStatusItems, refreshBackgroundProcesses, - resetBackgroundPollingGuard, type StatusGroup, stopBackgroundProcess } from '@/store/composer-status' @@ -103,13 +102,15 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const groups = useMemo(() => groupStatusItems(items), [items]) // Seed from the registry on session open; event-driven refreshes (terminal / - // process tool completions) live in use-message-stream. + // process tool completions) live in use-message-stream. This must NOT reset + // the gone-polling latch: a mount/remount is not proof of a fresh runtime + // binding (a boot-restored tile can remount repeatedly while still bound to + // a dead runtime id), so clearing it here re-arms an endless 4001 storm + // against that id. The latch is reset at the actual rebind seams instead — + // gateway reconnect and runtime re-mint (see resetBackgroundPollingGuard + // call sites in use-gateway-boot.ts and store/gateway.ts). useEffect(() => { if (sessionId) { - // Opening/rebinding a session is a fresh runtime binding: clear any - // gone-latch left by a previous runtime under this id so the poll below - // is allowed to run again (see resetBackgroundPollingGuard). - resetBackgroundPollingGuard(sessionId) void refreshBackgroundProcesses(sessionId) void refreshSessionGoal(sessionId) } diff --git a/apps/desktop/src/app/chat/composer/status-stack/polling-guard.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/polling-guard.test.tsx new file mode 100644 index 0000000000000..4df2aea8896b7 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/polling-guard.test.tsx @@ -0,0 +1,78 @@ +import { cleanup, render } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' +import { resetBackgroundPollingGuard } from '@/store/composer-status' +import { $gateway } from '@/store/gateway' + +import { ComposerStatusStack } from './index' + +// The stack measures itself into a surface var — jsdom has no ResizeObserver. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.stubGlobal('ResizeObserver', ResizeObserverStub) + +const SID = 'sess-dead-runtime' + +function renderStack() { + return render( + + + + + + ) +} + +// #98434: a boot-restored tile can stay bound to a dead runtime id and remount +// repeatedly (no genuine rebind ever happens). The mount effect used to clear +// the gone-polling latch on every mount, so each remount re-armed the 4001 +// storm against that id forever. +describe('ComposerStatusStack dead-runtime remount', () => { + beforeEach(() => { + resetBackgroundPollingGuard() + }) + + afterEach(() => { + cleanup() + $gateway.set(null as never) + resetBackgroundPollingGuard() + }) + + it('does not re-poll process.list after a remount once the session is latched gone', async () => { + const request = vi.fn(async (method: string) => { + if (method === 'process.list') { + throw new Error('session not found') + } + + return {} + }) + + const processListCalls = () => request.mock.calls.filter(([method]) => method === 'process.list').length + + $gateway.set({ request } as never) + + const first = renderStack() + await Promise.resolve() + await Promise.resolve() + + expect(processListCalls()).toBe(1) + + first.unmount() + + const second = renderStack() + await Promise.resolve() + await Promise.resolve() + + // Before the fix: the mount effect cleared the latch, so this remount + // re-fired process.list against the same dead id. + expect(processListCalls()).toBe(1) + + second.unmount() + }) +})