Skip to content

perf(cli): read long threads from the end instead of paging through them - #6

Draft
fagnersales wants to merge 80 commits into
base/thread-read-tailfrom
perf/thread-read-tail
Draft

fagnersales wants to merge 80 commits into
base/thread-read-tailfrom
perf/thread-read-tail

Conversation

@fagnersales

Copy link
Copy Markdown
Owner

Draft on Fagner's fork, for review. Not sent upstream. The base branch base/thread-read-tail is pingdotgg/t3code@f558007b20 (the fork's main is 7,572 commits behind), so this diff is only the one commit. The text to send upstream is at the bottom.

Why t3 thread show on "Auto Delivery" takes ~62 s

Measured with the installed t3 v0.1.5-nightly.20261029.3877 on Fagner's thread ba761708… (1,789 runs, 20,944 turn items, a 36 GB statev2.sqlite). thread show --messages 2 takes ~62 s, --messages 50 ~42 s, --messages 200 ~86 s. The CLI spends about 2 s of CPU; the rest is waiting on the server.

1. The client reads the whole thread, page by page.

  • In the nightly bundle, thread show calls readTimeline, which pages t3_thread_read forward from position 0 in pages of 100 until hasMore is false, then keeps the last N messages.
  • On main, perf(cli): thread show reads only the end of long threads pingdotgg/t3code#24087 (Oct 30, one day after this nightly) already made thread show start near the end (cli/thread.ts:344). It still needs 2–7 calls because the server can only page forward.
  • These still read the entire thread on main:
    • runResultText (cli/thread.ts:419), the result read behind thread send/start --wait. It reads the messages view, or the full activity view when a run failed.
    • RemoteDelegation.lastReply (peer/RemoteDelegation.ts:439), the result of a task delegated to a linked environment.

2. Every page builds the whole thread's index on the server. getTimelinePage (ProjectionStore.ts:5981) calls readTimelineIndex (ProjectionStore.ts:5711) for every page. That function:

  • decodes all of the thread's runs and attempts;
  • selects every turn item of the thread, with json_extract on message payloads and no LIMIT;
  • applies visibility rules (rolled-back runs, cancelled queued input, superseded interrupts), adds inherited fork rows, and only then slices out the page.

Positions are ranks in that filtered list, so they can't simply become an SQL keyset over ordinal. A full read is therefore pages × whole thread.

Where a page's time goes. One trace of t3_thread_read on a synthetic 20,680-item thread, 171 ms in total:

Part Time
The index SQL statement 82 ms
Decoding runs 15 ms, twice
getThreadRecords(messages) 35 ms
Shell and other work the rest

On the live server, the trace file (read only) shows readTimelineIndex at p50 162 ms, p90 661 ms and max 6.2 s, and readThread at p50 338 ms. A cold run of the index SQL statement on the real thread took 0.82 s; warm, it took 0.05 s.

So the 62 s is about 50 message pages at ~1.2 s each on a busy server, and the 86 s for --messages 200 comes from the same full read.

The fix (additive)

  • t3_thread_read gains beforePosition. It returns the newest limit matches before that position, oldest first. Every result now includes previousPosition: the cursor for the page before this one, or null when nothing older is left. afterPosition, nextPosition and hasMore keep their meaning.
    • ProjectionTimelinePage gains hasEarlier.
    • hasMore and hasEarlier both describe the view's items beyond the page, whatever position bounds the request had.
  • CLI: a shared readBackward reads from the newest item and stops as soon as it has what it needs.
    • thread show --messages N asks only for the messages still missing, up to 100 per page, so --messages 2 downloads 2 items instead of up to 100 items of 20k characters each.
    • runResultText (--wait) stops at the page that holds the run's result, usually the first.
  • RemoteDelegation.lastReply reads backward the same way. It stops at the run's reply, or at its error for a failed run.
  • Older servers keep working. t3_thread_read is a strict tool, so an older server rejects the unknown field with JSON-RPC -32602.
    • AcpMcpBridgeError now carries the JSON-RPC code.
    • The CLI maps invalid params to ThreadToolFailedError instead of "Could not reach the T3 Code server".
    • In that case readBackward falls back to the existing forward algorithm. A response without previousPosition falls back too.
    • The peer path falls back to the old forward read on any failure of its first backward call.
  • Not changed: each call still builds the full index (see risks). There is no schema migration and no new index.

Docs: docs/orchestration-v2/orchestrator-mcp-server.md (the t3_thread_read paragraph). Agent context budget: +208 bytes in the MCP tool list (agentContextBudget.json).

Numbers

Synthetic thread seeded through the real EventStore and ProjectionStore: 1,880 runs, 20,680 items (user and assistant messages, reasoning, commands with ~2.4 KB of output), a 171 MB DB. It ran on a temporary server with --base-dir /tmp/t3bench, never against ~/.t3. Times are server time measured from an MCP client, on a quiet machine:

Read nightly CLI main this PR
thread show --messages 2 57 calls, 11.0 s 2 calls, 0.27 s 1 call, 0.14 s
thread show --messages 200 57 calls, 11.0 s 7 calls, 1.05 s 2 calls, 0.31 s
--wait result (assistant reply) 57 calls, 11.0 s 57 calls, 11.0 s 1 call, 0.16 s
--wait result (failed run, activity view) 207 calls, 46.9 s 207 calls, 46.9 s 1 call in the usual case (the error is near the end)

End to end, including ~1–2 s of CLI startup:

  • The installed nightly CLI against this synthetic server: 13.5 s for --messages 2 and 16.5 s for --messages 200.
  • This branch's CLI: 1.3–3 s for both.

A second run on a loaded machine (~650 ms per call) gave the same call counts: full read 38.5 s, main 1.1 s / 4.5 s, this PR 0.86 s / 1.8 s.

Estimate for "Auto Delivery" (20,944 items, ~1.2 s per call on the live server):

Read Today With this PR
thread show --messages 2 ~62 s 1 call, about 2–3 s with startup
thread show --messages 200 ~86 s 2 calls, about 3–5 s
--wait result (reply) ~50 calls, about a minute 1 call
--wait result (failed run) ~210 calls, about 4 minutes 1 call in the usual case

A nightly that only includes pingdotgg#24087 would already bring thread show down to 2–7 calls. This PR adds the --wait and peer reads, and saves the remaining extra calls.

Compatibility, checked by hand:

  • This branch's CLI against the installed nightly server, on a copy of the synthetic DB: the strict schema rejects beforePosition, the CLI falls back, and the output is identical to main's for --messages 2 and 200.
  • The nightly CLI against this branch's server: works (the timings above). previousPosition is an extra output field, and older decoders ignore it.

Tests

  • ProjectionStore.test.ts: the fork-inheritance timeline test now checks beforePosition pages and hasEarlier across inherited and synthetic rows.
  • cli/thread.test.ts:
    • The thread show cases run against both a current server and a legacy one that rejects beforePosition.
    • On a current server, the long-thread case needs exactly 2 calls. That assertion fails on main.
    • New case: a JSON-RPC invalid-params error becomes a tool failure, not "server unreachable".
  • RemoteDelegation.test.ts: "a task's result is its own run's whole last reply, not a later run's" runs against a box that reads backward and against one that only reads forward.
  • vp run check:changed: all pass (format, lint, knip, typecheck of 36 packages, tests beside the changes).

Risks

  • No migration, no index build. I decided against a covering index on orchestration_v2_projection_turn_items (it would let the index SQL skip the table rows): building it would run over the whole 36 GB database at startup.
  • Each call still costs O(thread). About 160 ms p50 on the live server, up to seconds when the cache is cold. This PR cuts the number of calls, not their cost. Follow-ups, if wanted: an index cached per thread and keyed by the thread's latest event sequence, or the covering index above.
  • CLI and server version pairs: all four combinations work. A new CLI with an old server pays one rejected call, then uses the old algorithm. Peers behave the same way.
  • Model-facing text changed (one field description and a few words in the t3_thread_read description, +208 bytes). The mcp-tool-use evals were not run: there is no API key in this checkout and a full run costs ~$9–17. Upstream CI also requires the agent-context-increase label for budget growth.
  • The "newest" cursor is Number.MAX_SAFE_INTEGER (THREAD_READ_NEWEST_POSITION in contracts). Agents are told to use the thread's itemCount.
Benchmark scripts (not committed)

seed.ts builds the synthetic thread through EventStore + ProjectionStore into <baseDir>/userdata/statev2.sqlite. read.ts signs in as an MCP client of a running server and times the read patterns: the nightly full read, main's readLatestMessages, and this branch's. Both live in /tmp/t3bench-scripts/ on Fagner's Mac. Copy them into apps/server/.bench/ to run them, and copy main's cli/thread.ts to src/cli/thread.mainbench.ts for the main: mode.


Upstream PR text (for pingdotgg/t3code, if Fagner sends it)

Title: perf(cli): read long threads from the end instead of paging through them

Body:

On a thread with ~20k items, t3 thread send --wait and older t3 thread show builds took about a minute, and a linked environment's task result read the same way. They paged t3_thread_read forward through the whole thread to keep its last few items, and every page rebuilds the thread's whole visible index on the server (ProjectionStore.readTimelineIndex). The cost is pages × thread length. On a synthetic 20,680-item thread, the --wait result took 57 calls (11 s), or 207 calls (47 s) for a failed run.

t3_thread_read now takes beforePosition (the newest limit matches before it) and returns previousPosition:

  • The CLI's thread show, its --wait result, and RemoteDelegation.lastReply read backward from the newest item and stop once they have what they need: 1–2 calls instead of 2–207.
  • An older server's strict schema rejects the field, and the CLI then falls back to the forward read. To make that possible, invalid-params errors now arrive as tool failures instead of "Could not reach the T3 Code server".
  • No migration. Each call still builds the full index; this PR only cuts how many calls a read needs.

Before and after, on a synthetic thread with 1,880 runs and 20,680 items:

Read before (main) after
thread show --messages 2 2 calls, 0.27 s 1 call, 0.14 s
thread show --messages 200 7 calls, 1.05 s 2 calls, 0.31 s
--wait result 57 calls, 11.0 s 1 call, 0.16 s
--wait result, failed run 207 calls, 46.9 s 1 call

Needs the agent-context-increase label (+208 bytes in the tool list). The mcp-tool-use evals have not been run yet.

Claude Opus 5.5 in Claude Code (T3 Code)

🤖 Generated with Claude Code

Yash-Singh1 and others added 2 commits November 8, 2026 14:06
Enable prepared server bytecode on native Linux ARM64 desktop builds and install the Electron runtime libraries required to generate it on headless Linux runners. Keep host compatibility checks and extend the existing tests. Document the native ARM64 command and supported build prerequisites.

Validated with check:changed, native ARM64 tests, AppImage and .deb builds, and packaged Electron accepting the prepared CLI and server bytecode after extraction. CI and bot reviews passed on 8f89392.

Implemented and verified by GPT 6.1 Sol through the Codex harness.
t3_thread_read only paged forward, and every page builds the thread's
whole visible index on the server. `t3 thread show` on old CLIs and the
result read behind `send/start --wait` (and a linked environment's task
result) paged through the entire thread, so a 20k-item thread took about
a minute.

t3_thread_read now takes `beforePosition` (the newest `limit` matches
before it) and returns `previousPosition`. The CLI and RemoteDelegation
read backward from the newest item and stop once they have what they
need, falling back to the forward read when an older server rejects the
field. The CLI now reports a server's invalid-params answer as a tool
failure instead of "could not reach the server".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L labels Nov 8, 2026
fagnersales and others added 26 commits November 8, 2026 18:11
The RemoteDelegation.lastReply change overlaps pingdotgg#26664,
which already reworks that read, so this PR keeps to the CLI's reads.
The newest-position constant moves back into the CLI, its only user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ons (pingdotgg#32934)

Co-authored-by: GPT 6.1 Sol <codex@openai.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Grok 4.7 <noreply@x.ai>
Co-authored-by: GPT 6.1 Sol <codex@openai.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Grok 4.7 <noreply@x.ai>
…32573)

Co-authored-by: GPT 6.1 Sol <codex@openai.com>
Co-authored-by: Muse Wonder Full 1.0 <noreply@meta.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A server answering with a previousPosition at or past the requested
beforePosition would make readBackward read the same page forever.
Also shortens the new t3_thread_read wording (+171 bytes instead of +208).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page past the position requested is a repeat, so its items no longer
reach thread show's output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Yash Singh <saiansh2525@gmail.com>
…ngdotgg#28016)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: maria-rcks <maria@kuuro.net>
Co-authored-by: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com>
…#28060)

Co-authored-by: Nik <64274427+eyeveil@users.noreply.github.com>
…tgg#26724)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Yash Singh <saiansh2525@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…s reload over gzip HTTP (pingdotgg#32287)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…kout (pingdotgg#32927)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…32941)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…Code (pingdotgg#33134)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
bmdavis419 and others added 30 commits November 8, 2026 20:17
…3177)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…cript cleanup (pingdotgg#33147)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…earch primitives (pingdotgg#33193)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… unchecked (pingdotgg#27353)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rash Android (pingdotgg#32812)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ingdotgg#24482)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: maria-rcks <maria@kuuro.net>
…nal displays (pingdotgg#19064)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: maria-rcks <maria@kuuro.net>
… blank tile (pingdotgg#21163)

Co-authored-by: T3 Code Test <t3code-test@example.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: shivamhwp <shivamhwp@users.noreply.github.com>
Co-authored-by: maria-rcks <maria@kuuro.net>
…27576)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: maria-rcks <maria@kuuro.net>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
…gdotgg#21652)

Co-authored-by: maria-rcks <maria@kuuro.net>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
…tgg#24933)

Co-authored-by: maria-rcks <maria@kuuro.net>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
…pingdotgg#31998)

Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…gg#25828)

Co-authored-by: danddanddand <47586871+danddanddand@users.noreply.github.com>
Co-authored-by: whoisaldo <whois.younes@gmail.com>
Co-authored-by: maria-rcks <maria@kuuro.net>
Co-authored-by: juliusmarminge <julius0216@outlook.com>
Co-authored-by: Claudesaul <139932571+Claudesaul@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…gg#31207)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: maria-rcks <maria@kuuro.net>
…tgg#27753)

Co-authored-by: PR Batch Tester <agent@local.test>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: maria-rcks <maria@kuuro.net>
… Android (pingdotgg#28524)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ingdotgg#28125)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… on iPhone Duo (pingdotgg#33213)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…dotgg#24079)

Co-authored-by: PR Batch Tester <agent@local.test>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: GPT-6.1 Sol <noreply@openai.com>
Co-authored-by: maria-rcks <maria@kuuro.net>
# Conflicts:
#	apps/server/src/provider/agentContextBudget.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.