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

@@ -35,6 +35,7 @@ const CODE = {
bad_request: "bad_request",
upstream: "upstream_unavailable",
rate_limited: "rate_limited",
reconnect_failed: "browser_reconnect_failed",
} as const;
function route<D extends EndpointDef>(app: ModuleApp, def: D, handler: (input: Input<D>) => Promise<unknown>) {
@@ -284,6 +285,12 @@ export function registerBrowserRoutes(app: ModuleApp, svc: () => BrowserService)
};
});
// BR-3:浏览器服务重启后重新连接(幂等;原站视图收到 404/410/503 时调用一次)。不要求接管:控制权不变
route(app, B.reconnect, async ({ params }) => {
await svc().reconnectIfNeeded(params.id);
return svc().view(params.id);
});
route(app, B.acquire, async ({ params, body, c }) => {
const lease = await svc().acquire(params.id, human(c).holder, body.reason, body.ttlMs);
return { lease, session: await svc().view(params.id) };