Skip to content

bug(claude-queue): a redelivered dequeue/remove double-counts queue debt #1402

Open

Description

@Juliusolsson05

Problem

The Claude queue reconciler (src/renderer/src/session-runtime/claudeQueue/reconcile.ts) opens one unit of dequeue debt per dequeue record, and one unit of removeDebt per content-free remove. Only enqueue has a redelivery guard (see the comment above it in reconcile.ts). If the live burst channel redelivers a departure record, the debt is counted twice. Later settlement can then spend the phantom unit on an item Claude still holds, and retire a genuinely queued prompt as delivered-inferred.

Found by #1396 review a, round 2. It reproduced with a real-subscription probe: N1 is delivered, N2 is still queued, and the same dequeue is delivered again. debt = 1 then covers N2, and N2 is retired at the next settlement.

Why no key-based guard

Queue-operation records carry no uuid. In a 400-record sample from a local Claude corpus of 606 transcripts, every record had only type, operation, timestamp, sessionId, and sometimes content or reason.

The same corpus has 13,757 queue operations (6,927 enqueue, 4,435 remove, 2,391 dequeue, 4 popAll). It contains 99 genuine repeats of the same (operation, timestamp, content) key within a single file (99 dequeue repeats and 25 remove repeats). So an (operation, timestamp) guard would drop real departures.

Where it bites

Directions to evaluate

  • Dedupe at the transport. For example, the JSONL tailer could carry a byte offset or line number for each queue-operation record, so a redelivered record can be recognised by position rather than content.
  • Measure redelivery first. Is it real in production, and on which path (tailer recovery, bootstrap replay overlapping live bursts)? Use feed-debug bundles before choosing.

Refs #677, #1396.

Activity

  1. Juliusolsson05 commented on Sep 27, 2026

    @Juliusolsson05
    OwnerAuthor

    #1396 (the bootstrap half of #677) is parked behind this issue. Its committed it.fails test, 'does not retire a still-queued prompt at bootstrap when a dequeue is redelivered (#1402)', is the acceptance check: once departures are countable once, it must flip to it and pass.

  2. Juliusolsson05 commented on Sep 27, 2026

    @Juliusolsson05
    OwnerAuthor

    Evidence stage (W2): the "redelivery" framing is wrong; a partial bootstrap window is the real mechanism

    No current transport path redelivers a queue-operation record.

    • The enqueue guard's premise is historical. Its comment ("the live burst channel can redeliver the same record") was carried over in 6b1fd7cc from code that ran while main emitted every JSONL entry twice: a singular session:jsonl-entry IPC plus a coalesced burst. That dual emit was removed (see the "bootstrap-replay cascade" note that 6b1fd7cc^ carried in useIpcSubscriptions.ts).
    • Main's tailer (packages/claude-code-headless/src/transcript/JsonlTailer.ts) is offset-based and serializes its reads. On a diverged cursor it refuses to continue (onDiscontinuity / error) instead of re-reading. Relocation reuses the byte cursor only after verifying a 256-byte anchor.
    • Soft reload reloads history through loadInitialHistoryForSession, which maps entries for the feed and never feeds them into the queue reconciler.
    • Session exit deletes claudeQueueBySession, so a restarted process starts a fresh reconciler.

    What does reach the reconciler wrongly: a partial bootstrap window. A resumed Claude session bootstraps only the last RESUME_BOOTSTRAP_TAIL_LINES lines of the file's last 512 KiB (JsonlTailer.bootstrapTail, ClaudeCodeHeadless.ts:1332), so on resume the replay is partial by design:

    1. A dequeue or content-free remove in the window whose enqueue fell before it opens debt whenever any in-window item is pending (applyDequeue), even though its departed item was never pending here.
    2. That phantom unit is indistinguishable from a real one. At settlement it can retire an in-window prompt that is still queued. This is the same arithmetic fix(claude-queue): settle proven queue debt at bootstrap-complete (#677) #1396 review a showed with a duplicated dequeue, but it needs no duplicate.

    A direction worth checking before any fix. Claude's command queue lives in the CLI process's memory (messageQueueManager). A freshly RESUMED process therefore starts with an empty queue, and nothing replayed from the file can still be queued in it. If that holds, the resume bootstrap should treat replayed pending items as already gone, rather than inferring departures from a partial window. Needs confirming against vendor/claude-code-src (queue persistence across --resume) and one recorded resume.

    Consequence for #1396: it is still correctly held. The blocker is the partial window, not redelivery.

  3. Juliusolsson05 commented on Sep 27, 2026

    @Juliusolsson05
    OwnerAuthor

    Addendum (W2): confirmed in vendor/claude-code-src/full. Claude's command queue is process-local: utils/messageQueueManager.ts:53 is const commandQueue: QueuedCommand[] = [], and utils/sessionStorage.ts:1218 only APPENDS queue-operation records. Nothing reads them back on --resume. So an item whose enqueue timestamp is earlier than the current Claude process's start cannot be in that process's queue.

    Proposed fix (needs manager sign-off because it supersedes #1396): at session start and at bootstrap-complete, retire every pending item enqueued before the current process started, with a new decision reason such as process-restarted. That is a provable departure, not an inference from debt. It needs no departure counting, so neither a partial bootstrap window nor a duplicated record can make it retire a prompt the live process still holds. It covers the bootstrap half of #677, and the live process-idle half stays out of scope.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions