* fix(desktop): never idle-reap a local profile backend Support f502bc6f: with a remote primary, a "This device" profile's local `hermes serve` child was SIGTERM'd by the pool idle reaper after 60 min with nothing streamed (the #105239 pinned-tier TTL), leaving "This device · Backend offline" while the user still had the chat open. A local backend runs its profile's cron jobs and bot chats with nobody watching, so idleness is not a reason to kill it. The idle reaper now only reclaims connection descriptors (remote routes: no child, no slot). Local children are still bounded by the maxBackends slot cap and foreground reclaim (activeTurn vetoed), unchanged. Removes POOL_PINNED_IDLE_MS. The Backend Idle Timeout setting description now says it applies to remote connections only. E2E no-idle-reap.spec.ts: a pooled local profile backend survives two reaper ticks past both idle windows. Fails on main (backend reaped), passes here. * chore(desktop): drop the dead pinned-tier idle policy (retireIdle, lastStreamedAt) Greptile P2 on #134942: with local children no longer idle-reaped, retireIdle has no production caller and lastStreamedAt no reader. Removes both, the stamp write in touchPoolBackend, and the pinned-tier test. The shared retirement safeguards stay covered through evictTo (identity, admission permit, activeTurn veto).
Desktop core suite (required CI lane)
A small, deterministic Electron suite that guards three issue classes end to end:
-
C2 transcript integrity —
transcript-integrity.spec.ts: one real app + one realhermes serve, only the LLM faked (provider.ts, scripted per turn by a unique marker, every streamed chunk recorded). After every transition — stream, tool-call turn, reasoning turn, steer, queued follow-up, session switch mid-stream, warm resume, reload, WebSocket drop + reconnect mid-stream, a non-default profile's chat, a forced second socket to the same backend, final reload —oracle.tsasserts:- every persisted user/assistant message is rendered exactly once, in order, and nothing unpersisted is rendered;
- no marker is ever rendered twice, even transiently (in-page MutationObserver sampler — the #120005 garble healed on its own in the final DOM, so a final-state check alone misses it);
- no message is ever rendered out of order, even transiently: the same sampler fails a reply above its own prompt and two messages that swap places between frames;
- backend stream integrity: each turn's concatenated
message.delta/reasoning.deltaequals what the provider streamed, andmessage.completeequals the final completion. - one live socket per backend process (#120006).
switch-back-race.spec.tsforces both orders of "reply completes" vs "the switch-back REST hydrate resolves" with gates (no sleeps) under the same oracle.reply-settle-paths.spec.tsforces the shapes duplicate-reply reports describe: an answer the backend repeats as the final reply (with a review.summary row delivered before its message.complete, #131626), a steer at each point of a tool turn, then reload and switch-back over the folded tool turns.switch-back-race.spec.tsruns both orders with a tool turn too (history folds it under its first row, #128809).behind-window-send.spec.ts: two windows on one chat, window 2 deaf to window 1's turn pings; window 2's send from behind goes through with no "Chat out of date" refusal, the model's request carries window 1's turn, and both windows converge under the oracle.onboarding-first-chat.spec.tsstarts from a fresh home with no provider: the real onboarding (custom endpoint → the fake provider's URL), then the first chat, a second turn and a reload under the same oracle, plus persisted config == entered endpoint and one live socket afterwards.
-
C20 interactive prompts —
interactive-prompts.spec.ts: clarify (one card; the clicked choice is exactly what the model receives), approval Run once (the command runs only after the click) and Deny (never runs; the turn still completes), approvals in manual mode. -
C5 boot / process lifecycle —
boot-lifecycle.spec.ts: interactive composer + first turn, exactly one backend;kill -9backend → exactly one supervised respawn and a working turn; quit mid-turn with a running tool child → zero sandbox processes (/proc census on the sandboxHERMES_HOME, so orphans reparented to init are counted); relaunch the same home 3× → one backend per boot, zero after each quit, transcript cold-hydrates once. -
Session lineage —
lineage-sidebar.spec.ts: a branch child is born titled (#121062) and is its own sidebar row; switching branch ↔ parent (3 round trips + reload) never renders the other session's turn or a duplicate part (in-page sampler).lineage-rotation.spec.ts: REAL rotated compression rows (hermes chat -qwithcompression.in_place: falseon the sameHERMES_HOME, compacting against the fake provider), then the Desktop shows one row per lineage live, after rotations, after reload and after a cold relaunch (#121148).lineage-compaction-prompt.spec.ts: a redirect prompt acknowledged mid-turn whose turn then compacts; the refresh passes throughpreserveLocalPendingTurnMessages(#121088). -
Remote topology —
remote-topology.spec.ts: the whole app on a remotehermes serve(HERMES_DESKTOP_REMOTE_URL+ token): a client-only image is shipped as bytes, never as a client path (#120730, env-remote shape); remote backend restart keeps the session.remote-secondary.spec.ts: the Bot-Mode shape of #120730 — local primary backend + a remote secondary connection inconnections.json, the chat owned by the remote connection. Both hide the client's picture folder from the backend with a private mount namespace (unprivileged user namespaces; without them the test is annotatedfidelitybecause a same-host path would resolve on the backend).fleet-condensed-default.spec.ts: local primary + a remote connection with enough profiles to condense the sidebar rail; the profile menu lists the ACTIVE gateway's default (checked, home glyph) on both sides of a switch (#106017, #131632). -
Packaged build —
packaged-smoke.spec.ts: asarUnpack contract of theelectron-builder --diroutput (#121097) and the packaged binary booting to a first chat (≤60 s to interactive, main-process log tail on failure). It runs this checkout's Python backend, so it proves the packaged shell and renderer, not a bundled runtime. Skipped without a build unlessHERMES_E2E_REQUIRE_PACKAGED=1.
Known open bugs (known.ts): a scenario that reproduces an OPEN issue keeps
its correct assertion as the test's LAST assertion via expectNoSymptom. When
it fails with that bug's own message the test is marked expected-failing at
run time (annotation known-bug); any other failure is a real failure; a
clean pass is a pass, so a fix merging first never turns main red. Remove the
KNOWN entry once the fix lands.
Rules the suite keeps (why the old lane was disabled): no fixed sleeps as
synchronisation (every wait is on a frame, pid, DOM state or persisted row
with a deadline; the only timed waits are bounded observation windows for a
symptom sampler), no shared mock state between scenarios (replies are keyed
by the turn's own marker), no visual baselines, retries: 0, one worker,
sandboxed HOME/HERMES_HOME/user-data per test, all HERMES_* and
credential env stripped from the spawned app.
Run locally (Linux, after npm run build in apps/desktop):
cd apps/desktop
xvfb-run -a npx playwright test -c e2e/core/playwright.config.ts
HERMES_E2E_CORE_ROOT picks the sandbox parent dir (default: OS tmpdir);
HERMES_E2E_CORE_KEEP=1 keeps sandboxes for post-mortem.