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:
1 parent
919dc02200
commit
1fcfcac885
13 files changed
+523
-12
No files matched your search
@@ -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 浏览器服务重启后自愈 ----------
|
||||
|
||||
/** 各浏览器服务最近一次读到的实例标识(节流缓存;只在进程内) */
|
||||
|
||||
Reference in new issue
Block a user