baa-conductor

git clone 

commit
1d3ff73
parent
c42fb56
author
im_wower
date
2026-03-30 03:14:09 +0800 CST
fix: add timeout guard to instruction executor
4 files changed,  +193, -34
M apps/conductor-daemon/src/index.test.js
+59, -2
 1@@ -23,10 +23,13 @@ import {
 2   BrowserRequestPolicyController,
 3   ConductorDaemon,
 4   ConductorRuntime,
 5+  DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS,
 6+  DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS,
 7   PersistentBaaInstructionDeduper,
 8   PersistentBaaLiveInstructionMessageDeduper,
 9   PersistentBaaLiveInstructionSnapshotStore,
10   createFetchControlApiClient,
11+  executeBaaInstruction,
12   extractBaaInstructionBlocks,
13   handleConductorHttpRequest,
14   normalizeBaaInstruction,
15@@ -757,7 +760,8 @@ test("routeBaaInstruction maps browser.claude send/current to the existing local
16     key: "local.browser.claude.send",
17     method: "POST",
18     path: "/v1/browser/claude/send",
19-    requiresSharedToken: false
20+    requiresSharedToken: false,
21+    timeoutMs: DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS
22   });
23 
24   const currentRoute = routeBaaInstruction(createInstructionEnvelope({
25@@ -769,10 +773,63 @@ test("routeBaaInstruction maps browser.claude send/current to the existing local
26     key: "local.browser.claude.current",
27     method: "GET",
28     path: "/v1/browser/claude/current",
29-    requiresSharedToken: false
30+    requiresSharedToken: false,
31+    timeoutMs: DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS
32   });
33 });
34 
35+test("routeBaaInstruction applies a 60s default timeout to conductor exec", () => {
36+  const execRoute = routeBaaInstruction(createInstructionEnvelope({
37+    params: "printf 'exec-timeout-default'",
38+    tool: "exec"
39+  }));
40+
41+  assert.equal(execRoute.timeoutMs, DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS);
42+  assert.deepEqual(execRoute.body, {
43+    command: "printf 'exec-timeout-default'",
44+    timeoutMs: DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS
45+  });
46+});
47+
48+test("executeBaaInstruction returns a structured timeout failure when the local handler stalls", async () => {
49+  const { controlPlane, snapshot } = await createLocalApiFixture();
50+  const instruction = createInstructionEnvelope({
51+    params: "printf 'timeout-protected'",
52+    tool: "exec"
53+  });
54+  const route = {
55+    ...routeBaaInstruction(instruction),
56+    timeoutMs: 20
57+  };
58+
59+  try {
60+    const result = await executeBaaInstruction(
61+      instruction,
62+      route,
63+      {
64+        repository: null,
65+        sharedToken: "local-shared-token",
66+        snapshotLoader: () => snapshot
67+      },
68+      {
69+        requestHandler: () => new Promise(() => {})
70+      }
71+    );
72+
73+    assert.equal(result.ok, false);
74+    assert.equal(result.httpStatus, 504);
75+    assert.equal(result.error, "execution_timeout");
76+    assert.equal(result.message, "Local instruction execution timed out after 20ms.");
77+    assert.deepEqual(result.details, {
78+      timeout_ms: 20
79+    });
80+    assert.equal(result.route.key, "local.exec");
81+    assert.ok(result.artifact);
82+  } finally {
83+    controlPlane.close();
84+  }
85+});
86+
87 test("BaaInstructionCenter runs the Phase 1 execution loop and dedupes replayed messages", async () => {
88   const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
89   const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-instruction-center-"));
M apps/conductor-daemon/src/instructions/executor.ts
+85, -11
  1@@ -3,6 +3,11 @@ import {
  2   handleConductorHttpRequest,
  3   type ConductorLocalApiContext
  4 } from "../local-api.js";
  5+import type { ConductorHttpRequest, ConductorHttpResponse } from "../http-types.js";
  6+import {
  7+  DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS,
  8+  DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS
  9+} from "./router.js";
 10 import type {
 11   BaaInstructionEnvelope,
 12   BaaInstructionExecutionResult,
 13@@ -12,12 +17,29 @@ import type {
 14 } from "./types.js";
 15 import { isBaaJsonValue } from "./types.js";
 16 
 17+export interface ExecuteBaaInstructionOptions {
 18+  requestHandler?: (
 19+    request: ConductorHttpRequest,
 20+    context: ConductorLocalApiContext
 21+  ) => Promise<ConductorHttpResponse>;
 22+}
 23+
 24+class BaaInstructionExecutionTimeoutError extends Error {
 25+  readonly timeoutMs: number;
 26+
 27+  constructor(timeoutMs: number) {
 28+    super(`Local instruction execution timed out after ${timeoutMs}ms.`);
 29+    this.timeoutMs = timeoutMs;
 30+  }
 31+}
 32+
 33 function toExecutionFailure(
 34   instruction: BaaInstructionEnvelope,
 35   route: BaaInstructionRoute,
 36   message: string,
 37   error = "execution_failed",
 38-  details: BaaJsonValue | null = null
 39+  details: BaaJsonValue | null = null,
 40+  httpStatus = 500
 41 ): BaaInstructionExecutionResult {
 42   return {
 43     artifact: null,
 44@@ -25,7 +47,7 @@ function toExecutionFailure(
 45     dedupeKey: instruction.dedupeKey,
 46     details,
 47     error,
 48-    httpStatus: 500,
 49+    httpStatus,
 50     instructionId: instruction.instructionId,
 51     message,
 52     ok: false,
 53@@ -40,6 +62,35 @@ function toExecutionFailure(
 54   };
 55 }
 56 
 57+function resolveExecutionTimeoutMs(route: BaaInstructionRoute): number {
 58+  if (Number.isInteger(route.timeoutMs) && route.timeoutMs > 0) {
 59+    return route.timeoutMs;
 60+  }
 61+
 62+  return route.key === "local.exec"
 63+    ? DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS
 64+    : DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS;
 65+}
 66+
 67+function withRequestTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
 68+  return new Promise((resolve, reject) => {
 69+    const timeoutId = setTimeout(() => {
 70+      reject(new BaaInstructionExecutionTimeoutError(timeoutMs));
 71+    }, timeoutMs);
 72+
 73+    promise.then(
 74+      (value) => {
 75+        clearTimeout(timeoutId);
 76+        resolve(value);
 77+      },
 78+      (error) => {
 79+        clearTimeout(timeoutId);
 80+        reject(error);
 81+      }
 82+    );
 83+  });
 84+}
 85+
 86 function normalizeJsonBodyValue(value: unknown): BaaJsonValue | null {
 87   return isBaaJsonValue(value) ? value : null;
 88 }
 89@@ -166,9 +217,11 @@ async function withExecutionArtifact(
 90 export async function executeBaaInstruction(
 91   instruction: BaaInstructionEnvelope,
 92   route: BaaInstructionRoute,
 93-  context: ConductorLocalApiContext
 94+  context: ConductorLocalApiContext,
 95+  options: ExecuteBaaInstructionOptions = {}
 96 ): Promise<BaaInstructionExecutionResult> {
 97   try {
 98+    const timeoutMs = resolveExecutionTimeoutMs(route);
 99     const headers: Record<string, string> = {
100       "content-type": "application/json"
101     };
102@@ -177,14 +230,18 @@ export async function executeBaaInstruction(
103       headers.authorization = `Bearer ${context.sharedToken}`;
104     }
105 
106-    const response = await handleConductorHttpRequest(
107-      {
108-        body: route.body == null ? null : JSON.stringify(route.body),
109-        headers,
110-        method: route.method,
111-        path: route.path
112-      },
113-      context
114+    const requestHandler = options.requestHandler ?? handleConductorHttpRequest;
115+    const response = await withRequestTimeout(
116+      requestHandler(
117+        {
118+          body: route.body == null ? null : JSON.stringify(route.body),
119+          headers,
120+          method: route.method,
121+          path: route.path
122+        },
123+        context
124+      ),
125+      timeoutMs
126     );
127 
128     let parsedBody: unknown = null;
129@@ -235,6 +292,23 @@ export async function executeBaaInstruction(
130       tool: instruction.tool
131     }, instruction, context);
132   } catch (error) {
133+    if (error instanceof BaaInstructionExecutionTimeoutError) {
134+      return withExecutionArtifact(
135+        toExecutionFailure(
136+          instruction,
137+          route,
138+          error.message,
139+          "execution_timeout",
140+          {
141+            timeout_ms: error.timeoutMs
142+          },
143+          504
144+        ),
145+        instruction,
146+        context
147+      );
148+    }
149+
150     const message = error instanceof Error ? error.message : String(error);
151     return withExecutionArtifact(toExecutionFailure(instruction, route, message), instruction, context);
152   }
M apps/conductor-daemon/src/instructions/router.ts
+48, -21
  1@@ -5,6 +5,9 @@ import type {
  2 } from "./types.js";
  3 import { isBaaJsonObject } from "./types.js";
  4 
  5+export const DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS = 30_000;
  6+export const DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS = 60_000;
  7+
  8 export class BaaInstructionRouteError extends Error {
  9   readonly blockIndex: number;
 10   readonly stage = "route";
 11@@ -66,11 +69,35 @@ function requireNoParams(instruction: BaaInstructionEnvelope): void {
 12   }
 13 }
 14 
 15+function withRouteTimeout(
 16+  route: Omit<BaaInstructionRoute, "timeoutMs">,
 17+  timeoutMs = DEFAULT_BAA_INSTRUCTION_TIMEOUT_MS
 18+): BaaInstructionRoute {
 19+  return {
 20+    ...route,
 21+    timeoutMs
 22+  };
 23+}
 24+
 25+function applyDefaultExecTimeout(body: BaaJsonObject): BaaJsonObject {
 26+  if (
 27+    Object.prototype.hasOwnProperty.call(body, "timeoutMs")
 28+    || Object.prototype.hasOwnProperty.call(body, "timeout_ms")
 29+  ) {
 30+    return body;
 31+  }
 32+
 33+  return {
 34+    ...body,
 35+    timeoutMs: DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS
 36+  };
 37+}
 38+
 39 function normalizeExecBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
 40   if (typeof instruction.params === "string") {
 41-    return {
 42+    return applyDefaultExecTimeout({
 43       command: requireNonEmptyStringParam(instruction, "command", true)
 44-    };
 45+    });
 46   }
 47 
 48   const params = requireJsonObjectParams(instruction);
 49@@ -83,7 +110,7 @@ function normalizeExecBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
 50     );
 51   }
 52 
 53-  return params;
 54+  return applyDefaultExecTimeout(params);
 55 }
 56 
 57 function normalizeFileReadBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
 58@@ -162,64 +189,64 @@ function routeLocalInstruction(instruction: BaaInstructionEnvelope): BaaInstruct
 59   switch (instruction.tool) {
 60     case "describe":
 61       requireNoParams(instruction);
 62-      return {
 63+      return withRouteTimeout({
 64         body: null,
 65         key: "local.describe",
 66         method: "GET",
 67         path: "/describe",
 68         requiresSharedToken: false
 69-      };
 70+      });
 71     case "describe/business":
 72       requireNoParams(instruction);
 73-      return {
 74+      return withRouteTimeout({
 75         body: null,
 76         key: "local.describe.business",
 77         method: "GET",
 78         path: "/describe/business",
 79         requiresSharedToken: false
 80-      };
 81+      });
 82     case "describe/control":
 83       requireNoParams(instruction);
 84-      return {
 85+      return withRouteTimeout({
 86         body: null,
 87         key: "local.describe.control",
 88         method: "GET",
 89         path: "/describe/control",
 90         requiresSharedToken: false
 91-      };
 92+      });
 93     case "status":
 94       requireNoParams(instruction);
 95-      return {
 96+      return withRouteTimeout({
 97         body: null,
 98         key: "local.status",
 99         method: "GET",
100         path: "/v1/status",
101         requiresSharedToken: false
102-      };
103+      });
104     case "exec":
105-      return {
106+      return withRouteTimeout({
107         body: normalizeExecBody(instruction),
108         key: "local.exec",
109         method: "POST",
110         path: "/v1/exec",
111         requiresSharedToken: true
112-      };
113+      }, DEFAULT_BAA_EXEC_INSTRUCTION_TIMEOUT_MS);
114     case "files/read":
115-      return {
116+      return withRouteTimeout({
117         body: normalizeFileReadBody(instruction),
118         key: "local.files.read",
119         method: "POST",
120         path: "/v1/files/read",
121         requiresSharedToken: true
122-      };
123+      });
124     case "files/write":
125-      return {
126+      return withRouteTimeout({
127         body: normalizeFileWriteBody(instruction),
128         key: "local.files.write",
129         method: "POST",
130         path: "/v1/files/write",
131         requiresSharedToken: true
132-      };
133+      });
134     default:
135       throw new BaaInstructionRouteError(
136         instruction.blockIndex,
137@@ -231,22 +258,22 @@ function routeLocalInstruction(instruction: BaaInstructionEnvelope): BaaInstruct
138 function routeBrowserClaudeInstruction(instruction: BaaInstructionEnvelope): BaaInstructionRoute {
139   switch (instruction.tool) {
140     case "send":
141-      return {
142+      return withRouteTimeout({
143         body: normalizeBrowserClaudeSendBody(instruction),
144         key: "local.browser.claude.send",
145         method: "POST",
146         path: "/v1/browser/claude/send",
147         requiresSharedToken: false
148-      };
149+      });
150     case "current":
151       requireNoParams(instruction);
152-      return {
153+      return withRouteTimeout({
154         body: null,
155         key: "local.browser.claude.current",
156         method: "GET",
157         path: "/v1/browser/claude/current",
158         requiresSharedToken: false
159-      };
160+      });
161     default:
162       throw new BaaInstructionRouteError(
163         instruction.blockIndex,
M apps/conductor-daemon/src/instructions/types.ts
+1, -0
1@@ -65,6 +65,7 @@ export interface BaaInstructionRoute {
2   method: "GET" | "POST";
3   path: string;
4   requiresSharedToken: boolean;
5+  timeoutMs: number;
6 }
7 
8 export type BaaInstructionDeniedStage = "policy" | "route";