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() {