fix(webui): clear stale run status on reconnect

This commit is contained in:
axelray-dev 2026-06-28 07:07:14 +07:00 committed by Xubin Ren
parent 5005bca353
commit 194e9d5f5f
5 changed files with 63 additions and 35 deletions

View File

@ -346,11 +346,7 @@ class WebSocketChannel(BaseChannel):
async def _hydrate_after_subscribe(self, chat_id: str) -> None: async def _hydrate_after_subscribe(self, chat_id: str) -> None:
"""Replay goal/run strip state after subscribe (same-process refresh).""" """Replay goal/run strip state after subscribe (same-process refresh)."""
await self._maybe_push_active_goal_state(chat_id) await self._maybe_push_active_goal_state(chat_id)
t0 = websocket_turn_wall_started_at(chat_id) await self._maybe_push_turn_run_wall_clock(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")
async def _send_event(self, connection: Any, event: str, **fields: Any) -> None: async def _send_event(self, connection: Any, event: str, **fields: Any) -> None:
"""Send a control event (attached, error, ...) to a single connection.""" """Send a control event (attached, error, ...) to a single connection."""

View File

@ -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 import pytest
@ -8,8 +8,8 @@ from nanobot.channels.websocket import WebSocketChannel
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_hydrate_after_subscribe_pushes_idle_when_no_turn_active(): async def test_hydrate_after_subscribe_is_quiet_when_no_turn_active():
"""Reconnecting client should receive idle status when no turn is running.""" """Subscribe hydration must not inject an idle event into normal message order."""
channel = WebSocketChannel.__new__(WebSocketChannel) channel = WebSocketChannel.__new__(WebSocketChannel)
channel.gateway = MagicMock() channel.gateway = MagicMock()
channel.gateway.session_manager = 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): with patch("nanobot.channels.websocket.websocket_turn_wall_started_at", return_value=None):
await channel._hydrate_after_subscribe("test-chat") await channel._hydrate_after_subscribe("test-chat")
# Should have pushed idle status assert sent_events == []
assert any(e[0] == "goal_status" and e[2] == "idle" for e in sent_events)
@pytest.mark.asyncio @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): with patch("nanobot.channels.websocket.websocket_turn_wall_started_at", return_value=1234567890.0):
await channel._hydrate_after_subscribe("test-chat") 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"] running_events = [e for e in sent_events if e[0] == "goal_status" and e[2] == "running"]
assert len(running_events) == 1 assert len(running_events) == 1
assert running_events[0][3]["started_at"] == 1234567890.0 assert running_events[0][3]["started_at"] == 1234567890.0

View File

@ -425,6 +425,13 @@ export class NanobotClient {
for (const handler of this.statusHandlers) handler(status); 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 { private handleOpen(): void {
this.setStatus("open"); this.setStatus("open");
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
@ -629,6 +636,7 @@ export class NanobotClient {
} }
private scheduleReconnect(): void { private scheduleReconnect(): void {
this.clearRunStatusesForReconnect();
this.setStatus("reconnecting"); this.setStatus("reconnecting");
const attempt = this.reconnectAttempts++; const attempt = this.reconnectAttempts++;
// Exponential backoff: 0.5s, 1s, 2s, 4s, capped. // Exponential backoff: 0.5s, 1s, 2s, 4s, capped.

View File

@ -188,6 +188,32 @@ describe("NanobotClient", () => {
expect(client.getRunStartedAt("chat-strip")).toBeNull(); 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", () => { it("clears run strip when a turn_end arrives without idle", () => {
const client = new NanobotClient({ const client = new NanobotClient({
url: "ws://test", url: "ws://test",