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:
+3
-81
@@ -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)
|
||||
}
|
||||
|
||||
+4
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user