Skip to content

Commit 20af774

Browse files
jkyberneeesclaude
andcommitted
fix(memory): address PR #27 verification findings (off-lock rebuild + hardening)
AI Verification Protocol pass (agents B/C/D) on PR #27 surfaced one HIGH finding plus defensive gaps. Repairs: - HIGH (2.1/2.5 network-I/O-under-lock): episodeVectorIndex.ensureFresh now fits + embeds the corpus on a FRESH embedder instance OFF the write lock, then swaps the store/embedder in atomically. A single-flight guard collapses concurrent rebuilds and a dirty-sequence check folds in a write that lands mid-rebuild. A slow/remote embedding backend can no longer serialize every concurrent recall behind one rebuild under odek serve. New test TestRebuildDoesNotSerializeRecall fails against the old lock-held-on-network code; whole package stays race-clean. - cosineVector now guards NaN/Inf (hostile/buggy embedding backend) so ranking order stays well-defined, mirroring MergeDetector.Classify. - MergeDetector.ReplaceEntry guards a desynced vecs/corpus (post-failed-Fit) so it can never index-panic. - Tests closing the verification coverage gaps: config-loader end-to-end parse of memory.embedding (C2), api_key ${ENV_VAR} expansion (C10), and the safety-critical failure-mode net — ranker recency fallback + merge add-without- merge (C12) and dedup-never-deletes-on-embed-error (C13). - Docs: corrected the rebuild-cache wording (one batch over the corpus, off lock), added the base_url egress/SSRF caveat, and the dims:0 model-change note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4609f5b commit 20af774

7 files changed

Lines changed: 398 additions & 51 deletions

File tree

docs/CONFIG.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -262,16 +262,25 @@ that matches by meaning.
262262

263263
Operational notes:
264264

265-
- **Per-turn recall stays cheap.** Episode vectors are cached in a persisted
266-
index; a loop turn costs at most one embedding call (the query), bounded by
265+
- **Per-turn recall stays cheap.** Episode vectors live in a persisted index; a
266+
loop turn costs at most one embedding call (the query), bounded by
267267
`timeout_seconds`. If the backend is down, recall degrades to "no context"
268-
and rebuilds back off for 30s — the agent loop is never blocked.
268+
and rebuilds back off for 30s — the agent loop is never blocked. The index
269+
rebuild that follows a new episode (session-end) embeds the corpus on a fresh
270+
client *off* the index lock, so a slow backend never serializes concurrent
271+
recall; it is one batch call over the episode summaries.
269272
- **Switching backends is safe.** The persisted index records which embedding
270273
space it was built in; changing `provider`/`model`/`dims` automatically
271-
invalidates it and rebuilds on next use (one batch embedding call).
272-
- **Episode summaries leave the machine.** With `"http"`, episode summaries and
273-
fact entries are sent to the configured endpoint for embedding. Point it at a
274-
local server (Ollama/llama.cpp) if that must not happen.
274+
invalidates it and rebuilds on next use (one batch embedding call). Note: with
275+
`dims: 0`, if a server silently changes a model's output dimensionality (e.g.
276+
a model upgrade under the same name) the fingerprint cannot detect it; recall
277+
self-heals to "no context" on the dimension mismatch and rebuilds on the next
278+
write. Pin `dims` if you want such a change to force an explicit rebuild.
279+
- **`base_url` is an egress target — point it only at a server you trust.** Every
280+
episode summary and fact entry is POSTed there for embedding. The URL is used
281+
verbatim with no allowlist, so do not point it at internal/metadata endpoints
282+
(e.g. cloud metadata services) you would not otherwise expose. Prefer a local
283+
server (Ollama/llama.cpp) when episode/fact text must not leave the machine.
275284

276285
### ⚠️ `extract_facts` — automatic fact learning (opt-in, off by default)
277286

docs/MEMORY.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,11 +248,14 @@ Mechanics (`internal/memory/embedder.go`):
248248
`episodes_index_meta.json`; changing provider/model/dims invalidates and
249249
rebuilds it automatically. Pre-existing RP indexes (no meta file) keep
250250
loading without a rebuild.
251-
- The HTTP embedder caches text→vector, so an index rebuild after a new
252-
episode only embeds texts it hasn't seen — one batch call.
251+
- The HTTP embedder caches text→vector within an instance, so per-turn query
252+
embeds are not re-sent. An index rebuild (after a new episode) runs on a
253+
fresh client *off the index lock* — one batch call over the corpus — so a
254+
slow backend never serializes concurrent recall.
253255
- Failure mode: embedding errors degrade to "no recall context" / "add fact
254-
without merge check"; a failed index rebuild backs off for 30s so a down
255-
backend is not re-hit every loop turn. The agent loop is never blocked.
256+
without merge check" / recency ranking — never a wrong dedup (which would
257+
delete the matched episode). A failed index rebuild backs off for 30s so a
258+
down backend is not re-hit every loop turn. The agent loop is never blocked.
256259

257260
See `docs/CONFIG.md``embedding` for all fields and the privacy note
258261
(summaries are sent to the configured endpoint — use a local server if that

internal/config/loader_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,3 +893,45 @@ func TestLoadConfig_LegacyAPIKeyEnvVarLost(t *testing.T) {
893893
t.Error("ODEK_API_KEY should be present in child env after re-injection")
894894
}
895895
}
896+
897+
// TestLoadConfig_MemoryEmbeddingSection verifies the memory.embedding config
898+
// section is parsed and propagated through LoadConfig/resolveMemory, and that
899+
// the raw ${ENV_VAR} placeholders survive into ResolvedConfig (expansion is
900+
// deferred to embedder construction, where both base_url and api_key are run
901+
// through os.ExpandEnv). Closes the C2 end-to-end config gap surfaced by the
902+
// PR #27 verification pass.
903+
func TestLoadConfig_MemoryEmbeddingSection(t *testing.T) {
904+
dir := t.TempDir()
905+
t.Setenv("HOME", dir)
906+
t.Chdir(dir)
907+
if err := os.WriteFile(filepath.Join(dir, "odek.json"), []byte(`{
908+
"memory": {
909+
"embedding": {
910+
"provider": "http",
911+
"base_url": "${ODEK_EMBED_URL}",
912+
"model": "nomic-embed-text",
913+
"api_key": "${ODEK_EMBED_KEY}",
914+
"dims": 768,
915+
"timeout_seconds": 7
916+
}
917+
}
918+
}`), 0644); err != nil {
919+
t.Fatal(err)
920+
}
921+
cfg := LoadConfig(CLIFlags{})
922+
emb := cfg.Memory.Embedding
923+
if emb == nil {
924+
t.Fatal("memory.embedding was not parsed into ResolvedConfig")
925+
}
926+
if emb.Provider != "http" || emb.Model != "nomic-embed-text" {
927+
t.Errorf("provider/model = %q/%q, want http/nomic-embed-text", emb.Provider, emb.Model)
928+
}
929+
if emb.Dims != 768 || emb.TimeoutSeconds != 7 {
930+
t.Errorf("dims/timeout = %d/%d, want 768/7", emb.Dims, emb.TimeoutSeconds)
931+
}
932+
// Raw config keeps ${VAR} (expansion happens at embedder construction); assert
933+
// the literal so a future eager-expand change is caught deliberately.
934+
if emb.BaseURL != "${ODEK_EMBED_URL}" || emb.APIKey != "${ODEK_EMBED_KEY}" {
935+
t.Errorf("base_url/api_key = %q/%q, want unexpanded ${...} placeholders", emb.BaseURL, emb.APIKey)
936+
}
937+
}

internal/memory/episode_index.go

Lines changed: 81 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,20 @@ type scoredEpisode struct {
5252
// Thread-safety: all exported methods hold vi.mu as appropriate.
5353
// Per-directory singleton: see sharedEpisodeIndex.
5454
type episodeVectorIndex struct {
55-
mu sync.RWMutex
56-
store *vector.Store
57-
emb textEmbedder
58-
dir string
59-
ready bool
60-
dirty bool
61-
failedAt time.Time // last failed rebuild; zero = never failed
55+
mu sync.RWMutex
56+
store *vector.Store
57+
emb textEmbedder
58+
// newEmb builds a FRESH embedder instance for a rebuild. Rebuilds embed on
59+
// a detached instance off-lock (see ensureFresh), so the live (emb, store)
60+
// pair stays valid and consistent until the atomic swap — a query never
61+
// embeds against a half-refitted embedder.
62+
newEmb func() textEmbedder
63+
dir string
64+
ready bool
65+
dirty bool
66+
rebuilding bool // single-flight guard: a rebuild is embedding off-lock
67+
dirtySeq uint64 // bumped by markDirty; reconciled after an off-lock rebuild
68+
failedAt time.Time // last failed rebuild; zero = never failed
6269
}
6370

6471
// indexMeta is the persisted embedding-space identity (episodeIndexMetaFile).
@@ -104,7 +111,7 @@ func sharedEpisodeIndex(dir string, newEmb func() textEmbedder) *episodeVectorIn
104111
if vi, ok := epIdxes[key]; ok {
105112
return vi
106113
}
107-
vi := &episodeVectorIndex{dir: abs, emb: emb}
114+
vi := &episodeVectorIndex{dir: abs, emb: emb, newEmb: newEmb}
108115
epIdxes[key] = vi
109116
return vi
110117
}
@@ -117,6 +124,7 @@ func sharedEpisodeIndex(dir string, newEmb func() textEmbedder) *episodeVectorIn
117124
func (vi *episodeVectorIndex) markDirty() {
118125
vi.mu.Lock()
119126
vi.dirty = true
127+
vi.dirtySeq++
120128
vi.failedAt = time.Time{}
121129
vi.mu.Unlock()
122130
}
@@ -150,34 +158,78 @@ func (vi *episodeVectorIndex) search(query string, k int) []scoredEpisode {
150158

151159
// ensureFresh loads or rebuilds the index as needed. Must NOT be called while
152160
// holding vi.mu (it acquires it internally).
161+
//
162+
// The expensive part of a rebuild — fitting the embedder and embedding the
163+
// whole corpus, which is a blocking network call for the HTTP backend — runs
164+
// OFF the lock on a fresh embedder instance, then the result is swapped in
165+
// atomically under the lock. This keeps a slow embedding backend from
166+
// serializing every concurrent recall behind one rebuild (search runs per
167+
// turn; under `odek serve` all connections funnel through this per-dir
168+
// singleton). A single-flight guard (rebuilding) collapses a thundering herd
169+
// of concurrent rebuilds into one, and a dirty-sequence check folds in any
170+
// episode write that lands mid-rebuild.
153171
func (vi *episodeVectorIndex) ensureFresh() {
154172
vi.mu.RLock()
155-
if vi.ready && !vi.dirty {
156-
vi.mu.RUnlock()
173+
ready := vi.ready && !vi.dirty
174+
vi.mu.RUnlock()
175+
if ready {
157176
return
158177
}
159-
vi.mu.RUnlock()
160178

161179
vi.mu.Lock()
162-
defer vi.mu.Unlock()
163180
if vi.ready && !vi.dirty {
164-
return // double-checked
181+
vi.mu.Unlock()
182+
return
165183
}
166184
// Back off after a failed rebuild so a down embedding backend is not
167185
// re-hit on every loop turn (search runs per turn).
168186
if !vi.failedAt.IsZero() && time.Since(vi.failedAt) < rebuildRetryInterval {
187+
vi.mu.Unlock()
169188
return
170189
}
171-
172-
// Cold start without a pending write: try the persisted state first.
190+
// Single-flight: if another goroutine is already rebuilding off-lock, serve
191+
// the current state for this turn rather than launch a duplicate (and a
192+
// duplicate batch embed) — the in-flight rebuild will publish shortly.
193+
if vi.rebuilding {
194+
vi.mu.Unlock()
195+
return
196+
}
197+
// Cold start without a pending write: try the persisted state first
198+
// (disk-only, fast — fine to do under the lock).
173199
if !vi.ready && !vi.dirty {
174200
if vi.tryLoadLocked() {
201+
vi.mu.Unlock()
175202
return
176203
}
177204
}
178-
// Either cold-start without usable persisted state, or dirty after a
179-
// write — full rebuild.
180-
vi.rebuildLocked()
205+
// Rebuild needed. Snapshot the dirty sequence, take a fresh embedder, and
206+
// release the lock before the network-bound embedding work.
207+
vi.rebuilding = true
208+
seq := vi.dirtySeq
209+
emb := vi.newEmb()
210+
vi.mu.Unlock()
211+
212+
store := buildEpisodeStore(vi.readAllSummaries(), emb)
213+
214+
vi.mu.Lock()
215+
defer vi.mu.Unlock()
216+
vi.rebuilding = false
217+
if store == nil {
218+
// Embedding failed (e.g. backend down) — keep the previous index, if
219+
// any, serving and start the retry cool-down.
220+
vi.failedAt = time.Now()
221+
return
222+
}
223+
vi.store = store
224+
vi.emb = emb
225+
vi.ready = true
226+
vi.failedAt = time.Time{}
227+
// Only clear dirty if no write landed while we were rebuilding off-lock;
228+
// otherwise leave it set so the next search rebuilds with the newest data.
229+
if vi.dirtySeq == seq {
230+
vi.dirty = false
231+
}
232+
vi.saveLocked()
181233
}
182234

183235
// tryLoadLocked attempts to load persisted state. Returns true on success.
@@ -219,26 +271,23 @@ func legacyRPFingerprint() string {
219271
return newRPTextEmbedder(episodeVectorDim).fingerprint()
220272
}
221273

222-
// rebuildLocked reads all episode summaries from disk, fits the embedder on
223-
// the full corpus, and persists the result. On embedding failure (e.g. a
224-
// remote backend being down) the previous index — if any — is kept serving
225-
// and a retry cool-down starts. Caller must hold vi.mu (write lock).
226-
func (vi *episodeVectorIndex) rebuildLocked() {
227-
texts := vi.readAllSummaries()
228-
274+
// buildEpisodeStore fits emb on the full corpus and returns a populated vector
275+
// store, or nil on any embedding failure (e.g. a remote backend being down) so
276+
// the caller can keep the previous index serving. It takes NO lock and touches
277+
// no shared index state — it operates entirely on its arguments and a fresh
278+
// embedder, which is what lets ensureFresh run it off vi.mu.
279+
func buildEpisodeStore(texts []idText, emb textEmbedder) *vector.Store {
229280
corpus := make([]string, len(texts))
230281
for i, t := range texts {
231282
corpus[i] = t.text
232283
}
233284

234-
if err := vi.emb.fit(corpus); err != nil {
235-
vi.failedAt = time.Now()
236-
return
285+
if err := emb.fit(corpus); err != nil {
286+
return nil
237287
}
238-
vecs, err := vi.emb.embedAll(corpus)
288+
vecs, err := emb.embedAll(corpus)
239289
if err != nil {
240-
vi.failedAt = time.Now()
241-
return
290+
return nil
242291
}
243292

244293
store := vector.NewStore(vector.CosineDistance)
@@ -248,12 +297,7 @@ func (vi *episodeVectorIndex) rebuildLocked() {
248297
}
249298
store.Add(t.id, vecs[i])
250299
}
251-
252-
vi.store = store
253-
vi.ready = true
254-
vi.dirty = false
255-
vi.failedAt = time.Time{}
256-
vi.saveLocked()
300+
return store
257301
}
258302

259303
type idText struct {

internal/memory/episodes.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,15 @@ func cosineVector(a, b vector.Vector) float32 {
862862
if normA == 0 || normB == 0 {
863863
return 0
864864
}
865-
return float32(dot / (math.Sqrt(normA) * math.Sqrt(normB)))
865+
sim := dot / (math.Sqrt(normA) * math.Sqrt(normB))
866+
if math.IsNaN(sim) || math.IsInf(sim, 0) {
867+
// A buggy/hostile embedding backend can return NaN/Inf components; a
868+
// NaN score breaks sort ordering (non-strict-weak). Treat as "no
869+
// similarity" so ranking stays well-defined. Mirrors the NaN guard in
870+
// MergeDetector.Classify.
871+
return 0
872+
}
873+
return float32(sim)
866874
}
867875

868876
// truncateAtRune returns s truncated to at most maxBytes bytes, always

internal/memory/merge.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,14 +173,19 @@ func (m *MergeDetector) ReplaceEntry(idx int, entry string) {
173173
}
174174
m.corpus[idx] = entry
175175
if err := m.emb.fit(m.corpus); err != nil {
176-
m.vecs[idx] = nil
176+
m.vecs = nil // a failed fit invalidates the whole precomputed set
177177
return
178178
}
179179
vec, err := m.emb.embed(entry)
180180
if err != nil {
181181
vec = nil
182182
}
183-
m.vecs[idx] = vec
183+
// A prior failed Fit can leave m.vecs nil or shorter than m.corpus; guard
184+
// the write so a desynced index never panics here (Classify already treats
185+
// an empty/short vecs as "nobody").
186+
if idx < len(m.vecs) {
187+
m.vecs[idx] = vec
188+
}
184189
}
185190

186191
// Corpus returns the current corpus (for inspection).

0 commit comments

Comments
 (0)