Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions apps/desktop/src/app/chat/composer/status-stack/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
dismissBackgroundProcess,
groupStatusItems,
refreshBackgroundProcesses,
resetBackgroundPollingGuard,
type StatusGroup,
stopBackgroundProcess
} from '@/store/composer-status'
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<I18nProvider configClient={null} initialLocale="en">
<ComposerStatusStack queue={null} sessionId={SID} />
</I18nProvider>
</MemoryRouter>
)
}

// #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()
})
})
Loading