diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/RecoveryFileLoopBreak.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/RecoveryFileLoopBreak.kt index 061d3739..0c700a14 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/RecoveryFileLoopBreak.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/RecoveryFileLoopBreak.kt @@ -26,8 +26,16 @@ import com.correx.core.transitions.graph.WorkflowGraph internal data class FileRepairOutcome( val path: String, val writeCount: Int, - /** True when the diagnostic run following the LAST write for this path reported no errors. */ + /** + * True when the diagnostic run following the LAST write for this path reported no errors. False + * when it reported errors OR when no run has happened since that write — see [unchecked], which + * separates the two. "Not yet checked" must never read as "clean": telling the model to leave a + * file alone on the strength of a run that never happened is the exact mis-signal this ledger exists + * to prevent. + */ val resolved: Boolean, + /** True when NO diagnostic run has been recorded since the last write for this path. */ + val unchecked: Boolean, /** Diagnostic codes present after EVERY write (only meaningful when [resolved] is false). */ val persistentCodes: Set, /** 1-based write index at which the diagnostic first went clean (only set when [resolved]). */ @@ -50,21 +58,28 @@ internal fun fileRepairOutcomes(events: List, stageId: StageId): Li val paths = writes.map { (it.payload as FileWrittenEvent).path }.distinct() return paths.map { path -> val pathWrites = writes.filter { (it.payload as FileWrittenEvent).path == path } - val codesPerWrite = pathWrites.map { w -> + // null = no diagnostic run recorded after that write, which is NOT the same as a clean run. + val codesPerWrite: List?> = pathWrites.map { w -> diagRuns.firstOrNull { it.sequence > w.sequence } ?.let { (it.payload as LspDiagnosticsCompletedEvent).diagnostics } ?.filter { d -> d.path == path && d.severity.equals("error", ignoreCase = true) && !d.isLint } ?.mapNotNull { it.code } ?.toSet() - ?: emptySet() } - val resolved = codesPerWrite.last().isEmpty() + val checked = codesPerWrite.filterNotNull() + val unchecked = codesPerWrite.last() == null + val resolved = !unchecked && codesPerWrite.last()!!.isEmpty() FileRepairOutcome( path = path, writeCount = pathWrites.size, resolved = resolved, - persistentCodes = if (resolved) emptySet() else codesPerWrite.reduce { a, b -> a intersect b }, - clearedAtWrite = if (resolved) codesPerWrite.indexOfFirst { it.isEmpty() } + 1 else null, + unchecked = unchecked, + persistentCodes = if (resolved || checked.isEmpty()) { + emptySet() + } else { + checked.reduce { a, b -> a intersect b } + }, + clearedAtWrite = if (resolved) codesPerWrite.indexOfFirst { it?.isEmpty() == true } + 1 else null, ) } } @@ -72,6 +87,7 @@ internal fun fileRepairOutcomes(events: List, stageId: StageId): Li internal fun describeFileRepairOutcome(o: FileRepairOutcome): String { val header = "${o.path} — written ${o.writeCount}x." return when { + o.unchecked -> "$header not re-checked since the last write — outcome unknown." o.resolved && o.writeCount > 1 -> "$header cleared after write ${o.clearedAtWrite}. done, leave it." o.resolved -> "$header done, leave it." o.persistentCodes.isNotEmpty() -> "$header ${o.persistentCodes.joinToString(", ")}: present before " + @@ -101,7 +117,9 @@ internal fun DefaultSessionOrchestrator.recoveryFileLoopBreak( limit: Int, ): String? { val stuck = fileRepairOutcomes(repositories.eventStore.read(sessionId), stageId) - .firstOrNull { !it.resolved && it.writeCount >= limit } + // `unchecked` is excluded deliberately: killing a run terminally demands recorded proof the + // rewrites aren't working, not the absence of proof that they are. + .firstOrNull { !it.resolved && !it.unchecked && it.writeCount >= limit } ?: return null val codes = stuck.persistentCodes.takeIf { it.isNotEmpty() }?.joinToString(", ") ?: "its diagnostic" return "recovery stage ${stageId.value} rewrote '${stuck.path}' ${stuck.writeCount}x without " + diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/RecoveryFileLoopBreakTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/RecoveryFileLoopBreakTest.kt index 8dc4b62c..08d2a978 100644 --- a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/RecoveryFileLoopBreakTest.kt +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/RecoveryFileLoopBreakTest.kt @@ -74,11 +74,26 @@ class RecoveryFileLoopBreakTest { assertTrue(describeFileRepairOutcome(fixed).let { it.contains("cleared after write 2") }) } + @Test + fun `a write with no diagnostic run after it reads as unchecked, never as resolved`() { + val events = buildList { + repeat(3) { addAll(writeThenDiagnose("SessionsList.tsx", cleared = false, code = "TS6133")) } + addAll(writeOnly("SessionsList.tsx")) + } + val outcome = fileRepairOutcomes(events, stage).single() + assertTrue(outcome.unchecked) + assertEquals(false, outcome.resolved, "an unverified write must not read as clean") + assertTrue(describeFileRepairOutcome(outcome).contains("not re-checked")) + // and it must not terminally kill the run on the absence of evidence + assertNull(recoveryFileLoopBreakPure(events, stage, limit = 3)) + } + // recoveryFileLoopBreak is an extension on DefaultSessionOrchestrator that reads the event store; // its pure core is fileRepairOutcomes, exercised directly here for the same result without needing // to stand up an orchestrator instance. private fun recoveryFileLoopBreakPure(events: List, stageId: StageId, limit: Int): String? { - val stuck = fileRepairOutcomes(events, stageId).firstOrNull { !it.resolved && it.writeCount >= limit } + val stuck = fileRepairOutcomes(events, stageId) + .firstOrNull { !it.resolved && !it.unchecked && it.writeCount >= limit } ?: return null val codes = stuck.persistentCodes.takeIf { it.isNotEmpty() }?.joinToString(", ") ?: "its diagnostic" return "recovery stage ${stageId.value} rewrote '${stuck.path}' ${stuck.writeCount}x without " + @@ -100,6 +115,8 @@ class RecoveryFileLoopBreakTest { payload = payload, ) + private fun writeOnly(path: String): List = writeThenDiagnose(path, cleared = true).dropLast(1) + private fun writeThenDiagnose(path: String, cleared: Boolean, code: String? = null): List { val inv = ToolInvocationId("inv-${seq}") val req = ev(