Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion design/browser-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Two internal layers, added additively. Neither is exported from `agents/browser`
- Creation options (`keepAliveMs`, `recording`, `guardrails`) are durable: reapplied to every replacement session. `keep_alive` defaults to the 600-second platform maximum.
- There is no host-side sweep. Browser Run's `keep_alive` reclaims an idle browser; the next `resolve` finds it dead (404/410 on the liveness probe) and replaces it.
- `close`, and a `resolve` that finds its browser dead, retire the name under its lock: the record moves to a permanent `browser:retired:<name>` marker outside the named-session keyspace. The marker is what makes the next `resolve` report `restarted: true`, and it is read under the commit lock, so a name created and closed while another create is in flight still counts as used. `close` then deletes the Browser Run session best-effort; if that fails, `keep_alive` reclaims it.
- `connect(name)` attaches a CDP socket to the resolved browser. If the browser expires between the liveness probe and the WebSocket upgrade (404/410 on the upgrade), it retires the record and resolves once more, returning the replacement with `restarted: true`.
- `connect(name)` attaches a CDP socket to the resolved browser. If the browser expires between the liveness probe and the WebSocket upgrade (404/410 on the upgrade), it retires the record and resolves once more, returning the replacement with `restarted: true`. The returned `setActiveTarget(targetId)` records the agent's current tab on the record (`activeTargetId`) only while the record still holds that browser; a replacement starts with no active tab. The returned `spec()` reads the CDP protocol from that browser.
- CDP commands on sockets from `connect`, reattaches, and Live View links refresh the record's `updatedAt`, throttled to once per 60 seconds (capped at half `keep_alive`). A refresh never recreates a record that was closed or replaced in the meantime.
- Store locks are held around storage reads and writes only — never across Browser Run network calls. Concurrent resolvers commit first-wins, and the loser deletes its redundant browser.
- `openOneShotBrowserSession` covers store-less create-and-close use, including Kitesurf (which is connection-scoped, so it is one-shot only). Engine options are discriminated unions: choosing `browser: "kitesurf"` removes the Chromium-only options (`guardrails`, `keepAliveMs`, `recording`) at the type level, backed by one runtime guard per entry point for plain-JS callers.
Expand Down
57 changes: 53 additions & 4 deletions packages/agents/src/browser/session-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
BrowserSessionStore,
StoredBrowserSession
} from "./session-manager";
import { loadCdpSpec, type SearchableCdpSpec } from "./spec";

/**
* Browser Run's server-side `keep_alive` maximum (600 seconds). Named
Expand Down Expand Up @@ -98,14 +99,38 @@ export interface ResolvedBrowserSession {
restarted: boolean;
createdAt: number;
updatedAt: number;
/**
* The tab the agent last worked in, when one was recorded for this
* browser. Absent on a browser this resolution created; a replacement a
* concurrent caller already created and used may carry one.
*/
activeTargetId?: string;
}

export interface ConnectedBrowserSession {
name: string;
sessionId: string;
/**
* `true` when the browser this caller resolved is gone, even if a
* concurrent caller already replaced it. See
* {@link ResolvedBrowserSession.restarted}.
*/
restarted: boolean;
/** See {@link ResolvedBrowserSession.activeTargetId}. */
activeTargetId?: string;
/** Closing this socket does NOT delete the named session. */
cdp: CdpSession;
/**
* Record the tab the agent is working in (or clear it with `undefined`)
* on this session's record. Never resurrects: returns `false` when the
* session was closed or replaced since this connection resolved it.
*/
setActiveTarget(targetId: string | undefined): Promise<boolean>;
/**
* The Chrome DevTools Protocol description this browser serves, read from
* the browser itself (cached per binding).
*/
spec(): Promise<SearchableCdpSpec>;
}

/** One-shot session options for the default Chromium engine. */
Expand Down Expand Up @@ -280,7 +305,16 @@ export class NamedBrowserSessions {
name,
sessionId: resolved.sessionId,
restarted: resolved.restarted,
cdp
activeTargetId: resolved.activeTargetId,
Comment thread
ben-reitz marked this conversation as resolved.
cdp,
setActiveTarget: (targetId) =>
this.#update(key, resolved.sessionId, (current) => ({
...current,
activeTargetId: targetId,
updatedAt: Date.now()
})),
spec: () =>
loadCdpSpec({ browser: this.#browser, sessionId: resolved.sessionId })
Comment on lines +316 to +317

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Connector handoff differs from PR description

The PR description promises browser getters, but the final code exposes spec() on connected sessions instead. Confirm the planned connector consumes spec() and needs no direct binding access.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

};
}

Expand Down Expand Up @@ -331,14 +365,29 @@ export class NamedBrowserSessions {
}

/** Refresh `updatedAt` for an actively used session — never resurrects. */
async #touch(key: string, sessionId: string): Promise<boolean> {
#touch(key: string, sessionId: string): Promise<boolean> {
return this.#update(key, sessionId, (current) => ({
...current,
updatedAt: Date.now()
}));
}

/**
* Rewrite the record only while it still holds `sessionId`: a replaced or
* retired entry is never resurrected.
*/
async #update(
key: string,
sessionId: string,
change: (current: StoredBrowserSession) => StoredBrowserSession
): Promise<boolean> {
const lock = await this.#store.acquireLock(key);
try {
const current = await this.#store.get(key);
if (current?.sessionId !== sessionId) {
return false; // replaced or gone — activity no longer counts
return false; // replaced or gone — the caller's view is stale
}
await this.#store.set(key, { ...current, updatedAt: Date.now() });
await this.#store.set(key, change(current));
return true;
} finally {
await lock.release();
Expand Down
7 changes: 7 additions & 0 deletions packages/agents/src/browser/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ export interface StoredBrowserSession {
* loudly instead of silently continuing in a fresh browser.
*/
closedAt?: number;
/**
* Named sessions only: the tab (CDP `targetId`) the agent last worked in,
* so `sessionId: "active"` in a later execution lands on the same page.
* CDP session ids die with each connection; target ids live as long as
* the tab.
*/
activeTargetId?: string;
}

export interface BrowserSessionLock {
Expand Down
58 changes: 58 additions & 0 deletions packages/agents/src/tests/browser-session-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ function createFakeBrowser(options?: {
const status = deleteStatuses.shift();
return new Response(null, { status: status ?? 204 });
}
if (url.endsWith("/json/protocol")) {
return Response.json({
domains: [{ domain: "Page", commands: [{ name: "navigate" }] }]
});
}
if (url.endsWith("/json/list")) {
const status = listStatuses.shift();
if (status) return new Response(null, { status });
Expand Down Expand Up @@ -433,6 +438,25 @@ describe("NamedBrowserSessions.connect", () => {
expect(deletes(requests, "session-1")).toHaveLength(0);
});

it("reads the CDP spec from the connected browser, not a new one", async () => {
const { browser, requests } = createFakeBrowser();
const sessions = new NamedBrowserSessions({
browser,
store: new MemorySessionStore()
});

const connected = await sessions.connect("work");
const spec = await connected.spec();

expect(spec.domains[0].commands[0].method).toBe("Page.navigate");
expect(
requests.filter((r) => r.url.endsWith("/json/protocol")).map((r) => r.url)
).toEqual([
"https://localhost/v1/devtools/browser/session-1/json/protocol"
]);
expect(creates(requests)).toHaveLength(1); // only the named browser
});

it("replaces a browser that expires between the probe and the upgrade", async () => {
// The first upgrade finds the just-resolved browser gone (410).
const { browser, requests } = createFakeBrowser({ upgradeStatuses: [410] });
Expand All @@ -459,6 +483,40 @@ describe("NamedBrowserSessions.connect", () => {
await expect(sessions.connect()).rejects.toThrow(/\(502\)/);
});

it("records the active tab on the session record", async () => {
const { browser } = createFakeBrowser();
const store = new MemorySessionStore();
const sessions = new NamedBrowserSessions({ browser, store });

const first = await sessions.connect("work");
expect(first.activeTargetId).toBeUndefined();
expect(await first.setActiveTarget("target-7")).toBe(true);

const second = await sessions.connect("work");
expect(second.activeTargetId).toBe("target-7");
expect(await second.setActiveTarget(undefined)).toBe(true);
expect(
store.sessions.get(namedBrowserSessionKey("work"))?.activeTargetId
).toBeUndefined();
});

it("never resurrects a closed session when recording the active tab", async () => {
const { browser } = createFakeBrowser();
const store = new MemorySessionStore();
const sessions = new NamedBrowserSessions({ browser, store });

const connected = await sessions.connect("work");
await sessions.close("work");

expect(await connected.setActiveTarget("target-1")).toBe(false);
expect(store.sessions.has(namedBrowserSessionKey("work"))).toBe(false);

// A replacement browser starts with no active tab.
const replaced = await sessions.connect("work");
expect(replaced.restarted).toBe(true);
expect(replaced.activeTargetId).toBeUndefined();
});

it("CDP activity refreshes the record's updatedAt", async () => {
const { browser } = createFakeBrowser();
const store = new MemorySessionStore();
Expand Down
Loading