perf(server): stop reading a thread's whole activity timeline per event - #402
Merged
Merged
Conversation
`refreshThreadShellSummary` runs on every event in a thread and loaded every activity row that thread has ever produced — payloads included — to compute one integer, `pendingUserInputCount`. Those payloads are the tool timeline. On this deployment the busiest thread carries 10,652 activity rows totalling 467 MB, and none of them are rows the count is derived from: across the whole database, 5.01 GB of activity payloads reduce to the 13 rows (10 KB) that carry 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 the live database, the heaviest thread goes from 349 ms and 467 MB to 6.9 ms and nothing. That read is what fed the server's memory ceiling. systemd accounting shows every long-lived run climbing to the 32 GB `--max-old-space-size`: 34.1 G over 16h, 32.4 G over 4h, 31.8 G over 25h with 28.8 G read from disk. At the cap the process sits in back-to-back full GCs, which is why every client — desktop and mobile alike — saw slow thread opens, stalled sync and dropped connections until the server was restarted. The activity repository gains `listByThreadIdAndKinds`, which keeps the ordering and row decoding of `listByThreadId` and short-circuits an empty kind list without touching SQL. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>
omegent-app Bot
added a commit
that referenced
this pull request
Aug 15, 2026
#401 was squash-merged. That kept the code and threw the lineage away: the 23 upstream commits stopped being ancestors, so `fork/dev` read as **29 commits behind upstream when only 6 were genuinely outstanding**, and the next sync would have re-merged and re-resolved all 23 — on the same mobile files that took sixteen conflicts to land the first time. Nobody noticed for two merges. It surfaced in a deploy alert that said **"Commits (2)"** for a range that had carried 23. ## What this adds A `push`-triggered check on `fork/dev` that fails when a commit which carried a sync has fewer than two parents, and prints the `-s ours` repair in the log. Sync commits are identified by **the head branch of the PR they came from**, not by their subject, because subjects vary by merge method: ``` Merge pull request #400 from patroza/sync/upstream-2026-08-12b Merge upstream/main into fork/dev (23 commits) (#401) merge: sync upstream through b73232b ``` A merge-button commit names the branch inline, so no API call is needed; squash and rebase commits are resolved through the API, with the subject line as a fallback when that is unavailable. **Ordinary fork PRs are untouched** — they are expected to squash, and are never checked. ## Verified against the real commits | commit | what it is | result | |---|---|---| | `5e63531b1` | #401, squash-merged sync | **fails**, exit 1 | | `0bf7835cc` | #400, sync merged properly | recognised as a sync, passes (2 parents) | | `a76069bd9` | #402, an ordinary squashed PR | not flagged | The third row is the one that matters most: the guard has to stay silent on your normal workflow. ## This detects, it does not prevent Worth being explicit, since it was the first question asked: **clicking merge does not fail.** The check runs after the merge lands, because GitHub has no per-PR merge-method control, and a repository-wide setting cannot allow squash for ordinary fork PRs while requiring a merge commit for syncs. Squash merges do fire `push` — `5e63531b1` triggered Fork CI at 08:08 — so this turns a silent, weeks-later discovery into a red check within a minute. Actual prevention is `gh pr merge <n> --merge`, which is how #398, #399 and #400 all landed correctly. That is now written into the sync runbook in `AGENTS.md`. If you would rather it be enforced at the button, the next step is a `sync:upstream` label workflow that merges the PR through the API once checks pass — say the word and I will add it. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) via [T3 Chat](https://t3.chat) on Discord --------- Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com> Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
refreshThreadShellSummaryruns on every event in a thread and loaded every activity row that thread has ever produced — payloads included — to compute one integer,pendingUserInputCount.Those payloads are the tool timeline, and they are enormous. On this deployment:
derivePendingUserInputCountFromActivitiesonly reacts to three kinds —user-input.requested,user-input.resolved,provider.user-input.respond.failed— and skips everything else, so the read now filters on exactly those. Measured against the live database:…and that is before the Effect Schema decode, array copy and sort that followed it.
Why this is the memory problem
systemd's per-run accounting for
t3code-servershows every long-lived run climbing to the 32 GB--max-old-space-size:At the cap the process sits in back-to-back full GCs. That is why every client — desktop and mobile alike — saw slow thread opens, slow actions, stalled sync and dropped connections, and why restarting the server fixed it for a while. The
stopthat preceded the last restart had to be SIGKILLed after the 2-minute timeout.It is also cumulative rather than sudden: the cost is per-thread and grows with that thread's accumulated activity, which is why it felt fine last week and bad this week.
Not a merge casualty
Worth stating, since the suspicion was that a sync dropped something: this hot path is byte-identical to upstream's. Of the ten upstream perf/reconnect commits in range, eight are 100% present and the two partials are files this fork deliberately rewrote.
fork/timhas zero perf-related commits among its 18 unmerged ones. Upstream has the same code; our thread sizes are what make it pathological — so this is a clean upstream candidate too.Changes
ProjectionThreadActivityRepository.listByThreadIdAndKinds— same ordering and row decoding aslistByThreadId, short-circuits an empty kind list without touching SQL.refreshThreadShellSummarypasses the three kinds the deriver reads, named next to the deriver so they stay in step.Verification
pnpm typecheck(0 errors) ·pnpm test— 273 files, 0 failures ·vp check --fix. Five new repository tests cover the filtering, ordering parity with the unfiltered list, payload decoding parity, thread isolation, and the empty-kinds short circuit.Co-authored-by: Patrick Roza 42661+patroza@users.noreply.github.com
🤖 Generated with Claude Code via T3 Chat on Discord