feat(kernel): retried stages receive explicit failure feedback; default retry backoff 1s

This commit is contained in:
2026-06-10 21:53:45 +04:00
parent 91cca9aee3
commit 629b910e7f
5 changed files with 62 additions and 4 deletions
@@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class RetryPolicy( data class RetryPolicy(
val maxAttempts: Int, val maxAttempts: Int,
val backoffMs: Long = 0L, val backoffMs: Long = 1_000L,
) { ) {
init { init {
require(maxAttempts >= 1) { "maxAttempts must be >= 1" } require(maxAttempts >= 1) { "maxAttempts must be >= 1" }
@@ -6,10 +6,32 @@ package com.correx.core.kernel.orchestration
import com.correx.core.context.model.ContextEntry import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.EntryRole import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.ContextEntryId import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.StageId
import com.correx.core.sessions.BoundProjectProfile import com.correx.core.sessions.BoundProjectProfile
import java.util.UUID import java.util.UUID
fun buildRetryFeedbackEntry(events: List<StoredEvent>, stageId: StageId): ContextEntry? {
val latest = events
.mapNotNull { it.payload as? RetryAttemptedEvent }
.lastOrNull { it.stageId == stageId } ?: return null
val content = "## Retry feedback\n" +
"Attempt ${latest.attemptNumber} of ${latest.maxAttempts} for stage '${stageId.value}'. " +
"The previous attempt failed: ${latest.failureReason}\n" +
"Address the failure cause directly. Do not repeat the identical approach."
return ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L1,
content = content,
sourceType = "retryFeedback",
sourceId = stageId.value,
tokenEstimate = content.length / 4,
role = EntryRole.SYSTEM,
)
}
fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry { fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry {
val content = buildString { val content = buildString {
append("## Project profile\n") append("## Project profile\n")
@@ -365,9 +365,11 @@ abstract class SessionOrchestrator(
} ?: emptyList() } ?: emptyList()
val projectProfileEntries = session.state.boundProjectProfile val projectProfileEntries = session.state.boundProjectProfile
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList() ?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
val retryFeedbackEntries = buildRetryFeedbackEntry(eventStore.read(sessionId), stageId)
?.let { listOf(it) } ?: emptyList()
var accumulatedEntries = var accumulatedEntries =
systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries + systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries +
needsEntries + schemaEntries + promptEntries + steeringEntries needsEntries + schemaEntries + promptEntries + steeringEntries + retryFeedbackEntries
val contextPack = contextPackBuilder.build( val contextPack = contextPackBuilder.build(
id = ContextPackId(UUID.randomUUID().toString()), id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId, sessionId = sessionId,
@@ -28,9 +28,9 @@ class RetryPolicyTest {
} }
@Test @Test
fun `backoffMs defaults to zero`() { fun `backoffMs defaults to one second`() {
val policy = RetryPolicy(maxAttempts = 1) val policy = RetryPolicy(maxAttempts = 1)
assertEquals(0L, policy.backoffMs) assertEquals(1_000L, policy.backoffMs)
} }
@Test @Test
@@ -1,13 +1,47 @@
import com.correx.core.context.model.ContextLayer import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.EntryRole import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.kernel.orchestration.buildProjectProfileEntry import com.correx.core.kernel.orchestration.buildProjectProfileEntry
import com.correx.core.kernel.orchestration.buildRetryFeedbackEntry
import com.correx.core.sessions.BoundProjectProfile import com.correx.core.sessions.BoundProjectProfile
import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class ContextFeedbackTest { class ContextFeedbackTest {
@Test
fun `no retry events yields null`() {
assertNull(buildRetryFeedbackEntry(emptyList(), StageId("impl")))
}
@Test
fun `latest retry event for the stage becomes a labeled L1 system entry`() {
val events = listOf(
stored(payload = RetryAttemptedEvent(SessionId("s"), StageId("impl"), 1, 3, "old reason")),
stored(payload = RetryAttemptedEvent(SessionId("s"), StageId("other"), 1, 3, "other stage")),
stored(payload = RetryAttemptedEvent(SessionId("s"), StageId("impl"), 2, 3, "tests failed: NPE in FooTest")),
)
val entry = buildRetryFeedbackEntry(events, StageId("impl"))!!
assertEquals("retryFeedback", entry.sourceType)
assertEquals(ContextLayer.L1, entry.layer)
assertEquals(EntryRole.SYSTEM, entry.role)
assertTrue(entry.content.contains("Attempt 2 of 3"), "content: ${entry.content}")
assertTrue(entry.content.contains("tests failed: NPE in FooTest"), "content: ${entry.content}")
}
@Test
fun `retry entry for other stage is not returned for queried stage`() {
val events = listOf(
stored(payload = RetryAttemptedEvent(SessionId("s"), StageId("other"), 1, 3, "reason")),
)
assertNull(buildRetryFeedbackEntry(events, StageId("impl")))
}
@Test @Test
fun `project profile renders as single L0 entry`() { fun `project profile renders as single L0 entry`() {
val entry = buildProjectProfileEntry( val entry = buildProjectProfileEntry(