fix(webui): clear stale run status on reconnect
This commit is contained in:
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user