fix: PAUSED resume after server restart, diff viewer only shows last tool
This commit is contained in:
@@ -9,7 +9,9 @@ import com.correx.core.approvals.DefaultApprovalRepository
|
|||||||
import com.correx.core.artifactstore.ArtifactStore
|
import com.correx.core.artifactstore.ArtifactStore
|
||||||
import com.correx.core.config.ApprovalConfig
|
import com.correx.core.config.ApprovalConfig
|
||||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
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.stores.EventStore
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
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.sessions.DefaultSessionRepository
|
||||||
import com.correx.core.tools.registry.ToolRegistry
|
import com.correx.core.tools.registry.ToolRegistry
|
||||||
import com.correx.core.events.orchestration.OrchestrationStatus
|
import com.correx.core.events.orchestration.OrchestrationStatus
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -55,6 +58,7 @@ class ServerModule(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private var subscriptionJob: Job? = null
|
private var subscriptionJob: Job? = null
|
||||||
|
private val activeSessionJobs = ConcurrentHashMap<SessionId, Job>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to the event store and forward [ApprovalRequestedEvent] payloads to
|
* Subscribe to the event store and forward [ApprovalRequestedEvent] payloads to
|
||||||
@@ -73,26 +77,50 @@ class ServerModule(
|
|||||||
.filter { it.payload is ApprovalRequestedEvent }
|
.filter { it.payload is ApprovalRequestedEvent }
|
||||||
.onEach { approvalCoordinator.onApprovalRequested(it.payload as ApprovalRequestedEvent) }
|
.onEach { approvalCoordinator.onApprovalRequested(it.payload as ApprovalRequestedEvent) }
|
||||||
.launchIn(moduleScope)
|
.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() {
|
private fun resumeAbandonedSessions() {
|
||||||
eventStore.allSessionIds().forEach { sessionId ->
|
eventStore.allSessionIds().forEach { sessionId ->
|
||||||
val orchState = orchestrationRepository.getState(sessionId)
|
val orchState = orchestrationRepository.getState(sessionId)
|
||||||
if (orchState.status != OrchestrationStatus.RUNNING &&
|
// Skip PAUSED sessions — they are waiting for user approval, not for computation.
|
||||||
orchState.status != OrchestrationStatus.PAUSED
|
// preRegisterPendingApprovals() already routes their responses. When the user
|
||||||
) return@forEach
|
// 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 {
|
val graph = workflowRegistry.find(orchState.workflowId) ?: run {
|
||||||
log.warn("resumeAbandoned: no graph for session={} workflowId={}", sessionId.value, orchState.workflowId)
|
log.warn("resumeAbandoned: no graph for session={} workflowId={}", sessionId.value, orchState.workflowId)
|
||||||
return@forEach
|
return@forEach
|
||||||
}
|
}
|
||||||
log.info("resumeAbandoned: launching session={} workflow={}", sessionId.value, orchState.workflowId)
|
log.info("resumeAbandoned: launching session={} workflow={}", sessionId.value, orchState.workflowId)
|
||||||
moduleScope.launch {
|
launchSessionResume(sessionId, graph)
|
||||||
runCatching {
|
|
||||||
orchestrator.resume(sessionId, graph, defaultOrchestrationConfig)
|
|
||||||
}.onFailure { e ->
|
|
||||||
log.error("resumeAbandoned: session={} failed", sessionId.value, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,9 +163,9 @@ private fun renderIdleLayout(frame: Frame, state: TuiState) {
|
|||||||
private fun renderInSessionLayout(frame: Frame, state: TuiState) {
|
private fun renderInSessionLayout(frame: Frame, state: TuiState) {
|
||||||
val area = frame.area()
|
val area = frame.area()
|
||||||
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
|
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
|
||||||
val hasDiff = selectedSession?.tools?.any {
|
val hasDiff = selectedSession?.tools
|
||||||
it.status == ToolDisplayStatus.COMPLETED && it.diff != null
|
?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED }
|
||||||
} == true
|
?.diff != null
|
||||||
|
|
||||||
val constraints = mutableListOf(
|
val constraints = mutableListOf(
|
||||||
Constraint.length(STATUS_HEIGHT),
|
Constraint.length(STATUS_HEIGHT),
|
||||||
@@ -197,12 +197,20 @@ private fun renderInSessionLayout(frame: Frame, state: TuiState) {
|
|||||||
|
|
||||||
private fun renderApprovalLayout(frame: Frame, state: TuiState) {
|
private fun renderApprovalLayout(frame: Frame, state: TuiState) {
|
||||||
val area = frame.area()
|
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(
|
val constraints = mutableListOf(
|
||||||
Constraint.length(STATUS_HEIGHT),
|
Constraint.length(STATUS_HEIGHT),
|
||||||
)
|
)
|
||||||
if (state.eventStripVisible) {
|
if (state.eventStripVisible) {
|
||||||
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
|
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
|
||||||
}
|
}
|
||||||
|
if (hasDiff) {
|
||||||
|
constraints.add(Constraint.length(DIFF_HEIGHT))
|
||||||
|
}
|
||||||
constraints.add(Constraint.fill())
|
constraints.add(Constraint.fill())
|
||||||
constraints.add(Constraint.length(INPUT_HEIGHT))
|
constraints.add(Constraint.length(INPUT_HEIGHT))
|
||||||
|
|
||||||
@@ -215,6 +223,9 @@ private fun renderApprovalLayout(frame: Frame, state: TuiState) {
|
|||||||
if (state.eventStripVisible) {
|
if (state.eventStripVisible) {
|
||||||
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
|
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
|
||||||
}
|
}
|
||||||
|
if (hasDiff) {
|
||||||
|
frame.renderWidget(diffViewerWidget(state), vertRects[rectIdx++])
|
||||||
|
}
|
||||||
frame.renderWidget(approvalSurfaceWidget(state), vertRects[rectIdx++])
|
frame.renderWidget(approvalSurfaceWidget(state), vertRects[rectIdx++])
|
||||||
frame.renderWidget(inputBarWidget(state, DisplayState.APPROVAL), 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 {
|
fun diffViewerWidget(state: TuiState): Paragraph {
|
||||||
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
|
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
|
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()
|
val diffText = toolWithDiff?.diff ?: return Paragraph.builder().build()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user