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:
- A spinner spins endlessly (a tool card that never completes).
- 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 Pythonandroidplatform plugin (symlinked into~/.hermes/plugins/android).adapter.pyis the outbound frame classifier. This is where the gateway turns coreadapter.send()calls into WS frames.app/— Compose Multiplatform (Kotlin).:sharedholds most code (jvmMainis shared by android + desktop).IrisControllerroutes frames intoChatStore;ChatScreenrenders.
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
failedand stays down until you run:systemctl --user restart iris-hermes-gw.service - Logs:
~/.hermes/logs/gateway.log. Outbox DB:~/.hermes/android/outbox.db(tableoutbox(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— addednowMillis(): Long(expect/actual) for wall-clock timestamps.app/shared/src/commonMain/kotlin/iris/data/ChatStore.ktMessageItemgainedisSystem: Boolean = false.addSystemMessage(lane, text)— appends a centered local notice.finalizeInterrupted()— marks openToolItemsdone=true, ok=falseand streamingMessageItemsstreaming=false, across all lanes.
app/shared/src/commonMain/kotlin/iris/state/IrisController.kt- In the
client.state.collectblock, tracksprevState:Reconnecting → Connected: postsGATEWAY_ONLINE_MSG("♻️ Gateway online — Hermes is back and ready.") to the current lane.Connected → Reconnecting: callschat.finalizeInterrupted().
- New constant
GATEWAY_ONLINE_MSG.
- In the
app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt- Renders
isSystemmessages via a new centeredSystemMessagecomposable (non-selectable pill).
- Renders
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 atool.startcard.
- New
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
-
Wrong ordering / stale "shutting down" last. On reconnect the app posts "♻️ Gateway online", but then the
synccatch-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. -
Spinner returns. The outbox still contains
tool.startframes for the "Gateway shutting down" notice that were written before the adapter fix was loaded. Thesyncreplay turns those into tool cards that never receive atool.end, so they spin. The adapter fix only stops newtool.startframes; 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.startframes (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.pypass, 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.
- Dedup/order lifecycle notices in the app. Give the restart/online notices
stable identities and reconcile them (like
MessageItemis bymessageId): 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. - Stop the spinner at the source. Ensure no
tool.startis ever emitted for a lifecycle notice (adapter fix is done for new frames) AND make the app resilient: atool.startwith no matchingtool.endshould not spin indefinitely (e.g., finalize on the next turn, on reconnect, or time out). - Cursor hygiene. Investigate why the
syncreplay re-delivers frames the app already rendered live (live broadcasts may not advance the storedsyncCursor). If live frames should advance the cursor, the replay would not re-add them. CheckGatewayClient.dial(sendssync), theTYPE_SYNC_DONEhandler inIrisController, and the gatewayon_sync/outbox cursor logic. - 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(devicea5ca2a4b) - 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.serviceactive). - 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.