Skip to content

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

Merged
patroza merged 1 commit into
fork/devfrom
perf/thread-shell-summary-activity-read
Aug 14, 2026
Merged

perf(server): stop reading a thread's whole activity timeline per event#402
patroza merged 1 commit into
fork/devfrom
perf/thread-shell-summary-activity-read

Conversation

@omegent-app

@omegent-app omegent-app Bot commented Aug 14, 2026

Copy link
Copy Markdown

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, and they are enormous. On this deployment:

Busiest thread 10,652 activity rows / 467 MB
Rows that thread's count actually derives from 0
Whole database: activity payloads 5.01 GB
…rows carrying a user-input request id 13 rows / 10 KB (0.0002%)

derivePendingUserInputCountFromActivities only 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:

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

…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-server shows every long-lived run climbing to the 32 GB --max-old-space-size:

CPU wall memory peak disk read
8h37m 16h11m 34.1 G 58.6 G
2h11m 4h02m 32.4 G 23.7 G
10h28m 1d 1h 31.8 G 28.8 G

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 stop that 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/tim has 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 as listByThreadId, short-circuits an empty kind list without touching SQL.
  • refreshThreadShellSummary passes 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

`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>
@patroza
patroza merged commit a76069b into fork/dev Aug 14, 2026
4 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant