Files
iris_x_hermes/HANDOFF-gateway-restart.md
T

8.9 KiB

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 ToolItems done=true, ok=false and streaming MessageItems 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.