Merge remote-tracking branch 'origin/main' into ethie/pm-clean

# Conflicts:
#	.gitignore
#	Dockerfile
#	agent/onboarding.py
#	apps/desktop/electron/main.ts
#	apps/desktop/electron/pool-stop.ts
#	apps/desktop/src/components/model-picker.test.tsx
#	apps/desktop/src/store/updates.ts
#	apps/desktop/vite.config.ts
#	datagen-config-examples/run_browser_tasks.sh
#	docs/rca-ssl-cacert-post-git-pull.md
#	gateway/run.py
#	hermes_cli/backup.py
#	hermes_cli/credential_lifecycle.py
#	hermes_cli/dashboard_procs.py
#	hermes_cli/doctor_state.py
#	hermes_cli/env_loader.py
#	hermes_cli/gateway_windows.py
#	hermes_cli/local_runtime/endpoint.py
#	hermes_cli/psutil_android.py
#	hermes_cli/update_cmd.py
#	hermes_cli/update_cmd_windows.py
#	hermes_cli/web_routers/local_models.py
#	hermes_cli/web_server_config.py
#	hermes_cli/web_server_cron.py
#	plugins/memory/hindsight/__init__.py
#	plugins/memory/holographic/__init__.py
#	plugins/memory/honcho/cli.py
#	plugins/memory/mem0/__init__.py
#	plugins/platforms/google_chat/oauth.py
#	plugins/platforms/photon/adapter.py
#	scripts/ci/list_os_marked_tests.py
#	scripts/run_tests.sh
#	tests/agent/test_compression_stall_fallback.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/gateway/test_google_chat_oauth_dependencies.py
#	tests/hermes_cli/conftest.py
#	tests/hermes_cli/test_cli_init.py
#	tests/hermes_cli/test_gateway_migrate_multiplex.py
#	tests/hermes_cli/test_psutil_android_extract.py
#	tests/hermes_cli/test_relaunch.py
#	tests/hermes_cli/test_update_check.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_worktree_gc.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_retry_policy.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_autostash_conflict_recovery.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_commit_pin_rollback.py
#	tests/scripts/install/test_install_diverged_update.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_macos_launcher.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_ps1_ascii_only.py
#	tests/scripts/install/test_install_ps1_browser_install.py
#	tests/scripts/install/test_install_ps1_managed_node_swap.py
#	tests/scripts/install/test_install_ps1_native_stderr_eap.py
#	tests/scripts/install/test_install_ps1_node_path_for_npm.py
#	tests/scripts/install/test_install_ps1_python_fallback_venv.py
#	tests/scripts/install/test_install_ps1_resolver_strictmode.py
#	tests/scripts/install/test_install_ps1_uv_install_fallback.py
#	tests/scripts/install/test_install_ps1_uv_powershell_host.py
#	tests/scripts/install/test_install_ps1_venv_process_tree.py
#	tests/scripts/install/test_install_ps1_venv_recreate_safety.py
#	tests/scripts/install/test_install_ps1_venv_rename_abort.py
#	tests/scripts/install/test_install_ps1_venv_transaction_boundary.py
#	tests/scripts/install/test_install_ps1_web_server_syntax_probe.py
#	tests/scripts/install/test_install_scripts_computer_use.py
#	tests/scripts/install/test_install_sh_acp_launcher.py
#	tests/scripts/install/test_install_sh_bootstrap_marker.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_install_method_stamp.py
#	tests/scripts/install/test_install_sh_node_deps_failure.py
#	tests/scripts/install/test_install_sh_node_deps_workspaces.py
#	tests/scripts/install/test_install_sh_node_global_prefix.py
#	tests/scripts/install/test_install_sh_node_npm_check.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_node_probe.py
#	tests/scripts/install/test_install_sh_node_tarball_without_xz.py
#	tests/scripts/install/test_install_sh_pythonpath_sanitization.py
#	tests/scripts/install/test_install_sh_reuse_supported_python.py
#	tests/scripts/install/test_install_sh_root_fhs_uv_python_path.py
#	tests/scripts/install/test_install_sh_setup_wizard_tty_probe.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_termux_network_prereqs.py
#	tests/scripts/install/test_install_sh_termux_python_bounds.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_project_metadata.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_tts_pythonpath_fallback.py
#	tests/tui_gateway/test_hosted_room_driver_runtime.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	tools/lazy_deps.py
#	tools/voice_mode.py
#	uv.lock
#	website/docs/developer-guide/macos-bundle-updates.md
#	website/docs/developer-guide/pm-audit-status.md
#	website/docs/developer-guide/shared-bundle-builds.md
#	website/docs/developer-guide/source-update-completion.md
#	website/docs/developer-guide/stable-releases.md
This commit is contained in:
ethernet
2026-09-14 15:38:34 -04:00
2096 changed files with 115965 additions and 30004 deletions

View File

@@ -15,6 +15,7 @@ import queue
import random
import re
import sqlite3
import stat
import sys
import threading
import time
@@ -23,7 +24,6 @@ from collections import deque
from contextlib import contextmanager
from pathlib import Path
from agent.message_sanitization import _sanitize_surrogates
from hermes_constants import get_hermes_home, mkdir_under_hermes_home
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TypeVar, cast
@@ -45,14 +45,16 @@ from hermes_state_portability import SessionPortabilityMixin
from hermes_state_telegram import SessionTelegramTopicsMixin
from hermes_state_schema import SessionSchemaMixin
import hermes_state_holders as _state_holders
import hermes_state_lockguard as _lockguard
from hermes_state_dbfile import (
_canonical_sqlite_path, _connect_tracked_db, _fd_is_truly_unlinked, _prepare_connection_retirement,
_connect_tracked_db, _fd_is_truly_unlinked, _prepare_connection_retirement,
_read_sqlite_application_id, _stat_sqlite_sidecar_identity,
_watched_sqlite_sidecar_paths, has_invalid_sqlite_header_preopen, is_zeroed_state_db, quarantine_cross_process_lock,
quarantine_invalid_state_db,
RetiredGenerationCaptureError, capture_retired_wal_generation, refuse_deleted_wal_generation,
)
from hermes_state_messages import SessionMessagesMixin
from hermes_state_rewind import SessionRewindMixin
from hermes_state_wal import (
_WAL_INCOMPAT_MARKERS, _on_disk_journal_mode, apply_database_pragmas, apply_wal_with_fallback,
)
@@ -143,11 +145,6 @@ def _compression_lock_holder_process_is_dead(holder: str) -> bool:
return False
def _scrub_surrogates(value: Any) -> Any:
"""Replace lone surrogates in text (sqlite3 raises UnicodeEncodeError, aborting the whole write)."""
return _sanitize_surrogates(value) if isinstance(value, str) else value
# Billing buckets that aren't a routable provider identity: a session that persisted only
# one of these (never ran /model) falls back to the config default. Shared by
# session_gateway_runtime and tui_gateway.server so they cannot drift.
@@ -218,6 +215,65 @@ def _ensure_test_isolation(db_path: Path) -> None:
)
def _secure_state_db_files(db_path: Path, *, create_main: bool = False) -> None:
"""Create/tighten a writable state database and its sidecars to 0600.
SQLite otherwise creates ``state.db``, ``-wal``, and ``-shm`` according to
the process umask (commonly 0644 under 0022). Read-only SessionDB
attachments never call this helper and remain observational.
Existing files are tightened with ``chmod(2)`` on the path: opening the
file and closing that descriptor would drop every POSIX ``fcntl`` lock the
process holds on its inode — including the locks of an already-open SQLite
connection to the same database. A lock-losing close in one process lets a
sibling's connection take the shared-memory DMS exclusively at its own
close, checkpoint, and unlink the sidecars while long-lived holders
(gateway, desktop ``hermes serve``) keep using the deleted inodes.
"""
if os.name == "nt":
return
main_path = db_path
if create_main:
# O_EXCL: only a brand-new inode gets a descriptor. Opening an existing
# file here and closing it would drop this process's POSIX locks on it.
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
if hasattr(os, "O_CLOEXEC"):
flags |= os.O_CLOEXEC
try:
fd = os.open(main_path, flags, 0o600)
except FileExistsError:
pass
except IsADirectoryError:
# Not a database file at all; sqlite3.connect() raises the
# canonical error for this, and a directory leaks no row data.
return
else:
os.close(fd)
for path in (
main_path,
db_path.with_name(db_path.name + "-wal"),
db_path.with_name(db_path.name + "-shm"),
):
# fchmod on an fd of a pre-existing file cannot be used here: close(fd)
# would release this process's POSIX locks on that inode, stripping the
# locks of any live SQLite connection to the same database. chmod(2)
# never opens the file, so it leaves the lock state untouched.
try:
st = os.lstat(path)
except FileNotFoundError:
continue
if stat.S_ISLNK(st.st_mode):
# Refuse a planted symlink exactly like O_NOFOLLOW would.
continue
if not stat.S_ISREG(st.st_mode):
continue
os.chmod(path, 0o600)
# Openings of the background-review harness prompts (agent/background_review.py).
_REVIEW_HARNESS_PREFIXES = (
"Review the conversation above and update the skill library",
@@ -338,7 +394,7 @@ class SessionDB(
SessionSessionsMixin, SessionFtsSetupMixin, SessionSearchMixin, SessionSchemaMixin,
SessionPortabilityMixin, SessionTelegramTopicsMixin, SessionCompressionMixin,
SessionGatewayMixin, SessionMaintenanceMixin, SessionUsageMixin, SessionTitlesMixin,
SessionMessagesMixin,
SessionMessagesMixin, SessionRewindMixin,
):
"""SQLite-backed session storage with FTS5 search; many reader threads, one writer (WAL)."""
@@ -466,6 +522,7 @@ class SessionDB(
self._retired_capture_lock = threading.Lock()
self._retire_connection: Optional[Callable[[Any], None]] = None
self._connection_pinned = False # one unmatched C reference taken at most once per handle
self._wal_lock_guard: dict = {} # hermes_state_lockguard.hold() record, see _open_writer
self._db_corrupt, self._db_corrupt_reason = False, "" # sticky quarantine (StateDbCorruptError)
self._fts_usermerge_floor_applied = False # one-shot usermerge-floor write guard
self._fts_enabled = self._fts_stale = self._trigram_available = False
@@ -544,6 +601,11 @@ class SessionDB(
self._connect_and_init_with_lock_patience()
# FTS optimization is OPT-IN (`hermes db optimize`); no background worker races session lifecycle.
self._ensure_db_file_generation()
if self._wal_active:
# OFD copies of the two POSIX locks that keep a sibling's close from unlinking this WAL
# generation: any in-process open()/close() of state.db or -shm cancels SQLite's own
# (howtocorrupt §2.2); these survive it. Lifted in close().
self._wal_lock_guard = _lockguard.hold(self.db_path)
def _open_read_only(self) -> None:
"""Read-only attach for cross-profile aggregation: no schema init, NO write
@@ -625,6 +687,9 @@ class SessionDB(
# Unknown -> reads queue on the writer lock (slow but correct) instead of racing SQLITE_BUSY
# on a file that may really be in rollback-journal mode.
self._wal_active = mode == "wal" and _on_disk_journal_mode(conn) == "wal"
# Existing WAL/SHM files may predate the main-file hardening;
# normalize any sidecars that became visible during WAL setup.
_secure_state_db_files(self.db_path)
apply_database_pragmas(conn, db_label="state.db")
conn.execute("PRAGMA foreign_keys=ON")
self._fts_cjk_loaded = load_fts5_cjk_extension(conn)
@@ -637,6 +702,9 @@ class SessionDB(
# Refuse before sqlite3.connect (under the startup lock) so we cannot mint
# a replacement WAL while a live writer still holds a deleted sidecar inode.
refuse_deleted_wal_generation(self.db_path)
# Create/tighten the main database before sqlite3.connect() so a
# permissive process umask can never expose a fresh profile store.
_secure_state_db_files(self.db_path, create_main=True)
self._conn = self._open_writer_conn()
self._init_schema()
@@ -797,6 +865,8 @@ class SessionDB(
f"in flight (a session-teardown path called close() before "
f"this worker finished — #94736) and the automatic reopen failed: {exc}"
) from exc
if self._wal_active: # a reopened writer is a live generation holder like the first open
self._wal_lock_guard = _lockguard.hold(self.db_path)
def _execute_write(
self, fn: Callable[[sqlite3.Connection], T], patience_s: Optional[float] = None,
@@ -1044,7 +1114,7 @@ class SessionDB(
watched = _watched_sqlite_sidecar_paths(self.db_path)
try:
for target, fd_path in _proc_fd_targets(os.getpid()):
canonical = _canonical_sqlite_path(target)
canonical = _state_holders.canonical_sqlite_path(target)
if (" (deleted)" in target and canonical in watched
and _fd_is_truly_unlinked(fd_path, watched[canonical])):
return True
@@ -1264,6 +1334,10 @@ class SessionDB(
return
try:
with self._lock:
if self._conn is None:
return # closed underneath the timer: nothing to checkpoint, nothing to re-guard
if self._wal_lock_guard:
_lockguard.hold(self.db_path, self._wal_lock_guard) # a -shm minted after open
result = self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)").fetchone()
if result and result[1] > 0:
logger.debug("WAL checkpoint: %d/%d pages checkpointed", result[2], result[1])
@@ -1340,6 +1414,7 @@ class SessionDB(
self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
except Exception as exc:
logger.debug("WAL checkpoint (PASSIVE) at close failed: %s", exc)
_lockguard.release(self._wal_lock_guard) # before the close: see release()
if retire_without_close:
self._pin_connection(self._conn)
self._conn = None