fix(desktop): refetch the context meter when compression completes without a turn (#94001)

Sandbox verification of the first fix exposed the remaining gap: manual
/compress runs the session.compress RPC OUTSIDE any turn, so busy never
flips and useContextBreakdown's effect deps (attempt/busy/enabled/
requestGateway/sessionId) never change — the meter kept serving the
pre-compression breakdown (441.2k/1M) after the transcript shrank 1220 →
202 messages (~5.7x), until the status-bar item was unmounted and
remounted, which reset useState and forced a fresh fetch.

Add an invalidation seam: invalidateContextBreakdown(sessionId) bumps a
per-session generation and notifies subscribers; the hook subscribes for
its own session and re-runs the fetch effect on a bump. Two call sites:
slash.ts after a successful session.compress (the no-turn path), and
lifecycle.ts on session.reclaimed (belt-and-braces with the retry ladder
— invalidation makes the refetch immediate, retries cover failures).

Also verified live: the pre-compression value was wrong by 5.7x after
compression, and unmount/remount of the footer item (the user's manual
workaround) is exactly the cache-reset the fix now does automatically.
This commit is contained in:
gilfoyle
2026-09-09 16:20:20 -04:00
committed by brooklyn!
parent 32f146b07e
commit 0dbfa8ae9c
4 changed files with 160 additions and 17 deletions

View File

@@ -2,6 +2,7 @@ import type { GatewayEvent } from '@hermes/shared'
import type { HermesSkin } from '@hermes/shared/skin'
import { clearClarifyRequest } from '@/store/clarify'
import { invalidateContextBreakdown } from '@/app/shell/hooks/use-context-breakdown'
import {
notifyCronChanged,
notifyPairingChanged,
@@ -128,9 +129,18 @@ export function handleLifecycleEvent(ctx: GatewayEventContext): boolean {
// conversation and reopening it resumes from the DB.
const reclaimedRuntimeId = String((payload as { session_id?: string } | undefined)?.session_id ?? '')
// The compression/reclaim lifecycle invalidates the keyed context
// breakdown so the statusbar gauge refetches instead of serving the
// pre-compression figure (#94001). The breakdown cache keys on the
// STORED id; the reclaim payload carries it alongside the runtime id.
const reclaimedStoredId = String(
(payload as { stored_session_id?: string } | undefined)?.stored_session_id ?? ''
)
if (reclaimedRuntimeId) {
// Heal while the cached stored-id mapping is still intact, then drop.
markRuntimeGone(reclaimedRuntimeId)
invalidateContextBreakdown(reclaimedStoredId || reclaimedRuntimeId)
dropSessionState(reclaimedRuntimeId)
// A prompt keyed to the dead runtime must not outlive it. The runtime id
// rotates on every resume (cold/lazy/eager all mint a fresh sid), so the

View File

@@ -3,6 +3,7 @@ import { parseCommandDispatch, parseSlashCommand } from '@hermes/shared'
import { type MutableRefObject, useCallback, useRef } from 'react'
import { prepareDefaultNewSession } from '@/app/session/new-session-route'
import { invalidateContextBreakdown } from '@/app/shell/hooks/use-context-breakdown'
import { getProfiles } from '@/hermes'
import type { Translations } from '@/i18n'
import { type ChatMessage, toChatMessages } from '@/lib/chat-messages'
@@ -755,6 +756,13 @@ export function useSlashCommand(deps: SlashCommandDeps) {
)
}
// The transcript just shrank by 5-10x outside any turn (busy never
// flipped), so the keyed context breakdown — if already fetched —
// is now wrong by that factor. Bump the invalidation generation:
// the statusbar gauge refetches immediately instead of serving the
// pre-compression figure until the session is switched (#94001).
invalidateContextBreakdown(sessionId)
const usage = { ...result?.usage, ...result?.info?.usage }
if (Object.keys(usage).length && activeSessionIdRef.current === sessionId) {

View File

@@ -5,7 +5,11 @@ import type { ContextBreakdown } from '@/types/hermes'
import { deferred } from '../../../test/deferred'
import { useContextBreakdown } from './use-context-breakdown'
import {
_resetContextBreakdownInvalidationsForTests,
invalidateContextBreakdown,
useContextBreakdown
} from './use-context-breakdown'
type GatewayRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
@@ -236,4 +240,57 @@ describe('useContextBreakdown (#94001)', () => {
// s1's 91% must NOT appear under s2.
expect(result.current.breakdown).toBeNull()
})
})
})
describe('useContextBreakdown invalidation (#94001 follow-up)', () => {
beforeEach(() => {
_resetContextBreakdownInvalidationsForTests()
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.useRealTimers()
})
it('refetches immediately when invalidateContextBreakdown bumps the session generation', async () => {
let answer = PRE_COMPRESSION_BREAKDOWN
const requestGateway = vi.fn(async () => answer) as unknown as GatewayRequester
const { result, rerender } = renderHook(props => useContextBreakdown(props), {
initialProps: { busy: false, enabled: true, requestGateway, sessionId: 's1' as null | string }
})
await flushAsync()
expect(result.current.breakdown).toEqual(PRE_COMPRESSION_BREAKDOWN)
expect(requestGateway).toHaveBeenCalledTimes(1)
// Compression happened outside any turn (no busy toggle) — invalidate.
answer = LIVE_BREAKDOWN
invalidateContextBreakdown('s1')
rerender({ busy: false, enabled: true, requestGateway, sessionId: 's1' })
await flushAsync()
// The meter must serve the POST-compression figure without a busy toggle
// or a session switch.
expect(result.current.breakdown).toEqual(LIVE_BREAKDOWN)
expect(requestGateway).toHaveBeenCalledTimes(2)
})
it('does not refetch a different session when an unrelated session is invalidated', async () => {
const requestGateway = vi.fn(async () => PRE_COMPRESSION_BREAKDOWN) as unknown as GatewayRequester
const { result, rerender } = renderHook(props => useContextBreakdown(props), {
initialProps: { busy: false, enabled: true, requestGateway, sessionId: 's1' as null | string }
})
await flushAsync()
expect(requestGateway).toHaveBeenCalledTimes(1)
invalidateContextBreakdown('other-session')
rerender({ busy: false, enabled: true, requestGateway, sessionId: 's1' })
await flushAsync()
expect(requestGateway).toHaveBeenCalledTimes(1)
expect(result.current.breakdown).toEqual(PRE_COMPRESSION_BREAKDOWN)
})
})

View File

@@ -25,16 +25,25 @@ interface ContextBreakdownOptions {
* sessions drops the previous numbers instead of painting them under the new
* session's name.
*
* The refetch after a turn end can land in windows where the answer is not
* trustworthy (#94001): right after a `session.reclaimed` resume the new
* agent may still be building (the backend answers a ZEROED breakdown,
* which would blank the meter), or the RPC can fail outright during the
* rebind. Neither may freeze the gauge on pre-compression numbers: a failed
* or agentless answer is retried on a bounded backoff, and once retries are
* exhausted the cached breakdown is EVICTED — the meter shows nothing rather
* than numbers known to be stale. Without eviction the stale value would
* survive until the session is switched, which is the manual recovery every
* report describes. */
* Two events change the transcript WITHOUT a busy toggle, so the refetch
* must be driven explicitly:
*
* - **Compression** — manual `/compress` runs the `session.compress` RPC
* outside any turn (busy never flips), and auto-compression can commit
* mid-turn. The post-compression transcript measures a fraction of the
* pre-compression size, so a served pre-compression breakdown is not
* merely stale, it is wrong by 5-10x (#94001).
* - **Reclaim** (`session.reclaimed`) — the runtime is reaped and the
* session re-resumes; the first refetch can race the agent rebuild.
*
* `invalidateContextBreakdown` bumps a per-session generation that both
* call sites (slash.ts after a successful compress, lifecycle.ts on
* reclaim) fire, forcing an immediate refetch. An untrustworthy answer —
* an outright failure, or a ZEROED breakdown from the backend's
* agent-is-None branch (`context_max: 0`) — retries on a bounded backoff;
* once the ladder is exhausted the cached breakdown is EVICTED so the
* meter goes dark honestly rather than showing numbers known to be
* stale. */
/** A breakdown the backend computed from a live agent carries a real
* context_max (the compressor's context_length). Zero means the
@@ -45,15 +54,73 @@ function isZeroedBreakdown(breakdown: ContextBreakdown): boolean {
const RETRY_DELAYS_MS = [1_000, 4_000, 12_000]
/** Per-session invalidation generations. `invalidateContextBreakdown` bumps
* the generation for one session and notifies subscribers; the hook
* subscribes for its own session, so a bump re-runs the fetch immediately. */
const invalidationGenerations = new Map<string, number>()
const invalidationListeners = new Set<(sessionId: string, generation: number) => void>()
/** Force a refetch of the context breakdown for `sessionId` on the next
* render. Call when the transcript is known to have changed outside a
* turn: after a successful `session.compress`, and on
* `session.reclaimed`. */
export function invalidateContextBreakdown(sessionId: string): void {
const id = sessionId.trim()
if (id) {
const generation = (invalidationGenerations.get(id) ?? 0) + 1
invalidationGenerations.set(id, generation)
for (const listener of invalidationListeners) {
listener(id, generation)
}
}
}
/** Clear all invalidation generations (test isolation — the Map is
* module-level and would otherwise leak across tests). */
export function _resetContextBreakdownInvalidationsForTests(): void {
invalidationGenerations.clear()
}
export function useContextBreakdown({ busy, enabled, requestGateway, sessionId }: ContextBreakdownOptions) {
const [fetched, setFetched] = useState<{ breakdown: ContextBreakdown; sessionId: string } | null>(null)
const [loading, setLoading] = useState(false)
// Bounded retry: `attempt` indexes RETRY_DELAYS_MS; advancing it re-runs the
// fetch effect. `exhausted` marks the end state — cached numbers evicted,
// backoff stopped, meter dark until the next legitimate trigger (session
// change or turn end) starts a fresh ladder.
// change, turn end, or invalidation) starts a fresh ladder.
const [attempt, setAttempt] = useState(0)
const [exhausted, setExhausted] = useState(false)
const [generation, setGeneration] = useState(0)
// Subscribe to invalidation bumps for THIS session: a bump from
// invalidateContextBreakdown (post-compress, reclaim) re-runs the fetch
// effect immediately, without waiting for a busy toggle or session switch.
useEffect(() => {
if (!sessionId) {
return
}
const listener = (id: string, next: number) => {
if (id === sessionId) {
setGeneration(current => (current === next ? current : next))
}
}
invalidationListeners.add(listener)
// Adopt any generation bumped before this effect subscribed (e.g. the
// invalidation fired while the meter was hidden, or between sessions).
const pending = invalidationGenerations.get(sessionId) ?? 0
setGeneration(current => (current === pending ? current : pending))
return () => {
invalidationListeners.delete(listener)
}
}, [sessionId])
// A session switch must not inherit the previous session's retry state —
// its first successful fetch is authoritative for it. (The fetch effect's
@@ -127,12 +194,13 @@ export function useContextBreakdown({ busy, enabled, requestGateway, sessionId }
clearTimeout(timer)
}
}
// `attempt` drives the bounded-retry re-runs; everything else is a trigger.
}, [attempt, busy, enabled, requestGateway, sessionId])
// `attempt` + `generation` drive the refetch re-runs; everything else is a trigger.
}, [attempt, busy, enabled, generation, requestGateway, sessionId])
// While the retry ladder is still running, the last good breakdown stays on
// screen (the meter ticking down beats flickering dark); once exhausted the
// cache is gone, so this returns null and the meter goes dark honestly.
// screen (the meter ticking down beats flickering dark for a transient window);
// once exhausted the cache is gone, so this returns null and the meter goes
// dark honestly.
return {
// The effect clears `fetched` only after commit, so gate on `busy` here too:
// the first busy render must not hand out the pre-turn snapshot.