refactor(acr): ACR Store 1 as a disposable memo, not a durable side-store (#305)

The shipped Store 1 persisted repo-file descriptors in a new SqliteObservationStore
— a second, unsynchronized SQLite writer on the event-log DB (no WAL/busy_timeout,
one shared Connection across coroutines) holding a fact the log already carries. That
violates "the event log is the only source of truth" (inv #1/#8): an Observation is
just FileWrittenEvent.path + postImageHash + describe(bytes).render(), and render is a
pure function of content-addressed CAS bytes.

Replace the whole durable apparatus with an in-process memo (descriptorMemo, keyed on
repoRoot+path+contentHash) on the orchestrator, next to the existing artifactContentCache.
Rebuilt from the log on restart, disposable, no external store. Preserves the perf win
(skip CAS read + regex on repeat describes) with none of the concurrency/lock risk.

Deletes: ObservationStore/InMemoryObservationStore/SqliteObservationStore (+ tests),
InfrastructureModule.createObservationStore, the FileReadTool priming path (which never
hit anyway — it keyed on a workspace-relative path while every consumer looks up the
absolute FileWrittenEvent.path), the FileReadConfig/SessionOrchestrator plumbing, and
five orphaned build.gradle deps.

Store 2 (soft-confidence hints) unchanged — it was correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMbPmZZjcXhR2crU82zZ8S
This commit is contained in:
2026-07-21 18:42:47 +04:00
parent 8806de1628
commit 12775d56da
16 changed files with 9 additions and 267 deletions
@@ -1,36 +0,0 @@
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
}
}
@@ -1,43 +0,0 @@
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"))
}
}
@@ -7,7 +7,6 @@ import com.correx.core.inference.Tokenizer
import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.artifacts.kind.ArtifactKindRegistry
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.context.observation.ObservationStore
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ClarificationAnswer
@@ -112,8 +111,7 @@ class DefaultSessionOrchestrator(
readyTaskCounter: ReadyTaskCounter? = null,
taskClaimCoordinator: TaskClaimCoordinator? = null,
tuning: OrchestrationTuning = OrchestrationTuning(),
observationStore: ObservationStore? = null,
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever, readyTaskCounter = readyTaskCounter, taskClaimCoordinator = taskClaimCoordinator, tuning = tuning, observationStore = observationStore), ApprovalGateway {
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever, readyTaskCounter = readyTaskCounter, taskClaimCoordinator = taskClaimCoordinator, tuning = tuning), ApprovalGateway {
override val tokenizer: Tokenizer? = tokenizer
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
ConcurrentHashMap<SessionId, AtomicBoolean>()
@@ -8,7 +8,6 @@ import com.correx.core.artifacts.kind.KindInference
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.context.builder.ContextPackBuilder
import com.correx.core.context.model.ContextPack
import com.correx.core.context.observation.ObservationStore
import com.correx.core.events.events.ClarificationAnswer
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationRequestedEvent
@@ -203,7 +202,6 @@ abstract class SessionOrchestrator(
internal val readyTaskCounter: ReadyTaskCounter? = null,
internal val taskClaimCoordinator: TaskClaimCoordinator? = null,
internal val tuning: OrchestrationTuning = OrchestrationTuning(),
internal val observationStore: ObservationStore? = null,
) {
internal val log = LoggerFactory.getLogger(this::class.java)
internal val eventStore: EventStore = repositories.eventStore
@@ -250,6 +248,11 @@ abstract class SessionOrchestrator(
*/
internal val artifactContentCache: ConcurrentHashMap<String, String> = ConcurrentHashMap()
// ACR Store 1: in-process memo of describe().render(), keyed on (repoRoot, path, contentHash).
// Disposable — the source of truth is FileWrittenEvent + CAS; this only skips recomputing a pure
// function of content-addressed bytes. Empty string = a computed "no descriptor" (negative cache).
internal val descriptorMemo: ConcurrentHashMap<String, String> = ConcurrentHashMap()
/** Drops a terminated session's cached artifact contents (the heaviest per-session state — full
* file/JSON payloads). Safe: rehydrateArtifactContentCache rebuilds it from durable events if the
* session is ever resumed. Called on WorkflowCompleted/WorkflowFailed. */