baa-conductor

git clone 

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
M apps/conductor-daemon/src/artifacts/upload-session.ts
+8, -6
 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;
A apps/conductor-daemon/src/execution-timeouts.ts
+69, -0
 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+}
M apps/conductor-daemon/src/firefox-bridge.ts
+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   }
M apps/conductor-daemon/src/index.test.js
+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 
M apps/conductor-daemon/src/instructions/executor.ts
+22, -26
 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,
M apps/conductor-daemon/src/instructions/router.ts
+8, -2
 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;
M apps/conductor-daemon/src/local-api.ts
+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(
M apps/conductor-daemon/src/renewal/dispatcher.ts
+63, -6
  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;