From bedd6e020c6e26c7cf193272b51fc1e1e8dbd0fa Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 26 May 2026 21:41:56 +0400 Subject: [PATCH] fix: guard stuck-session fix against OrchestrationPaused/ApprovalRequested race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a TUI client connects at the exact moment between an OrchestrationPausedEvent being stored and its corresponding ApprovalRequestedEvent being stored, the approval projector sees empty pendingApprovalRequests while orchState.pendingApproval=true. The stuck-session fix would then append a spurious OrchestrationResumedEvent, clearing the TUI's pendingApproval display and hiding the approval dialog. Fix: count OrchestrationPausedEvents vs ApprovalRequestedEvents in the event log. If there is an unpaired pause (more pauses than requests), the session just entered the gate — skip the fix entirely. Also remove spurious ToolExecutionCompleted/Failed emissions from the kernel orchestrator; those events are emitted by SandboxedToolExecutor in the infrastructure layer. Keep ToolExecutionRejectedEvent which the orchestrator does own (rejection happens before executor.execute is called). --- .../apps/server/bridge/SessionEventBridge.kt | 11 +++++++- .../orchestration/SessionOrchestrator.kt | 26 ------------------- 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt index 850b8101..67476699 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt @@ -17,6 +17,7 @@ import com.correx.core.events.events.InferenceCompletedEvent import com.correx.core.events.events.InferenceStartedEvent import com.correx.core.events.events.InferenceTimeoutEvent import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.StageCompletedEvent @@ -77,7 +78,15 @@ class SessionEventBridge( // actual unresolved approval requests. This happens when approval was resolved but // OrchestrationResumedEvent was not emitted (pre-Feb-13 orchestrator bug). Append the // missing event permanently so the session state corrects on all future replays. - if (orchState.pendingApproval && pendingApprovalRequests.isEmpty()) { + // + // Guard: if there are more OrchestrationPausedEvents than ApprovalRequestedEvents the + // session just entered the approval gate and ApprovalRequestedEvent has not been stored + // yet (tiny race window between two sequential emits). Do NOT fire in that case — + // appending a spurious resume would hide the pending approval from the TUI. + val pauseCount = events.count { it.payload is OrchestrationPausedEvent } + val approvalRequestCount = events.count { it.payload is ApprovalRequestedEvent } + val hasUnpairedPause = pauseCount > approvalRequestCount + if (orchState.pendingApproval && pendingApprovalRequests.isEmpty() && !hasUnpairedPause) { val alreadyResumed = events.any { it.payload is OrchestrationResumedEvent } val stageId = orchState.currentStageId if (!alreadyResumed && stageId != null) { diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 593cab7f..cbd294cf 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -29,11 +29,8 @@ import com.correx.core.events.events.NewEvent 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.ToolExecutionCompletedEvent -import com.correx.core.events.events.ToolExecutionFailedEvent import com.correx.core.events.events.ToolExecutionRejectedEvent import com.correx.core.events.events.ToolInvocationRequestedEvent -import com.correx.core.events.events.ToolReceipt import com.correx.core.events.events.ToolRequest import com.correx.core.events.events.TransitionExecutedEvent import com.correx.core.events.events.WorkflowCompletedEvent @@ -432,29 +429,6 @@ abstract class SessionOrchestrator( artifactContentCache["${sessionId.value}:${slot.name.value}"] = jsonContent } - when (result) { - is ToolResult.Success -> emit(sessionId, ToolExecutionCompletedEvent( - invocationId = invocationId, - sessionId = sessionId, - toolName = toolCall.function.name, - receipt = ToolReceipt( - invocationId = invocationId, - toolName = toolCall.function.name, - exitCode = result.exitCode, - outputSummary = result.output.take(500), - durationMs = 0, - tier = tier, - timestamp = Clock.System.now(), - ), - )) - is ToolResult.Failure -> emit(sessionId, ToolExecutionFailedEvent( - invocationId = invocationId, - sessionId = sessionId, - toolName = toolCall.function.name, - reason = result.reason, - )) - } - val sourceId = toolCall.id ?: invocationId.value val assistantEntry = ContextEntry( id = ContextEntryId(UUID.randomUUID().toString()),