Merge pull request #7800 from nesquena/stage/0924-b

release: exp-v0.52.367 (#7798 CLI-archived sessions stay archived)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-09-24 00:04:26 -07:00
commit 2d1e6f812a
6 files changed
+144 -7

No files matched your search

+7
View File
@@ -39,6 +39,13 @@
### Fixed
- **Sessions archived in the CLI stay archived in the WebUI.** A cron, webhook, Kanban or CLI
session archived from the CLI came back as active in the sidebar whenever it had no WebUI sidecar,
because the projection treated a missing sidecar as "not archived". A missing sidecar now means no
opinion, so the state.db `archived` flag applies; archiving or unarchiving in the WebUI still wins.
In all-profiles mode, a CLI archive in another profile now refreshes the cached session list.
(#7548, #7798 by @webtecnica)
- **Approval and clarify prompts send a browser notification whenever you aren't looking at them.**
A card that surfaced through the normal prompt path never produced a notification, and the
visibility gate muted cards in a tab that was visible but unfocused. Now every approval or clarify
+3 -1
View File
@@ -576,7 +576,7 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
# expects from "Show agent sessions" sorted by activity.
for key in (
'id', 'model', 'message_count', 'actual_message_count', 'actual_user_message_count',
'ended_at', 'end_reason', 'last_activity',
'ended_at', 'end_reason', 'last_activity', 'archived',
):
if key in tip:
merged[key] = tip[key]
@@ -683,6 +683,7 @@ def read_importable_agent_session_rows(
origin_chat_id_expr = _optional_col('origin_chat_id', session_cols)
origin_user_id_expr = _optional_col('origin_user_id', session_cols)
platform_expr = _optional_col('platform', session_cols)
archived_expr = _optional_col('archived', session_cols)
# Older/minimal state.db schemas can have NO ``messages`` table at all,
# or a ``messages`` table without a ``session_id`` / ``timestamp`` column.
# The projection SQL below joins ``messages`` and aggregates
@@ -793,6 +794,7 @@ def read_importable_agent_session_rows(
{parent_expr},
{ended_expr},
{end_reason_expr},
{archived_expr},
{actual_count_expr} AS actual_message_count,
{user_message_count_expr} AS actual_user_message_count,
{last_activity_expr} AS last_activity
+17 -5
View File
@@ -8073,7 +8073,7 @@ def _state_projection_sidecar_metadata(sid: str) -> dict:
stops being true (metadata moves to another store), this gate would short-
circuit before the real source — update both together.
"""
default = {"title": None, "archived": False}
default = {"title": None, "archived": None}
if not is_safe_session_id(sid):
return dict(default)
p = SESSION_DIR / f'{sid}.json'
@@ -8270,7 +8270,10 @@ def _load_cli_sessions_uncached(
_sidecar_meta = _state_projection_sidecar_metadata(sid)
if _sidecar_meta.get('title'):
_title = _sidecar_meta['title']
_archived = bool(_sidecar_meta.get('archived'))
if _sidecar_meta.get('archived') is not None:
_archived = bool(_sidecar_meta['archived'])
else:
_archived = bool(row.get('archived'))
_display_title = _title or f'{_source.title()} Session'
cli_sessions.append({
'session_id': sid,
@@ -8342,7 +8345,10 @@ def _load_cli_sessions_uncached(
_sidecar_meta = _state_projection_sidecar_metadata(sid)
if _sidecar_meta.get('title'):
_title = _sidecar_meta['title']
_archived = bool(_sidecar_meta.get('archived'))
if _sidecar_meta.get('archived') is not None:
_archived = bool(_sidecar_meta['archived'])
else:
_archived = bool(row.get('archived'))
_display_title = _title or 'Cron Session'
cli_sessions.append({
'session_id': sid,
@@ -8408,7 +8414,10 @@ def _load_cli_sessions_uncached(
_sidecar_meta = _state_projection_sidecar_metadata(sid)
if _sidecar_meta.get('title'):
_title = _sidecar_meta['title']
_archived = bool(_sidecar_meta.get('archived'))
if _sidecar_meta.get('archived') is not None:
_archived = bool(_sidecar_meta['archived'])
else:
_archived = bool(row.get('archived'))
_display_title = _title or 'Webhook Session'
cli_sessions.append({
'session_id': sid,
@@ -8473,7 +8482,10 @@ def _load_cli_sessions_uncached(
_sidecar_meta = _state_projection_sidecar_metadata(sid)
if _sidecar_meta.get('title'):
_title = _sidecar_meta['title']
_archived = bool(_sidecar_meta.get('archived'))
if _sidecar_meta.get('archived') is not None:
_archived = bool(_sidecar_meta['archived'])
else:
_archived = bool(row.get('archived'))
cli_sessions.append({
'session_id': sid,
'title': _title or 'Kanban Session',
+17
View File
@@ -606,6 +606,22 @@ def _session_list_cache_source_stamp(key: tuple) -> tuple[tuple[int, int], tuple
session_index_path = _session_list_cache_session_dir() / "_index.json"
except Exception:
session_index_path = None
all_profiles_state_stamp = None
if _cache_all_profiles:
try:
from api.models import _all_profiles_cli_contexts
contexts, _ = _all_profiles_cli_contexts()
all_profiles_state_stamp = tuple(
(
_session_list_cache_path_stamp(ctx_db),
_session_list_cache_path_stamp(ctx_db.with_name(f"{ctx_db.name}-wal") if ctx_db else None),
_session_list_cache_state_db_fingerprint(ctx_db),
)
for _home, ctx_db, _prof in contexts
)
except Exception:
all_profiles_state_stamp = None
return (
_session_list_cache_path_stamp(state_db_path),
_session_list_cache_path_stamp(state_db_wal_path),
@@ -617,6 +633,7 @@ def _session_list_cache_source_stamp(key: tuple) -> tuple[tuple[int, int], tuple
# frame size), so without this a freshly-committed CLI/gateway session
# could be served stale for the cache TTL. Mirrors the models-layer fix.
_session_list_cache_state_db_fingerprint(state_db_path),
all_profiles_state_stamp,
swv,
)
+3 -1
View File
@@ -199,7 +199,9 @@ def test_missing_sidecar_returns_default_without_caching_growth(tmp_path):
session_dir.mkdir()
with mock.patch("api.models.SESSION_DIR", session_dir):
meta = models._state_projection_sidecar_metadata("cron_nope_999")
assert meta == {"title": None, "archived": False}
# No sidecar means no opinion on archived (None), so the projection falls back
# to the state.db row's archived flag (#7548) instead of forcing it to False.
assert meta == {"title": None, "archived": None}
# No file → nothing cached (so the cache can't be poisoned by absent files).
assert len(models._SIDECAR_METADATA_CACHE) == 0
@@ -0,0 +1,97 @@
"""Regression test for issue #7548:
CLI-archived cron/webhook/kanban sessions reappear from state.db projection (no sidecar case).
"""
import sqlite3
from pathlib import Path
from api.agent_sessions import read_importable_agent_session_rows
from api.models import get_cli_sessions, _state_projection_sidecar_metadata
def _create_state_db(path: Path, rows: list[tuple]):
conn = sqlite3.connect(str(path))
conn.execute(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
title TEXT,
model TEXT,
message_count INTEGER,
started_at REAL,
source TEXT,
session_source TEXT,
archived INTEGER DEFAULT 0
)
"""
)
conn.execute(
"""
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
session_id TEXT,
role TEXT,
timestamp REAL
)
"""
)
for r in rows:
conn.execute(
"INSERT INTO sessions (id, title, model, message_count, started_at, source, session_source, archived) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
r,
)
# insert a dummy user message so actual_message_count > 0
conn.execute(
"INSERT INTO messages (session_id, role, timestamp) VALUES (?, 'user', ?)",
(r[0], r[4]),
)
conn.commit()
conn.close()
def test_cli_archived_session_without_sidecar_retains_archived_state(tmp_path, monkeypatch):
"""When a session is marked archived in state.db and has no WebUI sidecar,
the projection must preserve archived=True and not reset it to False."""
db_path = tmp_path / "state.db"
session_rows = [
("cron-active-1", "Cron Active", "model", 1, 1000.0, "cron", "cron", 0),
("cron-archived-1", "Cron Archived", "model", 1, 1000.0, "cron", "cron", 1),
]
_create_state_db(db_path, session_rows)
# Test read_importable_agent_session_rows directly
projected = read_importable_agent_session_rows(
db_path,
limit=200,
exclude_sources=None,
include_sources=("cron",),
)
by_id = {r["id"]: r for r in projected}
assert "cron-active-1" in by_id
assert "cron-archived-1" in by_id
assert bool(by_id["cron-active-1"].get("archived")) is False
assert bool(by_id["cron-archived-1"].get("archived")) is True
# Test _state_projection_sidecar_metadata returns archived: None when no sidecar exists
meta = _state_projection_sidecar_metadata("cron-archived-1")
assert meta.get("archived") is None
# Test get_cli_sessions() projection
from api.models import clear_cli_sessions_cache
clear_cli_sessions_cache()
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
try:
import api.profiles # noqa: F401 # makes the monkeypatch target below importable
monkeypatch.setattr("api.profiles.get_active_hermes_home", lambda: str(tmp_path))
except Exception:
pass
monkeypatch.setattr("api.models._active_state_db_path", lambda: db_path)
monkeypatch.setattr("api.models.SESSION_DIR", tmp_path / "webui_sessions")
(tmp_path / "webui_sessions").mkdir(parents=True, exist_ok=True)
cli_sessions = get_cli_sessions(source_filter="cron")
cli_by_id = {s["session_id"]: s for s in cli_sessions}
assert "cron-active-1" in cli_by_id
assert "cron-archived-1" in cli_by_id
assert cli_by_id["cron-active-1"]["archived"] is False
assert cli_by_id["cron-archived-1"]["archived"] is True