Skip to content
63 changes: 63 additions & 0 deletions docs/plans/2026-09-26-claude-queue-decision-window.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Bound the Claude queue decision log (#676)

## Problem

`ClaudeQueueState.decisions` is append-only for a session's lifetime ("The diagnosis IS this record",
principle P4). Every departure from the queue appends a `QueueDecision` carrying a content preview and
evidence. `pending` stays small, but `decisions` grows with lifetime queue churn.

Every append also copies the whole array (`[...state.decisions, …]`, 7 sites in `reconcile.ts`), so a
long session pays O(n) per departure and O(n²) overall.

## Evidence

- `claudeQueue/types.ts:125` declares the field; `reconcile.ts` appends at 7 sites.
- **Readers (origin/main):** none outside `reconcile.ts` and the tests.
- The per-session state lives in `useIpcSubscriptions.ts`'s module-level `claudeQueueBySession` map,
not in `SessionRuntime`, so it is not part of debug bundles either.
- The UI (`QueueStrip`) renders `pending` and `stale`, not decisions.
- `reconcile.test.ts:253` asserts `settled.decisions.length === settled.nextSeq`: exact conservation
across a whole recorded replay. A fix must keep an equivalent contract.

## Decisions (defaults)

- **A bounded window, not a cap that forgets silently.** Keep the most recent
`QUEUE_DECISION_WINDOW = 200` decisions, the ones that explain what the queue is doing now (P4 is
about diagnosing the current misattribution, not the session's history). Count what was evicted in
`droppedDecisions`.
- **Conservation, restated exactly:** `decisions.length + droppedDecisions === nextSeq`. The test
asserts that, so every departure is still accounted for.
- **One helper** (`withDecisions`) owns appending and trimming. The 7 hand-written spreads go away,
which removes the per-departure whole-array copy beyond the window.
- 200 is generous. The recorded corpus replay produces well under that, so the existing corpus tests
see every decision; only long-lived sessions ever evict.

## Tests

- **Fail-first:** a session that queues and consumes 1 000 prompts keeps at most 200 decisions, the
newest are retained, and `decisions.length + droppedDecisions === nextSeq`. This fails on
`origin/main` (1 000 retained).
- The corpus conservation assertion is restated with `droppedDecisions`.
- Existing decision-content assertions are unchanged.

## Review of #1364 (round 1: a, b and c, all FIX-BEFORE-MERGE)
- **The conservation claim was false (a, b, c).** `nextSeq` counts enqueues, while a pending item has no decision yet and a `stale-unattributed` mark is an extra decision.
- `droppedDecisions` is restated as "evicted decision RECORDS": kept + evicted is every decision ever recorded.
- The tests assert `== nextSeq` only for sessions that drained every item exactly once and never went stale, and say so.
- **A stranded episode lost all its evidence (c).** Replaying `divergence-stranded-background-commands` and adding 200 churn cycles kept 0 of the 164 decisions around the two still-stranded rows.
- While any item is pending, the log may grow to `QUEUE_DECISION_CEILING = 2 000`.
- Once the queue is empty it trims to `QUEUE_DECISION_WINDOW`, raised to 500: the corpus already has a 175-decision session, and 200 left 25 slots of margin.
- **Stated residual:** a row stranded through more than 2 000 later decisions loses its oldest evidence.
- **Batch trim and the `popAll` path were unpinned (a, b):** both now have boundary tests.
- `markStaleWhenIdle`'s "has anything departed" guard now counts evicted decisions too.
- Mutations killed: a batch counted as one, `popAll` bypassing the bound, no pending ceiling.

## Review of #1364 (round 2: a and b MERGE-READY, c FIX-BEFORE-MERGE)
- **The ceiling only postponed the loss (c).** After 2 200 unrelated departures, none of the 164 decisions around the two stranded rows remained.
- An episode opens when an item enters an empty queue (`episodeStart`) and closes when the queue is empty again.
- While it is open, the FIRST decisions of the episode move into `episodeHead` (up to `QUEUE_EPISODE_HEAD = 200`) instead of being evicted.
- The head and the log share the 2 000 ceiling. When the episode closes, the head is released and counted as dropped.
- **Two unpinned idle-path mutations (c):** stale marks must be bounded, and bounded by the pending queue. Both are now pinned.
- Mutations killed: no episode head, stale marks bounded by the empty window, stale marks unbounded, the head outside the ceiling.
- **Stated residual:** an episode's middle (after its first 200 decisions, before its most recent ~1 800) can still be evicted.

142 changes: 141 additions & 1 deletion src/renderer/src/session-runtime/claudeQueue/reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
applyQueueOperation,
createClaudeQueueState,
markStaleWhenIdle,
QUEUE_DECISION_CEILING,
QUEUE_EPISODE_HEAD,
QUEUE_DECISION_WINDOW,
} from './reconcile'
import { derivePriority } from './priority'
import { PRIORITY_LATER, PRIORITY_NEXT, type ClaudeQueueState } from './types'
Expand Down Expand Up @@ -250,7 +253,12 @@ describe('remove: the two upstream callers disagree, and we resolve safely', ()
expect(settled.pending).toEqual([])
expect(settled.decisions.filter(d => d.reason === 'consumed-observed')).toHaveLength(13)
expect(settled.decisions.filter(d => d.reason === 'delivered-inferred')).toHaveLength(3)
expect(settled.decisions).toHaveLength(settled.nextSeq)
// Conservation: this recorded session drained every item exactly once and never went stale, so
// its decisions (kept + evicted) equal its enqueues. That is a property of THIS corpus, not of
// the reducer: a pending item has no decision yet and a stale mark is an extra one (review of
// #1364). The corpus is far below the window, so nothing was evicted.
expect(settled.droppedDecisions).toBe(0)
expect(settled.decisions.length + settled.droppedDecisions).toBe(settled.nextSeq)
})

it('never deletes a queued prompt when a notification is also removable', () => {
Expand Down Expand Up @@ -788,3 +796,135 @@ describe('recorded corpus replay', () => {
expect(reasons).toContain('consumed-inferred')
})
})

// #676: the decision log was append-only for a session's lifetime (and every departure copied the
// whole array). A long-lived session now keeps a bounded log of the most recent decisions and
// counts the evicted records.
describe('decision window', () => {
it('keeps the newest QUEUE_DECISION_WINDOW decisions of a long session and counts the rest', () => {
let state = createClaudeQueueState()
const cycles = 1_000
for (let index = 0; index < cycles; index += 1) {
state = applyQueueOperation(state, { operation: 'enqueue', content: `prompt ${index}`, timestamp: `2026-09-26T00:00:${String(index % 60).padStart(2, '0')}.000Z` })
state = applyQueueOperation(state, { operation: 'remove', content: `prompt ${index}`, timestamp: `2026-09-26T00:00:${String(index % 60).padStart(2, '0')}.500Z` })
}
expect(state.pending).toEqual([])
expect(state.decisions).toHaveLength(QUEUE_DECISION_WINDOW)
expect(state.droppedDecisions).toBe(cycles - QUEUE_DECISION_WINDOW)
// Drained, never stale: here (and only in such a session) kept + evicted equals enqueues.
expect(state.decisions.length + state.droppedDecisions).toBe(state.nextSeq)
// The window holds the most recent departures, in order.
expect(state.decisions.at(-1)?.preview).toBe(`prompt ${cycles - 1}`)
expect(state.decisions[0]?.preview).toBe(`prompt ${cycles - QUEUE_DECISION_WINDOW}`)
})

// Review of #1364 (c): a recent-only window evicted every decision around rows still stranded in
// the queue, while they were still stranded. While anything is pending the log may grow to the
// ceiling, so the episode's evidence outlives ordinary churn.
it('keeps a stranded episode\'s decisions while its rows are still pending', () => {
const stranded = replay(loadFixture('divergence-stranded-background-commands').events)
expect(stranded.pending.length).toBeGreaterThan(0)
const episode = stranded.decisions.length
let state = stranded
for (let index = 0; index < QUEUE_DECISION_WINDOW + 100; index += 1) {
state = applyQueueOperation(state, { operation: 'enqueue', content: `churn ${index}`, timestamp: `t${index}` })
state = applyQueueOperation(state, { operation: 'remove', content: `churn ${index}`, timestamp: `t${index}.5` })
}
expect(state.pending.map(item => item.seq)).toEqual(stranded.pending.map(item => item.seq))
expect(state.droppedDecisions).toBe(0)
expect(state.decisions.slice(0, episode)).toEqual(stranded.decisions)
})

// Review of #1364, round 2 (c): the ceiling alone only postponed the loss — after 2,200 unrelated
// departures none of the 164 decisions around the two stranded rows remained. The episode's
// first decisions are now kept aside for as long as anything is pending.
it('keeps the start of a stranded episode even past the ceiling', () => {
const stranded = replay(loadFixture('divergence-stranded-background-commands').events)
const start = stranded.episodeStart!
expect(stranded.droppedDecisions).toBe(0)
const opening = stranded.decisions.slice(start, start + QUEUE_EPISODE_HEAD)
expect(opening.length).toBeGreaterThan(0)
let state = stranded
for (let index = 0; index < QUEUE_DECISION_CEILING + 200; index += 1) {
state = applyQueueOperation(state, { operation: 'enqueue', content: `churn ${index}`, timestamp: `t${index}` })
state = applyQueueOperation(state, { operation: 'remove', content: `churn ${index}`, timestamp: `t${index}.5` })
}
expect(state.pending.map(item => item.seq)).toEqual(stranded.pending.map(item => item.seq))
expect(state.episodeHead.slice(0, opening.length)).toEqual(opening)
expect(state.episodeHead.length + state.decisions.length).toBeLessThanOrEqual(QUEUE_DECISION_CEILING)
})

// Review of #1364, round 2 (c), two unpinned mutations on the idle-marking path: stale marks at
// the ceiling must stay bounded, and must be bounded by the PENDING queue (not the empty-queue
// window, which would cut a live episode down to 500).
it('bounds stale marks at the ceiling by the pending queue', () => {
const stranded = replay(loadFixture('divergence-stranded-background-commands').events)
let state = stranded
for (let index = 0; index < QUEUE_DECISION_WINDOW + 100; index += 1) {
state = applyQueueOperation(state, { operation: 'enqueue', content: `churn ${index}`, timestamp: `t${index}` })
state = applyQueueOperation(state, { operation: 'remove', content: `churn ${index}`, timestamp: `t${index}.5` })
}
const before = state.decisions.length
expect(before).toBeGreaterThan(QUEUE_DECISION_WINDOW)
state = markStaleWhenIdle(state, true)
expect(state.pending.every(item => item.stale)).toBe(true)
expect(state.decisions.length).toBeGreaterThan(QUEUE_DECISION_WINDOW)
expect(state.decisions.slice(0, stranded.decisions.length)).toEqual(stranded.decisions)

let full = applyQueueOperation(createClaudeQueueState(), { operation: 'enqueue', content: 'stranded', timestamp: '0' })
for (let index = 0; index < QUEUE_DECISION_CEILING; index += 1) {
full = applyQueueOperation(full, { operation: 'enqueue', content: `churn ${index}`, timestamp: `t${index}` })
full = applyQueueOperation(full, { operation: 'remove', content: `churn ${index}`, timestamp: `t${index}.5` })
}
full = markStaleWhenIdle(full, true)
expect(full.episodeHead.length + full.decisions.length).toBeLessThanOrEqual(QUEUE_DECISION_CEILING)
})

it('still bounds the log at the ceiling when rows stay pending forever', () => {
let state = applyQueueOperation(createClaudeQueueState(), { operation: 'enqueue', content: 'stranded', timestamp: '0' })
for (let index = 0; index < QUEUE_DECISION_CEILING + 50; index += 1) {
state = applyQueueOperation(state, { operation: 'enqueue', content: `churn ${index}`, timestamp: `t${index}` })
state = applyQueueOperation(state, { operation: 'remove', content: `churn ${index}`, timestamp: `t${index}.5` })
}
expect(state.pending).toHaveLength(1)
// The episode's head and the recent log share the ceiling.
expect(state.episodeHead.length + state.decisions.length).toBe(QUEUE_DECISION_CEILING)
expect(state.episodeHead).toHaveLength(QUEUE_EPISODE_HEAD)
expect(state.episodeHead[0]?.preview).toBe('churn 0')
expect(state.droppedDecisions).toBe(50)
})

// Review of #1364 (a): one settlement can append many decisions at once; the trim must cover the
// whole batch, not one entry.
it('trims a single large batch to the window', () => {
let state = createClaudeQueueState()
const size = QUEUE_DECISION_WINDOW + 100
for (let index = 0; index < size; index += 1) {
state = applyQueueOperation(state, { operation: 'enqueue', content: `batch ${index}`, timestamp: `t${index}` })
}
// Content-free removes accumulate one debt that idle settles in ONE batch append.
for (let index = 0; index < size; index += 1) {
state = applyQueueOperation(state, { operation: 'remove', timestamp: `r${index}` })
}
expect(state.removeDebt?.count).toBe(size)
state = markStaleWhenIdle(state, true)
expect(state.pending).toEqual([])
expect(state.decisions).toHaveLength(QUEUE_DECISION_WINDOW)
expect(state.droppedDecisions).toBe(100)
})

// Review of #1364 (b): every append path goes through the same bound; popAll is pinned here.
it('bounds a popAll departure like any other', () => {
let state = createClaudeQueueState()
for (let index = 0; index < QUEUE_DECISION_WINDOW; index += 1) {
state = applyQueueOperation(state, { operation: 'enqueue', content: `p${index}`, timestamp: `t${index}` })
state = applyQueueOperation(state, { operation: 'remove', content: `p${index}`, timestamp: `t${index}.5` })
}
state = applyQueueOperation(state, { operation: 'enqueue', content: 'last', timestamp: 'x' })
state = applyQueueOperation(state, { operation: 'popAll', content: 'last', timestamp: 'y' })
expect(state.decisions).toHaveLength(QUEUE_DECISION_WINDOW)
expect(state.droppedDecisions).toBe(1)
expect(state.decisions[0]?.preview).toBe('p1')
expect(state.decisions.at(-1)?.reason).toBe('popped-to-composer')
})
})
Loading
Loading