harness-dispatch 0.4.0 → 0.6.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 (185) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +232 -87
  3. package/config.default.yaml +416 -397
  4. package/dist/auth.d.ts.map +1 -1
  5. package/dist/auth.js +2 -2
  6. package/dist/auth.js.map +1 -1
  7. package/dist/billing.d.ts +35 -4
  8. package/dist/billing.d.ts.map +1 -1
  9. package/dist/billing.js +48 -17
  10. package/dist/billing.js.map +1 -1
  11. package/dist/bin.d.ts.map +1 -1
  12. package/dist/bin.js +147 -91
  13. package/dist/bin.js.map +1 -1
  14. package/dist/breaker-store.d.ts +60 -9
  15. package/dist/breaker-store.d.ts.map +1 -1
  16. package/dist/breaker-store.js +246 -50
  17. package/dist/breaker-store.js.map +1 -1
  18. package/dist/circuit-breaker.d.ts +35 -0
  19. package/dist/circuit-breaker.d.ts.map +1 -1
  20. package/dist/circuit-breaker.js +68 -11
  21. package/dist/circuit-breaker.js.map +1 -1
  22. package/dist/config/coercions.d.ts +31 -0
  23. package/dist/config/coercions.d.ts.map +1 -0
  24. package/dist/config/coercions.js +169 -0
  25. package/dist/config/coercions.js.map +1 -0
  26. package/dist/config/env-interpolation.d.ts +36 -0
  27. package/dist/config/env-interpolation.d.ts.map +1 -0
  28. package/dist/config/env-interpolation.js +73 -0
  29. package/dist/config/env-interpolation.js.map +1 -0
  30. package/dist/config/protocol.d.ts +51 -0
  31. package/dist/config/protocol.d.ts.map +1 -0
  32. package/dist/config/protocol.js +300 -0
  33. package/dist/config/protocol.js.map +1 -0
  34. package/dist/config/route-fields.d.ts +72 -0
  35. package/dist/config/route-fields.d.ts.map +1 -0
  36. package/dist/config/route-fields.js +128 -0
  37. package/dist/config/route-fields.js.map +1 -0
  38. package/dist/config/validation.d.ts +72 -0
  39. package/dist/config/validation.d.ts.map +1 -0
  40. package/dist/config/validation.js +189 -0
  41. package/dist/config/validation.js.map +1 -0
  42. package/dist/config.d.ts +19 -2
  43. package/dist/config.d.ts.map +1 -1
  44. package/dist/config.js +214 -635
  45. package/dist/config.js.map +1 -1
  46. package/dist/configure-yaml.d.ts +23 -0
  47. package/dist/configure-yaml.d.ts.map +1 -0
  48. package/dist/configure-yaml.js +193 -0
  49. package/dist/configure-yaml.js.map +1 -0
  50. package/dist/dispatch-log.d.ts.map +1 -1
  51. package/dist/dispatch-log.js +2 -3
  52. package/dist/dispatch-log.js.map +1 -1
  53. package/dist/dispatchers/base.d.ts +3 -0
  54. package/dist/dispatchers/base.d.ts.map +1 -1
  55. package/dist/dispatchers/base.js.map +1 -1
  56. package/dist/dispatchers/generic-cli.d.ts +15 -1
  57. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  58. package/dist/dispatchers/generic-cli.js +69 -5
  59. package/dist/dispatchers/generic-cli.js.map +1 -1
  60. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  61. package/dist/dispatchers/openai-compatible.js +70 -2
  62. package/dist/dispatchers/openai-compatible.js.map +1 -1
  63. package/dist/dispatchers/shared/kill-tree.d.ts +17 -4
  64. package/dist/dispatchers/shared/kill-tree.d.ts.map +1 -1
  65. package/dist/dispatchers/shared/kill-tree.js +28 -4
  66. package/dist/dispatchers/shared/kill-tree.js.map +1 -1
  67. package/dist/dispatchers/shared/rate-limit-headers.d.ts +17 -0
  68. package/dist/dispatchers/shared/rate-limit-headers.d.ts.map +1 -1
  69. package/dist/dispatchers/shared/rate-limit-headers.js +27 -0
  70. package/dist/dispatchers/shared/rate-limit-headers.js.map +1 -1
  71. package/dist/dispatchers/shared/stream-subprocess.d.ts +11 -0
  72. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
  73. package/dist/dispatchers/shared/stream-subprocess.js +62 -35
  74. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
  75. package/dist/dispatchers/shared/subprocess.d.ts.map +1 -1
  76. package/dist/dispatchers/shared/subprocess.js +3 -0
  77. package/dist/dispatchers/shared/subprocess.js.map +1 -1
  78. package/dist/dispatchers/shared/which-available.d.ts.map +1 -1
  79. package/dist/dispatchers/shared/which-available.js +16 -2
  80. package/dist/dispatchers/shared/which-available.js.map +1 -1
  81. package/dist/file-lock.d.ts +17 -0
  82. package/dist/file-lock.d.ts.map +1 -0
  83. package/dist/file-lock.js +143 -0
  84. package/dist/file-lock.js.map +1 -0
  85. package/dist/http/parse.d.ts +64 -0
  86. package/dist/http/parse.d.ts.map +1 -0
  87. package/dist/http/parse.js +230 -0
  88. package/dist/http/parse.js.map +1 -0
  89. package/dist/http/server.d.ts +2 -0
  90. package/dist/http/server.d.ts.map +1 -1
  91. package/dist/http/server.js +249 -286
  92. package/dist/http/server.js.map +1 -1
  93. package/dist/index.d.ts +1 -1
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/index.js +3 -2
  96. package/dist/index.js.map +1 -1
  97. package/dist/job-runner.js +23 -5
  98. package/dist/job-runner.js.map +1 -1
  99. package/dist/jobs/context.d.ts +17 -0
  100. package/dist/jobs/context.d.ts.map +1 -0
  101. package/dist/jobs/context.js +84 -0
  102. package/dist/jobs/context.js.map +1 -0
  103. package/dist/jobs/store.d.ts +80 -0
  104. package/dist/jobs/store.d.ts.map +1 -0
  105. package/dist/jobs/store.js +279 -0
  106. package/dist/jobs/store.js.map +1 -0
  107. package/dist/jobs/types.d.ts +135 -0
  108. package/dist/jobs/types.d.ts.map +1 -0
  109. package/dist/jobs/types.js +11 -0
  110. package/dist/jobs/types.js.map +1 -0
  111. package/dist/jobs.d.ts +165 -85
  112. package/dist/jobs.d.ts.map +1 -1
  113. package/dist/jobs.js +824 -208
  114. package/dist/jobs.js.map +1 -1
  115. package/dist/leaderboard.d.ts +24 -1
  116. package/dist/leaderboard.d.ts.map +1 -1
  117. package/dist/leaderboard.js +94 -2
  118. package/dist/leaderboard.js.map +1 -1
  119. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  120. package/dist/mcp/config-hot-reload.js +1 -1
  121. package/dist/mcp/config-hot-reload.js.map +1 -1
  122. package/dist/mcp/dispatcher-factory.d.ts +16 -1
  123. package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
  124. package/dist/mcp/dispatcher-factory.js +41 -3
  125. package/dist/mcp/dispatcher-factory.js.map +1 -1
  126. package/dist/mcp/tool-schemas.d.ts +194 -0
  127. package/dist/mcp/tool-schemas.d.ts.map +1 -0
  128. package/dist/mcp/tool-schemas.js +311 -0
  129. package/dist/mcp/tool-schemas.js.map +1 -0
  130. package/dist/mcp/tools.d.ts +26 -51
  131. package/dist/mcp/tools.d.ts.map +1 -1
  132. package/dist/mcp/tools.js +122 -151
  133. package/dist/mcp/tools.js.map +1 -1
  134. package/dist/observability/index.d.ts +16 -3
  135. package/dist/observability/index.d.ts.map +1 -1
  136. package/dist/observability/index.js +23 -7
  137. package/dist/observability/index.js.map +1 -1
  138. package/dist/observability/spans.d.ts.map +1 -1
  139. package/dist/observability/spans.js +4 -1
  140. package/dist/observability/spans.js.map +1 -1
  141. package/dist/quota.d.ts +106 -22
  142. package/dist/quota.d.ts.map +1 -1
  143. package/dist/quota.js +198 -58
  144. package/dist/quota.js.map +1 -1
  145. package/dist/route-policy.d.ts.map +1 -1
  146. package/dist/route-policy.js +13 -3
  147. package/dist/route-policy.js.map +1 -1
  148. package/dist/router.d.ts +29 -1
  149. package/dist/router.d.ts.map +1 -1
  150. package/dist/router.js +102 -40
  151. package/dist/router.js.map +1 -1
  152. package/dist/safety.d.ts.map +1 -1
  153. package/dist/safety.js +11 -2
  154. package/dist/safety.js.map +1 -1
  155. package/dist/state-dir.d.ts +17 -0
  156. package/dist/state-dir.d.ts.map +1 -0
  157. package/dist/state-dir.js +21 -0
  158. package/dist/state-dir.js.map +1 -0
  159. package/dist/status.d.ts +49 -0
  160. package/dist/status.d.ts.map +1 -1
  161. package/dist/status.js +67 -4
  162. package/dist/status.js.map +1 -1
  163. package/dist/types.d.ts +74 -1
  164. package/dist/types.d.ts.map +1 -1
  165. package/dist/working-dir.d.ts +13 -0
  166. package/dist/working-dir.d.ts.map +1 -1
  167. package/dist/working-dir.js +27 -0
  168. package/dist/working-dir.js.map +1 -1
  169. package/dist/workspace-lock.d.ts +40 -0
  170. package/dist/workspace-lock.d.ts.map +1 -0
  171. package/dist/workspace-lock.js +258 -0
  172. package/dist/workspace-lock.js.map +1 -0
  173. package/dist/workspace-resolve.d.ts +98 -0
  174. package/dist/workspace-resolve.d.ts.map +1 -0
  175. package/dist/workspace-resolve.js +382 -0
  176. package/dist/workspace-resolve.js.map +1 -0
  177. package/dist/workspaces.d.ts +16 -0
  178. package/dist/workspaces.d.ts.map +1 -1
  179. package/dist/workspaces.js +166 -22
  180. package/dist/workspaces.js.map +1 -1
  181. package/package.json +99 -96
  182. package/dist/dashboard/live.d.ts +0 -49
  183. package/dist/dashboard/live.d.ts.map +0 -1
  184. package/dist/dashboard/live.js +0 -149
  185. package/dist/dashboard/live.js.map +0 -1
@@ -6,7 +6,12 @@ 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 { getAsyncJob, startAsyncJobTracked } from "../jobs.js";
10
+ import { BadRequestError, completionEnvelope, parseChatRequest, PayloadTooLargeError, readJson, } from "./parse.js";
11
+ // Re-exported: BadRequestError is part of this module's public surface
12
+ // (tests and the CLI import it from here) and moving its definition should
13
+ // not move where callers get it from.
14
+ export { BadRequestError };
10
15
  function sendJson(res, statusCode, body) {
11
16
  const text = JSON.stringify(body, null, 2);
12
17
  res.writeHead(statusCode, {
@@ -27,148 +32,236 @@ function isLoopbackHost(host) {
27
32
  // included — it means "bind all interfaces," the opposite of loopback.
28
33
  return host === "127.0.0.1" || host === "localhost" || host === "::1";
29
34
  }
30
- // Local-only server, but still worth bounding: an unbounded body read lets
31
- // any authorized (or, if --host is opened beyond loopback, network-adjacent)
32
- // caller exhaust process memory with one oversized POST.
33
- const MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024;
34
- class PayloadTooLargeError extends Error {
35
+ /**
36
+ * Run fanout arms to completion INDEPENDENTLY of one another.
37
+ *
38
+ * Promise.all rejected the whole batch when one arm threw (workspace lock
39
+ * timeout, worktree setup failure), discarding every other arm's completed —
40
+ * and possibly billed — work behind a single 500. Each arm now settles on its
41
+ * own; a thrown arm becomes a failed row naming its route. One row shape for
42
+ * the streaming and non-streaming branches on purpose: they used to differ
43
+ * (streaming omitted success/error), the one-sibling-guarded pattern again.
44
+ */
45
+ async function runFanoutArms(router, routes, parsed) {
46
+ const settled = await Promise.allSettled(routes.map((route) => router.routeTo(route, parsed.prompt, parsed.files, parsed.workingDir, {
47
+ ...(parsed.hints.safetyProfile !== undefined
48
+ ? { safetyProfile: parsed.hints.safetyProfile }
49
+ : {}),
50
+ ...(parsed.hints.workspacePolicy !== undefined
51
+ ? { workspacePolicy: parsed.hints.workspacePolicy }
52
+ : {}),
53
+ ...(parsed.hints.taskType !== undefined ? { taskType: parsed.hints.taskType } : {}),
54
+ })));
55
+ return settled.map((s, i) => {
56
+ if (s.status === "fulfilled") {
57
+ const r = s.value.result;
58
+ return {
59
+ route: r.service,
60
+ success: r.success,
61
+ output: r.output,
62
+ ...(r.error !== undefined ? { error: r.error } : {}),
63
+ ...(r.workspace !== undefined ? { workspace: r.workspace } : {}),
64
+ };
65
+ }
66
+ return {
67
+ route: routes[i],
68
+ success: false,
69
+ output: "",
70
+ error: s.reason instanceof Error ? s.reason.message : String(s.reason),
71
+ };
72
+ });
35
73
  }
36
- async function readJson(req, maxBytes = MAX_REQUEST_BODY_BYTES) {
37
- const chunks = [];
38
- let total = 0;
39
- for await (const chunk of req) {
40
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
41
- total += buf.length;
42
- if (total > maxBytes) {
43
- // Don't destroy() the socket here — that races with the 413 response
44
- // write and the client sees a connection reset instead of a clean
45
- // status code. Just stop buffering (the memory-exhaustion risk this
46
- // guards against) and let the normal response path write the 413.
47
- throw new PayloadTooLargeError(`request body exceeds ${maxBytes} bytes`);
74
+ /**
75
+ * POST /v1/chat/completions — extracted from the request callback, where
76
+ * this logic sat eleven brace-levels deep (the smell checker's worst
77
+ * finding for the whole repo). Behaviour is unchanged; only the nesting
78
+ * moved.
79
+ */
80
+ async function handleChatCompletions(holder, parsed, res, sse) {
81
+ const state = holder.state;
82
+ if (parsed.mode === "fanout") {
83
+ const fanoutSafetyProfile = parsed.hints.safetyProfile ?? "read_only";
84
+ if (fanoutSafetyProfile !== "read_only" &&
85
+ (parsed.hints.workspacePolicy === undefined ||
86
+ !isIsolatedWorkspacePolicy(parsed.hints.workspacePolicy))) {
87
+ sendJson(res, 400, {
88
+ error: {
89
+ message: "write-capable fanout requires workspacePolicy=copy or workspacePolicy=git_worktree; use read_only fanout or run single-route workspace_edit",
90
+ code: "workspace_isolation_required",
91
+ },
92
+ });
93
+ return;
48
94
  }
49
- chunks.push(buf);
95
+ parsed.hints.safetyProfile = fanoutSafetyProfile;
50
96
  }
51
- const text = Buffer.concat(chunks).toString("utf-8");
52
- return text.trim() ? JSON.parse(text) : {};
53
- }
54
- function contentToText(content) {
55
- if (typeof content === "string")
56
- return content;
57
- if (Array.isArray(content)) {
58
- return content
59
- .map((part) => {
60
- if (typeof part === "string")
61
- return part;
62
- if (part && typeof part === "object" && "text" in part) {
63
- const text = part.text;
64
- return typeof text === "string" ? text : "";
97
+ const eligibleRoutes = (requestedRoutes) => {
98
+ const routes = [];
99
+ const skippedRoutes = [];
100
+ for (const route of requestedRoutes) {
101
+ const svc = state.config.services[route];
102
+ if (!svc) {
103
+ // The MCP tool rejects unknown fanout targets by name; this
104
+ // surface silently skipped them, returning 200 with fewer arms
105
+ // and an empty skippedRoutes. Same input, two answers.
106
+ throw new BadRequestError(`Unknown fanout target: ${route}. Valid route ids: ` +
107
+ `${Object.keys(state.config.services).join(", ")}.`);
65
108
  }
66
- return "";
67
- })
68
- .filter(Boolean)
69
- .join("\n");
70
- }
71
- return "";
72
- }
73
- function messagesToPrompt(messages) {
74
- if (!Array.isArray(messages))
75
- return "";
76
- return messages
77
- .map((message) => {
78
- const role = typeof message.role === "string" ? message.role : "user";
79
- const text = contentToText(message.content);
80
- return text ? `${role}: ${text}` : "";
81
- })
82
- .filter(Boolean)
83
- .join("\n\n");
84
- }
85
- function parseHints(body) {
86
- const hints = {};
87
- if (typeof body.model === "string" && body.model !== "")
88
- hints.model = body.model;
89
- if (body.safetyProfile === "read_only" ||
90
- body.safetyProfile === "workspace_edit" ||
91
- body.safetyProfile === "full_auto") {
92
- hints.safetyProfile = body.safetyProfile;
93
- }
94
- if (body.hints && typeof body.hints === "object") {
95
- const raw = body.hints;
96
- if (typeof raw.model === "string")
97
- hints.model = raw.model;
98
- if (raw.taskType === "execute" ||
99
- raw.taskType === "plan" ||
100
- raw.taskType === "review" ||
101
- raw.taskType === "local") {
102
- hints.taskType = raw.taskType;
103
- }
104
- if (typeof raw.preferLargeContext === "boolean") {
105
- hints.preferLargeContext = raw.preferLargeContext;
109
+ const dispatcher = state.dispatchers[route];
110
+ const breaker = state.router.getBreaker(route);
111
+ const policy = evaluateRoutePolicy(route, svc, {
112
+ ...(dispatcher !== undefined ? { dispatcher } : {}),
113
+ circuitBroken: Boolean(breaker?.isTripped),
114
+ ...(parsed.hints.safetyProfile !== undefined
115
+ ? { requestedSafetyProfile: parsed.hints.safetyProfile }
116
+ : {}),
117
+ });
118
+ if (policy.skipped)
119
+ skippedRoutes.push(policy.skipped);
120
+ if (!policy.blocked)
121
+ routes.push(route);
106
122
  }
107
- if (raw.safetyProfile === "read_only" ||
108
- raw.safetyProfile === "workspace_edit" ||
109
- raw.safetyProfile === "full_auto") {
110
- hints.safetyProfile = raw.safetyProfile;
123
+ return { routes, skippedRoutes };
124
+ };
125
+ if (parsed.stream) {
126
+ // Resolve fanout targets BEFORE writing SSE headers.
127
+ //
128
+ // eligibleRoutes throws BadRequestError for an unknown route, but it
129
+ // was called after writeHead — so headersSent was true, the error
130
+ // handler could only res.end(), and the caller got HTTP 200 with a
131
+ // zero-byte body. The non-streaming path returns a proper 400 with
132
+ // the valid ids. Same input, two answers, again.
133
+ // Default to every dispatchable route when `models` is omitted,
134
+ // exactly as the non-streaming branch does. Streaming passed
135
+ // parsed.models straight through, so {"mode":"fanout","stream":true}
136
+ // with no models fanned out to ZERO routes and reported success —
137
+ // content "[]", empty skippedRoutes, HTTP 200.
138
+ const preSelected = parsed.mode === "fanout"
139
+ ? eligibleRoutes(parsed.models.length > 0
140
+ ? parsed.models
141
+ : Object.keys(state.config.services).filter((route) => route in state.dispatchers))
142
+ : undefined;
143
+ res.writeHead(200, {
144
+ "content-type": "text/event-stream; charset=utf-8",
145
+ "cache-control": "no-cache",
146
+ connection: "keep-alive",
147
+ });
148
+ sse.started = true;
149
+ if (parsed.mode === "fanout") {
150
+ const selected = preSelected;
151
+ const rows = await runFanoutArms(state.router, selected.routes, parsed);
152
+ writeSse(res, sseContent(JSON.stringify(rows), {
153
+ harness_dispatch: {
154
+ mode: "fanout",
155
+ skippedRoutes: selected.skippedRoutes,
156
+ ...(parsed.workingDirWarning !== undefined
157
+ ? { warning: parsed.workingDirWarning }
158
+ : {}),
159
+ },
160
+ }));
111
161
  }
112
- if (raw.workspacePolicy === "shared" ||
113
- raw.workspacePolicy === "shared_locked" ||
114
- raw.workspacePolicy === "copy" ||
115
- raw.workspacePolicy === "git_worktree") {
116
- hints.workspacePolicy = raw.workspacePolicy;
162
+ else {
163
+ for await (const { event, decision } of state.router.stream(parsed.prompt, parsed.files, parsed.workingDir, { hints: parsed.hints, maxFallbacks: 2 })) {
164
+ if (event.type === "stdout") {
165
+ writeSse(res, sseContent(event.chunk, {
166
+ harness_dispatch: decision ? { route: decision.service } : undefined,
167
+ }));
168
+ }
169
+ else if (event.type === "completion" && !event.result.success) {
170
+ writeSse(res, {
171
+ error: {
172
+ message: event.result.error ?? "routing failed",
173
+ route: event.result.service,
174
+ },
175
+ });
176
+ }
177
+ }
117
178
  }
179
+ writeSse(res, sseStop());
180
+ res.write("data: [DONE]\n\n");
181
+ res.end();
182
+ return;
118
183
  }
119
- if (body.workspacePolicy === "shared" ||
120
- body.workspacePolicy === "shared_locked" ||
121
- body.workspacePolicy === "copy" ||
122
- body.workspacePolicy === "git_worktree") {
123
- hints.workspacePolicy = body.workspacePolicy;
184
+ if (parsed.mode === "fanout") {
185
+ const routes = parsed.models.length > 0
186
+ ? parsed.models
187
+ : Object.keys(state.config.services).filter((route) => route in state.dispatchers);
188
+ const selected = eligibleRoutes(routes);
189
+ const rows = await runFanoutArms(state.router, selected.routes, parsed);
190
+ sendJson(res, 200, completionEnvelope(JSON.stringify(rows, null, 2), typeof parsed.hints.model === "string" ? parsed.hints.model : "harness-dispatch", {
191
+ harness_dispatch: {
192
+ mode: "fanout",
193
+ skippedRoutes: selected.skippedRoutes,
194
+ ...(parsed.workingDirWarning !== undefined
195
+ ? { warning: parsed.workingDirWarning }
196
+ : {}),
197
+ },
198
+ }));
199
+ return;
124
200
  }
125
- return hints;
126
- }
127
- function parseChatRequest(raw) {
128
- if (!raw || typeof raw !== "object") {
129
- throw new Error("request body must be a JSON object");
201
+ // Backed by a persisted job, not a bare in-process await. This
202
+ // surface's users are curl/CI/cron — exactly the clients that enforce
203
+ // their own request timeouts — and a direct await meant a client that
204
+ // gave up mid-run lost the finished result with no way to retrieve
205
+ // it. The job survives (it runs detached and lands on disk); the
206
+ // jobId is exposed in the response AND in a header so even a caller
207
+ // that only captured headers before timing out can recover the
208
+ // result via `job_status`.
209
+ const { status: jobStatus, completion } = await startAsyncJobTracked({ holder }, {
210
+ prompt: parsed.prompt,
211
+ files: parsed.files,
212
+ workingDir: parsed.workingDir,
213
+ hints: parsed.hints,
214
+ });
215
+ res.setHeader("x-harness-dispatch-job-id", jobStatus.jobId);
216
+ await completion;
217
+ const job = await getAsyncJob(jobStatus.jobId);
218
+ const result = job.result?.result;
219
+ const decision = job.result?.decision ?? undefined;
220
+ if (result === undefined) {
221
+ // Terminal without a result payload: the runner died or was
222
+ // orphaned. The job dir still names what happened.
223
+ sendJson(res, 500, {
224
+ error: `job ${jobStatus.jobId} ended without a result (status: ${job.status.status}); ` +
225
+ `check job_status for details`,
226
+ jobId: jobStatus.jobId,
227
+ });
228
+ return;
130
229
  }
131
- const body = raw;
132
- const prompt = typeof body.prompt === "string" && body.prompt.trim()
133
- ? body.prompt
134
- : messagesToPrompt(body.messages);
135
- if (!prompt.trim())
136
- throw new Error("messages or prompt is required");
137
- const files = Array.isArray(body.files)
138
- ? body.files.filter((v) => typeof v === "string")
139
- : [];
140
- const models = Array.isArray(body.models)
141
- ? body.models.filter((v) => typeof v === "string")
142
- : [];
143
- const resolvedWorkingDir = resolveWorkingDir(typeof body.workingDir === "string" ? body.workingDir : undefined);
144
- const warning = workingDirWarning(resolvedWorkingDir);
145
- return {
146
- prompt,
147
- files,
148
- workingDir: resolvedWorkingDir.workingDir,
149
- ...(warning !== undefined ? { workingDirWarning: warning } : {}),
150
- stream: body.stream === true,
151
- mode: body.mode === "fanout" ? "fanout" : "single",
152
- models,
153
- hints: parseHints(body),
154
- };
230
+ sendJson(res, 200, completionEnvelope(result.output, decision?.model ?? parsed.hints.model ?? "harness-dispatch", {
231
+ harness_dispatch: {
232
+ jobId: jobStatus.jobId,
233
+ route: result.service,
234
+ success: result.success,
235
+ error: result.error,
236
+ workspace: result.workspace,
237
+ routing: decision,
238
+ skippedRoutes: decision?.skippedRoutes ?? result.skippedRoutes,
239
+ ...(parsed.workingDirWarning !== undefined
240
+ ? { warning: parsed.workingDirWarning }
241
+ : {}),
242
+ },
243
+ }));
244
+ return;
155
245
  }
156
- function completionEnvelope(content, model, extra) {
246
+ /**
247
+ * One SSE content frame.
248
+ *
249
+ * The `choices[0].delta` envelope is what an OpenAI-compatible client parses,
250
+ * and it was written out as a literal at three call sites — which is both how
251
+ * the deepest nesting in this file appeared (a data shape indented under a
252
+ * loop inside a branch) and how the streaming and non-streaming fanout
253
+ * replies drifted apart in the first place. Built in one place now.
254
+ */
255
+ function sseContent(content, extra) {
157
256
  return {
158
- id: `chatcmpl-${randomUUID()}`,
159
- object: "chat.completion",
160
- created: Math.floor(Date.now() / 1000),
161
- model,
162
- choices: [
163
- {
164
- index: 0,
165
- message: { role: "assistant", content },
166
- finish_reason: "stop",
167
- },
168
- ],
169
- ...extra,
257
+ choices: [{ index: 0, delta: { content }, finish_reason: null }],
258
+ ...(extra ?? {}),
170
259
  };
171
260
  }
261
+ /** The terminal frame every stream ends with, before `[DONE]`. */
262
+ function sseStop() {
263
+ return { choices: [{ index: 0, delta: {}, finish_reason: "stop" }] };
264
+ }
172
265
  function writeSse(res, payload) {
173
266
  res.write(`data: ${JSON.stringify(payload)}\n\n`);
174
267
  }
@@ -190,9 +283,16 @@ export async function startHttpServer(opts = {}) {
190
283
  sendJson(res, 401, { error: "unauthorized" });
191
284
  return false;
192
285
  };
286
+ // A bare IPv6 host must be bracketed to be legal inside a URL:
287
+ // new URL("/mcp", "http://::1") throws "Invalid URL", which turned EVERY
288
+ // request to a server bound on ::1 into a 500 — the bind succeeded and the
289
+ // loopback check blessed the address, so nothing else ever caught it.
290
+ const urlBase = `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}`;
193
291
  const http = createServer(async (req, res) => {
292
+ // Shared with handleChatCompletions, read by the catch below.
293
+ const sse = { started: false };
194
294
  try {
195
- const url = new URL(req.url ?? "/", `http://${host}`);
295
+ const url = new URL(req.url ?? "/", urlBase);
196
296
  if (url.pathname === "/v1/models" && req.method === "GET") {
197
297
  if (!requireAuth(req, res))
198
298
  return;
@@ -244,161 +344,7 @@ export async function startHttpServer(opts = {}) {
244
344
  return;
245
345
  await reloader.maybeReload();
246
346
  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
- }));
347
+ await handleChatCompletions(holder, parsed, res, sse);
402
348
  return;
403
349
  }
404
350
  if (url.pathname === mcpRoute) {
@@ -439,10 +385,27 @@ export async function startHttpServer(opts = {}) {
439
385
  sendText(res, 404, "not found");
440
386
  }
441
387
  catch (err) {
442
- if (!res.headersSent) {
388
+ if (sse.started) {
389
+ // Mid-stream failure: the client already holds a 200 and possibly
390
+ // partial frames, and a bare end() made a truncated stream
391
+ // indistinguishable from a complete one. Emit an error frame and the
392
+ // stream terminator so the caller can tell.
393
+ try {
394
+ writeSse(res, { error: { message: err instanceof Error ? err.message : String(err) } });
395
+ res.write("data: [DONE]\n\n");
396
+ }
397
+ catch {
398
+ // Socket already gone; nothing left to tell it.
399
+ }
400
+ res.end();
401
+ }
402
+ else if (!res.headersSent) {
443
403
  if (err instanceof PayloadTooLargeError) {
444
404
  sendJson(res, 413, { error: err.message });
445
405
  }
406
+ else if (err instanceof BadRequestError) {
407
+ sendJson(res, 400, { error: err.message });
408
+ }
446
409
  else {
447
410
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
448
411
  }