Watch
0
0
Fork
You've already forked hyperhive
0

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:
iris 2026-10-21 23:16:04 +02:00 • committed by mara
commit 31b7452e56
9 changed files with 118 additions and 34 deletions

View file

@ -59,6 +59,10 @@ dimmed, as **not running**.
An agent's terminal opens with its last 100 rows from the past 24 hours, then 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 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. 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 settings menu in the header has a **hide debug output** toggle. It drops
the muted debug rows (thinking, coalesced ticks, harness chatter) from every 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, 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 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 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. sets the hive's own limits.
### Settings at creation ### Settings at creation

View file

@ -117,10 +117,22 @@ export function AgentLimits({ agent }: { agent: string }) {
// Shown as text, not a hover title. Caps already loaded stay under a // 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. // failed refetch, so a save that worked is not hidden by a read that did not.
const errorPanel = error ? ( 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; ) : null;
if (caps === 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 ( return (

View file

@ -203,8 +203,10 @@ export function AgentTermPreview({
leading?: ComponentChildren; leading?: ComponentChildren;
}) { }) {
const paths = termPaths(agentName, subagent); const paths = termPaths(agentName, subagent);
const { rows, connection } = useSwarmTermStream(paths.stream); const { rows, connection, reconnect } = useSwarmTermStream(paths.stream);
const { header } = useSwarmAgentStateStream(paths.state); const { header, reconnect: reconnectState } = useSwarmAgentStateStream(
paths.state,
);
const { open: openTab } = useDynamicTabs(); const { open: openTab } = useDynamicTabs();
const logRef = useRef<HTMLDivElement>(null); const logRef = useRef<HTMLDivElement>(null);
const [stickToBottom, setStickToBottom] = useState(true); const [stickToBottom, setStickToBottom] = useState(true);
@ -246,6 +248,17 @@ export function AgentTermPreview({
tone={CONNECTION_TONE[connection]} tone={CONNECTION_TONE[connection]}
value={CONNECTION_LABEL[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 && ( {header && showHeaderBadges && (
<StateBadges header={header} ctx={ctx} cost={cost} /> <StateBadges header={header} ctx={ctx} cost={cost} />
)} )}

View file

@ -17,7 +17,12 @@
// `status_text`/`running`). // `status_text`/`running`).
import { useEffect, useState } from "preact/hooks"; 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 { export interface TokenUsage {
input_tokens?: number; input_tokens?: number;
@ -53,6 +58,8 @@ export interface AgentStateHeader {
export interface UseSwarmAgentStateStreamResult { export interface UseSwarmAgentStateStreamResult {
header: AgentStateHeader | null; header: AgentStateHeader | null;
connection: ConnectionState; connection: ConnectionState;
/** Opens the stream again, for one the browser gave up on. */
reconnect: () => void;
} }
export function useSwarmAgentStateStream( export function useSwarmAgentStateStream(
@ -60,6 +67,8 @@ export function useSwarmAgentStateStream(
): UseSwarmAgentStateStreamResult { ): UseSwarmAgentStateStreamResult {
const [header, setHeader] = useState<AgentStateHeader | null>(null); const [header, setHeader] = useState<AgentStateHeader | null>(null);
const [connection, setConnection] = useState<ConnectionState>("connecting"); const [connection, setConnection] = useState<ConnectionState>("connecting");
// Bumped by `reconnect`, which reruns the effect below.
const [attempt, setAttempt] = useState(0);
useEffect(() => { useEffect(() => {
setHeader(null); setHeader(null);
@ -78,13 +87,9 @@ export function useSwarmAgentStateStream(
// badge cluster over — the next valid header replaces it. // badge cluster over — the next valid header replaces it.
} }
}; };
es.onerror = () => { es.onerror = () => setConnection(connectionAfterError(es.readyState));
setConnection(
es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed",
);
};
return () => es.close(); return () => es.close();
}, [streamUrl]); }, [streamUrl, attempt]);
return { header, connection }; return { header, connection, reconnect: () => setAttempt((n) => n + 1) };
} }

View file

@ -12,7 +12,12 @@
import { useEffect, useRef, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import type { TermMsg, TermRow } from "@hive/shared/term-msg.js"; 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[] { function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
const last = rows[rows.length - 1]; const last = rows[rows.length - 1];
@ -25,6 +30,8 @@ function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
export interface UseSwarmTermStreamResult { export interface UseSwarmTermStreamResult {
rows: TermRow[]; rows: TermRow[];
connection: ConnectionState; connection: ConnectionState;
/** Opens the stream again, for one the browser gave up on. */
reconnect: () => void;
} }
export function useSwarmTermStream( export function useSwarmTermStream(
@ -33,6 +40,8 @@ export function useSwarmTermStream(
const [rows, setRows] = useState<TermRow[]>([]); const [rows, setRows] = useState<TermRow[]>([]);
const [connection, setConnection] = useState<ConnectionState>("connecting"); const [connection, setConnection] = useState<ConnectionState>("connecting");
const keySeqRef = useRef(0); const keySeqRef = useRef(0);
// Bumped by `reconnect`, which reruns the effect below.
const [attempt, setAttempt] = useState(0);
useEffect(() => { useEffect(() => {
setRows([]); setRows([]);
@ -59,13 +68,9 @@ export function useSwarmTermStream(
} }
setRows((prev) => appendRow(prev, { ...msg, key, fromHistory: false })); setRows((prev) => appendRow(prev, { ...msg, key, fromHistory: false }));
}; };
es.onerror = () => { es.onerror = () => setConnection(connectionAfterError(es.readyState));
setConnection(
es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed",
);
};
return () => es.close(); return () => es.close();
}, [streamUrl]); }, [streamUrl, attempt]);
return { rows, connection }; return { rows, connection, reconnect: () => setAttempt((n) => n + 1) };
} }

View file

@ -50,7 +50,7 @@ const MessageRow = memo(function MessageRow({ m }: { m: ArrivedMessage }) {
export function MessagesPage() { export function MessagesPage() {
const [sinceMinutes, setSinceMinutes] = useState(DEFAULT_WINDOW_MINUTES); const [sinceMinutes, setSinceMinutes] = useState(DEFAULT_WINDOW_MINUTES);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const { messages, connection } = useMessageStream(sinceMinutes); const { messages, connection, reconnect } = useMessageStream(sinceMinutes);
const shown = filterMessages(messages, query); const shown = filterMessages(messages, query);
const logRef = useRef<HTMLDivElement>(null); const logRef = useRef<HTMLDivElement>(null);
@ -78,10 +78,20 @@ export function MessagesPage() {
title="messages" title="messages"
icon="💬" icon="💬"
actions={ actions={
<Badge <>
tone={CONNECTION_TONE[connection]} <Badge
value={CONNECTION_LABEL[connection]} 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"> <div class="ui-messages-bar">

View file

@ -11,7 +11,12 @@ import { useEffect, useRef, useState } from "preact/hooks";
import type { ArrivedMessage, SwarmMessage } from "./messages.js"; import type { ArrivedMessage, SwarmMessage } from "./messages.js";
import { messagesUrl, newest, numbered } 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. */ /** The most messages the page holds; older ones drop off. */
export const MAX_MESSAGES = 2000; export const MAX_MESSAGES = 2000;
@ -19,12 +24,16 @@ export const MAX_MESSAGES = 2000;
export function useMessageStream(sinceMinutes: number): { export function useMessageStream(sinceMinutes: number): {
messages: ArrivedMessage[]; messages: ArrivedMessage[];
connection: ConnectionState; connection: ConnectionState;
/** Opens the stream again, for one the browser gave up on. */
reconnect: () => void;
} { } {
const [messages, setMessages] = useState<ArrivedMessage[]>([]); const [messages, setMessages] = useState<ArrivedMessage[]>([]);
const [connection, setConnection] = useState<ConnectionState>("connecting"); const [connection, setConnection] = useState<ConnectionState>("connecting");
// The next id to hand out; it never goes back, so an id is not reused after // The next id to hand out; it never goes back, so an id is not reused after
// a reconnect either. // a reconnect either.
const nextId = useRef(0); const nextId = useRef(0);
// Bumped by `reconnect`, which reruns the effect below.
const [attempt, setAttempt] = useState(0);
useEffect(() => { useEffect(() => {
setMessages([]); setMessages([]);
@ -61,16 +70,12 @@ export function useMessageStream(sinceMinutes: number): {
pending.push(m); pending.push(m);
if (frame === null) frame = requestAnimationFrame(flush); if (frame === null) frame = requestAnimationFrame(flush);
}; };
es.onerror = () => { es.onerror = () => setConnection(connectionAfterError(es.readyState));
setConnection(
es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed",
);
};
return () => { return () => {
drop(); drop();
es.close(); es.close();
}; };
}, [sinceMinutes]); }, [sinceMinutes, attempt]);
return { messages, connection }; return { messages, connection, reconnect: () => setAttempt((n) => n + 1) };
} }

View 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";
}

View 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");
});