v1.38.9
1.38.9 - 2026-09-07
Fixed
hippo forgetcounted towardtotal_forgottenonly when the command missed the server. The dispatch routes throughrunViaServerIfAvailablewhenhippo serveis up (src/cli.ts:9147), but the counter lived in the CLI's direct path andapi.forgetincremented nothing, so the same command moved the number or not depending on whether a server happened to be running. Anyone runninghippo servefull time saw a counter that tracked only the forgets that missed it. v1.38.7 fixed one instance of this class by moving the archive counter intoapi.archiveRaw; this is the plain-forget half.api.forgetnow counts andsrc/cli.tsno longer does, because that function's only two callers (src/cli.ts:3698and the HTTP route atsrc/server.ts:1089) are the two paths of one user command, so no surface can miss it. Pinned by a real-server test that runs a routed pair and a direct pair and asserts the recorded actor on each, so it cannot pass by taking the direct path twice.- The comment above the thin-client routing filter claimed salience gates need the direct path. They do not.
richFlag(src/cli.ts:8808) has no salience condition, andapi.rememberhas no gate, so a routed remember stores what a direct one would skip. Measured on a real spawned server withsalience.enabled: true: a duplicate isSkipped (100% overlap)with no server up andRememberedtwice with one up. Same shape as the v1.38.7 archive bug, a comment describing behaviour nobody re-checked. The comment now states the bypass; the bypass itself is a product call and is filed inTODOS.md. hippo statscounters lost increments under concurrency, and a write to one counter could roll back another.updateStats(src/store.ts:2316) read all threemetacounters, added the delta in JS, and wrote all three back, with no transaction. Two writers interleaving between the read and the write lost one increment, and because the two counters the caller never named were written back too, a stale total could be stamped over another process's committed value. Reproduced with real node processes before the fix: four workers doing 40 increments each counted 140 of 160, and two workers incrementing different counters left one at 52 of 60. Both binds are the same string on purpose:node:sqlitebinds a JS number as REAL, which stores"1.0"into this TEXT column. Each counter is now a single atomicINSERT ... ON CONFLICT DO UPDATE SET value = CAST(meta.value AS INTEGER) + CAST(? AS INTEGER), applied only to the counters the delta names. No call site changes: all nine increment sites route through this one writer.bootstrapLegacyStore(src/store.ts:1128-1130) also writes these keys, but it sets absolute values once on a cold legacy store, so it is not part of this race. Per-counter atomicity is sufficient because the only consumer reads the three independently (src/cli.ts:3539-3541).
remember was attempted in this release and reverted before merge. There is no correct place for its counter yet: api.remember is the bulk write path for the Slack and GitHub connectors and for import, and the POST /v1/memories route is not a user surface either, since deploy/aml/adapter/adapter.mjs posts to it per leaderboard add and the Python SDK is a general client on it. Counting there would also make two paths that store different things report the same number. Recorded in TODOS.md behind the salience item. Two related gaps stay open there too: an HTTP-only recall does not count, and no MCP operation counts.
Known limitations
- Two processes opening a store that does not exist yet can still crash with
database is locked(errcode 517,SQLITE_BUSY_SNAPSHOT) insiderunMigrations, whichbusy_timeoutdoes not retry. Found while building the test for this fix and recorded inTODOS.md; it needs its own reproduce-and-measure pass. Warm stores are unaffected, becauserunMigrationsshort-circuits once the schema version is current.