fix(kernel): "not yet checked" must never read as "clean" in the repair ledger (#309)
fileRepairOutcomes mapped both "no diagnostic run after this write" and "diagnostics ran and found nothing" to emptySet(), so resolved was true for a file that was never actually re-checked. Two consequences, both the inverse of what #309 is for: - the ledger told the model "done, leave it" about a file whose last write was never verified — the precise mis-signal the tab-keeping exists to prevent; - the loop-breaker counted unchecked writes as unresolved, so it could kill a run terminally on the ABSENCE of evidence rather than on recorded proof the rewrites weren't working. Track it as a distinct `unchecked` state: the ledger says "not re-checked since the last write — outcome unknown", and the breaker requires !unchecked. ./gradlew check green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgDL1v3GuQ9RZnYR6fDT95
This commit is contained in:
+25
-7
@@ -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<String>,
|
||||
/** 1-based write index at which the diagnostic first went clean (only set when [resolved]). */
|
||||
@@ -50,21 +58,28 @@ internal fun fileRepairOutcomes(events: List<StoredEvent>, 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<Set<String>?> = 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<StoredEvent>, 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 " +
|
||||
|
||||
+18
-1
@@ -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<StoredEvent>, 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<StoredEvent> = writeThenDiagnose(path, cleared = true).dropLast(1)
|
||||
|
||||
private fun writeThenDiagnose(path: String, cleared: Boolean, code: String? = null): List<StoredEvent> {
|
||||
val inv = ToolInvocationId("inv-${seq}")
|
||||
val req = ev(
|
||||
|
||||
Reference in New Issue
Block a user