Commit Graph

47279 Commits

Author SHA1 Message Date
teknium1
02fc8f7de7 fix(tests): failure-writer ownership probe no longer races a real tirith download
The probe scrubs the environment down to PATH/LANG/LC_ALL/TZ, which also
dropped the harness's HERMES_DISABLE_LAZY_INSTALLS. In a fresh home with no
tirith on PATH (CI), GatewayRunner() then starts tirith's startup install on a
background thread: the PM runtime downloads and unpacks Python and uv from
GitHub while the cases run. When that unpacking overlaps the archive case, its
buffers land in the process-wide tracemalloc window and push the peak past the
8 MiB bound, so the probe reports 19/20.

The loopback fence also missed it: hermes_bootstrap's happy-eyeballs
create_connection dials with connect_ex, so the downloads reached GitHub with
blocked_external_attempts empty.

- set HERMES_DISABLE_LAZY_INSTALLS=1 in the probe environment
- fence socket.connect_ex as well as connect
- assert the receipt records no external network attempts
2026-10-02 04:14:45 -07:00
teknium1
0cdba5ec7e test(memory): migration fixture provider stands in for a formerly bundled one
Unattended consent is limited to providers that left core (#131266), so the
multi-profile fixture's fake provider has to be on that list to exercise it.
2026-10-02 04:09:57 -07:00
teknium1
c687e367c9 fix(memory): warn every profile whose memory provider is unavailable; say "Install declined" for a first install
The unavailable-provider warning was deduped by provider name process-wide,
so a multiplexed gateway or Desktop backend told only the first profile.
It is now keyed by (home, provider).

A first-time install refused at the consent gate said "Reinstall declined
... The installed plugin and active environment are unchanged" although
nothing was installed; migration notices now show that text to users.
2026-10-02 04:09:57 -07:00
teknium1
c5b3d66a97 fix(memory): one profile's refused migration no longer skips the others; hints name the right profile
Unattended consent is each home's own security.allow_lazy_installs, so a
refusal in one profile says nothing about the next: migrate_all_homes now
groups homes by (provider, consent) and only names the rest of a group
when that group fails. A default home with lazy installs off no longer
reports a lazy-on profile as failed while skipping its install.

The "answers apply to all of them" header is printed only when a terminal
can actually ask.

Install hints drop -p only for the default home with no sticky profile: an
agent started for a named profile (Desktop, gateway, hermes -p) printed a
bare `hermes plugins install <name>`, which installs into the sticky
profile instead. The lazy-installs-off startup line uses the same command.
2026-10-02 04:09:57 -07:00
teknium1
a5144c71e9 fix(memory): show every memory-provider migration outcome, ask once per update
Agent-start recovery (recover_at_startup) reported installed / refused / failed /
not-in-catalog only to agent.log, and so did a selected provider that reports unavailable,
so a user with security.allow_lazy_installs: false, offline, or who declined the dependency
prompt silently ran without external memory (#119769).

- Startup outcomes go through _emit_startup_warning: printed in the CLI; other drivers get
  a sticky warn AgentNotice (Desktop toasts notices but drops warn-kind status.update), and
  the messaging gateway, which wires notice_callback per turn, receives it on turn 1.
- hermes update labels each line with the profile it is about and gives the exact
  `hermes -p <profile> plugins install <name>` command for non-active homes.
- Profiles sharing one environment get one set of dependency answers per provider
  (shared_dependency_answers): the first install asks the normal Python/Node questions,
  later homes reuse them; a decline or failure names the remaining profiles once instead of
  failing (or re-prompting) one by one; Ctrl-C ends the migration with a message (#125794).
- The success line no longer names memory.<name> (#124038); docs follow.

Co-authored-by: chelsealong <chelsealong@126.com>
2026-10-02 04:09:57 -07:00
salch-cred
9d794107b3 fix(memory): migration note must not imply config.yaml provider keys still apply
Refs #124038.
2026-10-02 04:09:57 -07:00
funky-xamarin
b602a78faf fix(memory): identify profile homes in update migration notices 2026-10-02 04:09:57 -07:00
funky-xamarin
80c87376db fix(memory): surface startup migration outcomes through warning sink 2026-10-02 04:09:57 -07:00
teknium1
0be2d562b0 fix(memory): limit headless install consent to providers that left core
The unattended consent installs a plugin and its Python deps with no prompt.
It is justified for providers that shipped bundled (the user already accepted
those deps by choosing the built-in), not for any catalog plugin a synced or
cloned config happens to name in memory.provider.
2026-10-02 03:40:17 -07:00
teknium1
423387ede1 docs(plugins): publish_plugin assume_consent covers the migration's lazy-install consent
The docstring still described assume_consent as only the caller's --yes-deps
answer; the memory-provider migration now passes its allow_lazy_installs
consent through the same parameter.
2026-10-02 03:40:17 -07:00
teknium1
084a84b544 fix(memory): migrate deps-bearing providers without a terminal under allow_lazy_installs
Desktop, the gateway and a scripted `hermes update` have no TTY, so the
dependency consent gate refused every catalog provider that declares Python
deps: both migration hooks failed with 'Reinstall declined: dependency install
skipped (non-interactive)' and the provider stayed missing. The agent-start
hook exists precisely for those surfaces.

The configured memory.provider plus security.allow_lazy_installs (read for the
home being migrated) is the consent that let the bundled provider install its
deps on demand; carry it through dashboard_install_plugin ->
install_catalog_entry(assume_deps_consent=...). A terminal still prompts, and
allow_lazy_installs: false still refuses with the install one-liner.
2026-10-02 03:40:17 -07:00
liuhao1024
202bbbe6cf test(plugins): surface the admission refusal cause in the --yes-deps e2e test
An external verification run on Linux saw this test fail with a bare
`assert 'yes-deps-plugin' in []`: the install publishes into plugins/ and
a second PM admission transaction then enables it, and that admission
folds every exception into a refusal (exit 0, plugin installed but
disabled), so the assertion carried no clue about the environment-specific
cause. Verified locally that the mechanism reproduces the exact reported
shape: injecting a failure into the second sync_venv call (the enable
admission) yields the same bare failure while the first (publish) call
succeeds.

Carry the captured CLI output in the failure message so any machine that
reproduces this shows the `Enable ... refused: <cause>` line directly in
the pytest output.
2026-10-02 03:40:17 -07:00
liuhao1024
169e77039a fix(plugins): let --yes-deps answer the consent gate for headless installs
A non-interactive `hermes plugins install <name> --enable` had no path
forward (#122134): the y/N gate for Python deps refuses without a TTY,
the publication of an active replacement is declined the same way
("Reinstall declined: dependency install skipped (non-interactive)"),
and the suggested `hermes plugins enable` recovery cannot publish a
plugin that was never installed.

Add an explicit `--yes-deps` flag (mutually exclusive with `--no-deps`)
that carries the user's own answer through both consent gates: the
enable-time `_consent_python_deps` prompt and the publication-time
active-replacement veto in `publish_plugin`. The fail-closed default is
unchanged — an unattended install without the flag is still refused
rather than consented to on the user's behalf.
2026-10-02 03:40:17 -07:00
teknium1
501590a76f chore(plugin-catalog): unlist retaindb, byterover and holographic (no Nous maintenance)
Nous Research will not maintain memory providers. These three entries
pointed at NousResearch/hermes-plugin-<name> repos labelled
Nous-maintained; with no maintainer they must not be offered as
official catalog plugins. The bundled copies under plugins/memory/
are unchanged and still win on name, so no user-visible behaviour
changes today.
2026-10-02 03:28:41 -07:00
teknium1
4097709b0c fix(desktop): report and mute a Browser guest that attaches after its session is hidden
The off-screen hook only re-ran when hidden/parked changed, and a guest has
no webContents id until it attaches. A page whose session was parked between
mount and attach was never reported hidden to main (so mouse back/reload
could still target it) and never muted. The pane now feeds the guest's
dom-ready to the hook, which re-reports and re-mutes for the attached id.
2026-10-02 02:53:03 -07:00
teknium1
248091cd83 refactor(desktop): move preview tab ownership into store/preview-ownership.ts
preview.ts had grown to ~1450 lines. Who-owns-this-tab logic — compression
rotation aliases, the pending-runtime map, owner identity, ownsTab /
bucketTabsFor, the drawer visibility predicate — now lives in a topical
sibling. preview.ts keeps the tab list, its per-profile storage and the bucket
writers (adopt/rekey/prune), which need that storage. bucketTabsFor takes the
viewed bucket as a parameter instead of reading preview.ts's module state, so
the sibling has no import cycle back into preview.ts.

Behaviour-identical: importers of PreviewOwner and forgetPendingRuntimeTabs
now import from preview-ownership (no re-export shim).
2026-10-02 02:53:03 -07:00
teknium1
ab713f7c2b test(desktop): prove the kept Browser body survives A -> B -> A, not the registration identity
The keep-alive test compared the pane registration object across a session
switch — an implementation detail. It now renders the layout tree and asserts
what the user gets: A's body element stays connected (hidden, inert) while B
is shown, is the same node with the same input value when A returns, mounted
once and never disconnected. Red on the pre-keep-alive head f5f54a2b6c9.
2026-10-02 02:53:03 -07:00
teknium1
263fe5ccbd fix(desktop): mute a hidden session's kept Browser and restore its sound on return
A hidden session's live Browser stays mounted so returning shows the same
page, but it kept playing audio into the chat the user had switched to. The
parked-pane set is now an atom; a preview pane whose tab is parked mutes its
guest (webview.setAudioMuted) and unmutes on return only if it was the one that
muted it, so a page muted beforehand stays muted. Inactive tabs in the session
on screen keep playing, like background tabs in a browser.

Timers/rendering: a CSS-hidden webview still reports visible to Chromium and
Electron has no public API to mark a guest hidden without detaching it (any
detach destroys the guest, losing its state), so throttling is left as is.
2026-10-02 02:53:03 -07:00
teknium1
3cb409bed8 fix(desktop): mouse back/forward and reload never act on a hidden session's Browser
After a session switch, Chromium still tracked the hidden session's kept
Browser guest as the focused webContents, so main's focused-guest gestures
(mouse back/forward app-command, swipe, Ctrl/Cmd+R) navigated or reloaded a
page the user could not see. The keep-alive host also left focus on an inert
hidden guest, where keystrokes could still land.

- A keep-alive host going hidden blurs whatever inside it holds focus.
- The preview pane tells main when its guest leaves / returns to the screen
  (hermes:preview-guest-hidden; only the embedding renderer is believed), and
  commandFocusedGuest (moved to electron/preview-guest-offscreen.ts) skips a
  hidden guest so the gesture falls through to the renderer's visible pane.
2026-10-02 02:53:03 -07:00
teknium1
c2b049241d fix(desktop): a fresh draft's drawer no longer shows another runtime's pending tab
tabVisibleTo matched every ownerless tab when no session was focused, so a
runtime's pending tab (opened before its stored id bound) leaked into a fresh
draft's drawer and into any other runtime's. A pending tab is now visible only
while its own runtime is the one on screen; a true draft tab (no runtime) stays
in the draft.
2026-10-02 02:53:03 -07:00
teknium1
30b27de069 fix(desktop): keep a hidden session's live Browser mounted across session switches
Switching A -> B -> A unmounted A's Browser (its pane contribution was
disposed), so returning made a new Electron guest: unsaved input and page
JS state were lost, where main kept them. A preview tab whose session leaves
the screen now leaves the layout tree but keeps its contribution, so its
keep-alive host stays mounted (hidden, inert, aria-hidden, no pointer
events) and the same guest re-docks on return. Parked panes are excluded
from tree adoption (parked-panes.ts). Retention is capped at 8 hidden live
pages, longest-hidden first; past the cap a page unmounts and reloads from
its saved row when its session returns. Agent tools are unaffected: they
resolve tabs by owner, never by what is mounted.
2026-10-02 02:53:03 -07:00
teknium1
d481030dff fix(desktop): agent preview tools see only their own profile's pins and runtime's tabs
Two ownership holes in the per-session preview scoping:

- Cross-profile pins (P1): previewTabsFor read the viewed profile's bucket
  and admitted its pins unconditionally, and previewTabIdsVisibleTo
  authorized them for the pop-out bridge, so profile X's tile could
  preview.read profile Y's pinned page. The requester's owner now carries
  the profile its runtime belongs to (previewScopeForRuntime, the same
  resolution the rail scope uses); pins count only in that profile's
  bucket, own tabs count wherever they live. Agent opens and closes use the
  same identity, so X can neither navigate nor close Y's pinned Browser.

- Pending-runtime pool (P2): an ownerless tab was visible to every requester
  with no stored id, so runtime B read, reused (navigated) and closed runtime
  A's pre-stored-id Browser. Ownerless tabs are now either a runtime's pending
  tab (that runtime only) or a true draft tab (the draft on screen only:
  the primary's runtime, or a request naming no runtime) across reads,
  reuse and closes.

Human UI paths ($visiblePreviewTabs, the focused drawer) are unchanged.
2026-10-02 02:53:03 -07:00
teknium1
db91e4d993 fix(desktop): deleting a session prunes its preview tabs in every profile bucket
prunePreviewTabsForSession only filtered the viewed profile's tabs, so
deleting profile X's session while Y was on screen left X's unpinned tabs in
X's bucket and in storage. Prune every bucket (pins survive) and persist.
2026-10-02 02:53:03 -07:00
teknium1
f3747bc1e7 fix(desktop): preview tab menu reads the live pin state so Pin toggles back to Unpin
The zone menu's pin row captured `pinned` when the pane registered, and the
mirror keeps a same-title registration for the tab's whole life, so after the
first Pin the row kept offering Pin and kept setting true. Read the flag when
the menu renders and again on select.
2026-10-02 02:53:03 -07:00
teknium1
8685505b0b fix(desktop): third-review follow-ups for per-session preview tabs
- A popped-out Browser now answers a background session in another
  profile: previewTabIdsVisibleTo reads every profile bucket, not only the
  one in view. A background bucket counts only when the requester has a tab
  of its own there, so one profile's pins never answer for another
  profile's session.
- Agent preview tools act for the requesting runtime as well as its stored
  id (PreviewOwner): a tab that runtime opened before its stored id bound
  stays drivable/readable once the selection already names that id, locally
  and through the pop-out bridge.
- A runtime whose session state is dropped before it binds a stored id
  drops its pending notes (dropSessionState / clearAllSessionStates), so
  the next draft's first send adopts those tabs instead of skipping them.
- The drawer's pending-tab clause skips a LISTED selection: a cold resume
  selects an existing session a beat before it unbinds the old runtime, and
  that runtime's pending tab no longer joins the resumed drawer. An unlisted
  id (the runtime's own stored id arriving) still shows it.
- server-requests.test pop-out mocks forward the owner and assert it.
2026-10-02 02:53:03 -07:00
teknium1
59c9dea932 test(desktop): statusbar test mock exposes $focusedSessionIsTile
The preview store now derives visible tabs from session-focus's
$focusedSessionIsTile; the statusbar test's session-focus mock lacked it,
so the suite failed at import.
2026-10-02 02:53:03 -07:00
teknium1
bc636d37a3 fix(desktop): scope every agent preview tool to the requesting session
Second-pass review of the per-session preview tabs:

- drive_preview, tour, nav, input and the script runner resolve the
  requester's tab (storedSessionIdForRuntimeId of the event's runtime) via
  the shared resolveActivePreviewTab, not the focused session's; an id that
  does not resolve reads the ownerless/draft view, never the focused one.
- Pre-stored-id agent tabs are handed over when THAT runtime's stored id
  binds (handleTransition / ensureSessionState), and openPreview stamps the
  event's runtime rather than whichever runtime is active.
- Requests forwarded to a popped-out Browser carry the requester's tab ids;
  a pop-out showing another session's tab stays silent.
- Runtime notes are dropped with their tabs (close, prune, profile drop) and
  draft adoption skips tabs pending for a live runtime.
- The rotation-alias walk is bounded by the map size.
2026-10-02 02:53:03 -07:00
teknium1
bdba2705f4 fix(desktop): hand a pre-stored-id agent preview to the runtime that opened it
With focus-driven adoption gone (F1), main's "keeps the tab when the
stored session id arrives afterwards" regressed: an agent's open_preview
right after a first send lands before the stored id, so the tab was
ownerless and vanished from the drawer once the id arrived.

openPreview now notes the runtime an ownerless tab was opened under
(memory-only; a fresh draft has no runtime, so its file-browser / "+"
Browser tabs carry no note). The tab is adopted only when THAT runtime's
stored id arrives, never when another session or tile takes the screen.
2026-10-02 02:53:03 -07:00
teknium1
cb60c05506 refactor(desktop): import the focused-session atoms from session-focus, drop the re-export shim
$focusedStoredSessionId / $focusedSessionIsTile moved to session-focus.ts
(to break the session-states <-> preview load cycle) but session-states
kept re-exporting them for its old importers - an internal-move compat
shim (independent review F5). Every importer now reads
'@/store/session-focus' and the re-export is gone.

Test plumbing follows the symbol: suites that stubbed the atom on the
session-states mock now stub it on session-focus (profile-routing) or use
the real derivation (open-session-refresh-in-place), and two
session-states assertions read the atom from the module that defines it.
session-sidebar-focus loads session-states for the primary-follow wiring
its last case asserts, as its old import did implicitly.

Also drops the dead $focusedStoredSessionId stub this PR had added to the
close-tab.test.ts mock (F8); the file is back to main.
2026-10-02 02:53:03 -07:00
teknium1
8e737698bc fix(desktop): keep the agent's read_preview / drive_preview inside the session's tabs
resolveActivePreviewTab / readActivePreview, activePreviewInput and
activePreviewNav still read every session's tabs. With no visible
selection the tabs[0] fallback handed read_preview another session's
hidden tab, and the multi-tab meta listed hidden tabs' labels and URLs to
the model (independent review F3).

They now read previewTabsFor(session): the tabs that session can see (its
own plus pins). read_preview passes the requesting session's stored id;
drive_preview (active session only) uses the focused one. A popped-out
Browser renderer holds only its own tab, so it keeps reading that.
2026-10-02 02:53:03 -07:00
teknium1
8bb014d6a9 fix(desktop): resolve preview tab owners through compression rotation; one write per agent close
Independent review F2/F4/F9 on the session-scoped preview rail:

- F2 (hang): rekey(A,B) then a replayed rekey(B,A) put a cycle in the
  memory-only alias map and the latestSessionId walk spun forever, freezing
  $visiblePreviewTabs. Both ends of a rekey now resolve to their current
  tips first and a rekey that resolves to the same tip is ignored, so the
  map only ever links one tip to a different tip and stays acyclic.
- F4: a tab opened in the window between a rotation and focus following it
  was stamped with the rotated-away id; the alias map does not survive a
  relaunch, so the tab was orphaned. Opens, the strip "+" Browser and an
  unpin now stamp the resolved tip, and a deleted conversation is pruned by
  any of its ids.
- F9: close_preview with no url called closeRightRailTab per tab (N tab-list
  writes, N persists, N selection fix-ups). It now closes the session's
  unpinned tabs in one write and re-homes the selection once.
2026-10-02 02:53:03 -07:00
teknium1
4fc8c909cb fix(desktop): keep a draft's preview tabs in the draft until its first send
A fresh draft has no stored id, so the tabs it opens are ownerless. The
focus listener adopted every ownerless tab on ANY null -> id focus change,
so a side tile taking focus, or an existing session clicked in the
sidebar, swallowed the draft's file / Browser tabs and the draft's drawer
came back empty (independent review F1, same symptom as M1).

Adoption is now an explicit call made where the draft's first send
assigns its stored id, beside the composer draft's own hand-over
(announceNewSessionDraftKey). Focus changes never re-own a tab.
2026-10-02 02:53:03 -07:00
teknium1
6666be3b60 i18n(desktop): translate the preview tab pin/unpin labels in de, es, fr, ru
The re-landed pin-to-workspace menu shipped en/ja/zh/zh-hant/ar strings;
the other locales fell back to English.
2026-10-02 02:53:03 -07:00
teknium1
1cd91c69c5 fix(desktop): keep session-scoped preview tabs owned, restorable and rotation-safe
The session-scoped rail (#73890) regressed core preview flows and was
reverted in #130852. This keeps the scoping and fixes each regression:

- Ownership is explicit. The strip "+" Browser is stamped with the focused
  session, so it is visible at once and never adopted by another session
  on the next switch. openPreview takes an owner (default: the focused
  session); an agent preview.open/preview.close maps the event's runtime id
  to its stored id, so a side tile's agent previews land in, and close
  from, that tile's own drawer.
- Reuse is decided among the tabs the owner can see (its own plus pins):
  a URL open in session B never navigates session A's Browser, and an
  agent close without a url closes only that session's own tabs — never
  other sessions' tabs or pins.
- File tab ids are identity only. Lookups match a file by canonical path
  among the owner's visible tabs, so ids never need rekeying: ownerless
  tabs keep main's `file:<url>` id, owned ones `file:<owner>:<url>`.
  Restore keeps every row verbatim (the url dedupe that collapsed every
  Browser into one is gone), so pop-out finds a non-last Browser by id.
- Auto-compression tip rotation rekeys the conversation's tabs onto the
  new tip at both rotation sites (handleTransition and the state cache),
  and the old id stays an alias, so the tabs never blink out between the
  rotation and the focus following it.
- markPreviewTabMissing resolves by tab id or the file's url/path, which
  is what the file pane passes; the restored tombstone test is main's.
- Each session remembers the tab it had in front; switching back fronts
  it. Boot validates the persisted selection against every tab, not the
  (still unfocused) visible set.

Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
Co-authored-by: Johnny <universeszym@mail.ustc.edu.cn>
2026-10-02 02:53:03 -07:00
Brooklyn Nicholson
87a128a656 fix(desktop): keep popped Browser tabs out of docked closes after the session-scope rebase
Main's closeDockedPreviewMatching filtered popped-out tabs; carry that onto
the session-visible tab list. Re-add preview.pin/unpin in the split locale
modules and the key manifest, and let the model-picker suite derive the
focused session from its mocked tree focus.
2026-10-02 02:53:03 -07:00
Brooklyn Nicholson
9fe0a57533 fix(desktop): keep minted Browser tab ids across restore; complete test mocks for session-focus
parseTabList rekeyed restored URL tabs onto url:<address>, breaking the
pop-out handoff (tabs are looked up by their persisted minted id) and the
never-reuse id contract. Only FILE ids need the session-scoped rekey.

The session-focus module's new imports broke tests whose vi.mock factories
omitted them: tree/store mocks now export $activeTreeGroup/$layoutTree, and
the statusbar mock keeps the real $focusedStoredSessionId derivation.
2026-10-02 02:53:03 -07:00
David Metcalfe
c40d5be063 fix(desktop): harden preview session-scoping reuse paths
Review feedback on #87288 (Enough1122 + cross-vendor validation):

- openPreview: reusing a pinned file row from another session keeps the
  owner's id (no id/sessionId split); the singleton Browser and artifacts
  reached from another session re-own to the navigating session so the new
  URL is not invisible in the session that opened it; the rail selection
  follows the row's final id instead of the pre-computed one.
- preview-tile: merge the visible-list reveal/rehome listeners into a
  single rehome-then-reveal listener so ordering is explicit.
- preview-strip-tools: the pin toggle reads the current store state at
  click time instead of the render-time closure.
- Tests: pinned-reuse owner id, focused-tile-over-selection, and Browser
  re-own coverage — each mutation-verified (fails with the fix reverted).

42 targeted tests pass; renderer typecheck and eslint clean.
2026-10-02 02:53:03 -07:00
David Metcalfe
03855f9594 fix(desktop): scope preview tabs to the session that opened them
Preview tabs were window-global: a file/URL/artifact opened in one
conversation stayed visible above the composer in every other
conversation — including duplicates of the same file (file targets were
keyed by the raw URL, so file:// and plain-path entry points stacked).

Per the design proposed on #73890, tabs now belong to the session that
opened them (sessionId stamped at open), and the drawer renders only the
active session's tabs plus an explicit pinned set:

- pinned tabs render in every session (the deliberate cross-session
  workspace); a pin toggle rides on each preview tab's strip glyphs
- file tab ids are canonicalized (file:// stripped) so the same file
  dedupes across entry points; restored legacy rows are rekeyed and
  deduped on decode
- deleting a session drops its tabs (pins survive)
- legacy unscoped tabs migrate to pinned on load; ownerless tabs opened
  on a fresh draft are adopted into the session when one appears
- the terminal pane is untouched (process state stays global)

Tests: store unit tests for stamping/visibility/pin/prune/adoption/
canonicalization/migration, plus a mirror integration test proving the
drawer swaps panes on session switch. Full UI suite 561 files / 5529
tests green; typecheck and lint clean.

Partially addresses #73890 (the preview-tab scoping gap; the Artifacts
pane and focused-session fallback paths remain separate).
2026-10-02 02:53:03 -07:00
teknium1
89937f8685 fix(tests): kanban wake drain helpers no longer busy-spin forever on Python 3.12+
tests/gateway/test_kanban_wake_acceptance.py intermittently hung until the
CI runner SIGKILLed it at 600s (normally ~4s). The faulthandler stack of a
reproduced hang is the loop thread inside the test's own drain() helper:

    while adapter._background_tasks:
        await asyncio.gather(*list(adapter._background_tasks))

Since Python 3.12, asyncio.gather() completes eagerly when every child is
already done, so `await gather(...)` returns without yielding to the event
loop. When a session task finishes in the same loop iteration that resumes
the test (e.g. right after the notifier's advance() to_thread hop), the task
is done but its _background_tasks.discard done-callback is still queued for
the next iteration. drain() then re-checks a set that can never shrink and
spins forever without ever letting the loop run that callback.

Yield once after each gather so queued done-callbacks run before the set is
re-checked. Same fix for the identical helper in
tests/gateway/test_completion_admission.py and evals/heartbeat_idle_wire.py.
Production code is not affected (BasePlatformAdapter.cancel_background_tasks
filters done tasks and is bounded to 5 rounds).
2026-10-02 02:42:14 -07:00
kshitijk4poor
569fd58960 fix(dashboard): bound the PTY helper grace and await background closes
- Cap the helper grace at 2.0 s (and ignore non-finite or <= 0 env
  values) and wait only 0.5 s after SIGKILL, so a raised
  HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S cannot push close_all() past the
  backend SIGTERM -> SIGKILL budget.
- close_all() also awaits closes already running in the background
  (dead remnants, capacity evictions); the registry now holds those
  tasks, which also keeps them from being garbage-collected.
- _discard_output sleeps on EOF instead of spinning on a readable dead
  master, and catches ValueError from select() like read() does.
2026-10-02 14:37:54 +05:30
kshitijk4poor
9236c9c63d fix(dashboard): keep PTY teardown inside the shutdown budget and the save grace
- close_all() closed sessions one after another; each close may now
  wait out its helpers' SIGHUP grace, so N sessions could exceed the
  backend's SIGTERM -> SIGKILL budget (dashboard_procs) and strand the
  rest (#111912). Close them concurrently.
- attach_or_spawn awaited a dead remnant's close() under the
  registry-wide attach lock, stalling every new chat for the helper
  grace. Close it in the background, as capacity eviction already does.
- The helper grace now follows HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S
  (+0.5 s, at least 1.5 s), so a raised gateway grace is not cut short.
- close() drains the PTY master while it waits. The drain task has
  stopped reading by then, and on macOS an exiting child stays in exit
  until its pending terminal output is read: the leader was not reaped
  within 0.5 s, the loop escalated to SIGTERM on the whole group, and the
  gateway's SIGHUP save was killed mid-way.
2026-10-02 14:37:54 +05:30
kshitijk4poor
c047f35480 fix(dashboard): end a PTY session's helpers after its leader dies
close() signalled the process group only while the PTY leader was
still alive, and read the group id from the leader at close time. When
the leader dies first (crash, OOM kill), or exits on SIGHUP while a
helper ignores it, the helpers keep the PTY slave open: no EOF arrives,
and after the reaper frees the slot nothing ever signals them, so they
and their memory outlive the session (#76759).

Record the group id at spawn. After the leader is gone:
- if the group was already sent SIGHUP while the leader lived, only wait
  up to 1.5 s for it, then SIGKILL. The TUI gateway in that group saves
  its sessions on SIGHUP within a 1.0 s grace (tui_gateway/entry.py);
  a second SIGHUP or an earlier SIGKILL would cut that save short;
- if the leader was already dead, send SIGHUP, wait, then SIGKILL.
Stop at ESRCH. The id cannot be reused while any member is alive.

Also drop the duck-typed _bridge_alive helper: PtyBridge.is_alive()
already catches its own errors, and the keepalive test fake now
implements is_alive() like the real bridge.
2026-10-02 14:37:54 +05:30
webtecnica
f336141022 fix(dashboard): reap PTY sessions whose child process died without EOF (#76759)
reap_idle only freed a keep-alive PTY session on EOF (alive=False) or after
the detached TTL. If the child is killed while a grandchild still holds the
PTY slave, or the drain task dies, EOF never arrives and the dead TUI keeps
its registry slot, fds and memory forever. Also probe the bridge's
is_alive() (WNOHANG waitpid) in reap_idle.

Liveness-probe subset of #76768 (salvaged): the HERMES_PTY_* env vars, the
5-minute TTL and the session-cap changes are intentionally dropped; the
existing pop(key, None) race fix (5ddc5b6b0e) is kept.

(cherry picked from commit d3ae439098f, partial)
2026-10-02 14:37:54 +05:30
kshitijk4poor
346eaef707 fix(logging): keep records of a live profile whose logs/ was removed
When logs/ disappears but the profile itself is live (removed by hand,
no tombstone), the handler skipped the write and the router only
re-routed after a confirmed delete, so every record was silently dropped
until restart. Before the skip they were lost too, but loudly. Send a
skipped record to the default log whether or not the home is released.
2026-10-02 14:37:19 +05:30
kshitijk4poor
17553bd6f8 fix(logging): flag a skipped write instead of reading it from a closed stream
The router re-routed a record when its handler's stream was None after
the write. On Windows concurrent-log-handler closes the stream after
every write, so every live-profile record read as skipped: the liveness
check ran per record (defeating the 2 s rate limit), the handler paid an
isdir() per emit, and a tombstoned home whose dir still existed got the
same record written twice (profile log, then default log).

The handler now sets skipped_missing_dir only when it actually skips
because logs/ is gone, and checks the directory only when the file is
missing or its reopen just failed. The rate-limit test also runs with a
handler that closes its stream after each write.
2026-10-02 14:37:19 +05:30
kshitijk4poor
6e4f652a3b perf(logging): check a routed profile's liveness at most every 2 s
The out-of-band delete check added in the previous commit runs is_dir()
plus named_profile_is_deleted() on every record for a live named profile,
in every router: a live-profile emit went from 5 to 9 stat calls. Record
the last check per home and re-check only once _PROFILE_LIVENESS_RECHECK_S
(2.0 s) has passed. The first record seen for a home always checks, so a
home deleted before it ever logged is caught at once. The default home
still pays nothing.

An rmtree inside the interval must not bring back the FileNotFoundError
traceback from #103777. A routed per-profile handler that cannot reopen
because its logs/ directory is gone now skips the write. The router sees
the dropped stream, re-checks right away, releases the home and sends
that same record to the default log. A tombstone with the directory still
in place is noticed at the next interval check.

Scope: the stale fd for the deleted home is closed on the next routed
record after the tombstone or rmtree. This does not fix the Windows
rmtree failure. A CLI delete racing a live server's open handlers still
depends on release_profile_log_handlers in-process and on #130285.

Tests: the rmtree case no longer writes a tombstone first, so it covers
the missing-directory branch alone, inside the interval. The tombstone
case moves a patched _monotonic past the interval instead of sleeping. A
new test asserts one check per router for each interval.
2026-10-02 14:37:19 +05:30
kshitijk4poor
2d57cd7555 fix(logging): release an out-of-band deleted profile from the log router
The fallback above stops records landing in a vanished profile home, but
it leaves two gaps for a CLI `hermes profile delete` behind a running
serve/dashboard:

- The routed handler for the deleted home is never closed. Its fd stays
  open on the unlinked agent.log (and, on Windows, the CLH lock files keep
  the rmtree failing), and every later record for that home pays the
  is_dir() stat again because the startup snapshot still names it.
- The CLI tombstones the profile before rmtree. Between the two steps the
  directory still exists, so records keep writing into a profile that is
  already deleted.

Treat either signal (missing dir or named_profile_is_deleted tombstone)
as a delete and call the existing release_profile() path the in-process
DELETE /api/profiles route already uses: it closes the handler and drops
the home from the routing set, so later records for it fall straight
through to the default home with no further checks. The default-home path
is unchanged (short-circuits before any stat).
2026-10-02 14:37:19 +05:30
liuhao1024
d2ebd0606e fix(logging): route records to the default home when a profile is deleted
The profile-routing file handler keeps a static snapshot of the served
profile homes taken at startup. When 'hermes profile delete' removes a
home underneath a long-lived dashboard process, every record for that
home retries the vanished logs/ path: the managed rotating handler
stats, reopens, fails with FileNotFoundError and routes the error to
stderr — a repeating '--- Logging error ---' loop every logging tick
(#103777).

Fall back to the default home when a routed home's directory no longer
exists, mirroring the cron scheduler's _existing_profile_homes() filter.
The deleted profile's directory is never resurrected on disk and its
records keep flowing to the default home instead of being dropped with
stderr noise.

(cherry picked from commit deab9df17ffd896a61d02f7e25b897d6e2aa46ba)
2026-10-02 14:37:19 +05:30
kshitijk4poor
bfc7152687 test: remove the stub cli module the import-order test leaves behind
monkeypatch.delitem records nothing when the key is absent, so the stub
the test inserted through its import hook stayed in sys.modules for the
rest of a shared pytest process.
2026-10-02 14:26:08 +05:30
kshitijk4poor
c9a92dda67 test: cover the slash-worker watchdog parent choice without faking the host OS
The watchdog tests monkeypatched sys.platform, which AGENTS.md forbids.
Extract the parent choice into a pure _watchdog_parent(parent_pid, *,
is_windows, getppid) helper, test it with data, and drop the platform
patching from the main() ordering test.
2026-10-02 14:26:08 +05:30