fix(kernel): de-dup tool events, restore approval diff, break read-before-write deadlock

Three tool-path correctness fixes:

- Tool-execution events were emitted twice — SandboxedToolExecutor and
  SessionOrchestrator.recordToolExecution both emitted Completed/Failed/
  FileWritten per call (double TUI rows, double-counted metrics). Split
  ownership: orchestrator is the sole recorder of Completed/Failed (truncates
  output, drives the read-before-write gate); executor owns Started +
  FileWritten (pre/post-image hashes it alone can capture).

- computeToolPreview still gated on operation == "write", but file_write lost
  its operation param when delete was split out, so it bailed to raw-args for
  every write (approval card showed raw JSON). Drop the dead guard.

- A file_read blocked by REFERENCE_EXISTS can never complete, so it could never
  lift read-only mode: a stage writing a NEW file deadlocked (writes filtered,
  every read of the not-yet-created target blocked) until MAX_TOOL_ROUNDS.
  Lift read-only on a REFERENCE_EXISTS block, and reword the block message to
  tell the model to file_write directly. Regression test added.
This commit is contained in:
2026-07-03 01:01:19 +04:00
parent ca3fd7971e
commit 2698971082
5 changed files with 178 additions and 106 deletions
@@ -58,7 +58,6 @@ import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RiskAssessedEvent import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.StageCheckpointFailedEvent import com.correx.core.events.events.StageCheckpointFailedEvent
import com.correx.core.events.events.StageCheckpointPassedEvent import com.correx.core.events.events.StageCheckpointPassedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent import com.correx.core.events.events.SteeringNoteAddedEvent
@@ -162,6 +161,7 @@ private const val STAGE_COMPLETE_TOOL = "stage_complete"
private const val EMIT_ARTIFACT_TOOL = "emit_artifact" private const val EMIT_ARTIFACT_TOOL = "emit_artifact"
private const val SCOPE_PROPOSAL_TOOL = "propose_scope" private const val SCOPE_PROPOSAL_TOOL = "propose_scope"
private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE" private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE"
private const val REFERENCE_EXISTS_CODE = "REFERENCE_EXISTS"
private const val OUTPUT_SUMMARY_LIMIT = 500 private const val OUTPUT_SUMMARY_LIMIT = 500
private const val REPO_MAP_INJECT_TOP_K = 30 private const val REPO_MAP_INJECT_TOP_K = 30
@@ -1165,12 +1165,24 @@ abstract class SessionOrchestrator(
if (p.sessionId == sessionId && ToolCapability.FILE_READ in p.capabilities) if (p.sessionId == sessionId && ToolCapability.FILE_READ in p.capabilities)
pendingReadIds += p.invocationId.value pendingReadIds += p.invocationId.value
is ToolCallAssessedEvent -> is ToolCallAssessedEvent ->
if (p.sessionId == sessionId && p.disposition == RiskAction.BLOCK && if (p.sessionId == sessionId && p.disposition == RiskAction.BLOCK) {
p.issues.any { it.code == READ_BEFORE_WRITE_CODE } when {
) { p.issues.any { it.code == READ_BEFORE_WRITE_CODE } -> {
blocked = true blocked = true
pendingReadIds.clear() pendingReadIds.clear()
} }
// A read BLOCKED because its target doesn't exist (REFERENCE_EXISTS) can never
// produce a completion, so it can't be what lifts read-only mode. Treat the
// attempt as satisfying read-before-write: the file is absent, so there is
// nothing to read before creating it. Without this, a stage that writes a NEW
// file deadlocks — writes are filtered out and every read of the not-yet-created
// target is blocked, burning MAX_TOOL_ROUNDS with no artifact.
p.issues.any { it.code == REFERENCE_EXISTS_CODE } -> {
blocked = false
pendingReadIds.clear()
}
}
}
is ToolExecutionCompletedEvent -> is ToolExecutionCompletedEvent ->
if (p.sessionId == sessionId && p.invocationId.value in pendingReadIds) { if (p.sessionId == sessionId && p.invocationId.value in pendingReadIds) {
blocked = false blocked = false
@@ -1440,6 +1452,10 @@ abstract class SessionOrchestrator(
request: ToolRequest, request: ToolRequest,
fileWrittenSlots: List<TypedArtifactSlot>, fileWrittenSlots: List<TypedArtifactSlot>,
) { ) {
// Invariant #5: every tool side effect is captured. This is the single authoritative
// ToolExecutionCompleted/Failed record and the source the read-before-write gate replays.
// FileWrittenEvent (with pre/post-image hashes for reversibility) and ToolExecutionStarted are
// emitted once, by SandboxedToolExecutor — NOT here — so completions aren't duplicated in the log.
when (result) { when (result) {
is ToolResult.Failure -> emit( is ToolResult.Failure -> emit(
sessionId, sessionId,
@@ -1461,8 +1477,7 @@ abstract class SessionOrchestrator(
exitCode = result.exitCode, exitCode = result.exitCode,
outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT), outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT),
// Carry the tool's structured metadata (e.g. file_read's contentHash) so // Carry the tool's structured metadata (e.g. file_read's contentHash) so
// session projections can read it — matching SandboxedToolExecutor, which // session projections can read it.
// already does this; the two completion paths were inconsistent.
structuredOutput = result.metadata, structuredOutput = result.metadata,
affectedEntities = affected.map { it.toString() }, affectedEntities = affected.map { it.toString() },
durationMs = 0, durationMs = 0,
@@ -1474,18 +1489,6 @@ abstract class SessionOrchestrator(
) )
if (affected.isNotEmpty()) { if (affected.isNotEmpty()) {
materializeFileWritten(sessionId, stageId, request, affected, fileWrittenSlots, diff) materializeFileWritten(sessionId, stageId, request, affected, fileWrittenSlots, diff)
affected.forEach { p ->
emit(
sessionId,
FileWrittenEvent(
invocationId = invocationId,
sessionId = sessionId,
path = p.toString(),
preExisted = true,
timestampMs = Clock.System.now().toEpochMilliseconds(),
),
)
}
} }
} }
} }
@@ -2363,8 +2366,9 @@ private suspend fun computeToolPreview(toolName: String, parameters: Map<String,
if (toolName == "task_decompose") return renderDecomposePreview(parameters) if (toolName == "task_decompose") return renderDecomposePreview(parameters)
if (toolName != "file_write") return null if (toolName != "file_write") return null
val path = parameters["path"] as? String ?: return null val path = parameters["path"] as? String ?: return null
val operation = parameters["operation"] as? String // file_write no longer carries an `operation` param (delete was split into file_delete), so the
if (operation != "write") return null // preview is unconditional: path + content is always a write. The old `operation == "write"` gate
// silently bailed to the raw-JSON args fallback for every file_write.
val proposedContent = parameters["content"] as? String ?: return null val proposedContent = parameters["content"] as? String ?: return null
val existingContent = withContext(Dispatchers.IO) { val existingContent = withContext(Dispatchers.IO) {
@@ -45,8 +45,10 @@ class ReferenceExistsRule : ToolCallRule {
if (inWorkspace && !exists) { if (inWorkspace && !exists) {
issues += ValidationIssue( issues += ValidationIssue(
code = RULE_CODE, code = RULE_CODE,
message = "Tool '${input.request.toolName}' references '$raw', which does not " + message = "Tool '${input.request.toolName}' references '$raw', which does not exist. " +
"exist — list the directory or fix the path; do not assume its contents.", "Do NOT keep retrying the read. If you intend to create this file, call file_write " +
"directly — you cannot read a file that does not exist yet. Otherwise list the " +
"directory or fix the path; do not assume its contents.",
severity = ValidationSeverity.ERROR, severity = ValidationSeverity.ERROR,
) )
disposition = maxAction(disposition, RiskAction.BLOCK) disposition = maxAction(disposition, RiskAction.BLOCK)
@@ -7,15 +7,12 @@ import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.LowQualityExtractionEvent import com.correx.core.events.events.LowQualityExtractionEvent
import com.correx.core.events.events.SourceFetchedEvent import com.correx.core.events.events.SourceFetchedEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionStartedEvent import com.correx.core.events.events.ToolExecutionStartedEvent
import com.correx.core.events.events.ToolReceipt
import com.correx.core.events.events.ToolRequest import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.FileAffectingTool import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult import com.correx.core.tools.contract.ValidationResult
@@ -43,7 +40,6 @@ class SandboxedToolExecutor(
val sessionId = request.sessionId val sessionId = request.sessionId
val invocationId = request.invocationId val invocationId = request.invocationId
val toolName = request.toolName val toolName = request.toolName
val startTime = System.currentTimeMillis()
// 1. resolve tool // 1. resolve tool
val tool = registry.resolve(toolName) val tool = registry.resolve(toolName)
@@ -61,7 +57,6 @@ class SandboxedToolExecutor(
// tool's arg schema and the model can correct + retry (bounded by MAX_TOOL_ROUNDS), instead // tool's arg schema and the model can correct + retry (bounded by MAX_TOOL_ROUNDS), instead
// of stranding the stage with no artifact ("no transition condition matched"). // of stranding the stage with no artifact ("no transition condition matched").
(tool.validateRequest(request) as? ValidationResult.Invalid)?.let { invalid -> (tool.validateRequest(request) as? ValidationResult.Invalid)?.let { invalid ->
emitFailed(sessionId, invocationId, toolName, invalid.reason)
return@withContext ToolResult.Failure( return@withContext ToolResult.Failure(
invocationId = invocationId, invocationId = invocationId,
reason = invalid.reason, reason = invalid.reason,
@@ -91,14 +86,13 @@ class SandboxedToolExecutor(
} }
// 7. delegate // 7. delegate
val durationMs = { System.currentTimeMillis() - startTime }
when (val result = delegate.execute(request)) { when (val result = delegate.execute(request)) {
is ToolResult.Success -> { is ToolResult.Success -> {
val diff = computeDiff(affectedPaths, backupMap)
restoreOrClean(backupMap, success = true) restoreOrClean(backupMap, success = true)
cleanWorkingDir(workingDir) cleanWorkingDir(workingDir)
emitCompleted(sessionId, invocationId, toolName, result, tool, affectedPaths, durationMs(), diff) // ToolExecutionCompleted/Failed are recorded once, by SessionOrchestrator (the
// authoritative execution recorder). Here we only emit what the orchestrator can't
// reconstruct: pre/post-image hashes (reversibility) and research-source markers.
emitResearchSourceEvents(sessionId, request.stageId, result) emitResearchSourceEvents(sessionId, request.stageId, result)
emitFileMutations(sessionId, invocationId, affectedPaths, preImages) emitFileMutations(sessionId, invocationId, affectedPaths, preImages)
result result
@@ -107,7 +101,6 @@ class SandboxedToolExecutor(
is ToolResult.Failure -> { is ToolResult.Failure -> {
restoreOrClean(backupMap, success = false) restoreOrClean(backupMap, success = false)
cleanWorkingDir(workingDir) cleanWorkingDir(workingDir)
emitFailed(sessionId, invocationId, toolName, result.reason)
result result
} }
} }
@@ -168,39 +161,6 @@ class SandboxedToolExecutor(
toolName: String, toolName: String,
) = emit(sessionId, ToolExecutionStartedEvent(invocationId, sessionId, toolName)) ) = emit(sessionId, ToolExecutionStartedEvent(invocationId, sessionId, toolName))
private suspend fun emitCompleted(
sessionId: SessionId,
invocationId: ToolInvocationId,
toolName: String,
result: ToolResult.Success,
tool: Tool,
affectedPaths: Set<Path>,
durationMs: Long,
diff: String? = null,
) {
val affectedEntities = affectedPaths.map { it.toString() }
emit(
sessionId,
ToolExecutionCompletedEvent(
invocationId = invocationId,
sessionId = sessionId,
toolName = toolName,
receipt = ToolReceipt(
invocationId = invocationId,
toolName = toolName,
exitCode = result.exitCode,
outputSummary = result.output,
structuredOutput = result.metadata,
affectedEntities = affectedEntities,
durationMs = durationMs,
tier = tool.tier,
timestamp = Clock.System.now(),
diff = diff,
),
),
)
}
/** /**
* Promotes the research fetch's quality + content-hash (BACKLOG §D) from the generic * Promotes the research fetch's quality + content-hash (BACKLOG §D) from the generic
* [ToolExecutionCompletedEvent] metadata into first-class events, additively: the metadata write * [ToolExecutionCompletedEvent] metadata into first-class events, additively: the metadata write
@@ -232,44 +192,6 @@ class SandboxedToolExecutor(
} }
} }
@Suppress("MaxLineLength")
private fun computeDiff(affectedPaths: Set<Path>, backupMap: Map<Path, Path>): String? {
val diffs = mutableListOf<String>()
// Files that existed before the tool ran (backed up)
for ((original, backup) in backupMap) {
runCatching {
val oldContent = Files.readString(backup)
val newContent = if (Files.exists(original)) Files.readString(original) else ""
if (oldContent != newContent) {
diffs.add(DiffUtil.unifiedDiff(oldContent, newContent, original.toString()))
}
}
}
// Newly created files (no backup, but affected and now exist)
for (path in affectedPaths) {
if (!backupMap.containsKey(path) && Files.exists(path)) {
runCatching {
val newContent = Files.readString(path)
if (newContent.isNotEmpty()) {
diffs.add(DiffUtil.unifiedDiff("", newContent, path.toString()))
}
}
}
}
if (diffs.isEmpty()) return null
return diffs.joinToString("\n")
}
private suspend fun emitFailed(
sessionId: SessionId,
invocationId: ToolInvocationId,
toolName: String,
reason: String,
) = emit(sessionId, ToolExecutionFailedEvent(invocationId, sessionId, toolName, reason))
private suspend fun emit(sessionId: SessionId, payload: EventPayload) { private suspend fun emit(sessionId: SessionId, payload: EventPayload) {
eventDispatcher.emit(payload, sessionId) eventDispatcher.emit(payload, sessionId)
} }
@@ -82,6 +82,9 @@ class SandboxedToolExecutorValidationTest {
result as ToolResult.Failure result as ToolResult.Failure
assertTrue(result.recoverable, "validation failures must be recoverable so the model can correct + retry") assertTrue(result.recoverable, "validation failures must be recoverable so the model can correct + retry")
assertEquals(1, events.payloads.filterIsInstance<ToolExecutionFailedEvent>().size) // The executor surfaces the failure via the returned result; it does NOT emit
// ToolExecutionFailedEvent — SessionOrchestrator is the single authoritative recorder of
// tool completion/failure (avoids the double-emit that duplicated tool rows in the TUI).
assertEquals(0, events.payloads.filterIsInstance<ToolExecutionFailedEvent>().size)
} }
} }
@@ -19,6 +19,7 @@ import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolRequest import com.correx.core.events.events.ToolRequest
import com.correx.core.toolintent.rules.ReadBeforeWriteRule import com.correx.core.toolintent.rules.ReadBeforeWriteRule
import com.correx.core.toolintent.rules.ReferenceExistsRule
import com.correx.core.tools.contract.ParamRole import com.correx.core.tools.contract.ParamRole
import com.correx.core.events.execution.RetryPolicy import com.correx.core.events.execution.RetryPolicy
import com.correx.core.events.risk.RiskAction import com.correx.core.events.risk.RiskAction
@@ -575,6 +576,146 @@ class ToolCallGateTest {
) )
} }
@Test
fun `a read blocked by REFERENCE_EXISTS lifts read-only mode so a new-file write is not deadlocked`(): Unit = runBlocking {
val writeTarget = "/work/Existing.kt" // exists → READ_BEFORE_WRITE blocks the unread write
val ghostRead = "/work/Ghost.kt" // absent → REFERENCE_EXISTS blocks the read
val fileWriteTool = object : Tool {
override val name = "file_write"
override val description = "write"
override val parametersSchema: JsonObject = buildJsonObject {}
override val tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
}
val fileReadTool = object : Tool {
override val name = "file_read"
override val description = "read"
override val parametersSchema: JsonObject = buildJsonObject {}
override val tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
}
val twoToolRegistry = object : ToolRegistry {
override fun resolve(name: String): Tool? = when (name) {
"file_write" -> fileWriteTool
"file_read" -> fileReadTool
else -> null
}
override fun all(): List<Tool> = listOf(fileWriteTool, fileReadTool)
}
// turn 1 → write existing-but-unread (→ READ_BEFORE_WRITE block → read-only),
// turn 2 → read a file that does not exist (→ REFERENCE_EXISTS block),
// turn 3 → write again; file_write must be offered again (read-only lifted, no deadlock).
val offeredToolsPerTurn = mutableListOf<Set<String>>()
var turnCount = 0
val provider = object : InferenceProvider {
override val id = ProviderId("rbw-ghost")
override val name = "rbw-ghost"
override val tokenizer: Tokenizer = MockTokenizer()
override suspend fun infer(request: InferenceRequest): InferenceResponse {
offeredToolsPerTurn += request.tools.map { it.function.name }.toSet()
turnCount++
val toolCall = when (turnCount) {
1 -> ToolCallRequest(id = "tc-w1", function = ToolCallFunction("file_write", """{"path":"$writeTarget"}"""))
2 -> ToolCallRequest(id = "tc-r1", function = ToolCallFunction("file_read", """{"path":"$ghostRead"}"""))
3 -> ToolCallRequest(id = "tc-w2", function = ToolCallFunction("file_write", """{"path":"$writeTarget"}"""))
else -> null
}
return if (toolCall != null) {
InferenceResponse(request.requestId, "", FinishReason.ToolCall, TokenUsage(1, 1), 0, listOf(toolCall))
} else {
InferenceResponse(request.requestId, "done", FinishReason.Stop, TokenUsage(1, 1), 0)
}
}
override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
override fun capabilities(): Set<CapabilityScore> = setOf(CapabilityScore(ModelCapability.General, 1.0))
}
// Path-discriminating probe: the write target exists, the ghost read does not.
val fakeProbe = object : com.correx.core.toolintent.WorldProbe {
override fun exists(path: java.nio.file.Path): Boolean = path.toString().endsWith("Existing.kt")
override fun resolveReal(path: java.nio.file.Path): java.nio.file.Path = path.toAbsolutePath().normalize()
}
val eventStore = InMemoryEventStore()
val artifactStore = NoopArtifactStore()
val assessor = ToolCallAssessor(listOf(ReadBeforeWriteRule(), ReferenceExistsRule()))
val policy = WorkspacePolicy(workspace)
val repositories = OrchestratorRepositories(
eventStore = eventStore,
inferenceRepository = InferenceRepository(object : EventReplayer<InferenceState> {
override fun rebuild(sessionId: SessionId) = InferenceState()
}),
orchestrationRepository = OrchestrationRepository(
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
),
sessionRepository = DefaultSessionRepository(
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
),
artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
approvalRepository = DefaultApprovalRepository(
DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
),
)
val engines = OrchestratorEngines(
transitionResolver = DefaultTransitionResolver { _, _ -> true },
contextPackBuilder = ContextFixtures.simpleBuilder(),
inferenceRouter = object : InferenceRouter {
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider = provider
},
validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
approvalEngine = DefaultApprovalEngine(),
riskAssessor = DefaultRiskAssessor(),
toolExecutor = object : ToolExecutor {
override suspend fun execute(request: ToolRequest): ToolResult =
ToolResult.Success(request.invocationId, "ok", 0)
},
toolRegistry = twoToolRegistry,
toolCallAssessor = assessor,
workspacePolicy = policy,
worldProbe = fakeProbe,
)
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = artifactStore,
decisionJournalRepository = DefaultDecisionJournalRepository(
DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())),
),
)
val sessionId = SessionId("rbw-ghost-deadlock")
val graph = WorkflowGraph(
id = "rbw-ghost-test",
stages = mapOf(
StageId("A") to StageConfig(allowedTools = setOf("file_write", "file_read")),
),
transitions = setOf(
TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { true }),
),
start = StageId("A"),
)
orchestrator.run(sessionId, graph, OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0)))
// Turn 2 is read-only (the turn-1 write was READ_BEFORE_WRITE-blocked).
assertTrue(offeredToolsPerTurn.size >= 3, "expected at least 3 inference turns, got ${offeredToolsPerTurn.size}")
assertTrue("file_write" !in offeredToolsPerTurn[1], "turn 2 must be read-only after the write block")
// Turn 3: the ghost read could never complete, so without the fix read-only would never lift
// and file_write would stay filtered forever. The fix lifts it on the REFERENCE_EXISTS block.
assertTrue(
"file_write" in offeredToolsPerTurn[2],
"turn 3 must offer file_write again — a REFERENCE_EXISTS-blocked read must lift read-only, " +
"got: ${offeredToolsPerTurn[2]}",
)
}
@Test @Test
fun `empty allowedTools denies all domain tools and emits ToolExecutionRejectedEvent`(): Unit = runBlocking { fun `empty allowedTools denies all domain tools and emits ToolExecutionRejectedEvent`(): Unit = runBlocking {
val executor = RecordingExecutor() val executor = RecordingExecutor()