diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/JournalCompactionService.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/JournalCompactionService.kt index d2d54179..ae6210c0 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/JournalCompactionService.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/JournalCompactionService.kt @@ -43,16 +43,18 @@ class JournalCompactionService( } val summaryArtifactId = artifactStore.put(summaryText.toByteArray(Charsets.UTF_8)) - artifactStore.flushBefore { - emit( - JournalCompactedEvent( - sessionId = sessionId, - coversThroughSequence = throughSequence, - summaryArtifactId = summaryArtifactId, - lowSalienceOmittedCount = lowCount, - ), - ) - } + // Emit directly, NOT inside flushBefore { }: append() already fsyncs artifacts before it + // persists the event, so wrapping the emit here re-acquires the (non-reentrant) artifact + // Mutex that flushBefore already holds → self-deadlock. Only surfaces on long runs, which + // are the ones that cross the compaction threshold. + emit( + JournalCompactedEvent( + sessionId = sessionId, + coversThroughSequence = throughSequence, + summaryArtifactId = summaryArtifactId, + lowSalienceOmittedCount = lowCount, + ), + ) return true } } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/TierContextSummarizer.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/TierContextSummarizer.kt index c984bf19..590d3819 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/TierContextSummarizer.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/TierContextSummarizer.kt @@ -48,17 +48,18 @@ class TierContextSummarizer( } val summaryText = summarize(prompt) val summaryArtifactId = artifactStore.put(summaryText.toByteArray(Charsets.UTF_8)) - artifactStore.flushBefore { - emit( - ContextSummarizedEvent( - sessionId = sessionId, - coversThroughSequence = coversThroughSequence, - summaryArtifactId = summaryArtifactId, - tier = tier, - entriesOmittedCount = freeformEntries.size, - ), - ) - } + // Emit directly, NOT inside flushBefore { }: append() already fsyncs artifacts before it + // persists the event, so wrapping the emit here re-acquires the (non-reentrant) artifact + // Mutex that flushBefore already holds → self-deadlock (same bug as JournalCompactionService). + emit( + ContextSummarizedEvent( + sessionId = sessionId, + coversThroughSequence = coversThroughSequence, + summaryArtifactId = summaryArtifactId, + tier = tier, + entriesOmittedCount = freeformEntries.size, + ), + ) return true } } diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/JournalCompactionServiceTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/JournalCompactionServiceTest.kt index 238eb610..a48615e4 100644 --- a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/JournalCompactionServiceTest.kt +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/JournalCompactionServiceTest.kt @@ -9,6 +9,9 @@ import com.correx.core.journal.model.DecisionKind import com.correx.core.journal.model.DecisionRecord import com.correx.core.utils.TypeId import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -70,6 +73,30 @@ class JournalCompactionServiceTest { assertEquals(fixedArtifactId, event.summaryArtifactId) } + // Regression: append() flushes artifacts inside the SAME non-reentrant Mutex that flushBefore + // holds. If compaction wraps its emit in flushBefore, the emit->append->flushBefore re-acquires + // that Mutex and self-deadlocks. This store mirrors production locking so the deadlock is real; + // the fake above cannot catch it because its flushBefore takes no lock. + private fun reentrantHostileArtifactStore(): ArtifactStore = object : ArtifactStore { + val mutex = Mutex() + override suspend fun put(bytes: ByteArray) = fixedArtifactId + override suspend fun get(id: TypeId): ByteArray? = null + override suspend fun flushBefore(commit: suspend () -> Unit) = mutex.withLock { commit() } + } + + @Test + fun `does not deadlock when emit re-enters the artifact store (as append does)`(): Unit = runBlocking { + val store = reentrantHostileArtifactStore() + val svc = JournalCompactionService(store, { "summary" }, tokenThreshold = { 100 }) + val state = stateWithRecords(makeRecord(1, DecisionKind.INTENT)) + // emit simulates SqliteEventStore.append: it flushes artifacts inside the store mutex. + withTimeout(2000) { + svc.compactIfNeeded(SessionId("s1"), state, renderedTokenEstimate = 500) { + store.flushBefore { /* persist event row */ } + } + } + } + @Test fun `only HIGH-salience records are included in the summarize prompt`(): Unit = runBlocking { val capturedPrompts = mutableListOf()