perf(server): sending, editing and steering queued messages no longer read a long thread's whole history#29521
Problem
Sending a message, editing a queued message, and promoting a queued message to steer took 7-15 s on a long-lived thread. Each command reads the thread's projection before it plans, and those reads walked the thread's whole history: every turn item to filter by type, every node to drop settled leaves, every node again to find owned provider threads, plus a separate pass that grouped every user and assistant item to order messages.
Reproduced on main (c8a601c0df) with a synthetic thread at the reporter's scale (1,867 runs, 16,899 nodes, 23,670 turn items, 76 MB of item payload): one send read about 177 MB from SQLite to return about 0.6 MB, and the query plans match the issue's (USE TEMP B-TREE FOR GROUP BY, turn_items_thread_ordinal_idx (thread_id=?) for a type filter, nodes_thread_run_idx (thread_id=?) for the settled-node and provider-thread reads).
Change
- Message ordering.
readCanonicalProjectionskips the separate ordinal query when the loaded turn items already include every item type that can carry a loaded message's id (user →user_message, assistant →assistant_message, system → both), with no window and no run/node/status narrowing of the items. The order is then fully determined by the loaded items. Otherwise the query still runs, now limited to the loaded message ids and served by a message-id expression index. - Typed turn items. A type filter selects matching rowids through a new
(thread_id, type, status)index and sorts outside the subquery; SQLite only chose that index when the match itself had noORDER BY. Status is in the index so Stop's active-item read (types pluspending/running/waiting) no longer fetches every settled tool row to reject it. - Settled leaf nodes.
omitSettledLeafNodesuses an index on the settled-leaf expression plus aUNION ALLbranch for settled leaves of this thread's active runs, the same rows the oldNOT (… AND NOT EXISTS …)predicate kept. - Provider-thread ownership. One shared subquery, used by canonical reads and
getRuntimeRecoveryProjection, starts from this thread's node ids in a new covering(thread_id, node_id)index and probes the owner index per node. It no longer reads node rows, or every owned provider thread in the environment. - Promote to steer and Stop.
queued-message.promote-to-steerandrun.interrupt(which promote reaches through its stop path) now read nodes withomitSettledLeafNodes, asmessage.dispatchalready did for the same steer path. Their node uses are root nodes, live nodes and nodes of the active run, which the filter keeps. - Migration 078 adds the four indexes.
Scope and approval
Fixes pingdotgg/t3code#29468
Triaged via the automated issue flow; GitCafe issue #29468.
Verification
- New test
queued-message commands read a long thread's history without walking all of it(Orchestrator.control-reads.test.ts) drivesmessage.dispatch,queued-run.editandqueued-message.promote-to-steerthrough the Orchestrator and checksEXPLAIN QUERY PLANof every read they run: no message-ordering GROUP BY pass, no node or turn-item row search bounded only by the thread, no environment-wide active-run lookup, no status-filtered read that fetches every typed row to check its status. It also checks message order (with the edit applied) and provider-thread membership. It fails on main for all three commands and passes here. bounds runtime provider-thread reads by ownership…(ProjectionRecovery.test.ts) now also asserts the per-node owner probe; it fails with the previous owner-first shape.- Focused suites:
ProjectionStore,ProjectionRecovery,ProjectionControlReads,Orchestrator.control-reads,ThreadStop,SteeringCompletionandpersistence/: 33 files, 189 tests pass.vp run check:changedpasses. - Reviewers compared old and new SQL on randomized synthetic data (settled-leaf node reads, typed turn items under combined status/run/node filters, provider-thread ownership, message order through the service): no differences in rows or order.
Runtime transcript, synthetic thread above, getThreadRecords medians of 3 warm runs:
| Command | main | this branch |
|---|---|---|
message.dispatch | 277 ms | 108 ms |
queued-run.edit | 164 ms | 74 ms |
queued-message.promote-to-steer | 464 ms | 96 ms |
Bytes read per statement (sqlite3 .stats, fresh immutable read-only connection), which is what dominates on a large database whose pages are not cached:
| Statement | main | this branch |
|---|---|---|
| message ordering (GROUP BY over every user/assistant item) | 69.3 MB | not run on these commands |
| turn items filtered by type (1,085 rows) | 62.9 MB | 4.7 MB |
| nodes with settled leaves omitted (1,867 rows) | 22.7 MB | 10.2 MB |
| provider threads (0 rows) | 21.9 MB | 1.0 MB |
| Stop's active turn items (5 types, active statuses, 0 rows) | 62.9 MB | 0.1 MB |
Unrelated history stays out of the reads: for a 10-node thread next to 50,000 owned provider threads in another thread, the provider-thread read takes 26 page misses (117 KB); next to 50,000 queued runs in another thread, the settled-node read takes 29 page misses (130 KB).
Write cost, measured by a reviewer: an assistant-message upsert growing to 117 KB in a 24,000-item thread went from 1,097 µs to 1,545 µs with both new turn-item indexes; with Codex flushing assistant text every 50 ms that is a few ms of CPU per second of streaming. Building the indexes on a 5.9 GB database took about 2.6 s once.
Not checked: the reporter's macOS desktop app on their own database, and end-to-end client latency.
Implemented with Claude Opus 5.5 (Claude Code) through T3 Code; reviewed by GPT-6.1-Sol (Codex) and Claude Opus 5.5.
🤖 Generated with Claude Code
Checks
Added 7 commits
- perf(server): bound settled-node active-run lookups to the thread016cff8
- Merge remote-tracking branch 'origin/main' into fix/issue-2946802b32d9
- perf(server): queued-message commands stop walking a long thread's history069bcb9
- perf(server): Stop's active turn-item read checks status in the index168017c
- Merge remote-tracking branch 'origin/main' into fix/issue-29468388667c
- perf(server): bound provider-thread ownership lookups to the thread's nodes6ab6b12
- test(server): reproduce #29468 queued-message commands walking thread historyced36e1
Approvability
Verdict: Approved at 02b32d9
Macroscope's review found this PR approvable — Thread-history reads become more selective, with supporting indexes and settled-node filtering for steering and Stop. The migration adds indexes without changing stored customer content or product defaults.
You can add or adjust custom eligibility rules. Learn more.
No actionable comments were generated in the recent review.
External image Recent review info
-
External image Run configuration
- Source mode: Auto
- Repository: pingdotgg/t3code
- Branch: fix/issue-29468
- File: .coderabbit.config.ts
- Commit: 02b32d9
- Layers: Repository TS config
- Review profile: CHILL
- Plan: Team
- Run ID:
cc2aff86-dd16-495c-a6be-d98fd368ef8f
External image Commits
Reviewing files that changed from the base of the PR and between 9bcf75335f9b2a8af877e1317c809b72a070b280 and 02b32d9ceef3a1806e0117f99330d0071c8b8534.
External image Files selected for processing: 7
apps/server/src/orchestration-v2/Orchestrator.control-reads.test.tsapps/server/src/orchestration-v2/Orchestrator.tsapps/server/src/orchestration-v2/ProjectionRecovery.test.tsapps/server/src/orchestration-v2/ProjectionStore.tsapps/server/src/persistence/Migrations.tsapps/server/src/persistence/Migrations/078_ThreadHistoryReadIndexes.tsapps/server/src/persistence/reconcileV2PreviewMigration.test.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.
Walkthrough
Migration 78 adds thread-history indexes. ProjectionStore changes turn-item, node, provider-thread, and message-ordinal reads. Two orchestration queries omit settled leaf nodes. Tests check query plans and long-history behavior for message dispatch, queued-run editing, and promotion to steer.
Changes: Long-thread reads
Layer / File(s) Summary Add thread-history indexes
apps/server/src/persistence/Migrations/078_ThreadHistoryReadIndexes.ts,apps/server/src/persistence/Migrations.ts,apps/server/src/persistence/reconcileV2PreviewMigration.test.tsMigration 78 adds indexes for turn-item filtering, node lookup, message ordinals, and settled leaf nodes. The migration loader and migration tests include the new migration. Optimize projection reads
apps/server/src/orchestration-v2/ProjectionStore.ts,apps/server/src/orchestration-v2/ProjectionRecovery.test.tsProjectionStore shares turn-item filters and provider-thread ownership queries, changes node selection, and limits message-ordinal lookup to loaded message IDs. The recovery query-plan assertion checks owner-index use. Filter orchestration reads
apps/server/src/orchestration-v2/Orchestrator.ts,apps/server/src/orchestration-v2/Orchestrator.control-reads.test.tsQueued-message promotion and run-interrupt queries omit settled leaf nodes. The long-history test checks query plans and resulting projections for dispatch, queued-run editing, and promotion to steer. Estimated code review effort: 3 (Moderate) | ~25 minutes
Change: Bug fix · Severity of issue fixed: Medium
Suggested reviewers:
yash-singh1,t3dotgg
Merge Risk: External image Minimal · up to 02b32
This change adds indexes and narrows history reads to speed up sending, editing and promoting queued messages in long threads. No concrete correctness or availability risk was found, so it looks safe to merge. The author did not check end-to-end latency in the desktop app.
Pre-merge checks: Passed 4
Check name Status Explanation Title check Passed The title clearly identifies the main performance improvement: queued message operations no longer read an entire long thread history. It is specific and uses the repository's conventional commit form… Description check Passed The description follows the required Problem, Change, Scope and approval, and Verification sections. It explains the issue, implementation, linked issue, focused tests, performance measurements, limit… Linked Issues check Passed Direct issue [ #29468] requires lower-cost reads for send, queued edit, and promote-to-steer while preserving projection behavior.ProjectionStoreskips message-order scans when loaded turn items cov…Out of Scope Changes check Passed The changed production files implement the performance and correctness objectives in [ #29468]. Migration registration and migration expectation updates support deployment. The query-plan and projectio…Full details: Title check
Explanation
The title clearly identifies the main performance improvement: queued message operations no longer read an entire long thread history. It is specific and uses the repository's conventional commit format.
Full details: Description check
Explanation
The description follows the required Problem, Change, Scope and approval, and Verification sections. It explains the issue, implementation, linked issue, focused tests, performance measurements, limitations, and agent usage. The scope section does not include an explicit maintainer approval comment, but it provides a linked triaged issue and is otherwise sufficiently complete.
Full details: Linked Issues check
Explanation
Direct issue [
#29468] requires lower-cost reads for send, queued edit, and promote-to-steer while preserving projection behavior.ProjectionStoreskips message-order scans when loaded turn items cover the message roles, bounds fallback ordering to loaded message IDs, uses typed-item and settled-leaf index paths, and bounds provider-thread ownership from the current thread's node IDs.Orchestratorapplies settled-leaf filtering to promote-to-steer and Stop. Migration 078 adds and registers the four supporting indexes. The addedOrchestrator.control-reads.test.tstest exercises all three commands and checks query plans, message order, and provider-thread membership. The reported focused tests and changed-file checks pass. The optional command cache and unrelated closed issues are not required.
Full details: Out of Scope Changes check
Explanation
The changed production files implement the performance and correctness objectives in [
#29468]. Migration registration and migration expectation updates support deployment. The query-plan and projection assertions provide automated coverage for the changed read paths. No unrelated production behavior or unrelated feature appears in the reviewed changes.
Finishing Touches
Generate docstrings
- Commit to this branch
- Create a new PR
Generate unit tests (beta)
- Commit to this branch
- Create a new PR
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thread transfer impact
✅ Thread transfer remains within every enforced ceiling.
ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful
mainmeasurement shown below.
| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |
|---|---|---|---|---|---|---|
| Codex | Total thread wire | 5.0 KiB | 5.0 KiB | −35 B (−0.7%) | 6.8 KiB | ✅ |
| Codex | Thread snapshot wire | 3.8 KiB | 3.8 KiB | 0 B (0.0%) | 4.9 KiB | ✅ |
| Codex | Live turn WebSocket wire | 1.3 KiB | 1.2 KiB | −35 B (−2.7%) | 2.0 KiB | ✅ |
| Codex | Live turn WebSocket decoded | 21.6 KiB | 21.5 KiB | −41 B (−0.2%) | 24.4 KiB | ✅ |
| Codex | Live turn messages | 2 | 1 | −1 (−50.0%) | 8 | ✅ |
| Claude | Total thread wire | 5.1 KiB | 5.1 KiB | 0 B (0.0%) | 6.8 KiB | ✅ |
| Claude | Thread snapshot wire | 3.8 KiB | 3.8 KiB | 0 B (0.0%) | 4.9 KiB | ✅ |
| Claude | Live turn WebSocket wire | 1.3 KiB | 1.3 KiB | 0 B (0.0%) | 2.0 KiB | ✅ |
| Claude | Live turn WebSocket decoded | 21.9 KiB | 21.9 KiB | 0 B (0.0%) | 24.4 KiB | ✅ |
| Claude | Live turn messages | 2 | 2 | 0 (0.0%) | 8 | ✅ |
Baseline: 0847a44 · PR result: 02b32d9 · Source CI: success
Scenario and decoded snapshot size
10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
- Codex decoded thread snapshot: 108.5 KiB
- Claude decoded thread snapshot: 108.8 KiB
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.
Reporter of #29468 here. I compared the command projection-read sets on fresh copy-on-write clones of the affected 4.2 GB database, using PR head 02b32d9 with migration 078 applied. Send, edit and promote improve substantially. Cancel improves less, and acknowledge and one status-only background read stay expensive.
Projection-read timings (first read on fresh clones, live page layout; v0.1.6 vs this branch):
| Command | v0.1.6 | this PR |
|---|---|---|
message.dispatch | 11.7–12.4 s | 2.3–2.5 s |
queued-run.edit | 5.2–7.5 s | 1.2 s |
queued-message.promote-to-steer | 12.8–14.9 s | 2.0–2.1 s |
queued-run.cancel | 6.9–7.1 s | 3.7–4.2 s |
delegated_task.completion-delivery.acknowledge | 3.1–3.3 s | 3.1–3.3 s |
Thread shape: about 25k turn items (most of them command_execution, dynamic_tool and reasoning), 17k nodes, 2k runs and 5.9k messages. On the real data, plans use all four new indexes. Old and new SQL return identical rows and order for items, nodes and provider threads. Message order is also identical for all 2,024 user messages, 852 of which have no user_message item. For dispatch, page misses fell from about 90k to 21.6k, about 76% fewer.
Still open, with suggested fixes (line numbers at 02b32d9):
queued-run.canceland completion-delivery acknowledge still read every node (about 2.2 s for cancel's node read).dispatchQueuedRunCancel(Orchestrator.ts:9675) and the acknowledge/dispose path (Orchestrator.ts:2446) callgetThreadRecordswithnodesand noomitSettledLeafNodes. Cancel also loads nouser_messageitems, so the ordering query still runs, now bounded by the index (about 0.2–0.3 s). Cancel is called in loops fromthread.settle(:3316) and the stop path (:10798).omitSettledLeafNodes: truelooks safe here, because cancellation reads root nodes. Cancel could also omit settled subagents. Acknowledge/dispose must keep the target task and any tasks needed to rebuild the remaining delivery message.- The turn-item status filter can't use the active-item partial index.
turnItemMatchemitsstatus IN (SELECT value FROM json_each(?))(ProjectionStore.ts:3036), which never matches migration 073'sWHERE status IN ('pending','running','waiting'). Status-only reads therefore walk the whole thread. Idle compaction (IdleCompactionService.ts:53) did this 50 times in about 80 minutes, taking about 4.2 s each in the first-read benchmark. Emitting the literal list when the filter is the active set brings that read to about 25 pages and 4 ms. - Messages by role scan the thread's whole message history. At about 0.7 s, this is the largest remaining read on send.
role IN (SELECT value FROM json_each(?))(ProjectionStore.ts:3507) can't use therole = 'user'partial index, so the planner walksmessages_thread_created_idx. Two options: a literalrole = 'user'for the user-only filter, keeping the existing behavior for other filters, or a(thread_id, role, created_at)index. - Smaller reads that still scale with history: runs, attempts, provider turns, subagents and context transfers, about 0.14–0.3 s each on the first read. Context transfers are read in full (
ProjectionStore.ts:3608), although send's transfer checks only need pending ones. A caller-specific pending filter could help; merge-back and finalization still need historical transfers. - Migration 078 took 59.7 s on the live-layout clone, versus about 5.2 s on a warm compacted copy. The cache was not forcibly reset. A startup progress note may be useful.
Other measurements:
- Commit (
EventSink.commitCommand) takes about 7 ms. - Effect decode of what a send loads takes about 0.1–0.15 s.
- The new indexes add a few µs per upsert on this data.
Roughly half of the measured reader time was outside the three commands compared above. The heavy background readers were idle compaction, MCP loadCaller, startNextQueuedRun and subagent finalization. With 4 reader slots, the traces show 111 s of slot waits, up to 14 s for a single read. That may be worth a separate issue.
Another data point from a much bigger thread: about 41k turn items / 37k nodes, in a 28 GB database. On 0.1.7-nightly.20261103.4119 one send to that thread took 354 s under host load. Roughly: message-order lookup 97 s, provider threads 78 s, nodes 65 s, turn items 63 s, messages 20 s, subagents 18 s.
The worse part is the knock-on effect. While that read was open the WAL couldn't checkpoint, and the writer sat waiting 335 s, so every other thread froze with it. These are exactly the queries this PR rewrites, so we'd really like to see it land.
Heads up: it conflicts with main now, since migration 078 is already taken there.
Another data point from a much bigger thread: about 41k turn items / 37k nodes, in a 28 GB database. On 0.1.7-nightly.20261103.4119 one send to that thread took 354 s under host load. Roughly: message-order lookup 97 s, provider threads 78 s, nodes 65 s, turn items 63 s, messages 20 s, subagents 18 s.
The worse part is the knock-on effect. While that read was open the WAL couldn't checkpoint, and the writer sat waiting 335 s, so every other thread froze with it. These are exactly the queries this PR rewrites, so we'd really like to see it land.
Heads up: it conflicts with main now, since migration 078 is already taken there.
You continue using the thread after so much slowdown? How?
It's an automated pipeline thread: subagent reports and scheduled tasks post to it (about 260 sends in 31 h), so nobody sits waiting on the composer. Most of those sends are much faster than that one, about 2,500 s of history reads across the 260; the 354 s send was the worst, under heavy host load. What actually hurts is the knock-on effect: while one of those reads is open the writer is blocked, so every other thread on the server freezes with it.
Note
Written by Claude Opus 5.5 (Claude Code) on behalf of Navid
In case it helps get this lookup fixed sooner: #30873 adds the same index on the message-ordinal statement, with a json_valid guard so the index build and writes don't fail on a row whose payload isn't valid JSON. It is green and mergeable on current main (migration 081). Happy to close it if you'd rather land this PR instead.