Skip to content

feat(extension): attach the resident runtime on stagehand.init - #2803

Open
miguelg719 wants to merge 5 commits into
feat/resident-main-b-target-safetyfrom
feat/resident-main-c-gateway-runtime
Open

feat(extension): attach the resident runtime on stagehand.init#2803
miguelg719 wants to merge 5 commits into
feat/resident-main-b-target-safetyfrom
feat/resident-main-c-gateway-runtime

Conversation

@miguelg719

@miguelg719 miguelg719 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Stack C/5: -a-transport-b-target-safetyfeat/resident-main-c-gateway-runtime (this PR) → -d-artifacts-e-browserbase-opt-in. Supersedes #2465; redesigned for main (#2752 reattach, #2588 bootstrapLogger, #2581 page CDP subscriptions).

stagehand.init without browserCdpUrl now attaches the resident runtime: resolve the 9224 proxy (stack A) → connect → BrowserContext.create({ restrictToWebTargets, deferPageInstrumentation, ensureInitialPage: false }) (stack B) → initialize. Local/custom browserCdpUrl disables resident bootstrap (unchanged behavior).

  • ResidentRuntimeLifecycle: idle → connecting → bootstrapping → ready → reconnecting/failed/closed; generation guards; reconnect delays [100, 250, 500, 1000, 2000] ms consumed by the init-triggered bootstrap before it rejects; recovery on the next init after budget exhaustion; reconnect failures report phase reconnecting.
  • Reattach-aware (Allow SDKs to reattach to Stagehand runtimes #2752): a second stagehand.init on an initialized runtime refreshes config (ready) or waits for the in-flight restore (reconnecting/failed) instead of rejecting; browserCdpUrl on an already-initialized runtime is ignored with a log.
  • Factory/replace take an options object { bootstrapLogger?, lifecycle? }; generation-guarded replaceBrowserConnection keeps disposeAllPageEventSubscriptions() and responseHandles.clear(); page-scoped state and page CDP subscriptions are replayed after a resident reconnect.
  • Hardening found in review: BrowserWebSocketTransport.send throws on non-OPEN sockets and CdpConnection rejects sends when disconnected (previously silently dropped → permanent lifecycle wedge); per-generation operation queue; a pending init follows reconnect generations.
  • Found on the devbox E2E: replaceBrowserConnection nulls the session until the new one is up, so an RPC in that window failed with "loopback CDP is not configured". RPCRouter.route now awaits an in-flight replacement (10 s budget; stagehand.init/close, stagehand.metrics, context.close exempt) and raises STAGEHAND_BROWSER_SESSION_UNAVAILABLE on timeout. Non-resident flows unaffected.
  • Structured transport errors: RESIDENT_PROXY_UNAVAILABLE / RESIDENT_PROXY_NOT_READY / RESIDENT_PROXY_FORBIDDEN, keyed on pid2's JSON error bodies.

Open product decision (not changed here): stagehand.close() is runtime-wide, so a reattached client closing takes the browser away from the first client.

Validation

pnpm check; extension tests (414, incl. lifecycle tests with controllable session/transport fakes and browser-session-wait.test.ts); protocol unit + real-Chrome browser tests; sdk-ts unit — green. Devbox E2E: init ≈ 35–40 ms after session create through 9224, SIGTERM drain survives, reattach 0/36 failures.


Summary by cubic

Attaches the resident runtime on stagehand.init when browserCdpUrl is absent and the worker is configured, improving resilience and reattach behavior; custom-CDP flows are unchanged. Hardens CDP transport and unwedges init/RPCs across reconnects by following generation replacements.

  • New behavior: stagehand.init without browserCdpUrl resolves the loopback proxy, creates a restricted BrowserContext, restores instrumentation, and publishes ready; a second stagehand.init refreshes config or waits during reconnecting instead of rejecting. Custom browserCdpUrl disables resident bootstrap; providing it after initialization is logged and ignored. Public/unconfigured builds do not auto-bootstrap. Pending inits now follow reconnect generations and recover after budget exhaustion on the next init.
  • Reconnects: bounded delays [100, 250, 500, 1000, 2000] ms; failure phase reports connecting or reconnecting; structured proxy errors (RESIDENT_PROXY_UNAVAILABLE|NOT_READY|FORBIDDEN) are surfaced with codes.
  • RPC router: waits up to 10s for an in-flight session replacement; exempt methods are stagehand.init, stagehand.close, stagehand.metrics, and context.close; times out with BrowserSessionUnavailableError (STAGEHAND_BROWSER_SESSION_UNAVAILABLE).
  • Transport hardening: BrowserWebSocketTransport.send throws on non-open sockets; CdpConnection rejects sends when disconnected and converts synchronous transport errors into rejections, preventing lifecycle wedges.
  • Marker and SDK: the worker marker now includes state, connected, and timings; the SDK reads a loose envelope while validating the strict runtime descriptor.
  • Restore logic: page-scoped state and page CDP event subscriptions survive reconnects; vanished pages are pruned; stale restores cannot affect newer generations. Known limitation: stagehand.close() remains runtime-wide.

Migration

  • Controller and router:
    • StagehandController option initialize now receives (params, logger).
    • RPCRouter option closeContext is available; session-independent methods are enforced.
  • Browser session wiring:
    • StagehandBrowserSessionFactory and replaceBrowserConnection now take { bootstrapLogger?, lifecycle? }.
  • Error handling:
    • Handle BrowserSessionUnavailableError and ResidentBrowserProxyError codes.
    • Expect BrowserWebSocketTransport.send and CdpConnection.send to throw/reject when not connected.

Written for commit 1c1d6dd. Summary will update on new commits.

Review in cubic

@changeset-bot

changeset-bot Bot commented Aug 22, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 1c1d6dd

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

6 issues found across 22 files

Confidence score: 2/5

  • packages/extension/tests/resident-runtime.test.ts locks in ResidentRuntimeLifecycle exposing a URL-bearing browser-session failure, which can leak transport or session details to users; assert a typed, sanitized error instead.
  • packages/extension/understudy/cdp.ts preserves arbitrary transport exception text in CDP send rejection paths, exposing internal failure details; use a dedicated typed CDP error with a safe public message.
  • packages/extension/runtime.ts raises the generation-guard failure as a generic Error, weakening the typed-error contract and making consistent sanitization harder; replace it with the appropriate dedicated error class. packages/extension/service-worker-lifecycle/resident-browser-proxy.ts has the same exposure risk when nested version-fetch text reaches stagehand.init, so sanitize that public error too.
  • The test updates leave regressions under-checked: packages/extension/tests/resident-browser-proxy.test.ts no longer asserts the configured timeout duration, and packages/extension/tests/runtime-descriptor.test.ts uses {} matching that does not prove timings is empty; restore exact assertions.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/extension/understudy/cdp.ts">

<violation number="1" location="packages/extension/understudy/cdp.ts:220">
P2: Custom agent: **Exception and error message sanitization**

These new CDP rejection paths expose generic errors and, in the send catches, preserve arbitrary transport exception text. Use a dedicated typed CDP error with a fixed sanitized message for disconnected and transport-send failures.</violation>
</file>

<file name="packages/extension/tests/resident-runtime.test.ts">

<violation number="1" location="packages/extension/tests/resident-runtime.test.ts:334">
P1: Custom agent: **Exception and error message sanitization**

When the browser-session factory fails with a URL-bearing message, `ResidentRuntimeLifecycle` rethrows the generic error, and this test locks in exposing `private.internal` and its query secret. Wrap terminal bootstrap failures in a typed sanitized error before propagating them, while retaining only safe failure metadata in the marker.</violation>
</file>

<file name="packages/extension/runtime.ts">

<violation number="1" location="packages/extension/runtime.ts:428">
P2: Custom agent: **Exception and error message sanitization**

The new generation-guard failure is raised as a generic `Error`, which violates the requirement that user-raised failures use individually typed error classes. Define a dedicated sanitized supersession error in `errors.ts` and throw it from both replacement checks and `assertBrowserSessionCurrent`.</violation>
</file>

<file name="packages/extension/tests/resident-browser-proxy.test.ts">

<violation number="1" location="packages/extension/tests/resident-browser-proxy.test.ts:143">
P2: The timeout test no longer verifies the configured duration in the failure message, so regressions that lose or change `timed out after 1ms` will pass. Keep the duration assertion alongside the new error-code assertion.</violation>
</file>

<file name="packages/extension/tests/runtime-descriptor.test.ts">

<violation number="1" location="packages/extension/tests/runtime-descriptor.test.ts:24">
P2: The `toMatchObject` matcher does not verify that `timings` is empty, because `{}` matches every nested object. Assert `scope.__stagehand_runtime?.timings` with `toStrictEqual({})` so unexpected initialization timings are caught.

(Based on your team's feedback about adding focused regression tests for new behavior.)</violation>
</file>

<file name="packages/extension/service-worker-lifecycle/resident-browser-proxy.ts">

<violation number="1" location="packages/extension/service-worker-lifecycle/resident-browser-proxy.ts:57">
P2: When the version fetch rejects, this interpolates the nested fetch message into the error returned by `stagehand.init`. The RPC layer forwards that message unchanged, exposing transport details and making the public error unstable; keep a fixed proxy-unavailable message and retain diagnostics only internally.

(Based on your team's feedback about sanitizing CDP discovery failures.)</violation>
</file>
Architecture diagram
sequenceDiagram
    participant SDK as Stagehand SDK (TS)
    participant Router as RPC Router (Service Worker)
    participant Life as ResidentRuntimeLifecycle
    participant RT as StagehandRuntime
    participant Proxy as Resident Browser Proxy
    participant Session as Browser Session (CDP)

    Note over SDK,Session: stagehand.init() Flow

    SDK->>Router: stagehand.init(params)
    
    alt NEW: Resident Mode (no browserCdpUrl provided)
        Router->>Life: NEW: initialize(params)
        Life->>Life: Set state to "connecting"
        Life->>Proxy: resolveResidentBrowserWebSocketUrl()
        Proxy-->>Life: Return WebSocket URL
        
        Life->>RT: NEW: replaceBrowserConnection(options)
        RT->>Session: NEW: BrowserContext.create({ restrictToWebTargets: true, ... })
        Session-->>RT: Connection established
        
        RT->>RT: NEW: restoreInitializedBrowserSession()
        Note right of RT: Replays init scripts, headers, and viewports
        
        RT-->>Life: Ready
        Life->>Life: Set state to "ready"
    else CHANGED: Custom CDP (browserCdpUrl provided)
        Router->>Life: NEW: initializeWithBrowserCdpUrl(params)
        Life->>RT: replaceBrowserConnection({ cdpUrl })
        RT->>Session: BrowserContext.create(defaults)
    end
    
    RT-->>Router: Init result
    Router-->>SDK: 200 OK

    Note over SDK,Session: NEW: RPC Guard & Reconnect Resilience

    SDK->>Router: context.pages()
    
    Router->>Router: Check if method is SESSION_INDEPENDENT
    
    opt CHANGED: Session Disconnected / Reconnecting
        Router->>RT: NEW: waitForBrowserSession(timeout: 10s)
        RT->>Life: NEW: pendingBrowserSessionRecovery()
        
        alt Reconnect Success
            Life-->>RT: Session ready
            RT-->>Router: Resume RPC
        else Reconnect Failure / Timeout
            RT-->>Router: Throw BrowserSessionUnavailableError
            Router-->>SDK: Error: STAGEHAND_BROWSER_SESSION_UNAVAILABLE
        end
    end

    Router->>RT: contextPages()
    RT->>Session: CDP: Target.getTargets
    Session-->>RT: list of targets
    RT-->>Router: pages metadata
    Router-->>SDK: result

    Note over Life,Session: Resident Reconnect Loop

    Session-xRT: CHANGED: Connection dropped
    RT->>Life: Trigger onDisconnected
    Life->>Life: NEW: Schedule reconnect [100ms, 250ms, ..., 2000ms]
    
    loop Reconnect Strategy
        Life->>Proxy: resolveResidentBrowserWebSocketUrl()
        alt Proxy Error (e.g., 503)
            Proxy-->>Life: RESIDENT_PROXY_NOT_READY
            Life->>Life: Wait for next delay slot
        else Proxy OK
            Life->>RT: replaceBrowserConnection()
            RT->>Session: Re-establish CDP
            RT->>RT: restoreInitializedBrowserSession()
        end
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

});
const lifecycle = new ResidentRuntimeLifecycle(runtime, residentOptions());

await expect(lifecycle.bootstrap()).rejects.toThrow("private.internal");

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.

P1: Custom agent: Exception and error message sanitization

When the browser-session factory fails with a URL-bearing message, ResidentRuntimeLifecycle rethrows the generic error, and this test locks in exposing private.internal and its query secret. Wrap terminal bootstrap failures in a typed sanitized error before propagating them, while retaining only safe failure metadata in the marker.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/tests/resident-runtime.test.ts, line 334:

<comment>When the browser-session factory fails with a URL-bearing message, `ResidentRuntimeLifecycle` rethrows the generic error, and this test locks in exposing `private.internal` and its query secret. Wrap terminal bootstrap failures in a typed sanitized error before propagating them, while retaining only safe failure metadata in the marker.</comment>

<file context>
@@ -0,0 +1,981 @@
+    });
+    const lifecycle = new ResidentRuntimeLifecycle(runtime, residentOptions());
+
+    await expect(lifecycle.bootstrap()).rejects.toThrow("private.internal");
+    expect(lifecycle.marker).toMatchObject({
+      state: "failed",
</file context>

Comment thread packages/extension/service-worker-lifecycle/resident-runtime.ts
params: JSON.stringify(params ?? {}),
});
if (!this.transport.connected) {
return Promise.reject(new Error(`CDP connection is closed (method=${method})`));

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.

P2: Custom agent: Exception and error message sanitization

These new CDP rejection paths expose generic errors and, in the send catches, preserve arbitrary transport exception text. Use a dedicated typed CDP error with a fixed sanitized message for disconnected and transport-send failures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/understudy/cdp.ts, line 220:

<comment>These new CDP rejection paths expose generic errors and, in the send catches, preserve arbitrary transport exception text. Use a dedicated typed CDP error with a fixed sanitized message for disconnected and transport-send failures.</comment>

<file context>
@@ -216,6 +216,9 @@ export class CdpConnection implements CDPSessionLike {
       params: JSON.stringify(params ?? {}),
     });
+    if (!this.transport.connected) {
+      return Promise.reject(new Error(`CDP connection is closed (method=${method})`));
+    }
     const p = new Promise<unknown>((resolve, reject) => {
</file context>

bootstrapLogger,
);
if (generation !== this.browserSessionGeneration) {
throw new Error("Stagehand browser session bootstrap was superseded");

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.

P2: Custom agent: Exception and error message sanitization

The new generation-guard failure is raised as a generic Error, which violates the requirement that user-raised failures use individually typed error classes. Define a dedicated sanitized supersession error in errors.ts and throw it from both replacement checks and assertBrowserSessionCurrent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/runtime.ts, line 428:

<comment>The new generation-guard failure is raised as a generic `Error`, which violates the requirement that user-raised failures use individually typed error classes. Define a dedicated sanitized supersession error in `errors.ts` and throw it from both replacement checks and `assertBrowserSessionCurrent`.</comment>

<file context>
@@ -313,27 +349,92 @@ export class StagehandRuntime {
-        bootstrapLogger,
-      );
+      if (generation !== this.browserSessionGeneration) {
+        throw new Error("Stagehand browser session bootstrap was superseded");
+      }
+      browserSession = await this.adapters.browserSessionFactory(cdpUrl, this.logger, options);
</file context>

await expect(
resolveResidentBrowserWebSocketUrl("http://127.0.0.1:9333", { fetch, timeoutMs: 1 }),
).rejects.toThrow("timed out after 1ms");
).rejects.toMatchObject({ code: "RESIDENT_PROXY_UNAVAILABLE" });

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.

P2: The timeout test no longer verifies the configured duration in the failure message, so regressions that lose or change timed out after 1ms will pass. Keep the duration assertion alongside the new error-code assertion.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/tests/resident-browser-proxy.test.ts, line 143:

<comment>The timeout test no longer verifies the configured duration in the failure message, so regressions that lose or change `timed out after 1ms` will pass. Keep the duration assertion alongside the new error-code assertion.</comment>

<file context>
@@ -131,7 +140,60 @@ describe("resident browser proxy resolver", () => {
     await expect(
       resolveResidentBrowserWebSocketUrl("http://127.0.0.1:9333", { fetch, timeoutMs: 1 }),
-    ).rejects.toThrow("timed out after 1ms");
+    ).rejects.toMatchObject({ code: "RESIDENT_PROXY_UNAVAILABLE" });
+  });
+
</file context>
Suggested change
).rejects.toMatchObject({ code: "RESIDENT_PROXY_UNAVAILABLE" });
).rejects.toMatchObject({
code: "RESIDENT_PROXY_UNAVAILABLE",
message: expect.stringContaining("timed out after 1ms"),
});

Comment on lines +24 to 28
expect(scope.__stagehand_runtime).toMatchObject({
state: "unconfigured",
connected: false,
timings: {},
});

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.

P2: The toMatchObject matcher does not verify that timings is empty, because {} matches every nested object. Assert scope.__stagehand_runtime?.timings with toStrictEqual({}) so unexpected initialization timings are caught.

(Based on your team's feedback about adding focused regression tests for new behavior.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/tests/runtime-descriptor.test.ts, line 24:

<comment>The `toMatchObject` matcher does not verify that `timings` is empty, because `{}` matches every nested object. Assert `scope.__stagehand_runtime?.timings` with `toStrictEqual({})` so unexpected initialization timings are caught.

(Based on your team's feedback about adding focused regression tests for new behavior.) </comment>

<file context>
@@ -12,15 +12,19 @@ describe("runtime descriptor", () => {
-      },
+      serverInfo: { name: "stagehand", version: extensionPackageJson.version },
+    });
+    expect(scope.__stagehand_runtime).toMatchObject({
+      state: "unconfigured",
+      connected: false,
</file context>
Suggested change
expect(scope.__stagehand_runtime).toMatchObject({
state: "unconfigured",
connected: false,
timings: {},
});
expect(scope.__stagehand_runtime).toMatchObject({
state: "unconfigured",
connected: false,
});
expect(scope.__stagehand_runtime?.timings).toStrictEqual({});

Comment on lines +57 to +62
const underlying = error instanceof Error ? error.message : String(error);
throw new ResidentBrowserProxyError(
"RESIDENT_PROXY_UNAVAILABLE",
`RESIDENT_PROXY_UNAVAILABLE: Resident browser proxy at ${proxyUrl.origin} is unavailable; the Browserbase session may not have enabled the Stagehand runtime or the proxy is not listening. Ensure browserSettings.extensions includes "stagehand". (${underlying})`,
{ cause: error },
);

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.

P2: When the version fetch rejects, this interpolates the nested fetch message into the error returned by stagehand.init. The RPC layer forwards that message unchanged, exposing transport details and making the public error unstable; keep a fixed proxy-unavailable message and retain diagnostics only internally.

(Based on your team's feedback about sanitizing CDP discovery failures.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/service-worker-lifecycle/resident-browser-proxy.ts, line 57:

<comment>When the version fetch rejects, this interpolates the nested fetch message into the error returned by `stagehand.init`. The RPC layer forwards that message unchanged, exposing transport details and making the public error unstable; keep a fixed proxy-unavailable message and retain diagnostics only internally.

(Based on your team's feedback about sanitizing CDP discovery failures.) </comment>

<file context>
@@ -28,11 +47,48 @@ export async function resolveResidentBrowserWebSocketUrl(
+        signal: abortController.signal,
+      });
+    } catch (error) {
+      const underlying = error instanceof Error ? error.message : String(error);
+      throw new ResidentBrowserProxyError(
+        "RESIDENT_PROXY_UNAVAILABLE",
</file context>
Suggested change
const underlying = error instanceof Error ? error.message : String(error);
throw new ResidentBrowserProxyError(
"RESIDENT_PROXY_UNAVAILABLE",
`RESIDENT_PROXY_UNAVAILABLE: Resident browser proxy at ${proxyUrl.origin} is unavailable; the Browserbase session may not have enabled the Stagehand runtime or the proxy is not listening. Ensure browserSettings.extensions includes "stagehand". (${underlying})`,
{ cause: error },
);
throw new ResidentBrowserProxyError(
"RESIDENT_PROXY_UNAVAILABLE",
"RESIDENT_PROXY_UNAVAILABLE: Resident browser proxy is unavailable; the Browserbase session may not have enabled the Stagehand runtime or the proxy is not listening. Ensure browserSettings.extensions includes \"stagehand\".",
{ cause: error },
);

Comment thread packages/extension/runtime.ts
@miguelg719 miguelg719 changed the title [AP-000] feat(extension): attach the resident runtime on stagehand.init feat(extension): attach the resident runtime on stagehand.init Aug 24, 2026
…ach-aware

Port of #2465 (feat/resident-gateway-runtime) onto main, redesigned for
main's #2752 SDK reattach semantics.

Lifecycle (service-worker-lifecycle/resident-runtime.ts):
- A configured worker stays idle until the first URL-less stagehand.init,
  then resolves the loopback browser proxy, bootstraps the restricted
  BrowserContext (restrictToWebTargets, deferPageInstrumentation,
  ensureInitialPage:false), restores initialized instrumentation, waits for
  the RPC receiver and only then publishes `ready`. Socket loss publishes
  `reconnecting` and retries within a bounded budget (100/250/500ms).
- initialize() no longer rejects an initialized runtime: ready+connected
  refreshes config via runtime.initialize; reconnecting/failed awaits a
  bootstrap (which replays init scripts, headers, domain policy, viewport
  and page CDP event subscriptions) before reattaching; a failed runtime
  gets a fresh attempt (post-budget recovery). Concurrent init still fails
  with "already in progress" via a lifecycle-level guard.
- initializeWithBrowserCdpUrl on an initialized runtime logs and ignores
  the URL instead of replacing the session; a legacy (custom CDP) session
  reattaches with or without browserCdpUrl.
- Reconnect generations reset the operation queue so a stalled stale
  restore cannot starve the next attempt; `closed` is published even if
  session teardown throws.

Runtime (runtime.ts):
- Factory/replaceBrowserConnection take an options object
  { bootstrapLogger?, lifecycle? } instead of a positional bootstrapLogger.
- replaceBrowserConnection is generation-guarded ("superseded") and keeps
  main's page-event-subscription disposal and responseHandles.clear().
- Restore bookkeeping for context/page init scripts, headers, domain
  policy and viewport; vanished pages are pruned (pageClose, registry
  refresh, restore). Page CDP event subscriptions are snapshotted across
  replaces (merged, so a superseded reconnect cannot drop them), honored by
  page.off while pending, and re-subscribed on restore.

Worker wiring: RPCRouter gains closeContext and passes the request logger
to initializeStagehand; stagehand.close/context.close tear down through
the lifecycle; the worker marker is the lifecycle marker (state, connected,
timings, failure). Local/custom CDP sessions pass none of the resident
BrowserContext options, so that path is unchanged.

SDK: negotiateRuntimeCompatibility reads protocolVersion/serverInfo from a
loose envelope so the strict RuntimeDescriptor still validates beside the
operational marker fields.
…ions

A stagehand.init that arrived during a reconnect window awaited one
generation's bootstrap promise. If that attempt stalled (socket dropped
mid-restore and a post-close CDP send never settled), the timer-driven next
generation reconnected and published `ready`, but the init stayed parked on
the stale promise, so `initializationInFlight` was never cleared and every
later stagehand.init rejected with "already in progress" forever.

- Add a per-generation supersession signal; all generation bumps (bootstrap
  timer-clear, reconnect timer, disableResidentBootstrap, close) go through
  bumpOperationGeneration(), which resolves the previous signal.
- initializeResident races the current bootstrap against supersession and,
  when superseded, follows the new generation's bootstrap until it reaches
  ready, fails, or the lifecycle is closed/disabled (which reject). Only the
  final runtime.initialize is serialized on the operation queue; the wait
  itself cannot sit on the tail because a stalled op would block it.
- Test: init awaiting a stalled stale reconnect resolves on the new
  generation and a subsequent init succeeds.
… budget exhaustion

Step C2 on top of the resident gateway runtime (same branch).

Queue-wedge root cause (understudy):
- BrowserWebSocketTransport.send throws when the socket is not OPEN instead
  of letting the browser silently drop the frame.
- CdpConnection.send/_sendViaSession reject immediately when the transport is
  not connected and turn synchronous transport throws into rejections; no
  inflight entry is left behind. A pre-resume dispatch waiter settles with the
  outcome of its send (rejects when the command never reached the wire), so
  the context's Promise.all over dispatch promises cannot hang.

Lifecycle (service-worker-lifecycle/resident-runtime.ts):
- Every awaited bootstrap step (resolve URL, replaceBrowserConnection,
  restore, RPC receiver) is raced against the run's own disconnect signal and
  the generation's supersession, so a stale run can never hold the queue.
- bootstrap() always opens a new generation for a fresh run (and every bump
  resets the operation tail); hooks of an abandoned run cannot publish into or
  schedule reconnects for its replacement. A superseded run rejects rather
  than resolving with a non-ready marker.
- stagehand.init rides out the reconnect budget: a failed attempt with a retry
  pending makes the init follow the next generation instead of rejecting; only
  budget exhaustion/disable/close reject it. Default delays are now
  100/250/500/1000/2000ms. A later init on a failed (budget-exhausted)
  runtime re-arms a fresh budget.
- Failure phase: retries of the first connection report `connecting`;
  anything after the lifecycle held a socket reports `reconnecting`; the
  budget-exhausted diagnostic is preserved. marker.failure gains an optional
  `code` from the structured proxy error.

Runtime (runtime.ts): restoreInitializedBrowserSession re-checks that the
session it is restoring is still the active one after every await, so a
stale restore cannot instrument the next generation's pages. Page CDP event
subscriptions whose page vanished across a reconnect are dropped with a
warning (no wire-level invalidation event: the page itself is gone).

Proxy discovery (resident-browser-proxy.ts): ResidentBrowserProxyError with
codes RESIDENT_PROXY_UNAVAILABLE (fetch rejected/aborted),
RESIDENT_PROXY_NOT_READY (503 or {"error":"stagehand_not_enabled"|
"browser_unavailable"}), RESIDENT_PROXY_FORBIDDEN (403); other HTTP
failures stay plain errors.

Tests: CDP send-guard/dispatch-waiter/WebSocket-guard tests with a fake
transport that drops mid-operation; lifecycle tests for hang-then-drop
during restore (budget exhausted, no timer to rescue it), first-init retry
within budget, first-init rejection with the last attempt's error and code,
init during the reconnect window following a failed retry, fresh budget
after exhaustion, close during a stalled bootstrap with a pending init,
stale restore isolation, dropped-subscription warning, and the structured
proxy error mapping.
@miguelg719
miguelg719 force-pushed the feat/resident-main-c-gateway-runtime branch from 91d03b8 to 1c1d6dd Compare August 24, 2026 19:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant