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.RiskAssessedEvent
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.StageCheckpointPassedEvent
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 SCOPE_PROPOSAL_TOOL = "propose_scope"
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 REPO_MAP_INJECT_TOP_K = 30
@@ -1165,11 +1165,23 @@ abstract class SessionOrchestrator(
if (p.sessionId == sessionId && ToolCapability.FILE_READ in p.capabilities)
pendingReadIds += p.invocationId.value
is ToolCallAssessedEvent ->
if (p.sessionId == sessionId && p.disposition == RiskAction.BLOCK &&
p.issues.any { it.code == READ_BEFORE_WRITE_CODE }
) {
blocked = true
pendingReadIds.clear()
if (p.sessionId == sessionId && p.disposition == RiskAction.BLOCK) {
when {
p.issues.any { it.code == READ_BEFORE_WRITE_CODE } -> {
blocked = true
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 ->
if (p.sessionId == sessionId && p.invocationId.value in pendingReadIds) {
@@ -1440,6 +1452,10 @@ abstract class SessionOrchestrator(
request: ToolRequest,
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) {
is ToolResult.Failure -> emit(
sessionId,
@@ -1461,8 +1477,7 @@ abstract class SessionOrchestrator(
exitCode = result.exitCode,
outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT),
// Carry the tool's structured metadata (e.g. file_read's contentHash) so
// session projections can read it — matching SandboxedToolExecutor, which
// already does this; the two completion paths were inconsistent.
// session projections can read it.
structuredOutput = result.metadata,
affectedEntities = affected.map { it.toString() },
durationMs = 0,
@@ -1474,18 +1489,6 @@ abstract class SessionOrchestrator(
)
if (affected.isNotEmpty()) {
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 != "file_write") return null
val path = parameters["path"] as? String ?: return null
val operation = parameters["operation"] as? String
if (operation != "write") return null
// file_write no longer carries an `operation` param (delete was split into file_delete), so the
// 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 existingContent = withContext(Dispatchers.IO) {
@@ -45,8 +45,10 @@ class ReferenceExistsRule : ToolCallRule {
if (inWorkspace && !exists) {
issues += ValidationIssue(
code = RULE_CODE,
message = "Tool '${input.request.toolName}' references '$raw', which does not " +
"exist — list the directory or fix the path; do not assume its contents.",
message = "Tool '${input.request.toolName}' references '$raw', which does not exist. " +
"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,
)
disposition = maxAction(disposition, RiskAction.BLOCK)