Reduce Android realtime heartbeat noise
This commit is contained in:
@@ -592,7 +592,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
private boolean shouldRefreshConversationsTab(BossRealtimeEvent event) {
|
private boolean shouldRefreshConversationsTab(BossRealtimeEvent event) {
|
||||||
if ("conversation.context_indicator.updated".equals(event.eventName)) {
|
if ("conversation.context_indicator.updated".equals(event.eventName)) {
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
if ("conversation.updated".equals(event.eventName)) {
|
if ("conversation.updated".equals(event.eventName)) {
|
||||||
return hasProjectId(event) || hasDeviceId(event);
|
return hasProjectId(event) || hasDeviceId(event);
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -7648,6 +7648,7 @@ export async function upsertDeviceHeartbeat(payload: {
|
|||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const result = await mutateState((state) => {
|
const result = await mutateState((state) => {
|
||||||
|
let conversationRefreshRequired = false;
|
||||||
const projectUnderstandingSyncRequests: Array<{
|
const projectUnderstandingSyncRequests: Array<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
observedActivityAt: string;
|
observedActivityAt: string;
|
||||||
@@ -7761,6 +7762,7 @@ export async function upsertDeviceHeartbeat(payload: {
|
|||||||
} else if (!existing.deviceIds.includes(payload.deviceId)) {
|
} else if (!existing.deviceIds.includes(payload.deviceId)) {
|
||||||
existing.deviceIds.push(payload.deviceId);
|
existing.deviceIds.push(payload.deviceId);
|
||||||
existing.isGroup = existing.deviceIds.length > 1;
|
existing.isGroup = existing.deviceIds.length > 1;
|
||||||
|
conversationRefreshRequired = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7896,6 +7898,7 @@ export async function upsertDeviceHeartbeat(payload: {
|
|||||||
pruneMissingCandidates: true,
|
pruneMissingCandidates: true,
|
||||||
});
|
});
|
||||||
draft = applied.draft;
|
draft = applied.draft;
|
||||||
|
conversationRefreshRequired = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7905,13 +7908,16 @@ export async function upsertDeviceHeartbeat(payload: {
|
|||||||
pairingStatus: claimedEnrollment?.status,
|
pairingStatus: claimedEnrollment?.status,
|
||||||
importDraft: draft,
|
importDraft: draft,
|
||||||
projectUnderstandingSyncRequests,
|
projectUnderstandingSyncRequests,
|
||||||
|
conversationRefreshRequired,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
for (const request of result.projectUnderstandingSyncRequests ?? []) {
|
for (const request of result.projectUnderstandingSyncRequests ?? []) {
|
||||||
await queueProjectUnderstandingSyncTask(request);
|
await queueProjectUnderstandingSyncTask(request);
|
||||||
}
|
}
|
||||||
publishBossEvent("devices.updated", { deviceId: payload.deviceId });
|
publishBossEvent("devices.updated", { deviceId: payload.deviceId });
|
||||||
publishBossEvent("conversation.updated", { deviceId: payload.deviceId });
|
if (result.conversationRefreshRequired) {
|
||||||
|
publishBossEvent("conversation.updated", { deviceId: payload.deviceId, note: "device_import.updated" });
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7994,6 +8000,13 @@ function shouldAutoSyncHeartbeatCandidates(input: {
|
|||||||
if (!input.wasExistingDevice) return false;
|
if (!input.wasExistingDevice) return false;
|
||||||
if (input.device.source !== "production") return false;
|
if (input.device.source !== "production") return false;
|
||||||
if (!input.draft || input.draft.candidates.length === 0) return false;
|
if (!input.draft || input.draft.candidates.length === 0) return false;
|
||||||
|
if (
|
||||||
|
input.draft.status === "applied" &&
|
||||||
|
input.draft.resolutionId &&
|
||||||
|
sameStringSet(input.draft.selectedCandidateIds, resolveAutoSyncCandidateIds(input.draft))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
input.claimedEnrollment?.enrollmentId &&
|
input.claimedEnrollment?.enrollmentId &&
|
||||||
input.draft.enrollmentId === input.claimedEnrollment.enrollmentId
|
input.draft.enrollmentId === input.claimedEnrollment.enrollmentId
|
||||||
|
|||||||
16
tests/android-root-realtime-noise.test.ts
Normal file
16
tests/android-root-realtime-noise.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
test("android root page does not force a full list refresh for initial context snapshots", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../android/app/src/main/java/com/hyzq/boss/MainActivity.java", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/"conversation\.context_indicator\.updated"[\s\S]{0,120}return true;/,
|
||||||
|
"context indicator snapshots are payload-only hints and should not trigger a full Android list reload",
|
||||||
|
);
|
||||||
|
});
|
||||||
116
tests/device-heartbeat-event-noise.test.ts
Normal file
116
tests/device-heartbeat-event-noise.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
|
||||||
|
let runtimeRoot = "";
|
||||||
|
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||||
|
let upsertDeviceHeartbeat: (typeof import("../src/lib/boss-data"))["upsertDeviceHeartbeat"];
|
||||||
|
let subscribeBossEvents: (typeof import("../src/lib/boss-events"))["subscribeBossEvents"];
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
if (runtimeRoot) return;
|
||||||
|
|
||||||
|
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-heartbeat-noise-"));
|
||||||
|
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||||
|
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||||
|
|
||||||
|
const [data, events] = await Promise.all([
|
||||||
|
import("../src/lib/boss-data.ts"),
|
||||||
|
import("../src/lib/boss-events.ts"),
|
||||||
|
]);
|
||||||
|
readState = data.readState;
|
||||||
|
upsertDeviceHeartbeat = data.upsertDeviceHeartbeat;
|
||||||
|
subscribeBossEvents = events.subscribeBossEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.after(async () => {
|
||||||
|
if (runtimeRoot) {
|
||||||
|
await rm(runtimeRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildHeartbeatPayload(deviceId: string, projectCandidates: Array<{
|
||||||
|
folderName: string;
|
||||||
|
threadId: string;
|
||||||
|
threadDisplayName: string;
|
||||||
|
codexFolderRef: string;
|
||||||
|
codexThreadRef: string;
|
||||||
|
lastActiveAt: string;
|
||||||
|
}>) {
|
||||||
|
return {
|
||||||
|
deviceId,
|
||||||
|
token: `${deviceId}-token`,
|
||||||
|
name: "Mac Studio",
|
||||||
|
avatar: "M",
|
||||||
|
account: "17600003315",
|
||||||
|
status: "online" as const,
|
||||||
|
quota5h: 68,
|
||||||
|
quota7d: 81,
|
||||||
|
projects: [],
|
||||||
|
endpoint: "mac://kris.local",
|
||||||
|
projectCandidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bossThreadCandidate = {
|
||||||
|
folderName: "boss",
|
||||||
|
threadId: "thread-boss-main",
|
||||||
|
threadDisplayName: "Boss开发主线程",
|
||||||
|
codexFolderRef: "/Users/kris/code/boss",
|
||||||
|
codexThreadRef: "thread-boss-main",
|
||||||
|
lastActiveAt: "2026-04-10T10:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("unchanged device heartbeats do not publish conversation refresh events", async () => {
|
||||||
|
await setup();
|
||||||
|
|
||||||
|
const heartbeat = buildHeartbeatPayload("noise-device-a", [bossThreadCandidate]);
|
||||||
|
await upsertDeviceHeartbeat(heartbeat);
|
||||||
|
await upsertDeviceHeartbeat(heartbeat);
|
||||||
|
|
||||||
|
const primedState = await readState();
|
||||||
|
const primedDraft = primedState.deviceImportDrafts.find((draft) => draft.deviceId === "noise-device-a");
|
||||||
|
assert.equal(primedDraft?.status, "applied");
|
||||||
|
|
||||||
|
const events: Array<{ event: string; payload: { note?: string; deviceId?: string } }> = [];
|
||||||
|
const unsubscribe = subscribeBossEvents((event, payload) => {
|
||||||
|
events.push({ event, payload });
|
||||||
|
});
|
||||||
|
await upsertDeviceHeartbeat(heartbeat);
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
assert.deepEqual(events.map((event) => event.event), ["devices.updated"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("device heartbeats publish one conversation refresh when import candidates change", async () => {
|
||||||
|
await setup();
|
||||||
|
|
||||||
|
const nextThreadCandidate = {
|
||||||
|
folderName: "boss",
|
||||||
|
threadId: "thread-boss-review",
|
||||||
|
threadDisplayName: "Boss回归线程",
|
||||||
|
codexFolderRef: "/Users/kris/code/boss",
|
||||||
|
codexThreadRef: "thread-boss-review",
|
||||||
|
lastActiveAt: "2026-04-10T10:01:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
await upsertDeviceHeartbeat(buildHeartbeatPayload("noise-device-b", [bossThreadCandidate]));
|
||||||
|
await upsertDeviceHeartbeat(buildHeartbeatPayload("noise-device-b", [bossThreadCandidate]));
|
||||||
|
|
||||||
|
const events: Array<{ event: string; payload: { note?: string; deviceId?: string } }> = [];
|
||||||
|
const unsubscribe = subscribeBossEvents((event, payload) => {
|
||||||
|
events.push({ event, payload });
|
||||||
|
});
|
||||||
|
await upsertDeviceHeartbeat(buildHeartbeatPayload("noise-device-b", [bossThreadCandidate, nextThreadCandidate]));
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
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"],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user