From 6591d7cec02fcef8af9e5b0baf6c74173bcf117f Mon Sep 17 00:00:00 2001 From: ARIA Date: Sat, 22 Aug 2026 11:31:10 +0200 Subject: [PATCH] Gateway restart notices: explicit status{restarting} signal, correct timing The app previously showed 'Gateway restarting' on every connection loss. Now the gateway broadcasts status{state=restarting} on its shutdown path (before closing the sockets), and the app: - posts 'Gateway restarting' immediately on that frame (not on the socket-drop transition, which lags by the ~20s WS ping timeout) - posts 'Gateway online' on the next reconnect only when the restart notice was posted (latch) - a plain network drop shows neither, just the reconnect banner - drops the 'Gateway is restarting...' banner (replaced by the chat notice) Docs (04-wire-protocol, frames.schema.json) updated: restarting is no longer reserved. Test for the disconnect broadcast added to the local hermes-agent test mirror (git-ignored, not committed). --- .../commonMain/kotlin/iris/data/ChatStore.kt | 13 ++-- .../kotlin/iris/state/IrisController.kt | 73 +++++++++++++------ .../kotlin/iris/ui/screens/ChatScreen.kt | 6 +- docs/04-wire-protocol.md | 8 +- docs/protocol/frames.schema.json | 2 +- gateway-plugin/adapter.py | 10 +++ 6 files changed, 79 insertions(+), 33 deletions(-) diff --git a/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt b/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt index 02e0035..e44e50c 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt @@ -477,13 +477,12 @@ class ChatStore { frame: Frame, ) { val p = frame.payloadAs() ?: return - // Gateway lifecycle notices (restart / shutdown / online) are rendered - // as a centered system notice by the controller, on the down/up state - // transition. The server also emits the "restarting" notice as a - // commentary frame (and the sync catch-up can replay it *after* the - // local "online" notice), so drop it here: the app is the source of - // truth for these notices, which keeps the order (restarting → online) - // and prevents duplicates. + // Gateway lifecycle notices (restart / shutdown / online) are the + // controller's business: it renders the "restarting" / "online" pair + // as centered system notices on the down/up state transitions (gated + // on the gateway's status{restarting} frame). The server also emits + // these as commentary frames (and the sync catch-up can replay them + // out of order), so drop them here to prevent duplicates. if (isGatewayLifecycleNotice(p.text)) return updateLane(lane) { list -> if (list.any { it.id == p.messageId }) { diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index e0606a9..a779ed1 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -142,10 +142,21 @@ class IrisController( * so "lane is empty" is not a reliable first-open signal. */ private val historyLoaded = mutableSetOf() - /** Gateway health state (M5: status frame; null = never received). */ + /** Gateway health state (M5: status frame; null = never received). + * Note: "restarting" is deliberately NOT stored here — it posts the + * chat notice immediately (see TYPE_STATUS) instead of showing a banner. */ private val _gatewayStatus = MutableStateFlow(null) val gatewayStatus: StateFlow = _gatewayStatus.asStateFlow() + // Latches the restart pair: set when the "restarting" notice is posted + // (on the gateway's status{restarting} frame), consumed by the matching + // "online" notice on the next reconnect. A plain network drop never sets + // it, so it never produces an "online" notice. @Volatile: written on the + // frame-handler coroutine, read on the state-collector coroutine + // (Dispatchers.Default). + @Volatile + private var restartAnnounced = false + /** M5: highest outbox cursor already delivered to this device via the * push backend (from hello.ack; 0 = never). Sync-replayed frames with * `cursor <= lastPushedCursor` already woke the device via push, so the @@ -619,7 +630,22 @@ class IrisController( } TYPE_STATUS -> { - frame.payloadAs()?.let { _gatewayStatus.value = it.state } + frame.payloadAs()?.let { st -> + if (st.state == "restarting") { + // Gateway is going down (restart/stop): + // post the notice IMMEDIATELY — the + // socket can take up to the ping timeout + // (~20 s) to actually drop, and waiting + // for that transition would delay the + // message. No banner for this state: the + // chat notice replaces it (the reconnect + // banner covers the wait). + restartAnnounced = true + chat.addSystemMessage(homeChannel.value, GATEWAY_RESTARTING_MSG) + } else { + _gatewayStatus.value = st.state + } + } } TYPE_ERROR -> { @@ -647,6 +673,10 @@ class IrisController( val prev = prevState prevState = s if (s is GatewayClient.State.Connected) { + // Clear any stale "restarting" latch from the previous + // down phase (the gateway's own status{online} frame + // follows on hello.ack and re-asserts the truth). + _gatewayStatus.value = "online" // The lane/history fast path runs on [client.onHelloAck] // (promptly, on the WS thread) — see onConnectedLane. Here // we do the non-time-critical connect work. @@ -666,22 +696,20 @@ class IrisController( // Slash-command catalog for the composer's "/" drawer // (static per gateway run; re-fetched on every (re)connect). requestCommandsCatalog() - // Gateway came back after a restart -> announce it (hermes - // routine, same icon + wording on all platforms). The core - // does not send a startup/online notice to this platform, so - // the app adds it. - if (prev is GatewayClient.State.Reconnecting) { + // Gateway is back from a RESTART (not just a network + // drop) -> post the second half of the restart pair. + if (restartAnnounced) { + restartAnnounced = false chat.addSystemMessage(homeChannel.value, GATEWAY_ONLINE_MSG) } } else if (s is GatewayClient.State.Reconnecting && prev is GatewayClient.State.Connected) { - // Gateway went away (restart / network drop): announce it - // (hermes routine, same icon + wording on all platforms) and - // close the in-flight turn's dangling tool cards / streaming - // bubble (nothing spins forever). The app is the source of - // truth for the "restarting" notice (the server's commentary - // frame is dropped in ChatStore), so the order is guaranteed: - // restarting (here) before online (on reconnect). - chat.addSystemMessage(homeChannel.value, GATEWAY_RESTARTING_MSG) + // Gateway went away. The restart notice was already + // posted on the status{restarting} frame (immediately, + // not on this transition — the socket can take ~20 s to + // drop); a plain network drop posts nothing, the + // reconnect banner + status bubble cover it. Here we + // just close the in-flight turn's dangling tool cards / + // streaming bubble (nothing spins forever). chat.finalizeInterrupted() } } @@ -1050,12 +1078,15 @@ private fun HistoryMessage.toMessageItem(): MessageItem = }, ) -// Gateway restart routine (hermes, same icon + wording on all platforms). The -// app generates both notices locally, on the down/up state transition, so the -// order is guaranteed (restarting before online) and there is no dependency on -// the server frame (which may be dropped on shutdown or replayed out of order -// by the sync catch-up). The core does not send a startup/online notice to this -// platform, and its "restarting" commentary frame is dropped in ChatStore. +// Gateway restart pair (hermes wording, same icon on all platforms). The app +// generates both notices locally: "restarting" IMMEDIATELY on the gateway's +// explicit status{state=restarting} frame (broadcast on its shutdown path — +// not on the socket-drop transition, which can lag by the ~20 s ping +// timeout) so a plain network drop doesn't claim a restart; and "online" on +// the next reconnect, only when the "restarting" notice was posted (the +// restartAnnounced latch). A plain network drop produces neither — +// connection state is shown by the banner + status bubble only. The server's +// lifecycle commentary frames are dropped in ChatStore. private const val GATEWAY_RESTARTING_MSG = "⚠️ Gateway restarting — Your current task will be interrupted. Send any message after restart and I'll try to resume where you left off." private const val GATEWAY_ONLINE_MSG = diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index 84cfe7d..e5c9a8c 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -618,9 +618,9 @@ fun ChatScreen(controller: IrisController) { "Reconnecting to gateway… messages will sync automatically when the connection is back." } - gatewayStatus == "restarting" -> { - "Gateway is restarting…" - } + // Note: gatewayStatus never becomes "restarting" — that + // state posts a chat notice immediately (IrisController, + // TYPE_STATUS) instead of showing a banner. gatewayStatus == "degraded" -> { "Gateway reports a degraded state — replies may be slow or unavailable." diff --git a/docs/04-wire-protocol.md b/docs/04-wire-protocol.md index 1e4cb72..b3b4a3e 100644 --- a/docs/04-wire-protocol.md +++ b/docs/04-wire-protocol.md @@ -280,7 +280,13 @@ messages only. ### `status` Gateway health state. Broadcast to all connected clients at startup -(`state: "online"`); `restarting` / `degraded` are reserved for future use. +(`state: "online"`) and to late joiners on `hello.ack`. The gateway also +broadcasts `state: "restarting"` on its shutdown path (restart/stop), right +before closing the sockets — the app posts the "Gateway restarting" chat +notice immediately on that frame (the socket can take up to the ~20 s ping +timeout to actually drop, so the notice must not wait for the disconnect); +a plain network drop shows just the reconnect banner. `degraded` is reserved +for future use. ```json {"type":"status","payload":{"state":"online"}} diff --git a/docs/protocol/frames.schema.json b/docs/protocol/frames.schema.json index e6328dd..3f6d349 100644 --- a/docs/protocol/frames.schema.json +++ b/docs/protocol/frames.schema.json @@ -59,7 +59,7 @@ "search.results": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "hits": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "chat_id": {"type":"string"}, "thread_id": {"type":["string","null"]}, "role": {"type":"string"}, "snippet": {"type":"string"}, "ts": {"type":"integer"} } } } } }, "media.offer": { "description": "Agent-sent media available; app pulls bytes.", "payload": { "$ref": "#/definitions/media_ref" } }, "read.receipt": { "description": "Agent received and started processing the user's message; app shows ✓✓ on user bubbles. Emitted to the originating connection when a message.send is accepted for processing.", "payload": { "message_id": { "type": "string" } } }, - "status": { "description": "Gateway health state; broadcast to all connected clients at startup (state=online).", "payload": { "state": { "type": "string", "enum": ["online", "restarting", "degraded"] } } }, + "status": { "description": "Gateway health state; broadcast to all connected clients at startup (state=online) and to late joiners on hello.ack. state=restarting is broadcast on the gateway's shutdown path (restart/stop) before the sockets close; the app shows the 'Gateway restarting' chat notice only on that signal, not on a plain network drop.", "payload": { "state": { "type": "string", "enum": ["online", "restarting", "degraded"] } } }, "error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } }, "pong": { "payload": { "ts": { "type": "integer" } } }, "sync.done": { "payload": { "cursor": { "type": "integer" } } }, diff --git a/gateway-plugin/adapter.py b/gateway-plugin/adapter.py index 932bc5c..2293f68 100644 --- a/gateway-plugin/adapter.py +++ b/gateway-plugin/adapter.py @@ -1212,6 +1212,9 @@ class AndroidAdapter(BasePlatformAdapter): # M5: announce gateway health to connected clients (none yet at # startup; the frame + plumbing exist for future transitions). + # Reset in case this adapter instance previously went down (the + # gateway may reconnect the same adapter after a fatal error). + self._gateway_status = protocol.STATUS_ONLINE await self._ws_server.broadcast(protocol.status(self._gateway_status)) # M3: ensure the default (home) channel exists in the directory so the @@ -1238,6 +1241,13 @@ class AndroidAdapter(BasePlatformAdapter): async def disconnect(self) -> None: """Tear down the platform: stop the server, close device sockets.""" + # Tell live clients the gateway is going away (restart/shutdown) so + # the app can distinguish a clean gateway teardown from a plain + # network drop: the "Gateway restarting" chat notice is shown only + # when this frame was received (docs/04 §status). + self._gateway_status = protocol.STATUS_RESTARTING + with contextlib.suppress(Exception): + await self._ws_server.broadcast(protocol.status(self._gateway_status)) with contextlib.suppress(ImportError): from gateway.status import release_scoped_lock