diff --git a/apps/api/src/browserbase/browserbase-session.service.spec.ts b/apps/api/src/browserbase/browserbase-session.service.spec.ts index 5a1c986db..de2cc2059 100644 --- a/apps/api/src/browserbase/browserbase-session.service.spec.ts +++ b/apps/api/src/browserbase/browserbase-session.service.spec.ts @@ -238,6 +238,26 @@ describe('BrowserbaseSessionService', () => { expect(close).toHaveBeenCalledTimes(1); }); + it('runs Stagehand with the hosted API disabled', async () => { + const service = new BrowserbaseSessionService(); + const init = jest.fn().mockResolvedValue(undefined); + const close = jest.fn().mockResolvedValue(undefined); + const StagehandCtor = mockStagehandClass({ init, close }); + jest.spyOn(service, 'loadStagehand').mockResolvedValue(StagehandCtor); + + await service.createStagehand('session_1'); + + // disableAPI:true skips Stagehand's hosted POST /sessions/start (the source + // of "Unknown error: 400"); the session still resumes over CDP. + expect(StagehandCtor).toHaveBeenCalledWith( + expect.objectContaining({ + env: 'BROWSERBASE', + browserbaseSessionID: 'session_1', + disableAPI: true, + }), + ); + }); + it('navigateToUrl returns the friendly unavailable message when init keeps failing', async () => { const service = new BrowserbaseSessionService(); jest diff --git a/apps/api/src/browserbase/browserbase-session.service.ts b/apps/api/src/browserbase/browserbase-session.service.ts index df858f8ef..24485d719 100644 --- a/apps/api/src/browserbase/browserbase-session.service.ts +++ b/apps/api/src/browserbase/browserbase-session.service.ts @@ -167,11 +167,18 @@ export class BrowserbaseSessionService { async createStagehand(sessionId: string): Promise { const Stagehand = await this.loadStagehand(); - // Stagehand.init() resumes the session by calling the Browserbase API on - // its own internal SDK client (outside getBrowserbase()), so transient - // upstream failures there — e.g. "Premature close" — bypass the per-call - // retry that wraps our direct SDK calls. Retry init the same way, closing - // any half-initialized instance between attempts so we don't leak it. + // We create and own the Browserbase session ourselves, and this feature only + // needs CDP navigation plus local inference. Stagehand's default hosted-API + // mode adds a POST /sessions/start round-trip that is unnecessary here and is + // the source of opaque "Unknown error: " failures. disableAPI:true + // skips it: the session still resumes over CDP and extract/act/agent run + // locally against ANTHROPIC_API_KEY. + // + // init() still performs a Browserbase API round-trip to resume the session, + // whose transient failures — e.g. "Premature close" — bypass the retry that + // wraps our direct SDK calls, so retry init too, closing any half-initialized + // instance between attempts to avoid leaking it. Stagehand strips upstream + // error bodies from its throws, so forward its error logs into our logger. return this.withBrowserbaseRetry({ operationName: 'stagehand initialization', operation: async () => { @@ -180,11 +187,19 @@ export class BrowserbaseSessionService { apiKey: process.env.BROWSERBASE_API_KEY, projectId: this.getProjectId(), browserbaseSessionID: sessionId, + disableAPI: true, model: { modelName: STAGEHAND_MODEL, apiKey: process.env.ANTHROPIC_API_KEY, }, verbose: 1, + logger: (line) => { + if ((line.level ?? 1) === 0) { + this.logger.error( + `Stagehand[${line.category ?? 'log'}]: ${line.message}`, + ); + } + }, }); try { diff --git a/apps/api/src/trigger/browser-automation/run-browser-automation.ts b/apps/api/src/trigger/browser-automation/run-browser-automation.ts index 05dbf1d02..eb649e6c6 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automation.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automation.ts @@ -188,7 +188,7 @@ async function sendTaskStatusChangeEmails(params: { */ export const runBrowserAutomation = task({ id: 'run-browser-automation', - maxDuration: 1000 * 60 * 10, // 10 minutes per automation + maxDuration: 60 * 10, // 10 minutes per automation — Trigger.dev maxDuration is in SECONDS queue: { concurrencyLimit: browserAutomationConcurrencyLimit(), }, diff --git a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts index d3aceb930..f06b42d88 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts @@ -93,7 +93,7 @@ export function limitAutomationBatch< export const browserAutomationsSchedule = schedules.task({ id: 'browser-automations-schedule', cron: '0 5 * * *', // Daily at 5:00 AM UTC - maxDuration: 1000 * 60 * 30, // 30 minutes + maxDuration: 60 * 30, // 30 minutes — Trigger.dev maxDuration is in SECONDS run: async (payload) => { logger.info('Starting daily browser automations orchestrator', { scheduledAt: payload.timestamp,