Skip to content

perf(server): stop reading a thread's whole activity timeline per event - #6613

Open
patroza wants to merge 1 commit into
pingdotgg:mainfrom
patroza:upstream-pr/thread-shell-summary-activity-read
Open

perf(server): stop reading a thread's whole activity timeline per event#6613
patroza wants to merge 1 commit into
pingdotgg:mainfrom
patroza:upstream-pr/thread-shell-summary-activity-read

Conversation

@patroza

@patroza patroza commented Aug 14, 2026

Copy link
Copy Markdown

The problem

refreshThreadShellSummary loads every activity row a thread has ever produced — payloads included — to compute a single integer, pendingUserInputCount.

Activity payloads are the tool timeline, so the cost of one refresh scales with the thread's entire history. Across the 402 threads on a heavily-used instance, comparing what the refresh reads against what the derivation needs:

thread rows read of which needed bytes read of which needed
median 277 0 2.8 MB 0
p95 1,190 0 60.4 MB 0
p99 4,279 0 123.0 MB 0
max 11,137 0 493.3 MB 0

The "needed" column is zero for 397 of 402 threads, and that is not a quirk of this dataset: pendingUserInputCount derives only from user-input.requested, user-input.resolved and provider.user-input.respond.failed, which exist only when a provider stops mid-turn to ask the user something. Even on the 5 threads here that have had one, they account for 2–4 rows out of thousands — 1.3 KB out of 3.5 MB on the largest.

So the read is not merely oversized on one unusual thread; it is reading the whole timeline to find a handful of rows that are usually not there at all.

This is already reported

How this relates to #5855 and #6608

Both of those reduce how often the refresh runs. This PR reduces what it costs when it runs. They compose; none of them replaces another:

#5855 #6608 this PR
Skip refresh for assistant deltas
Skip refresh for streaming activity kinds
Make the remaining refreshes cheap

Neither #5855 nor #6608 changes the read itself — both still call projectionThreadActivityRepository.listByThreadId({ threadId }) and pull the full payload set. With either merged, every lifecycle event (user-input.requested/resolved, approval events, proposed-plan upserts, thread.session-set, thread.turn-diff-completed) still pays the whole-thread read. On the instance above that is up to 467 MB per event, for one integer.

Merging this alongside them means the refreshes that remain are also cheap. If the maintainers prefer #6608's shape, this still applies unchanged on top of it — the two touch different lines.

The change

derivePendingUserInputCountFromActivities only reacts to three kinds — user-input.requested, user-input.resolved and provider.user-input.respond.failed — and continues past everything else. The read now filters on exactly those three, with the kinds declared next to the deriver so the two stay in step.

Measured against the same database. The busiest thread, warm, before schema decode and the sort that follows:

before:  10,652 rows      467.4 MB     348.8 ms
after :       0 rows        0.000 MB       6.9 ms

And on the threads where the filtered read is not empty — the fix's worst case:

3,778 rows / 3.5 MB → 2 rows / 1.3 KB     7.3 ms → 1.9 ms
1,576 rows / 1.8 MB → 3 rows / 1.3 KB     2.3 ms → 0.8 ms
1,226 rows / 1.6 MB → 2 rows / 1.4 KB     1.9 ms → 0.6 ms

Deployed, the traced projection step moved:

applyThreadsProjection   p50 1,707 ms → 1.5 ms     p95 4,814 ms → 17.4 ms     max 15,010 ms → 35.1 ms

94% of orchestration events there reach this path (thread.activity-appended alone), averaging ~356/hour and peaking at 2,613 in one hour.

ProjectionThreadActivityRepository gains listByThreadIdAndKinds, which reuses the row decoding of listByThreadId, keeps its exact ordering, and short-circuits an empty kind list without issuing a query. listByThreadId is unchanged and still used everywhere else.

No behaviour change: the rows removed from the read are ones the deriver already skipped.

Why it went unnoticed for so long

The code landed in #1973 (2026-04-13) and has not been touched in the 1,239 commits since. It only bites at the tail — on the instance measured, the median thread holds 306 activity rows (~2.8 MB), which is unnoticeable; the p99 holds 4,279 rows / 123 MB. It needs long-lived threads and a server process that stays up for days, which is not the common desktop profile.

Adjacent fixes have repeatedly addressed the same underlying volume on the serve path — #4622 pruning activity payloads over the wire, #4788 gzipping snapshots, #5482 dropping MCP tool results from thread payloads, #5147 bounding catch-up replay. This is the same volume problem on the projection path.

Tests

Five repository tests cover kind filtering, ordering parity with the unfiltered list, payload-decoding parity, thread isolation, and the empty-kinds short circuit.

One ProjectionPipeline test is added, because the existing suite could not catch the failure mode this change introduces. The suite asserted pendingUserInputCount only where it settles back to 0, so a filter that dropped every row would still have passed. The new test appends a user-input.requested activity alongside unrelated tool noise and asserts the count is 1.

Verified by mutation: renaming the filtered kinds makes only the new test fail — the other 22 in that file still pass.

Equivalence was also checked against real data. Replaying the deriver over all 402 threads on the instance above, once from the full activity list and once from the filtered list, produced identical counts for every thread.

Not a UI change, so no screenshots.

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b26c65e3-6424-494b-adb3-203b5777e313

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 14, 2026
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Aug 14, 2026
@macroscopeapp

macroscopeapp Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved 845bcf4

Performance optimization that adds a filtered database query method to avoid loading entire thread activity timelines. The change is self-contained with comprehensive test coverage, and doesn't alter runtime behavior beyond improved efficiency.

You can customize Macroscope's approvability policy. Learn more.

`refreshThreadShellSummary` runs on every event in a thread and loads every
activity row that thread has produced — payloads included — to compute one
integer, `pendingUserInputCount`.

Those payloads are the tool timeline, so the cost of each event scales with the
thread's entire history. On a heavily-used instance the busiest thread carries
10,652 activity rows totalling 467 MB, and none of them are rows the count
derives from: across that whole database, 5.01 GB of activity payloads reduce to
13 rows (10 KB) carrying a user-input request id.

`derivePendingUserInputCountFromActivities` only reacts to three kinds —
`user-input.requested`, `user-input.resolved` and
`provider.user-input.respond.failed` — and skips everything else, so the read
now filters on exactly those. Measured against that database, the heaviest
thread goes from 349 ms and 467 MB to 6.9 ms and no rows, before the schema
decode and sort that follow.

The repository gains `listByThreadIdAndKinds`, which keeps the ordering and row
decoding of `listByThreadId` and short-circuits an empty kind list without
issuing a query.

Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>
@omegent-app
omegent-app Bot force-pushed the upstream-pr/thread-shell-summary-activity-read branch from 4d6d0bd to 845bcf4 Compare August 14, 2026 15:09
@macroscopeapp
macroscopeapp Bot dismissed their stale review August 14, 2026 15:09

Dismissing prior approval to re-evaluate 845bcf4

patroza added a commit to patroza/t3code that referenced this pull request Aug 15, 2026
## Summary

The PR panel mapped every unclassified `gh` exit to **GitHub CLI command
failed.**, so the real guest-wrapper reason never reached the UI.

That is what `pingdotgg#6613` (`pingdotgg/t3code`) showed this time. The live
t3vm image already has ops #80 (host-qualified `--repo`). The product
`#373` shim fix is still on `fork/dev`. App-only mint dies with:

```
t3-github-app-token: app is not installed on pingdotgg/t3code (or repo does not exist)
```

The App is installed on `patroza` / `macs-holding` / `effect-app` /
`aaaomega` — not `pingdotgg`.

This change passes through guest wrapper lines (`t3-github-app-token:` /
`gh-app-wrapper:`) as the command-failed detail, and keeps raw provider
stderr off the VCS error message (tokens stay out of logs).

Installing the App on `pingdotgg` (or using a user/SSH token for those
reads) is still required for the panel to actually load that PR.

## Test plan

- [x] `vp test run apps/server/src/vcs/VcsProcess.test.ts
apps/server/src/sourceControl/GitHubCli.test.ts`
- [ ] After deploy: open a PR the App is not installed on and confirm
the panel shows `app is not installed on …` instead of the generic CLI
line
- [ ] Confirm a `patroza/t3code` PR still loads on t3vm

Co-authored-by: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant