fix: unblock sessions stuck in PAUSED state after approval resolved
Sessions that had an approval resolved (or never needed one) but never received OrchestrationResumedEvent would show the last tool as RUNNING forever with no subsequent events. Three coordinated fixes: 1. SessionOrchestrator now emits OrchestrationResumedEvent in both the approved and rejected approval branches, so the paused flag clears correctly going forward. 2. SessionEventBridge.replaySnapshot() detects the stuck pattern (pendingApproval=true with no unresolved requests) and appends a synthetic OrchestrationResumedEvent so historical sessions self-heal on next TUI connect. 3. DomainEventMapper maps OrchestrationResumedEvent → SessionResumed; TUI SessionsReducer/SnapshotPhaseReducer handle the new message, clearing pendingApproval and setting status back to ACTIVE. Also adds server-restart recovery path in DefaultSessionOrchestrator (emit decision + resume events directly when no live deferred exists) and pre-registers pending approvals in ServerModule.start() to close the race between reconnect and ApprovalResponse routing.
This commit is contained in:
@@ -4,5 +4,5 @@ import com.correx.core.approvals.model.ApprovalDecision
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
|
||||
interface ApprovalGateway {
|
||||
fun submitApprovalDecision(requestId: ApprovalRequestId, decision: ApprovalDecision)
|
||||
suspend fun submitApprovalDecision(requestId: ApprovalRequestId, decision: ApprovalDecision)
|
||||
}
|
||||
|
||||
+33
-3
@@ -1,10 +1,15 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.model.ApprovalDecision
|
||||
import com.correx.core.artifacts.ArtifactState
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.orchestration.OrchestrationState
|
||||
import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.ArtifactLifecyclePhase
|
||||
@@ -16,7 +21,9 @@ import com.correx.core.sessions.Session
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.resolution.TransitionDecision
|
||||
import kotlinx.datetime.Clock
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
|
||||
@@ -133,10 +140,33 @@ class DefaultSessionOrchestrator(
|
||||
cancellations.getOrPut(sessionId) { AtomicBoolean(false) }.set(true)
|
||||
}
|
||||
|
||||
override fun submitApprovalDecision(requestId: ApprovalRequestId, decision: ApprovalDecision) {
|
||||
override suspend fun submitApprovalDecision(requestId: ApprovalRequestId, decision: ApprovalDecision) {
|
||||
val deferred = pendingApprovals[requestId]
|
||||
?: error("No pending approval for requestId ${requestId.value}")
|
||||
deferred.complete(decision)
|
||||
if (deferred != null) {
|
||||
deferred.complete(decision)
|
||||
return
|
||||
}
|
||||
// No live orchestrator coroutine for this request — server restarted while approval was pending.
|
||||
// Record the decision and emit resume to unblock the session in the event store.
|
||||
log.warn(
|
||||
"submitApprovalDecision: no live deferred for requestId={} (server restart?), emitting events directly",
|
||||
requestId.value,
|
||||
)
|
||||
val sessionId = decision.contextSnapshot.identity.sessionId
|
||||
val stageId = decision.contextSnapshot.identity.stageId
|
||||
?: orchestrationRepository.getState(sessionId).currentStageId
|
||||
?: return
|
||||
emit(sessionId, ApprovalDecisionResolvedEvent(
|
||||
decisionId = ApprovalDecisionId(UUID.randomUUID().toString()),
|
||||
requestId = requestId,
|
||||
outcome = decision.outcome ?: ApprovalOutcome.REJECTED,
|
||||
status = ApprovalStatus.COMPLETED,
|
||||
tier = decision.tier,
|
||||
resolutionTimestamp = Clock.System.now(),
|
||||
reason = decision.reason,
|
||||
userSteering = decision.userSteering,
|
||||
))
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
}
|
||||
|
||||
private suspend fun executeMove(
|
||||
|
||||
+2
@@ -371,6 +371,7 @@ abstract class SessionOrchestrator(
|
||||
}
|
||||
emitDecisionResolved(sessionId, domainRequest, decision)
|
||||
if (!decision.isApproved) {
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
val sourceId = toolCall.id ?: invocationId.value
|
||||
val assistantEntry = ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
@@ -775,6 +776,7 @@ abstract class SessionOrchestrator(
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
StageExecutionResult.Success(emptyList())
|
||||
} else {
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
StageExecutionResult.Failure(decision.reason ?: "approval rejected", retryable = false)
|
||||
}
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user