feat(browser): self-heal sessions after camofox restarts (BR-3)

- Any session operation that hits 404/410 (or finds no open tab) reconciles
  tabs; if none survive it re-injects the account cookies (BR-2), opens a new
  tab under the same userId/sessionKey, returns to the recorded URL (site home
  otherwise), marks old tabs lost and retries the operation once. Failure
  returns 503 browser_reconnect_failed with the reason, with a 10s backoff.
- Restart detection: umt plugin GET /umt/instance (fallback /health fields);
  a changed instance id marks every open session needsReconnect.
- POST /browser/sessions/{id}/reconnect (idempotent); session view exposes
  needsReconnect and reconnect {status, trigger, at, reason, humanNotice}.
- Controller and lease are kept; reconnecting during takeover asks the human
  to double-check the reloaded page.
- Web: screenshot and desktop modes show "reconnecting", auto-reconnect once,
  pause polling, offer a reconnect button with the reason on failure; desktop
  mode rebuilds the Selkies connection after a reconnect.
- Mock camofox restart(), tests, smoke step and runbook section 10.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 17:29:11 +08:00
1 parent da24380870
commit 838aea3ebd
19 files changed
+977 -45

No files matched your search

@@ -10,6 +10,7 @@ import { Button } from "~/components/ui/button.tsx";
import { apiData, errorMessage } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
import { CONTROLLER_LABEL, credentialBadge, holderName, waitingText } from "./coords.ts";
import { ReconnectPanel, ReloadNotice, useAutoReconnect } from "./reconnect.tsx";
import { SiteOriginView } from "./site-origin-view.tsx";
// 原站视图桌面模式(docs/design/webtop-remote-browser.md §5.7):iframe 嵌浏览器桌面(Selkies),
@@ -99,6 +100,25 @@ function DesktopView({
const [reloadKey, setReloadKey] = useState(0);
const [loaded, setLoaded] = useState(false);
const reloadDesktop = () => {
setLoaded(false);
setReloadKey((k) => k + 1);
};
// BR-3:浏览器服务(同一容器里的 Selkies)重启过 → 自动重连会话一次,成功后重建桌面连接
const rc = useAutoReconnect(site.id, s, false, reloadDesktop);
// 别处(Agent 操作、截图)触发的重连成功后也重建桌面连接
const reconnectedAt = s?.reconnect?.status === "ok" ? s.reconnect.at : null;
const prevReconnect = useRef(reconnectedAt);
useEffect(() => {
if (prevReconnect.current !== reconnectedAt) {
prevReconnect.current = reconnectedAt;
if (reconnectedAt) {
setLoaded(false);
setReloadKey((k) => k + 1);
}
}
}, [reconnectedAt]);
// 控制权变化(接管 / 交还 / 过期)后重连,Selkies 按新口令认证;服务端也会主动断开旧连接
const role = status.role;
const prevRole = useRef(role);
@@ -117,11 +137,16 @@ function DesktopView({
const waiting = waitingText(s.waitingReason);
const cred = credentialBadge(s.credential);
const leaseHolder = s.lease ? holderName(s.lease.holder) : null;
const conn: { tone: StatusTone; text: string } = loaded
? mine
? { tone: "success", text: "已连接,可操作" }
: { tone: "success", text: "已连接,只读" }
: { tone: "neutral", text: "连接中" };
const conn: { tone: StatusTone; text: string } =
rc.state.phase === "reconnecting"
? { tone: "warning", text: "正在重新连接" }
: rc.state.phase === "failed"
? { tone: "destructive", text: "连接中断" }
: loaded
? mine
? { tone: "success", text: "已连接,可操作" }
: { tone: "success", text: "已连接,只读" }
: { tone: "neutral", text: "连接中" };
return (
<div className="space-y-3">
@@ -167,14 +192,7 @@ function DesktopView({
接管
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => {
setLoaded(false);
setReloadKey((k) => k + 1);
}}
>
<Button variant="outline" size="sm" disabled={rc.busy} onClick={reloadDesktop}>
<RotateCw />
重新连接
</Button>
@@ -194,6 +212,8 @@ function DesktopView({
</div>
</div>
<ReloadNotice session={s} />
<p className="text-caption text-muted-foreground">
{mine
? "直接在画面里点击和输入。中文、日文用本机输入法输入;复制粘贴用 Ctrl+C / Ctrl+V。"
@@ -201,6 +221,11 @@ function DesktopView({
</p>
<div className="pg-slab relative min-w-0 overflow-hidden rounded-xl">
{rc.state.phase !== "idle" ? (
<div className="absolute inset-0 z-10 grid place-items-center bg-background/85">
<ReconnectPanel state={rc.state} onRetry={rc.retry} />
</div>
) : null}
<iframe
key={reloadKey}
title="浏览器桌面"