fix(webui): clear stale run status on reconnect

This commit is contained in:
axelray-dev
2026-06-28 19:46:47 +08:00
committed by Xubin Ren
parent 5005bca353
commit 194e9d5f5f
5 changed files with 63 additions and 35 deletions
+1 -5
View File
@@ -346,11 +346,7 @@ class WebSocketChannel(BaseChannel):
async def _hydrate_after_subscribe(self, chat_id: str) -> None:
"""Replay goal/run strip state after subscribe (same-process refresh)."""
await self._maybe_push_active_goal_state(chat_id)
t0 = websocket_turn_wall_started_at(chat_id)
if t0 is not None:
await self.send_goal_status(chat_id, "running", started_at=t0)
else:
await self.send_goal_status(chat_id, "idle")
await self._maybe_push_turn_run_wall_clock(chat_id)
async def _send_event(self, connection: Any, event: str, **fields: Any) -> None:
"""Send a control event (attached, error, ...) to a single connection."""
@@ -1,6 +1,6 @@
"""Test websocket reconnect pushes idle status when no turn is active."""
"""Test websocket subscribe hydration only replays known active turns."""
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
import pytest
@@ -8,8 +8,8 @@ from nanobot.channels.websocket import WebSocketChannel
@pytest.mark.asyncio
async def test_hydrate_after_subscribe_pushes_idle_when_no_turn_active():
"""Reconnecting client should receive idle status when no turn is running."""
async def test_hydrate_after_subscribe_is_quiet_when_no_turn_active():
"""Subscribe hydration must not inject an idle event into normal message order."""
channel = WebSocketChannel.__new__(WebSocketChannel)
channel.gateway = MagicMock()
channel.gateway.session_manager = MagicMock()
@@ -29,8 +29,7 @@ async def test_hydrate_after_subscribe_pushes_idle_when_no_turn_active():
with patch("nanobot.channels.websocket.websocket_turn_wall_started_at", return_value=None):
await channel._hydrate_after_subscribe("test-chat")
# Should have pushed idle status
assert any(e[0] == "goal_status" and e[2] == "idle" for e in sent_events)
assert sent_events == []
@pytest.mark.asyncio
@@ -55,7 +54,6 @@ async def test_hydrate_after_subscribe_pushes_running_when_turn_active():
with patch("nanobot.channels.websocket.websocket_turn_wall_started_at", return_value=1234567890.0):
await channel._hydrate_after_subscribe("test-chat")
# Should have pushed running status with started_at
running_events = [e for e in sent_events if e[0] == "goal_status" and e[2] == "running"]
assert len(running_events) == 1
assert running_events[0][3]["started_at"] == 1234567890.0
+8
View File
@@ -425,6 +425,13 @@ export class NanobotClient {
for (const handler of this.statusHandlers) handler(status);
}
private clearRunStatusesForReconnect(): void {
if (this.runStartedAtByChatId.size === 0) return;
const chatIds = [...this.runStartedAtByChatId.keys()];
this.runStartedAtByChatId.clear();
for (const chatId of chatIds) this.emitRunStatus(chatId, null);
}
private handleOpen(): void {
this.setStatus("open");
this.reconnectAttempts = 0;
@@ -629,6 +636,7 @@ export class NanobotClient {
}
private scheduleReconnect(): void {
this.clearRunStatusesForReconnect();
this.setStatus("reconnecting");
const attempt = this.reconnectAttempts++;
// Exponential backoff: 0.5s, 1s, 2s, 4s, capped.
+26
View File
@@ -188,6 +188,32 @@ describe("NanobotClient", () => {
expect(client.getRunStartedAt("chat-strip")).toBeNull();
});
it("clears stale run strip when reconnecting after a dropped socket", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
maxBackoffMs: 10,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onRunStatus(handler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-strip",
status: "running",
started_at: 12_345,
});
lastSocket().close();
expect(client.getRunStartedAt("chat-strip")).toBeNull();
expect(handler).toHaveBeenLastCalledWith("chat-strip", null);
await vi.advanceTimersByTimeAsync(20);
expect(FakeSocket.instances.length).toBeGreaterThan(1);
});
it("clears run strip when a turn_end arrives without idle", () => {
const client = new NanobotClient({
url: "ws://test",