Skip to content

fix(desktop): bound the in-flight turn journal and stop rewriting it every 400ms - #80872

Closed
royalaid wants to merge 9 commits into
NousResearch:mainfrom
royalaid:upstream-pr/inflight-journal-bounded
Closed

fix(desktop): bound the in-flight turn journal and stop rewriting it every 400ms#80872
royalaid wants to merge 9 commits into
NousResearch:mainfrom
royalaid:upstream-pr/inflight-journal-bounded

Conversation

@royalaid

@royalaid royalaid commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Stacked on #80866 and #80870 — review the last commit only (fix(desktop): bound the in-flight turn journal ...).

What

The crash-recovery in-flight turn journal (inflight-turn-journal.ts) was the dominant source of desktop UI hitching under streaming, especially with concurrent threads. While any session is busy, every 400ms per busy session it synchronously, on the renderer main thread:

  1. getItem + JSON.parse of the entire single-key store (all sessions' journaled turns, up to 24 entries kept 7 days),
  2. deep-cloned the unbounded turn tail via JSON.parse(JSON.stringify(...)) — full tool-call bodies included,
  3. re-JSON.stringifyed everything and setItemed the whole blob back.

On a real profile the blob had grown to ~12MB (agentic turns journal full tool results; old sessions ride along for a week). A DevTools Performance trace of live hitching showed ~2s of a 10s window inside this timer: repeating ~260–310ms tasks ending in setItem, ~1.9s of JSON serialization self-time. Cost multiplies per concurrent busy session — which is why multi-thread streaming felt so much worse. A 12MB value also exceeds typical localStorage quota, so writes were likely failing silently (errors are swallowed) after paying full serialization cost: all the jank, possibly no actual recovery.

The rewrite (recovery/merge semantics preserved verbatim, all pre-existing tests unchanged and passing):

  • Bounded schema-aware projection built before any clone: user prompt kept in full (recovery matching normalizes and compares it) + attachment refs; assistant text ≤64KB/part; reasoning ≤16KB/part; tool calls reduced to id/name/error + 2KB arg/result previews. 256KB hard entry budget enforced structurally (never byte-slicing JSON), single stringify.
  • Write-only hot path to per-session keys (…journal.v2.<sessionId>): no read-before-write, no aggregate in-memory cache (multi-window coherence — secondary/peer windows share the storage partition). Reads happen only during recovery and the boot sweep.
  • One-shot boot migration: v1 entries are projected onto bounded v2 keys (new-key-wins), then the v1 blob is deleted unconditionally — including on parse failure.
  • Boot sweep enforces the 7-day / 24-entry global bound, replacing the per-write filter/sort/slice.
  • Quota failures now warn once per session and are recorded; kill switch (…inflightTurnJournal.disabled = '1') for A/B diagnosis; new journal_write diagnostics ring event (durationMs/bytes/outcome/busySessions).

Result

Verified before/after on the same machine and workload (two threads streaming):

Metric (DevTools trace) Before After
localStorage.setItem self-time 394ms / 10s 1ms / 34.5s
~277ms journal timer tasks 7 in 10s 0
Renderer long-task time share ~38% ~7%

The design was cross-checked by two independent external model reviews before implementation (both confirmed the mechanism; the write-only no-cache design and the explicit legacy-blob policy come out of that review).

Related issues

Testing

vitest 24 journal tests (all pre-existing recovery-semantics tests unchanged) + new: 5MB tool-result fixture bounds under budget with tool identity and recovery merge intact; 100KB user prompt kept whole; migration (normal/oversized/expired/garbage v1 blobs, new-key-wins, unconditional v1 deletion); quota-throw resilience (streaming unaffected, warn once, later writes recover). Full session-hooks suite: 442 tests passing. tsc and eslint clean.

royalaid and others added 8 commits August 6, 2026 23:55
Production-buildable ring-buffer capture: LoAF observer core extracted from
the dev-only profiler, live IPC arm/disarm, per-flush stream_delta_applied
events reusing the existing commit-cost rAF measurement, record-time
sanitization (numbers/counts/durations/IDs only). Zero observers when
disarmed. 14 new tests; 94 total green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause: a desktop app launched from inside a delegate_task child's
terminal inherits HERMES_DELEGATED_CHILD_CONTEXT=1, poisoning the long-lived
serve process — connect()'s migration pass hits the mutation guard and the
dashboard event stream dies and reconnects every few seconds forever.

Fix: server-role entrypoints (serve/dashboard/gui/desktop/gateway run) drop
the inherited marker at startup; agent-role argv keeps it and real child and
grandchild lineage is still rejected (guard assertion unchanged). The event
stream now closes terminally (4004, once-per-process log) on PermissionError,
and the dashboard client stops reconnecting on that code with a non-auth
error string.

Tests: 43 new across dashboard-plugin and delegate-isolation suites; 185-test
regression sweep green. One pre-existing unrelated failure in
test_kanban_attachments (Windows path mangling), verified identical with the
change stashed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Capture controller with bounded main ring (unresponsive/responsive,
app.getAppMetrics sampling while armed, sanitized hermes:api transport
errors), renderer arm/disarm/collect IPC over the preload bridge with
per-window timeout, and an authenticated gateway pull client
(POST /api/diagnostics/arm|collect|disarm) that degrades to an absent
stream on remote-gateway/unsupported/unauthenticated. 27 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While a capture is armed the CF-1 heartbeat tightens to 20Hz and records
every loop-drift sample >=250ms into a bounded in-memory ring, with an
off-loop watchdog thread attaching a sanitized frame summary (module/
function/line) once per stall episode — closing the 5s blind spot that made
the complaint-day hitch unattributable. ws-write-slow events join the ring
with per-capture HMAC'd peer ids. Served over the existing authenticated
/api/diagnostics/arm|disarm|collect routes (401 unauthenticated; 409 on
mis-sequenced collect). Zero overhead disarmed. 23 new tests; 250-test
regression sweep green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nose (U4)

Stop & export writes a local bundle under userData: per-stream JSONL,
manifest with basename-only process tree (rebuilt fields — argv can never
pass through), and classification.json labeling the capture renderer/
gateway/IPC/memory/history-bound via threshold heuristics. Minimal
Diagnostics settings section (start/stop/status/bundle path). New
'hermes debug diagnose' subcommand records the PID tree and an optional
time-bounded WPR trace into a separate unsanitized directory labeled
unsafe-to-share. Docs entry added. 48 electron + 18 python tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real harness-launched run injects renderer long tasks (CDP busy loop) and
gateway loop blocks (env-guarded test route, absent unless
HERMES_DIAGNOSTICS_TEST_HOOKS=1 at registration) and asserts the exported
bundle's classification: renderer-bound, gateway-bound at 6s AND at 3x
~0.6s sub-threshold blocks (CF-1 blind spot closed), and combined
multi-label. 5/5 cases pass; 33 gateway tests green. Scenario pins the
spawned backend to this checkout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ures

A two-thread capture showed sessions:1 on every stream_delta_applied event
while the long-frame rate doubled: ordering-sensitive gateway events
(tool.start/complete, message.interim/complete, moa, error) drain the delta
queue eagerly, outside the instrumented timer flush, so the second thread's
apply cost was invisible — and per-event commits (tool-row upserts, subagent
progress, session.info patches, terminal chunks) never touch the queue at
all.

Instrument both gaps:
- stream_delta_applied gains path ('eager'|'timer') and busySessions (turns
  in flight at record time — counts tool-only turns the queue never sees);
  eager drains are now recorded with their drain cost.
- new gateway_event_applied event times every gateway-event dispatch and
  records type tag + duration (>=4ms floor) + busySessions, covering the
  unqueued commit families by construction.

Sanitization unchanged: counts, durations, and type tags only. Disarmed
cost stays one boolean test per flush/event. Classification heuristics
untouched — eager rows flow into the existing rendererStreamDeltas count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…every 400ms

A DevTools trace of live hitching showed ~2s of a 10s window inside the
journal's timer: every 400ms per busy session it re-read and JSON-parsed the
entire single-key store (~12MB on this profile — over localStorage quota, so
writes were likely failing silently after paying full serialization cost),
deep-cloned the unbounded turn tail with full tool bodies, re-stringified
everything, and setItem'd it back — 250-400ms synchronous main-thread stalls,
multiplied by concurrent streaming threads. Cross-checked by two independent
external reviews; both confirmed the mechanism.

Rewrite the storage layer, preserving recovery semantics:
- Bounded schema-aware projection built before any clone: full user prompt
  (recovery matching needs it) + attachment refs, assistant text <=64KB/part,
  reasoning <=16KB/part, tool calls reduced to id/name/error + 2KB previews;
  256KB hard entry budget enforced structurally, single stringify.
- Write-only hot path to per-session keys (v2.<sessionId>): no read before
  write, no aggregate cache (multi-window coherence), per-session timers kept.
- One-shot boot migration: project v1 entries onto v2 keys (new-key-wins),
  then delete the v1 blob unconditionally — including on parse failure.
- Startup sweep replaces the per-write filter/sort/slice for the 7-day /
  24-entry global bound.
- Quota failures warned once per session and recorded, never thrown into
  streaming; kill switch key ...inflightTurnJournal.disabled=1 for A/B.
- New journal_write diagnostics ring event (durationMs/bytes/outcome/
  busySessions) so captures attribute journal cost directly.

Tests: 5MB tool-result fixture bounds under budget with recovery intact,
100KB prompt kept whole, migration (normal/oversized/expired/garbage),
new-key-wins, quota-throw resilience; all pre-existing recovery semantics
tests unchanged and passing (47 journal+diagnostics, 442 session-hook).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alt-glitch alt-glitch added type/perf Performance improvement or optimization comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 7, 2026
Five review findings, each verified then fixed:

- previewOf ran JSON.stringify on object-valued tool args/results before
  truncating, fully serializing multi-MB structures every tick. Replaced
  with boundedJsonPreview: traversal and emission share one 2KB budget
  (stops at the cap, slices giant strings pre-escape, depth 6, cycle-safe,
  getter-throw-safe). Preview output was never parsed back — consumers
  already try/catch with {} fallback.
- The 256KB entry budget was not re-checked after the tight re-projection;
  a giant user prompt could still produce multi-MB writes. New 512KB hard
  ceiling: still-over writes are skipped with outcome 'oversize' in the
  journal_write ring event (prompt stays untruncated per the recovery-
  matching contract).
- Recovery's getItem was outside any failure boundary; a storage read
  error now degrades to "no journal" instead of breaking session resume.
- The 24-entry global cap was boot-only; a long-lived renderer can now
  trigger one extra sweep when a new session key pushes the distinct
  count past the cap (never on the per-tick hot path).
- The kill switch ran migration + sweep before disabling; ensureBooted now
  returns right after the flag probe, making it the subsystem's only
  storage access when disabled (legacy blob preserved for revert).

Tests: Proxy-counted 200k-element result proves bounded traversal (<1000
element reads); oversize-skip, guarded-read, runtime-cap, and kill-switch
no-op coverage. 663 tests across src/lib + src/diagnostics passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@royalaid

royalaid commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up from an external adversarial review — all five findings verified and fixed in the new head commit (fix(desktop): harden journal bounds per external review):

  • P1: previewOf fully JSON.stringifyed object-valued tool args/results before truncating. Replaced with a bounded walker where traversal and emission share one 2KB budget — a Proxy-instrumented 200k-element fixture proves it touches <1000 elements where a full stringify touches all of them.
  • P1: the 256KB budget wasn't re-checked after the tight re-projection. New 512KB hard ceiling: still-over entries skip the write entirely, recorded as outcome oversize in the journal_write diagnostics event (the user prompt stays untruncated per the recovery-matching contract).
  • P2: recovery's getItem is now inside a failure boundary (storage read failure degrades to "no journal", never breaks session resume).
  • P2: the 24-entry global cap is now also enforced at runtime (one extra sweep when a new session key pushes past the cap — never on the per-tick hot path).
  • P2: the kill switch now short-circuits before migration and sweep; when disabled, the flag probe is the subsystem's only storage access.

663 tests passing across src/lib + src/diagnostics.

@marcatmadu

Copy link
Copy Markdown

As the Windows reporter of #63047, I'm willing to test this PR on Windows (with renderer logging + crash dumps) once the maintainers have chosen the preferred scope between this and #82832. Just let me know which build to try.

@royalaid

royalaid commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favor of #82832.

Both PRs address the same in-flight journal hot path, but #82832 provides the narrower and cleaner per-session persistence and recovery implementation. The broader diagnostics work from this branch can remain independent and build on top of #82832 rather than carrying a competing journal implementation.

@royalaid royalaid closed this Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/perf Performance improvement or optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Desktop app becomes completely unresponsive (including Settings) after ~5 messages on macOS 27 beta

3 participants