Phase 2 of cleanup now sweeps _index.json for stale entries with no
backing file and no in-memory session, removing them regardless of
title (catches multi-language 'Untitled' variants). Phase 3 only
deletes the index when Phase 1 removed files AND Phase 2 couldn't
run, fixing the cache-busting on every cleanup call.
Fix two phase-interaction bugs flagged by Greptile review:
- Track phase1_removed_ids to prevent Phase 2 double-counting
sessions already removed from disk by Phase 1.
- Track phase2_rewrote_index so Phase 3 skips deletion when
Phase 2 already cleaned the index in-place.
Adds test_issue5331_index_only_ghost_cleanup.py with 12 test cases.
The agent source at /opt/hermes inside the container may contain a
.playwright/ directory with browser dependency files that have restricted
permissions. rsync fails with exit code 23 when attempting to read them,
which kills the container build ("Failed to stage hermes-agent source").
- Add --exclude=.playwright to rsync in docker_init.bash
- Add rm -rf .playwright to cp -a fallback path for symmetry
- Update test_docker_init_excludes_egg_info_during_staging to assert
both the rsync --exclude and a broad .playwright presence check
- Add a brief note in AGENTS.md Contribution style about mirroring
directory exclusions in both rsync and cp paths
Fixes#5315
Sidebar rows whose backing state.db session has zero messages (a '+'-click
that opened a row but the first turn never committed, or a sidebar nav that
opened then closed before any message landed) now get pruned instead of
lingering forever. The existing #3238/#4591 orphan-prune path only handled
CLI/API-server sidecars; this adds a parallel pass for native-WebUI rows.
- api/models.py: add agent_session_zero_message_sids() batch helper that
mirrors agent_session_rows_existing()'s safe-degrade contract (returns
frozenset() on any error so a transient failure never causes data loss).
Add a small tombstone file at SESSION_DIR/_pruned_webui_orphans.json
(capped at the last 500 sids) so the next poll's
recover_missing_index_sidecars does not re-add a pruned orphan to the
sidebar index — the per-poll fsync'd index write + state.db probe loop
the original PR would have hit on every poll for every orphan. The
helper, the load, and the full-scan fallback all honor the tombstone.
new_session() and import_cli_session() clear any matching tombstone
entry so a fresh session with the same id isn't shadowed.
- api/routes.py: extract the webui zero-message prune into a private
_prune_orphaned_webui_zero_message_sessions() helper, then call it
from BOTH branches of _build_session_list_cache_payload. Established
installs pin show_cli_sessions=False (per api/config.py:7637-7648)
and are exactly the long-time users who accumulated the #4985 404
orphans — the original PR only ran the prune in the if branch and
silently skipped the else branch (maintainer review
IC_kwDOR1LuPM8AAAABHsyFGg). The helper also honors the tombstone
defensively so the prune can't thrash on a row the index still
carries from a prior poll.
- tests/test_issue4985_orphaned_webui_zero_message.py: 39 regression
tests — the original 25 (7 helper-direct + 12 monkeypatch predicate
tests + 6 real-pipeline end-to-end tests) plus 14 follow-up tests
driven by the MUST-FIX / tombstone / r6 signal-swap reviews:
* test_helper_prunes_orphan_keeps_safety_retained_rows — direct call
to the extracted helper with a row set covering every shape the
gate is responsible for.
* test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_false
— explicit regression for the established-install case the
original PR missed.
* test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_true
— companion positive control.
* test_tombstone_persists_across_polls — drives two pipeline builds,
asserts prune fires once total and the tombstoned sid is NOT
re-added to SESSION_INDEX_FILE.
* test_tombstone_does_not_block_new_session_with_same_id — defensive
cover for the new_session() / import_cli_session() tombstone clear.
* test_tombstone_trimmed_to_last_N_entries — verifies the cap.
* test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_true
— companion positive control (show_cli_sessions=True path).
* test_tombstone_self_heals_when_message_added — confirms
Session.save()'s belt-and-suspenders tombstone clear fires when
a sidecar commit lands with len(messages) > 0.
* test_tombstone_loader_is_fail_open_on_corrupt_file[non-json|version-mismatch|non-dict-root]
— 3-parameter regression for the fail-open corrupt-file contract
(#5023 lesson).
* test_sidecar_only_webui_session_is_retained — sidecar JSON carries
real messages but state.db.messages is empty (mirroring lag); the
r6 loaded-sidecar probe retains the row.
* test_sidecar_only_webui_session_self_heals_tombstone — the r4.5
self-heal path combined with the r6 sidecar-only retention rule.
* test_truly_empty_sidecar_with_title_still_pruned — companion
negative test that the r6 fix does NOT over-retain empty sidecars
with non-Untitled titles.
* test_sidecar_only_webui_session_retained_when_show_cli_sessions_false
— established-install path also respects the r6 retention rule.
- CHANGELOG.md: Unreleased entry under Fixed.
fix: target post-#1171 survivors + hoist + tombstone + r6 sidecar-load probe
The r5 sidecar-only retention signal keyed off the row's cached
``message_count`` — which is stale-positive on the very phantom rows
#4985 exists to prune (sidecar ``messages`` empty but cached count > 0).
The r6 signal swaps that cached-count probe for a ``Session.load(sid)
.messages`` probe, gated to ``state.db``-empty candidates only so the
common live-row path stays a no-op. Three retain tests now write real
populated ``messages`` arrays (not just ``[]``) so the loaded-sidecar
check has something to find; the stale-count phantom test is left as
the canonical r6 regression (its ``messages=[]`` + ``message_count=5``
sidecar is exactly the shape the r6 loaded-sidecar check correctly
classifies as orphan). Maintainer review 4584722701.