Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/renderer/src/workspace/claudeQueueReconstruction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { applyClaudeQueueDequeue } from '@renderer/workspace/claudeQueueReconstruction'
import type { QueuedMessage } from '@renderer/workspace/workspaceState'

// Regression coverage for the 2026-07-07 soak bundles
// debug-bundles/manual/2026-07-07T13-17-20-472-5b19529f ("completed agents
// renders as queued") and .../2026-07-07T13-17-48-452-5b19529f ("a queued
// prompt did not get shown in the feed"). Both fall out of reconstructing
// Claude's PRIORITY queue with FIFO drop-head semantics; see
// claudeQueueReconstruction.ts for the full write-up.

function q(content: string, timestamp = String(Math.random())): QueuedMessage {
return { content, timestamp }
}

// Mirrors the shape Claude parks in queuedMessages for a completed subagent
// (priority 'later'). parseTaskNotification only requires the opening tag.
function taskNotification(summary: string): string {
return [
'<task-notification>',
'<task-id>abc</task-id>',
'<status>completed</status>',
`<summary>${summary}</summary>`,
'</task-notification>',
].join('\n')
}

describe('applyClaudeQueueDequeue', () => {
it('returns the same array reference for an empty queue', () => {
const queue: QueuedMessage[] = []
expect(applyClaudeQueueDequeue(queue)).toBe(queue)
})

it('drops the head for a homogeneous plain-prompt queue (unchanged FIFO)', () => {
const first = q('first prompt', '1')
const second = q('second prompt', '2')
const third = q('third prompt', '3')
expect(applyClaudeQueueDequeue([first, second, third])).toEqual([second, third])
})

it('drops the head for a homogeneous task-notification queue', () => {
const a = q(taskNotification('Agent A finished'))
const b = q(taskNotification('Agent B finished'))
expect(applyClaudeQueueDequeue([a, b])).toEqual([b])
})

it("removes the 'next' user prompt ahead of an earlier 'later' notification", () => {
// This is the exact drift the bundles captured: a completed-agent
// task-notification (priority 'later') is enqueued FIRST, then the user
// types a prompt (priority 'next'). Claude delivers the prompt first, so
// the notification must SURVIVE the dequeue — the old slice(1) wrongly
// dropped the notification and left the already-delivered prompt behind.
const notif = q(taskNotification('Agent finished'))
const prompt = q('user typed this while busy')
expect(applyClaudeQueueDequeue([notif, prompt])).toEqual([notif])
})

it("keeps completed notifications queued while draining a later user prompt", () => {
// Bundle B2 shape: two completed task-notifications sit in the queue and
// the user's prompt is delivered. The prompt leaves; both notifications
// stay until Claude reaches a 'later' drain point.
const tn1 = q(taskNotification('Audit structural conventions + isolation finished'))
const tn2 = q(taskNotification('Audit test + fixture conventions finished'))
const prompt = q('please also check the queue strip')
expect(applyClaudeQueueDequeue([tn1, tn2, prompt])).toEqual([tn1, tn2])
})

it("drops the user prompt at the head when it precedes a notification", () => {
const prompt = q('earliest prompt')
const notif = q(taskNotification('Agent finished'))
expect(applyClaudeQueueDequeue([prompt, notif])).toEqual([notif])
})
})
83 changes: 83 additions & 0 deletions src/renderer/src/workspace/claudeQueueReconstruction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { parseTaskNotification } from '@renderer/features/feed/lib/taskNotification'
import type { QueuedMessage } from '@renderer/workspace/workspaceState'

// Reconstruction of Claude's PROVIDER-OWNED message queue from the
// `queue-operation` transcript records our renderer replays live in
// useIpcSubscriptions.
//
// WHY this file exists (2026-07-07 soak bundles
// debug-bundles/manual/2026-07-07T13-17-20-472-5b19529f "completed agents
// renders as queued" and .../2026-07-07T13-17-48-452-5b19529f "a queued
// prompt did not get shown in the feed"):
//
// Claude's real queue (vendor/claude-code-src/full/utils/messageQueueManager.ts)
// is a PRIORITY queue, not FIFO:
// - `enqueue` (user prompts / bash / orphaned-permission) → priority 'next' (1)
// - `enqueuePendingNotification` (<task-notification>) → priority 'later' (2)
// - `dequeue` removes the LOWEST priority NUMBER (now:0 > next:1 > later:2),
// FIFO WITHIN a priority level.
// So a user prompt enqueued AFTER two completed-agent task-notifications is
// still delivered FIRST, because 'next' beats 'later'.
//
// Our old reconstruction dropped `queue.slice(1)` (the head, index 0) on every
// dequeue/remove op. That is only correct when the queue is homogeneous. The
// moment the queue holds a MIX of a 'later' task-notification and a 'next'
// user prompt, Claude dequeues the prompt (a non-head item) while our slice(1)
// dropped a task-notification instead. The reconstruction then permanently
// diverges from Claude's real queue — and because Claude's queue is
// provider-owned (shouldClearIdleQueuedMessages is capability-gated to SKIP
// Claude on purpose, see queueInvariants.ts), nothing else ever repairs it.
// The two visible failures both fall out of that single drift:
// 1. completed-agent task-notifications never leave the QueueStrip
// ("completed agents render as queued"), because the dequeue that should
// have removed them removed the head instead; and
// 2. the user's own queued prompt vanishes from the strip
// ("queued prompt did not get shown"), because a head-drop removed it
// when Claude actually dequeued a different item.
//
// The transcript record cannot carry the priority — Claude's `logOperation`
// only writes `{ operation, content? }`, and dequeue/remove log NO content at
// all (no identity). So we INFER priority from the enqueued content we DO
// have: a `<task-notification>` payload is 'later', everything else is 'next'.
// We cannot detect 'now' (interrupt-class) prompts, but user prompts default
// to 'next', which is the correct relative order against 'later'
// notifications — the only mix these bundles exercise.
//
// This helper reduces to the old drop-head behaviour for a single-priority
// queue (every item same priority → lowest index wins → slice(1)), so the
// common "N queued user prompts, no notifications" path is unchanged.

const PRIORITY_LATER = 2
const PRIORITY_NEXT = 1

function inferQueuePriority(content: string): number {
// A parsed task-notification is Claude's 'later' bucket
// (enqueuePendingNotification). Everything else — plain prompts, bash,
// slash commands — rides the default 'next' bucket like `enqueue` uses.
return parseTaskNotification(content) !== null ? PRIORITY_LATER : PRIORITY_NEXT
}

/**
* Mirror one Claude `dequeue`/`remove` operation against our reconstructed
* queue: remove the first (lowest-index) item whose inferred priority is the
* minimum present, exactly as messageQueueManager.dequeue selects its victim.
*
* `remove` in Claude targets specific commands by object identity, which we
* do not have. Priority order is our best available proxy for which item left
* the real queue, and it is strictly better than the previous blind head-drop
* (identical for a homogeneous queue, correct for the mixed case that caused
* the 2026-07-07 soak bugs).
*/
export function applyClaudeQueueDequeue(queue: QueuedMessage[]): QueuedMessage[] {
if (queue.length === 0) return queue
let bestIdx = 0
let bestPriority = Number.POSITIVE_INFINITY
for (let i = 0; i < queue.length; i++) {
const priority = inferQueuePriority(queue[i]!.content)
if (priority < bestPriority) {
bestPriority = priority
bestIdx = i
}
}
return [...queue.slice(0, bestIdx), ...queue.slice(bestIdx + 1)]
}
16 changes: 15 additions & 1 deletion src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
import {
codexPromptsMatchForOwnership,
} from '@renderer/workspace/hook/actions/streaming'
import { applyClaudeQueueDequeue } from '@renderer/workspace/claudeQueueReconstruction'
import { shouldClearIdleQueuedMessages } from '@renderer/workspace/queueInvariants'
import type { StreamPhase } from '@renderer/workspace/workspaceState'
import {
Expand Down Expand Up @@ -1415,7 +1416,20 @@ export function useIpcSubscriptions(
]
}
} else if (op.operation === 'dequeue' || op.operation === 'remove') {
queuedMessages = queuedMessages.slice(1)
// Mirror Claude's PRIORITY dequeue instead of dropping the head.
// Claude delivers a queued user prompt ('next') ahead of any
// completed-agent <task-notification> ('later') regardless of
// insertion order, so a blind slice(1) removed the wrong logical
// item whenever the queue mixed priorities — permanently
// desyncing this reconstruction from Claude's provider-owned
// queue (which the idle-clear invariant deliberately never
// touches). That drift is the shared root cause of the
// 2026-07-07 soak bundles: stale task-notifications stuck in the
// QueueStrip ("completed agents render as queued") AND the user's
// own queued prompt vanishing ("queued prompt did not get
// shown"). See claudeQueueReconstruction.ts for the priority
// inference; it collapses to slice(1) for a homogeneous queue.
queuedMessages = applyClaudeQueueDequeue(queuedMessages)
}
// Force the streaming flag on whenever the queue has
// items so the streaming card doesn't disappear
Expand Down
14 changes: 12 additions & 2 deletions src/renderer/src/workspace/tile-tree/TileLeaf/QueueStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,18 @@ export function QueueStrip({
{kind === 'error' ? '✗' : '✓'}
</span>
<span className="flex-1 min-w-0 truncate">
{notification.summary ?? notification.taskId ?? 'background task'}
{notification.status ? ` ${notification.status}` : ''} — delivering to agent…
{/*
WHY no raw `status` append (2026-07-07 bundle
debug-bundles/manual/2026-07-07T13-17-20-472-5b19529f
"completed agents renders as queued"): the summary already
reads `Agent "X" finished`, and appending the raw <status>
produced the nonsense "finished completed — delivering to
agent…" the user screenshotted. The ✓/✗ glyph (driven by
taskNotificationStatusKind) already carries the outcome, so
the summary alone is the human-readable line.
*/}
{notification.summary ?? notification.taskId ?? 'background task'} —
delivering to agent…
</span>
</li>
)
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.