Skip to content
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## [Unreleased]

## [v0.50.251] — 2026-04-30

### Fixed
- **Sidebar lineage collapse now works for WebUI JSON sessions, not just imported gateway rows** — PR #1358 (v0.50.249) added the client-side lineage-collapse helper but `/api/sessions` only included `_lineage_root_id` for gateway-imported rows. WebUI JSON sessions (the common case) had no grouping key, so cross-surface continuation chains (CLI-close → WebUI continuation, or compression chains within WebUI) still rendered as separate sidebar rows. Now `/api/sessions` reads `parent_session_id` and `end_reason` from `state.db.sessions` for every WebUI session id in the sidebar payload, walks the parent chain when `end_reason in {'compression', 'cli_close'}`, and exposes `_lineage_root_id` + `_compression_segment_count`. Cycle-detected via a `seen` set; depth-bounded to 20 hops to cap pathological data. **Pre-release fix:** swapped the original full-table-scan for a parameterized `WHERE id IN (...)` query that hits PRIMARY KEY + `idx_sessions_parent` — ~50× faster at 1000 rows, scales linearly. **Pre-release fix:** chunked IN clause to 500 vars to stay under SQLITE_MAX_VARIABLE_NUMBER on older sqlite (Python 3.9 ships sqlite 3.31 with default limit 999) — without this a power user with 2000+ sessions in the sidebar would hit `OperationalError: too many SQL variables`, the silent except-wrapper would swallow it, and lineage collapse would never work for them. **Pre-release fix:** tightened `parent_session_id` exposure — only emitted when the parent's `end_reason` is `compression` or `cli_close` (not for `user_stop`/etc), since the frontend's `_sessionLineageKey` falls through to `parent_session_id` and would incorrectly collapse two children of a non-continuation parent into a single row. (`api/agent_sessions.py`, `api/models.py`, `tests/test_session_lineage_metadata_api.py`, `tests/test_pr1370_lineage_metadata_perf_and_orphan.py`, `tests/test_gateway_sync.py`) @dso2ng — PR #1370
- **Manual cron runs persist output and metadata like scheduled runs** — manual WebUI cron runs called `cron.scheduler.run_job(job)` and then only cleared the in-memory running flag. The job's output was dropped (never written via `save_job_output`) and `last_run_at` / `last_status` were never updated. Now the manual-run wrapper (`_run_cron_tracked`) matches the scheduled-cron path at `cron/scheduler.py:1334-1364` exactly: saves output, marks the job complete, treats empty `final_response` as a soft failure (with the same error string), and records failures via `mark_job_run(False, str(e))`. (`api/routes.py`, `tests/test_cron_manual_run_persistence.py`) @NocGeek — PR #1372 (split out from the held #1352 per pre-release feedback)
- **Reasoning trace, tool calls, and partial output preserved on Stop/Cancel** — three distinct data-loss paths fixed: §A reasoning text accumulated in a thread-local `_reasoning_text` was invisible to `cancel_stream()` because it went out of scope when the thread was interrupted; §B live tool calls in thread-local `_live_tool_calls` were similarly lost; §C reasoning-only streams (no visible tokens) produced no partial assistant message because the thinking-block regex strip returned empty string and the `if _stripped:` guard skipped the append. The fix mirrors the existing `STREAM_PARTIAL_TEXT` pattern (#893) by adding two new shared dicts (`STREAM_REASONING_TEXT`, `STREAM_LIVE_TOOL_CALLS`) populated during streaming and read by `cancel_stream()`. The cancel path now appends the partial assistant message when content text, reasoning trace, OR tool calls exist (not just text). Eliminates "paid tokens disappeared" reports on Stop. 8 regression tests covering all three sections plus tools+text combinations. (`api/config.py`, `api/streaming.py`, `tests/test_issue1361_cancel_data_loss.py`) @bergeouss — PR #1375, fixes #1361
- **New profiles route sessions to the profile dir on first use, not back to default** — `get_hermes_home_for_profile()` had a `if profile_dir.is_dir(): return profile_dir; return _DEFAULT_HERMES_HOME` fallback. New profiles (no session yet, so no dir) routed every session back to default until the directory existed on disk — making profile switching silently broken for the first session of every new profile. Removed the `is_dir()` guard; the profile path is now returned unconditionally and the directory is created on first use by the agent/session layer. Path traversal is still blocked by the `_PROFILE_ID_RE` regex (`^[a-z0-9][a-z0-9_-]{0,63}$`); R19j tests were updated to pin that the regex is now the sole defense. R19c was tightened to assert the new behavior. 5 regression tests in `test_issue1195_session_profile_routing.py` covering existing-profile, non-existent-profile (the core fix), None, empty-string, and 'default' return paths. (`api/profiles.py`, `tests/test_issue798.py`, `tests/test_issue1195_session_profile_routing.py`) @bergeouss — PR #1373, fixes #1195


## [v0.50.250] — 2026-04-30

### Fixed
Expand Down
117 changes: 117 additions & 0 deletions api/agent_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,120 @@ def read_importable_agent_session_rows(
if limit is None:
return projected
return projected[:max(0, int(limit))]



def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[str]) -> dict[str, dict]:
"""Return compression-lineage metadata for known WebUI sidebar sessions.

WebUI sessions are persisted as JSON files, but Hermes Agent also mirrors
them into ``state.db.sessions`` for insights/session history. Compression
and cross-surface continuation create parent chains there. ``/api/sessions``
needs to surface that lineage to the sidebar so client-side collapse can
group logical continuations without mutating or deleting any session files.

Missing DBs, old schemas, or incomplete rows degrade to an empty mapping.
"""
wanted = {str(sid) for sid in (session_ids or []) if sid}
db_path = Path(db_path)
if not wanted or not db_path.exists():
return {}

try:
with sqlite3.connect(str(db_path)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("PRAGMA table_info(sessions)")
session_cols = {row[1] for row in cur.fetchall()}
if 'parent_session_id' not in session_cols or 'end_reason' not in session_cols:
return {}
# Scoped fetch via PRIMARY KEY + idx_sessions_parent rather than a
# full table scan. The sessions table grows unbounded over time
# (1000+ rows is normal, 10000+ for power users), and this function
# runs on every sidebar refresh — a full SELECT was ~50x slower
# than the indexed lookup at 1000 rows and scales linearly.
#
# Fetch the wanted ids first, then chase parent_session_id chains
# in batches until no new ids appear. Each batch hits PRIMARY KEY
# so it's effectively O(N) lookups.
#
# IN-clause is chunked to 500 to stay under SQLITE_MAX_VARIABLE_NUMBER
# on older sqlite (Python 3.9 ships sqlite 3.31 which defaults to 999;
# newer Python ships sqlite 3.32+ at 32766). On a power user with
# 2000+ sessions in the sidebar, an unchunked first hop would raise
# `OperationalError: too many SQL variables`, get swallowed by the
# except below, and silently disable lineage collapse forever.
# (Opus pre-release review of v0.50.251, SHOULD-FIX 2.)
IN_CHUNK = 500
rows: dict[str, dict] = {}
to_fetch = set(wanted)
# Cap walk depth to bound worst-case query count. Real lineage
# chains seen in production are <10 segments; anything longer is
# almost certainly pathological data and not worth chasing.
for _hop in range(20):
if not to_fetch:
break
fetch_list = list(to_fetch)
to_fetch = set()
for i in range(0, len(fetch_list), IN_CHUNK):
chunk = fetch_list[i:i + IN_CHUNK]
placeholders = ','.join('?' * len(chunk))
cur.execute(
f"SELECT id, parent_session_id, end_reason FROM sessions WHERE id IN ({placeholders})",
chunk,
)
for row in cur.fetchall():
rows[row['id']] = dict(row)
# Queue up parents we haven't fetched yet.
for sid in fetch_list:
parent_id = rows.get(sid, {}).get('parent_session_id')
if parent_id and parent_id not in rows and parent_id not in to_fetch:
to_fetch.add(parent_id)
except Exception:
return {}

metadata: dict[str, dict] = {}
for sid in wanted:
row = rows.get(sid)
if not row:
continue

parent_id = row.get('parent_session_id')
# Only expose parent_session_id when:
# 1) the parent actually exists in state.db (orphan refs would
# otherwise leak through and the frontend would treat them as
# sidebar grouping keys via #1358's _sessionLineageKey
# fall-through)
# 2) the parent's end_reason is one of {compression, cli_close} —
# i.e. only TRUE continuations. Without this, two distinct
# WebUI sessions sharing a `user_stop` parent would get
# collapsed into a single sidebar row by #1358's helper
# (it groups by parent_session_id as the third-fallback key).
# (Opus pre-release review of v0.50.251, SHOULD-FIX 1.)
parent_row = rows.get(parent_id) if parent_id else None
if parent_row and parent_row.get('end_reason') in {'compression', 'cli_close'}:
metadata.setdefault(sid, {})['parent_session_id'] = parent_id

root_id = sid
current_id = sid
segment_count = 1
seen = {sid}
while True:
current = rows.get(current_id)
parent_id = current.get('parent_session_id') if current else None
parent = rows.get(parent_id) if parent_id else None
if not parent or parent_id in seen:
break
if parent.get('end_reason') not in {'compression', 'cli_close'}:
break
root_id = parent_id
current_id = parent_id
seen.add(parent_id)
segment_count += 1

if root_id != sid:
entry = metadata.setdefault(sid, {})
entry['_lineage_root_id'] = root_id
entry['_compression_segment_count'] = segment_count

return metadata
2 changes: 2 additions & 0 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2115,6 +2115,8 @@ def _build_configured_model_badges() -> dict[str, dict[str, str]]:
CANCEL_FLAGS: dict = {}
AGENT_INSTANCES: dict = {} # stream_id -> AIAgent instance for interrupt propagation
STREAM_PARTIAL_TEXT: dict = {} # stream_id -> partial assistant text accumulated during streaming
STREAM_REASONING_TEXT: dict = {} # stream_id -> reasoning trace accumulated during streaming (#1361 §A)
STREAM_LIVE_TOOL_CALLS: dict = {} # stream_id -> live tool calls accumulated during streaming (#1361 §B)
SERVER_START_TIME = time.time()

# Agent cache: reuse AIAgent across messages in the same WebUI session so that
Expand Down
33 changes: 32 additions & 1 deletion api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
get_effective_default_model, _get_session_agent_lock,
)
from api.workspace import get_last_workspace
from api.agent_sessions import read_importable_agent_session_rows
from api.agent_sessions import read_importable_agent_session_rows, read_session_lineage_metadata

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -746,6 +746,31 @@ def _hide_from_default_sidebar(session: dict) -> bool:
return source == 'cron' or sid.startswith('cron_')


def _active_state_db_path() -> Path:
"""Return state.db for the active Hermes profile, degrading to HERMES_HOME."""
try:
from api.profiles import get_active_hermes_home
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
except Exception:
hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve()
return hermes_home / 'state.db'


def _enrich_sidebar_lineage_metadata(sessions: list[dict]) -> None:
"""Attach state.db compression lineage metadata used by sidebar collapse."""
try:
metadata = read_session_lineage_metadata(
_active_state_db_path(),
{s.get('session_id') for s in sessions},
)
except Exception:
return
for session in sessions:
sid = session.get('session_id')
if sid in metadata:
session.update(metadata[sid])


def all_sessions():
active_stream_ids = _active_stream_ids()
# Phase C: try index first for O(1) read; fall back to full scan
Expand Down Expand Up @@ -804,6 +829,7 @@ def all_sessions():
for s in result:
if not s.get('profile'):
s['profile'] = 'default'
_enrich_sidebar_lineage_metadata(result)
return result
except Exception:
logger.debug("Failed to load session index, falling back to full scan")
Expand Down Expand Up @@ -832,6 +858,7 @@ def all_sessions():
for s in result:
if not s.get('profile'):
s['profile'] = 'default'
_enrich_sidebar_lineage_metadata(result)
return result


Expand Down Expand Up @@ -1015,6 +1042,10 @@ def _cron_pid():
'raw_source': row.get('raw_source'),
'session_source': row.get('session_source'),
'source_label': row.get('source_label'),
'parent_session_id': row.get('parent_session_id'),
'_lineage_root_id': row.get('_lineage_root_id'),
'_lineage_tip_id': row.get('_lineage_tip_id'),
'_compression_segment_count': row.get('_compression_segment_count'),
'is_cli_session': True,
})
except Exception as _cli_err:
Expand Down
6 changes: 2 additions & 4 deletions api/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,10 @@ def get_hermes_home_for_profile(name: str) -> Path:
empty, 'default', or does not match the profile-name format (rejects path
traversal such as '../../etc').
"""
if not name or name == 'default' or not _PROFILE_ID_RE.match(name):
if not name or name == 'default' or not _PROFILE_ID_RE.fullmatch(name):
return _DEFAULT_HERMES_HOME
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
if profile_dir.is_dir():
return profile_dir
return _DEFAULT_HERMES_HOME
return profile_dir


_TERMINAL_ENV_MAPPINGS = {
Expand Down
22 changes: 20 additions & 2 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,28 @@ def _cron_output_content_window(text: str, limit: int = _CRON_OUTPUT_CONTENT_LIM
def _run_cron_tracked(job):
"""Wrapper that tracks running state around cron.scheduler.run_job."""
from cron.scheduler import run_job # import here — runs inside a worker thread
from cron.jobs import mark_job_run, save_job_output

job_id = job.get("id", "")
try:
run_job(job)
success, output, final_response, error = run_job(job)
save_job_output(job_id, output)

# Match the scheduled cron path: an apparently successful run with no
# final response should not leave the job looking healthy.
if success and not final_response:
success = False
error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)"

mark_job_run(job_id, success, error)
except Exception as e:
logger.exception("Manual cron run failed for job %s", job_id)
try:
mark_job_run(job_id, False, str(e))
except Exception:
logger.debug("Failed to mark manual cron run failure for %s", job_id)
finally:
_mark_cron_done(job.get("id", ""))
_mark_cron_done(job_id)

_PROVIDER_ALIASES = {
"claude": "anthropic",
Expand Down
Loading
Loading