Reduce Android realtime heartbeat noise

This commit is contained in:
kris
2026-04-10 13:25:05 +08:00
parent 0cba837ed3
commit c4dbfc7398
6 changed files with 308 additions and 2 deletions

View File

@@ -0,0 +1,138 @@
# Android Realtime Noise Reduction Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Reduce Android realtime stutter by suppressing heartbeat-only conversation refresh events and avoiding full reloads from initial SSE context snapshots.
**Architecture:** Keep the existing SSE endpoint and Android `BossRealtimeClient`. Narrow event production in `upsertDeviceHeartbeat` and narrow Android root-page event consumption in `MainActivity`.
**Tech Stack:** Next.js App Router backend, TypeScript `node:test`, Android Java native client, Server-Sent Events.
---
### Task 1: Lock Heartbeat Event Contract
**Files:**
- Create: `tests/device-heartbeat-event-noise.test.ts`
- Modify: `src/lib/boss-data.ts`
- [ ] **Step 1: Write the failing unchanged-heartbeat test**
```ts
assert.deepEqual(events.map((event) => event.event), ["devices.updated"]);
```
- [ ] **Step 2: Run the test and verify it fails**
Run: `npx tsx --test tests/device-heartbeat-event-noise.test.ts`
Expected: unchanged heartbeat currently emits both `devices.updated` and `conversation.updated`.
- [ ] **Step 3: Suppress unchanged heartbeat conversation refreshes**
Track whether heartbeat import/candidate state actually changes the conversation drawer. Always publish `devices.updated`; publish `conversation.updated` only when the drawer changes.
- [ ] **Step 4: Run the focused test and verify it passes**
Run: `npx tsx --test tests/device-heartbeat-event-noise.test.ts`
Expected: unchanged heartbeat emits only `devices.updated`.
### Task 2: Keep Import Changes Visible
**Files:**
- Modify: `tests/device-heartbeat-event-noise.test.ts`
- Modify: `src/lib/boss-data.ts`
- [ ] **Step 1: Write the changed-candidates test**
```ts
assert.deepEqual(
events.map((event) => [event.event, event.payload.deviceId, event.payload.note]),
[
["devices.updated", "noise-device-b", undefined],
["conversation.updated", "noise-device-b", "device_import.updated"],
],
);
```
- [ ] **Step 2: Run the test and verify it fails**
Run: `npx tsx --test tests/device-heartbeat-event-noise.test.ts`
Expected: the event exists before the fix but is too broad and lacks the `device_import.updated` marker.
- [ ] **Step 3: Publish a marked conversation event only for import changes**
Use `note: "device_import.updated"` on the conditional `conversation.updated` heartbeat event.
- [ ] **Step 4: Run the focused test and verify it passes**
Run: `npx tsx --test tests/device-heartbeat-event-noise.test.ts`
Expected: changed candidates publish one marked conversation refresh event.
### Task 3: Avoid Android Initial Snapshot Full Reloads
**Files:**
- Create: `tests/android-root-realtime-noise.test.ts`
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
- [ ] **Step 1: Write the failing Android static test**
```ts
assert.doesNotMatch(
source,
/"conversation\.context_indicator\.updated"[\s\S]{0,120}return true;/,
);
```
- [ ] **Step 2: Run the test and verify it fails**
Run: `npx tsx --test tests/android-root-realtime-noise.test.ts`
Expected: `MainActivity` currently returns `true` for the context snapshot event.
- [ ] **Step 3: Ignore context snapshot as a full reload trigger**
Change the context snapshot branch to return `false` in `shouldRefreshConversationsTab`.
- [ ] **Step 4: Run the focused test and verify it passes**
Run: `npx tsx --test tests/android-root-realtime-noise.test.ts`
Expected: Android root no longer treats initial snapshot events as full reload triggers.
### Task 4: Verify And Ship
**Files:**
- Verify: `src/lib/boss-data.ts`
- Verify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
- Verify: `tests/device-heartbeat-event-noise.test.ts`
- Verify: `tests/android-root-realtime-noise.test.ts`
- [ ] **Step 1: Run realtime and device regression tests**
Run: `npx tsx --test tests/device-heartbeat-event-noise.test.ts tests/android-root-realtime-noise.test.ts tests/device-gui-cli-capabilities.test.ts tests/device-execution-conflict.test.ts tests/conversation-home-items.test.ts tests/realtime-refresh-utils.test.ts tests/project-scoped-realtime-refresh.test.ts tests/android-folder-realtime-refresh.test.ts`
Expected: all selected tests pass.
- [ ] **Step 2: Run project gates**
Run: `npm run lint`
Expected: lint exits 0.
Run: `npm run build`
Expected: production build exits 0.
- [ ] **Step 3: Build and install Android release APK**
Run: `cd android && ./gradlew assembleRelease`
Expected: release APK builds successfully.
Run: `adb -s <wireless-device-id> install -r android/app/build/outputs/apk/release/app-release.apk`
Expected: install returns `Success`.

View File

@@ -0,0 +1,23 @@
# Android Realtime Noise Reduction Design
## Goal
Reduce Android message receive stutter and reconnect-visible reloads by preventing heartbeat-only SSE traffic from triggering full conversation list refreshes.
## Evidence
The production `boss-web` service was active and had no recent service errors. A direct authenticated SSE probe showed the stream stays open with keepalive comments, but every local-agent heartbeat emitted `devices.updated` followed by `conversation.updated` with only `deviceId`. Android `MainActivity` treated that device-only `conversation.updated` as a full conversations tab reload. The stream also sends an initial `conversation.context_indicator.updated` snapshot on connection; Android was not consuming its payload directly and instead used it as another full reload trigger.
## Design
Keep `devices.updated` for every heartbeat so device status remains fresh. Stop publishing `conversation.updated` for unchanged device heartbeats. Publish a single `conversation.updated` with `note: "device_import.updated"` only when heartbeat candidate/import state actually changes the conversation drawer structure.
On Android, do not use the initial `conversation.context_indicator.updated` snapshot as a full list reload trigger. Actual conversation and project message events still refresh the relevant views, while the existing root-page auto-refresh remains the low-frequency fallback for aggregate context status.
## Testing
Add event-contract tests proving unchanged heartbeats only emit `devices.updated`, while changed import candidates emit one `conversation.updated` marked as `device_import.updated`. Add an Android static test proving `MainActivity` no longer returns `true` for the initial context snapshot refresh path.
## Out Of Scope
This change does not replace SSE with a different transport, does not remove project-scoped message events, and does not implement payload-level in-place updates for Android conversation rows.