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

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

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

@patrozapatroza 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:

threadrows readof which neededbytes readof which needed
median27702.8 MB0
p951,190060.4 MB0
p994,2790123.0 MB0
max11,1370493.3 MB0

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#6608this 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

coderabbitaiBot 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-actionsgithub-actionsBot 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]
macroscopeappBot previously approved these changes Aug 14, 2026
@macroscopeapp

macroscopeappBot 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-appBotforce-pushed the upstream-pr/thread-shell-summary-activity-read branch from 4d6d0bd to 845bcf4CompareAugust 14, 2026 15:09
@macroscopeapp
macroscopeappBot dismissed their stale reviewAugust 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>
@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this because its server optimization is already on main. #8988 limits shell-summary activity reads to user-input lifecycle rows, and #8150 skips shell-summary refreshes for routine streamed activity.

#9032 records this PR in the related-work credits.

@t3dotggt3dotgg closed this Sep 1, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@patroza@t3dotgg
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

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

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

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

@patrozapatroza 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:

threadrows readof which neededbytes readof which needed
median27702.8 MB0
p951,190060.4 MB0
p994,2790123.0 MB0
max11,1370493.3 MB0

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#6608this 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

coderabbitaiBot 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-actionsgithub-actionsBot 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]
macroscopeappBot previously approved these changes Aug 14, 2026
@macroscopeapp

macroscopeappBot 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-appBotforce-pushed the upstream-pr/thread-shell-summary-activity-read branch from 4d6d0bd to 845bcf4CompareAugust 14, 2026 15:09
@macroscopeapp
macroscopeappBot dismissed their stale reviewAugust 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>
@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this because its server optimization is already on main. #8988 limits shell-summary activity reads to user-input lifecycle rows, and #8150 skips shell-summary refreshes for routine streamed activity.

#9032 records this PR in the related-work credits.

@t3dotggt3dotgg closed this Sep 1, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@patroza@t3dotgg
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

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

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

@patrozapatroza 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:

threadrows readof which neededbytes readof which needed
median27702.8 MB0
p951,190060.4 MB0
p994,2790123.0 MB0
max11,1370493.3 MB0

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#6608this 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

coderabbitaiBot 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-actionsgithub-actionsBot 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]
macroscopeappBot previously approved these changes Aug 14, 2026
@macroscopeapp

macroscopeappBot 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-appBotforce-pushed the upstream-pr/thread-shell-summary-activity-read branch from 4d6d0bd to 845bcf4CompareAugust 14, 2026 15:09
@macroscopeapp
macroscopeappBot dismissed their stale reviewAugust 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>
@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this because its server optimization is already on main. #8988 limits shell-summary activity reads to user-input lifecycle rows, and #8150 skips shell-summary refreshes for routine streamed activity.

#9032 records this PR in the related-work credits.

@t3dotggt3dotgg closed this Sep 1, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@patroza@t3dotgg
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

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

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

@patrozapatroza 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:

threadrows readof which neededbytes readof which needed
median27702.8 MB0
p951,190060.4 MB0
p994,2790123.0 MB0
max11,1370493.3 MB0

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#6608this 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

coderabbitaiBot 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-actionsgithub-actionsBot 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]
macroscopeappBot previously approved these changes Aug 14, 2026
@macroscopeapp

macroscopeappBot 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-appBotforce-pushed the upstream-pr/thread-shell-summary-activity-read branch from 4d6d0bd to 845bcf4CompareAugust 14, 2026 15:09
@macroscopeapp
macroscopeappBot dismissed their stale reviewAugust 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>
@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this because its server optimization is already on main. #8988 limits shell-summary activity reads to user-input lifecycle rows, and #8150 skips shell-summary refreshes for routine streamed activity.

#9032 records this PR in the related-work credits.

@t3dotggt3dotgg closed this Sep 1, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@patroza@t3dotgg
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

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

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

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

@patrozapatroza 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:

threadrows readof which neededbytes readof which needed
median27702.8 MB0
p951,190060.4 MB0
p994,2790123.0 MB0
max11,1370493.3 MB0

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#6608this 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

coderabbitaiBot 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-actionsgithub-actionsBot 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]
macroscopeappBot previously approved these changes Aug 14, 2026
@macroscopeapp

macroscopeappBot 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-appBotforce-pushed the upstream-pr/thread-shell-summary-activity-read branch from 4d6d0bd to 845bcf4CompareAugust 14, 2026 15:09
@macroscopeapp
macroscopeappBot dismissed their stale reviewAugust 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>
@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this because its server optimization is already on main. #8988 limits shell-summary activity reads to user-input lifecycle rows, and #8150 skips shell-summary refreshes for routine streamed activity.

#9032 records this PR in the related-work credits.

@t3dotggt3dotgg closed this Sep 1, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@patroza@t3dotgg
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

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

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

@patrozapatroza 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:

threadrows readof which neededbytes readof which needed
median27702.8 MB0
p951,190060.4 MB0
p994,2790123.0 MB0
max11,1370493.3 MB0

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#6608this 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

coderabbitaiBot 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-actionsgithub-actionsBot 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]
macroscopeappBot previously approved these changes Aug 14, 2026
@macroscopeapp

macroscopeappBot 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-appBotforce-pushed the upstream-pr/thread-shell-summary-activity-read branch from 4d6d0bd to 845bcf4CompareAugust 14, 2026 15:09
@macroscopeapp
macroscopeappBot dismissed their stale reviewAugust 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>
@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this because its server optimization is already on main. #8988 limits shell-summary activity reads to user-input lifecycle rows, and #8150 skips shell-summary refreshes for routine streamed activity.

#9032 records this PR in the related-work credits.

@t3dotggt3dotgg closed this Sep 1, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@patroza@t3dotgg
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

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

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

@patrozapatroza 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:

threadrows readof which neededbytes readof which needed
median27702.8 MB0
p951,190060.4 MB0
p994,2790123.0 MB0
max11,1370493.3 MB0

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#6608this 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

coderabbitaiBot 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-actionsgithub-actionsBot 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]
macroscopeappBot previously approved these changes Aug 14, 2026
@macroscopeapp

macroscopeappBot 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-appBotforce-pushed the upstream-pr/thread-shell-summary-activity-read branch from 4d6d0bd to 845bcf4CompareAugust 14, 2026 15:09
@macroscopeapp
macroscopeappBot dismissed their stale reviewAugust 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>
@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this because its server optimization is already on main. #8988 limits shell-summary activity reads to user-input lifecycle rows, and #8150 skips shell-summary refreshes for routine streamed activity.

#9032 records this PR in the related-work credits.

@t3dotggt3dotgg closed this Sep 1, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@patroza@t3dotgg
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

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

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

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

@patrozapatroza 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:

threadrows readof which neededbytes readof which needed
median27702.8 MB0
p951,190060.4 MB0
p994,2790123.0 MB0
max11,1370493.3 MB0

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#6608this 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

coderabbitaiBot 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-actionsgithub-actionsBot 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]
macroscopeappBot previously approved these changes Aug 14, 2026
@macroscopeapp

macroscopeappBot 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-appBotforce-pushed the upstream-pr/thread-shell-summary-activity-read branch from 4d6d0bd to 845bcf4CompareAugust 14, 2026 15:09
@macroscopeapp
macroscopeappBot dismissed their stale reviewAugust 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>
@t3dotgg

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this because its server optimization is already on main. #8988 limits shell-summary activity reads to user-input lifecycle rows, and #8150 skips shell-summary refreshes for routine streamed activity.

#9032 records this PR in the related-work credits.

@t3dotggt3dotgg closed this Sep 1, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@patroza@t3dotgg