swarm-ui: let a closed stream reconnect and a failed caps read retry
When the server answers an event stream with an error status the browser gives up and the badge stayed on disconnected with nothing to press. The terminal, its state header and the messages page now offer a reconnect, and the one rule for the state after an error moved into a tested helper. The resource caps panel gets a retry beside its error, and its loading state announces itself.
This commit is contained in:
parent
9b1fe85706
commit
31b7452e56
9 changed files with 118 additions and 34 deletions
|
|
@ -59,6 +59,10 @@ dimmed, as **not running**.
|
|||
An agent's terminal opens with its last 100 rows from the past 24 hours, then
|
||||
follows new ones; a reconnect picks up after the last row it showed. A subagent
|
||||
terminal does the same with that subagent's rows, and has no turn-state badges.
|
||||
The browser retries a dropped connection by itself. When the swarm answers the
|
||||
stream with an error status, the browser gives up and the badge reads
|
||||
**disconnected**. The **reconnect** button beside it opens the stream again, and
|
||||
the stream replays the rows. The messages page works the same way.
|
||||
|
||||
The settings menu in the header has a **hide debug output** toggle. It drops
|
||||
the muted debug rows (thinking, coalesced ticks, harness chatter) from every
|
||||
|
|
@ -419,7 +423,8 @@ refusal's reason under the fields and leaves them as you typed them.
|
|||
A saved change isn't instant. The hive reads the caps about every 30 seconds,
|
||||
and a running agent takes a changed cap at its next spawn or rebuild, so the
|
||||
row shows the new value before the agent runs under it. The panel reads
|
||||
`GET /api/agents/{agent}/limits`. [hivectl](../tools/hivectl.md#per-agent-resource-limits)
|
||||
`GET /api/agents/{agent}/limits`; when that read fails, the panel shows the
|
||||
reason and a **retry** button. [hivectl](../tools/hivectl.md#per-agent-resource-limits)
|
||||
sets the hive's own limits.
|
||||
|
||||
### Settings at creation
|
||||
|
|
|
|||
|
|
@ -117,10 +117,22 @@ export function AgentLimits({ agent }: { agent: string }) {
|
|||
// Shown as text, not a hover title. Caps already loaded stay under a
|
||||
// failed refetch, so a save that worked is not hidden by a read that did not.
|
||||
const errorPanel = error ? (
|
||||
<ApiErrorPanel context="reading the resource caps failed" problem={error} />
|
||||
<>
|
||||
<ApiErrorPanel
|
||||
context="reading the resource caps failed"
|
||||
problem={error}
|
||||
/>
|
||||
<Button onClick={() => setReload((n) => n + 1)}>retry</Button>
|
||||
</>
|
||||
) : null;
|
||||
if (caps === null) {
|
||||
return errorPanel ?? <span class="ui-agent-limits-muted">…</span>;
|
||||
return (
|
||||
errorPanel ?? (
|
||||
<span class="ui-agent-limits-muted" role="status">
|
||||
loading the resource caps…
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -203,8 +203,10 @@ export function AgentTermPreview({
|
|||
leading?: ComponentChildren;
|
||||
}) {
|
||||
const paths = termPaths(agentName, subagent);
|
||||
const { rows, connection } = useSwarmTermStream(paths.stream);
|
||||
const { header } = useSwarmAgentStateStream(paths.state);
|
||||
const { rows, connection, reconnect } = useSwarmTermStream(paths.stream);
|
||||
const { header, reconnect: reconnectState } = useSwarmAgentStateStream(
|
||||
paths.state,
|
||||
);
|
||||
const { open: openTab } = useDynamicTabs();
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
const [stickToBottom, setStickToBottom] = useState(true);
|
||||
|
|
@ -246,6 +248,17 @@ export function AgentTermPreview({
|
|||
tone={CONNECTION_TONE[connection]}
|
||||
value={CONNECTION_LABEL[connection]}
|
||||
/>
|
||||
{connection === "closed" && (
|
||||
<Badge
|
||||
variant="quiet"
|
||||
value="reconnect"
|
||||
title={`open ${paths.label}'s terminal stream again`}
|
||||
onClick={() => {
|
||||
reconnect();
|
||||
reconnectState();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{header && showHeaderBadges && (
|
||||
<StateBadges header={header} ctx={ctx} cost={cost} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@
|
|||
// `status_text`/`running`).
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed";
|
||||
import {
|
||||
connectionAfterError,
|
||||
type ConnectionState,
|
||||
} from "../../ui/stream/connection.js";
|
||||
|
||||
export type { ConnectionState };
|
||||
|
||||
export interface TokenUsage {
|
||||
input_tokens?: number;
|
||||
|
|
@ -53,6 +58,8 @@ export interface AgentStateHeader {
|
|||
export interface UseSwarmAgentStateStreamResult {
|
||||
header: AgentStateHeader | null;
|
||||
connection: ConnectionState;
|
||||
/** Opens the stream again, for one the browser gave up on. */
|
||||
reconnect: () => void;
|
||||
}
|
||||
|
||||
export function useSwarmAgentStateStream(
|
||||
|
|
@ -60,6 +67,8 @@ export function useSwarmAgentStateStream(
|
|||
): UseSwarmAgentStateStreamResult {
|
||||
const [header, setHeader] = useState<AgentStateHeader | null>(null);
|
||||
const [connection, setConnection] = useState<ConnectionState>("connecting");
|
||||
// Bumped by `reconnect`, which reruns the effect below.
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setHeader(null);
|
||||
|
|
@ -78,13 +87,9 @@ export function useSwarmAgentStateStream(
|
|||
// badge cluster over — the next valid header replaces it.
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
setConnection(
|
||||
es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed",
|
||||
);
|
||||
};
|
||||
es.onerror = () => setConnection(connectionAfterError(es.readyState));
|
||||
return () => es.close();
|
||||
}, [streamUrl]);
|
||||
}, [streamUrl, attempt]);
|
||||
|
||||
return { header, connection };
|
||||
return { header, connection, reconnect: () => setAttempt((n) => n + 1) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@
|
|||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { TermMsg, TermRow } from "@hive/shared/term-msg.js";
|
||||
|
||||
export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed";
|
||||
import {
|
||||
connectionAfterError,
|
||||
type ConnectionState,
|
||||
} from "../../ui/stream/connection.js";
|
||||
|
||||
export type { ConnectionState };
|
||||
|
||||
function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
|
||||
const last = rows[rows.length - 1];
|
||||
|
|
@ -25,6 +30,8 @@ function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
|
|||
export interface UseSwarmTermStreamResult {
|
||||
rows: TermRow[];
|
||||
connection: ConnectionState;
|
||||
/** Opens the stream again, for one the browser gave up on. */
|
||||
reconnect: () => void;
|
||||
}
|
||||
|
||||
export function useSwarmTermStream(
|
||||
|
|
@ -33,6 +40,8 @@ export function useSwarmTermStream(
|
|||
const [rows, setRows] = useState<TermRow[]>([]);
|
||||
const [connection, setConnection] = useState<ConnectionState>("connecting");
|
||||
const keySeqRef = useRef(0);
|
||||
// Bumped by `reconnect`, which reruns the effect below.
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setRows([]);
|
||||
|
|
@ -59,13 +68,9 @@ export function useSwarmTermStream(
|
|||
}
|
||||
setRows((prev) => appendRow(prev, { ...msg, key, fromHistory: false }));
|
||||
};
|
||||
es.onerror = () => {
|
||||
setConnection(
|
||||
es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed",
|
||||
);
|
||||
};
|
||||
es.onerror = () => setConnection(connectionAfterError(es.readyState));
|
||||
return () => es.close();
|
||||
}, [streamUrl]);
|
||||
}, [streamUrl, attempt]);
|
||||
|
||||
return { rows, connection };
|
||||
return { rows, connection, reconnect: () => setAttempt((n) => n + 1) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const MessageRow = memo(function MessageRow({ m }: { m: ArrivedMessage }) {
|
|||
export function MessagesPage() {
|
||||
const [sinceMinutes, setSinceMinutes] = useState(DEFAULT_WINDOW_MINUTES);
|
||||
const [query, setQuery] = useState("");
|
||||
const { messages, connection } = useMessageStream(sinceMinutes);
|
||||
const { messages, connection, reconnect } = useMessageStream(sinceMinutes);
|
||||
const shown = filterMessages(messages, query);
|
||||
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -78,10 +78,20 @@ export function MessagesPage() {
|
|||
title="messages"
|
||||
icon="💬"
|
||||
actions={
|
||||
<>
|
||||
<Badge
|
||||
tone={CONNECTION_TONE[connection]}
|
||||
value={CONNECTION_LABEL[connection]}
|
||||
/>
|
||||
{connection === "closed" ? (
|
||||
<Badge
|
||||
variant="quiet"
|
||||
value="reconnect"
|
||||
title="open the message stream again"
|
||||
onClick={reconnect}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="ui-messages-bar">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ import { useEffect, useRef, useState } from "preact/hooks";
|
|||
import type { ArrivedMessage, SwarmMessage } from "./messages.js";
|
||||
import { messagesUrl, newest, numbered } from "./messages.js";
|
||||
|
||||
export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed";
|
||||
import {
|
||||
connectionAfterError,
|
||||
type ConnectionState,
|
||||
} from "../../ui/stream/connection.js";
|
||||
|
||||
export type { ConnectionState };
|
||||
|
||||
/** The most messages the page holds; older ones drop off. */
|
||||
export const MAX_MESSAGES = 2000;
|
||||
|
|
@ -19,12 +24,16 @@ export const MAX_MESSAGES = 2000;
|
|||
export function useMessageStream(sinceMinutes: number): {
|
||||
messages: ArrivedMessage[];
|
||||
connection: ConnectionState;
|
||||
/** Opens the stream again, for one the browser gave up on. */
|
||||
reconnect: () => void;
|
||||
} {
|
||||
const [messages, setMessages] = useState<ArrivedMessage[]>([]);
|
||||
const [connection, setConnection] = useState<ConnectionState>("connecting");
|
||||
// The next id to hand out; it never goes back, so an id is not reused after
|
||||
// a reconnect either.
|
||||
const nextId = useRef(0);
|
||||
// Bumped by `reconnect`, which reruns the effect below.
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setMessages([]);
|
||||
|
|
@ -61,16 +70,12 @@ export function useMessageStream(sinceMinutes: number): {
|
|||
pending.push(m);
|
||||
if (frame === null) frame = requestAnimationFrame(flush);
|
||||
};
|
||||
es.onerror = () => {
|
||||
setConnection(
|
||||
es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed",
|
||||
);
|
||||
};
|
||||
es.onerror = () => setConnection(connectionAfterError(es.readyState));
|
||||
return () => {
|
||||
drop();
|
||||
es.close();
|
||||
};
|
||||
}, [sinceMinutes]);
|
||||
}, [sinceMinutes, attempt]);
|
||||
|
||||
return { messages, connection };
|
||||
return { messages, connection, reconnect: () => setAttempt((n) => n + 1) };
|
||||
}
|
||||
|
|
|
|||
14
frontend/packages/swarm-ui/src/ui/stream/connection.ts
Normal file
14
frontend/packages/swarm-ui/src/ui/stream/connection.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// The state of an `EventSource` as the swarm UI's stream hooks report it.
|
||||
|
||||
export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed";
|
||||
|
||||
/** `EventSource.CONNECTING`: the browser is retrying by itself. */
|
||||
const CONNECTING = 0;
|
||||
|
||||
/** The state after an `error` event. While the browser is still retrying
|
||||
* (`readyState` 0), the stream is "reconnecting". Any other state means the
|
||||
* browser gave up, which it does when the server answers with a non-200 status
|
||||
* or a wrong content type, so nothing reopens the stream unless the page does. */
|
||||
export function connectionAfterError(readyState: number): ConnectionState {
|
||||
return readyState === CONNECTING ? "reconnecting" : "closed";
|
||||
}
|
||||
15
frontend/packages/swarm-ui/test/stream-connection.test.mjs
Normal file
15
frontend/packages/swarm-ui/test/stream-connection.test.mjs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { connectionAfterError } from "../src/ui/stream/connection.ts";
|
||||
|
||||
test("connectionAfterError: the browser still retrying is reconnecting", () => {
|
||||
assert.equal(connectionAfterError(0), "reconnecting");
|
||||
});
|
||||
|
||||
test("connectionAfterError: a given-up stream is closed", () => {
|
||||
assert.equal(connectionAfterError(2), "closed");
|
||||
});
|
||||
|
||||
test("connectionAfterError: an open readyState on error is also closed", () => {
|
||||
assert.equal(connectionAfterError(1), "closed");
|
||||
});
|
||||
Loading…
Reference in a new issue