harness-dispatch 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +228 -86
  2. package/config.default.yaml +416 -397
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js +2 -2
  5. package/dist/auth.js.map +1 -1
  6. package/dist/billing.d.ts +35 -4
  7. package/dist/billing.d.ts.map +1 -1
  8. package/dist/billing.js +48 -17
  9. package/dist/billing.js.map +1 -1
  10. package/dist/bin.d.ts.map +1 -1
  11. package/dist/bin.js +253 -23
  12. package/dist/bin.js.map +1 -1
  13. package/dist/breaker-store.d.ts +60 -9
  14. package/dist/breaker-store.d.ts.map +1 -1
  15. package/dist/breaker-store.js +246 -50
  16. package/dist/breaker-store.js.map +1 -1
  17. package/dist/circuit-breaker.d.ts +35 -0
  18. package/dist/circuit-breaker.d.ts.map +1 -1
  19. package/dist/circuit-breaker.js +68 -11
  20. package/dist/circuit-breaker.js.map +1 -1
  21. package/dist/config/coercions.d.ts +31 -0
  22. package/dist/config/coercions.d.ts.map +1 -0
  23. package/dist/config/coercions.js +169 -0
  24. package/dist/config/coercions.js.map +1 -0
  25. package/dist/config/env-interpolation.d.ts +36 -0
  26. package/dist/config/env-interpolation.d.ts.map +1 -0
  27. package/dist/config/env-interpolation.js +73 -0
  28. package/dist/config/env-interpolation.js.map +1 -0
  29. package/dist/config/protocol.d.ts +51 -0
  30. package/dist/config/protocol.d.ts.map +1 -0
  31. package/dist/config/protocol.js +300 -0
  32. package/dist/config/protocol.js.map +1 -0
  33. package/dist/config/validation.d.ts +72 -0
  34. package/dist/config/validation.d.ts.map +1 -0
  35. package/dist/config/validation.js +189 -0
  36. package/dist/config/validation.js.map +1 -0
  37. package/dist/config.d.ts +19 -2
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +229 -511
  40. package/dist/config.js.map +1 -1
  41. package/dist/dispatch-log.d.ts.map +1 -1
  42. package/dist/dispatch-log.js +2 -3
  43. package/dist/dispatch-log.js.map +1 -1
  44. package/dist/dispatchers/generic-cli.d.ts +15 -1
  45. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  46. package/dist/dispatchers/generic-cli.js +65 -5
  47. package/dist/dispatchers/generic-cli.js.map +1 -1
  48. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  49. package/dist/dispatchers/openai-compatible.js +52 -2
  50. package/dist/dispatchers/openai-compatible.js.map +1 -1
  51. package/dist/dispatchers/shared/kill-tree.d.ts +17 -4
  52. package/dist/dispatchers/shared/kill-tree.d.ts.map +1 -1
  53. package/dist/dispatchers/shared/kill-tree.js +28 -4
  54. package/dist/dispatchers/shared/kill-tree.js.map +1 -1
  55. package/dist/dispatchers/shared/rate-limit-headers.d.ts +17 -0
  56. package/dist/dispatchers/shared/rate-limit-headers.d.ts.map +1 -1
  57. package/dist/dispatchers/shared/rate-limit-headers.js +27 -0
  58. package/dist/dispatchers/shared/rate-limit-headers.js.map +1 -1
  59. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
  60. package/dist/dispatchers/shared/stream-subprocess.js +5 -0
  61. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
  62. package/dist/dispatchers/shared/subprocess.d.ts.map +1 -1
  63. package/dist/dispatchers/shared/subprocess.js +3 -0
  64. package/dist/dispatchers/shared/subprocess.js.map +1 -1
  65. package/dist/dispatchers/shared/which-available.d.ts.map +1 -1
  66. package/dist/dispatchers/shared/which-available.js +16 -2
  67. package/dist/dispatchers/shared/which-available.js.map +1 -1
  68. package/dist/file-lock.d.ts +17 -0
  69. package/dist/file-lock.d.ts.map +1 -0
  70. package/dist/file-lock.js +143 -0
  71. package/dist/file-lock.js.map +1 -0
  72. package/dist/http/server.d.ts +11 -0
  73. package/dist/http/server.d.ts.map +1 -1
  74. package/dist/http/server.js +331 -162
  75. package/dist/http/server.js.map +1 -1
  76. package/dist/job-runner.js +23 -5
  77. package/dist/job-runner.js.map +1 -1
  78. package/dist/jobs.d.ts +91 -1
  79. package/dist/jobs.d.ts.map +1 -1
  80. package/dist/jobs.js +647 -36
  81. package/dist/jobs.js.map +1 -1
  82. package/dist/leaderboard.d.ts +24 -1
  83. package/dist/leaderboard.d.ts.map +1 -1
  84. package/dist/leaderboard.js +94 -2
  85. package/dist/leaderboard.js.map +1 -1
  86. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  87. package/dist/mcp/config-hot-reload.js +1 -1
  88. package/dist/mcp/config-hot-reload.js.map +1 -1
  89. package/dist/mcp/dispatcher-factory.d.ts +16 -1
  90. package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
  91. package/dist/mcp/dispatcher-factory.js +41 -3
  92. package/dist/mcp/dispatcher-factory.js.map +1 -1
  93. package/dist/mcp/tools.d.ts +23 -4
  94. package/dist/mcp/tools.d.ts.map +1 -1
  95. package/dist/mcp/tools.js +163 -16
  96. package/dist/mcp/tools.js.map +1 -1
  97. package/dist/observability/index.d.ts +16 -3
  98. package/dist/observability/index.d.ts.map +1 -1
  99. package/dist/observability/index.js +23 -7
  100. package/dist/observability/index.js.map +1 -1
  101. package/dist/observability/spans.d.ts.map +1 -1
  102. package/dist/observability/spans.js +4 -1
  103. package/dist/observability/spans.js.map +1 -1
  104. package/dist/quota.d.ts +88 -22
  105. package/dist/quota.d.ts.map +1 -1
  106. package/dist/quota.js +163 -58
  107. package/dist/quota.js.map +1 -1
  108. package/dist/route-policy.d.ts.map +1 -1
  109. package/dist/route-policy.js +13 -3
  110. package/dist/route-policy.js.map +1 -1
  111. package/dist/router.d.ts +25 -1
  112. package/dist/router.d.ts.map +1 -1
  113. package/dist/router.js +94 -40
  114. package/dist/router.js.map +1 -1
  115. package/dist/safety.d.ts.map +1 -1
  116. package/dist/safety.js +11 -2
  117. package/dist/safety.js.map +1 -1
  118. package/dist/state-dir.d.ts +17 -0
  119. package/dist/state-dir.d.ts.map +1 -0
  120. package/dist/state-dir.js +21 -0
  121. package/dist/state-dir.js.map +1 -0
  122. package/dist/status.d.ts +36 -0
  123. package/dist/status.d.ts.map +1 -1
  124. package/dist/status.js +61 -4
  125. package/dist/status.js.map +1 -1
  126. package/dist/types.d.ts +50 -1
  127. package/dist/types.d.ts.map +1 -1
  128. package/dist/working-dir.d.ts +13 -0
  129. package/dist/working-dir.d.ts.map +1 -1
  130. package/dist/working-dir.js +27 -0
  131. package/dist/working-dir.js.map +1 -1
  132. package/dist/workspace-lock.d.ts +40 -0
  133. package/dist/workspace-lock.d.ts.map +1 -0
  134. package/dist/workspace-lock.js +258 -0
  135. package/dist/workspace-lock.js.map +1 -0
  136. package/dist/workspaces.d.ts +15 -0
  137. package/dist/workspaces.d.ts.map +1 -1
  138. package/dist/workspaces.js +104 -12
  139. package/dist/workspaces.js.map +1 -1
  140. package/package.json +98 -96
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/http/server.ts"],"names":[],"mappings":"AAQA,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,SAAS,EACf,MAAM,kBAAkB,CAAC;AAO1B,MAAM,WAAW,gBAAiB,SAAQ,SAAS;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,gBAAiB,SAAQ,eAAe;IACvD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AA4ND,wBAAsB,eAAe,CAAC,IAAI,GAAE,gBAAqB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAyX5F;AAED,eAAO,MAAM,kBAAkB,wBAAkB,CAAC"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/http/server.ts"],"names":[],"mappings":"AAQA,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,SAAS,EACf,MAAM,kBAAkB,CAAC;AAU1B,MAAM,WAAW,gBAAiB,SAAQ,SAAS;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,gBAAiB,SAAQ,eAAe;IACvD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAiDD;;;;;;;;GAQG;AACH,qBAAa,eAAgB,SAAQ,KAAK;CAAG;AA0gB7C,wBAAsB,eAAe,CAAC,IAAI,GAAE,gBAAqB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAgN5F;AAED,eAAO,MAAM,kBAAkB,wBAAkB,CAAC"}
@@ -6,7 +6,8 @@ import { buildMcpServer, buildMcpServerInstance, } from "../mcp/server.js";
6
6
  import { buildStatus, buildUsage } from "../status.js";
7
7
  import { evaluateRoutePolicy } from "../route-policy.js";
8
8
  import { isIsolatedWorkspacePolicy } from "../workspaces.js";
9
- import { resolveWorkingDir, workingDirWarning } from "../working-dir.js";
9
+ import { resolveWorkingDir, validateWorkingDir, workingDirWarning } from "../working-dir.js";
10
+ import { getAsyncJob, startAsyncJobTracked } from "../jobs.js";
10
11
  function sendJson(res, statusCode, body) {
11
12
  const text = JSON.stringify(body, null, 2);
12
13
  res.writeHead(statusCode, {
@@ -31,6 +32,19 @@ function isLoopbackHost(host) {
31
32
  // any authorized (or, if --host is opened beyond loopback, network-adjacent)
32
33
  // caller exhaust process memory with one oversized POST.
33
34
  const MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024;
35
+ /**
36
+ * A request the CALLER can fix — malformed JSON, a missing required field, a
37
+ * working directory that does not exist.
38
+ *
39
+ * Everything except the 413 was returned as 500. PRODUCT.md names CI and cron
40
+ * as consumers of this surface, and retry-on-5xx will happily retry a request
41
+ * that can never succeed. A 4xx says "stop and fix the request", which is the
42
+ * true statement.
43
+ */
44
+ export class BadRequestError extends Error {
45
+ }
46
+ /** Mirrors MAX_CONTEXT_FILES in mcp/tools.ts — the two surfaces must agree. */
47
+ const MAX_CONTEXT_FILES_HTTP = 64;
34
48
  class PayloadTooLargeError extends Error {
35
49
  }
36
50
  async function readJson(req, maxBytes = MAX_REQUEST_BODY_BYTES) {
@@ -49,7 +63,14 @@ async function readJson(req, maxBytes = MAX_REQUEST_BODY_BYTES) {
49
63
  chunks.push(buf);
50
64
  }
51
65
  const text = Buffer.concat(chunks).toString("utf-8");
52
- return text.trim() ? JSON.parse(text) : {};
66
+ if (!text.trim())
67
+ return {};
68
+ try {
69
+ return JSON.parse(text);
70
+ }
71
+ catch (err) {
72
+ throw new BadRequestError(`request body is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
73
+ }
53
74
  }
54
75
  function contentToText(content) {
55
76
  if (typeof content === "string")
@@ -115,6 +136,37 @@ function parseHints(body) {
115
136
  raw.workspacePolicy === "git_worktree") {
116
137
  hints.workspacePolicy = raw.workspacePolicy;
117
138
  }
139
+ // routePolicy was never read here at all. evaluateRoutePolicy implements
140
+ // local_only, approval_required and blocked in full, and on this surface
141
+ // they were wired to nothing: POST {"hints":{"routePolicy":"blocked"}}
142
+ // returned 200 and dispatched. PRODUCT.md names CI and cron as this
143
+ // surface's consumers, and calls a guarantee that reads correctly and does
144
+ // nothing at runtime its own counter-signal.
145
+ if (raw.routePolicy === "local_only" ||
146
+ raw.routePolicy === "approval_required" ||
147
+ raw.routePolicy === "blocked") {
148
+ hints.routePolicy = raw.routePolicy;
149
+ }
150
+ if (typeof raw.timeoutMs === "number" && Number.isFinite(raw.timeoutMs)) {
151
+ hints.timeoutMs = raw.timeoutMs;
152
+ }
153
+ // Unknown keys are REJECTED, matching the MCP surface. The whole point of
154
+ // making hints strict there was that `safety_profile` (the config
155
+ // spelling) silently disabled a safety limit; accepting it here left the
156
+ // identical typo failing open on the other surface.
157
+ const known = new Set([
158
+ "model",
159
+ "taskType",
160
+ "preferLargeContext",
161
+ "safetyProfile",
162
+ "workspacePolicy",
163
+ "routePolicy",
164
+ "timeoutMs",
165
+ ]);
166
+ const unknown = Object.keys(raw).filter((k) => !known.has(k));
167
+ if (unknown.length > 0) {
168
+ throw new BadRequestError(`unknown hints key(s): ${unknown.join(", ")}. Valid: ${[...known].join(", ")}.`);
169
+ }
118
170
  }
119
171
  if (body.workspacePolicy === "shared" ||
120
172
  body.workspacePolicy === "shared_locked" ||
@@ -125,15 +177,33 @@ function parseHints(body) {
125
177
  return hints;
126
178
  }
127
179
  function parseChatRequest(raw) {
128
- if (!raw || typeof raw !== "object") {
129
- throw new Error("request body must be a JSON object");
180
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
181
+ // Was a plain Error, so `null`, `"hello"` and `42` all returned 500. The
182
+ // BadRequestError mapping was added for JSON PARSE failures only — same
183
+ // fix, one of two paths, which is the pattern this file keeps repeating.
184
+ throw new BadRequestError("request body must be a JSON object");
130
185
  }
131
186
  const body = raw;
132
187
  const prompt = typeof body.prompt === "string" && body.prompt.trim()
133
188
  ? body.prompt
134
189
  : messagesToPrompt(body.messages);
135
190
  if (!prompt.trim())
136
- throw new Error("messages or prompt is required");
191
+ throw new BadRequestError("messages or prompt is required");
192
+ // MCP validates this; HTTP did not, so `workingDir: "Z:/nope"` surfaced as
193
+ // `spawn node.EXE ENOENT` — verbatim the wrong-cause error working-dir.ts
194
+ // exists to prevent, on the surface CI uses.
195
+ const workingDirError = validateWorkingDir(typeof body.workingDir === "string"
196
+ ? body.workingDir
197
+ : undefined);
198
+ if (workingDirError !== undefined)
199
+ throw new BadRequestError(workingDirError);
200
+ // Same cap as the MCP surface, and for the same reason: each file's parent
201
+ // directory becomes an --add-dir grant on CLI routes, so an unbounded list
202
+ // is an unbounded set of directories handed to a coding agent. The cap was
203
+ // added at the MCP boundary only; this surface accepted 500.
204
+ if (Array.isArray(body.files) && body.files.length > MAX_CONTEXT_FILES_HTTP) {
205
+ throw new BadRequestError(`files: ${body.files.length} entries exceeds the maximum of ${MAX_CONTEXT_FILES_HTTP}.`);
206
+ }
137
207
  const files = Array.isArray(body.files)
138
208
  ? body.files.filter((v) => typeof v === "string")
139
209
  : [];
@@ -153,6 +223,235 @@ function parseChatRequest(raw) {
153
223
  hints: parseHints(body),
154
224
  };
155
225
  }
226
+ /**
227
+ * Run fanout arms to completion INDEPENDENTLY of one another.
228
+ *
229
+ * Promise.all rejected the whole batch when one arm threw (workspace lock
230
+ * timeout, worktree setup failure), discarding every other arm's completed —
231
+ * and possibly billed — work behind a single 500. Each arm now settles on its
232
+ * own; a thrown arm becomes a failed row naming its route. One row shape for
233
+ * the streaming and non-streaming branches on purpose: they used to differ
234
+ * (streaming omitted success/error), the one-sibling-guarded pattern again.
235
+ */
236
+ async function runFanoutArms(router, routes, parsed) {
237
+ const settled = await Promise.allSettled(routes.map((route) => router.routeTo(route, parsed.prompt, parsed.files, parsed.workingDir, {
238
+ ...(parsed.hints.safetyProfile !== undefined
239
+ ? { safetyProfile: parsed.hints.safetyProfile }
240
+ : {}),
241
+ ...(parsed.hints.workspacePolicy !== undefined
242
+ ? { workspacePolicy: parsed.hints.workspacePolicy }
243
+ : {}),
244
+ ...(parsed.hints.taskType !== undefined ? { taskType: parsed.hints.taskType } : {}),
245
+ })));
246
+ return settled.map((s, i) => {
247
+ if (s.status === "fulfilled") {
248
+ const r = s.value.result;
249
+ return {
250
+ route: r.service,
251
+ success: r.success,
252
+ output: r.output,
253
+ ...(r.error !== undefined ? { error: r.error } : {}),
254
+ ...(r.workspace !== undefined ? { workspace: r.workspace } : {}),
255
+ };
256
+ }
257
+ return {
258
+ route: routes[i],
259
+ success: false,
260
+ output: "",
261
+ error: s.reason instanceof Error ? s.reason.message : String(s.reason),
262
+ };
263
+ });
264
+ }
265
+ /**
266
+ * POST /v1/chat/completions — extracted from the request callback, where
267
+ * this logic sat eleven brace-levels deep (the smell checker's worst
268
+ * finding for the whole repo). Behaviour is unchanged; only the nesting
269
+ * moved.
270
+ */
271
+ async function handleChatCompletions(holder, parsed, res, sse) {
272
+ const state = holder.state;
273
+ if (parsed.mode === "fanout") {
274
+ const fanoutSafetyProfile = parsed.hints.safetyProfile ?? "read_only";
275
+ if (fanoutSafetyProfile !== "read_only" &&
276
+ (parsed.hints.workspacePolicy === undefined ||
277
+ !isIsolatedWorkspacePolicy(parsed.hints.workspacePolicy))) {
278
+ sendJson(res, 400, {
279
+ error: {
280
+ message: "write-capable fanout requires workspacePolicy=copy or workspacePolicy=git_worktree; use read_only fanout or run single-route workspace_edit",
281
+ code: "workspace_isolation_required",
282
+ },
283
+ });
284
+ return;
285
+ }
286
+ parsed.hints.safetyProfile = fanoutSafetyProfile;
287
+ }
288
+ const eligibleRoutes = (requestedRoutes) => {
289
+ const routes = [];
290
+ const skippedRoutes = [];
291
+ for (const route of requestedRoutes) {
292
+ const svc = state.config.services[route];
293
+ if (!svc) {
294
+ // The MCP tool rejects unknown fanout targets by name; this
295
+ // surface silently skipped them, returning 200 with fewer arms
296
+ // and an empty skippedRoutes. Same input, two answers.
297
+ throw new BadRequestError(`Unknown fanout target: ${route}. Valid route ids: ` +
298
+ `${Object.keys(state.config.services).join(", ")}.`);
299
+ }
300
+ const dispatcher = state.dispatchers[route];
301
+ const breaker = state.router.getBreaker(route);
302
+ const policy = evaluateRoutePolicy(route, svc, {
303
+ ...(dispatcher !== undefined ? { dispatcher } : {}),
304
+ circuitBroken: Boolean(breaker?.isTripped),
305
+ ...(parsed.hints.safetyProfile !== undefined
306
+ ? { requestedSafetyProfile: parsed.hints.safetyProfile }
307
+ : {}),
308
+ });
309
+ if (policy.skipped)
310
+ skippedRoutes.push(policy.skipped);
311
+ if (!policy.blocked)
312
+ routes.push(route);
313
+ }
314
+ return { routes, skippedRoutes };
315
+ };
316
+ if (parsed.stream) {
317
+ // Resolve fanout targets BEFORE writing SSE headers.
318
+ //
319
+ // eligibleRoutes throws BadRequestError for an unknown route, but it
320
+ // was called after writeHead — so headersSent was true, the error
321
+ // handler could only res.end(), and the caller got HTTP 200 with a
322
+ // zero-byte body. The non-streaming path returns a proper 400 with
323
+ // the valid ids. Same input, two answers, again.
324
+ // Default to every dispatchable route when `models` is omitted,
325
+ // exactly as the non-streaming branch does. Streaming passed
326
+ // parsed.models straight through, so {"mode":"fanout","stream":true}
327
+ // with no models fanned out to ZERO routes and reported success —
328
+ // content "[]", empty skippedRoutes, HTTP 200.
329
+ const preSelected = parsed.mode === "fanout"
330
+ ? eligibleRoutes(parsed.models.length > 0
331
+ ? parsed.models
332
+ : Object.keys(state.config.services).filter((route) => route in state.dispatchers))
333
+ : undefined;
334
+ res.writeHead(200, {
335
+ "content-type": "text/event-stream; charset=utf-8",
336
+ "cache-control": "no-cache",
337
+ connection: "keep-alive",
338
+ });
339
+ sse.started = true;
340
+ if (parsed.mode === "fanout") {
341
+ const selected = preSelected;
342
+ const rows = await runFanoutArms(state.router, selected.routes, parsed);
343
+ writeSse(res, {
344
+ choices: [
345
+ {
346
+ index: 0,
347
+ delta: {
348
+ content: JSON.stringify(rows),
349
+ },
350
+ finish_reason: null,
351
+ },
352
+ ],
353
+ harness_dispatch: {
354
+ mode: "fanout",
355
+ skippedRoutes: selected.skippedRoutes,
356
+ ...(parsed.workingDirWarning !== undefined
357
+ ? { warning: parsed.workingDirWarning }
358
+ : {}),
359
+ },
360
+ });
361
+ }
362
+ else {
363
+ for await (const { event, decision } of state.router.stream(parsed.prompt, parsed.files, parsed.workingDir, { hints: parsed.hints, maxFallbacks: 2 })) {
364
+ if (event.type === "stdout") {
365
+ writeSse(res, {
366
+ choices: [
367
+ {
368
+ index: 0,
369
+ delta: { content: event.chunk },
370
+ finish_reason: null,
371
+ },
372
+ ],
373
+ harness_dispatch: decision ? { route: decision.service } : undefined,
374
+ });
375
+ }
376
+ else if (event.type === "completion" && !event.result.success) {
377
+ writeSse(res, {
378
+ error: {
379
+ message: event.result.error ?? "routing failed",
380
+ route: event.result.service,
381
+ },
382
+ });
383
+ }
384
+ }
385
+ }
386
+ writeSse(res, {
387
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
388
+ });
389
+ res.write("data: [DONE]\n\n");
390
+ res.end();
391
+ return;
392
+ }
393
+ if (parsed.mode === "fanout") {
394
+ const routes = parsed.models.length > 0
395
+ ? parsed.models
396
+ : Object.keys(state.config.services).filter((route) => route in state.dispatchers);
397
+ const selected = eligibleRoutes(routes);
398
+ const rows = await runFanoutArms(state.router, selected.routes, parsed);
399
+ sendJson(res, 200, completionEnvelope(JSON.stringify(rows, null, 2), typeof parsed.hints.model === "string" ? parsed.hints.model : "harness-dispatch", {
400
+ harness_dispatch: {
401
+ mode: "fanout",
402
+ skippedRoutes: selected.skippedRoutes,
403
+ ...(parsed.workingDirWarning !== undefined
404
+ ? { warning: parsed.workingDirWarning }
405
+ : {}),
406
+ },
407
+ }));
408
+ return;
409
+ }
410
+ // Backed by a persisted job, not a bare in-process await. This
411
+ // surface's users are curl/CI/cron — exactly the clients that enforce
412
+ // their own request timeouts — and a direct await meant a client that
413
+ // gave up mid-run lost the finished result with no way to retrieve
414
+ // it. The job survives (it runs detached and lands on disk); the
415
+ // jobId is exposed in the response AND in a header so even a caller
416
+ // that only captured headers before timing out can recover the
417
+ // result via `job_status`.
418
+ const { status: jobStatus, completion } = await startAsyncJobTracked({ holder }, {
419
+ prompt: parsed.prompt,
420
+ files: parsed.files,
421
+ workingDir: parsed.workingDir,
422
+ hints: parsed.hints,
423
+ });
424
+ res.setHeader("x-harness-dispatch-job-id", jobStatus.jobId);
425
+ await completion;
426
+ const job = await getAsyncJob(jobStatus.jobId);
427
+ const result = job.result?.result;
428
+ const decision = job.result?.decision ?? undefined;
429
+ if (result === undefined) {
430
+ // Terminal without a result payload: the runner died or was
431
+ // orphaned. The job dir still names what happened.
432
+ sendJson(res, 500, {
433
+ error: `job ${jobStatus.jobId} ended without a result (status: ${job.status.status}); ` +
434
+ `check job_status for details`,
435
+ jobId: jobStatus.jobId,
436
+ });
437
+ return;
438
+ }
439
+ sendJson(res, 200, completionEnvelope(result.output, decision?.model ?? parsed.hints.model ?? "harness-dispatch", {
440
+ harness_dispatch: {
441
+ jobId: jobStatus.jobId,
442
+ route: result.service,
443
+ success: result.success,
444
+ error: result.error,
445
+ workspace: result.workspace,
446
+ routing: decision,
447
+ skippedRoutes: decision?.skippedRoutes ?? result.skippedRoutes,
448
+ ...(parsed.workingDirWarning !== undefined
449
+ ? { warning: parsed.workingDirWarning }
450
+ : {}),
451
+ },
452
+ }));
453
+ return;
454
+ }
156
455
  function completionEnvelope(content, model, extra) {
157
456
  return {
158
457
  id: `chatcmpl-${randomUUID()}`,
@@ -190,9 +489,16 @@ export async function startHttpServer(opts = {}) {
190
489
  sendJson(res, 401, { error: "unauthorized" });
191
490
  return false;
192
491
  };
492
+ // A bare IPv6 host must be bracketed to be legal inside a URL:
493
+ // new URL("/mcp", "http://::1") throws "Invalid URL", which turned EVERY
494
+ // request to a server bound on ::1 into a 500 — the bind succeeded and the
495
+ // loopback check blessed the address, so nothing else ever caught it.
496
+ const urlBase = `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}`;
193
497
  const http = createServer(async (req, res) => {
498
+ // Shared with handleChatCompletions, read by the catch below.
499
+ const sse = { started: false };
194
500
  try {
195
- const url = new URL(req.url ?? "/", `http://${host}`);
501
+ const url = new URL(req.url ?? "/", urlBase);
196
502
  if (url.pathname === "/v1/models" && req.method === "GET") {
197
503
  if (!requireAuth(req, res))
198
504
  return;
@@ -244,161 +550,7 @@ export async function startHttpServer(opts = {}) {
244
550
  return;
245
551
  await reloader.maybeReload();
246
552
  const parsed = parseChatRequest(await readJson(req));
247
- const state = holder.state;
248
- if (parsed.mode === "fanout") {
249
- const fanoutSafetyProfile = parsed.hints.safetyProfile ?? "read_only";
250
- if (fanoutSafetyProfile !== "read_only" &&
251
- (parsed.hints.workspacePolicy === undefined ||
252
- !isIsolatedWorkspacePolicy(parsed.hints.workspacePolicy))) {
253
- sendJson(res, 400, {
254
- error: {
255
- message: "write-capable fanout requires workspacePolicy=copy or workspacePolicy=git_worktree; use read_only fanout or run single-route workspace_edit",
256
- code: "workspace_isolation_required",
257
- },
258
- });
259
- return;
260
- }
261
- parsed.hints.safetyProfile = fanoutSafetyProfile;
262
- }
263
- const eligibleRoutes = (requestedRoutes) => {
264
- const routes = [];
265
- const skippedRoutes = [];
266
- for (const route of requestedRoutes) {
267
- const svc = state.config.services[route];
268
- if (!svc)
269
- continue;
270
- const dispatcher = state.dispatchers[route];
271
- const breaker = state.router.getBreaker(route);
272
- const policy = evaluateRoutePolicy(route, svc, {
273
- ...(dispatcher !== undefined ? { dispatcher } : {}),
274
- circuitBroken: Boolean(breaker?.isTripped),
275
- ...(parsed.hints.safetyProfile !== undefined
276
- ? { requestedSafetyProfile: parsed.hints.safetyProfile }
277
- : {}),
278
- });
279
- if (policy.skipped)
280
- skippedRoutes.push(policy.skipped);
281
- if (!policy.blocked)
282
- routes.push(route);
283
- }
284
- return { routes, skippedRoutes };
285
- };
286
- if (parsed.stream) {
287
- res.writeHead(200, {
288
- "content-type": "text/event-stream; charset=utf-8",
289
- "cache-control": "no-cache",
290
- connection: "keep-alive",
291
- });
292
- if (parsed.mode === "fanout") {
293
- const selected = eligibleRoutes(parsed.models);
294
- const results = await Promise.all(selected.routes.map((route) => state.router.routeTo(route, parsed.prompt, parsed.files, parsed.workingDir, {
295
- ...(parsed.hints.safetyProfile !== undefined
296
- ? { safetyProfile: parsed.hints.safetyProfile }
297
- : {}),
298
- ...(parsed.hints.workspacePolicy !== undefined
299
- ? { workspacePolicy: parsed.hints.workspacePolicy }
300
- : {}),
301
- ...(parsed.hints.taskType !== undefined ? { taskType: parsed.hints.taskType } : {}),
302
- })));
303
- writeSse(res, {
304
- choices: [
305
- {
306
- index: 0,
307
- delta: {
308
- content: JSON.stringify(results.map((r) => ({
309
- route: r.result.service,
310
- output: r.result.output,
311
- workspace: r.result.workspace,
312
- }))),
313
- },
314
- finish_reason: null,
315
- },
316
- ],
317
- harness_dispatch: {
318
- mode: "fanout",
319
- skippedRoutes: selected.skippedRoutes,
320
- ...(parsed.workingDirWarning !== undefined
321
- ? { warning: parsed.workingDirWarning }
322
- : {}),
323
- },
324
- });
325
- }
326
- else {
327
- for await (const { event, decision } of state.router.stream(parsed.prompt, parsed.files, parsed.workingDir, { hints: parsed.hints, maxFallbacks: 2 })) {
328
- if (event.type === "stdout") {
329
- writeSse(res, {
330
- choices: [
331
- {
332
- index: 0,
333
- delta: { content: event.chunk },
334
- finish_reason: null,
335
- },
336
- ],
337
- harness_dispatch: decision ? { route: decision.service } : undefined,
338
- });
339
- }
340
- else if (event.type === "completion" && !event.result.success) {
341
- writeSse(res, {
342
- error: {
343
- message: event.result.error ?? "routing failed",
344
- route: event.result.service,
345
- },
346
- });
347
- }
348
- }
349
- }
350
- writeSse(res, {
351
- choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
352
- });
353
- res.write("data: [DONE]\n\n");
354
- res.end();
355
- return;
356
- }
357
- if (parsed.mode === "fanout") {
358
- const routes = parsed.models.length > 0
359
- ? parsed.models
360
- : Object.keys(state.config.services).filter((route) => route in state.dispatchers);
361
- const selected = eligibleRoutes(routes);
362
- const results = await Promise.all(selected.routes.map((route) => state.router.routeTo(route, parsed.prompt, parsed.files, parsed.workingDir, {
363
- ...(parsed.hints.safetyProfile !== undefined
364
- ? { safetyProfile: parsed.hints.safetyProfile }
365
- : {}),
366
- ...(parsed.hints.workspacePolicy !== undefined
367
- ? { workspacePolicy: parsed.hints.workspacePolicy }
368
- : {}),
369
- ...(parsed.hints.taskType !== undefined ? { taskType: parsed.hints.taskType } : {}),
370
- })));
371
- sendJson(res, 200, completionEnvelope(JSON.stringify(results.map((r) => ({
372
- route: r.result.service,
373
- success: r.result.success,
374
- output: r.result.output,
375
- error: r.result.error,
376
- workspace: r.result.workspace,
377
- })), null, 2), typeof parsed.hints.model === "string" ? parsed.hints.model : "harness-dispatch", {
378
- harness_dispatch: {
379
- mode: "fanout",
380
- skippedRoutes: selected.skippedRoutes,
381
- ...(parsed.workingDirWarning !== undefined
382
- ? { warning: parsed.workingDirWarning }
383
- : {}),
384
- },
385
- }));
386
- return;
387
- }
388
- const { result, decision } = await state.router.route(parsed.prompt, parsed.files, parsed.workingDir, { hints: parsed.hints, maxFallbacks: 2 });
389
- sendJson(res, 200, completionEnvelope(result.output, decision?.model ?? parsed.hints.model ?? "harness-dispatch", {
390
- harness_dispatch: {
391
- route: result.service,
392
- success: result.success,
393
- error: result.error,
394
- workspace: result.workspace,
395
- routing: decision,
396
- skippedRoutes: decision?.skippedRoutes ?? result.skippedRoutes,
397
- ...(parsed.workingDirWarning !== undefined
398
- ? { warning: parsed.workingDirWarning }
399
- : {}),
400
- },
401
- }));
553
+ await handleChatCompletions(holder, parsed, res, sse);
402
554
  return;
403
555
  }
404
556
  if (url.pathname === mcpRoute) {
@@ -439,10 +591,27 @@ export async function startHttpServer(opts = {}) {
439
591
  sendText(res, 404, "not found");
440
592
  }
441
593
  catch (err) {
442
- if (!res.headersSent) {
594
+ if (sse.started) {
595
+ // Mid-stream failure: the client already holds a 200 and possibly
596
+ // partial frames, and a bare end() made a truncated stream
597
+ // indistinguishable from a complete one. Emit an error frame and the
598
+ // stream terminator so the caller can tell.
599
+ try {
600
+ writeSse(res, { error: { message: err instanceof Error ? err.message : String(err) } });
601
+ res.write("data: [DONE]\n\n");
602
+ }
603
+ catch {
604
+ // Socket already gone; nothing left to tell it.
605
+ }
606
+ res.end();
607
+ }
608
+ else if (!res.headersSent) {
443
609
  if (err instanceof PayloadTooLargeError) {
444
610
  sendJson(res, 413, { error: err.message });
445
611
  }
612
+ else if (err instanceof BadRequestError) {
613
+ sendJson(res, 400, { error: err.message });
614
+ }
446
615
  else {
447
616
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
448
617
  }