Skip to content

perf(tray): apply an unchanged listing without rebuilding (event data plane M2a) - #370

Merged
ufna merged 6 commits into
mainfrom
work-ba0ed9fd-m2a
Oct 27, 2026
Merged

ufna merged 6 commits into
mainfrom
work-ba0ed9fd-m2a

Conversation

@ufna

@ufna ufna commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Event data plane M2a (epic zerus-604d, task zerus-604d.2). Stacked on #369 (stream M3+M4); the M2a change is the last commit.

What changes

  • Listing digest: each listing carries a digest prepared off the GUI thread (in the poll's prepare step and in the stream worker). It masks rows' goal_observed_at and process_work.sampled_at, plus metrics and desktop_presence.
  • Light path: when the digest and the stored read state are unchanged, the tray only refreshes poll times, metrics and background observations. It still updates automatic approvals, attention, the connection state and the icon.
  • Sessions: rows keep their entries unless:
    • the clock changes their text (SessionPresentation::textChangesAt, e.g. retry countdowns, waits, scheduled runs, midnight),
    • a peer crosses the staleness age,
    • or the selected session's latest inspection would merge differently.
  • Lazy pages: Accounts and Machines keep their data but render only while shown, as Sessions and Overview already do.
  • Tray menu: rebuilds only when its signature (everything it shows or binds) changed.
  • Organization: serialized and sent to the swarm only when its revision changed.
  • Unread: read replies, attention marks and pending reads are applied in one pass (FleetState::setReadState). A test checks it matches the former three passes on mixed fixtures.
  • The design spec now describes M2a and the watcher's incremental listing.

Measurements (arch, Release, offscreen, 35-row real listings, medians of 40 × 3 runs)

GUI-thread CPU per listing before after
window shown, unchanged 5.40–5.62 ms 0.36–0.41 ms
window shown, changed 5.42–5.65 ms 4.08–4.33 ms
window hidden, unchanged 1.98–2.06 ms 0.31–0.32 ms
window hidden, changed 2.00–2.01 ms 1.34–1.37 ms

The remaining changed-listing cost is mostly SessionsWindow::rebuild (about 3 ms), which is M2b's job.

Verification (arch)

  • Full Qt suite with ZERUS_GUI_THREAD_CHECKS=fatal: 84/84 suites, 3021 test functions, 0 failed, 23 skipped (platform-only). Run independently by the subagent and the reviewer.
  • 14 new tests across test_hgsclient, test_fleetstate, test_traymenu, test_accountspage, test_machinespage, test_workspace, test_eventstream and test_sessionswindow.
  • test_source_* 26 OK; check-source.py 0 privacy findings.

Behaviour changes to note

  • Rows used to re-render every 2 s, which hid state changes that lack their own trigger. This PR covers three: clock-dependent text, the selected row's inspection merge, and the worktree cleanup offer. Anything else of that kind now waits for the next changed listing or a user action.
  • A failed swarm apply is retried by the controller's own 5 s poll rather than on every rebuild.
  • Status semantics (Working, Background, Scheduled, Needs attention, unread reply identity) are unchanged.

🤖 Generated with Claude Code

ufna and others added 6 commits October 27, 2026 16:52
Event data plane M3 (zerus-604d.3). `hgs [@host] watch --json` is a
long-lived NDJSON stream: hello, a bounded catalog snapshot of the live
listing (rows exactly as ls --json --local --no-archives, keyed run:<run_id>),
deltas with removal reasons, catalog.meta, and Activity subscriptions over
stdin whose cursor is the events.sqlite3 seq that inspect --after takes.
Heartbeats, gap/snapshot resync, a 4 MiB coalescing writer queue and 1 MiB
frames bound it; EOF on stdin ends it. `hgs -h` prints the marker
`Event stream ABI: watch-v1`; @host runs it over one SSH connection with
keepalives, guarded so an older peer never reads it as a launch.

Change detection uses no new dependency: a 250 ms pulse stats the listing's
inputs and polls PRAGMA data_version on a held journal connection, with a 5 s
safety listing for changes no file shows. Volatile observation fields are
masked, so deltas carry real changes only. The serde frame types live in
crates/watch-protocol (no Loro or Qt) for the desktop, the Tauri client and
later the connector. The inspection's journal query is shared, not forked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Event data plane M4 (zerus-604d.4). EventStream runs one
`hgs [@host] watch --json` per computer whose CLI prints the watch-v1
marker. The child, NDJSON framing, JSON parsing and the computer's catalog
and Activity store live on the stream's own thread; the GUI thread receives
the same BoxState a listing poll delivers and inspect-shaped Activity
increments, so the existing apply paths stay unchanged.

While a computer's stream is live, the tray skips its periodic listing polls
and the Sessions window follows the selected session's Activity through it,
polling only the Processes section at its own interval. Listings are applied
at most every 2 s until M2 makes applying one cheap; Activity within 50 ms.
A failed or silent stream restarts after 5 s, 15 s, 60 s and then every
5 minutes, and polling continues meanwhile. A computer without the marker
keeps polling. ZERUS_EVENT_STREAM=0 or dataPlane/eventStream=false opts out.

On arch, following a busy session: event lag p50 0.20 s, p90 0.28 s against
2.34 s and 2.70 s for today's 2.5 s inspect polling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A loaded macOS runner produced one heartbeat in the fixed 180 ms the test
slept (PR #369, CLI unit job). Both writer tests now wait for the expected
frames with a 5 s deadline instead of sleeping a fixed time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zerus-604d.6 and zerus-604d.7. The watcher re-derives only a session whose
binding changed or whose journal rows arrived, through the listing's own row
steps under the same lock, and lists all rows again when anything else
changes and every 5 s. A 5 s probe covers what no file shows: tmux sessions
that start, end, are renamed, attached or detached, exited agent processes,
ended background work, sessions without a hook event yet, and DeepSeek and
OpenCode hosts.

A listing captured each live pane several times: once for the effort footer
and once per eligible terminal chooser (usage limit, resume, Kimi cache hint,
approval), 62 tmux capture-pane calls on arch for 35 panes. Inside the
read-only status observation those readers now share one capture per pane;
answers, resizes and key sends still capture afresh.

On arch: a listing dropped from about 0.25 s to 0.21 s CPU, and the watcher
from 12.9 % to 6.1 % of a core over 60 s. With a 15 s whole listing it
measured 4.3 %, but a chooser without a hook would show up to 15 s late;
that tradeoff waits for the owner. ls and inspect output are unchanged
(105/105 stable rows and 831/831 stable inspect fields compared).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Windows fake CLI runs Python scripts; a /bin/sh fake never answered there,
so the stream failed instead of reporting Unsupported (#369 Windows job).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Event data plane M2a (zerus-604d.2). Each listing now carries a digest,
computed off the GUI thread with the observation fields masked
(goal_observed_at, process_work.sampled_at, metrics, desktop_presence).
When a listing matches the last one and the stored read state is unchanged,
the tray only refreshes poll times, metrics and background observations;
Sessions keeps its rows unless the clock changes their text, a peer crosses
the staleness age or the selected session's inspection reads differently.

Accounts and Machines keep their data but render only while shown, the tray
menu rebuilds only when what it shows changed, the organization is
serialized and sent to the swarm only when it changed, and read replies,
attention marks and pending reads are applied in one unread pass (a test
checks it matches the former three passes).

On arch (35 rows, window shown): 5.5 ms -> 0.36 ms of GUI-thread time per
unchanged listing and 5.6 ms -> 4.1 ms per changed one. The design spec
also describes the watcher's incremental listing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ufna
ufna force-pushed the work-ba0ed9fd-m2a branch from 1e9138f to 5a6e059 Compare October 27, 2026 15:24
@ufna
ufna merged commit dc29d5b into main Oct 27, 2026
28 checks passed
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