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
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.
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.
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.
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>
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
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.
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.
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.
$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.
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.
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.
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.
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>
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.
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.
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.
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).
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).
- 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.
- 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.
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.
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)
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.
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.
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.
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).
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)
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.
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.