feat(kernel): retried stages receive explicit failure feedback; default retry backoff 1s
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
+3
-1
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user