- 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
+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-"));
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 }
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,
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";