fix(projects): open HermesDataService before filtering sessions

Sessions tab was showing "This project has N attributed sessions,
but none are in the recent history. They may have been deleted
from Hermes." on projects with valid sidecar entries and actual
sessions present in state.db. Root cause: the VM never opened
the DB handle.

`HermesDataService` is an actor with a lazily-initialised SQLite
pointer. Every query method short-circuits to `[]` when
`db == nil`. Callers have to open/refresh the handle explicitly
— InsightsViewModel does it (line 106), ActivityViewModel does
it (line 60). ProjectSessionsViewModel was constructed fresh
per project, never inherited a shared service, and never called
refresh() itself, so fetchSessions returned empty on every load
and the filter against the (correctly-populated) sidecar map
produced zero matches. The empty-state message ("may have been
deleted") fired on that false-negative.

The data was fine all along: sqlite3 ~/.hermes/state.db confirmed
both attributed sessions with source='acp', parent_session_id
IS NULL — they pass fetchSessions's WHERE clause cleanly. The
sidecar mappings were correct. The file watcher was firing. The
only missing piece was the DB-open precondition.

Fix: `_ = await dataService.refresh()` before fetchSessions,
mirroring the pattern used by every other feature VM that
consumes HermesDataService. Also adds a `close()` on the VM + an
onDisappear handler on the view, so the handle doesn't dangle
once the tab isn't visible — same cleanup ActivityView has.

This is NOT forward-only. Existing sidecar entries that
currently show the misleading empty-state will surface
correctly as soon as users rebuild — no data migration, no
re-create-the-chat, no backfill. The bug was "couldn't read what
was already there," not "lost old data."

93/93 Swift tests still pass. No test change — the fix is an
integration-level call-ordering detail that isn't meaningfully
testable without mocking HermesDataService (overkill for a
two-line fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 01:27:06 +02:00
parent 7656ad8052
commit fb833d4a0a
2 changed files with 26 additions and 0 deletions
@@ -51,6 +51,17 @@ final class ProjectSessionsViewModel {
return
}
// Open (or re-open for remote) the DB handle before querying.
// `HermesDataService` is an actor with a lazily-initialised
// SQLite pointer; every query method short-circuits to `[]`
// when `db == nil`. This VM constructs its own service
// instance (separate from ChatViewModel / InsightsVM /
// ActivityVM), so we have to open it ourselves. Same
// pattern used by those other VMs (`refresh()` rather than
// `open()` because refresh also re-pulls the remote-server
// snapshot on each call local is a cheap no-op).
_ = await dataService.refresh()
// Fetch a generous page; we filter client-side by attribution
// map membership. The 200 ceiling matches other feature VMs
// (ActivityViewModel, InsightsViewModel). HermesDataService
@@ -73,4 +84,13 @@ final class ProjectSessionsViewModel {
emptyStateHint = nil
}
}
/// Release the underlying DB handle. Safe to call repeatedly; the
/// service re-opens on the next `load()`. Mirrors the pattern in
/// ActivityViewModel.swift:80 view calls this on `.onDisappear`
/// so file descriptors and the SQLite cache don't dangle once
/// the tab isn't visible.
func close() async {
await dataService.close()
}
}
@@ -44,6 +44,12 @@ struct ProjectSessionsView: View {
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel?.load() }
}
.onDisappear {
// Release the SQLite handle so it doesn't dangle once
// the user leaves this tab. `load()` will re-open next
// time. Mirrors ActivityView's disappear cleanup.
Task { await viewModel?.close() }
}
}
// MARK: - Header