fix(kernel,router): rehydrate by CAS content hash (F-021) + narration budget

rehydrate() now scans ArtifactContentStoredEvent (the durable slot->CAS-hash
bridge) and fetches stored bytes by content hash, keying the cache by the
logical slot name. The slot name is never passed to the hash-keyed CAS, so the
odd-length-hex crash is gone and artifact content is restored on cold-start
resume. RehydrateTest reworked to use a content-addressed fake store (slot name
!= content hash) so it actually exercises the bug. Replay (#8) is unaffected —
rehydrate is live-resume-only.

Also bump router narration maxTokens 1024 -> 4096: reasoning models were
spending the whole budget thinking and emitting empty content (finishReason=
length), so narrations never reached the TUI.

Removes the stale TUI-refactor progress doc.
This commit is contained in:
2026-06-09 10:14:04 +04:00
parent b407b47503
commit 89487db72a
5 changed files with 42 additions and 156 deletions
@@ -4,7 +4,7 @@ import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.approvals.domain.DefaultApprovalEngine
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.artifacts.DefaultArtifactReducer
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.types.ArtifactId
@@ -44,15 +44,21 @@ import java.util.UUID
class RehydrateTest {
private val sessionId: SessionId = TypeId("session-rehydrate")
private val artifactId: ArtifactId = TypeId("artifact-001")
// The CAS is content-addressed: the stored key is a content hash, NOT the
// logical slot name. F-021 — rehydrate must resolve slot.name -> contentHash
// via ArtifactContentStoredEvent and fetch by hash, never pass the slot name
// (which is non-hex and would crash a real CAS) to ArtifactStore.get.
private val slotName: ArtifactId = TypeId("analysis")
private val contentHash: ArtifactId = TypeId("a1b2c3d4")
private val artifactContent = """{"status":"success"}"""
private val eventStore = InMemoryEventStore()
private val fakeArtifactStore: ArtifactStore = object : ArtifactStore {
override suspend fun put(bytes: ByteArray): ArtifactId = artifactId
override suspend fun put(bytes: ByteArray): ArtifactId = contentHash
override suspend fun get(id: ArtifactId): ByteArray? =
if (id == artifactId) artifactContent.toByteArray(Charsets.UTF_8) else null
if (id == contentHash) artifactContent.toByteArray(Charsets.UTF_8) else null
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
}
@@ -100,7 +106,7 @@ class RehydrateTest {
decisionJournalRepository = decisionJournalRepository,
)
private suspend fun appendValidatedEvent() {
private suspend fun appendContentStoredEvent() {
eventStore.append(
NewEvent(
metadata = EventMetadata(
@@ -111,8 +117,9 @@ class RehydrateTest {
causationId = null,
correlationId = null,
),
payload = ArtifactValidatedEvent(
artifactId = artifactId,
payload = ArtifactContentStoredEvent(
artifactId = slotName,
contentHash = contentHash,
sessionId = sessionId,
stageId = StageId("stage-1"),
),
@@ -121,20 +128,21 @@ class RehydrateTest {
}
@Test
fun `rehydrate populates artifactContentCache from ArtifactValidatedEvent`(): Unit = runBlocking {
appendValidatedEvent()
fun `rehydrate resolves slot name to content hash and populates cache`(): Unit = runBlocking {
appendContentStoredEvent()
orchestrator.rehydrate(sessionId)
val cached = orchestrator.validatedArtifactContent(sessionId, artifactId)
// Cache is keyed by the logical slot name; content fetched from CAS by hash.
val cached = orchestrator.validatedArtifactContent(sessionId, slotName)
assertEquals(artifactContent, cached)
}
@Test
fun `rehydrate is a no-op when there are no ArtifactValidatedEvents`(): Unit = runBlocking {
fun `rehydrate is a no-op when there are no ArtifactContentStoredEvents`(): Unit = runBlocking {
orchestrator.rehydrate(sessionId)
val cached = orchestrator.validatedArtifactContent(sessionId, artifactId)
val cached = orchestrator.validatedArtifactContent(sessionId, slotName)
assertEquals(null, cached)
}
}