Skip to content

Agent host: measure, then move runner + Store into a utilityProcess #1661

Closed

Description

@currentbits

Split out of #418, where the analysis is in the comments.

Why a thin agent host isn't enough: a utilityProcess that only owns the provider child processes would take over just the stdout line splitting and JSON.parse (electron/agent.js runJsonLines). Every event still has to come back to main by structured clone, which costs about the same as the parse. The work main actually does per event is the runner's Store work: message upserts, the 250 ms partial saves and broadcasts (see #1475 and scripts/perf/store-stream-bench.js). Crash isolation is already there, because the agent CLIs run as separate OS processes. Sending events straight to the renderer over a MessagePort would skip the Store, and the Store owns the persisted transcript.

Step 1, measure first. Before any refactor, show that main is actually unresponsive under agent churn:

  • Run N concurrent fake stream-json agents (the smoke.js pass C fake works) against a copy of the real store.
  • Record main event-loop lag with perf_hooks.monitorEventLoopDelay (p50/p99/max), plus IPC round-trip latency for a cheap invoke such as threads.list.
  • If p99 lag stays under about 50 ms at realistic concurrency (5–10 agents), close this issue.

Step 2, only if step 1 shows real lag. Move runner + Store into one utilityProcess.fork host:

  • ipc.js and services.js reach the Store through a proxy.
  • The renderer gets a MessageChannelMain port for thread-detail pushes.
  • This touches most of electron/. Plan it as its own staged roadmap.

Activity

  1. added and removed on Oct 18, 2026
  2. currentbits commented on Oct 19, 2026

    @currentbits
    OwnerAuthor

    Step 1 results: main is not lagging. Closing per the issue's own criterion.

    Bench: scripts/perf/agent-churn-bench.js (commit 1b574870, branch coder/agent-host-measure-then-move-runner-stor-d995a2). It uses the same wiring as electron/smoke.js: real Store, runner, registerIpc, and a sandboxed window on preload.js. It runs against a /tmp clone of the live store: 3.2 MiB envelope, 1952 threads, 719 MB of message shards. Each agent is a fake claude CLI that streams stream-json partials through the real runner-provider-claude.js path: about 40 text_delta/s, and a tool_use plus tool_result every ~2 s. The bench measures:

    • main loop delay with monitorEventLoopDelay({ resolution: 1 })
    • main CPU from process.cpuUsage
    • threads.list round-trip, timed in the renderer every 100 ms

    There are 20 s of samples per level, after a 3 s warm-up. Run on an Apple Silicon Mac.

    Fresh threads (agents stream into new, empty transcripts):

    agents loop p50 loop p99 loop max main CPU list p50 list p99 pushes/s
    0 1.3 2.7 7.5 9% 28.4 36.1 0
    1 1.4 4.7 21.0 13% 31.1 49.4 6
    5 1.4 4.5 18.4 16% 32.0 43.1 30
    10 1.3 4.2 10.1 16% 29.2 35.1 59
    20 1.3 4.3 13.9 20% 29.3 33.2 119

    Worst case, BENCH_BIG_THREADS=1 (agents stream into the copy's largest transcripts, 1.9–3.6 MiB shards each; the threads are moved onto a scratch repo in the clone only):

    agents loop p50 loop p99 loop max main CPU list p50 list p99 pushes/s
    0 1.4 2.5 12.7 11% 30.9 40.2 0
    1 1.3 5.4 21.3 15% 30.5 35.2 6
    5 1.3 5.4 36.3 30% 32.2 55.4 29
    10 1.3 5.4 69.9 45% 34.9 82.5 55
    20 1.3 6.3 132.0 67% 37.5 145.8 104

    (All times in ms.)

    Reading:

    • p99 loop lag is 4–6 ms at every level, including 20 agents on the biggest transcripts. That is about 10× under the 50 ms bar, so the step-2 utilityProcess move is not justified.
    • The one thing that does scale is the max stall in the big-thread case, about 6.5 ms per agent. Its cause is Store._snapshotDirtyShards (electron/store.js:786). It JSON.stringifys every dirty shard synchronously inside a single save, so N agents streaming into multi-MB transcripts produce one N × stringify(shard) stall. Moving the Store to a utilityProcess would not remove this. The stall would just move there, and every Store-backed invoke would still wait behind it. The fix belongs in the Store: stagger shard snapshots across ticks, or make shard writes append-only. I've filed that as a separate suggestion.
    • At idle, threads.list takes about 28–31 ms of round-trip with 1952 threads. That cost is constant and does not get worse under churn.

    Not covered:

    • main.js periodic work: git polls, automations, sweeps. The CPU numbers are therefore a floor.
    • The real renderer, which runs in its own process.
    • CI-class hardware.
  3. added and removed on Oct 19, 2026
  4. currentbits commented on Oct 19, 2026

    @currentbits
    OwnerAuthor

    Closed per step 1: p99 main loop lag stayed at 4–6 ms up to 20 concurrent agents, against a 50 ms bar. The numbers are in the comment above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions