fix: unblock sessions stuck in PAUSED state after approval resolved

Sessions that had an approval resolved (or never needed one) but never
received OrchestrationResumedEvent would show the last tool as RUNNING
forever with no subsequent events. Three coordinated fixes:

1. SessionOrchestrator now emits OrchestrationResumedEvent in both the
   approved and rejected approval branches, so the paused flag clears
   correctly going forward.

2. SessionEventBridge.replaySnapshot() detects the stuck pattern
   (pendingApproval=true with no unresolved requests) and appends a
   synthetic OrchestrationResumedEvent so historical sessions self-heal
   on next TUI connect.

3. DomainEventMapper maps OrchestrationResumedEvent → SessionResumed;
   TUI SessionsReducer/SnapshotPhaseReducer handle the new message,
   clearing pendingApproval and setting status back to ACTIVE.

Also adds server-restart recovery path in DefaultSessionOrchestrator
(emit decision + resume events directly when no live deferred exists)
and pre-registers pending approvals in ServerModule.start() to close
the race between reconnect and ApprovalResponse routing.
This commit is contained in:
2026-05-26 21:12:26 +04:00
parent e32bd121e5
commit 766b91081a
11 changed files with 140 additions and 19 deletions
@@ -165,6 +165,7 @@ object SessionsReducer {
val (result, effects) = when (msg) {
is ServerMessage.SessionStarted -> processSessionStartedMessage(msg, clock, sessions)
is ServerMessage.SessionPaused -> processSessionPausedMessage(msg, sessions, clock)
is ServerMessage.SessionResumed -> processSessionResumedMessage(msg, sessions, clock)
is ServerMessage.SessionCompleted -> processSessionCompletedMessage(sessions, msg, clock)
is ServerMessage.SessionFailed -> processSessionFailedMessage(sessions, msg, clock)
is ServerMessage.StageStarted -> processStageStartedMessage(msg, sessions)
@@ -189,6 +190,7 @@ object SessionsReducer {
private fun sessionIdFromMessage(msg: ServerMessage): String? = when (msg) {
is ServerMessage.SessionStarted -> msg.sessionId.value
is ServerMessage.SessionPaused -> msg.sessionId.value
is ServerMessage.SessionResumed -> msg.sessionId.value
is ServerMessage.SessionCompleted -> msg.sessionId.value
is ServerMessage.SessionFailed -> msg.sessionId.value
is ServerMessage.StageStarted -> msg.sessionId.value
@@ -507,6 +509,20 @@ object SessionsReducer {
) to emptyList()
}
private fun processSessionResumedMessage(
msg: ServerMessage.SessionResumed,
sessions: SessionsState,
clock: () -> Long,
): Pair<SessionsState, List<Effect>> = sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(
status = "ACTIVE",
pendingApproval = null,
lastEventAt = clock(),
) else s
},
) to emptyList()
private fun processSessionStartedMessage(
msg: ServerMessage.SessionStarted,
clock: () -> Long,
@@ -64,6 +64,7 @@ object SnapshotPhaseReducer {
internal fun sessionIdOf(msg: ServerMessage): String? = when (msg) {
is ServerMessage.SessionStarted -> msg.sessionId.value
is ServerMessage.SessionPaused -> msg.sessionId.value
is ServerMessage.SessionResumed -> msg.sessionId.value
is ServerMessage.SessionCompleted -> msg.sessionId.value
is ServerMessage.SessionFailed -> msg.sessionId.value
is ServerMessage.StageStarted -> msg.sessionId.value