🫱 fix: Keep Polling Through Queued Run Handoffs (#15509)

Queue a message during a run — queue, not steer — and the row is server-owned:
`useQueueDrain` deliberately declines the boundary (`serverOwnsBoundary`) and
the backend admits the successor itself. So the client has to hear about that
run through the active-job list, exactly like a background-tool continuation.

It cannot. Finishing a run empties the list — the client removes its own job
optimistically the moment FINAL lands — and the poll is disabled on an empty
list, so it goes quiet at precisely the moment the successor is admitted. With
the tab already focused there is no focus refetch either, which is why this
reproduces on desktop and not only on mobile. The announcement never arrives,
so the re-arm never runs and the pane sits on the previous turn until a reload.

Keep asking for a bounded window after the last known job ends. The window is
module-scoped rather than per-observer because this is one shared query and a
component mounting mid-handover should inherit the grace, not restart it.

This is the other half of the previous commits: without the re-arm the
announcement lands on a pane that still reads its finished submission as an
attachment, and without this the announcement never lands at all. Neither half
is observable alone.
This commit is contained in:
Danny Avila 2026-09-02 09:38:42 -04:00 • committed by GitHub
parent 519e64769a
commit a20048ab2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 93 additions and 4 deletions

View file

@ -42,6 +42,10 @@ import {
genTitleQueryKey,
queueTitleGeneration,
useActiveJobs,
resetActiveJobsGrace,
getActiveJobsRefetchInterval,
ACTIVE_JOBS_POLL_MS,
ACTIVE_JOBS_SUCCESSOR_GRACE_MS,
} from '../queries';
describe('fetchStreamStatus generation protocol advertisement', () => {
@ -84,14 +88,65 @@ describe('useActiveJobs focus behaviour', () => {
expect(options.staleTime).toBe(5_000);
});
it('polls only while something is listed', () => {
it('polls while something is listed', () => {
(useQuery as jest.Mock).mockClear();
resetActiveJobsGrace();
useActiveJobs();
const options = (useQuery as jest.Mock).mock.calls[0][0];
expect(options.refetchInterval({ activeJobIds: ['convo-1'] })).toBe(5_000);
expect(options.refetchInterval({ activeJobIds: [] })).toBe(false);
expect(options.refetchInterval({ activeJobIds: ['convo-1'] })).toBe(ACTIVE_JOBS_POLL_MS);
});
});
describe('useActiveJobs successor grace', () => {
let now = 1_000_000;
beforeEach(() => {
now = 1_000_000;
jest.spyOn(Date, 'now').mockImplementation(() => now);
resetActiveJobsGrace();
});
afterEach(() => {
(Date.now as jest.Mock).mockRestore?.();
resetActiveJobsGrace();
});
it('does not poll for an empty list it has never seen fill', () => {
expect(getActiveJobsRefetchInterval({ activeJobIds: [] })).toBe(false);
});
it('keeps asking across the handover to a server-started successor', () => {
/**
* The run ends, the client removes its own job optimistically, and the
* backend admits the queued turn (or a background-tool continuation) it
* owns. Going quiet on the empty list is what leaves the pane on the
* previous turn until a reload.
*/
expect(getActiveJobsRefetchInterval({ activeJobIds: ['convo-1'] })).toBe(ACTIVE_JOBS_POLL_MS);
now += 1_000;
expect(getActiveJobsRefetchInterval({ activeJobIds: [] })).toBe(ACTIVE_JOBS_POLL_MS);
});
it('stops once the handover window has passed without a successor', () => {
expect(getActiveJobsRefetchInterval({ activeJobIds: ['convo-1'] })).toBe(ACTIVE_JOBS_POLL_MS);
now += ACTIVE_JOBS_SUCCESSOR_GRACE_MS + 1;
expect(getActiveJobsRefetchInterval({ activeJobIds: [] })).toBe(false);
});
it('renews the window when the successor turns up', () => {
getActiveJobsRefetchInterval({ activeJobIds: ['convo-1'] });
now += 1_000;
getActiveJobsRefetchInterval({ activeJobIds: [] });
now += 1_000;
expect(getActiveJobsRefetchInterval({ activeJobIds: ['convo-1'] })).toBe(ACTIVE_JOBS_POLL_MS);
now += ACTIVE_JOBS_SUCCESSOR_GRACE_MS - 1;
expect(getActiveJobsRefetchInterval({ activeJobIds: [] })).toBe(ACTIVE_JOBS_POLL_MS);
});
});

View file

@ -219,6 +219,40 @@ export function useTitleGeneration(enabled = true) {
* - Polls while jobs are active
* - Shows generation indicators in conversation list
*/
export const ACTIVE_JOBS_POLL_MS = 5_000;
/**
* How long to keep asking after the last job this client knew about ended.
*
* A successor the server admits for itself — a queued turn handed to the
* backend, a background-tool continuation — starts as part of terminalizing
* the run before it. But finishing a run also empties this list, partly
* because the client removes its own job optimistically the moment `FINAL`
* lands. Stopping the poll on an empty list therefore goes quiet at exactly
* the moment a successor appears, and with the tab already focused there is no
* focus refetch to recover: the pane waits for a reload. Keep asking across
* that handover instead.
*/
export const ACTIVE_JOBS_SUCCESSOR_GRACE_MS = 30_000;
/** Module-scoped rather than per-observer: this is one shared query, and a
* component mounting mid-handover should inherit the grace, not restart it. */
let lastListedActiveJobsAt = 0;
/** @internal Test seam — the grace window is wall-clock state. */
export function resetActiveJobsGrace(): void {
lastListedActiveJobsAt = 0;
}
export function getActiveJobsRefetchInterval(data?: ActiveJobsResponse): number | false {
if ((data?.activeJobIds?.length ?? 0) > 0) {
lastListedActiveJobsAt = Date.now();
return ACTIVE_JOBS_POLL_MS;
}
return Date.now() - lastListedActiveJobsAt < ACTIVE_JOBS_SUCCESSOR_GRACE_MS
? ACTIVE_JOBS_POLL_MS
: false;
}
export function useActiveJobs(enabled = true) {
return useQuery({
queryKey: [QueryKeys.activeJobs],
@ -231,7 +265,7 @@ export function useActiveJobs(enabled = true) {
* invisible until some unrelated refetch, and returning to the tab is
* exactly when a pane needs to know whether its history has moved. */
refetchOnWindowFocus: 'always',
refetchInterval: (data) => ((data?.activeJobIds?.length ?? 0) > 0 ? 5_000 : false),
refetchInterval: getActiveJobsRefetchInterval,
retry: false,
});
}