feat(browser): keep sessions alive during desktop, takeover and agent use (AG-4)

camofox reaps a tab after TAB_INACTIVITY_MS (5 min) without API tool calls;
desktop-mode input goes straight to Selkies and screenshots don't count, so
tabs disappeared under the user during takeover.

- BrowserKeepalive (in-process): sessions held by an open desktop WebSocket
  (ref-counted), a human takeover lease, or a running/queued agent run that
  used browser.* get a keepalive every BROWSER_KEEPALIVE_MS (default 120s,
  0 = off). Holds are re-checked each round; release/lease expiry/agent end/
  session close stop it and camofox reaps as usual.
- Keepalive call: camofox 1.17 has no keepalive/touch endpoint and /stats does
  not bump toolCalls; use GET /tabs/{id}/downloads without includeData/consume
  (read-only metadata). Goes through BrowserService.run so restarts or reaped
  tabs self-heal via BR-3 reconnect; no lastAutomationAt, no control needed.
- Runbook browser.md §12, deploy env table, compose env; tests against the
  testkit mock camofox.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 18:05:12 +08:00
1 parent 919dc02200
commit 1fcfcac885
13 files changed
+523 -12

No files matched your search

+77 -3
View File
@@ -59,12 +59,18 @@ import {
} from "../sources-identity/service/credentials.ts";
import { registerBrowserIdentityReader } from "../sources-identity/service/user-agent.ts";
import type { ServerContext } from "../types.ts";
import { AGENT_KEEPALIVE_MAX_IDLE_MS, BROWSER_KEEPALIVE_MS, BrowserKeepalive } from "./keepalive.ts";
// C1-E 浏览器服务层:BrowserProvider(camofox)之上的会话/标签/控制权持久化(数据库为准)、
// 站点域名白名单 + 站点限流、凭证注入、接管状态机、下载产物暂存核验。
// 适配器只是内存镜像:服务重启后按数据库恢复,旧元素引用一律失效。
export type Actor = { kind: "automation" | "human"; holder: string };
export type Actor = {
kind: "automation" | "human";
holder: string;
/** 智能体会话发起的动作(AG-4):会话还在跑期间保活这个浏览器会话 */
agentRunId?: string;
};
export interface BrowserModuleOptions {
/** camofox 请求用的 fetch(测试指向 mock) */
@@ -79,6 +85,8 @@ export interface BrowserModuleOptions {
evidenceDir?: string;
/** 坐标动作之后等页面稳定再截图的毫秒数;缺省 400 */
settleMs?: number;
/** 保活间隔(AG-4);缺省 BROWSER_KEEPALIVE_MS 配置(120 秒),0 = 关闭 */
keepaliveMs?: number;
/** 测试替换 provider 实现 */
providerFactory?: (row: BrowserProviderRow, deps: ProviderDeps) => BrowserProvider;
}
@@ -194,12 +202,21 @@ export class BrowserService {
private readonly providers = new Map<string, { sig: string; provider: BrowserProvider }>();
private envSync: Promise<void> | undefined;
readonly now: () => Date;
/** AG-4 保活:桌面 / 人工接管 / 智能体正在用的会话定期做一次只读调用 */
readonly keepalive: BrowserKeepalive;
constructor(
readonly ctx: ServerContext,
readonly opts: BrowserModuleOptions = {},
) {
this.now = opts.now ?? (() => new Date());
this.keepalive = new BrowserKeepalive(
{ keepAlive: (id) => this.keepAlive(id) },
{
intervalMs: opts.keepaliveMs ?? ctx.config?.BROWSER_KEEPALIVE_MS ?? BROWSER_KEEPALIVE_MS,
log: (level, msg, extra) => ctx.logger[level]({ component: "browser", ...extra }, msg),
},
);
// refresh-login 发现 Cookie 失效时从浏览器 profile 读回一次(sources-identity 经钩子调用,不直接依赖本模块)
registerBrowserCookieReader(ctx, (siteId, accountId) => this.readProfileCookie(siteId, accountId));
// 站点请求 UA 跟随浏览器固定身份(BR-4):sources-identity 经钩子取
@@ -1081,6 +1098,7 @@ export class BrowserService {
}
async close(id: string, reason = "closed"): Promise<void> {
this.keepalive.drop(id);
const row = await this.sessionRow(id);
if (row.controller === "closed") return;
const prow = await this.ctx.db
@@ -1235,9 +1253,38 @@ export class BrowserService {
.execute();
return l;
});
// 接管期间人的操作不经上游 API(桌面模式),由 app 保活;交还 / 租约过期 / 会话关闭后停
this.keepalive.ensure(id, "takeover", "takeover", () => this.humanHolding(id));
return toLease(lease);
}
/** 会话仍在人工接管中(租约有效) */
private async humanHolding(id: string): Promise<boolean> {
const row = await this.sessionRow(id).catch(() => null);
if (row?.controller !== "human") return false;
const lease = await this.activeLease(id);
return lease?.holder_kind === "human" && new Date(lease.expires_at).getTime() > this.now().getTime();
}
/**
* 保活一次(AG-4):对当前标签页做最轻的只读调用。不要求控制权、不记自动化活动;浏览器重启过 / 标签页已被回收时
* 按 BR-3 自愈重连(注入 Cookie、回到原地址)。会话已关闭返回 false;上游不支持保活时只做实例检查与对账。
*/
async keepAlive(id: string): Promise<boolean> {
const row = await this.sessionRow(id).catch(() => null);
if (!row || row.controller === "closed") return false;
await this.run(
id,
{ kind: "automation", holder: "system:keepalive" },
async (p, ref) => {
if (p.keepAlive) await p.keepAlive(ref);
else await p.reconcileTabs(id);
},
{ write: false, persist: false, background: true },
);
return true;
}
/** 交还:回到自动化(或留在等待处理);Agent 恢复前必须重新读取页面,不复用旧引用、不重放旧点击 */
async release(
id: string,
@@ -1248,6 +1295,7 @@ export class BrowserService {
if (lease?.holder_kind !== "human" || (opts.leaseId && lease.id !== opts.leaseId))
throw new BrowserServiceError("conflict", "没有可交还的人工接管");
const at = this.now();
this.keepalive.unhold(id, "takeover");
const { provider } = await this.loaded(row);
provider.setController(id, opts.to);
const rs = obj(row.recovery_state) as RecoveryState;
@@ -1296,7 +1344,14 @@ export class BrowserService {
ref: TabRef,
ctx: { row: BrowserSessionRow; site: SiteRow; budget: NavigationBudget },
) => Promise<T>,
opts: { tabId?: string; write?: boolean; persist?: boolean; patch?: (out: T) => RecoveryState } = {},
opts: {
tabId?: string;
write?: boolean;
persist?: boolean;
patch?: (out: T) => RecoveryState;
/** 后台维护调用(保活):不记自动化活动 */
background?: boolean;
} = {},
): Promise<T> {
let row = await this.expireLeases(await this.sessionRow(id));
if (row.controller === "closed") throw new BrowserServiceError("conflict", "会话已关闭");
@@ -1366,7 +1421,9 @@ export class BrowserService {
throw fromBrowserError(e2);
}
}
const automation: RecoveryState = actor.kind === "automation" ? { lastAutomationAt: this.now().toISOString() } : {};
const automation: RecoveryState =
actor.kind === "automation" && !opts.background ? { lastAutomationAt: this.now().toISOString() } : {};
if (actor.agentRunId) this.holdForAgent(id, actor.agentRunId);
if (opts.persist !== false)
await this.persist(id, loaded.provider, { ...(opts.patch?.(out) ?? {}), ...automation });
else if (automation.lastAutomationAt) await this.patchRecovery(await this.sessionRow(id), automation);
@@ -1374,6 +1431,23 @@ export class BrowserService {
return out;
}
/** 智能体会话最后一次用各浏览器会话的时间(保活复查用) */
private readonly agentUse = new Map<string, number>();
/** 智能体会话在用这个浏览器会话:会话还在排队 / 运行期间保活(最后一次使用后最多 AGENT_KEEPALIVE_MAX_IDLE_MS) */
holdForAgent(sessionId: string, runId: string): void {
const key = `agent:${runId}`;
this.agentUse.set(`${sessionId}|${runId}`, Date.now());
this.keepalive.ensure(sessionId, key, "agent", async () => {
const last = this.agentUse.get(`${sessionId}|${runId}`) ?? 0;
const r = await this.ctx.db.selectFrom("agent_run").select("status").where("id", "=", runId).executeTakeFirst();
const ok =
!!r && (r.status === "running" || r.status === "queued") && Date.now() - last < AGENT_KEEPALIVE_MAX_IDLE_MS;
if (!ok) this.agentUse.delete(`${sessionId}|${runId}`);
return ok;
});
}
// ---------- BR-3 浏览器服务重启后自愈 ----------
/** 各浏览器服务最近一次读到的实例标识(节流缓存;只在进程内) */