Files
hermes-agent/apps/desktop/e2e/core
Teknium 167e9fdb84 fix(desktop): never idle-reap a local profile backend (#134942)
* 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).
2026-10-08 00:54:37 -07:00
..

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 real hermes 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.ts asserts:

    • 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.delta equals what the provider streamed, and message.complete equals the final completion.
    • one live socket per backend process (#120006). switch-back-race.spec.ts forces both orders of "reply completes" vs "the switch-back REST hydrate resolves" with gates (no sleeps) under the same oracle. reply-settle-paths.spec.ts forces 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.ts runs 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.ts starts 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 -9 backend → exactly one supervised respawn and a working turn; quit mid-turn with a running tool child → zero sandbox processes (/proc census on the sandbox HERMES_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 -q with compression.in_place: false on the same HERMES_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 through preserveLocalPendingTurnMessages (#121088).

  • Remote topology — remote-topology.spec.ts: the whole app on a remote hermes 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 in connections.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 annotated fidelity because 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 the electron-builder --dir output (#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 unless HERMES_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.