fix(sync): settle completed turns and finished messages promptly

Two remaining stuck/incorrect busy-state edge cases from the post-#483
spinner audit (OPE-193):

- B1: when a turn ended but the session.idle SSE event was delayed or
  lost, the busy spinner kept showing until the next watchdog poll tick
  (~5s) and its escalation (~10s). An assistant message.updated that
  carries time.completed now triggers one immediate directory status
  poll (monotonic confirm, authoritative settle when the snapshot
  reports the session idle) — recovery drops to a single round-trip,
  with one in-flight fetch per directory and the watchdog poll as the
  backstop.

- C1: the streaming derivation marked the trailing assistant message as
  streaming while the session stayed busy even after the server stamped
  time.completed (whole response incl. tools finished) — the typing
  indicator and streaming part-update suspension lingered on finished
  content until the session settled or the next message started. A
  completed trailing message is now never marked streaming; both the
  full and incremental derivations complete the previous streaming
  message instead.

Refs OPE-193
This commit is contained in:
Serhii Dziupin
2026-08-05 11:57:12 +03:00
parent 34c221b07f
commit 498a029e51
5 changed files with 313 additions and 0 deletions
+54
View File
@@ -290,6 +290,11 @@ type PendingSessionMaterialization = {
const SESSION_MATERIALIZATION_COOLDOWN_MS = 5_000
const pendingSessionMaterializations = new Map<string, PendingSessionMaterialization>()
// In-flight guard for the immediate status poll fired when an assistant
// message completes (see maybePollStatusAfterMessageCompletion). Keyed by
// directory so a burst of completing messages shares one status fetch.
const messageCompletionStatusPolls = new Set<string>()
function enqueueSessionMaterialization(
directory: string,
sessionID: string,
@@ -632,6 +637,49 @@ async function resyncDirectorySessionStatuses(
return nextStatuses
}
/**
* Immediately re-check the session status after an assistant message
* completes. The turn-ending `session.idle` event can be delayed or lost; left
* alone, the busy spinner keeps showing until the next watchdog poll tick
* (up to ~5s) and its escalation (up to ~10s). One cheap status fetch right
* after the completion confirms the turn really ended, mirroring the watchdog
* escalation: the monotonic pass confirms/raises busy but never lowers it, and
* when the snapshot reports the session idle while the store still believes it
* busy, an authoritative resync settles the status immediately.
*
* Bounded: one in-flight fetch per directory, only for sessions the store
* currently believes active, best-effort (the watchdog poll remains the
* backstop). This narrows recovery latency without restructuring the polling
* design.
*/
export function maybePollStatusAfterMessageCompletion(
directory: string,
store: StoreApi<DirectoryStore>,
sessionID: string,
): void {
if (!directory || directory === "global" || !sessionID) return
const current = store.getState().session_status?.[sessionID]
if (!current || current.type === "idle") return
if (messageCompletionStatusPolls.has(directory)) return
messageCompletionStatusPolls.add(directory)
void (async () => {
try {
const statuses = await runBackgroundNetworkTask(() =>
resyncDirectorySessionStatuses(directory, store, [sessionID], "monotonic"))
if (!statuses) return
if (needsSnapshotAfterStatusPoll(store.getState(), sessionID, statuses[sessionID])) {
await runBackgroundNetworkTask(() =>
resyncDirectorySessionStatuses(directory, store, [sessionID], "authoritative"))
}
} catch {
// Best-effort — the watchdog poll retries on its own cadence.
} finally {
messageCompletionStatusPolls.delete(directory)
}
})()
}
// After a monotonic poll, decide whether to escalate to a full authoritative
// resync: the store believes the session is active but the snapshot reports it
// idle/absent — a suspected missed idle that the monotonic poll deliberately
@@ -1722,6 +1770,12 @@ function handleEvent(
messageID,
})
}
// An assistant message that finished is strong evidence the turn may
// have ended; if the session.idle event was delayed or lost, settle the
// busy status immediately instead of waiting for the next watchdog poll.
if (info.role === "assistant" && typeof info.time?.completed === "number") {
maybePollStatusAfterMessageCompletion(resolvedDirectory, store, sessionID)
}
}
} else {
const sessionID = getSessionIdFromPayload(payload) ?? undefined