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
@@ -7,15 +7,12 @@ import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.LowQualityExtractionEvent
import com.correx.core.events.events.SourceFetchedEvent
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.ToolReceipt
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
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.ToolResult
import com.correx.core.tools.contract.ValidationResult
@@ -43,7 +40,6 @@ class SandboxedToolExecutor(
val sessionId = request.sessionId
val invocationId = request.invocationId
val toolName = request.toolName
val startTime = System.currentTimeMillis()
// 1. resolve tool
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
// of stranding the stage with no artifact ("no transition condition matched").
(tool.validateRequest(request) as? ValidationResult.Invalid)?.let { invalid ->
emitFailed(sessionId, invocationId, toolName, invalid.reason)
return@withContext ToolResult.Failure(
invocationId = invocationId,
reason = invalid.reason,
@@ -91,14 +86,13 @@ class SandboxedToolExecutor(
}
// 7. delegate
val durationMs = { System.currentTimeMillis() - startTime }
when (val result = delegate.execute(request)) {
is ToolResult.Success -> {
val diff = computeDiff(affectedPaths, backupMap)
restoreOrClean(backupMap, success = true)
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)
emitFileMutations(sessionId, invocationId, affectedPaths, preImages)
result
@@ -107,7 +101,6 @@ class SandboxedToolExecutor(
is ToolResult.Failure -> {
restoreOrClean(backupMap, success = false)
cleanWorkingDir(workingDir)
emitFailed(sessionId, invocationId, toolName, result.reason)
result
}
}
@@ -168,39 +161,6 @@ class SandboxedToolExecutor(
toolName: String,
) = 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
* [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) {
eventDispatcher.emit(payload, sessionId)
}
@@ -82,6 +82,9 @@ class SandboxedToolExecutorValidationTest {
result as ToolResult.Failure
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)
}
}