# Hand-off: Gateway restart UX (spinner + "back online" message) > This document is a hand-off. A previous attempt at this task is **incomplete > and leaves the chat in a broken state**. Read this fully before touching code. ## 1. Original task (from the user) On **gateway restart** the Android/desktop app had two problems: 1. A **spinner spins endlessly** (a tool card that never completes). 2. There is **no "gateway back online" message**. The user wants the standard hermes routine (same wording on all apps): ``` ⚠️ Gateway restarting — Your current task will be interrupted. Send any message after restart and I'll try to resume where you left off. ``` ``` ♻️ Gateway online — Hermes is back and ready. ``` ## 2. Architecture (critical context) Three layers, only two are editable: - **`hermes-agent/`** — READ-ONLY research reference (git-ignored, pre-commit hook blocks commits). The hermes **core** lives here. Do NOT modify it. - **`gateway-plugin/`** — the editable Python `android` platform plugin (symlinked into `~/.hermes/plugins/android`). `adapter.py` is the outbound frame classifier. This is where the gateway turns core `adapter.send()` calls into WS frames. - **`app/`** — Compose Multiplatform (Kotlin). `:shared` holds most code (`jvmMain` is shared by android + desktop). `IrisController` routes frames into `ChatStore`; `ChatScreen` renders. ### How the hermes core signals a restart (verified in logs + code) - On **shutdown/restart** the core calls `adapter.send(home_channel, "⚠️ Gateway restarting|shutting down — …")`. The android adapter classifies this into a frame. The core **does** send this to the android home channel (log line: `Sent shutdown notification to home channel android:android:default`). - On **startup** the core sends "♻️ Gateway online — …" to home channels of *configured* platforms, but **does NOT send it to the android platform** (no such log line ever appears). So the app must generate the "online" notice itself. ### The gateway is a transient systemd user service - Unit: `iris-hermes-gw.service` (transient, `/run/user/1000/systemd/transient/`). - **It does NOT auto-revive on SIGTERM.** After a SIGTERM it goes to `failed` and stays down until you run: `systemctl --user restart iris-hermes-gw.service` - Logs: `~/.hermes/logs/gateway.log`. Outbox DB: `~/.hermes/android/outbox.db` (table `outbox(cursor, chat_id, frame, created)`). ## 3. What the previous attempt changed (all uncommitted, in working tree) Run `git diff` to see the full patch. Summary: ### App (Kotlin) - `app/shared/src/commonMain/kotlin/iris/util/TimeFormat.kt` + `app/shared/src/jvmMain/kotlin/iris/util/TimeFormatJvm.kt` — added `nowMillis(): Long` (expect/actual) for wall-clock timestamps. - `app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt` - `MessageItem` gained `isSystem: Boolean = false`. - `addSystemMessage(lane, text)` — appends a centered local notice. - `finalizeInterrupted()` — marks open `ToolItem`s `done=true, ok=false` and streaming `MessageItem`s `streaming=false`, across all lanes. - `app/shared/src/commonMain/kotlin/iris/state/IrisController.kt` - In the `client.state.collect` block, tracks `prevState`: - `Reconnecting → Connected`: posts `GATEWAY_ONLINE_MSG` ("♻️ Gateway online — Hermes is back and ready.") to the current lane. - `Connected → Reconnecting`: calls `chat.finalizeInterrupted()`. - New constant `GATEWAY_ONLINE_MSG`. - `app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt` - Renders `isSystem` messages via a new centered `SystemMessage` composable (non-selectable pill). ### Gateway plugin (Python) - `gateway-plugin/adapter.py` - New `_is_gateway_lifecycle_notice(content)` — true when content contains "Gateway restarting" / "Gateway shutting down" / "Gateway online". - In `send()`, the tool-progress branch now skips lifecycle notices so they fall through to **commentary** instead of a `tool.start` card. ## 4. Current (BROKEN) state — what the user is seeing After several test restarts, the chat shows a **stack of repeated entries**: multiple "⚠️ Gateway shutting down" cards (some with a **spinning** indicator, some with a red ✗) interleaved with "♻️ Gateway online" pills. The **last** entry is "Gateway shutting down" even though the app is **connected**. The user is (correctly) unhappy: the ordering is wrong and a spinner is present. ### The two concrete defects 1. **Wrong ordering / stale "shutting down" last.** On reconnect the app posts "♻️ Gateway online", but then the **`sync` catch-up replay** re-delivers the "⚠️ Gateway shutting down" frame that the *old* gateway parked in the outbox during shutdown. That replayed frame lands **after** the "online" notice, so the newest line reads "shutting down" while connected. 2. **Spinner returns.** The outbox still contains `tool.start` frames for the "Gateway shutting down" notice that were written **before** the adapter fix was loaded. The `sync` replay turns those into tool cards that never receive a `tool.end`, so they spin. The adapter fix only stops *new* `tool.start` frames; it does not clean up the already-parked ones. ### Root cause (the part the previous attempt did NOT solve) The app's **reconnect catch-up (`sync`)** replays the outbox from the stored cursor, and the app does **not** deduplicate or order these replayed lifecycle frames against what it already rendered live. Consequences: - Replayed "shutting down" frames appear after the locally-generated "online" notice (wrong order). - Replayed `tool.start` frames (pre-fix) become endless-spinning cards. - Every restart adds another pair of entries (the "chaos" stack). The previous attempt treated the symptoms (finalize on disconnect, add an online notice, reclassify new notices) but did **not** address the replay/dedup/ordering problem, which is what actually produces the visible mess. ## 5. What is verified vs. not - **Verified working:** Kotlin compiles (android + desktop), 53/53 Python `tests/gateway/test_android.py` pass, the "♻️ Gateway online" pill renders, and *new* lifecycle notices now render as commentary (not a tool card). - **NOT working / still broken:** the chat accumulates a mis-ordered stack of "shutting down"/"online" entries and a spinning tool card after restarts. ## 6. Suggested direction for the next engineer Pick the cleanest of these (or a combination); the goal is: after a restart the chat shows at most one "⚠️ Gateway restarting" then one "♻️ Gateway online", in that order, with **no** spinner and **no** accumulation across restarts. 1. **Dedup/order lifecycle notices in the app.** Give the restart/online notices stable identities and reconcile them (like `MessageItem` is by `messageId`): a replayed "shutting down" that is already present should not be re-added, and the "online" notice should supersede the "shutting down" one (replace, not append). Consider a single "gateway state" line that updates in place (restarting → online) instead of appending a new line each transition. 2. **Stop the spinner at the source.** Ensure no `tool.start` is ever emitted for a lifecycle notice (adapter fix is done for new frames) AND make the app resilient: a `tool.start` with no matching `tool.end` should not spin indefinitely (e.g., finalize on the next turn, on reconnect, or time out). 3. **Cursor hygiene.** Investigate why the `sync` replay re-delivers frames the app already rendered live (live broadcasts may not advance the stored `syncCursor`). If live frames should advance the cursor, the replay would not re-add them. Check `GatewayClient.dial` (sends `sync`), the `TYPE_SYNC_DONE` handler in `IrisController`, and the gateway `on_sync`/outbox cursor logic. 4. **Clean the existing mess.** The outbox (`~/.hermes/android/outbox.db`) and the app's in-memory lanes currently hold the stacked test entries. Decide whether to prune the outbox and/or reset the app's chat state (`adb shell pm clear dev.iris.app`) so the user starts clean. ## 7. Commands - Build/verify app: `cd app && ./gradlew :shared:compileKotlinDesktop :androidApp:compileDebugKotlin` - Install on phone: `cd app && ./gradlew :androidApp:installDebug` (device `a5ca2a4b`) - Python tests: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` - Restart gateway: `systemctl --user restart iris-hermes-gw.service` - Screenshot: `adb exec-out screencap -p > /tmp/shot.png` - Launch app: `adb shell am start -n dev.iris.app/.MainActivity` - Reset app state: `adb shell pm clear dev.iris.app` - Gateway log: `~/.hermes/logs/gateway.log` ## 8. Environment state at hand-off time - Gateway: **running** (systemd `iris-hermes-gw.service` active). - App: **installed** on the phone with the (broken) changes above. - Working tree: 6 modified files (see `git diff`), **nothing committed**. - The phone's chat currently shows the stacked test entries described in §4.