feat(clarification): rehydrate pending questions on reconnect + restart

A stage parked on operator clarification lost its modal whenever the
client disconnected or the server restarted — the questions were stranded
in the event log with no way to re-surface them (unlike approvals, which
already rehydrate).

Two composing mechanisms, mirroring the approval path:

- On reconnect: SessionEventBridge.replaySnapshot() now re-pushes the last
  unanswered ClarificationRequestedEvent for a PAUSED/CLARIFICATION_PENDING
  session as a ClarificationRequired message. Gated on the orchestrator
  still holding a live deferred (SessionOrchestrator.liveClarificationRequestIds)
  so a client never sees a modal whose answer could not be delivered.

- On restart: ServerModule.resumeAbandonedSessions() eagerly relaunches
  clarification-parked sessions (approval-parked stay lazy). rehydrate+resume
  re-runs the parked stage, which re-emits a fresh, live
  ClarificationRequestedEvent (round budget MAX=3, 1 prior round recorded, so
  it re-parks rather than silently proceeding), re-arming the deferred that
  the reconnect path gates on.

pauseReason == "CLARIFICATION_PENDING" is the discriminator because the
reducer sets pendingApproval=true for every pause.
This commit is contained in:
2026-07-07 18:30:09 +04:00
parent 8cfc590c7e
commit d6bada6f10
5 changed files with 167 additions and 5 deletions
@@ -312,6 +312,15 @@ abstract class SessionOrchestrator(
ConcurrentHashMap<ClarificationRequestId, CompletableDeferred<List<ClarificationAnswer>>> =
ConcurrentHashMap()
/**
* Request-ids of clarifications this process is still parked on (the [CompletableDeferred] is
* live and awaitable). A reconnecting client uses this to re-push only clarifications that can
* actually be answered — a request whose deferred died with a server restart is excluded, so the
* client never shows a modal that would resume nothing.
*/
fun liveClarificationRequestIds(): Set<String> =
pendingClarifications.keys.mapTo(mutableSetOf()) { it.value }
/** Cache mapping "sessionId:artifactId" to JSON-serialized artifact payload content.
* Populated when ProcessResult artifacts are stored on tool failure.
* Used by DefaultSessionOrchestrator.step() to populate EvaluationContext.artifactContent. */