Skip to content

Commit e1ad0b7

Browse files
committed
fix(cache): fingerprint SQLite WAL sidecars so reload picks up new sessions
OpenCode (and Hermes) run SQLite in WAL mode: new sessions land in <db>-wal while the main .db's size/mtime stay put until a checkpoint. cache_inputs() fingerprinted only the .db, so CachedStore kept serving the stale rollup and a reload (r) or the --web report's refresh never showed sessions written since. Include the -wal/-shm sidecars in the fingerprint; a read-only connection reads them, so the re-parse surfaces the new rows. Missing sidecars (a non-WAL DB, or a checkpoint that removed them) are skipped by the fingerprint's stat().
1 parent b98ca7f commit e1ad0b7

3 files changed

Lines changed: 88 additions & 4 deletions

File tree

src/opentab/stores/hermes.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,8 +403,12 @@ def _node(
403403
}
404404

405405
def cache_inputs(self) -> list[str]:
406-
# The single DB file whose (size, mtime) fingerprints the warm-start cache.
407-
return [self.db_path]
406+
# The DB file whose (size, mtime) fingerprints the warm-start cache, plus its
407+
# WAL sidecars: if Hermes runs SQLite in WAL mode, new sessions land in
408+
# <db>-wal and the main .db's mtime doesn't move until a checkpoint, so
409+
# fingerprinting the .db alone would let a reload serve a stale cache. Missing
410+
# sidecars (a non-WAL DB) are simply skipped by the fingerprint's stat().
411+
return [self.db_path, self.db_path + "-wal", self.db_path + "-shm"]
408412

409413
def workflows(self) -> list[Workflow]:
410414
self._sessions = None # reload on `r`

src/opentab/stores/opencode.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,16 @@ def _session_text_expr(self, alias: str, columns: list[str], fallback: str) -> s
187187
return f"coalesce({', '.join(parts)}, {fallback})"
188188

189189
def cache_inputs(self) -> list[str]:
190-
# The single DB file whose (size, mtime) fingerprints the warm-start cache.
191-
return [os.path.abspath(self.db)]
190+
# The DB file whose (size, mtime) fingerprints the warm-start cache, plus its
191+
# WAL sidecars. OpenCode runs SQLite in WAL mode, so new sessions land in
192+
# <db>-wal and the main .db's size/mtime don't move until a checkpoint -- so
193+
# fingerprinting the .db alone made a reload (r, or the report's refresh) serve
194+
# the stale cache and never show sessions written since. The sidecars move on
195+
# every commit; a read-only connection still reads them, so a re-parse sees the
196+
# new rows. Missing sidecars (a non-WAL DB, or a checkpoint that removed them)
197+
# are simply skipped by the fingerprint's stat().
198+
db = os.path.abspath(self.db)
199+
return [db, db + "-wal", db + "-shm"]
192200

193201
def workflows(self) -> list[Workflow]:
194202
# Load every root session; the App filters by the active range in memory

test_opentab.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7551,6 +7551,78 @@ def workflow_nodes(self, workflow_id):
75517551
assert app.store.node_calls == 2
75527552

75537553

7554+
def test_cache_invalidates_on_wal_write_so_reload_sees_new_opencode_sessions():
7555+
# OpenCode runs SQLite in WAL mode, so a new session lands in <db>-wal while the
7556+
# main .db's size/mtime don't move until a checkpoint. cache_inputs() must
7557+
# fingerprint the WAL sidecars, or CachedStore keeps serving the stale rollup and a
7558+
# reload (r) / the report's refresh never shows sessions written since -- the
7559+
# reported "--web refresh doesn't get new sessions" bug.
7560+
with tempfile.TemporaryDirectory() as tmp:
7561+
old_xdg = os.environ.get("XDG_CONFIG_HOME")
7562+
os.environ["XDG_CONFIG_HOME"] = os.path.join(tmp, "cfg") # isolate the cache dir
7563+
db = os.path.join(tmp, "opencode.db")
7564+
# Writer stays open the whole test with autocheckpoint off, so every commit
7565+
# stays in the -wal file and the main .db is never checkpointed/rewritten.
7566+
w = sqlite3.connect(db)
7567+
w.execute("PRAGMA journal_mode=WAL")
7568+
w.execute("PRAGMA wal_autocheckpoint=0")
7569+
w.executescript(
7570+
"""
7571+
create table session (
7572+
id text primary key, parent_id text, title text, directory text,
7573+
time_created integer, cost real default 0 not null,
7574+
tokens_input integer default 0 not null, tokens_output integer default 0 not null,
7575+
tokens_reasoning integer default 0 not null, tokens_cache_read integer default 0 not null,
7576+
tokens_cache_write integer default 0 not null
7577+
);
7578+
create table message (id text primary key, session_id text, data text);
7579+
"""
7580+
)
7581+
w.execute(
7582+
"insert into session values ('s1',null,'One','/work/repo',1760000000000,1.0,0,0,0,0,0)"
7583+
)
7584+
w.commit()
7585+
try:
7586+
store = ot.Store(db, type("A", (), {"demo": False})())
7587+
ci = store.cache_inputs()
7588+
assert db in ci and db + "-wal" in ci and db + "-shm" in ci # sidecars fingerprinted
7589+
7590+
cid = "opencode|" + db
7591+
cargs = type("A", (), {"demo": False, "no_cache": False})()
7592+
7593+
# Cold: parse s1 and write the cache (workflows + breakdown both fresh).
7594+
c1 = ot.CachedStore(store, cid, cargs)
7595+
assert [x.id for x in c1.workflows()] == ["s1"]
7596+
c1.model_breakdown()
7597+
assert c1.served_from_cache is False
7598+
7599+
# Warm: a fresh wrapper over the unchanged DB serves the cache untouched.
7600+
c2 = ot.CachedStore(store, cid, cargs)
7601+
c2.workflows()
7602+
assert c2.served_from_cache is True
7603+
7604+
# OpenCode adds a new session -> it lands in the WAL, main .db mtime unchanged.
7605+
mtime_before = os.stat(db).st_mtime_ns
7606+
w.execute(
7607+
"insert into session values ('s2',null,'Two','/work/repo',1760000100000,2.0,0,0,0,0,0)"
7608+
)
7609+
w.commit()
7610+
assert os.stat(db).st_mtime_ns == mtime_before # the WAL grew, not the .db
7611+
7612+
# A reload now MISSES the cache (the -wal fingerprint moved) and re-parses,
7613+
# so the new session is visible -- the fix.
7614+
c3 = ot.CachedStore(store, cid, cargs)
7615+
wf3 = c3.workflows()
7616+
assert c3.served_from_cache is False
7617+
assert sorted(x.id for x in wf3) == ["s1", "s2"]
7618+
finally:
7619+
w.close()
7620+
if old_xdg is None:
7621+
os.environ.pop("XDG_CONFIG_HOME", None)
7622+
else:
7623+
os.environ["XDG_CONFIG_HOME"] = old_xdg
7624+
7625+
75547626
def test_wsl_mount_root_and_windows_path_mapping():
75557627
with tempfile.TemporaryDirectory() as tmp:
75567628
# wsl.conf parsing: [automount] root= wins, comments stripped, missing -> /mnt.

0 commit comments

Comments
 (0)