Commit Graph

13538 Commits

Author SHA1 Message Date
fangliquanflq fd24ac94e0 fix(update): resume deferred Windows desktop updates 2026-08-31 10:05:13 -07:00
fangliquanflq 915ec169a0 fix(update): verify failed restore cleanup 2026-08-31 10:04:56 -07:00
fangliquanflq 53cf38c3b8 fix(update): fail closed on incomplete restore checks 2026-08-31 10:04:56 -07:00
fangliquanflq 3e4dc5f2c3 fix(update): preserve unknown restore cleanup state 2026-08-31 10:04:56 -07:00
fangliquanflq 8236b51878 fix(update): authenticate import health markers 2026-08-31 10:04:56 -07:00
fangliquanflq 6c608e2f59 fix(update): reject terminated import probes 2026-08-31 10:04:56 -07:00
fangliquanflq 68f9681acb fix(update): capture terminating restored imports 2026-08-31 10:04:56 -07:00
fangliquanflq f6c3942957 fix(update): compare every restored module failure 2026-08-31 10:04:56 -07:00
fangliquanflq 7b958b3575 fix(update): detect restored import-time failures 2026-08-31 10:04:56 -07:00
fangliquanflq 716b10314a fix(update): reject unsafe stash restores 2026-08-31 10:04:56 -07:00
Teknium e49df40682 fix(update): refuse to mutate a venv containing foreign-owned files (#83529)
A venv ever touched by sudo pip / sudo hermes contains root-owned files
(classically site-packages/*.dist-info/INSTALLER). A later normal-user
'hermes update' pulls code fine, then 'uv pip install -e .' dies with
'Permission denied (os error 13)' mid-mutation — venv/bin/hermes already
deleted, CLI bricked.

Add a bounded, pure-stat ownership preflight (_venv_foreign_owned_paths)
that runs after the code pull and immediately before the dependency
install. If foreign-owned paths are found it refuses up front, names the
offending paths + owner uid, prints the exact recovery command
(sudo chown -R $(id -un): <root>), and confirms the venv is untouched.
Windows (no os.geteuid) and root skip entirely. Never raises, capped at
~2000 stat calls, no subprocess use (update tests mock subprocess.run).

Same refuse-before-mutate philosophy as the contended-venv gate (#87331).

Fixes #83529
Diagnosis and documented recovery by @eabase.
2026-08-31 10:04:40 -07:00
walker 1f713dce38 test(packaging): exempt every exact pin from exclude-newer — release-day brick class
Each release exact-pins at least one dependency to a version published
days before the release (v0.20.6: snowballstemmer==3.1.1, psutil==7.2.2).
For two weeks after release the relative exclude-newer cutoff filters
those versions out, so any venv that predates the release cannot resolve
the new pins at all ('no version of snowballstemmer==3.1.1' — observed
2026-08-29 updating three production installs v0.20.0 -> v0.20.6, one
Termux and two Linux servers; the Termux host additionally bricked on
psutil==7.2.2 sdist resolution, and cryptography's isolated build
environment resolved maturin/setuptools-rust under the same cutoff).

Same zero-float-protection logic as the setuptools/pillow/mcp/
firecrawl-anydoc exemptions: the pin bump WAS the review, so the cutoff
adds nothing for an exact pin and can only brick. Extend
exclude-newer-package to every exact-pinned package in
[project].dependencies / optional-dependencies (table moved to
one-key-per-line — 97 entries), plus maturin and setuptools-rust for
wheel-less sdist builds of the exempted cryptography pin.

test_exact_pinned_deps_exempt_from_exclude_newer enforces the invariant
going forward: adding a name==version pin without a matching
exclude-newer-package entry fails CI.
2026-08-31 10:03:34 -07:00
Olympusbuildz a300a7fa79 fix(packaging): include wheel in PEP 517 build-system requires
Windows installer editable builds fail in uv's isolated sandbox with
ModuleNotFoundError: wheel.cli because build-system.requires only listed
setuptools. setuptools.build_meta and our setup.py bdist_wheel guard both
import wheel during the build.

Also whitelist wheel in tool.uv.exclude-newer-package so the existing
build-system exclude-newer brick guard stays green.

Fixes #96488

Signed-off-by: Olympusbuildz <Olympus.roots@outlook.com>
Co-authored-by: Olympusbuildz <Olympus.roots@outlook.com>
Signed-off-by: Olympusbuildz <Olympus.roots@outlook.com>
2026-08-31 10:03:34 -07:00
Teknium f57802c7ad fix(state): bound the rewind-tail walk at the watermark and rewind concurrent-tail originals too 2026-08-31 10:02:22 -07:00
BrunoBza 9d9d9194d4 fix(state): archive carried-forward compaction tail as rewind rows (#86366)
archive_and_compact() soft-archives every active row with compacted=1 and
then re-inserts compacted_messages as fresh live rows. When the
compressor's protected tail rides inside that list verbatim - which is
the normal batch-compaction shape ([summary] + tail) - the tail's
ORIGINALS end up stored twice per compaction: (active=0, compacted=1)
next to their live clones. search_messages() recalls both flags without
DISTINCT, so every carried-forward message came back once per compaction
(measured up to 4 identical hits) and was mislabeled to users and the
agent as archived "summarized away" content.

Add an optional tail_count parameter: the last tail_count archived rows
are superseded byte-identical duplicates, stamped rewind-style
(active=0, compacted=0, hidden from recall) instead of compacted=1.

Callers:
- batch in-place compaction counts the compressor-tagged tail dicts
  (_COMPACTION_TAIL_MARKER set by compress() on every carried-forward
  message);
- micro-compaction splices [prefix, marker, suffix] - everything except
  the single marker row is carried forward, so tail_count=len-1;
- proactive tool-result pruning rewrites content in place (not verbatim),
  keeping the historical archive-everything behavior.

Fixes #86366
2026-08-31 10:02:22 -07:00
xxxigm 59c5bd0913 test(install): pin the workspace selection away from apps/desktop
Runs the installer's real node_deps_workspace_args against fabricated
checkout layouts by sourcing install.sh in --manifest mode, which defines
its functions without performing an install.

The load-bearing assertion is the invariant that no checkout shape lets
apps/desktop resolve, including the empty-argument case that would silently
hand npm the whole workspace glob back.

Also cover classify_changes recovering the PR file list when compare
returns nothing, so fail-open does not demand ci-reviewed for a CLI-only
install change.
2026-08-31 10:00:55 -07:00
liuhao1024 cb89872c28 fix(install): clean up a broken managed Node and guard the termux probe
AI-review follow-up on #87467:
- On probe failure, remove the extracted ~/.hermes/node tree and the
  node/npm/npx bin links so later installer steps and retry runs start
  clean instead of resolving node to a binary that cannot start.
- The termux pkg branch had the same silent-success class: an empty
  version probe logged success and set HAS_NODE=true. Degrade with the
  binary's own error instead.
2026-08-31 10:00:46 -07:00
liuhao1024 8f8351aff3 fix(install): report a managed Node that cannot start, and preinstall libatomic1
install_node's post-install probe was
installed_ver=$(node --version 2>/dev/null) under set -e: when the
downloaded Node exists but cannot start (Node 26 linux-x64 builds link
libatomic.so.1, missing on minimal Debian/Ubuntu), the assignment
aborted the whole installer at exit 127 with the loader's explanation
discarded — installs died mid-sentence with no output at all (#87460).

- Probe now captures stderr and degrades with log_error carrying the
  loader message plus the libatomic1 hint instead of aborting.
- Debian/Ubuntu installs preinstall libatomic1 (best-effort, mirroring
  the existing apt idiom) so the common case just works.
- Termux branch's same-shaped probe gets a || true guard.
Fixes #87460
2026-08-31 10:00:46 -07:00
liuhao1024 0aa6b44917 fix(install): defer the partial clone's checkout so the throttle fallback engages
Review feedback on this PR: without --no-checkout, the blob fetch runs
inside git clone's own checkout step, so when the repo-scoped 429 hits
that fetch the whole clone exits non-zero, the else branch removes the
directory, and the fallback degrades to one more failed clone under
exactly the condition it exists for.

- Clone with --no-checkout (commits+trees only — small, passes the
  throttle); the blobs are then fetched by a separate 'git reset --hard
  HEAD' the retry can actually wrap. Verified on a local file://
  filtering remote: the no-checkout clone materializes nothing and the
  reset alone produces the full working tree.
- Fail closed: both reset attempts failing now removes the checkout and
  reports 'Failed to clone repository' instead of the previous '|| true'
  + unconditional clone_ok=true handing the installer a half-materialized
  tree printed as a success.
- The reset runs under a subshell cd so a failed materialization never
  leaves the shell in a deleted cwd, and the direct-retry loop bound now
  derives from $max_attempts (seq) instead of a hardcoded 1 2 3 4 that
  could drift from the reported attempt count.
2026-08-31 10:00:38 -07:00
liuhao1024 11afd07f16 fix(install): retry the HTTPS clone and degrade past repo-scoped 429s
GitHub throttles packfile generation for this repository with
repo-scoped HTTP 429s that are not client IP rate limits: an
anonymous clone of a small repo succeeds and the API quota is
untouched, but the single big pack behind --depth 1 dies
mid-transfer with 'RPC failed; HTTP 429 / expected packfile'. The
fresh-install clone path had no retry and no fallback, so a clean
machine exited 1 at the download stage and left a half-populated
install directory (same throttle as the update path in #89287).

Retry the HTTPS clone with linear backoff, removing the partial
clone between attempts; when every direct attempt fails, degrade
to a blobless partial clone and materialize the working tree with
a hard reset — many small packs instead of one big one, which is
what gets past the throttle. SSH-first ordering, the existing
installation update branch, and the commit-pin flow are unchanged.
2026-08-31 10:00:38 -07:00
Teknium f2f7a3bf15 feat(cron): doctor flags overdue next_run_at as silent non-firing
Widens the salvaged cron doctor with the highest-value fleet check:
an active job whose next_run_at is parked >15min in the past is not
firing (dead ticker, downed gateway, wedged fire-claim). Also registers
doctor in the docs (cron guide + CLI reference) and resolves the salvage
onto current main alongside runs/incidents/notepad.
2026-08-31 10:00:29 -07:00
joe102084 b028fe632e feat: add cron doctor health check 2026-08-31 10:00:29 -07:00
Justin Adkins d957e0e403 fix(cron): bound local fire-fence waits 2026-08-31 09:59:47 -07:00
Andrew Bagrin 5cf4ffc6ae test(cron): cover execution identity and sync docs 2026-08-31 09:59:39 -07:00
Andrew Bagrin b7c59bda54 fix(cron): isolate per-execution working directories 2026-08-31 09:59:39 -07:00
Teknium 89bcad4d0c test: create real profile-home dirs for the multiplex yield test
The multiplex loop on current main filters profile homes through
_existing_profile_homes (#47368); literal non-existent /tmp paths are
skipped, so the salvaged test's homes must exist on disk.
2026-08-31 09:59:07 -07:00
Jay. (neocode24) 9a7732b45f fix(cron): stale ticker yields its tick to a fresh gateway
A long-lived process whose checkout was updated underneath it (hot git
pull, interrupted hermes update) serves mixed sys.modules. When such a
stale process races a fresh gateway for the cron tick lock and wins the
minute, every agent job it dispatches can die on ImportErrors whose real
cause is staleness — and the fresh gateway's ticker skips the same minute
as lock-loser, so the user's scheduled job fires broken or not at all.

tick() now checks, BEFORE acquiring the tick lock:

  skew detected (boot fingerprint != disk revision)
    AND this process does not own the gateway runtime lock
    AND that lock is held (a fresh gateway is alive)
      -> raise CronTickYielded, skipping the tick entirely

Each arm alone keeps the old behavior:
- skew + self-owned lock -> proceed (delivery-path stale-code hint stays
  the surface for gateway-owned dispatches)
- skew + no lock holder -> proceed (desktop-standalone users must not
  lose their only ticker to a silent yield)
- skew None (non-git install, no boot fingerprint, probe failure) ->
  proceed; yielding is a certainty claim, never a guess

The yield RAISES instead of returning 0 so the provider loops record it
via record_ticker_error and mark the heartbeat success=False — a yielded
tick must not look like a healthy one (hermes cron status shows why),
mirroring the EMFILE propagation contract (#87644). Yield logging is
throttled to once per skew episode. Self-healing: when the fresh gateway
dies, its lock releases and the stale ticker's next tick proceeds.

Multiplex loop: a yield for one profile no longer cancels sibling
profiles' ticks in the same cycle; only the yielding profile records an
unsuccessful beat.

gateway/status.py gains owns_gateway_runtime_lock() —
is_gateway_runtime_lock_active() is True for the lock's own owner too, so
a caller deciding whether to yield to a FRESH gateway needs the
in-process handle as the discriminator.
2026-08-31 09:59:07 -07:00
fangliquanflq fd1d8271db fix(cron): isolate lazy imports from stale modules 2026-08-31 09:58:51 -07:00
Teknium 6cc4f3913d fix(timezone): make profile-keyed tz cache atomic and add cron persistence regression
Follow-up on the cherry-picked #92489 base: replace the four separate
process-global cache slots with one lock-guarded identity->(name, zone)
mapping so racing profile-scoped threads can never publish a mixed
identity/value pair (the P1 interleaving flagged in the #92489 review),
keep each profile's resolved zone hot across multiplex switches, and pin
the #97905 symptom with a real-store regression test: a foreign-process
tick (desktop multiplex ticker pattern) must persist next_run_at with the
job-owning profile's UTC offset.

Fixes #97905. Refs #88220, #92489.
2026-08-31 09:58:40 -07:00
IvanShang 9d8a053ce5 fix(timezone): isolate cache by active profile 2026-08-31 09:58:40 -07:00
Teknium ac7cefe0d8 test(gateway): regression coverage for the durable-reaped session guard (#99106) 2026-08-31 09:58:32 -07:00
Teknium fc2421cfa7 fix(gateway): hold inbound gate until turn machinery is warm on fresh boot (#99373)
On a fresh boot with no resume_pending sessions, _finish_startup_restore
opened the inbound gate almost immediately while the agent-side turn
machinery (run_agent import graph, tool schemas + check_fn probes,
context-file tier) was still cold. A message arriving in that window was
served with a skeleton system prompt (~1.7K tokens vs ~14.6K healthy):
no AGENTS.md/context tier, no tool schemas, memory provider initializing
mid-turn.

Fix: start a background turn-machinery warm-up when the startup gate
closes (overlapping the network-bound platform connects) and have
_finish_startup_restore await it — BOUNDED by
agent.gateway_startup_warmup_timeout (default 20s, 0 disables) — before
draining the queue and opening the gate. On timeout the gate opens
anyway and the warm-up finishes in the background, so a wedged init can
never make the gateway permanently unavailable.

Reported by @yhfmstr in #99373.

Fixes #99373
2026-08-31 09:58:24 -07:00
Teknium c0667439ec fix(compression): rotation heals stale automatic ended_at stamps instead of wedging (#88197)
TUI server shutdown stamps ended_at/end_reason='tui_shutdown' on sessions
whose agent keeps running; every rotation then aborts at
publish_compression_child's liveness check forever (the #88197 wedge; the
amplification half was fixed by #88411).

Class fix: is_automatic_end_reason() in hermes_state_common owns the
"accidental infrastructure cleanup vs deliberate boundary" taxonomy.
publish_compression_child clears automatic stamps in its own transaction
and proceeds (parent re-closes with its TRUE boundary,
end_reason='compression'); the #88411 pre-flush guard no longer aborts on
stamps the publish can heal. Deliberate boundaries (compression,
session_reset, explicit close) still fail closed at both sites.

TEST REPIN (deliberate contract change):
test_ended_parent_aborts_before_the_prepublish_flush pinned
"tui_shutdown stamp => rotation aborts and parent must not grow" — the
abort it required IS the #88197 wedge. Repinned as two tests:
- test_automatic_stamp_no_longer_wedges_rotation: automatic stamp =>
  rotation COMMITS (no abort loop, so no growth-by-abort is possible);
- test_deliberately_ended_parent_aborts_before_the_prepublish_flush:
  session_reset (deliberate boundary) => still aborts BEFORE the #47202
  flush, preserving #88411's no-growth contract where an abort remains
  correct.
The class invariant "no aborted rotation grows the parent" holds
everywhere: automatic stamps no longer produce aborts, deliberate
boundaries still abort pre-flush.
2026-08-31 09:58:11 -07:00
Teknium 8b73720fa7 fix(memory): tolerate bare-signature v2 providers when forwarding checkpoint requirement
Hardening on top of @Soju06's forwarding fix: v2 providers written against
the original docs example (def on_pre_compress(self, messages)) must not
TypeError when the host forwards require_checkpoint — inspect the signature
and fall back to the legacy call shape. Docs example updated to advertise
the keyword.
2026-08-31 09:57:22 -07:00
Soju06 5db9058cdb fix(memory): forward checkpoint requirement to v2 providers
MemoryManager.on_pre_compress() detects checkpoint API v2 providers,
selects the normalized evidence list for them, and re-raises their
failures under require_checkpoint — but it never tells the provider
that a checkpoint is required: the call passes only the messages.

A v2 provider therefore runs in its default best-effort mode, swallows
durable-write failures, and returns normally; the host then treats the
checkpoint as succeeded and lossy compression proceeds. With
compression.checkpoint_required: true this silently defeats the
guarantee the option exists to provide.

Forward require_checkpoint only to providers advertising the requested
checkpoint API version. Legacy providers keep the strict one-argument
on_pre_compress(self, messages) contract, so bundled v1 providers
(honcho, mem0, supermemory, ...) are unaffected.

Regression tests cover required and best-effort signaling, legacy
signature compatibility, and required-mode failure propagation.
2026-08-31 09:57:22 -07:00
HexLab98 2ea37eb8c5 test(gateway): cover hygiene fence-cancel cooldown and in-flight skip (#96953)
Prove a fence-cancelled helper (no abort flag) persists cooldown so the
next turn does not re-arm compression, the host does not wait out the
600s ceiling after cancel, a held lock skips the sibling agent, and
unwind cancellation records the same brake.

(cherry picked from commit d2e178cd96fbcc8b2baf68488a8f46c70bff31a2)
2026-08-31 09:57:13 -07:00
HexLab98 3d10a51afe test(tui): cover idle compaction status retag and FaceTicker freeze
Pin gateway re-tagging of idle/preflight lifecycle lines as compacting,
and assert the TUI keeps that status until compacted rather than
restoring the busy bar after 4s.
2026-08-31 09:57:04 -07:00
Nio Thomas ba7743b076 fix(state-db): report corruption instead of "session not found", detect it early
Re-applied onto 3aee29089 after `hermes update` reset main to origin/main.

1. web_routers/sessions.py: _resolve_session_id() classifies malformed-DB
   errors via the existing is_malformed_db_error() and raises 503 at all five
   call sites. delete_session_endpoint was the worst — an unresolvable id
   counted as idempotent success, so DELETE reported it had removed a session
   that was still on disk.
2. gateway/lifecycle_ledger.py: check_state_db_integrity() runs PRAGMA
   quick_check(1) on the unclean-exit path only (~2s on 500MB) and records the
   verdict into gateway-exit-diag.log. The 2026-08-31 corruption sat undetected
   for 3.5 days because nothing ever looked.
3. hermes_cli/gateway.py: `gateway run --replace` gave the outgoing gateway 5s
   before SIGKILL; SessionDB.close() runs a PASSIVE WAL checkpoint that does
   not finish in 5s on a WAL 4x past the autocheckpoint threshold, and a kill
   mid-checkpoint tears b-tree pages. Grace raised to 30s via a testable
   _await_gateway_exit() that also re-checks after the final sleep (a PID
   exiting in the last interval must not be SIGKILLed — PID-reuse hazard).

NOT added: wal_checkpoint(TRUNCATE) at shutdown — removed upstream in #45383
because a TRUNCATE reset races the live writer and tears b-tree pages.

Adversarial review: Codex gpt-5.6-sol, 9.0/10 across three groups, no must-fix.
2026-08-31 09:56:54 -07:00
Teknium cdd3b84c13 fix(state): reject special files in zeroed probe; real schema-bytes decode fixture
Follow-ups on the salvage: regular-file guard before the zeroed byte-probe (a FIFO at the state.db path would block startup forever — #98017 review P2), plus an on-main-reproducing UnicodeDecodeError fixture for #98924 (raw bytes in sqlite_master, not messages.content, are what reach pysqlite error-message decode).
2026-08-31 09:56:43 -07:00
Alvin T. Veroy e17fd0a708 fix(state): decode errors now reach the heal path and fail loud in TUI (residual #98924 surfaces)
Companion to #98935, which fixes _fts_table_probe itself. This covers the
surfaces that PR does not touch:

- web_server._open_session_db_at_path: the one-writable-open heal only
  caught sqlite3.DatabaseError; a raw UnicodeDecodeError (pysqlite failing
  to decode SQLite's own error message over corrupt file bytes) bypassed
  it, so the heal documented for malformed schema never fired (#98924
  Failure 1). Both catches widened; decode errors dispatch to the heal.
- SessionSchemaMixin._recover_stale_fts_locked: drop-and-recreate skipped
  vtables whose probe raised UnicodeDecodeError, the same too-narrow
  catch the issue identified in the probe.
- TUI gateway: _ensure_session_db_row returned silently when the store
  could not open, so prompt.submit streamed the turn while persisting
  nothing (#98924 Failure 2). It now returns False and prompt.submit
  fails the RPC with code 5072 so desktop maps it to a toast, mirroring
  the disk-full/5070 convention. session.create stays silent per its
  pinned degraded-mode contract.
2026-08-31 09:56:43 -07:00
chelsealong 47b0e6561e fix(state): honor _ensure_fts_cjk_schema's never-raises contract
The tokenizer-not-loaded branch ran its sqlite_master presence check
and self-heal statements (drop stale cjk triggers) with no guard, so a
transient sqlite3.OperationalError there (e.g. a locked database)
escaped the method despite its documented "Never raises" contract.

That exception then hit _migrate_broad_fts_update_triggers's
quarantine-then-reraise handler, aborting _init_schema and the whole
SessionDB open. Wrap the presence-check/self-heal block in the same
kind of OperationalError guard the tokenizer-loaded branch already
has, degrading to "no cjk index" instead of propagating.
2026-08-31 09:56:43 -07:00
kokhlo 50c3cb7276 fix(state): _fts_table_probe catches UnicodeDecodeError (#98924)
Invalid UTF-8 bytes in messages.content (e.g. 0x81 from hardware issues,
corrupted disk I/O, or manual DB edits) caused read-only SessionDB init
to die on a bare UnicodeDecodeError in _fts_table_probe, taking down
every read endpoint (GET /api/sessions, Desktop read-only opens of other
profiles' DBs). The probe caught only sqlite3.OperationalError and would
re-raise any other exception, including UnicodeDecodeError (a ValueError,
not an sqlite3.Error subclass).

On some Python/SQLite builds the decode failure surfaces as
UnicodeDecodeError; on others as OperationalError('Could not decode to
UTF-8 column ...'). The fix catches both and treats them the same:
the FTS index is degraded (search may return less or fail), but the store
itself stays accessible for writes and non-FTS reads. Writable init
schedules a rebuild or degrades to LIKE search until repaired.

Adds test_98924_readonly_fts_decode_error.py with a regression test that
injects invalid UTF-8 via CAST(x'...' AS TEXT) through the Python sqlite3
module, triggers an FTS rebuild, then confirms that read-only init succeeds
instead of raising.
2026-08-31 09:56:43 -07:00
loulanyue 69245e65bd fix(state): serialize startup across zero-byte check, quarantine, connect, and schema commit (#97568)
- Guard against concurrent-opener race where newly created 0-byte state.db was falsely quarantined before first schema write
- Wrap startup in quarantine_cross_process_lock when database is uninitialized or zeroed
- Guard is_zeroed_sqlite_file and is_zeroed_state_db against active live connections in current process
- Add concurrent-opener and live-connection regression tests
2026-08-31 09:56:43 -07:00
Teknium 22dcbdece6 fix(state): contain post-commit FTS maintenance errors + lock-audit the writer conn (salvage #90734)
Salvaged from PR #90734 by @Kyzcreig onto current main:
- hermes_state_search.py: post-commit FTS incremental merge failures
  (including the bare SystemError CPython's sqlite3 layer raises under
  cross-thread errmsg scrambling) are contained and logged instead of
  escaping and making the caller replay an ambiguous, possibly-durable
  write (exactly-once refinement by @yuzilongleif-collab).
- tests/state/test_writer_conn_thread_safety.py: live reader/writer race
  hammer + AST sweep freezing the no-unlocked-writer-conn invariant.

On top: the sweep now also flags self._conn PASSED to helpers, which
caught one more live site on main — get_session_delete_targets handed
the shared writer connection to _collect_delegate_child_ids inside a
_read_ctx block, executing on it without self._lock. Routed to the
borrowed read connection.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Co-authored-by: yuzilongleif-collab <235949691+yuzilongleif-collab@users.noreply.github.com>
2026-08-31 09:56:04 -07:00
Teknium 04bc1e1cbc test(state): AST lock audit — every self._conn call must hold self._lock
Salvaged from PR #99435. Fails on the next lock-free self._conn.<method>()
call site added to hermes_state.py; reads belong in _read_ctx(), writes
under self._lock (#99349).

Co-authored-by: dimitrysuen <dimitrysuen@users.noreply.github.com>
2026-08-31 09:56:04 -07:00
milnerrad 94869e5a0d fix(state): isolate background reads from writer connection 2026-08-31 09:56:04 -07:00
Teknium d16622cd14 test(buzz): regression coverage for fresh-subscription window, membership-rejection phrasing, and WS periodic discovery
- fresh conversation subscribes with no since floor + bounded limit; seeded
  channels keep since=last_ts-1 (#78429)
- all three production CLOSED membership phrasings prune per-subscription
  without reconnect (#76850 + #97502)
- restricted channels are not re-adopted by discovery sweeps
- WS periodic discovery subscribes conversations found without a membership
  event (#93557) and the companion task dies with its connection
- watch-all live channel adoption vs explicit-list scoping (#75107)

All six new tests sabotage-checked: each fails when its fix is reverted.
2026-08-31 09:49:30 -07:00
José Leos d3730a3fa9 fix(buzz): handle restricted CLOSED per-subscription, stop reconnect flood
When a Buzz relay sends a CLOSED frame for a single subscription with a
'restricted: not a channel member' error, the adapter was raising
ConnectionError, tearing down the entire WebSocket connection, and
immediately reconnecting — causing a ~1.6 s flood in gateway.log.

Root cause: the CLOSED handler unconditionally raised ConnectionError
regardless of whether the error was permanent (restricted) or transient
(e.g. server shutdown).

Fix:
- On a 'restricted' CLOSED, drop only the offending subscription and
  record the channel in a new _restricted_channels set instead of
  tearing down the whole connection.
- Skip restricted channels during connect() seeding and
  _subscribe_websocket() so reconnects don't re-trigger the same error.
- Non-restricted CLOSED frames still raise ConnectionError and reconnect
  as before.

Adds three regression tests:
- test_websocket_loop_drops_restricted_channel_without_reconnect
- test_websocket_loop_reconnects_on_non_restricted_closed
- test_restricted_channels_skipped_during_subscribe

Tested on macOS against buzz.xozai.com: gateway.log shows zero
'restricted' errors and stable 'watching N channel(s) via websocket'
after the fix.
2026-08-31 09:49:30 -07:00
Alessandro Boni d36827b086 fix(buzz): resume watched channels from a durable cursor across restarts (#90464)
`connect()` calls `_seed_channel()` unconditionally, and seeding marks every
event currently in the channel as seen so a start never replays history at the
agent. A message that arrives after the process starts but before the seed
completes — or at any point while the gateway is down — sits in exactly that
history, so the seed swallows it permanently even though the Buzz relay still
has it. The `seen` set and `last_ts` lived only in memory, so there was nothing
to distinguish "already handled" from "never seen".

Each watched channel's cursor (`chat_type`, `last_ts`, and the bounded `seen`
id list) is now persisted under `HERMES_HOME/buzz/channel-cursors.json` and
restored at connect. Where a cursor exists the channel resumes from it and the
history fetch is skipped entirely; where none exists the old seed-from-history
behaviour is unchanged, so a first-ever run still never replays a backlog.

Details worth noting:

- The file records the identity and relay it was written for. A cursor from a
  different bot or relay is ignored rather than trusted — the channel ids
  would collide while the event stream behind them is a different one.
- Any read or parse failure leaves the cursors empty, which degrades to
  seeding instead of failing the connect. Writes go through
  `utils.atomic_json_write` (temp + fsync + replace), so a crash mid-write
  cannot leave a truncated cursor behind.
- The restored `seen` list is trimmed to `_SEEN_CAP` on load, keeping the
  newest ids, so a hand-edited or legacy file cannot grow the de-dupe set
  without bound.
- Saves are gated on the cursor actually moving, so an idle channel does not
  rewrite the file every poll interval. Both inbound transports are covered:
  the poll sweep and the WebSocket event path share the same check.

Tests: six new cases in `TestChannelCursorPersistence` — the cursor is written
on seed, a restart resumes without spending a CLI call on history and then
delivers the mention that landed while the gateway was down, a foreign
identity or relay is ignored, a corrupt file falls back to seeding, the
restored `seen` set stays bounded, and an idle poll leaves the file untouched.
All six fail on main.

Tested on: Windows 11, Python 3.12. `python -m pytest
tests/gateway/test_buzz_adapter.py tests/gateway/test_buzz_websocket.py -q` —
33 passed (23 pre-existing + 6 new here, plus 4 WebSocket). Requires
`pytest-asyncio` (pinned at 1.3.0 in pyproject) — without it the async cases
in this file error out as unknown marks.
2026-08-31 09:49:30 -07:00
liuhao1024 94d86fa4de fix(buzz): bound WebSocket read idle time to force reconnect on silent relays
A relay-side close the transport never surfaces (observed as a CLOSE_WAIT
socket behind Cloudflare, #98097) parks the read loop forever while the
gateway keeps reporting connected: inbound stops, gateway_state.json stays
healthy, and only a restart recovers. The library keepalive should catch
this first, but as a last resort the read side now waits at most
_WS_READ_IDLE_TIMEOUT (300s) for a frame before raising into the existing
reconnect path, which re-authenticates and re-subscribes with per-channel
since filters intact.

Fixes #98097
2026-08-31 09:49:30 -07:00