- commit
- 889f746
- parent
- 04fbd60
- author
- codex@macbookpro
- date
- 2026-03-31 20:20:35 +0800 CST
fix: harden conductor execution timeouts
8 files changed,
+470,
-80
1@@ -1,5 +1,9 @@
2 import { DEFAULT_SUMMARY_LENGTH } from "../../../../packages/artifact-db/dist/index.js";
3 import type { BrowserBridgeController } from "../browser-types.js";
4+import {
5+ DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS,
6+ DEFAULT_BAA_DELIVERY_ACTION_TIMEOUT_MS
7+} from "../execution-timeouts.js";
8 import {
9 type BaaInstructionExecutionResult,
10 sortBaaJsonValue,
11@@ -14,8 +18,6 @@ import type {
12 } from "./types.js";
13
14 const DEFAULT_COMPLETED_SESSION_TTL_MS = 10 * 60_000;
15-const DEFAULT_DELIVERY_ACTION_RESULT_TIMEOUT_MS = 20_000;
16-const DEFAULT_DELIVERY_ACTION_TIMEOUT_MS = 15_000;
17 export const DEFAULT_BAA_DELIVERY_LINE_LIMIT = 200;
18 export const DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD = 2_000;
19 export const DEFAULT_BAA_DELIVERY_SUMMARY_LENGTH = DEFAULT_SUMMARY_LENGTH;
20@@ -609,7 +611,7 @@ export class BaaBrowserDeliveryBridge {
21 platform: input.platform,
22 shellPage: route.shellPage,
23 tabId: route.tabId,
24- timeoutMs: DEFAULT_DELIVERY_ACTION_TIMEOUT_MS
25+ timeoutMs: DEFAULT_BAA_DELIVERY_ACTION_TIMEOUT_MS
26 });
27
28 record.snapshot.mode = "proxy";
29@@ -652,7 +654,7 @@ export class BaaBrowserDeliveryBridge {
30 pageUrl: route.pageUrl,
31 shellPage: route.shellPage,
32 tabId: route.tabId,
33- timeoutMs: DEFAULT_DELIVERY_ACTION_TIMEOUT_MS
34+ timeoutMs: DEFAULT_BAA_DELIVERY_ACTION_TIMEOUT_MS
35 });
36
37 record.snapshot.mode = "dom_fallback";
38@@ -681,7 +683,7 @@ export class BaaBrowserDeliveryBridge {
39 pageUrl: route.pageUrl,
40 shellPage: route.shellPage,
41 tabId: route.tabId,
42- timeoutMs: DEFAULT_DELIVERY_ACTION_TIMEOUT_MS
43+ timeoutMs: DEFAULT_BAA_DELIVERY_ACTION_TIMEOUT_MS
44 });
45
46 record.snapshot.sendRequestId = sendDispatch.requestId;
47@@ -789,4 +791,4 @@ function normalizeDeliveryReason(value: string | null | undefined): string | nul
48 return normalized === "" ? null : normalized;
49 }
50
51-export const DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT = DEFAULT_DELIVERY_ACTION_RESULT_TIMEOUT_MS;
52+export const DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT = DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS;
1@@ -0,0 +1,69 @@
2+export const DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS = 30_000;
3+export const DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS = 60_000;
4+
5+export const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
6+export const DEFAULT_BROWSER_PROXY_ACTION_RESULT_TIMEOUT_GRACE_MS = 5_000;
7+
8+export const DEFAULT_BAA_DELIVERY_ACTION_TIMEOUT_MS = 15_000;
9+export const DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS = 20_000;
10+
11+export const DEFAULT_RENEWAL_EXECUTION_TIMEOUT_MS = DEFAULT_BROWSER_PROXY_TIMEOUT_MS;
12+
13+export const DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS = 15_000;
14+export const DEFAULT_FIREFOX_ACTION_RESULT_TIMEOUT_MS = 10_000;
15+export const DEFAULT_FIREFOX_STREAM_IDLE_TIMEOUT_MS = 30_000;
16+export const DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_BYTES = 512 * 1024;
17+export const DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_EVENTS = 256;
18+export const DEFAULT_FIREFOX_STREAM_OPEN_TIMEOUT_MS = 10_000;
19+
20+export function resolveDeliveryActionResultTimeoutMs(timeoutMs?: number | null): number {
21+ return timeoutMs == null
22+ ? DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS
23+ : timeoutMs + DEFAULT_BROWSER_PROXY_ACTION_RESULT_TIMEOUT_GRACE_MS;
24+}
25+
26+export async function withAbortableTimeout<T>(
27+ timeoutMs: number,
28+ createTimeoutError: () => Error,
29+ work: (signal: AbortSignal) => Promise<T>
30+): Promise<T> {
31+ const controller = new AbortController();
32+
33+ return await new Promise<T>((resolve, reject) => {
34+ let settled = false;
35+ const timeoutId = globalThis.setTimeout(() => {
36+ const timeoutError = createTimeoutError();
37+ controller.abort(timeoutError);
38+
39+ if (settled) {
40+ return;
41+ }
42+
43+ settled = true;
44+ reject(timeoutError);
45+ }, timeoutMs);
46+
47+ const finish = (handler: () => void) => {
48+ if (settled) {
49+ return;
50+ }
51+
52+ settled = true;
53+ globalThis.clearTimeout(timeoutId);
54+ handler();
55+ };
56+
57+ void Promise.resolve().then(() => work(controller.signal)).then(
58+ (value) => {
59+ finish(() => {
60+ resolve(value);
61+ });
62+ },
63+ (error) => {
64+ finish(() => {
65+ reject(error);
66+ });
67+ }
68+ );
69+ });
70+}
+32,
-31
1@@ -4,14 +4,16 @@ import type {
2 BrowserBridgeActionDispatch,
3 BrowserBridgeActionResultSnapshot
4 } from "./browser-types.js";
5-import { DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT } from "./artifacts/upload-session.js";
6-
7-const DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS = 15_000;
8-const DEFAULT_FIREFOX_ACTION_RESULT_TIMEOUT_MS = 10_000;
9-const DEFAULT_FIREFOX_STREAM_IDLE_TIMEOUT_MS = 30_000;
10-const DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_BYTES = 512 * 1024;
11-const DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_EVENTS = 256;
12-const DEFAULT_FIREFOX_STREAM_OPEN_TIMEOUT_MS = 10_000;
13+import {
14+ DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS,
15+ DEFAULT_FIREFOX_ACTION_RESULT_TIMEOUT_MS,
16+ DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS,
17+ DEFAULT_FIREFOX_STREAM_IDLE_TIMEOUT_MS,
18+ DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_BYTES,
19+ DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_EVENTS,
20+ DEFAULT_FIREFOX_STREAM_OPEN_TIMEOUT_MS,
21+ resolveDeliveryActionResultTimeoutMs
22+} from "./execution-timeouts.js";
23
24 type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
25
26@@ -434,6 +436,7 @@ export class FirefoxBridgeError extends Error {
27 readonly code: FirefoxBridgeErrorCode;
28 readonly connectionId: string | null;
29 readonly requestId: string | null;
30+ readonly timeoutMs: number | null;
31
32 constructor(
33 code: FirefoxBridgeErrorCode,
34@@ -442,6 +445,7 @@ export class FirefoxBridgeError extends Error {
35 clientId?: string | null;
36 connectionId?: string | null;
37 requestId?: string | null;
38+ timeoutMs?: number | null;
39 } = {}
40 ) {
41 super(message);
42@@ -450,6 +454,7 @@ export class FirefoxBridgeError extends Error {
43 this.code = code;
44 this.connectionId = options.connectionId ?? null;
45 this.requestId = options.requestId ?? null;
46+ this.timeoutMs = normalizeOptionalPositiveInteger(options.timeoutMs ?? undefined) ?? null;
47 }
48 }
49
50@@ -883,16 +888,17 @@ export class FirefoxCommandBroker {
51 const timer = this.setTimeoutImpl(() => {
52 this.pendingActionRequests.delete(requestId);
53 rejectResult(
54- new FirefoxBridgeError(
55- "action_timeout",
56- `Firefox client "${client.clientId}" did not report action_result "${requestId}" within ${timeoutMs}ms.`,
57- {
58- clientId: client.clientId,
59- connectionId: client.connectionId,
60- requestId
61- }
62- )
63- );
64+ new FirefoxBridgeError(
65+ "action_timeout",
66+ `Firefox client "${client.clientId}" did not report action_result "${requestId}" within ${timeoutMs}ms.`,
67+ {
68+ clientId: client.clientId,
69+ connectionId: client.connectionId,
70+ requestId,
71+ timeoutMs
72+ }
73+ )
74+ );
75 }, normalizeTimeoutMs(timeoutMs, DEFAULT_FIREFOX_ACTION_RESULT_TIMEOUT_MS));
76
77 this.pendingActionRequests.set(requestId, {
78@@ -966,7 +972,8 @@ export class FirefoxCommandBroker {
79 {
80 clientId: client.clientId,
81 connectionId: client.connectionId,
82- requestId: options.requestId
83+ requestId: options.requestId,
84+ timeoutMs
85 }
86 )
87 );
88@@ -1481,10 +1488,8 @@ export class FirefoxBridgeService {
89 clientId: normalizeOptionalString(input.clientId) ?? undefined
90 }),
91 normalizeTimeoutMs(
92- input.timeoutMs == null
93- ? DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT
94- : input.timeoutMs + 5_000,
95- DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT
96+ resolveDeliveryActionResultTimeoutMs(input.timeoutMs),
97+ DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS
98 )
99 );
100 }
101@@ -1512,10 +1517,8 @@ export class FirefoxBridgeService {
102 clientId: normalizeOptionalString(input.clientId) ?? undefined
103 }),
104 normalizeTimeoutMs(
105- input.timeoutMs == null
106- ? DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT
107- : input.timeoutMs + 5_000,
108- DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT
109+ resolveDeliveryActionResultTimeoutMs(input.timeoutMs),
110+ DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS
111 )
112 );
113 }
114@@ -1543,10 +1546,8 @@ export class FirefoxBridgeService {
115 clientId: normalizeOptionalString(input.clientId) ?? undefined
116 }),
117 normalizeTimeoutMs(
118- input.timeoutMs == null
119- ? DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT
120- : input.timeoutMs + 5_000,
121- DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT
122+ resolveDeliveryActionResultTimeoutMs(input.timeoutMs),
123+ DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS
124 )
125 );
126 }
+201,
-1
1@@ -26,6 +26,7 @@ import {
2 ConductorRuntime,
3 DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS,
4 DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS,
5+ FirefoxBridgeError,
6 PersistentBaaInstructionDeduper,
7 PersistentBaaLiveInstructionMessageDeduper,
8 PersistentBaaLiveInstructionSnapshotStore,
9@@ -843,6 +844,11 @@ test("executeBaaInstruction returns a structured timeout failure when the local
10 ...routeBaaInstruction(instruction),
11 timeoutMs: 20
12 };
13+ let requestSignal = null;
14+ let resolveAbortObserved;
15+ const abortObserved = new Promise((resolve) => {
16+ resolveAbortObserved = resolve;
17+ });
18
19 try {
20 const result = await executeBaaInstruction(
21@@ -854,10 +860,20 @@ test("executeBaaInstruction returns a structured timeout failure when the local
22 snapshotLoader: () => snapshot
23 },
24 {
25- requestHandler: () => new Promise(() => {})
26+ requestHandler: (request) => {
27+ requestSignal = request.signal ?? null;
28+ request.signal?.addEventListener("abort", () => {
29+ resolveAbortObserved();
30+ }, {
31+ once: true
32+ });
33+ return new Promise(() => {});
34+ }
35 }
36 );
37
38+ await abortObserved;
39+
40 assert.equal(result.ok, false);
41 assert.equal(result.httpStatus, 504);
42 assert.equal(result.error, "execution_timeout");
43@@ -867,6 +883,7 @@ test("executeBaaInstruction returns a structured timeout failure when the local
44 });
45 assert.equal(result.route.key, "local.exec");
46 assert.ok(result.artifact);
47+ assert.equal(requestSignal?.aborted, true);
48 } finally {
49 controlPlane.close();
50 }
51@@ -4006,6 +4023,147 @@ test("renewal dispatcher defers paused jobs and retries transient proxy failures
52 }
53 });
54
55+test("renewal dispatcher records timeout failures with a distinct timeout result and timeout_ms", async () => {
56+ const rootDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-timeout-"));
57+ const stateDir = join(rootDir, "state");
58+ const artifactStore = new ArtifactStore({
59+ artifactDir: join(stateDir, ARTIFACTS_DIRNAME),
60+ databasePath: join(stateDir, ARTIFACT_DB_FILENAME)
61+ });
62+ let nowMs = Date.UTC(2026, 2, 30, 14, 0, 0);
63+ const timeoutMs = 250;
64+ let requestCount = 0;
65+ const runner = createRenewalDispatcherRunner({
66+ browserBridge: {
67+ proxyDelivery(input) {
68+ requestCount += 1;
69+ const requestId = `proxy-timeout-${requestCount}`;
70+
71+ return {
72+ clientId: input.clientId || "firefox-claude",
73+ connectionId: "conn-firefox-claude",
74+ dispatchedAt: nowMs,
75+ requestId,
76+ result: Promise.reject(
77+ new FirefoxBridgeError(
78+ "action_timeout",
79+ `Firefox client "${input.clientId || "firefox-claude"}" did not report action_result "${requestId}" within ${timeoutMs}ms.`,
80+ {
81+ clientId: input.clientId || "firefox-claude",
82+ connectionId: "conn-firefox-claude",
83+ requestId,
84+ timeoutMs
85+ }
86+ )
87+ ),
88+ type: "browser.proxy_delivery"
89+ };
90+ }
91+ },
92+ executionTimeoutMs: timeoutMs,
93+ now: () => nowMs,
94+ retryBaseDelayMs: 1,
95+ retryMaxDelayMs: 1
96+ });
97+
98+ try {
99+ await artifactStore.insertMessage({
100+ conversationId: "conv_dispatch_timeout",
101+ id: "msg_dispatch_timeout",
102+ observedAt: nowMs - 60_000,
103+ platform: "claude",
104+ rawText: "renewal dispatcher timeout message",
105+ role: "assistant"
106+ });
107+ await artifactStore.upsertLocalConversation({
108+ automationStatus: "auto",
109+ localConversationId: "lc_dispatch_timeout",
110+ platform: "claude"
111+ });
112+ await artifactStore.upsertConversationLink({
113+ clientId: "firefox-claude",
114+ linkId: "link_dispatch_timeout",
115+ localConversationId: "lc_dispatch_timeout",
116+ observedAt: nowMs - 20_000,
117+ pageTitle: "Timeout Renewal",
118+ pageUrl: "https://claude.ai/chat/conv_dispatch_timeout",
119+ platform: "claude",
120+ remoteConversationId: "conv_dispatch_timeout",
121+ routeParams: {
122+ conversationId: "conv_dispatch_timeout"
123+ },
124+ routePath: "/chat/conv_dispatch_timeout",
125+ routePattern: "/chat/:conversationId",
126+ targetId: "tab:13",
127+ targetKind: "browser.proxy_delivery",
128+ targetPayload: {
129+ clientId: "firefox-claude",
130+ conversationId: "conv_dispatch_timeout",
131+ pageUrl: "https://claude.ai/chat/conv_dispatch_timeout",
132+ tabId: 13
133+ }
134+ });
135+ await artifactStore.insertRenewalJob({
136+ jobId: "job_dispatch_timeout",
137+ localConversationId: "lc_dispatch_timeout",
138+ maxAttempts: 2,
139+ messageId: "msg_dispatch_timeout",
140+ nextAttemptAt: nowMs,
141+ payload: "[renewal] timeout",
142+ payloadKind: "text"
143+ });
144+
145+ const firstContext = createTimedJobRunnerContext({
146+ artifactStore
147+ });
148+ const firstResult = await runner.run(firstContext.context);
149+ const retryJob = await artifactStore.getRenewalJob("job_dispatch_timeout");
150+
151+ assert.equal(firstResult.result, "ok");
152+ assert.equal(retryJob.status, "pending");
153+ assert.equal(retryJob.attemptCount, 1);
154+ assert.equal(retryJob.lastError, "browser_action_timeout");
155+ assert.equal(retryJob.nextAttemptAt, nowMs + 1);
156+ assert.ok(
157+ firstContext.entries.find(
158+ (entry) =>
159+ entry.stage === "job_retry_scheduled"
160+ && entry.result === "browser_action_timeout"
161+ && entry.details?.error_code === "action_timeout"
162+ && entry.details?.timeout_ms === timeoutMs
163+ )
164+ );
165+
166+ nowMs = retryJob.nextAttemptAt;
167+ const secondContext = createTimedJobRunnerContext({
168+ artifactStore
169+ });
170+ const secondResult = await runner.run(secondContext.context);
171+ const failedJob = await artifactStore.getRenewalJob("job_dispatch_timeout");
172+
173+ assert.equal(secondResult.result, "ok");
174+ assert.equal(failedJob.status, "failed");
175+ assert.equal(failedJob.attemptCount, 2);
176+ assert.equal(failedJob.lastError, "browser_action_timeout");
177+ assert.equal(failedJob.nextAttemptAt, null);
178+ assert.ok(
179+ secondContext.entries.find(
180+ (entry) =>
181+ entry.stage === "job_failed"
182+ && entry.result === "browser_action_timeout"
183+ && entry.details?.error_code === "action_timeout"
184+ && entry.details?.timeout_ms === timeoutMs
185+ )
186+ );
187+ } finally {
188+ artifactStore.close();
189+ rmSync(rootDir, {
190+ force: true,
191+ recursive: true
192+ });
193+ }
194+});
195+
196 test("ConductorTimedJobs keeps standby runners idle and clears interval handles on stop", async () => {
197 const logsDir = mkdtempSync(join(tmpdir(), "baa-timed-jobs-standby-"));
198 const intervalScheduler = createManualIntervalScheduler();
199@@ -5333,6 +5491,47 @@ test("handleConductorHttpRequest returns a codexd-specific availability error wh
200 assert.doesNotMatch(payload.message, /bridge/u);
201 });
202
203+test("handleConductorHttpRequest returns browser_request_timeout with timeout details for stalled browser proxy calls", async () => {
204+ const { repository, snapshot } = await createLocalApiFixture();
205+ const browser = createBrowserBridgeStub();
206+ browser.context.browserBridge.apiRequest = async () => {
207+ throw new FirefoxBridgeError(
208+ "request_timeout",
209+ 'Firefox client "firefox-claude" did not respond to api_request "browser-timeout-1" within 50ms.',
210+ {
211+ clientId: "firefox-claude",
212+ connectionId: "conn-firefox-claude",
213+ requestId: "browser-timeout-1",
214+ timeoutMs: 50
215+ }
216+ );
217+ };
218+
219+ const response = await handleConductorHttpRequest(
220+ {
221+ body: JSON.stringify({
222+ path: "/api/organizations",
223+ platform: "claude",
224+ timeoutMs: 50
225+ }),
226+ method: "POST",
227+ path: "/v1/browser/request"
228+ },
229+ {
230+ ...browser.context,
231+ repository,
232+ snapshotLoader: () => snapshot
233+ }
234+ );
235+
236+ assert.equal(response.status, 504);
237+ const payload = parseJsonBody(response);
238+ assert.equal(payload.error, "browser_request_timeout");
239+ assert.equal(payload.details.error_code, "request_timeout");
240+ assert.equal(payload.details.timeout_ms, 50);
241+ assert.equal(payload.details.bridge_request_id, "browser-timeout-1");
242+});
243+
244 test("handleConductorHttpRequest returns a clear 503 for Claude browser actions without an active Firefox client", async () => {
245 const { repository, snapshot } = await createLocalApiFixture();
246
247@@ -8834,6 +9033,7 @@ test("Firefox bridge api requests reject on timeout, disconnect, and replacement
248 await assert.rejects(timedOutPromise, (error) => {
249 assert.equal(error?.code, "request_timeout");
250 assert.equal(error?.requestId, timedOutMessage.id);
251+ assert.equal(error?.timeoutMs, 50);
252 return true;
253 });
254
1@@ -6,8 +6,9 @@ import {
2 import type { ConductorHttpRequest, ConductorHttpResponse } from "../http-types.js";
3 import {
4 DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS,
5- DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS
6-} from "./router.js";
7+ DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS,
8+ withAbortableTimeout
9+} from "../execution-timeouts.js";
10 import type {
11 BaaInstructionEnvelope,
12 BaaInstructionExecutionResult,
13@@ -72,25 +73,6 @@ function resolveExecutionTimeoutMs(route: BaaInstructionRoute): number {
14 : DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS;
15 }
16
17-function withRequestTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
18- return new Promise((resolve, reject) => {
19- const timeoutId = setTimeout(() => {
20- reject(new BaaInstructionExecutionTimeoutError(timeoutMs));
21- }, timeoutMs);
22-
23- promise.then(
24- (value) => {
25- clearTimeout(timeoutId);
26- resolve(value);
27- },
28- (error) => {
29- clearTimeout(timeoutId);
30- reject(error);
31- }
32- );
33- });
34-}
35-
36 function normalizeJsonBodyValue(value: unknown): BaaJsonValue | null {
37 return isBaaJsonValue(value) ? value : null;
38 }
39@@ -162,6 +144,17 @@ function logArtifactPersistenceFailure(instructionId: string, error: unknown): v
40 console.error(`[artifact] failed to persist execution ${instructionId}: ${message}`);
41 }
42
43+function logInstructionExecutionTimeout(
44+ instruction: BaaInstructionEnvelope,
45+ route: BaaInstructionRoute,
46+ timeoutMs: number
47+): void {
48+ console.warn(
49+ `[baa-executor] execution_timeout instruction=${instruction.instructionId} route=${route.key} `
50+ + `target=${instruction.target} tool=${instruction.tool} timeout_ms=${timeoutMs}`
51+ );
52+}
53+
54 async function withExecutionArtifact(
55 result: BaaInstructionExecutionResult,
56 instruction: BaaInstructionEnvelope,
57@@ -231,17 +224,19 @@ export async function executeBaaInstruction(
58 }
59
60 const requestHandler = options.requestHandler ?? handleConductorHttpRequest;
61- const response = await withRequestTimeout(
62- requestHandler(
63+ const response = await withAbortableTimeout(
64+ timeoutMs,
65+ () => new BaaInstructionExecutionTimeoutError(timeoutMs),
66+ (signal) => requestHandler(
67 {
68 body: route.body == null ? null : JSON.stringify(route.body),
69 headers,
70 method: route.method,
71- path: route.path
72+ path: route.path,
73+ signal
74 },
75 context
76- ),
77- timeoutMs
78+ )
79 );
80
81 let parsedBody: unknown = null;
82@@ -293,6 +288,7 @@ export async function executeBaaInstruction(
83 }, instruction, context);
84 } catch (error) {
85 if (error instanceof BaaInstructionExecutionTimeoutError) {
86+ logInstructionExecutionTimeout(instruction, route, error.timeoutMs);
87 return withExecutionArtifact(
88 toExecutionFailure(
89 instruction,
1@@ -3,10 +3,16 @@ import type {
2 BaaInstructionRoute,
3 BaaJsonObject
4 } from "./types.js";
5+import {
6+ DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS,
7+ DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS
8+} from "../execution-timeouts.js";
9 import { isBaaJsonObject } from "./types.js";
10
11-export const DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS = 30_000;
12-export const DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS = 60_000;
13+export {
14+ DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS,
15+ DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS
16+};
17
18 export class BaaInstructionRouteError extends Error {
19 readonly blockIndex: number;
+67,
-8
1@@ -74,6 +74,7 @@ import {
2 type BrowserRequestPolicyLease
3 } from "./browser-request-policy.js";
4 import type { BaaBrowserDeliveryBridge } from "./artifacts/upload-session.js";
5+import { DEFAULT_BROWSER_PROXY_TIMEOUT_MS } from "./execution-timeouts.js";
6 import {
7 RenewalConversationNotFoundError,
8 getRenewalConversationDetail,
9@@ -97,7 +98,6 @@ const DEFAULT_LIST_LIMIT = 20;
10 const DEFAULT_LOG_LIMIT = 200;
11 const MAX_LIST_LIMIT = 100;
12 const MAX_LOG_LIMIT = 500;
13-const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
14 const DEFAULT_BROWSER_REQUEST_POLICY_CONFIG = createDefaultBrowserRequestPolicyConfig();
15 const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUS_VALUES);
16 const BROWSER_LOGIN_STATUS_SET = new Set<BrowserLoginStateStatus>(["fresh", "stale", "lost"]);
17@@ -2447,16 +2447,44 @@ function readBridgeErrorCode(error: unknown): string | null {
18 return readUnknownString(asUnknownRecord(error), ["code"]);
19 }
20
21+function readBridgeTimeoutMs(error: unknown): number | null {
22+ const record = asUnknownRecord(error);
23+ const value = record?.timeoutMs;
24+
25+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
26+ return null;
27+ }
28+
29+ return Math.round(value);
30+}
31+
32+function logBrowserBridgeTimeout(
33+ action: string,
34+ code: string | null,
35+ clientId: string | null,
36+ requestId: string | null,
37+ timeoutMs: number | null
38+): void {
39+ console.warn(
40+ `[baa-browser-timeout] action=${action} code=${code ?? "timeout"} client_id=${clientId ?? "-"} `
41+ + `request_id=${requestId ?? "-"} timeout_ms=${timeoutMs ?? "-"}`
42+ );
43+}
44+
45 function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiHttpError {
46 const record = asUnknownRecord(error);
47 const code = readBridgeErrorCode(error);
48+ const timeoutMs = readBridgeTimeoutMs(error);
49+ const clientId = readUnknownString(record, ["clientId", "client_id"]);
50+ const requestId = readUnknownString(record, ["requestId", "request_id", "id"]);
51 const details = compactJsonObject({
52 action,
53- bridge_client_id: readUnknownString(record, ["clientId", "client_id"]),
54+ bridge_client_id: clientId,
55 bridge_connection_id: readUnknownString(record, ["connectionId", "connection_id"]),
56- bridge_request_id: readUnknownString(record, ["requestId", "request_id", "id"]),
57+ bridge_request_id: requestId,
58 cause: error instanceof Error ? error.message : String(error),
59- error_code: code
60+ error_code: code,
61+ timeout_ms: timeoutMs ?? undefined
62 });
63
64 switch (code) {
65@@ -2489,6 +2517,7 @@ function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiH
66 details
67 );
68 case "request_timeout":
69+ logBrowserBridgeTimeout(action, code, clientId, requestId, timeoutMs);
70 return new LocalApiHttpError(
71 504,
72 "browser_request_timeout",
73@@ -2496,6 +2525,7 @@ function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiH
74 details
75 );
76 case "action_timeout":
77+ logBrowserBridgeTimeout(action, code, clientId, requestId, timeoutMs);
78 return new LocalApiHttpError(
79 504,
80 "browser_action_timeout",
81@@ -3295,21 +3325,48 @@ async function requestBrowserProxy(
82 }
83 ): Promise<ParsedBrowserProxyResponse> {
84 const bridge = requireBrowserBridge(context);
85+ const requestId = normalizeOptionalString(input.id) ?? randomUUID();
86+ const abortSignal = context.request.signal;
87+ const cancelOnAbort = () => {
88+ try {
89+ bridge.cancelApiRequest({
90+ clientId: input.clientId,
91+ platform: input.platform,
92+ reason: "request_aborted",
93+ requestId
94+ });
95+ } catch {
96+ // Best-effort cancel: the bridge request may have already completed or failed.
97+ }
98+ };
99+
100+ if (abortSignal != null && !abortSignal.aborted) {
101+ abortSignal.addEventListener("abort", cancelOnAbort, {
102+ once: true
103+ });
104+ }
105+
106 let apiResponse: BrowserBridgeApiResponse;
107
108 try {
109- apiResponse = await bridge.apiRequest({
110+ const apiRequestPromise = bridge.apiRequest({
111 body: input.body,
112 clientId: input.clientId,
113 headers: input.headers,
114- id: input.id,
115+ id: requestId,
116 method: input.method,
117 path: input.path,
118 platform: input.platform,
119 timeoutMs: input.timeoutMs ?? DEFAULT_BROWSER_PROXY_TIMEOUT_MS
120 });
121+ if (abortSignal?.aborted) {
122+ cancelOnAbort();
123+ }
124+ apiResponse = await apiRequestPromise;
125 } catch (error) {
126 throw createBrowserBridgeHttpError(input.action, error);
127+ } finally {
128+ abortSignal?.removeEventListener("abort", cancelOnAbort);
129 }
130
131 const parsedBody = parseBrowserProxyBody(apiResponse.body);
132@@ -3834,7 +3891,8 @@ async function requestCodexd(
133 : {
134 accept: "application/json"
135 },
136- body: input.body ? JSON.stringify(input.body) : undefined
137+ body: input.body ? JSON.stringify(input.body) : undefined,
138+ signal: context.request.signal
139 });
140 } catch (error) {
141 throw new LocalApiHttpError(
142@@ -6771,7 +6829,8 @@ async function requestClaudeCoded(
143 : {
144 accept: "application/json"
145 },
146- body: input.body ? JSON.stringify(input.body) : undefined
147+ body: input.body ? JSON.stringify(input.body) : undefined,
148+ signal: context.request.signal
149 });
150 } catch (error) {
151 throw new LocalApiHttpError(
1@@ -14,6 +14,7 @@ import type {
2 BrowserBridgeController
3 } from "../browser-types.js";
4 import type { TimedJobRunner, TimedJobRunnerResult, TimedJobTickContext } from "../timed-jobs/index.js";
5+import { DEFAULT_RENEWAL_EXECUTION_TIMEOUT_MS } from "../execution-timeouts.js";
6
7 import {
8 buildRenewalTargetSnapshot,
9@@ -21,7 +22,6 @@ import {
10 type RenewalProjectorTargetSnapshot
11 } from "./projector.js";
12
13-const DEFAULT_EXECUTION_TIMEOUT_MS = 20_000;
14 const DEFAULT_RECHECK_DELAY_MS = 10_000;
15 const DEFAULT_RETRY_BASE_DELAY_MS = 30_000;
16 const DEFAULT_RETRY_MAX_DELAY_MS = 5 * 60_000;
17@@ -77,6 +77,13 @@ interface RenewalDispatchOutcome {
18 result: BrowserBridgeActionResultSnapshot;
19 }
20
21+interface RenewalExecutionFailure {
22+ errorCode: string | null;
23+ message: string;
24+ result: string;
25+ timeoutMs: number | null;
26+}
27+
28 interface RenewalDispatcherRunnerOptions {
29 browserBridge: BrowserBridgeController | null;
30 executionTimeoutMs?: number;
31@@ -303,7 +310,7 @@ export async function runRenewalDispatcher(
32 assistantMessageId: job.messageId,
33 messageText: payload.text,
34 target: dispatchContext.target,
35- timeoutMs: resolvePositiveInteger(options.executionTimeoutMs, DEFAULT_EXECUTION_TIMEOUT_MS)
36+ timeoutMs: resolvePositiveInteger(options.executionTimeoutMs, DEFAULT_RENEWAL_EXECUTION_TIMEOUT_MS)
37 });
38 const finishedAt = now();
39 const attemptCount = job.attemptCount + 1;
40@@ -343,11 +350,11 @@ export async function runRenewalDispatcher(
41 }
42 });
43 } catch (error) {
44- const errorMessage = toErrorMessage(error);
45+ const failure = normalizeExecutionFailure(error);
46 const attempts = job.attemptCount + 1;
47 const failureResult = await applyFailureOutcome(artifactStore, job, {
48 attemptCount: attempts,
49- errorMessage,
50+ errorMessage: failure.result,
51 logDir: context.logDir,
52 now: now(),
53 retryBaseDelayMs: options.retryBaseDelayMs,
54@@ -362,8 +369,11 @@ export async function runRenewalDispatcher(
55 result: failureResult.result,
56 details: {
57 attempt_count: attempts,
58+ error_code: failure.errorCode,
59+ error_message: failure.message,
60 job_id: job.jobId,
61- message_id: job.messageId
62+ message_id: job.messageId,
63+ timeout_ms: failure.timeoutMs
64 }
65 });
66 } else {
67@@ -373,9 +383,12 @@ export async function runRenewalDispatcher(
68 result: failureResult.result,
69 details: {
70 attempt_count: attempts,
71+ error_code: failure.errorCode,
72+ error_message: failure.message,
73 job_id: job.jobId,
74 message_id: job.messageId,
75- next_attempt_at: failureResult.nextAttemptAt
76+ next_attempt_at: failureResult.nextAttemptAt,
77+ timeout_ms: failure.timeoutMs
78 }
79 });
80 }
81@@ -815,6 +828,50 @@ function sanitizePathSegment(value: string): string {
82 return collapsed === "" ? "unknown" : collapsed;
83 }
84
85+function readErrorCode(error: unknown): string | null {
86+ return isPlainRecord(error) && typeof error.code === "string" ? error.code : null;
87+}
88+
89+function readErrorTimeoutMs(error: unknown): number | null {
90+ if (!isPlainRecord(error) || typeof error.timeoutMs !== "number") {
91+ return null;
92+ }
93+
94+ return Number.isFinite(error.timeoutMs) && error.timeoutMs > 0
95+ ? Math.round(error.timeoutMs)
96+ : null;
97+}
98+
99+function normalizeExecutionFailure(error: unknown): RenewalExecutionFailure {
100+ const errorCode = readErrorCode(error);
101+ const timeoutMs = readErrorTimeoutMs(error);
102+ const message = toErrorMessage(error);
103+
104+ switch (errorCode) {
105+ case "action_timeout":
106+ return {
107+ errorCode,
108+ message,
109+ result: "browser_action_timeout",
110+ timeoutMs
111+ };
112+ case "request_timeout":
113+ return {
114+ errorCode,
115+ message,
116+ result: "browser_request_timeout",
117+ timeoutMs
118+ };
119+ default:
120+ return {
121+ errorCode,
122+ message,
123+ result: message,
124+ timeoutMs
125+ };
126+ }
127+}
128+
129 function toErrorMessage(error: unknown): string {
130 if (error instanceof Error && normalizeOptionalString(error.message) != null) {
131 return error.message;