Skip to content

perf(claude-queue): keep a bounded window of queue decisions (#676) - #1364

Merged
Juliusolsson05 merged 7 commits into
mainfrom
perf/claude-queue-decision-window
Sep 27, 2026
Merged

Juliusolsson05 merged 7 commits into
mainfrom
perf/claude-queue-decision-window

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Closes #676.

Problem

ClaudeQueueState.decisions was append-only for the life of a Claude session. Every time a prompt left the queue (delivered, consumed, popped, stale), a QueueDecision was appended with a content preview and evidence. Each append copied the whole array ([...state.decisions, …], at 7 sites), so a long session paid growing memory and O(n) per departure.

Nothing reads the old decisions:

  • the per-session state lives in useIpcSubscriptions' module-level map, not in SessionRuntime, so debug bundles don't carry it;
  • QueueStrip renders pending and stale, not decisions.

Change

  • A bounded log instead of an append-only one.
    • While the queue is empty, a session keeps its QUEUE_DECISION_WINDOW = 500 most recent decisions. The corpus already has a 175-decision session, so 200 was too thin.
    • While any queue item is pending, the log may grow to QUEUE_DECISION_CEILING = 2 000. Evidence about a row that is still stranded then survives ordinary churn (review c).
    • The ceiling keeps a forever-stranded row from bringing back the unbounded growth this issue fixes.
    • The opening of a pending episode survives the ceiling (review round 2, c). An episode runs from an item entering an empty queue to the queue emptying again. Its first decisions move into episodeHead (up to 200) instead of being evicted. The head shares the ceiling, and is released and counted when the episode closes.
  • droppedDecisions counts evicted decision records, so kept + evicted is every decision ever recorded.
    • That is not the number of items (nextSeq): a pending item has no decision yet, and a stale-unattributed mark is an extra decision (reviews a, b, c).
    • Only a session that drained every item exactly once and never went stale has the two equal, and the tests assert the equality only there.
  • One helper, withDecisions(state, added, pendingAfter), owns appending and trimming, and replaces the 7 hand-written spreads. Each append copies at most the bound.
  • markStaleWhenIdle's "has anything departed" guard counts evicted records too.
  • Plan: docs/plans/2026-09-26-claude-queue-decision-window.md (with a review section).

Tests

src/renderer/src/session-runtime/claudeQueue/reconcile.test.ts:

  • Fail-first: 1 000 queue/consume cycles keep the newest 500 and count 500 dropped. On origin/main the session keeps all 1 000.
  • A stranded episode: the recorded divergence-stranded-background-commands fixture plus 600 churn cycles still holds every original decision while its rows are pending.
  • A forever-stranded row: the log is still bounded at the ceiling.
  • Boundaries: a single 600-item batch trims to the window, and a popAll departure is bounded like any other.
  • Episode opening: after 2 200 unrelated departures, the fixture's episode opening is still in episodeHead. Stale marks at the ceiling are bounded, and by the pending queue.
  • Mutations killed:
    • a batch counted as one (a);
    • popAll bypassing the bound (b);
    • no pending ceiling (c);
    • no episode head, the head outside the ceiling, and stale marks unbounded or bounded by the empty window (round 2, c).
  • The corpus replay's conservation assertion is scoped to what that corpus is (drained, never stale).
  • Stated residual: an episode's middle (after its first 200 decisions, before its most recent ~1 800) can still be evicted.

npx tsc -b prints 0 lines, and the branch is merged with current main.

🤖 Generated with Claude Code

Juliusolsson05 and others added 3 commits September 26, 2026 20:38
Refs #676

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t the rest

The decision log was append-only for a session's lifetime and every
departure copied the whole array, so a long-lived session paid memory
and O(n) per departure for history nothing reads (no UI, no debug
bundle). One helper now appends, keeps the newest 200 decisions and
counts the evicted ones; conservation is restated exactly as
decisions.length + droppedDecisions === nextSeq. Fail-first: 1,000
queue/remove cycles kept 1,000 decisions on origin/main.

Refs #676

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05 Juliusolsson05 added provider:claude Affects Claude Code integration type:perf Performance or resource use class:C6-unbounded Unbounded growth sev:P3 Minor labels Sep 27, 2026
…l invariant (review)

Round 1 of #1364 (a, b, c):
- `decisions.length + droppedDecisions === nextSeq` was false for valid
  states (pending items, stale marks). droppedDecisions now counts
  evicted decision RECORDS; the equality is asserted only for drained,
  never-stale sessions, and says why.
- A recent-only window evicted every decision around rows still stranded
  in the queue. While any item is pending the log may grow to
  QUEUE_DECISION_CEILING (2000); with the queue empty it trims to the
  window, raised to 500 (the corpus already has a 175-decision session).
- Boundary tests for a single large batch and the popAll path.
- markStaleWhenIdle's "anything departed" guard counts evicted records.

Mutations killed: batch counted as one, popAll bypassing the bound, no
pending ceiling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05

Juliusolsson05 commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner Author

Review disposition (round 1 at 3079ef8c, round 2 at 5313a7b8; all codex)

# Finding Verdict Disposition
a1 / b1 / c2 decisions.length + droppedDecisions === nextSeq is false for valid states (pending items, stale marks) valid Fixed: droppedDecisions is restated as evicted decision records. The equality is asserted only for drained, never-stale sessions, and the test says why.
c1 A recent-only window evicted every decision around rows still stranded in the queue valid Fixed: while any item is pending, the log may grow to a 2 000 ceiling; with the queue empty, it trims to the window, raised to 500. A fixture-replay test and a forever-stranded bound test pin it; the no-ceiling mutation is red. Residual: evidence older than 2 000 later decisions.
a2 A batch trim is untested (+1 mutant survived) valid Fixed: a single 600-item batch test; the mutant is red.
b2 The popAll path could bypass the bound valid Fixed: a popAll boundary test; the bypass mutant is red.

Also fixed: markStaleWhenIdle's "anything departed" guard now counts evicted records. Verification pass: see Round 2 below.

Round 2 (verification at 5313a7b8: a and b MERGE-READY, c FIX-BEFORE-MERGE):

  • c major — the ceiling only postponed the loss (after 2 200 departures, 0 of the 164 episode decisions were kept). Valid. Fixed in 94d4d5ca: while items are pending, the episode's first 200 decisions are kept in episodeHead within the ceiling.
  • c's two surviving idle-path mutations: pinned.

Round 2 is the cap: any further finding is the manager's call.

Juliusolsson05 and others added 2 commits September 27, 2026 00:16
…ling (review round 2)

Round 2 of #1364 (c): the 2000 ceiling only postponed the loss; after
2,200 unrelated departures none of the 164 decisions around two stranded
rows remained. An episode opens when an item enters an empty queue and
closes when it is empty again; while open, its first decisions move into
episodeHead (up to 200) instead of being evicted. Head and log share the
ceiling; the head is released and counted when the episode closes.

Also pins the idle-marking path (stale marks bounded, and by the pending
queue). Mutations killed: no episode head, stale marks bounded by the
empty window, stale marks unbounded, head outside the ceiling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 merged commit c186126 into main Sep 27, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the perf/claude-queue-decision-window branch September 27, 2026 11:49
Juliusolsson05 added a commit that referenced this pull request Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

class:C6-unbounded Unbounded growth provider:claude Affects Claude Code integration sev:P3 Minor type:perf Performance or resource use

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(claude-queue): per-session decision log grows without eviction

1 participant