Skip to content

perf(agents): parse activity records once per snapshot - #1449

Merged
kalvinnchau merged 1 commit into
mainfrom
perf/request-activity-cache
Oct 26, 2026
Merged

kalvinnchau merged 1 commit into
mainfrom
perf/request-activity-cache

Conversation

@kalvinnchau

@kalvinnchau kalvinnchau commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Problem

requestWork runs inside per-message-row activity selectors: ActivityAccessory's hasWork and MessageRow's useThreadAgents. The agent activity store notifies subscribers synchronously on every admitted observer frame, about once per agent per second. Each requestWork call reaches requestActivity, which runs three passes over the agent's data:

  1. activityRecords JSON.parses every retained record of each agent active in the channel.
  2. It pretty-stringifys each matching batch child.
  3. It parses each of those children again.

Both selectors return primitives, so React bails out, but only after that work runs. The cost therefore scales with loaded rows × retained records × frame rate. It only applies while the viewed channel contains an active agent's records. Because the tree stays mounted, it also continues while the window is hidden.

Change

Snapshot record arrays are frozen and replaced whenever records change. requestActivity now caches the parsed agent/channel projection in a WeakMap keyed by that array, so all rows share one parse per snapshot. Request matching, turn selection and terminal classification are unchanged and still run per call. The cache is released together with the snapshot.

Evidence

Setup

  • Packaged release bundle (tauri build --bundles app) on a disposable macOS 26.6 VM (6 vCPU), with an owned local relay and a generated identity.
  • 8 synthetic agents. Each, in its own channel and per second, sends one NIP-44 observer batch frame (12 children × 900 B) and a working-typing event every 3 s. It also sends a message every 15 s.
  • Per-process CPU comes from proc_pid_rusage deltas, covering the native host, the WebKit WebContent, GPU and Networking processes, and WindowServer.
  • Typing latency is measured from the OS key post to the next presented frame (150 keys). Channel switch is measured from click to rows.

Reconciliation

  • Every generated event was reconciled against relay acceptance, app socket delivery and relay persistence where applicable.
  • Across 11 rounds: 36,740 observer frames, 12,243 typing events and 2,453 messages.
  • Zero rejected, zero unacknowledged after drain, and zero undelivered.

Attribution

  • Interleaved ablations on one binary: base ×3, plus ×2 each of 1 Hz activity tick off, plugin catalog poll off, agent runner tick off, and requestWork short-circuited.
  • Only the requestWork ablation moved foreground cost.
  • With 8 agents it cut WebContent CPU from 32.5% to 13.9% of a core while viewing an active channel, and from 27.6% to 8.7% while hidden.
  • It cut typing p95 from 95 ms to 33 ms.
  • With requestWork ablated, the viewed active channel cost the same as a quiet-channel control (13.3%).

Ablation: requestWork per-row re-parse owns viewed-channel CPU and typing tail

A/B

  • This change was compared with the same probe build without it, alternating builds over 3 rounds each, under the same workload and with the same reconciliation (zero rejected, unacked or undelivered).

WebContent CPU, as % of one core (means):

Window Before After
4 agents, viewed 21.0% 11.1%
8 agents, viewed 32.3% 15.0%
8 agents, hidden 27.5% 10.3%
8 agents, foreground return and drain 32.8% 14.6%
  • Total app CPU (WebKit and native) with 8 agents viewed: 38.8% → 23.4%. Hidden: 31.4% → 15.5%.
  • Typing p95: 94–108 ms → 27–33 ms.
  • Channel switch p95 with 4 agents: 161 → 91 ms.
  • The quiet-channel and idle controls are unchanged: 13.1% vs 13.3% and 0.8% vs 0.9%. IPC is identical at 10.2 invokes/s.
  • While visible, native and WindowServer CPU rise slightly (8 agents: +1.3 and +3.9 points). Their combined total with the app still drops from 51.8% to 40.3%.

A/B: WebContent CPU, typing p95 and channel-switch p95 with and without this change

Validation

  • src/bundled/agent-activity/request-activity.test.ts: a new case asserts that repeated requests against one snapshot parse no further records, and that a newer record array is not served stale. Without the change it fails (12 parses vs 4); with the change it passes.
  • pnpm typecheck, design:typecheck and design:check pass. biome check passes on changed files.
  • Local full vitest run was not green: 30 of 10,922 tests timed out at 5 s while the machine was under parallel load. All 14 affected files pass when rerun, but that rerun does not establish a full pass, so hosted CI is the package receipt. Hosted CI is green. One WebKit browser-journey shard failed once in agent-search-chat.spec.mjs (agent search choices, a path that does not touch activity parsing) and passed on rerun. Locally the test passes 22/22 under --repeat-each.
  • Packaged-app workflow on both builds: an agent request in the viewed channel with a linked turn (turn_started carrying triggeringEventIds, 20 s of activity, then turn_completed). In both builds the request row shows the working accessory during the turn and clears it after completion. An earlier, completed request is not re-associated.

requestwork runs in per-row activity selectors on every observer frame.
each call re-parsed every retained record of the channel's agents,
re-stringified each matching batch child and parsed it again, so the
cost scaled with loaded rows times retained records. snapshot record
arrays are immutable, so cache the parsed agent/channel projection in
a weakmap keyed by the record array.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Kalvin Chau <[email protected]>
@kalvinnchau
kalvinnchau marked this pull request as ready for review October 21, 2026 20:40
@kalvinnchau
kalvinnchau requested a review from a team as a code owner October 21, 2026 20:40

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket (systems/performance), Mantis (correctness/security), and Groot (product/accessibility) reviews.

No actionable findings in the two-file change. Source assessment: 9/10 for minimalness, elegance, and correctness.

Reviewed head e4538a66130f9f18128aa4ff509fd0719a339982 against base/merge-base 9366357512d18b5b43c6b86d401a0a9cf8ee4eb9.

The cache matches the current production ownership contract: activity records are frozen, and admission, trimming, reset and access changes replace the records array. Per-request selection and lifecycle classification remain fresh. I traced the shared projection through requestWork, the message accessory and thread indicators, including error/stop/dismiss and focus-removal paths; I found no introduced behavioral regression. The new test covers reuse across requests and invalidation on a new array. Agent/channel separation remains supported by observerFrame’s fixed-hex agent validation and the existing channel filtering.

The public PR description, both attached performance charts, and feature commit metadata were inspected; no disclosure finding.

Validation limits: source-only review, with no tests, builds, app launches or benchmark execution. The PR’s packaged-app measurements and test/CI results are author-reported evidence, not independently reproduced or verified here. Runtime performance, heap/GC behavior, rendered accessibility/focus and human acceptance remain unverified by this review. This COMMENT is not approval or merge authorization.

@kalvinnchau
kalvinnchau merged commit c75ef68 into main Oct 26, 2026
69 of 71 checks passed
@kalvinnchau
kalvinnchau deleted the perf/request-activity-cache branch October 26, 2026 16:20
johnmatthewtennant added a commit that referenced this pull request Oct 26, 2026
…r-emoji-size

* origin/main: (26 commits)
  perf(profiles): notify once per batch of owner attestations (#1650)
  feat(pi): load Pi durable extensions and providers from Pi packages (#1610)
  Teach agents memory links and source metadata; frame memory as information (#1562)
  Make avatar choices visible in agent creation (#1631)
  fix(pi): take plugin wakes instead of ignoring them (#1612)
  perf(agents): parse activity records once per snapshot (#1449)
  perf(terminal): parse hidden terminal output in slices (#1459)
  perf(composer): reuse the document projection for editor reads (#1463)
  perf(read-state): load peer journals without a strict writeback (#1448)
  perf(channels): keep openLink stable across tab changes (#1525)
  fix: name the Cargo package Buzz so the dev build shows "Buzz" (#789)
  test(browser): re-type a dropped page name in the search palette (#1635)
  test(browser): fix two main CI timing failures (#1567)
  feat(agents): deliver Attention watches and timers to conversations (#1578)
  fix(inbox): stop listing tag-only composer state as drafts (#1531)
  Choose Builderlab agent models at creation and in Settings (#1533)
  feat(communities): sync known communities through Builderlab accounts (#644)
  Tag the last-tagged agent when a Solo chat composer is shown empty (#1615)
  perf(usage): read Claude usage once, then only what changed (#1586)
  feat(chats): ask to share access when sending a chat link into a DM (#1614)
  ...
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.

2 participants