Gateway restart notices: explicit status{restarting} signal, correct timing
CI / Gateway plugin tests (push) Successful in 4m22s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m41s

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).
This commit is contained in:
ARIA committed 2026-08-22 11:31:10 +02:00
1 parent 3a33f6be15
commit 6591d7cec0
6 files changed
+79 -33

No files matched your search

@@ -477,13 +477,12 @@ class ChatStore {
frame: Frame, frame: Frame,
) { ) {
val p = frame.payloadAs<CommentaryPayload>() ?: return val p = frame.payloadAs<CommentaryPayload>() ?: return
// Gateway lifecycle notices (restart / shutdown / online) are rendered // Gateway lifecycle notices (restart / shutdown / online) are the
// as a centered system notice by the controller, on the down/up state // controller's business: it renders the "restarting" / "online" pair
// transition. The server also emits the "restarting" notice as a // as centered system notices on the down/up state transitions (gated
// commentary frame (and the sync catch-up can replay it *after* the // on the gateway's status{restarting} frame). The server also emits
// local "online" notice), so drop it here: the app is the source of // these as commentary frames (and the sync catch-up can replay them
// truth for these notices, which keeps the order (restarting → online) // out of order), so drop them here to prevent duplicates.
// and prevents duplicates.
if (isGatewayLifecycleNotice(p.text)) return if (isGatewayLifecycleNotice(p.text)) return
updateLane(lane) { list -> updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) { if (list.any { it.id == p.messageId }) {
@@ -142,10 +142,21 @@ class IrisController(
* so "lane is empty" is not a reliable first-open signal. */ * so "lane is empty" is not a reliable first-open signal. */
private val historyLoaded = mutableSetOf<String>() private val historyLoaded = mutableSetOf<String>()
/** 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<String?>(null) private val _gatewayStatus = MutableStateFlow<String?>(null)
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow() val gatewayStatus: StateFlow<String?> = _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 /** M5: highest outbox cursor already delivered to this device via the
* push backend (from hello.ack; 0 = never). Sync-replayed frames with * push backend (from hello.ack; 0 = never). Sync-replayed frames with
* `cursor <= lastPushedCursor` already woke the device via push, so the * `cursor <= lastPushedCursor` already woke the device via push, so the
@@ -619,7 +630,22 @@ class IrisController(
} }
TYPE_STATUS -> { TYPE_STATUS -> {
frame.payloadAs<StatusPayload>()?.let { _gatewayStatus.value = it.state } frame.payloadAs<StatusPayload>()?.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 -> { TYPE_ERROR -> {
@@ -647,6 +673,10 @@ class IrisController(
val prev = prevState val prev = prevState
prevState = s prevState = s
if (s is GatewayClient.State.Connected) { 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] // The lane/history fast path runs on [client.onHelloAck]
// (promptly, on the WS thread) — see onConnectedLane. Here // (promptly, on the WS thread) — see onConnectedLane. Here
// we do the non-time-critical connect work. // we do the non-time-critical connect work.
@@ -666,22 +696,20 @@ class IrisController(
// Slash-command catalog for the composer's "/" drawer // Slash-command catalog for the composer's "/" drawer
// (static per gateway run; re-fetched on every (re)connect). // (static per gateway run; re-fetched on every (re)connect).
requestCommandsCatalog() requestCommandsCatalog()
// Gateway came back after a restart -> announce it (hermes // Gateway is back from a RESTART (not just a network
// routine, same icon + wording on all platforms). The core // drop) -> post the second half of the restart pair.
// does not send a startup/online notice to this platform, so if (restartAnnounced) {
// the app adds it. restartAnnounced = false
if (prev is GatewayClient.State.Reconnecting) {
chat.addSystemMessage(homeChannel.value, GATEWAY_ONLINE_MSG) chat.addSystemMessage(homeChannel.value, GATEWAY_ONLINE_MSG)
} }
} else if (s is GatewayClient.State.Reconnecting && prev is GatewayClient.State.Connected) { } else if (s is GatewayClient.State.Reconnecting && prev is GatewayClient.State.Connected) {
// Gateway went away (restart / network drop): announce it // Gateway went away. The restart notice was already
// (hermes routine, same icon + wording on all platforms) and // posted on the status{restarting} frame (immediately,
// close the in-flight turn's dangling tool cards / streaming // not on this transition — the socket can take ~20 s to
// bubble (nothing spins forever). The app is the source of // drop); a plain network drop posts nothing, the
// truth for the "restarting" notice (the server's commentary // reconnect banner + status bubble cover it. Here we
// frame is dropped in ChatStore), so the order is guaranteed: // just close the in-flight turn's dangling tool cards /
// restarting (here) before online (on reconnect). // streaming bubble (nothing spins forever).
chat.addSystemMessage(homeChannel.value, GATEWAY_RESTARTING_MSG)
chat.finalizeInterrupted() chat.finalizeInterrupted()
} }
} }
@@ -1050,12 +1078,15 @@ private fun HistoryMessage.toMessageItem(): MessageItem =
}, },
) )
// Gateway restart routine (hermes, same icon + wording on all platforms). The // Gateway restart pair (hermes wording, same icon on all platforms). The app
// app generates both notices locally, on the down/up state transition, so the // generates both notices locally: "restarting" IMMEDIATELY on the gateway's
// order is guaranteed (restarting before online) and there is no dependency on // explicit status{state=restarting} frame (broadcast on its shutdown path —
// the server frame (which may be dropped on shutdown or replayed out of order // not on the socket-drop transition, which can lag by the ~20 s ping
// by the sync catch-up). The core does not send a startup/online notice to this // timeout) so a plain network drop doesn't claim a restart; and "online" on
// platform, and its "restarting" commentary frame is dropped in ChatStore. // 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 = 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." "⚠️ 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 = private const val GATEWAY_ONLINE_MSG =
@@ -618,9 +618,9 @@ fun ChatScreen(controller: IrisController) {
"Reconnecting to gateway… messages will sync automatically when the connection is back." "Reconnecting to gateway… messages will sync automatically when the connection is back."
} }
gatewayStatus == "restarting" -> { // Note: gatewayStatus never becomes "restarting" — that
"Gateway is restarting…" // state posts a chat notice immediately (IrisController,
} // TYPE_STATUS) instead of showing a banner.
gatewayStatus == "degraded" -> { gatewayStatus == "degraded" -> {
"Gateway reports a degraded state — replies may be slow or unavailable." "Gateway reports a degraded state — replies may be slow or unavailable."
+7 -1
View File
@@ -280,7 +280,13 @@ messages only.
### `status` ### `status`
Gateway health state. Broadcast to all connected clients at startup 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 ```json
{"type":"status","payload":{"state":"online"}} {"type":"status","payload":{"state":"online"}}
+1 -1
View File
@@ -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"} } } } } }, "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" } }, "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" } } }, "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" } } }, "error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } },
"pong": { "payload": { "ts": { "type": "integer" } } }, "pong": { "payload": { "ts": { "type": "integer" } } },
"sync.done": { "payload": { "cursor": { "type": "integer" } } }, "sync.done": { "payload": { "cursor": { "type": "integer" } } },
+10
View File
@@ -1212,6 +1212,9 @@ class AndroidAdapter(BasePlatformAdapter):
# M5: announce gateway health to connected clients (none yet at # M5: announce gateway health to connected clients (none yet at
# startup; the frame + plumbing exist for future transitions). # 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)) await self._ws_server.broadcast(protocol.status(self._gateway_status))
# M3: ensure the default (home) channel exists in the directory so the # M3: ensure the default (home) channel exists in the directory so the
@@ -1238,6 +1241,13 @@ class AndroidAdapter(BasePlatformAdapter):
async def disconnect(self) -> None: async def disconnect(self) -> None:
"""Tear down the platform: stop the server, close device sockets.""" """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): with contextlib.suppress(ImportError):
from gateway.status import release_scoped_lock from gateway.status import release_scoped_lock