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).
This commit is contained in:
1 parent
3a33f6be15
commit
6591d7cec0
6 files changed
+79
-33
No files matched your search
@@ -477,13 +477,12 @@ class ChatStore {
|
||||
frame: Frame,
|
||||
) {
|
||||
val p = frame.payloadAs<CommentaryPayload>() ?: 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 }) {
|
||||
|
||||
@@ -142,10 +142,21 @@ class IrisController(
|
||||
* so "lane is empty" is not a reliable first-open signal. */
|
||||
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)
|
||||
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
|
||||
* 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<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 -> {
|
||||
@@ -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 =
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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" } } },
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user