fix(memory): trailing-delimiter turnId namespace + newline fingerprint

- repomap: tag prefix match requires trailing ':' so /repo can't match /repo2
  (L3RepoKnowledgeRetriever filter + ProjectMemoryService write; the existing
  existsByTurnIdPrefix check already included the delimiter)
- WorkspaceStateProbe fingerprint joins entries with newline to match docstring
- regression test for /repo vs /repo2 non-collision

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 08:08:49 +00:00
parent f783eed4be
commit 784accb08d
4 changed files with 36 additions and 3 deletions
@@ -17,7 +17,7 @@ class L3RepoKnowledgeRetriever(
override suspend fun retrieve(sessionId: SessionId, query: String, k: Int): List<RepoKnowledgeHit> { override suspend fun retrieve(sessionId: SessionId, query: String, k: Int): List<RepoKnowledgeHit> {
val vector = embedder.embed(query) val vector = embedder.embed(query)
return l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR)) return l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR))
.filter { it.entry.turnId.startsWith("repomap:$repoRoot") } .filter { it.entry.turnId.startsWith("repomap:$repoRoot:") }
.take(k) .take(k)
.map { RepoKnowledgeHit(path = it.entry.text.substringBefore(":"), text = it.entry.text, score = it.score) } .map { RepoKnowledgeHit(path = it.entry.text.substringBefore(":"), text = it.entry.text, score = it.score) }
} }
@@ -85,7 +85,7 @@ class ProjectMemoryService(
), ),
), ),
) )
val tag = if (stateKey != null) "repomap:$repoRoot:$stateKey" else "repomap:$repoRoot" val tag = if (stateKey != null) "repomap:$repoRoot:$stateKey" else "repomap:$repoRoot:"
entries.forEach { entry -> entries.forEach { entry ->
runCatching { runCatching {
val text = entry.path + if (entry.symbols.isEmpty()) "" else ": ${entry.symbols.joinToString(", ")}" val text = entry.path + if (entry.symbols.isEmpty()) "" else ": ${entry.symbols.joinToString(", ")}"
@@ -109,7 +109,7 @@ class RealWorkspaceStateProbe : WorkspaceStateProbe {
.toList() .toList()
} }
val digest = MessageDigest.getInstance("SHA-256") val digest = MessageDigest.getInstance("SHA-256")
lines.forEach { digest.update(it.toByteArray()) } digest.update(lines.joinToString("\n").toByteArray())
return digest.digest().joinToString("") { "%02x".format(it) }.take(FINGERPRINT_HEX_LENGTH) return digest.digest().joinToString("") { "%02x".format(it) }.take(FINGERPRINT_HEX_LENGTH)
} }
} }
@@ -12,6 +12,7 @@ import com.correx.core.events.types.SessionId
import com.correx.infrastructure.persistence.InMemoryEventStore import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.nio.file.Path import java.nio.file.Path
@@ -121,4 +122,36 @@ class ProjectMemoryServiceReuseTest {
"entries should be embedded with stateKey tag", "entries should be embedded with stateKey tag",
) )
} }
@Test
fun `repoRoot prefix does not collide across roots — repo vs repo2`(): Unit = runBlocking {
val es = InMemoryEventStore()
val l3 = InMemoryL3MemoryStore()
val probe = FakeWorkspaceStateProbe(WorkspaceState("git:hash1", "git", "main", false))
val embedder = ConstantEmbedderReuse()
// Index /repo and /repo2 into the same shared L3 store with the same stateKey.
// The trailing-':' delimiter on the repomap: tag must keep their entries from bleeding
// across roots: "/repo" must NOT match tags written for "/repo2".
val repoEntries = listOf(RepoMapEntry(path = "src/Repo.kt", score = 1.0, symbols = listOf("RepoClass")))
val repo2Entries = listOf(RepoMapEntry(path = "src/Repo2.kt", score = 1.0, symbols = listOf("Repo2Class")))
service(es, l3, CountingIndexer(repoEntries), probe, root = "/repo")
.indexAndRecord(SessionId("session-collide-repo"), "/repo")
service(es, l3, CountingIndexer(repo2Entries), probe, root = "/repo2")
.indexAndRecord(SessionId("session-collide-repo2"), "/repo2")
// existsByTurnIdPrefix with the delimiter must not match the other root.
assertTrue(l3.existsByTurnIdPrefix("repomap:/repo:"), "tag for /repo should exist")
assertTrue(l3.existsByTurnIdPrefix("repomap:/repo2:"), "tag for /repo2 should exist")
// The retriever scoped to /repo must return only /repo's entry, never /repo2's.
val hits = L3RepoKnowledgeRetriever(embedder = embedder, l3MemoryStore = l3, repoRoot = "/repo")
.retrieve(SessionId("session-collide-query"), "anything", k = 10)
assertTrue(hits.isNotEmpty(), "retriever for /repo should find /repo entries")
val text = hits.joinToString(" ") { it.text }
assertTrue(text.contains("src/Repo.kt"), "should contain /repo entry; got: $text")
assertFalse(text.contains("src/Repo2.kt"), "must NOT contain /repo2 entry (prefix collision); got: $text")
}
} }