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

A lost or delayed turn-ending `session.idle` left the busy spinner up until
the watchdog poll caught it (5-10s). An assistant `message.updated` carrying
`time.completed` now schedules one status check for that session, and
`streaming.ts` stops treating a completed trailing message as streaming.

The check is deferred by 750ms and re-reads the session status when the timer
fires, so the overwhelmingly common case — the turn's own `session.idle`
arriving right behind the completed message — settles on its own and costs
zero extra requests; only a session the store still believes busy spends a
fetch. The poll shares the watchdog's in-flight directory guard, so the
deferred check and the periodic poll cannot overlap on one directory.
Status authority is unchanged: the monotonic pass never lowers status, and an
authoritative resync runs only when the snapshot disagrees.
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 00:29:42 +03:00
parent 5c26c6ae21
commit 10634c4512
5 changed files with 100 additions and 86 deletions
+1 -1
View File
@@ -222,7 +222,7 @@ Streaming lifecycle derivation has two paths. Directory attach, switch, bootstra
A trailing assistant message that the server stamped `time.completed` is never marked as streaming: the stamp means the whole response (text plus every tool call) finished, so even while the session stays busy for the next step of the turn, the typing indicator and the streaming part-update suspension must not linger on finished content. The message-level streaming state (`streamingMessageIds` / `messageStreamStates`) is therefore a *message* lifecycle, not a turn lifecycle — it is completed by an explicit `time.completed`, by a newer trailing message, or by the session leaving `busy`.
When an assistant `message.updated` event carries `time.completed` and the store still believes the session busy, sync fires one immediate directory status poll (`maybePollStatusAfterMessageCompletion`): the monotonic pass confirms/raises active status but never lowers it, and when the snapshot reports the session idle while the store believes it busy — a delayed or lost `session.idle` — an authoritative resync settles the status at once. This narrows the stuck-spinner window after turn completion from a full watchdog poll interval to a single round-trip; one in-flight fetch per directory bounds the fan-out and the 5s watchdog poll remains the backstop.
When an assistant `message.updated` event carries `time.completed` and the store still believes the session busy, sync schedules one deferred status check (`maybePollStatusAfterMessageCompletion`, ~750ms). The status is re-read when the timer fires, so a normal turn whose `session.idle` lands inside that window issues no request at all; only a still-busy session spends a directory status poll, sharing the watchdog's one-in-flight-per-directory guard. The invariant is unchanged from the watchdog escalation: the monotonic pass confirms or raises active status and never lowers it, and an authoritative resync runs only when the snapshot disagrees with a store that still believes the session busy. This narrows the stuck-spinner window after a lost `session.idle` from a watchdog interval to one round-trip; the 5s watchdog poll remains the backstop.
Incomplete-session materialization is deduplicated by runtime, directory, and session for the full cooldown window, including after a fast success or failure. A settled-running-tool recovery may supersede a different request in that window so an earlier pre-settlement refresh cannot consume the only terminal recovery signal. Deferred recovery is dropped if its captured runtime is no longer active. If recovery requests a tail refresh while an older load is in flight, one refresh runs after that load instead of losing the newer authority demand. Completion retains the cooldown marker until expiry, and an older completion cannot clear a newer request marker. Recovery starts after the current ordered event batch and rechecks whether local state already contains the requested entity before starting HTTP. An explicit empty part bucket is authoritative fetched-empty state, not a missing snapshot. This prevents repeated orphan/missing-part events from creating message-tail and status request storms while preserving later recovery.