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:
+2
-24
@@ -1,10 +1,7 @@
|
||||
package com.correx.infrastructure.tools.filesystem
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.context.observation.Observation
|
||||
import com.correx.core.context.observation.ObservationStore
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.sourcedesc.describe
|
||||
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
|
||||
import com.correx.core.tools.compression.CompressionRule.StripLeadingWhitespace
|
||||
import com.correx.core.tools.compression.DeclarativeCompressor
|
||||
@@ -35,12 +32,6 @@ import kotlin.io.path.name
|
||||
class FileReadTool(
|
||||
private val allowedPaths: Set<Path> = emptySet(),
|
||||
private val workingDir: Path? = null,
|
||||
// ACR Store 1 (docs/plans/2026-07-21-acr-knowledge-accretion.md): primes the cross-session
|
||||
// observation cache on a whole-file read, not just on write, so a later describeCached lookup
|
||||
// (SessionOrchestratorArtifacts.kt) for this exact content hash is already warm. Both null by
|
||||
// default — no-op unless a workspace wiring supplies them.
|
||||
private val observationStore: ObservationStore? = null,
|
||||
private val repoRoot: String? = null,
|
||||
) : Tool, ToolExecutor {
|
||||
|
||||
// Resolve relative paths against the bound workspace's working dir, NOT the JVM
|
||||
@@ -149,7 +140,7 @@ class FileReadTool(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readFile(path: Path, startLine: Int?, endLine: Int?, request: ToolRequest): ToolResult = runCatching {
|
||||
private fun readFile(path: Path, startLine: Int?, endLine: Int?, request: ToolRequest): ToolResult = runCatching {
|
||||
// Read the file once. Lines and the (whole-read) content hash both derive from these bytes,
|
||||
// so a full read no longer hits the disk twice (readAllLines + readAllBytes for the hash).
|
||||
val bytes = Files.readAllBytes(path)
|
||||
@@ -178,9 +169,7 @@ class FileReadTool(
|
||||
// against what the agent actually saw. A partial or truncated view establishes no baseline —
|
||||
// the agent must read the relevant range before editing.
|
||||
val sawWholeFile = startLine == null && endLine == null && !lineCapped && !charCapped
|
||||
val hash = if (sawWholeFile) sha256(bytes) else null
|
||||
val metadata = hash?.let { mapOf("contentHash" to it) } ?: emptyMap()
|
||||
hash?.let { primeObservation(path, bytes, it) }
|
||||
val metadata = if (sawWholeFile) mapOf("contentHash" to sha256(bytes)) else emptyMap()
|
||||
ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = content + note,
|
||||
@@ -194,17 +183,6 @@ class FileReadTool(
|
||||
)
|
||||
}
|
||||
|
||||
/** No-op unless both [observationStore] and [repoRoot] are wired; skips if already cached for this hash. */
|
||||
private suspend fun primeObservation(path: Path, bytes: ByteArray, hash: String) {
|
||||
val store = observationStore ?: return
|
||||
val root = repoRoot ?: return
|
||||
val relPath = workingDir?.let { runCatching { it.relativize(path).toString() }.getOrNull() } ?: path.toString()
|
||||
val cached = store.get(root, relPath)
|
||||
if (cached?.contentHash == hash) return
|
||||
val rendered = describe(relPath, bytes).render().takeIf { it.isNotBlank() }
|
||||
store.put(Observation(root, relPath, hash, rendered, System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
private fun sha256(bytes: ByteArray): String =
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
-19
@@ -1,6 +1,5 @@
|
||||
package com.correx.infrastructure.tools.filesystem
|
||||
|
||||
import com.correx.core.context.observation.InMemoryObservationStore
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
@@ -194,24 +193,6 @@ class FileReadToolTest {
|
||||
assertTrue((result as ToolResult.Success).metadata["contentHash"] != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute primes the observation store for a whole-file read`(): Unit = runBlocking {
|
||||
val tempFile = Files.createTempFile("test_prime", ".kt")
|
||||
Files.writeString(tempFile, "fun foo() {}")
|
||||
val store = InMemoryObservationStore()
|
||||
val tool = FileReadTool(
|
||||
allowedPaths = setOf(tempFile.parent),
|
||||
workingDir = tempFile.parent,
|
||||
observationStore = store,
|
||||
repoRoot = "repoA",
|
||||
)
|
||||
|
||||
tool.execute(createRequest(tempFile.toString()))
|
||||
|
||||
val observed = store.get("repoA", tempFile.fileName.toString())
|
||||
assertTrue(observed != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `outputCompressor strips blank lines and leading whitespace`() {
|
||||
val tool = FileReadTool()
|
||||
|
||||
Reference in New Issue
Block a user