fix: PAUSED resume after server restart, diff viewer only shows last tool

This commit is contained in:
2026-05-26 22:26:56 +04:00
parent 1af42befca
commit 92bea6c2c4
3 changed files with 56 additions and 14 deletions
@@ -9,7 +9,9 @@ import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.config.ApprovalConfig
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationRepository
@@ -17,6 +19,7 @@ import com.correx.core.router.RouterFacade
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.events.orchestration.OrchestrationStatus
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -55,6 +58,7 @@ class ServerModule(
)
private var subscriptionJob: Job? = null
private val activeSessionJobs = ConcurrentHashMap<SessionId, Job>()
/**
* Subscribe to the event store and forward [ApprovalRequestedEvent] payloads to
@@ -73,26 +77,50 @@ class ServerModule(
.filter { it.payload is ApprovalRequestedEvent }
.onEach { approvalCoordinator.onApprovalRequested(it.payload as ApprovalRequestedEvent) }
.launchIn(moduleScope)
// When a PAUSED session's approval is resolved without a live orchestrator coroutine
// (server was restarted while approval was pending), emit OrchestrationResumedEvent
// triggers this subscription to continue the session.
eventStore.subscribeAll()
.filter { it.payload is OrchestrationResumedEvent }
.onEach { event ->
val sessionId = (event.payload as OrchestrationResumedEvent).sessionId
if (activeSessionJobs.containsKey(sessionId)) return@onEach
val orchState = orchestrationRepository.getState(sessionId)
if (orchState.status != OrchestrationStatus.RUNNING) return@onEach
val graph = workflowRegistry.find(orchState.workflowId) ?: return@onEach
launchSessionResume(sessionId, graph)
}
.launchIn(moduleScope)
}
private fun launchSessionResume(sessionId: SessionId, graph: com.correx.core.transitions.graph.WorkflowGraph) {
if (activeSessionJobs.containsKey(sessionId)) return
val job = moduleScope.launch {
runCatching {
orchestrator.resume(sessionId, graph, defaultOrchestrationConfig)
}.onFailure { e ->
log.error("resumeSession: session={} failed", sessionId.value, e)
}
activeSessionJobs.remove(sessionId)
}
activeSessionJobs[sessionId] = job
}
private fun resumeAbandonedSessions() {
eventStore.allSessionIds().forEach { sessionId ->
val orchState = orchestrationRepository.getState(sessionId)
if (orchState.status != OrchestrationStatus.RUNNING &&
orchState.status != OrchestrationStatus.PAUSED
) return@forEach
// Skip PAUSED sessions — they are waiting for user approval, not for computation.
// preRegisterPendingApprovals() already routes their responses. When the user
// approves, submitApprovalDecision emits OrchestrationResumedEvent which triggers
// the subscription above to continue the session.
if (orchState.status != OrchestrationStatus.RUNNING) return@forEach
val graph = workflowRegistry.find(orchState.workflowId) ?: run {
log.warn("resumeAbandoned: no graph for session={} workflowId={}", sessionId.value, orchState.workflowId)
return@forEach
}
log.info("resumeAbandoned: launching session={} workflow={}", sessionId.value, orchState.workflowId)
moduleScope.launch {
runCatching {
orchestrator.resume(sessionId, graph, defaultOrchestrationConfig)
}.onFailure { e ->
log.error("resumeAbandoned: session={} failed", sessionId.value, e)
}
}
launchSessionResume(sessionId, graph)
}
}
@@ -163,9 +163,9 @@ private fun renderIdleLayout(frame: Frame, state: TuiState) {
private fun renderInSessionLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val hasDiff = selectedSession?.tools?.any {
it.status == ToolDisplayStatus.COMPLETED && it.diff != null
} == true
val hasDiff = selectedSession?.tools
?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED }
?.diff != null
val constraints = mutableListOf(
Constraint.length(STATUS_HEIGHT),
@@ -197,12 +197,20 @@ private fun renderInSessionLayout(frame: Frame, state: TuiState) {
private fun renderApprovalLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val hasDiff = selectedSession?.tools
?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED }
?.diff != null
val constraints = mutableListOf(
Constraint.length(STATUS_HEIGHT),
)
if (state.eventStripVisible) {
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
}
if (hasDiff) {
constraints.add(Constraint.length(DIFF_HEIGHT))
}
constraints.add(Constraint.fill())
constraints.add(Constraint.length(INPUT_HEIGHT))
@@ -215,6 +223,9 @@ private fun renderApprovalLayout(frame: Frame, state: TuiState) {
if (state.eventStripVisible) {
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
}
if (hasDiff) {
frame.renderWidget(diffViewerWidget(state), vertRects[rectIdx++])
}
frame.renderWidget(approvalSurfaceWidget(state), vertRects[rectIdx++])
frame.renderWidget(inputBarWidget(state, DisplayState.APPROVAL), vertRects[rectIdx])
}
@@ -23,8 +23,11 @@ private val yellowStyle = Style.create().yellow()
fun diffViewerWidget(state: TuiState): Paragraph {
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
// Only show the diff for the most recently completed tool. If the last tool has no diff
// (e.g. a shell command), the viewer is hidden — stale diffs from earlier tools are noise.
val toolWithDiff = selectedSession?.tools
?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED && it.diff != null }
?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED }
?.takeIf { it.diff != null }
val diffText = toolWithDiff?.diff ?: return Paragraph.builder().build()