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.
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