feat(acr): ACR Store 1 — content-hash observation cache for repo-file descriptors (#305)

Adds ObservationStore (core:context) keyed on (repoRoot, path), an in-memory default and
a durable SqliteObservationStore (infrastructure:persistence), and wires it into the two
SessionOrchestratorArtifacts call sites that re-derive a SourceDescriptor from CAS bytes
on every call — a hit on matching content hash skips both the CAS read and the regex
extraction. First slice of docs/plans/2026-07-21-acr-knowledge-accretion.md (build order:
observations before fixes/plan-shapes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMbPmZZjcXhR2crU82zZ8S
This commit is contained in:
2026-07-21 17:37:25 +04:00
parent f08a784432
commit 5df35879eb
13 changed files with 251 additions and 6 deletions
+2
View File
@@ -8,6 +8,8 @@ dependencies {
implementation(project(":core:events"))
implementation(project(":core:artifacts"))
implementation(project(":core:sessions"))
testImplementation "org.jetbrains.kotlin:kotlin-test"
testImplementation "org.junit.jupiter:junit-jupiter"
}
tasks.named("koverVerify").configure { enabled = false }
@@ -0,0 +1,36 @@
package com.correx.core.context.observation
import java.util.concurrent.ConcurrentHashMap
/**
* ACR Store 1 (docs/plans/2026-07-21-acr-knowledge-accretion.md): a fact about a repo file,
* keyed on (repoRoot, path) — task-agnostic, any future run benefits. [contentHash] is the
* staleness check: a caller re-validates by hash before trusting [descriptorRender], per
* invariant #9 (never re-derive from a re-observed environment when the prior observation still
* holds). [descriptorRender] is [com.correx.core.sourcedesc.SourceDescriptor.render] output — the
* only thing every current call site actually consumes, so that's what's stored, not the object.
*/
data class Observation(
val repoRoot: String,
val path: String,
val contentHash: String,
val descriptorRender: String?,
val observedAtMs: Long,
)
interface ObservationStore {
suspend fun get(repoRoot: String, path: String): Observation?
suspend fun put(observation: Observation)
}
/** Default/test backend — process-lifetime only. Production wiring swaps in a durable store. */
class InMemoryObservationStore : ObservationStore {
private val entries = ConcurrentHashMap<Pair<String, String>, Observation>()
override suspend fun get(repoRoot: String, path: String): Observation? =
entries[repoRoot to path]
override suspend fun put(observation: Observation) {
entries[observation.repoRoot to observation.path] = observation
}
}
@@ -0,0 +1,43 @@
package com.correx.core.context.observation
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class InMemoryObservationStoreTest {
@Test
fun `put then get returns the stored observation`(): Unit = runBlocking {
val store = InMemoryObservationStore()
val obs = Observation("repoA", "src/Foo.kt", "hash1", "module=foo", 100L)
store.put(obs)
assertEquals(obs, store.get("repoA", "src/Foo.kt"))
}
@Test
fun `same path in different repos does not collide`(): Unit = runBlocking {
val store = InMemoryObservationStore()
store.put(Observation("repoA", "src/Foo.kt", "hashA", "a", 1L))
store.put(Observation("repoB", "src/Foo.kt", "hashB", "b", 2L))
assertEquals("hashA", store.get("repoA", "src/Foo.kt")?.contentHash)
assertEquals("hashB", store.get("repoB", "src/Foo.kt")?.contentHash)
}
@Test
fun `put overwrites the prior observation for the same key`(): Unit = runBlocking {
val store = InMemoryObservationStore()
store.put(Observation("repoA", "src/Foo.kt", "hash1", "old", 1L))
store.put(Observation("repoA", "src/Foo.kt", "hash2", "new", 2L))
assertEquals("hash2", store.get("repoA", "src/Foo.kt")?.contentHash)
}
@Test
fun `unknown key returns null`(): Unit = runBlocking {
assertNull(InMemoryObservationStore().get("repoA", "nope"))
}
}