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,
) {
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."