diff --git a/docs/plans/2026-09-27-claude-queue-idle-settlement.md b/docs/plans/2026-09-27-claude-queue-idle-settlement.md new file mode 100644 index 000000000..c4c259d45 --- /dev/null +++ b/docs/plans/2026-09-27-claude-queue-idle-settlement.md @@ -0,0 +1,47 @@ +# Claude queue idle settlement (#677) + +## Problem +`markStaleWhenIdle` is how Claude queue debt settles (`settleDebtByCohort` + `settleRemoveDebtByCohort`) and how unattributed survivors are marked stale. It had one live call site: a semantic event arriving while the session was already idle. + +Exact-evidence carriers (a content-bearing `remove`, `popAll(content)`) deliberately leave debt for that settlement. Two orderings therefore stranded a queue chip for an item that had already left: + +1. **Live:** the turn's last semantic event lands while `processActive` is still true, and the process then goes idle. The process-state handler never settled. +2. **Bootstrap:** production replay delivers no semantic events. The recorded `exact-remove-after-open-dequeue-debt` run ends with 3 pending items and `debt.count = 3`. + +## Rule +Every transition INTO idle asks the same question through one helper, `settleClaudeQueueIfIdle` in `useIpcSubscriptions.ts`: + +| Site | Idle means | +|---|---| +| semantic event (existing) | `!processActive && streamPhase === 'idle' && !awaitingAssistant` | +| process-state flip | `!active && streamPhase === 'idle'` (the handler clears `awaitingAssistant` itself) | +| bootstrap-complete | no live signal (`!processActive && streamPhase === 'idle'`). `awaitingAssistant` is excluded because queue-op replay forces it true. | + +Each site commits the result to `claudeQueueBySession` after `setRuntimes` returns, the same rule as the other queue commits. + +**Not done:** restoring inline settlement in the carriers. That was the #670 bug: it consumed the item the carrier named. + +## Tests +`claudeQueueIdleSettlement.renderer.test.tsx` drives the real subscriptions through the fake feed: + +- the issue's exact carrier sequence, settled on the process-idle flip; +- no settlement while the process is still active; +- the process-idle settlement is committed (a later `user` entry must not bring the item back); +- the recorded fixture replay, settled at bootstrap-complete and committed; +- no bootstrap settlement while the process is live. + +**Fail-first:** 2 fail on origin/main. + +**Mutations killed:** +- no process settle; +- no process commit; +- no bootstrap settle; +- no bootstrap commit; +- bootstrap ignores the live signal; +- process path ignores `active`. + +## Narrowed (manager decision, option B, after review round 2) +- **Process-state flip:** does NOT settle. A redelivered `dequeue` (queue-operation records carry no uuid) can make `debt >= pending` cover a genuinely queued prompt, and at a live flip that would hide a real queued user prompt. +- **Bootstrap-complete:** settles only when the open debt covers EVERY pending item, stale ones included (`debtCoversPending`). The recorded fixture proves this case. +- **Semantic site:** unchanged. +- **Scope:** the live-idle half of #677 waits on the redelivery follow-up issue, so this PR says `Refs #677`, not `Fixes`. diff --git a/src/renderer/src/workspace/hook/ipc/claudeQueueIdleSettlement.renderer.test.tsx b/src/renderer/src/workspace/hook/ipc/claudeQueueIdleSettlement.renderer.test.tsx new file mode 100644 index 000000000..b423f2cde --- /dev/null +++ b/src/renderer/src/workspace/hook/ipc/claudeQueueIdleSettlement.renderer.test.tsx @@ -0,0 +1,249 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, expect, it, vi } from 'vitest' +import { render } from '@testing-library/react' +import { act, useRef } from 'react' + +import { createFakeSessionFeed } from '@renderer/features/sessionFeed/FakeSessionFeed' +import { emptyRuntime } from '@renderer/session-runtime/state' +import type { SessionRuntime } from '@renderer/session-runtime/state' +import type { SessionId, WorkspaceState } from '@renderer/workspace/types' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' + +import { useIpcSubscriptions } from './useIpcSubscriptions' +import { makeWorkspaceRefsForTest as makeRefs } from './testing/workspaceRefsForTest' + +// #677: Claude queue debt settles only in markStaleWhenIdle, and its one live +// call site was a semantic event that arrives while the session is ALREADY +// idle. When the turn's last semantic event lands while the process is still +// active and the process then goes idle, nothing settled: a queue chip for an +// item that already left stayed on screen until the next turn, or forever for +// a session with no next turn. These drive the real subscriptions with the +// issue's exact carrier sequence. +const originalWindowApi = window.api +afterEach(() => { + vi.useRealTimers() + Object.defineProperty(window, 'api', { configurable: true, value: originalWindowApi }) +}) + +// Read, not imported: the fixture sits outside the web tsconfig's file list. +const fixture = JSON.parse(readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '../../../../../../testing/fixtures/queue-operations/exact-remove-after-open-dequeue-debt.json'), + 'utf8', +)) as { events: Array<{ op: string; content?: string; timestamp: string }> } + +const NOTIFICATION = '\nt-1\ncompleted\n' +const PROMPT = 'queued prompt' + +const op = (uuid: string, operation: string, content: string | undefined, second: number) => ({ + file: '/s/claude.jsonl', + entry: { + type: 'queue-operation', uuid, operation, ...(content === undefined ? {} : { content }), + timestamp: `2026-09-27T00:00:${String(second).padStart(2, '0')}.000Z`, + } as never, +}) + +let mounts = 0 +function mount(initial: Partial = {}) { + vi.useFakeTimers() + const fake = createFakeSessionFeed() + // A fresh id per test: the reconciler's per-session state is module-level + // (claudeQueueBySession), so a shared id would carry one test's queue into + // the next. + const sessionId = `queue-idle-${++mounts}` as SessionId + let workspaceState = { sessions: { [sessionId]: { cwd: '/repo', kind: 'claude' } } } as unknown as WorkspaceState + let runtimes: Record = { [sessionId]: { ...emptyRuntime(), ...initial } } + let refsForTest!: WorkspaceRefs + const commitRuntimes = (updater: Record | ((current: Record) => Record)): void => { + runtimes = typeof updater === 'function' ? updater(runtimes) : updater + refsForTest.latestRuntimesRef.current = runtimes + } + function Harness(): React.JSX.Element { + const refs = useRef(null) + if (refs.current === null) { + refs.current = makeRefs(workspaceState) + refs.current.latestRuntimesRef.current = runtimes + refsForTest = refs.current + } + useIpcSubscriptions(fake, refs.current, updater => { + workspaceState = typeof updater === 'function' ? updater(workspaceState) : updater + refs.current!.stateRef.current = workspaceState + refs.current!.latestStateRef.current = workspaceState + }, commitRuntimes, () => {}, () => {}) + return
+ } + Object.defineProperty(window, 'api', { configurable: true, value: { gitWorktrees: vi.fn(async () => ({ ok: false })) } }) + render() + return { fake, sessionId, runtime: () => runtimes[sessionId]! } +} + +/** Steps 1-3 of the issue: N and P queued, a content-free remove opens + * removeDebt, and an exact remove retires P while leaving that debt open. */ +function strandDebt(fake: ReturnType, sessionId: SessionId) { + act(() => { + fake.emitJsonlEntries({ sessionId, entries: [ + op('q1', 'enqueue', NOTIFICATION, 1), + op('q2', 'enqueue', PROMPT, 2), + op('q3', 'remove', undefined, 3), + op('q4', 'remove', PROMPT, 4), + ] }) + }) +} + +const visible = (runtime: SessionRuntime) => + runtime.queuedMessages.filter(item => !(item as { stale?: boolean }).stale).map(item => item.content) + +// Manager decision (option B, #1396 round 2): the process-idle flip does NOT +// settle. A redelivered `dequeue` can make the debt cover a genuinely queued +// prompt, and at a live flip that would hide a real queued user prompt. This +// pins the removed site: with the issue's exact sequence, the chip stays until +// a semantic idle event or bootstrap-complete settles it. +it('does not settle at a process-idle flip (removed site)', () => { + const { fake, sessionId, runtime } = mount() + act(() => { fake.emitProcessState({ sessionId, active: true, status: 'Working' }) }) + strandDebt(fake, sessionId) + act(() => { fake.emitSemantic({ sessionId, event: { type: 'turn_completed', ts: Date.now() } as never }) }) + act(() => { vi.advanceTimersByTime(50) }) + act(() => { fake.emitProcessState({ sessionId, active: false }) }) + expect(visible(runtime())).toEqual([NOTIFICATION]) +}) + +it('does not settle while the process is still active', () => { + const { fake, sessionId, runtime } = mount() + act(() => { fake.emitProcessState({ sessionId, active: true, status: 'Working' }) }) + strandDebt(fake, sessionId) + act(() => { fake.emitProcessState({ sessionId, active: true, status: 'Still working' }) }) + expect(visible(runtime())).toEqual([NOTIFICATION]) +}) + +// Bootstrap: production replay delivers no semantic events, so the recorded +// run that ends with 3 pending items and debt.count = 3 (reconcile.test.ts +// pins those numbers on the reducer) kept all three chips after replay. +it('settles the recorded open-debt replay at bootstrap-complete', () => { + const { fake, sessionId, runtime } = mount({ bootstrapping: true }) + act(() => { + fake.emitJsonlEntries({ + sessionId, + entries: fixture.events.map((event, index) => op(`r${index}`, event.op, event.content, 0)).map((entry, index) => ({ + ...entry, entry: { ...(entry.entry as object), timestamp: fixture.events[index]!.timestamp } as never, + })), + }) + }) + expect(runtime().queuedMessages).toHaveLength(3) + act(() => { vi.advanceTimersByTime(1_000) }) + expect(runtime().bootstrapping).toBe(false) + expect(visible(runtime())).toEqual([]) + + // Committed, not only painted (see the process-idle commit test). + act(() => { + fake.emitJsonlEntries({ sessionId, entries: [{ + file: '/s/claude.jsonl', + entry: { type: 'user', uuid: 'u-after-replay', message: { role: 'user', content: 'a later prompt' }, timestamp: '2026-09-27T00:00:09.000Z' } as never, + }] }) + }) + expect(visible(runtime())).toEqual([]) +}) + +it('leaves the queue alone at bootstrap-complete while the process is live', () => { + const { fake, sessionId, runtime } = mount({ bootstrapping: true, processActive: true }) + strandDebt(fake, sessionId) + act(() => { vi.advanceTimersByTime(1_000) }) + expect(visible(runtime())).toEqual([NOTIFICATION]) +}) + +// #1396 review a: idleness is not proof the queue drained. Between turns the +// spinner can read idle while Claude still holds N2, and a resumed pane's +// first replay-quiet tick starts from idle defaults. With no open debt to +// account for N2, neither the process-idle flip (which no longer settles) nor +// bootstrap-complete may touch it. +const FIRST = 'first queued prompt' +const SECOND = 'second queued prompt' +function deliverFirstKeepSecond(fake: ReturnType, sessionId: SessionId) { + act(() => { + fake.emitJsonlEntries({ sessionId, entries: [ + op('k1', 'enqueue', FIRST, 1), + op('k2', 'enqueue', SECOND, 2), + op('k3', 'dequeue', undefined, 3), + { file: '/s/claude.jsonl', entry: { type: 'user', uuid: 'k-user', message: { role: 'user', content: FIRST }, timestamp: '2026-09-27T00:00:04.000Z' } as never }, + ] }) + }) +} + +it('leaves a genuinely queued item live when the process goes idle between turns', () => { + const { fake, sessionId, runtime } = mount() + act(() => { fake.emitProcessState({ sessionId, active: true, status: 'Working' }) }) + deliverFirstKeepSecond(fake, sessionId) + expect(visible(runtime())).toEqual([SECOND]) + act(() => { fake.emitProcessState({ sessionId, active: false }) }) + expect(visible(runtime())).toEqual([SECOND]) +}) + +it('leaves a genuinely queued item live at bootstrap-complete', () => { + const { fake, sessionId, runtime } = mount({ bootstrapping: true }) + deliverFirstKeepSecond(fake, sessionId) + act(() => { vi.advanceTimersByTime(1_000) }) + expect(runtime().bootstrapping).toBe(false) + expect(visible(runtime())).toEqual([SECOND]) +}) + +// The stream-phase half of the bootstrap guard: a quiet replay while the +// semantic stream is still responding is not idle, even with the debt +// covering everything. +it('does not settle at bootstrap-complete while the stream is still responding', () => { + const { fake, sessionId, runtime } = mount({ bootstrapping: true, streamPhase: 'responding' }) + strandDebt(fake, sessionId) + act(() => { vi.advanceTimersByTime(1_000) }) + expect(visible(runtime())).toEqual([NOTIFICATION]) +}) + +// #1396 review a, round 2: stale items stay inference candidates, so they +// must count against the debt too. S is stale-marked by the semantic idle +// site while still queued; Claude then really dequeues S (one debt unit) while +// N stays queued. Counting only non-stale items saw debt 1 >= live 1 and +// settled, which consumed S by cohort and stale-marked the live N. +it('counts stale items when deciding whether the debt covers the queue', () => { + const { fake, sessionId, runtime } = mount({ bootstrapping: true }) + act(() => { + fake.emitJsonlEntries({ sessionId, entries: [ + op('s1', 'enqueue', 'A', 1), + op('s2', 'enqueue', 'S', 2), + op('s3', 'dequeue', undefined, 3), + { file: '/s/claude.jsonl', entry: { type: 'user', uuid: 's-user-a', message: { role: 'user', content: 'A' }, timestamp: '2026-09-27T00:00:04.000Z' } as never }, + ] }) + }) + // The existing semantic idle site marks S stale. + act(() => { fake.emitSemantic({ sessionId, event: { type: 'turn_completed', ts: Date.now() } as never }) }) + act(() => { vi.advanceTimersByTime(50) }) + expect(runtime().queuedMessages.map(item => [item.content, Boolean((item as { stale?: boolean }).stale)])).toEqual([['S', true]]) + act(() => { + fake.emitJsonlEntries({ sessionId, entries: [op('s4', 'enqueue', 'N', 5), op('s5', 'dequeue', undefined, 6)] }) + }) + act(() => { vi.advanceTimersByTime(1_000) }) + expect(runtime().bootstrapping).toBe(false) + expect(visible(runtime())).toContain('N') +}) + +// q101 (review a, round 2): the same `dequeue` record delivered twice (queue +// operations have no uuid, #1402) opens a phantom debt unit, and bootstrap +// spends it on N2, a prompt Claude still holds. KNOWN FAILURE, pinned with +// it.fails: nothing observable at bootstrap tells this apart from the +// recorded fixture (both are `debt >= pending`), so #1396 is parked behind +// #1402. When #1402 makes departures countable once, flip this to `it`; it +// must then pass before #1396 can merge. +it.fails('does not retire a still-queued prompt at bootstrap when a dequeue is redelivered (#1402)', () => { + const { fake, sessionId, runtime } = mount({ bootstrapping: true }) + act(() => { + fake.emitJsonlEntries({ sessionId, entries: [ + op('r1', 'enqueue', FIRST, 1), + op('r2', 'enqueue', SECOND, 2), + op('r3', 'dequeue', undefined, 3), + { file: '/s/claude.jsonl', entry: { type: 'user', uuid: 'r-user', message: { role: 'user', content: FIRST }, timestamp: '2026-09-27T00:00:04.000Z' } as never }, + ] }) + }) + // The redelivery: the identical dequeue record again. + act(() => { fake.emitJsonlEntries({ sessionId, entries: [op('r3', 'dequeue', undefined, 3)] }) }) + act(() => { vi.advanceTimersByTime(1_000) }) + expect(runtime().bootstrapping).toBe(false) + expect(visible(runtime())).toEqual([SECOND]) +}) diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts index 7c48f18fa..5c23f0dc9 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts @@ -5,7 +5,7 @@ import { DEFAULT_PROVIDER, isAgentProviderKind, } from '@shared/types/providerKind' -import type { AgentProviderKind } from '@shared/types/providerKind' +import type { AgentProviderKind, SessionKind } from '@shared/types/providerKind' import { useEffect } from 'react' import type { Entry } from '@shared/types/transcript' @@ -178,6 +178,88 @@ function claudeUserEntryText(raw: unknown): string | null { // once per burst. const claudeQueueBySession = new Map() +/** + * The idle settlement of a Claude session's queue, or null when nothing + * changed (#677). Callers commit a non-null result to claudeQueueBySession + * AFTER setRuntimes returns (the rule above) and paint `result.pending`. + * + * WHY one helper with three callers: settlement (settle both debts, then mark + * unattributed survivors stale) is how a queue item that already departed + * stops being displayed, and exact-evidence carriers (content-bearing + * `remove`, `popAll(content)`) deliberately leave their debt for it. It used + * to be reachable only from a semantic event that arrived while the session + * was already idle. When the turn's last semantic event lands while the + * process is still active and the process then goes idle, or a bootstrap + * replay ends with debt open (production bootstrap gets no replayed semantic + * events), nothing settled and the chip stayed until the next turn, or + * forever for a session with no next turn. The semantic event (existing + * guard) and bootstrap-complete now ask this; bootstrap also requires + * `debt-covers-pending` (see debtCoversPending), because a quiet replay alone + * is not proof the queue drained. + * + * WHY NOT the process-state flip (manager decision, option B, on #1396's + * round-2 review): a REDELIVERED `dequeue` makes `debt >= pending` cover a + * genuinely queued prompt, and at a live process-idle flip that would hide a + * real queued user prompt. We do not trade a hidden user prompt for tidier + * state. Bootstrap is kept because the recorded fixture proves that case. + * The live-idle half of #677 waits on the redelivery follow-up. + * + * WHY not settle inline in the carriers instead: that inline settlement was + * the bug those carriers were fixed for. It consumed the very item the + * carrier named and left an unrelated item pending forever (#670). + * + * Only an EXISTING reconciler state is settled. A session with no map entry + * has no queue, and defaulting to a fresh state compared unequal on every idle + * tick (the no-op bail regression noted at the semantic call site). + */ +function settleClaudeQueueIfIdle( + sessionId: SessionId, + sessionKind: SessionKind | undefined, + idle: boolean, + proof: 'idle' | 'debt-covers-pending', +): ClaudeQueueState | null { + if (sessionKind !== 'claude' || !idle) return null + const existing = claudeQueueBySession.get(sessionId) + if (existing === undefined) return null + if (proof === 'debt-covers-pending' && !debtCoversPending(existing)) return null + const marked = markStaleWhenIdle(existing, true) + // markStaleWhenIdle is reference-stable, so identity is the change test. + return marked === existing ? null : marked +} + +/** + * True when the open departure debt accounts for EVERY pending item + * (#1396 review a). Each unit of debt is a departure Claude logged (a + * `dequeue`, or a content-free `remove`) whose item was not identified. When + * those departures number at least the pending items, every one of them has + * left, PROVIDED each logged departure is counted once. + * + * RESIDUAL (review a, round 2): queue-operation records carry no uuid, so a + * REDELIVERED `dequeue` (the burst channel can redeliver, see the enqueue + * guard in reconcile.ts) is counted twice and can make the debt cover a + * genuinely queued item. A key-based guard is not possible: the local Claude + * corpus has 99 genuine same-millisecond, same-content `dequeue` repeats + * within one file (2,391 dequeues across 606 transcripts), which a + * (operation, timestamp) guard would wrongly drop. Redelivery already + * over-counts debt for every reducer path; this site inherits it. + * + * WHY bootstrap-complete needs this proof and the semantic site does not get + * it added: the quiet replay timer is not proof that Claude's queue is empty. + * A resumed pane's first 150 ms after replay starts from emptyRuntime's idle + * values, and settling there without the proof marked a genuinely queued N2 + * `stale-unattributed`, which nothing un-stales. The semantic site keeps its + * existing guard and behaviour; this PR does not widen it. + */ +function debtCoversPending(state: ClaudeQueueState): boolean { + // EVERY pending item, stale ones included (#1396 review a, round 2): the + // reconciler settles debt against all of `pending`, so a stale item is + // still a candidate the debt can be spent on. Counting only live items let + // one genuine departure of a stale S look like proof that the live N had + // left, and settling then stale-marked N. + const debt = (state.debt?.count ?? 0) + (state.removeDebt?.count ?? 0) + return debt >= state.pending.length +} + const codexCurrentTurnIdBySession = new Map() const jsonlProviderStreamBySession = new Map() @@ -1408,18 +1490,14 @@ export function useIpcSubscriptions( // created state and compared it against `undefined` — which read as // "changed" on every idle event, wiping queuedMessages to the empty // pending list and defeating the no-op bail below. - const existingQueue = claudeQueueBySession.get(sessionId) - const claudeQueueIdle = - sessionKind === 'claude' && - existingQueue !== undefined && - !current.processActive && - streamPhase === 'idle' && - !nextAwaitingAssistant - const markedQueue = - claudeQueueIdle && existingQueue ? markStaleWhenIdle(existingQueue, true) : null - // markStaleWhenIdle is reference-stable, so identity is the change test. - const staleChanged = markedQueue !== null && markedQueue !== existingQueue - if (staleChanged) pendingStaleQueue = markedQueue + const markedQueue = settleClaudeQueueIfIdle( + sessionId, + sessionKind, + !current.processActive && streamPhase === 'idle' && !nextAwaitingAssistant, + 'idle', + ) + const staleChanged = markedQueue !== null + pendingStaleQueue = markedQueue // A completed turn is the one unambiguous proof that the limit episode // is over: the provider answered. `turn_stopped` deliberately does NOT @@ -2664,6 +2742,8 @@ export function useIpcSubscriptions( if (existing) clearTimeout(existing) const timer = setTimeout(() => { refs.bootstrapTimersRef.current.delete(sessionId) + // Captured by the updater, committed after setRuntimes returns (#677). + let pendingIdleQueue: ClaudeQueueState | null = null setRuntimes(prev => { const current = prev[sessionId] if (!current || !current.bootstrapping) return prev @@ -2723,6 +2803,21 @@ export function useIpcSubscriptions( let next = current const reconciled: string[] = [] + // #677: replay can end with Claude queue debt open (the recorded + // exact-remove-after-open-dequeue-debt sequence finishes with 3 + // pending and debt.count = 3), and production bootstrap receives no + // replayed semantic events to settle it. awaitingAssistant is NOT + // part of idleness here: queue-op replay itself forces it true + // (case 1 above), so requiring it false would never settle. Runs + // before the awaitingAssistant clear so a queue that settlement + // empties lets that clear follow. + const idleQueue = settleClaudeQueueIfIdle(sessionId, sessionKind, !hasLiveSignal, 'debt-covers-pending') + pendingIdleQueue = idleQueue + if (idleQueue !== null) { + next = { ...next, queuedMessages: idleQueue.pending } + reconciled.push('claudeQueue') + } + if ( !hasLiveSignal && next.awaitingAssistant && @@ -2816,6 +2911,7 @@ export function useIpcSubscriptions( ), } }) + if (pendingIdleQueue !== null) claudeQueueBySession.set(sessionId, pendingIdleQueue) }, 150) refs.bootstrapTimersRef.current.set(sessionId, timer) closeSpan({