diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorConcepts.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorConcepts.kt index 9756c2b4..c270e559 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorConcepts.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorConcepts.kt @@ -4,6 +4,7 @@ import com.correx.core.context.model.ContextEntry import com.correx.core.context.model.ContextLayer import com.correx.core.context.model.EntryRole import com.correx.core.events.events.ConceptPromotedEvent +import com.correx.core.events.events.ContextAssembledEvent import com.correx.core.events.events.RetryAttemptedEvent import com.correx.core.events.events.StoredEvent import com.correx.core.events.types.ContextEntryId @@ -81,12 +82,24 @@ internal suspend fun SessionOrchestrator.promotedConceptEntries(stageConfig: Sta * `classKey` is in [state.promoted][com.correx.core.kernel.concept.ConceptCompilerState.promoted] the * hard-promoted delivery ([promotedConceptEntries]) already covers it, so this is skipped to avoid * double delivery. + * + * Genuinely one-shot per retry occurrence (#306): the hint is keyed to the LATEST + * [RetryAttemptedEvent] for this stage, and is only injected while that retry hasn't yet been + * delivered — derived by folding prior [ContextAssembledEvent] manifests (sourceType="unconfirmedFix", + * sourceId=classKey) recorded AFTER that retry's own position in the log. So a fresh contradicted + * retry injects the steer-away exactly once (the first context build following it); every later + * rebuild for the SAME retry occurrence — whether more tool rounds in this attempt or a subsequent + * stage retry that hasn't reproduced the class again — sees the prior delivery and stays silent. A + * later, NEW `RetryAttemptedEvent` of the same classKey (the class recurred) advances "latest" past + * that delivery and earns one fresh injection of its own. No new mutable state: purely a fold over + * existing events (invariant #9). */ internal suspend fun SessionOrchestrator.unconfirmedFixEntries( sessionEvents: List, stageId: StageId, ): List { - val latest = sessionEvents.mapNotNull { it.payload as? RetryAttemptedEvent }.lastOrNull { it.stageId == stageId } + val latestRetry = sessionEvents.lastOrNull { (it.payload as? RetryAttemptedEvent)?.stageId == stageId } + val latest = latestRetry?.payload as? RetryAttemptedEvent val sig = latest?.failureReason?.lineSequence()?.firstOrNull()?.take(SIGNATURE_MAX)?.trim().orEmpty() val classKey = latest?.let { conceptClassKey(it.gate, sig) } val projection = ConceptCompilerProjection() @@ -104,18 +117,54 @@ internal suspend fun SessionOrchestrator.unconfirmedFixEntries( (cluster.fixPath?.let { " in `$it`" } ?: "") + " — worth trying first, but it hasn't " + "recurred enough times across sessions to be a certain fix here. Verify it actually applies." else -> null - } ?: return emptyList() - return listOf( + } + val deliverable = deliverableUnconfirmedFix(content, classKey, latestRetry, sessionEvents) + val entry = deliverable?.let { (text, key) -> ContextEntry( id = ContextEntryId(UUID.randomUUID().toString()), layer = ContextLayer.L1, - content = content, + content = text, sourceType = "unconfirmedFix", - sourceId = classKey.orEmpty(), - tokenEstimate = estimateTokens(content), + sourceId = key, + tokenEstimate = estimateTokens(text), role = EntryRole.USER, - ), - ) + ) + } + return listOfNotNull(entry) +} + +/** + * (content, classKey) pair to deliver, or null if any of the one-shot preconditions fail: no + * content derived, no classKey (no retry seen), no retry event to anchor the delivery check + * against, or the classKey was already delivered for this retry occurrence. Split out of + * [unconfirmedFixEntries] to keep that function's branching flat. + */ +private fun deliverableUnconfirmedFix( + content: String?, + classKey: String?, + latestRetry: StoredEvent?, + sessionEvents: List, +): Pair? = content?.let { text -> + classKey?.let { key -> + latestRetry + ?.takeUnless { unconfirmedFixAlreadyDelivered(sessionEvents, it.sessionSequence, key) } + ?.let { text to key } + } +} + +/** + * True when a prior [ContextAssembledEvent] manifest already recorded delivery of the + * "unconfirmedFix" hint for [classKey] AFTER [afterSequence] (the triggering retry's own + * position in the session log). Pure fold over recorded events — see #306. + */ +internal fun unconfirmedFixAlreadyDelivered( + sessionEvents: List, + afterSequence: Long, + classKey: String, +): Boolean = sessionEvents.any { stored -> + stored.sessionSequence > afterSequence && + (stored.payload as? ContextAssembledEvent)?.entries.orEmpty() + .any { it.sourceType == "unconfirmedFix" && it.sourceId == classKey } } private const val SIGNATURE_MAX = 200 diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/UnconfirmedFixDeliveryTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/UnconfirmedFixDeliveryTest.kt new file mode 100644 index 00000000..8a88b229 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/UnconfirmedFixDeliveryTest.kt @@ -0,0 +1,86 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.ContextAssembledEvent +import com.correx.core.events.events.ContextManifestEntry +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.kernel.concept.conceptClassKey +import kotlinx.datetime.Clock +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.UUID + +/** + * #306: the steer-away hint must fire once per retry occurrence, not on every context rebuild for + * as long as the latest retry stays contradicted. [unconfirmedFixAlreadyDelivered] is the pure fold + * that makes that "already delivered?" question replay-safe (folded over recorded + * [ContextAssembledEvent] manifests, no new mutable state). + */ +class UnconfirmedFixDeliveryTest { + + private val sessionId = SessionId("s1") + + private fun stored(sessionSequence: Long, payload: EventPayload) = StoredEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + sequence = sessionSequence, + sessionSequence = sessionSequence, + payload = payload, + ) + + private fun assembled(sessionSequence: Long, sourceType: String, sourceId: String) = stored( + sessionSequence, + ContextAssembledEvent( + sessionId = sessionId, + stageId = StageId("scaffold_frontend"), + contextPackId = "pack-$sessionSequence", + entries = listOf( + ContextManifestEntry(sourceType, sourceId, tokenEstimate = 10, layer = "L1", role = "USER"), + ), + timestampMs = 0L, + ), + ) + + @Test + fun `not yet delivered for a fresh retry`() { + val events = listOf(assembled(1, "unconfirmedFix", "other-class")) + assertFalse(unconfirmedFixAlreadyDelivered(events, afterSequence = 5, classKey = "stage:x")) + } + + @Test + fun `delivered once is not delivered again on the next rebuild for the same retry`() { + // retry lands at seq 5; the hint is delivered in the ContextAssembledEvent at seq 6 + // (first context build after the retry). A LATER rebuild for that same retry (still the + // latest one, unchanged) must see it already delivered. + val events = listOf(assembled(6, "unconfirmedFix", "stage:x")) + assertTrue(unconfirmedFixAlreadyDelivered(events, afterSequence = 5, classKey = "stage:x")) + } + + @Test + fun `a fresh recurrence of the same class after a new retry earns one more delivery`() { + // Prior delivery at seq 6 for the FIRST retry (afterSequence=5). A NEW retry of the same + // class lands later (seq 20) — the delivery check for the new retry only looks after seq 20, + // so the stale seq-6 delivery no longer counts. + val events = listOf(assembled(6, "unconfirmedFix", "stage:x")) + assertFalse(unconfirmedFixAlreadyDelivered(events, afterSequence = 20, classKey = "stage:x")) + } + + @Test + fun `a routing dead-end and a build failure never collapse into one classKey`() { + val routing = conceptClassKey("stage", "no transition condition matched from stage scaffold_frontend") + val build = conceptClassKey("build", "no transition condition matched from stage scaffold_frontend") + assertNotEquals(routing, build) + } +}