fix: P1 TUI UX batch — race root cause, diff content, per-session routerMessages, optimistic IDLE submit

P1-1: Replace check-then-act with computeIfAbsent for activeSessionJobs to
prevent double-launch. Register pendingApprovals[requestId] BEFORE emitting
ApprovalRequestedEvent to close the approve-before-register window.
P1-2: ToolCompleted with a diff now appends msg.diff to routerMessages
instead of the static "--- diff from $toolName ---" marker.
P1-3: Change routerMessages from List<String> to Map<String, List<String>>
keyed by sessionId.value. RootReducer appends to the correct session's list;
RouterPanel renders only routerMessages[selectedId].
P1-4: On IDLE chat submit, SessionsReducer creates an optimistic
SessionSummary(status="STARTING") and sets selectedId.
processSessionStartedMessage removes any STARTING session and inserts the
real one on echo. RootReducer sets sessionEntered=true for the optimistic
session.
Tests: LaunchRegistrationRaceTest, ApprovalRegisterBeforeEmitTest,
RootReducerTest (diff/per-session/isolation/optimistic-submit),
SessionsReducerTest (optimistic/reconciliation). All P0/P1 tests pass.
./gradlew check green.
This commit is contained in:
2026-05-28 23:24:07 +04:00
parent eed557fe9c
commit 8ea3c381ef
10 changed files with 345 additions and 49 deletions
@@ -107,47 +107,47 @@ class ServerModule(
* duplicating inference calls (the root cause of the healthcheck script corruption).
*/
fun launchSessionRun(sessionId: SessionId, graph: com.correx.core.transitions.graph.WorkflowGraph) {
if (activeSessionJobs.containsKey(sessionId)) return
val job = moduleScope.launch {
runCatching {
orchestrator.run(sessionId, graph, defaultOrchestrationConfig)
}.onFailure { ex ->
log.error("runSession: session={} failed: {}", sessionId.value, ex.message, ex)
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(java.util.UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
activeSessionJobs.computeIfAbsent(sessionId) {
moduleScope.launch {
runCatching {
orchestrator.run(sessionId, graph, defaultOrchestrationConfig)
}.onFailure { ex ->
log.error("runSession: session={} failed: {}", sessionId.value, ex.message, ex)
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(java.util.UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = WorkflowFailedEvent(
sessionId = sessionId,
stageId = graph.start,
reason = ex.message ?: "unexpected orchestrator failure",
retryExhausted = false,
),
),
payload = WorkflowFailedEvent(
sessionId = sessionId,
stageId = graph.start,
reason = ex.message ?: "unexpected orchestrator failure",
retryExhausted = false,
),
),
)
)
}
activeSessionJobs.remove(sessionId)
}
activeSessionJobs.remove(sessionId)
}
activeSessionJobs[sessionId] = job
}
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.computeIfAbsent(sessionId) {
moduleScope.launch {
runCatching {
orchestrator.resume(sessionId, graph, defaultOrchestrationConfig)
}.onFailure { e ->
log.error("resumeSession: session={} failed", sessionId.value, e)
}
activeSessionJobs.remove(sessionId)
}
activeSessionJobs.remove(sessionId)
}
activeSessionJobs[sessionId] = job
}
private fun resumeAbandonedSessions() {
@@ -17,7 +17,8 @@ fun routerPanelWidget(state: TuiState): Paragraph {
if (!state.routerConnected) {
add(Line.from(Span.styled("not connected — epic 14", dimStyle)))
} else {
for (msg in state.routerMessages) {
val msgs = state.routerMessages[state.sessions.selectedId].orEmpty()
for (msg in msgs) {
add(Line.from(Span.raw(msg)))
}
}
@@ -134,6 +134,11 @@ object RootReducer {
)
}
// Optimistic session created by SessionsReducer for IDLE chat submit.
sessions.selectedId != null && prevInputMode == InputMode.ROUTER &&
withBgReset.displayState == DisplayState.IDLE ->
withBgReset.copy(sessionEntered = true)
else -> withBgReset
}
}
@@ -159,13 +164,18 @@ object RootReducer {
}
msg is ServerMessage.RouterResponseMessage -> {
withBgReset.copy(
routerMessages = withBgReset.routerMessages + msg.content,
routerMessages = withBgReset.routerMessages.run {
val key = msg.sessionId.value
this + (key to (this[key].orEmpty() + msg.content))
},
)
}
msg is ServerMessage.ToolCompleted && msg.diff != null -> {
withBgReset.copy(
routerMessages = withBgReset.routerMessages +
"--- diff from ${msg.toolName} ---",
routerMessages = withBgReset.routerMessages.run {
val key = msg.sessionId.value
this + (key to (this[key].orEmpty() + msg.diff!!))
},
)
}
else -> withBgReset
@@ -62,7 +62,17 @@ object SessionsReducer {
text.isNotBlank() -> {
val sessionId = SessionId(java.util.UUID.randomUUID().toString())
sessions to listOf(
val optimistic = SessionSummary(
id = sessionId.value,
status = "STARTING",
workflowId = "chat",
name = "chat",
lastEventAt = clock(),
)
sessions.copy(
sessions = sessions.sessions + optimistic,
selectedId = sessionId.value,
) to listOf(
Effect.SendWs(ClientMessage.StartChatSession(sessionId = sessionId, text = text)),
)
}
@@ -559,8 +569,11 @@ object SessionsReducer {
clock: () -> Long,
sessions: SessionsState,
): Pair<SessionsState, List<Effect>> {
// TODO(RF-3): If StageToolManifest created a placeholder, dedup/replace instead of
// unconditional append to avoid duplicate session entries.
// Remove any optimistic (STARTING) session created by IDLE submit, then insert the
// real session from the server. This prevents duplicate session entries when the
// server-generated session ID differs from the client-generated optimistic ID.
val hadOptimistic = sessions.sessions.any { it.status == "STARTING" }
val cleaned = sessions.sessions.filter { it.status != "STARTING" }
val summary = SessionSummary(
id = msg.sessionId.value,
status = "ACTIVE",
@@ -570,8 +583,13 @@ object SessionsReducer {
currentStage = null,
lastOutput = null,
)
val selected = sessions.selectedId ?: msg.sessionId.value
return sessions.copy(sessions = sessions.sessions + summary, selectedId = selected) to emptyList()
// When replacing an optimistic session, switch selectedId to the real session ID.
// Otherwise preserve the existing selection (normal SessionStarted for a new session).
val selected = if (hadOptimistic) msg.sessionId.value else (sessions.selectedId ?: msg.sessionId.value)
return sessions.copy(
sessions = cleaned + summary,
selectedId = selected,
) to emptyList()
}
private fun processInferenceCompletedMessage(
@@ -46,7 +46,7 @@ data class TuiState(
val currentModel: String? = null,
val providerType: ProviderType = ProviderType.LOCAL,
val routerConnected: Boolean = false,
val routerMessages: List<String> = emptyList(),
val routerMessages: Map<String, List<String>> = emptyMap(),
/**
* True from connect until SnapshotComplete observed. Reset to true on Disconnected.
* When true, event-bearing messages are buffered in [pendingEvents]; snapshots are applied immediately.
@@ -17,6 +17,7 @@ import com.correx.core.approvals.Tier
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@@ -388,4 +389,72 @@ class RootReducerTest {
assertEquals("s2", state.sessions.selectedId)
assertTrue(state.sessionEntered)
}
@Test
fun `ToolCompleted with diff appends diff content to per-session routerMessages`() {
val initial = TuiState(snapshotPhase = false)
val toolCompleted = ServerMessage.ToolCompleted(
sessionId = SessionId("s1"),
toolName = "file_write",
outputSummary = "wrote 3 lines",
occurredAt = fixedClock(),
diff = "--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old content\n+new content",
sequence = 1L,
sessionSequence = 1L,
)
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(toolCompleted), fixedClock)
val msgs = state.routerMessages["s1"]
assertEquals(1, msgs?.size)
assertTrue(msgs?.first()?.contains("--- a/file.txt") == true)
assertTrue(msgs?.first()?.contains("+new content") == true)
}
@Test
fun `ToolCompleted without diff does not append to routerMessages`() {
val initial = TuiState(snapshotPhase = false, routerMessages = mapOf("s1" to listOf("existing msg")))
val toolCompleted = ServerMessage.ToolCompleted(
sessionId = SessionId("s1"),
toolName = "file_write",
outputSummary = "wrote 3 lines",
occurredAt = fixedClock(),
diff = null,
sequence = 1L,
sessionSequence = 1L,
)
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(toolCompleted), fixedClock)
val msgs = state.routerMessages["s1"]
assertEquals(1, msgs?.size)
assertEquals("existing msg", msgs?.first())
}
@Test
fun `routerMessages are isolated per session`() {
val initial = TuiState(snapshotPhase = false)
val msg1 = ServerMessage.RouterResponseMessage(
sessionId = SessionId("s1"), content = "hello from s1",
steeringEmitted = false, sequence = null, sessionSequence = null,
)
val msg2 = ServerMessage.RouterResponseMessage(
sessionId = SessionId("s2"), content = "hello from s2",
steeringEmitted = false, sequence = null, sessionSequence = null,
)
val after1 = RootReducer.reduce(initial, Action.ServerEventReceived(msg1), fixedClock).first
val after2 = RootReducer.reduce(after1, Action.ServerEventReceived(msg2), fixedClock).first
assertEquals(listOf("hello from s1"), after2.routerMessages["s1"])
assertEquals(listOf("hello from s2"), after2.routerMessages["s2"])
}
@Test
fun `SubmitInput in IDLE with text creates optimistic session and enters session`() {
val initial = TuiState(snapshotPhase = false, inputMode = InputMode.ROUTER, inputBuffer = "my chat text")
val (state, effects) = RootReducer.reduce(initial, Action.SubmitInput, fixedClock)
assertTrue(state.sessionEntered)
assertNotNull(state.sessions.selectedId)
assertEquals(1, state.sessions.sessions.size)
assertEquals("STARTING", state.sessions.sessions[0].status)
assertEquals(state.sessions.selectedId, state.sessions.sessions[0].id)
val wsEffects = effects.filterIsInstance<Effect.SendWs>()
assertEquals(1, wsEffects.size)
assertTrue(wsEffects[0].message is ClientMessage.StartChatSession)
}
}
@@ -389,4 +389,32 @@ class SessionsReducerTest {
assertEquals("SessionCompleted", events[2].type)
assertEquals("", events[2].detail)
}
@Test
fun `SubmitInput in IDLE creates optimistic STARTING session`() {
val (state, effects) = reduce(action = Action.SubmitInput, inputText = "hello world")
assertEquals(1, state.sessions.size)
assertEquals("STARTING", state.sessions[0].status)
assertEquals(state.sessions[0].id, state.selectedId)
val wsEffects = effects.filterIsInstance<Effect.SendWs>()
assertEquals(1, wsEffects.size)
assertTrue(wsEffects[0].message is ClientMessage.StartChatSession)
val chatSession = wsEffects[0].message as ClientMessage.StartChatSession
assertEquals("hello world", chatSession.text)
}
@Test
fun `SessionStarted reconciles by removing optimistic STARTING session`() {
val optimisticId = SessionId("opt-1")
val optimistic = SessionSummary(id = "opt-1", status = "STARTING", workflowId = "chat", name = "chat", lastEventAt = 0L)
val s = SessionsState(sessions = listOf(optimistic), selectedId = "opt-1")
val msg = ServerMessage.SessionStarted(
sessionId = SessionId("real-1"), workflowId = "wf", sequence = 1L, sessionSequence = 1L,
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(1, state.sessions.size)
assertEquals("real-1", state.sessions[0].id)
assertEquals("ACTIVE", state.sessions[0].status)
assertEquals("real-1", state.selectedId)
}
}