fix(gates,tools): stop pinning deleted files; match edit targets across blank lines (#419, #417)

Both defects come from live session fced377e, where scaffold_frontend burned 89
turns without converging.

#419 — the stage output manifest never tracked deletions. A deletion is a
FileWrittenEvent with a null postImageHash, and stageWrittenPaths filtered that
per-event instead of per-path, so a written-then-deleted file stayed in the
manifest forever. The contract gate stamps file_exists on every entry, which made
deleting — and therefore renaming, which is delete plus write — a permanent
contract violation. That deadlocked against the build gate: it ordered "rename it
to use the '.cjs' file extension", the agent complied, and the contract gate then
failed file_exists on the .js it had just been told to remove. Keep the last
mutation per path instead. Fixed in the shared function, so the build-gate
toolchain probe, the review gate, and verification all stop seeing deleted files
too. Moved to its own file to keep the gates file under the detekt function cap.

#417 — file_edit failed 4 of 6 live calls, all "Target not found" with a correct
"did you mean" suggestion attached. flexibleMatch already tolerated indentation
drift but walked target lines against file lines positionally, so one blank line
the model dropped shifted every later index and missed the whole block. Compare
only non-blank lines and map the hit back to real file lines. Ambiguity still
fails rather than picking a match, and reindent keeps handling the writeback.

Guard tests both ways, each mutation-verified against the old logic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:23:51 +04:00
parent 5ed8ebd0c5
commit d1b84a1a9f
5 changed files with 277 additions and 22 deletions
@@ -364,18 +364,6 @@ internal fun SessionOrchestrator.sessionProducedBuildTarget(sessionId: SessionId
KindContractTable.assertionsFor(kind, path).any { it.id == "imports_resolve" }
}
internal fun SessionOrchestrator.stageWrittenPaths(sessionId: SessionId, stageId: StageId): List<String> {
val events = eventStore.read(sessionId)
val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
.filter { it.stageId == stageId }
.map { it.invocationId }
.toSet()
return events.mapNotNull { it.payload as? FileWrittenEvent }
.filter { it.invocationId in invocationIds && it.postImageHash != null }
.map { it.path }
.distinct()
}
/**
* Static-first reviewer gate (role-reliability §5): for a stage that declares `static_analysis`
* commands, run them (compiler / detekt / formatters) against its just-produced output in the
@@ -0,0 +1,33 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
internal fun SessionOrchestrator.stageWrittenPaths(sessionId: SessionId, stageId: StageId): List<String> =
stageWrittenPathsFrom(eventStore.read(sessionId), stageId)
/**
* The files [stageId] wrote that still exist — the stage's live output manifest.
*
* Keeps only paths whose LAST mutation still has content. A deletion is a [FileWrittenEvent] with a
* null `postImageHash`, so filtering per-event rather than per-path leaves a written-then-deleted file
* in the manifest forever. Callers treat the manifest as ground truth: the contract gate stamps
* `file_exists` on every entry, which makes deleting — or renaming, which is delete plus write — a
* permanent contract violation, deadlocking against a build gate that demands one (observed live:
* "rename it to use the '.cjs' file extension" against `file_exists` on the `.js`).
*/
internal fun stageWrittenPathsFrom(events: List<StoredEvent>, stageId: StageId): List<String> {
val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
.filter { it.stageId == stageId }
.map { it.invocationId }
.toSet()
return events.mapNotNull { it.payload as? FileWrittenEvent }
.filter { it.invocationId in invocationIds }
.associateBy { it.path } // last write per path wins
.filterValues { it.postImageHash != null }
.keys
.toList()
}
@@ -0,0 +1,155 @@
package com.correx.core.kernel.orchestration
import com.correx.core.approvals.Tier
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolRequest
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.events.types.ToolInvocationId
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The stage output manifest must track deletions. Callers treat it as ground truth — the contract gate
* stamps `file_exists` on every entry — so a written-then-deleted path that survives here makes
* deleting, and therefore renaming, a permanent contract violation. Live session fced377e deadlocked
* exactly there: the build gate ordered "rename it to use the '.cjs' file extension", the agent
* complied, and the contract gate then failed `file_exists` on the `.js` it had just been told to
* remove — 89 turns without converging.
*/
class StageWrittenPathsTest {
private val stage = StageId("scaffold_frontend")
private val other = StageId("review_ui")
@Test
fun `a written-then-deleted path drops out of the manifest`() {
val events = listOf(
invoked("inv1", stage),
wrote("inv1", "frontend/postcss.config.js", "h1"),
invoked("inv2", stage),
deleted("inv2", "frontend/postcss.config.js"),
)
assertEquals(emptyList<String>(), stageWrittenPathsFrom(events, stage))
}
@Test
fun `a rename leaves only the new path`() {
val events = listOf(
invoked("inv1", stage),
wrote("inv1", "frontend/postcss.config.js", "h1"),
invoked("inv2", stage),
wrote("inv2", "frontend/postcss.config.cjs", "h1"),
invoked("inv3", stage),
deleted("inv3", "frontend/postcss.config.js"),
)
assertEquals(listOf("frontend/postcss.config.cjs"), stageWrittenPathsFrom(events, stage))
}
@Test
fun `a path deleted and then rewritten is back in the manifest`() {
val events = listOf(
invoked("inv1", stage),
wrote("inv1", "frontend/vite.config.ts", "h1"),
invoked("inv2", stage),
deleted("inv2", "frontend/vite.config.ts"),
invoked("inv3", stage),
wrote("inv3", "frontend/vite.config.ts", "h2"),
)
assertEquals(listOf("frontend/vite.config.ts"), stageWrittenPathsFrom(events, stage))
}
@Test
fun `surviving writes are unaffected by a sibling deletion`() {
val events = listOf(
invoked("inv1", stage),
wrote("inv1", "frontend/src/App.tsx", "h1"),
invoked("inv2", stage),
wrote("inv2", "frontend/tailwind.config.js", "h2"),
invoked("inv3", stage),
deleted("inv3", "frontend/src/App.css"),
)
assertEquals(
listOf("frontend/src/App.tsx", "frontend/tailwind.config.js"),
stageWrittenPathsFrom(events, stage),
)
}
@Test
fun `another stage's writes stay out of this stage's manifest`() {
val events = listOf(
invoked("inv1", stage),
wrote("inv1", "frontend/src/App.tsx", "h1"),
invoked("inv2", other),
wrote("inv2", "frontend/src/Sessions.tsx", "h2"),
)
assertEquals(listOf("frontend/src/App.tsx"), stageWrittenPathsFrom(events, stage))
assertTrue(stageWrittenPathsFrom(events, other) == listOf("frontend/src/Sessions.tsx"))
}
private var seq = 0L
private fun stored(payload: EventPayload): StoredEvent {
seq++
return StoredEvent(
metadata = EventMetadata(
eventId = EventId("e$seq"),
sessionId = SessionId("s1"),
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = seq,
sessionSequence = seq,
payload = payload,
)
}
private fun invoked(invocationId: String, stageId: StageId) = stored(
ToolInvocationRequestedEvent(
invocationId = ToolInvocationId(invocationId),
sessionId = SessionId("s1"),
stageId = stageId,
toolName = "file_write",
tier = Tier.T3,
request = ToolRequest(
invocationId = ToolInvocationId(invocationId),
sessionId = SessionId("s1"),
stageId = stageId,
toolName = "file_write",
parameters = emptyMap(),
),
),
)
private fun wrote(invocationId: String, path: String, hash: String) = stored(
FileWrittenEvent(
invocationId = ToolInvocationId(invocationId),
sessionId = SessionId("s1"),
path = path,
postImageHash = hash,
preExisted = false,
timestampMs = 1L,
),
)
/** A deletion is a [FileWrittenEvent] with no post-image. */
private fun deleted(invocationId: String, path: String) = stored(
FileWrittenEvent(
invocationId = ToolInvocationId(invocationId),
sessionId = SessionId("s1"),
path = path,
postImageHash = null,
preExisted = true,
timestampMs = 1L,
),
)
}