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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user