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."
|
||||
|
||||
Reference in new issue
Block a user