Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
28 changes: 26 additions & 2 deletions server/src/browser-management/classes/BrowserPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,12 @@ export class BrowserPool {
return undefined;
}

// Return undefined for failed slots
if (poolInfo.status === "failed") {
logger.log('debug', `Browser ${id} has failed status`);
return undefined;
}
Comment on lines +224 to +228
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

'failed' status check is currently unreachable given failBrowserSlot deletes the entry

getRemoteBrowser returns undefined for status "failed", but failBrowserSlot immediately deletes slots instead of persisting a failed status. As a result, worker logs will see finalStatus=null, never "failed".

To expose meaningful final statuses and enable earlier bailouts:

  • Option A (preferred): Persist "failed" for a short TTL (e.g., 30s) before deletion.
  • Option B: At least set status="failed" before deletion so other components can read it momentarily.

Proposed minimal change to persist "failed" briefly:

-  public failBrowserSlot = (id: string): void => {
-      if (this.pool[id]) {
-          logger.log('info', `Marking browser slot ${id} as failed`);
-          this.deleteRemoteBrowser(id);
-      }
-  };
+  public failBrowserSlot = (id: string): void => {
+      const info = this.pool[id];
+      if (info) {
+          logger.log('info', `Marking browser slot ${id} as failed`);
+          info.status = "failed";
+          info.browser = null;
+          // Retain failed status briefly for observability, then cleanup
+          setTimeout(() => {
+              this.deleteRemoteBrowser(id);
+          }, 30000);
+      }
+  };

If you go with this, also have the worker break early when it sees status === "failed". I’ve added a suggested worker diff in its review.

Committable suggestion skipped: line range outside the PR's diff.


return poolInfo.browser || undefined;
};

Expand Down Expand Up @@ -607,8 +613,13 @@ export class BrowserPool {
* @returns true if successful, false if slot wasn't reserved
*/
public upgradeBrowserSlot = (id: string, browser: RemoteBrowser): boolean => {
if (!this.pool[id] || this.pool[id].status !== "reserved") {
logger.log('warn', `Cannot upgrade browser ${id}: slot not reserved`);
if (!this.pool[id]) {
logger.log('warn', `Cannot upgrade browser ${id}: slot does not exist in pool`);
return false;
}

if (this.pool[id].status !== "reserved") {
logger.log('warn', `Cannot upgrade browser ${id}: slot not in reserved state (current: ${this.pool[id].status})`);
return false;
Comment on lines +616 to 623
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Good guard: only upgrade reserved slots; add an 'initializing' step for richer introspection

The stricter validation is appropriate. For better observability, consider setting status="initializing" when initialization starts (in controller) so getBrowserStatus reflects progress beyond "reserved".

Add a setter and use it from the controller:

  1. Add to BrowserPool:
+  public setBrowserStatus = (id: string, status: "reserved" | "initializing" | "ready" | "failed"): boolean => {
+      const info = this.pool[id];
+      if (!info) return false;
+      info.status = status;
+      return true;
+  };
  1. In controller before await browserSession.initialize(...):
+  browserPool.setBrowserStatus(id, "initializing");
🤖 Prompt for AI Agents
In server/src/browser-management/classes/BrowserPool.ts around lines 616 to 623,
the guard correctly prevents upgrading non-reserved slots but we should allow
richer status introspection by adding an "initializing" state; add a public
setter method on BrowserPool to set a slot's status (e.g., setStatus(id: string,
status: BrowserStatus)) that validates the slot exists and updates the status
atomically, then update the controller to call pool.setStatus(id,
"initializing") immediately before awaiting browserSession.initialize(...) so
getBrowserStatus will report "initializing" while setup runs.

}

Expand All @@ -629,4 +640,17 @@ export class BrowserPool {
this.deleteRemoteBrowser(id);
}
};

/**
* Gets the current status of a browser slot.
*
* @param id browser ID to check
* @returns the status or null if browser doesn't exist
*/
public getBrowserStatus = (id: string): "reserved" | "initializing" | "ready" | "failed" | null => {
if (!this.pool[id]) {
return null;
}
return this.pool[id].status || null;
};
}
12 changes: 9 additions & 3 deletions server/src/browser-management/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ const initializeBrowserAsync = async (id: string, userId: string) => {
logger.log('warn', `No client connected to browser ${id} within timeout, proceeding with dummy socket`);
resolve(null);
}
}, 10000);
}, 15000);
});

namespace.on('error', (error: any) => {
Expand Down Expand Up @@ -272,21 +272,25 @@ const initializeBrowserAsync = async (id: string, userId: string) => {
browserSession = new RemoteBrowser(dummySocket, userId, id);
}

logger.log('debug', `Starting browser initialization for ${id}`);
await browserSession.initialize(userId);
logger.log('debug', `Browser initialization completed for ${id}`);

Comment on lines +275 to 278
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Log additions are good; set status='initializing' here for better introspection

Emit initializing state before await initialize so worker logs report real progress.

If you add BrowserPool.setBrowserStatus as suggested:

+      browserPool.setBrowserStatus(id, "initializing");
       await browserSession.initialize(userId);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
logger.log('debug', `Starting browser initialization for ${id}`);
await browserSession.initialize(userId);
logger.log('debug', `Browser initialization completed for ${id}`);
logger.log('debug', `Starting browser initialization for ${id}`);
browserPool.setBrowserStatus(id, "initializing");
await browserSession.initialize(userId);
logger.log('debug', `Browser initialization completed for ${id}`);
🤖 Prompt for AI Agents
In server/src/browser-management/controller.ts around lines 275 to 278, the code
logs start of initialization but doesn't set the browser status; before awaiting
browserSession.initialize(userId) call BrowserPool.setBrowserStatus(id,
'initializing') (or the equivalent setter on the pool) so the status is
persisted and worker logs reflect real progress, then proceed to await
initialize and afterwards set the final status (e.g., 'ready' or error) as
appropriate.

const upgraded = browserPool.upgradeBrowserSlot(id, browserSession);
if (!upgraded) {
throw new Error('Failed to upgrade reserved browser slot');
}

await new Promise(resolve => setTimeout(resolve, 500));

if (socket) {
socket.emit('ready-for-run');
} else {
setTimeout(async () => {
try {
logger.log('info', `Starting execution for browser ${id} with dummy socket`);
logger.log('info', `Browser ${id} with dummy socket is ready for execution`);
} catch (error: any) {
logger.log('error', `Error executing run for browser ${id}: ${error.message}`);
logger.log('error', `Error with dummy socket browser ${id}: ${error.message}`);
}
}, 100);
}
Expand All @@ -299,10 +303,12 @@ const initializeBrowserAsync = async (id: string, userId: string) => {
if (socket) {
socket.emit('error', { message: error.message });
}
throw error;
}
Comment on lines +306 to 307
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Rethrowing here creates an unhandled promise rejection at the call site

initializeBrowserAsync is invoked without await/catch in createRemoteBrowserForRun, so rethrowing will surface as an unhandled rejection and can crash the process depending on Node settings.

Fix by catching at the call site:

-  initializeBrowserAsync(id, userId);
+  void initializeBrowserAsync(id, userId).catch((err) => {
+    logger.log('error', `Failed to initialize browser ${id}: ${err?.message || err}`);
+    try { browserPool.failBrowserSlot(id); } catch (e) {
+      logger.log('warn', `Failed to mark slot ${id} as failed: ${e}`);
+    }
+  });

Optionally, if you prefer not to rethrow inside initializeBrowserAsync, remove the throws and rely on logging + failBrowserSlot there; but catching at the call site is safer and explicit.

Also applies to: 312-313

🤖 Prompt for AI Agents
In server/src/browser-management/controller.ts around lines 306-307 (and
similarly 312-313), initializeBrowserAsync currently rethrows errors which leads
to unhandled promise rejections because callers (createRemoteBrowserForRun)
invoke it without await/catch; update the call site(s) in
createRemoteBrowserForRun to await initializeBrowserAsync inside a try/catch and
handle failures by logging and calling failBrowserSlot (or other cleanup)
instead of allowing the rejection to bubble, or alternatively remove the throw
inside initializeBrowserAsync and rely on its internal logging + failBrowserSlot
so the function resolves normally on error.


} catch (error: any) {
logger.log('error', `Error setting up browser ${id}: ${error.message}`);
browserPool.failBrowserSlot(id);
throw error;
}
};
36 changes: 29 additions & 7 deletions server/src/pgboss-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,8 @@ async function triggerIntegrationUpdates(runId: string, robotMetaId: string): Pr
* Modified processRunExecution function - only add browser reset
*/
async function processRunExecution(job: Job<ExecuteRunData>) {
const BROWSER_INIT_TIMEOUT = 30000;
const BROWSER_INIT_TIMEOUT = 60000;
const BROWSER_PAGE_TIMEOUT = 45000;

const data = job.data;
logger.log('info', `Processing run execution job for runId: ${data.runId}, browserId: ${data.browserId}`);
Expand Down Expand Up @@ -244,15 +245,28 @@ async function processRunExecution(job: Job<ExecuteRunData>) {

let browser = browserPool.getRemoteBrowser(browserId);
const browserWaitStart = Date.now();
let lastLogTime = 0;

while (!browser && (Date.now() - browserWaitStart) < BROWSER_INIT_TIMEOUT) {
logger.log('debug', `Browser ${browserId} not ready yet, waiting...`);
await new Promise(resolve => setTimeout(resolve, 1000));
const currentTime = Date.now();

const browserStatus = browserPool.getBrowserStatus(browserId);
if (browserStatus === null) {
throw new Error(`Browser slot ${browserId} does not exist in pool`);
}

if (currentTime - lastLogTime > 10000) {
logger.log('info', `Browser ${browserId} not ready yet (status: ${browserStatus}), waiting... (${Math.round((currentTime - browserWaitStart) / 1000)}s elapsed)`);
lastLogTime = currentTime;
}

await new Promise(resolve => setTimeout(resolve, 2000));
browser = browserPool.getRemoteBrowser(browserId);
Comment on lines +251 to 264
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Early-exit when status is 'failed' to avoid spinning until timeout

If BrowserPool begins to persist 'failed' (recommended), this loop should stop immediately rather than wait until timeout.

Apply:

-      const browserStatus = browserPool.getBrowserStatus(browserId);
+      const browserStatus = browserPool.getBrowserStatus(browserId);
       if (browserStatus === null) {
         throw new Error(`Browser slot ${browserId} does not exist in pool`);
       }
+      if (browserStatus === "failed") {
+        throw new Error(`Browser ${browserId} initialization failed`);
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const currentTime = Date.now();
const browserStatus = browserPool.getBrowserStatus(browserId);
if (browserStatus === null) {
throw new Error(`Browser slot ${browserId} does not exist in pool`);
}
if (currentTime - lastLogTime > 10000) {
logger.log('info', `Browser ${browserId} not ready yet (status: ${browserStatus}), waiting... (${Math.round((currentTime - browserWaitStart) / 1000)}s elapsed)`);
lastLogTime = currentTime;
}
await new Promise(resolve => setTimeout(resolve, 2000));
browser = browserPool.getRemoteBrowser(browserId);
const currentTime = Date.now();
const browserStatus = browserPool.getBrowserStatus(browserId);
if (browserStatus === null) {
throw new Error(`Browser slot ${browserId} does not exist in pool`);
}
if (browserStatus === "failed") {
throw new Error(`Browser ${browserId} initialization failed`);
}
if (currentTime - lastLogTime > 10000) {
logger.log('info', `Browser ${browserId} not ready yet (status: ${browserStatus}), waiting... (${Math.round((currentTime - browserWaitStart) / 1000)}s elapsed)`);
lastLogTime = currentTime;
}
await new Promise(resolve => setTimeout(resolve, 2000));
browser = browserPool.getRemoteBrowser(browserId);
🤖 Prompt for AI Agents
In server/src/pgboss-worker.ts around lines 251 to 264, the loop waiting for a
browser to become ready currently spins until timeout even if the browser status
becomes 'failed'; detect when browserStatus === 'failed' and stop immediately by
throwing a clear Error (or otherwise exiting the wait loop) with context
including browserId and any last error info, and optionally log an error before
throwing so we don't waste time waiting for timeout.

}

if (!browser) {
throw new Error(`Browser ${browserId} not found in pool after timeout`);
const finalStatus = browserPool.getBrowserStatus(browserId);
throw new Error(`Browser ${browserId} not found in pool after ${BROWSER_INIT_TIMEOUT/1000}s timeout (final status: ${finalStatus})`);
}

logger.log('info', `Browser ${browserId} found and ready for execution`);
Expand All @@ -273,14 +287,22 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
let currentPage = browser.getCurrentPage();

const pageWaitStart = Date.now();
while (!currentPage && (Date.now() - pageWaitStart) < 30000) {
logger.log('debug', `Page not ready for browser ${browserId}, waiting...`);
let lastPageLogTime = 0;

while (!currentPage && (Date.now() - pageWaitStart) < BROWSER_PAGE_TIMEOUT) {
const currentTime = Date.now();

if (currentTime - lastPageLogTime > 5000) {
logger.log('info', `Page not ready for browser ${browserId}, waiting... (${Math.round((currentTime - pageWaitStart) / 1000)}s elapsed)`);
lastPageLogTime = currentTime;
}

await new Promise(resolve => setTimeout(resolve, 1000));
currentPage = browser.getCurrentPage();
}

if (!currentPage) {
throw new Error(`No current page available for browser ${browserId} after timeout`);
throw new Error(`No current page available for browser ${browserId} after ${BROWSER_PAGE_TIMEOUT/1000}s timeout`);
}

logger.log('info', `Starting workflow execution for run ${data.runId}`);
Expand Down
96 changes: 37 additions & 59 deletions src/helpers/clientSelectorGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2494,70 +2494,38 @@ class ClientSelectorGenerator {
};

private getAllDescendantsIncludingShadow(
parentElement: HTMLElement
parentElement: HTMLElement,
maxDepth: number = 20
): HTMLElement[] {
const allDescendants: HTMLElement[] = [];
const visited = new Set<HTMLElement>();
const shadowRootsSeen = new Set<ShadowRoot>();

const traverseShadowRoot = (shadowRoot: ShadowRoot, depth: number = 0) => {
if (depth > 10) return;

try {
const shadowElements = Array.from(
shadowRoot.querySelectorAll("*")
) as HTMLElement[];

shadowElements.forEach((shadowElement) => {
if (!visited.has(shadowElement)) {
visited.add(shadowElement);
allDescendants.push(shadowElement);

if (
shadowElement.shadowRoot &&
!shadowRootsSeen.has(shadowElement.shadowRoot)
) {
shadowRootsSeen.add(shadowElement.shadowRoot);
traverseShadowRoot(shadowElement.shadowRoot, depth + 1);
}
}
});
const traverse = (element: HTMLElement, currentDepth: number) => {
if (currentDepth >= maxDepth || visited.has(element)) {
return;
}
visited.add(element);

Array.from(shadowRoot.children).forEach((child) => {
const htmlChild = child as HTMLElement;
if (
htmlChild.shadowRoot &&
!shadowRootsSeen.has(htmlChild.shadowRoot)
) {
shadowRootsSeen.add(htmlChild.shadowRoot);
traverseShadowRoot(htmlChild.shadowRoot, depth + 1);
}
});
} catch (error) {
console.warn(`Error traversing shadow root:`, error);
if (element !== parentElement) {
allDescendants.push(element);
}
};

const regularDescendants = Array.from(
parentElement.querySelectorAll("*")
) as HTMLElement[];
regularDescendants.forEach((descendant) => {
if (!visited.has(descendant)) {
visited.add(descendant);
allDescendants.push(descendant);
// Traverse light DOM children
const children = Array.from(element.children) as HTMLElement[];
for (const child of children) {
traverse(child, currentDepth + 1);
}
});

const elementsWithShadow = [parentElement, ...regularDescendants].filter(
(el) => el.shadowRoot
);
elementsWithShadow.forEach((element) => {
if (!shadowRootsSeen.has(element.shadowRoot!)) {
shadowRootsSeen.add(element.shadowRoot!);
traverseShadowRoot(element.shadowRoot!, 0);
// Traverse shadow DOM if it exists
if (element.shadowRoot) {
const shadowChildren = Array.from(element.shadowRoot.children) as HTMLElement[];
for (const shadowChild of shadowChildren) {
traverse(shadowChild, currentDepth + 1);
}
}
});
};

traverse(parentElement, 0);
return allDescendants;
}

Expand All @@ -2577,6 +2545,8 @@ class ClientSelectorGenerator {
if (processedElements.has(descendant)) return;
processedElements.add(descendant);

if (!this.isMeaningfulElement(descendant)) return;

const absolutePath = this.buildOptimizedAbsoluteXPath(
descendant,
listSelector,
Expand Down Expand Up @@ -2766,16 +2736,20 @@ class ClientSelectorGenerator {
rootElement: HTMLElement,
otherListElements: HTMLElement[] = []
): string | null {
if (!this.elementContains(rootElement, targetElement) || targetElement === rootElement) {
if (
!this.elementContains(rootElement, targetElement) ||
targetElement === rootElement
) {
return null;
}

const pathParts: string[] = [];
let current: HTMLElement | null = targetElement;
let pathDepth = 0;
const MAX_PATH_DEPTH = 20;

// Build path from target up to root
while (current && current !== rootElement) {
// Calculate conflicts for each element in the path
while (current && current !== rootElement && pathDepth < MAX_PATH_DEPTH) {
const classes = this.getCommonClassesAcrossLists(
current,
otherListElements
Expand Down Expand Up @@ -2806,11 +2780,15 @@ class ClientSelectorGenerator {
pathParts.unshift(pathPart);
}

// Move to parent (either regular parent or shadow host)
current = current.parentElement ||
current =
current.parentElement ||
((current.getRootNode() as ShadowRoot).host as HTMLElement | null);

pathDepth++;
}

if (!current) break;
if (current !== rootElement) {
return null;
}

return pathParts.length > 0 ? "/" + pathParts.join("/") : null;
Expand Down