harness-dispatch 0.8.0 → 0.10.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 (174) hide show
  1. package/CHANGELOG.md +1074 -2
  2. package/README.md +92 -36
  3. package/config.default.yaml +19 -3
  4. package/dist/auth.d.ts.map +1 -1
  5. package/dist/auth.js.map +1 -1
  6. package/dist/billing.d.ts +24 -0
  7. package/dist/billing.d.ts.map +1 -1
  8. package/dist/billing.js +24 -0
  9. package/dist/billing.js.map +1 -1
  10. package/dist/bin.d.ts.map +1 -1
  11. package/dist/bin.js +299 -48
  12. package/dist/bin.js.map +1 -1
  13. package/dist/circuit-breaker.d.ts +9 -1
  14. package/dist/circuit-breaker.d.ts.map +1 -1
  15. package/dist/circuit-breaker.js +15 -1
  16. package/dist/circuit-breaker.js.map +1 -1
  17. package/dist/client-register.d.ts +11 -1
  18. package/dist/client-register.d.ts.map +1 -1
  19. package/dist/client-register.js +15 -6
  20. package/dist/client-register.js.map +1 -1
  21. package/dist/config/coercions.d.ts +5 -0
  22. package/dist/config/coercions.d.ts.map +1 -1
  23. package/dist/config/coercions.js +8 -0
  24. package/dist/config/coercions.js.map +1 -1
  25. package/dist/config/protocol.js.map +1 -1
  26. package/dist/config/validation.d.ts +12 -0
  27. package/dist/config/validation.d.ts.map +1 -1
  28. package/dist/config/validation.js +133 -7
  29. package/dist/config/validation.js.map +1 -1
  30. package/dist/config.d.ts +15 -5
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/config.js +199 -152
  33. package/dist/config.js.map +1 -1
  34. package/dist/configure-yaml.d.ts +2 -0
  35. package/dist/configure-yaml.d.ts.map +1 -1
  36. package/dist/configure-yaml.js +114 -4
  37. package/dist/configure-yaml.js.map +1 -1
  38. package/dist/dispatch-log.d.ts.map +1 -1
  39. package/dist/dispatch-log.js +3 -1
  40. package/dist/dispatch-log.js.map +1 -1
  41. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  42. package/dist/dispatchers/generic-cli.js +281 -112
  43. package/dist/dispatchers/generic-cli.js.map +1 -1
  44. package/dist/dispatchers/openai-compatible.d.ts +0 -5
  45. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  46. package/dist/dispatchers/openai-compatible.js +150 -83
  47. package/dist/dispatchers/openai-compatible.js.map +1 -1
  48. package/dist/dispatchers/shared/harness-login.d.ts +25 -0
  49. package/dist/dispatchers/shared/harness-login.d.ts.map +1 -0
  50. package/dist/dispatchers/shared/harness-login.js +73 -0
  51. package/dist/dispatchers/shared/harness-login.js.map +1 -0
  52. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
  53. package/dist/dispatchers/shared/stream-subprocess.js +71 -58
  54. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
  55. package/dist/file-lock.d.ts +29 -1
  56. package/dist/file-lock.d.ts.map +1 -1
  57. package/dist/file-lock.js +53 -20
  58. package/dist/file-lock.js.map +1 -1
  59. package/dist/harness-presets.d.ts +51 -0
  60. package/dist/harness-presets.d.ts.map +1 -0
  61. package/dist/harness-presets.js +154 -0
  62. package/dist/harness-presets.js.map +1 -0
  63. package/dist/http/parse.d.ts +8 -1
  64. package/dist/http/parse.d.ts.map +1 -1
  65. package/dist/http/parse.js +10 -3
  66. package/dist/http/parse.js.map +1 -1
  67. package/dist/http/server.d.ts +2 -0
  68. package/dist/http/server.d.ts.map +1 -1
  69. package/dist/http/server.js +188 -16
  70. package/dist/http/server.js.map +1 -1
  71. package/dist/job-runner.js +6 -0
  72. package/dist/job-runner.js.map +1 -1
  73. package/dist/jobs/context.d.ts +0 -7
  74. package/dist/jobs/context.d.ts.map +1 -1
  75. package/dist/jobs/context.js +95 -7
  76. package/dist/jobs/context.js.map +1 -1
  77. package/dist/jobs/lifecycle.d.ts +100 -0
  78. package/dist/jobs/lifecycle.d.ts.map +1 -0
  79. package/dist/jobs/lifecycle.js +263 -0
  80. package/dist/jobs/lifecycle.js.map +1 -0
  81. package/dist/jobs/read.d.ts +17 -0
  82. package/dist/jobs/read.d.ts.map +1 -0
  83. package/dist/jobs/read.js +103 -0
  84. package/dist/jobs/read.js.map +1 -0
  85. package/dist/jobs/run.d.ts +45 -0
  86. package/dist/jobs/run.d.ts.map +1 -0
  87. package/dist/jobs/run.js +351 -0
  88. package/dist/jobs/run.js.map +1 -0
  89. package/dist/jobs/start.d.ts +9 -0
  90. package/dist/jobs/start.d.ts.map +1 -0
  91. package/dist/jobs/start.js +126 -0
  92. package/dist/jobs/start.js.map +1 -0
  93. package/dist/jobs/store.d.ts.map +1 -1
  94. package/dist/jobs/store.js +13 -3
  95. package/dist/jobs/store.js.map +1 -1
  96. package/dist/jobs/supervisor.d.ts +151 -0
  97. package/dist/jobs/supervisor.d.ts.map +1 -0
  98. package/dist/jobs/supervisor.js +679 -0
  99. package/dist/jobs/supervisor.js.map +1 -0
  100. package/dist/jobs/types.d.ts +8 -1
  101. package/dist/jobs/types.d.ts.map +1 -1
  102. package/dist/jobs.d.ts +15 -218
  103. package/dist/jobs.d.ts.map +1 -1
  104. package/dist/jobs.js +14 -1317
  105. package/dist/jobs.js.map +1 -1
  106. package/dist/leaderboard.d.ts.map +1 -1
  107. package/dist/leaderboard.js +44 -28
  108. package/dist/leaderboard.js.map +1 -1
  109. package/dist/mcp/config-hot-reload.d.ts +14 -3
  110. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  111. package/dist/mcp/config-hot-reload.js +27 -6
  112. package/dist/mcp/config-hot-reload.js.map +1 -1
  113. package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
  114. package/dist/mcp/dispatcher-factory.js +15 -4
  115. package/dist/mcp/dispatcher-factory.js.map +1 -1
  116. package/dist/mcp/resources.d.ts.map +1 -1
  117. package/dist/mcp/resources.js +3 -2
  118. package/dist/mcp/resources.js.map +1 -1
  119. package/dist/mcp/tool-schemas.d.ts +1 -1
  120. package/dist/mcp/tool-schemas.d.ts.map +1 -1
  121. package/dist/mcp/tool-schemas.js +6 -3
  122. package/dist/mcp/tool-schemas.js.map +1 -1
  123. package/dist/mcp/tools.d.ts +20 -6
  124. package/dist/mcp/tools.d.ts.map +1 -1
  125. package/dist/mcp/tools.js +92 -14
  126. package/dist/mcp/tools.js.map +1 -1
  127. package/dist/mcp-clients.d.ts +9 -0
  128. package/dist/mcp-clients.d.ts.map +1 -1
  129. package/dist/mcp-clients.js +4 -0
  130. package/dist/mcp-clients.js.map +1 -1
  131. package/dist/observability/spans.d.ts.map +1 -1
  132. package/dist/observability/spans.js +10 -2
  133. package/dist/observability/spans.js.map +1 -1
  134. package/dist/quota.d.ts.map +1 -1
  135. package/dist/quota.js +16 -2
  136. package/dist/quota.js.map +1 -1
  137. package/dist/redaction.d.ts +90 -0
  138. package/dist/redaction.d.ts.map +1 -0
  139. package/dist/redaction.js +221 -0
  140. package/dist/redaction.js.map +1 -0
  141. package/dist/route-policy.d.ts +2 -1
  142. package/dist/route-policy.d.ts.map +1 -1
  143. package/dist/route-policy.js +54 -0
  144. package/dist/route-policy.js.map +1 -1
  145. package/dist/router.d.ts +12 -23
  146. package/dist/router.d.ts.map +1 -1
  147. package/dist/router.js +86 -223
  148. package/dist/router.js.map +1 -1
  149. package/dist/safety.d.ts.map +1 -1
  150. package/dist/safety.js +7 -1
  151. package/dist/safety.js.map +1 -1
  152. package/dist/state-dir.d.ts +10 -0
  153. package/dist/state-dir.d.ts.map +1 -1
  154. package/dist/state-dir.js +27 -1
  155. package/dist/state-dir.js.map +1 -1
  156. package/dist/status.d.ts +14 -3
  157. package/dist/status.d.ts.map +1 -1
  158. package/dist/status.js +36 -3
  159. package/dist/status.js.map +1 -1
  160. package/dist/types.d.ts +24 -1
  161. package/dist/types.d.ts.map +1 -1
  162. package/dist/workspace-lock.d.ts +20 -0
  163. package/dist/workspace-lock.d.ts.map +1 -1
  164. package/dist/workspace-lock.js +63 -0
  165. package/dist/workspace-lock.js.map +1 -1
  166. package/dist/workspace-resolve.d.ts +0 -9
  167. package/dist/workspace-resolve.d.ts.map +1 -1
  168. package/dist/workspace-resolve.js +115 -183
  169. package/dist/workspace-resolve.js.map +1 -1
  170. package/dist/workspaces.d.ts +15 -0
  171. package/dist/workspaces.d.ts.map +1 -1
  172. package/dist/workspaces.js +330 -90
  173. package/dist/workspaces.js.map +1 -1
  174. package/package.json +5 -4
package/dist/jobs.js CHANGED
@@ -1,1319 +1,16 @@
1
1
  /**
2
- * Async job bundle support.
3
- *
4
- * `dispatch` returns quickly after creating a reproducible bundle on disk,
5
- * then the run executes in a DETACHED job-runner process (job-runner.ts) —
6
- * not inside the MCP server so a server restart, session reconnect, or
7
- * client timeout never kills an in-flight run. The server (and any later
8
- * server instance) reads progress and results back from the job directory.
9
- * Set HARNESS_DISPATCH_INPROC_JOBS=1 to run jobs in-process instead (used
10
- * by the unit-test suite, which injects fake dispatchers a separate
11
- * process could not see).
12
- */
13
- import { spawn } from "node:child_process";
14
- import { randomUUID } from "node:crypto";
15
- import { closeSync, existsSync, mkdirSync, openSync, writeFileSync } from "node:fs";
16
- import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
17
- import path from "node:path";
18
- import { setTimeout as delay } from "node:timers/promises";
19
- import { fileURLToPath } from "node:url";
20
- import { declaresModel } from "./router.js";
21
- import { resolveWorkingDir, validateWorkingDir, workingDirWarning } from "./working-dir.js";
22
- import { acquireWorkspaceLock } from "./workspace-lock.js";
23
- import { applyWorkspace, discardWorkspace, isResolvable, workspaceDiff, } from "./workspace-resolve.js";
24
- import { buildContextPreamble } from "./jobs/context.js";
25
- import { assertValidJobId, boundedError, cancelReason, cancelRequested, requestCancel, jobsRoot, newJobId, ORPHAN_THRESHOLD_MS, pollInstructions, pruneStaleJobs, readJson, setJobRetentionDays, snapshotFiles, SUGGESTED_POLL_SECONDS, timestamp, updateStatus, withOrphanCheck, writeJson, } from "./jobs/store.js";
26
- // Re-exported so existing importers (mcp/tools.ts, http/server.ts, the job
27
- // runner, and the tests) keep their current import paths through the split.
28
- export { buildContextPreamble };
29
- export { setJobRetentionDays };
30
- /** How often a live background run bumps its status file's updatedAt. */
31
- const HEARTBEAT_INTERVAL_MS = 15_000;
32
- /**
33
- * Fallback dispatch timeout for jobs. Dispatchers hard-code a short default
34
- * (10 min for CLI harnesses, 2 min for openai_compatible) meant to catch a
35
- * genuinely hung process — waiting on stdin that'll never come, a stalled
36
- * network call — not to cap a slow-but-healthy run. That default made sense
37
- * as-is for `code`, which blocks an MCP call anyway, but `job` runs in the
38
- * background and is polled, so nothing about it requires killing a process
39
- * that's still making progress after 10 minutes. Below both an explicit
40
- * `hints.timeoutMs` and the route's own configured `timeoutMs` in
41
- * precedence, so this only fills the gap when nobody set either.
42
- *
43
- * Router.stream() treats this specific value as a budget for the WHOLE call
44
- * (including router fallback retries), not a per-attempt allowance — without
45
- * that, a job that falls back twice (the router's default maxFallbacks: 2)
46
- * could burn up to 3x this value before failing conclusively.
47
- */
48
- const JOB_DEFAULT_TIMEOUT_MS = 60 * 60 * 1000;
49
- async function runJob(deps, jobDir, manifest, input) {
50
- const started = Date.now();
51
- const runningStatus = () => ({
52
- jobId: manifest.jobId,
53
- status: "running",
54
- createdAt: manifest.createdAt,
55
- updatedAt: timestamp(),
56
- jobDir,
57
- ...(input.service !== undefined ? { service: input.service } : {}),
58
- ...(manifest.warning !== undefined ? { warning: manifest.warning } : {}),
59
- });
60
- await updateStatus(jobDir, runningStatus());
61
- // Heartbeat: bump updatedAt while the run is alive so a reader can tell
62
- // "running" apart from "the server that owned this run died and left a
63
- // stale status file" (getAsyncJob reports the latter as "orphaned").
64
- // unref'd so an exiting process never lingers on it — which is exactly
65
- // the scenario the heartbeat exists to expose. The `finished` flag stops
66
- // a beat that FIRES after the terminal write; `pendingBeat` covers the
67
- // beat that fired BEFORE it and is still mid-write — updateStatus's rename
68
- // can back off ~900ms on Windows EPERM, long enough to land after the
69
- // terminal status and re-mark a completed job "running" (then "orphaned"
70
- // forever in the list view). The terminal paths await it before writing.
71
- let finished = false;
72
- let pendingBeat = Promise.resolve();
73
- const heartbeat = setInterval(() => {
74
- if (finished)
75
- return;
76
- pendingBeat = updateStatus(jobDir, runningStatus()).catch(() => undefined);
77
- }, HEARTBEAT_INTERVAL_MS);
78
- heartbeat.unref?.();
79
- try {
80
- const state = deps.holder.state;
81
- const files = input.files ?? [];
82
- // Reuse the value already resolved (and recorded) at job creation, not a
83
- // fresh process.cwd() snapshot — the two must stay in sync with the
84
- // warning captured in manifest.warning.
85
- const workingDir = manifest.workingDir;
86
- const hints = { ...(input.hints ?? {}) };
87
- if (input.workspacePolicy !== undefined)
88
- hints.workspacePolicy = input.workspacePolicy;
89
- // Stream the dispatch so agents polling action=get can watch progress in
90
- // stdout.partial.log instead of waiting blind for the final result.
91
- const partialPath = path.join(jobDir, "output", "stdout.partial.log");
92
- // Cancellation travels DOWN to the child process, not up through the
93
- // iterator. Returning from an async generator that is suspended at an
94
- // `await` does not take effect until that await settles — which for an
95
- // agent CLI gone quiet is never — so the only thing that reliably stops a
96
- // silent run is aborting the subprocess (or fetch) directly.
97
- const cancelController = new AbortController();
98
- const events = input.service
99
- ? state.router.streamTo(input.service, input.prompt, files, workingDir, {
100
- ...(hints.safetyProfile !== undefined
101
- ? { safetyProfile: hints.safetyProfile }
102
- : {}),
103
- ...(hints.workspacePolicy !== undefined
104
- ? { workspacePolicy: hints.workspacePolicy }
105
- : {}),
106
- ...(hints.routePolicy !== undefined
107
- ? { routePolicy: hints.routePolicy }
108
- : {}),
109
- ...(hints.model !== undefined ? { model: hints.model } : {}),
110
- ...(hints.taskType !== undefined ? { taskType: hints.taskType } : {}),
111
- ...(hints.timeoutMs !== undefined ? { timeoutMs: hints.timeoutMs } : {}),
112
- defaultTimeoutMs: JOB_DEFAULT_TIMEOUT_MS,
113
- signal: cancelController.signal,
114
- })
115
- : state.router.stream(input.prompt, files, workingDir, {
116
- hints,
117
- maxFallbacks: 2,
118
- defaultTimeoutMs: JOB_DEFAULT_TIMEOUT_MS,
119
- signal: cancelController.signal,
120
- });
121
- let finalResult = null;
122
- let finalDecision = null;
123
- let cancelled = false;
124
- // Driven through an explicit iterator rather than `for await`, so a
125
- // cancellation can interrupt a stream that is producing NOTHING. A
126
- // for-await body only runs when an event arrives, and the case that most
127
- // needs cancelling is the agent that has gone quiet for twenty minutes.
128
- // Racing next() against a poll lets us stop either way, and calling
129
- // return() on the iterator is what tears the child process down —
130
- // stream-subprocess's return() runs killTree, which on POSIX now signals
131
- // the whole process group.
132
- const iterator = events[Symbol.asyncIterator]();
133
- const CANCEL_POLL_MS = 1_000;
134
- // The in-flight next() is held ACROSS polls rather than re-issued.
135
- // Racing a fresh iterator.next() each time round drops events: when the
136
- // poll wins, the previous next() is still pending, and calling next()
137
- // again queues a second pull whose result is the one we read — the first
138
- // event resolves into nothing. Losing a `completion` that way leaves a
139
- // finished run with no result.json, so the job never reaches a terminal
140
- // state and the caller polls a corpse. Caught by the slot-queue test,
141
- // which waits for a queued job to actually complete.
142
- let pending;
143
- for (;;) {
144
- pending ??= iterator.next();
145
- const winner = await Promise.race([
146
- pending.then((r) => ({ kind: "event", r })),
147
- delay(CANCEL_POLL_MS, { kind: "poll" }, { ref: false }),
148
- ]);
149
- if (winner.kind === "poll") {
150
- if (!cancelRequested(jobDir))
151
- continue; // `pending` deliberately kept
152
- cancelled = true;
153
- cancelController.abort();
154
- // Not awaited: the generator is parked on an await that only settles
155
- // once the abort above kills the child, so awaiting return() here
156
- // would deadlock on the very thing it is trying to stop.
157
- void iterator.return?.().catch(() => undefined);
158
- break;
159
- }
160
- pending = undefined;
161
- const next = winner.r;
162
- if (next.done)
163
- break;
164
- if (cancelRequested(jobDir)) {
165
- cancelled = true;
166
- cancelController.abort();
167
- void iterator.return?.().catch(() => undefined);
168
- break;
169
- }
170
- const { event, decision } = next.value;
171
- if (decision)
172
- finalDecision = decision;
173
- if (input.onEvent) {
174
- try {
175
- input.onEvent(event);
176
- }
177
- catch {
178
- // Progress forwarding is best-effort; the job itself must not fail.
179
- }
180
- }
181
- if (event.type === "stdout" || event.type === "stderr") {
182
- try {
183
- await appendFile(partialPath, event.chunk, { encoding: "utf8", mode: 0o600 });
184
- }
185
- catch {
186
- // Progress mirroring is best-effort; the final result still lands.
187
- }
188
- }
189
- else if (event.type === "completion") {
190
- // Fallback chains yield one completion per attempt; last one wins.
191
- finalResult = event.result;
192
- }
193
- }
194
- if (cancelled) {
195
- // Terminal, and deliberately NOT routed through the result/failure path:
196
- // no result.json is written and the router never sees a failure, so a
197
- // cancellation cannot charge the route's breaker or failure count for
198
- // the caller changing their mind.
199
- finished = true;
200
- await pendingBeat;
201
- const reason = await cancelReason(jobDir);
202
- await updateStatus(jobDir, {
203
- jobId: manifest.jobId,
204
- status: "cancelled",
205
- createdAt: manifest.createdAt,
206
- updatedAt: timestamp(),
207
- jobDir,
208
- ...(input.service !== undefined ? { service: input.service } : {}),
209
- success: false,
210
- error: reason !== undefined ? `Cancelled: ${reason}` : "Cancelled before it finished.",
211
- ...(manifest.warning !== undefined ? { warning: manifest.warning } : {}),
212
- durationMs: Date.now() - started,
213
- });
214
- return;
215
- }
216
- const result = finalResult ?? {
217
- output: "",
218
- service: input.service ?? "none",
219
- success: false,
220
- error: "Router stream ended without a completion event",
221
- };
222
- finished = true;
223
- await pendingBeat;
224
- const payload = {
225
- jobId: manifest.jobId,
226
- result: { ...result, ...(result.error !== undefined ? { error: boundedError(result.error) } : {}) },
227
- decision: finalDecision,
228
- };
229
- await writeFile(path.join(jobDir, "output", "stdout.log"), result.output, { encoding: "utf8", mode: 0o600 });
230
- await writeFile(path.join(jobDir, "output", "stderr.log"), result.error ?? "", { encoding: "utf8", mode: 0o600 });
231
- await writeJson(path.join(jobDir, "output", "result.json"), payload);
232
- await writeFile(path.join(jobDir, "output", "result.md"), result.output || result.error || "", { encoding: "utf8", mode: 0o600 });
233
- await updateStatus(jobDir, {
234
- jobId: manifest.jobId,
235
- status: result.success ? "completed" : "failed",
236
- createdAt: manifest.createdAt,
237
- updatedAt: timestamp(),
238
- jobDir,
239
- ...(input.service !== undefined ? { service: input.service } : {}),
240
- route: result.service,
241
- success: result.success,
242
- ...(result.error !== undefined ? { error: boundedError(result.error) } : {}),
243
- ...(manifest.warning !== undefined ? { warning: manifest.warning } : {}),
244
- durationMs: Date.now() - started,
245
- });
246
- }
247
- catch (err) {
248
- finished = true;
249
- await pendingBeat;
250
- const message = err instanceof Error ? err.message : String(err);
251
- try {
252
- await writeFile(path.join(jobDir, "output", "stderr.log"), message, {
253
- encoding: "utf8",
254
- mode: 0o600,
255
- });
256
- await updateStatus(jobDir, {
257
- jobId: manifest.jobId,
258
- status: "failed",
259
- createdAt: manifest.createdAt,
260
- updatedAt: timestamp(),
261
- jobDir,
262
- ...(input.service !== undefined ? { service: input.service } : {}),
263
- success: false,
264
- error: boundedError(message),
265
- ...(manifest.warning !== undefined ? { warning: manifest.warning } : {}),
266
- durationMs: Date.now() - started,
267
- });
268
- }
269
- catch {
270
- // The job directory can be GONE by the time a failure is recorded —
271
- // retention pruning, or a caller that tore down its state mid-run.
272
- // There is nowhere to write and no reader left to care; throwing here
273
- // would reject `completion`, which is documented to never reject (and
274
- // surfaced in CI as an unhandled rejection out of a finished test).
275
- }
276
- }
277
- finally {
278
- clearInterval(heartbeat);
279
- }
280
- }
281
- /**
282
- * Rebuild a job's input from its on-disk bundle and execute it. This is the
283
- * detached runner's whole job; the manifest deliberately carries everything
284
- * a run needs (prompt path, resolved workingDir, hints, service) precisely
285
- * so execution can happen in a process that wasn't there when the job was
286
- * created.
287
- */
288
- export async function executeJobDir(deps, jobDir) {
289
- const manifest = await readJson(path.join(jobDir, "manifest.json"));
290
- const prompt = await readFile(manifest.promptPath, "utf8");
291
- const input = {
292
- prompt,
293
- files: manifest.files.map((f) => f.originalPath),
294
- workingDir: manifest.workingDir,
295
- ...(manifest.hints !== undefined ? { hints: manifest.hints } : {}),
296
- ...(manifest.workspacePolicy !== undefined
297
- ? { workspacePolicy: manifest.workspacePolicy }
298
- : {}),
299
- ...(manifest.service !== undefined ? { service: manifest.service } : {}),
300
- };
301
- await runJob(deps, jobDir, manifest, input);
302
- }
303
- /** dist/job-runner.js next to this module (compiled), or via the package's dist/ when running from src. */
304
- function resolveRunnerPath() {
305
- const here = path.dirname(fileURLToPath(import.meta.url));
306
- const candidates = [
307
- path.join(here, "job-runner.js"),
308
- path.join(here, "..", "dist", "job-runner.js"),
309
- ];
310
- return candidates.find((candidate) => existsSync(candidate));
311
- }
312
- const TERMINAL_WATCH_INTERVAL_MS = 300;
313
- /**
314
- * Watch a detached job's directory until it reaches a terminal state
315
- * (result.json present, or a failed/orphaned status — the orphan check
316
- * doubles as the exit path if the runner dies). Timer is unref'd: an
317
- * exiting server abandons the watch, which is exactly the point of
318
- * detached execution.
319
- */
320
- async function watchUntilTerminal(jobDir) {
321
- const deadline = Date.now() + JOB_DEFAULT_TIMEOUT_MS + 10 * 60 * 1000;
322
- while (Date.now() < deadline) {
323
- // Waits for a terminal STATUS, deliberately not for result.json.
324
- //
325
- // runJob writes result.json and then updates the status, so returning on
326
- // result.json alone let this resolve in the window between the two: a
327
- // caller could `await` a job and then read `status: "running"` from the
328
- // job it had just been told was finished. Observed on Windows CI as
329
- // "expected 'running' to be 'completed'".
330
- //
331
- // Because the status write comes last, a terminal status implies the
332
- // result is already on disk — the ordering does the synchronising, so no
333
- // extra check is needed here. A runner that dies between the two writes
334
- // is covered by withOrphanCheck below, which is the same exit path as any
335
- // other dead runner.
336
- try {
337
- const status = withOrphanCheck(await readJson(path.join(jobDir, "status.json")));
338
- if (status.status === "completed" ||
339
- status.status === "failed" ||
340
- status.status === "orphaned" ||
341
- status.status === "cancelled") {
342
- return;
343
- }
344
- }
345
- catch {
346
- // Transient read during an atomic rename — retry next tick.
347
- }
348
- await delay(TERMINAL_WATCH_INTERVAL_MS, undefined, { ref: false });
349
- }
350
- }
351
- /**
352
- * Default ceiling on agent CLIs running at once, machine-wide.
353
- *
354
- * 4 is a resource guard, not a throughput target. Measured 2026-08-03: 20
355
- * dispatches to one route, 13 running concurrently, 10 of the 20 failing, one
356
- * killed outright by a Rust OOM inside Codex. Agent CLIs each carry a model
357
- * runtime; the binding constraint is memory, not cores, so this does NOT
358
- * scale with CPU count. Override with `max_concurrent_runs:` in config.yaml
359
- * (0 disables the bound).
360
- */
361
- const DEFAULT_MAX_CONCURRENT_RUNS = 4;
362
- /** A CLI harness is a whole agent process; an endpoint call is one HTTP request. */
363
- const DEFAULT_CLI_WEIGHT = 1.0;
364
- const DEFAULT_ENDPOINT_WEIGHT = 0.1;
365
- function maxConcurrentRuns(config) {
366
- const configured = config?.maxConcurrentRuns;
367
- if (configured !== undefined && Number.isFinite(configured) && configured >= 0) {
368
- return configured;
369
- }
370
- return DEFAULT_MAX_CONCURRENT_RUNS;
371
- }
372
- /** Job dirs, oldest first by name — jobIds embed Date.now(), so name order is start order. */
373
- async function readJobStatuses() {
374
- const root = jobsRoot();
375
- if (!existsSync(root))
376
- return [];
377
- const entries = await readdir(root, { withFileTypes: true });
378
- const out = [];
379
- for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
380
- if (!entry.isDirectory())
381
- continue;
382
- const jobDir = path.join(root, entry.name);
383
- try {
384
- out.push({ jobDir, status: await readJson(path.join(jobDir, "status.json")) });
385
- }
386
- catch {
387
- // Half-written or pruned mid-scan — not a live run either way.
388
- }
389
- }
390
- return out;
391
- }
392
- /**
393
- * Occupied slots: jobs actually executing right now. Counts `running` (and
394
- * plain `queued` — a runner spawned but not yet started) only while the
395
- * heartbeat is fresh, so a crashed runner's slot is reclaimed by the same
396
- * ORPHAN_THRESHOLD_MS rule that already frees its status. Slot-queued jobs
397
- * are waiting for a slot, not holding one.
398
- */
399
- /**
400
- * What one run of a route costs against the concurrency budget.
401
- *
402
- * Unknown routes count as a full 1.0 on purpose. A job that has not been
403
- * routed yet (no forced `service`) has no weight to look up, and this bound
404
- * exists because a measured burst of 13 concurrent CLIs exhausted memory —
405
- * so the safe assumption for "might be anything" is "might be heavy".
406
- */
407
- export function resourceWeightFor(status, config) {
408
- const routeId = status.route ?? status.service;
409
- const svc = routeId !== undefined ? config?.services?.[routeId] : undefined;
410
- if (svc?.resourceWeight !== undefined && Number.isFinite(svc.resourceWeight) && svc.resourceWeight >= 0) {
411
- return svc.resourceWeight;
412
- }
413
- if (svc?.type === "openai_compatible")
414
- return DEFAULT_ENDPOINT_WEIGHT;
415
- return DEFAULT_CLI_WEIGHT;
416
- }
417
- /** In-flight jobs, counted. Used for supervisor pool sizing, not for the budget. */
418
- function countActiveJobs(statuses) {
419
- let n = 0;
420
- for (const { status } of statuses) {
421
- if (status.slotQueued)
422
- continue;
423
- if (status.status !== "running" && status.status !== "queued")
424
- continue;
425
- const beat = Date.parse(status.updatedAt);
426
- if (Number.isFinite(beat) && Date.now() - beat > ORPHAN_THRESHOLD_MS)
427
- continue;
428
- n += 1;
429
- }
430
- return n;
431
- }
432
- /**
433
- * Capacity currently in use, as a weighted sum rather than a job count.
434
- *
435
- * With every weight at 1.0 this is exactly the old count, so an existing
436
- * `max_concurrent_runs` keeps its previous meaning.
437
- */
438
- export function activeCapacity(statuses, config) {
439
- let active = 0;
440
- for (const { status } of statuses) {
441
- if (status.slotQueued)
442
- continue;
443
- if (status.status !== "running" && status.status !== "queued")
444
- continue;
445
- const beat = Date.parse(status.updatedAt);
446
- if (Number.isFinite(beat) && Date.now() - beat > ORPHAN_THRESHOLD_MS)
447
- continue;
448
- active += resourceWeightFor(status, config);
449
- }
450
- return active;
451
- }
452
- // ---------------------------------------------------------------------------
453
- // Supervisor pool
454
- // ---------------------------------------------------------------------------
455
- /**
456
- * How many supervisor PROCESSES may exist, regardless of how many jobs run.
457
- *
458
- * Previously every job got its own detached Node process. Measured on Windows
459
- * with Node 24: a bare node process is 52 MB RSS and one that has bootstrapped
460
- * a runtime is 65 MB, against ~54 MB for the agent CLI it exists to supervise.
461
- * So more than half the memory of a concurrent run was wrapper, and it scaled
462
- * linearly — 13 concurrent jobs meant 845 MB of supervision before any agent
463
- * had read a file. That is the concurrency ceiling.
464
- *
465
- * A supervisor is almost entirely idle: it waits on a child process and writes
466
- * the result. One can watch several at once for the cost of async I/O, so
467
- * wrapper memory becomes O(1) in the number of jobs instead of O(N), capped
468
- * here at ~260 MB.
469
- *
470
- * Four rather than one purely to bound blast radius: a supervisor crash strands
471
- * only the jobs it held. Those are recoverable anyway — the job directory is
472
- * the source of truth and the heartbeat check already marks stranded jobs
473
- * orphaned — but losing a quarter of in-flight work beats losing all of it.
474
- */
475
- export const SUPERVISOR_POOL_SIZE = 4;
476
- /** Poll interval while a supervisor waits for claimable work. */
477
- const SUPERVISOR_POLL_MS = 250;
478
- /** How long a supervisor stays alive with nothing to do before exiting. */
479
- const SUPERVISOR_IDLE_EXIT_MS = 5_000;
480
- /** Jobs one supervisor may run at once, so the pool can reach the global limit. */
481
- function jobsPerSupervisor(limit) {
482
- return Math.max(1, Math.ceil(limit / SUPERVISOR_POOL_SIZE));
483
- }
484
- /**
485
- * Take exclusive ownership of a job directory.
486
- *
487
- * `wx` fails if the file exists, atomically, on both Windows and POSIX — which
488
- * is what stops two supervisors racing onto the same job. A claim left behind
489
- * by a crashed supervisor is reclaimed once that job's heartbeat has gone
490
- * stale, by the same ORPHAN_THRESHOLD_MS rule used everywhere else.
491
- *
492
- * Exported for tests: the one-winner property under concurrent reclaim is the
493
- * invariant, and it is only checkable by calling this directly.
494
- */
495
- export async function claimJobDir(jobDir, status) {
496
- const claimPath = path.join(jobDir, "claim.json");
497
- try {
498
- await writeFile(claimPath, JSON.stringify({ pid: process.pid, at: timestamp() }), {
499
- encoding: "utf8",
500
- mode: 0o600,
501
- flag: "wx",
502
- });
503
- return true;
504
- }
505
- catch {
506
- const beat = Date.parse(status.updatedAt);
507
- if (!Number.isFinite(beat) || Date.now() - beat <= ORPHAN_THRESHOLD_MS)
508
- return false;
509
- // Reclaiming a crashed supervisor's claim must pick exactly ONE winner.
510
- // This path used to rewrite claim.json WITHOUT `wx`, so two supervisors
511
- // deciding "stale" in the same window both succeeded — the job ran twice,
512
- // a duplicate CLI execution billed twice. Renaming the stale claim aside
513
- // is atomic: the loser gets ENOENT and leaves the job alone, and the
514
- // winner still has to win the `wx` create below like any first claimant.
515
- const tomb = path.join(path.dirname(claimPath), `claim.stale-${process.pid}-${Date.now().toString(36)}`);
516
- try {
517
- await rename(claimPath, tomb);
518
- }
519
- catch {
520
- return false; // Another supervisor reclaimed it first.
521
- }
522
- await rm(tomb, { force: true }).catch(() => undefined);
523
- try {
524
- await writeFile(claimPath, JSON.stringify({ pid: process.pid, at: timestamp() }), {
525
- encoding: "utf8",
526
- mode: 0o600,
527
- flag: "wx",
528
- });
529
- return true;
530
- }
531
- catch {
532
- return false;
533
- }
534
- }
535
- }
536
- /**
537
- * Oldest released-but-unstarted job this supervisor can take, or undefined.
538
- *
539
- * "Released" means drainSlotQueue already granted it a slot and cleared
540
- * slotQueued; it is waiting for a supervisor rather than for capacity. A job
541
- * that is still slotQueued is deliberately NOT claimable here — that would let
542
- * a supervisor jump the FIFO order the drainer exists to enforce.
543
- */
544
- async function claimNextJob() {
545
- const statuses = await readJobStatuses();
546
- for (const { jobDir, status } of statuses) {
547
- if (status.slotQueued)
548
- continue;
549
- if (status.status !== "queued")
550
- continue;
551
- // Cancelled before a supervisor ever picked it up: claiming it would
552
- // start work someone has already asked not to happen.
553
- if (cancelRequested(jobDir))
554
- continue;
555
- if (!(await claimJobDir(jobDir, status)))
556
- continue;
557
- return jobDir;
558
- }
559
- return undefined;
560
- }
561
- /**
562
- * Supervisor main loop: claim work, run several jobs at once, exit when idle.
563
- *
564
- * Exiting on idle keeps the no-jobs steady state at zero processes, same as
565
- * before — the pool is a way to share supervision cost while work exists, not
566
- * a daemon.
567
- */
568
- export async function runSupervisor(deps, supervisorId) {
569
- const inflight = new Set();
570
- let idleSince = Date.now();
571
- // Imported here rather than at module scope: config-hot-reload.ts imports
572
- // setJobRetentionDays from THIS file, so a static value import would close a
573
- // cycle and leave one of the two half-initialised depending on entry point.
574
- // The type-only import at the top is fine; it is erased.
575
- const { ConfigHotReloader } = await import("./mcp/config-hot-reload.js");
576
- const reloader = new ConfigHotReloader(deps.holder, deps.holder.state.configPath);
577
- // Heartbeat so drainSlotQueue can tell how many supervisors already exist
578
- // and avoid piling on. Same staleness rule as jobs, so a killed supervisor
579
- // stops being counted without anything having to clean up after it.
580
- const beatDir = path.join(jobsRoot(), ".supervisors");
581
- // Adopt the file the spawning process already created for this slot, so the
582
- // slot is continuously accounted for rather than briefly disappearing
583
- // between the parent's registration and the child's first beat.
584
- const beatFile = path.join(beatDir, `${supervisorId ?? process.pid}.txt`);
585
- await mkdir(beatDir, { recursive: true, mode: 0o700 });
586
- const beat = async () => {
587
- try {
588
- await writeFile(beatFile, timestamp(), { encoding: "utf8", mode: 0o600 });
589
- }
590
- catch {
591
- // A missing heartbeat only risks an extra supervisor, which exits idle.
592
- }
593
- };
594
- await beat();
595
- const beatTimer = setInterval(() => void beat(), SUPERVISOR_POLL_MS * 4);
596
- const cleanup = async () => {
597
- clearInterval(beatTimer);
598
- try {
599
- await rm(beatFile, { force: true });
600
- }
601
- catch {
602
- // Stale file ages out of the liveness count on its own.
603
- }
604
- };
605
- try {
606
- for (;;) {
607
- const limit = maxConcurrentRuns(deps.holder.state.config);
608
- if (limit === 0)
609
- return;
610
- // Exit at once if the jobs root has gone. A per-job runner died with its
611
- // job, so a deleted jobs directory could never strand one; a pooled
612
- // supervisor outlives individual jobs and would otherwise sit polling a
613
- // path that no longer exists — spinning in the field, and in tests
614
- // interfering with whatever creates the next jobs root.
615
- if (!existsSync(jobsRoot()))
616
- return;
617
- if (inflight.size < jobsPerSupervisor(limit)) {
618
- // Pick up config edits before claiming anything.
619
- //
620
- // A supervisor OUTLIVES the server that spawned it, by design and by
621
- // up to SUPERVISOR_IDLE_EXIT_MS. Without this it also outlived the
622
- // server's CONFIG: restart with a route removed and dispatch inside
623
- // that window, and the old supervisor claimed the job and ran the
624
- // removed route, reporting plain success. `disabled:`,
625
- // `allow_paid_usage` and safety profiles are meant to be controls, and
626
- // for those few seconds they were not — against this product's own
627
- // "never spend money silently".
628
- //
629
- // maybeReload is mtime-gated, so the steady-state cost is one stat per
630
- // poll, and it keeps the old state when an edit is malformed.
631
- await reloader.maybeReload();
632
- // Promote waiting jobs into released ones first. The old per-job
633
- // runner called drainSlotQueue as it exited, which is what kept the
634
- // queue moving; a pooled supervisor outlives individual jobs, so it
635
- // has to do the same thing on every pass or a slot freed by a job it
636
- // just finished never reaches the next job in line.
637
- try {
638
- await drainSlotQueue(deps.holder.state.config, deps.holder.state.configPath);
639
- }
640
- catch {
641
- // Next pass retries; a drain failure must not kill the supervisor.
642
- }
643
- const jobDir = await claimNextJob();
644
- if (jobDir !== undefined) {
645
- idleSince = Date.now();
646
- const run = executeJobDir(deps, jobDir)
647
- .catch(() => undefined)
648
- .finally(() => inflight.delete(run));
649
- inflight.add(run);
650
- continue; // Try to fill the remaining slots before waiting.
651
- }
652
- }
653
- if (inflight.size === 0) {
654
- if (Date.now() - idleSince > SUPERVISOR_IDLE_EXIT_MS)
655
- return;
656
- await new Promise((r) => setTimeout(r, SUPERVISOR_POLL_MS));
657
- continue;
658
- }
659
- idleSince = Date.now();
660
- await Promise.race([...inflight, new Promise((r) => setTimeout(r, SUPERVISOR_POLL_MS))]);
661
- }
662
- }
663
- finally {
664
- await cleanup();
665
- }
666
- }
667
- /**
668
- * Start slot-queued jobs, oldest first, until the machine is at its limit.
669
- *
670
- * Deliberately has no daemon behind it: this runs on every new dispatch and
671
- * again as each runner exits, which between them covers every moment a slot
672
- * can free. The cost of that choice is that if every runner dies while jobs
673
- * are queued, the queue resumes on the next dispatch rather than immediately.
674
- * Bounded waiting was the explicit alternative and was not chosen — a queued
675
- * job keeps its jobId and its artifacts either way, so nothing is lost.
676
- *
677
- * NOT called at server start, which was tried and reverted: it silently ran
678
- * jobs abandoned by a dead session. `orphanStrandedSlotQueue` runs there
679
- * instead and reports them. See its comment for why reporting beats resuming.
680
- */
681
- /**
682
- * Mark jobs stranded in the slot queue by a server that is gone.
683
- *
684
- * Called once at server start, where the reasoning holds unconditionally: this
685
- * process has not queued anything yet, so anything still slot-queued was
686
- * queued by a session that no longer exists and nothing will ever drain it —
687
- * a new dispatch would, but the caller is asking about THIS job, and until
688
- * they happen to send unrelated work it reads `queued` forever.
689
- *
690
- * Deliberately reports rather than runs. Resuming was tried and is worse: a
691
- * job queued days ago would execute at the next server start, in its original
692
- * workingDir, at up to `full_auto`, with nobody watching. The job keeps its id
693
- * and artifacts, so `retry_job` re-runs it as a decision rather than a side
694
- * effect of opening an editor.
695
- *
696
- * The one status this writes back. Orphan detection elsewhere is
697
- * compute-on-read and never persists its verdict, because the owner might
698
- * still be alive; here the owner is definitionally gone.
699
- */
700
- export async function orphanStrandedSlotQueue() {
701
- // Only when nothing is left to work the queue.
702
- //
703
- // The first version of this reasoned "a server is starting, so anything
704
- // already slot-queued belongs to a session that is gone". That is false in
705
- // the configuration this product ships by default: `connect` registers with
706
- // Claude Code AND Cursor, and `serve` is a third — several servers routinely
707
- // share one jobs root. An acceptance pass measured the consequence: with
708
- // server A alive and holding a legitimately queued job, starting server B
709
- // marked that job orphaned within about a second, and because orphaning
710
- // clears `slotQueued` the drainer then skipped it forever. Live work,
711
- // killed, with an error stating a cause that was not true.
712
- //
713
- // A supervisor heartbeat answers the question the comment was guessing at.
714
- // If any supervisor is alive, the queue is being worked and nothing is
715
- // stranded — a waiting job is waiting, which is what
716
- // `ux-walkthrough.md` promises it stays.
717
- if ((await countLiveSupervisors()) > 0)
718
- return 0;
719
- const jobs = await listAsyncJobs().catch(() => []);
720
- let marked = 0;
721
- for (const status of jobs) {
722
- if (status.slotQueued !== true)
723
- continue;
724
- const { slotQueued: _cleared, ...rest } = status;
725
- await updateStatus(status.jobDir, {
726
- ...rest,
727
- status: "orphaned",
728
- updatedAt: timestamp(),
729
- success: false,
730
- error: "This job was still waiting for a concurrency slot when the dispatch server " +
731
- "exited, so it never started. It is NOT resumed automatically — re-running " +
732
- "an abandoned job unattended, in its original working directory, is not " +
733
- "something a server restart should decide. Use retry_job to run it.",
734
- }).catch(() => undefined);
735
- marked += 1;
736
- }
737
- return marked;
738
- }
739
- export async function drainSlotQueue(config, configPath) {
740
- const limit = maxConcurrentRuns(config);
741
- if (limit === 0)
742
- return;
743
- const runnerPath = resolveRunnerPath();
744
- if (runnerPath === undefined)
745
- return;
746
- // ONE drainer at a time, across processes. The body below is a
747
- // read-count-release: two drainers (every dispatch AND every runner exit
748
- // calls this) whose reads interleaved with each other's releases could
749
- // each release a job at active = limit-1 and exceed the cap — the cap that
750
- // exists because of a measured OOM. The FIFO comment below also assumes a
751
- // single drainer decides the order; this is what enforces that assumption.
752
- let releaseDrainLock;
753
- try {
754
- releaseDrainLock = await acquireWorkspaceLock(path.join(jobsRoot(), ".slot-drain"), DRAIN_LOCK_TIMEOUT_MS);
755
- }
756
- catch {
757
- // Another process is mid-drain and sees the same queue; this call's
758
- // trigger is covered by that drain or by the next one (every dispatch and
759
- // every runner exit re-runs this), so skipping is safe — waiting is not
760
- // worth blocking a dispatch for.
761
- return;
762
- }
763
- try {
764
- await drainSlotQueueLocked(limit, runnerPath, configPath, config);
765
- }
766
- finally {
767
- releaseDrainLock();
768
- }
769
- }
770
- /** How long a drain waits for a concurrent drainer before ceding to it. */
771
- const DRAIN_LOCK_TIMEOUT_MS = 5_000;
772
- async function drainSlotQueueLocked(limit, runnerPath, configPath, config) {
773
- const statuses = await readJobStatuses();
774
- let active = activeCapacity(statuses, config);
775
- // Supervisors are sized by how many JOBS there are, not by how much budget
776
- // they consume. Once `active` became a weighted sum these had to part
777
- // company: ten endpoint calls are 1.0 of capacity but still ten jobs, and
778
- // sizing the pool off the weight would hand all ten to one supervisor that
779
- // runs them a few at a time.
780
- let activeJobs = countActiveJobs(statuses);
781
- const waiting = statuses.filter((s) => s.status.slotQueued);
782
- // Release stays HERE, synchronously and oldest-first, even though a
783
- // supervisor is what will actually run the job. Two reasons: the caller's
784
- // returned status must still distinguish "got a slot" from "waiting", which
785
- // it cannot if clearing the flag is deferred to whichever supervisor wakes
786
- // first; and FIFO across concurrent dispatches is only guaranteed while one
787
- // drainer decides the order. Supervisors then pick up released work.
788
- let released = 0;
789
- for (const { jobDir, status } of waiting) {
790
- const weight = resourceWeightFor(status, config);
791
- // The `active > 0` guard prevents a deadlock the plain count could not
792
- // produce: a single job heavier than the whole budget (weight 1.0 against
793
- // a capacity of 0.5) would otherwise wait forever for room that can never
794
- // exist. When nothing is running, the next job always goes — the same
795
- // reasoning as the earlier fix for a job whose own queued status counted
796
- // against its own admission.
797
- if (active > 0 && active + weight > limit)
798
- break;
799
- const { slotQueued: _dropped, ...cleared } = status;
800
- await updateStatus(jobDir, {
801
- ...cleared,
802
- updatedAt: timestamp(),
803
- instructions: pollInstructions(status.jobId),
804
- });
805
- active += weight;
806
- activeJobs += 1;
807
- released += 1;
808
- }
809
- if (released === 0)
810
- return;
811
- // Size the pool against ALL outstanding work, not just the jobs released on
812
- // this call. Dispatches arrive one at a time, so `released` is usually 1;
813
- // sizing on that gave a single supervisor for twelve jobs, which then ran
814
- // them three at a time because each supervisor takes only
815
- // jobsPerSupervisor(limit). The cap must come from the pool size, never from
816
- // how the work happened to arrive.
817
- const outstanding = activeJobs;
818
- const wanted = Math.min(SUPERVISOR_POOL_SIZE, Math.ceil(outstanding / jobsPerSupervisor(limit)));
819
- const running = await countLiveSupervisors();
820
- for (let i = running; i < wanted; i += 1) {
821
- spawnDetachedSupervisor(runnerPath, configPath);
822
- }
823
- }
824
- /**
825
- * Supervisors currently alive, counted from their heartbeat files.
826
- *
827
- * Approximate on purpose: over-counting briefly means the pool runs one short
828
- * until the next drain, and under-counting means one extra supervisor that
829
- * finds no work and exits within SUPERVISOR_IDLE_EXIT_MS. Neither warrants a
830
- * lock, and both self-correct.
831
- */
832
- async function countLiveSupervisors() {
833
- const dir = path.join(jobsRoot(), ".supervisors");
834
- let entries;
835
- try {
836
- entries = await readdir(dir);
837
- }
838
- catch {
839
- return 0;
840
- }
841
- let live = 0;
842
- for (const entry of entries) {
843
- try {
844
- const beat = await readFile(path.join(dir, entry), "utf8");
845
- if (Date.now() - Date.parse(beat) <= ORPHAN_THRESHOLD_MS)
846
- live += 1;
847
- }
848
- catch {
849
- // Vanished mid-read: it is not live.
850
- }
851
- }
852
- return live;
853
- }
854
- /**
855
- * Start one detached supervisor; it finds its own work.
856
- *
857
- * Output goes to a log beside the heartbeats, for the same reason the per-job
858
- * runner logged to its job dir: a supervisor that dies during bootstrap (bad
859
- * config, missing module) is otherwise completely silent, and the only symptom
860
- * is jobs that never start.
861
- */
862
- function spawnDetachedSupervisor(runnerPath, configPath) {
863
- const dir = path.join(jobsRoot(), ".supervisors");
864
- mkdirSync(dir, { recursive: true, mode: 0o700 });
865
- const id = `${Date.now()}-${randomUUID().slice(0, 8)}`;
866
- // Register the slot HERE, before spawning, and hand the id to the child.
867
- //
868
- // Letting the supervisor write its own first heartbeat looks tidier and does
869
- // not work: booting a Node process takes a few hundred ms, so a burst of
870
- // dispatches all counted zero live supervisors and each spawned another.
871
- // Measured at 12 concurrent jobs: 12 supervisors, 748 MB — the pool capping
872
- // nothing at all. The parent claiming the slot synchronously is what makes
873
- // the cap real.
874
- writeFileSync(path.join(dir, `${id}.txt`), timestamp(), { encoding: "utf8", mode: 0o600 });
875
- const logFd = openSync(path.join(dir, `spawn-${id}.log`), "a");
876
- try {
877
- const child = spawn(process.execPath, [runnerPath, "--supervisor", id], {
878
- detached: true,
879
- stdio: ["ignore", logFd, logFd],
880
- windowsHide: true,
881
- env: {
882
- ...process.env,
883
- ...(configPath !== undefined ? { HARNESS_DISPATCH_CONFIG: configPath } : {}),
884
- },
885
- });
886
- child.unref();
887
- }
888
- finally {
889
- closeSync(logFd);
890
- }
891
- }
892
- function spawnDetachedRunner(runnerPath, jobDir, configPath) {
893
- // The runner's own stdout/stderr go to a log inside the job dir so a
894
- // bootstrap crash (bad config, missing module) leaves evidence.
895
- const logFd = openSync(path.join(jobDir, "output", "runner.log"), "a");
896
- try {
897
- const child = spawn(process.execPath, [runnerPath, jobDir], {
898
- detached: true,
899
- stdio: ["ignore", logFd, logFd],
900
- windowsHide: true,
901
- env: {
902
- ...process.env,
903
- ...(configPath !== undefined ? { HARNESS_DISPATCH_CONFIG: configPath } : {}),
904
- },
905
- });
906
- child.unref();
907
- }
908
- finally {
909
- closeSync(logFd);
910
- }
911
- }
912
- /**
913
- * Why a detached runner would fail to bootstrap from this config path, if it
914
- * would. `undefined` means the file loads (or there is none, which is the
915
- * auto-detect case and always fine).
916
- *
917
- * Deliberately re-reads rather than trusting the server's in-memory config:
918
- * the two disagreeing is exactly the condition being detected.
919
- */
920
- async function configLoadError(configPath) {
921
- if (configPath === undefined)
922
- return undefined;
923
- const { loadConfig } = await import("./config.js");
924
- try {
925
- await loadConfig(configPath);
926
- return undefined;
927
- }
928
- catch (err) {
929
- const detail = err instanceof Error ? err.message : String(err);
930
- return (`cannot start a background run: ${configPath} no longer loads, so the detached ` +
931
- `runner this dispatch needs cannot start — ${detail}. This server is still using the ` +
932
- `last config that loaded cleanly, which is why it accepted the request at all. Fix the ` +
933
- `file (harness-dispatch doctor --config "${configPath}" reports the problem) and retry.`);
934
- }
935
- }
936
- export async function startAsyncJob(deps, input) {
937
- return (await startAsyncJobTracked(deps, input)).status;
938
- }
939
- export async function startAsyncJobTracked(deps, input) {
940
- // Before anything is created on disk. Every dispatch path — MCP, HTTP,
941
- // fanout — funnels through here, so this is the one place that catches a bad
942
- // workingDir while the error can still name the real cause, and the only
943
- // point at which failing leaves no half-built job directory behind.
944
- const workingDirError = validateWorkingDir(input.workingDir);
945
- if (workingDirError !== undefined)
946
- throw new Error(workingDirError);
947
- // The runner reads the config FILE, so a file this server can no longer load
948
- // means no runner can start — and the job would sit untouched until the 90s
949
- // orphan threshold reported it dead. Observed: a caller told
950
- // "ended without a result (status: orphaned)" about a job whose own
951
- // status.json later read completed/success. Two false statements from one
952
- // broken file, ninety seconds apart.
953
- //
954
- // The server itself is fine: a failed hot-reload keeps the previous config
955
- // in memory, which is why it can still accept the dispatch at all. That
956
- // divergence between what the server runs and what the runner would read is
957
- // the whole bug, so it is refused here, immediately, naming the real cause —
958
- // before a job directory exists to be misreported.
959
- const configError = await configLoadError(deps.holder.state.configPath);
960
- if (configError !== undefined)
961
- throw new Error(configError);
962
- await pruneStaleJobs();
963
- const jobId = newJobId();
964
- const root = jobsRoot();
965
- const jobDir = path.join(root, jobId);
966
- await mkdir(path.join(jobDir, "context"), { recursive: true, mode: 0o700 });
967
- await mkdir(path.join(jobDir, "output"), { recursive: true, mode: 0o700 });
968
- const promptPath = path.join(jobDir, "prompt.md");
969
- // Prepend prior-job context before the prompt is frozen to disk, so the
970
- // runner, the manifest and any later inspection all see exactly what the
971
- // delegate was given.
972
- const preamble = await buildContextPreamble(input.contextJobs ?? []);
973
- const effectivePrompt = preamble + input.prompt;
974
- await writeFile(promptPath, effectivePrompt, { encoding: "utf8", mode: 0o600 });
975
- const fileSnapshots = await snapshotFiles(jobDir, input.files ?? []);
976
- const createdAt = timestamp();
977
- const resolvedWorkingDir = resolveWorkingDir(input.workingDir);
978
- const warning = workingDirWarning(resolvedWorkingDir);
979
- const manifest = {
980
- jobId,
981
- createdAt,
982
- workingDir: resolvedWorkingDir.workingDir,
983
- promptPath,
984
- files: fileSnapshots,
985
- ...(input.hints !== undefined ? { hints: input.hints } : {}),
986
- ...(input.workspacePolicy !== undefined ? { workspacePolicy: input.workspacePolicy } : {}),
987
- ...(input.service !== undefined ? { service: input.service } : {}),
988
- ...(input.retryOf !== undefined ? { retryOf: input.retryOf } : {}),
989
- ...(warning !== undefined ? { warning } : {}),
990
- };
991
- await writeJson(path.join(jobDir, "manifest.json"), manifest);
992
- const status = {
993
- jobId,
994
- status: "queued",
995
- createdAt,
996
- updatedAt: createdAt,
997
- jobDir,
998
- ...(input.service !== undefined ? { service: input.service } : {}),
999
- nextPollSeconds: SUGGESTED_POLL_SECONDS,
1000
- instructions: pollInstructions(jobId),
1001
- ...(warning !== undefined ? { warning } : {}),
1002
- };
1003
- await updateStatus(jobDir, status);
1004
- // Detached by default: the run must not die with this process. In-process
1005
- // mode exists for unit tests (injected fake dispatchers aren't visible to
1006
- // a separate process) and as the fallback when the runner script can't be
1007
- // found (running from raw src/ with no dist/ build).
1008
- const inproc = process.env.HARNESS_DISPATCH_INPROC_JOBS === "1";
1009
- const runnerPath = inproc ? undefined : resolveRunnerPath();
1010
- if (inproc || runnerPath === undefined) {
1011
- if (!inproc) {
1012
- console.error("harness-dispatch: dist/job-runner.js not found (unbuilt checkout?) — " +
1013
- "running the job in-process; it will not survive a server restart.");
1014
- }
1015
- // The DETACHED runner re-reads prompt.md, which carries the context
1016
- // preamble — so the in-process run must dispatch the same frozen prompt,
1017
- // not input.prompt. Passing the raw prompt here silently dropped
1018
- // contextJobs for every in-process run (unit tests with injected fakes,
1019
- // and the unbuilt-checkout fallback).
1020
- const completion = runJob(deps, jobDir, manifest, { ...input, prompt: effectivePrompt });
1021
- return { status, completion };
1022
- }
1023
- // Concurrency gate. Every dispatch spawns its own detached runner, so an
1024
- // in-process semaphore would bound nothing — the count has to come off
1025
- // disk. The caller still gets its jobId back immediately either way, so the
1026
- // API contract is unchanged and only the start time can move.
1027
- const limit = maxConcurrentRuns(deps.holder.state.config);
1028
- if (limit === 0) {
1029
- spawnDetachedRunner(runnerPath, jobDir, deps.holder.state.configPath);
1030
- return { status, completion: watchUntilTerminal(jobDir) };
1031
- }
1032
- // Enqueue first, then let drainSlotQueue decide — rather than testing the
1033
- // limit here and spawning inline. Two reasons, both learned the hard way:
1034
- // this job's own `queued` status is already on disk, so an inline count
1035
- // included itself and deadlocked at limit 1; and a fresh dispatch arriving
1036
- // while others wait must not jump the queue, which only one FIFO drainer
1037
- // can guarantee. Whether this job starts now is then just "did the drain
1038
- // reach it".
1039
- await updateStatus(jobDir, { ...status, slotQueued: true });
1040
- await drainSlotQueue(deps.holder.state.config, deps.holder.state.configPath);
1041
- const settled = await readJson(path.join(jobDir, "status.json"));
1042
- return { status: settled, completion: watchUntilTerminal(jobDir) };
1043
- }
1044
- const MAX_PARTIAL_OUTPUT_CHARS = 4000;
1045
- export async function getAsyncJob(jobId) {
1046
- assertValidJobId(jobId);
1047
- const jobDir = path.join(jobsRoot(), jobId);
1048
- // A well-formed id for a job that is gone is the ORDINARY case, not an
1049
- // internal error: retention prunes finished jobs, so any caller holding an
1050
- // id long enough will hit this. It used to surface as a raw Node ENOENT
1051
- // quoting an absolute path inside the jobs directory, which tells the caller
1052
- // nothing actionable and leaks the layout.
1053
- const noSuchJob = () => new Error(`No such job: ${jobId}. It may have been pruned by the retention window, ` +
1054
- `or it was never started on this machine.`);
1055
- if (!existsSync(path.join(jobDir, "manifest.json")))
1056
- throw noSuchJob();
1057
- let manifest;
1058
- let status;
1059
- let result;
1060
- try {
1061
- manifest = await readJson(path.join(jobDir, "manifest.json"));
1062
- status = withOrphanCheck(await readJson(path.join(jobDir, "status.json")));
1063
- const resultPath = path.join(jobDir, "output", "result.json");
1064
- result = existsSync(resultPath) ? await readJson(resultPath) : undefined;
1065
- }
1066
- catch (err) {
1067
- // existsSync-then-read is a TOCTOU window: retention pruning can delete
1068
- // the directory between the two calls, resurfacing the exact raw-ENOENT-
1069
- // with-an-absolute-path error this function's message exists to replace.
1070
- if (err?.code === "ENOENT")
1071
- throw noSuchJob();
1072
- throw err;
1073
- }
1074
- if (result !== undefined) {
1075
- return { manifest, status, result };
1076
- }
1077
- if (status.status === "orphaned") {
1078
- // Terminal: no poll guidance — polling will never resolve this job.
1079
- return { manifest, status };
1080
- }
1081
- const out = {
1082
- manifest,
1083
- status: {
1084
- ...status,
1085
- nextPollSeconds: SUGGESTED_POLL_SECONDS,
1086
- instructions: pollInstructions(jobId),
1087
- },
1088
- };
1089
- const partialPath = path.join(jobDir, "output", "stdout.partial.log");
1090
- if (existsSync(partialPath)) {
1091
- try {
1092
- const partial = await readFile(partialPath, "utf8");
1093
- out.partialOutput =
1094
- partial.length <= MAX_PARTIAL_OUTPUT_CHARS
1095
- ? partial
1096
- : `… [${partial.length - MAX_PARTIAL_OUTPUT_CHARS} chars omitted] …` +
1097
- partial.slice(-MAX_PARTIAL_OUTPUT_CHARS);
1098
- }
1099
- catch {
1100
- // Best-effort; absence of partial output isn't an error.
1101
- }
1102
- }
1103
- return out;
1104
- }
1105
- export async function listAsyncJobs() {
1106
- const root = jobsRoot();
1107
- if (!existsSync(root))
1108
- return [];
1109
- const entries = await readdir(root, { withFileTypes: true });
1110
- const statuses = [];
1111
- for (const entry of entries) {
1112
- if (!entry.isDirectory())
1113
- continue;
1114
- try {
1115
- statuses.push(withOrphanCheck(await readJson(path.join(root, entry.name, "status.json"))));
1116
- }
1117
- catch {
1118
- // Ignore incomplete or manually edited job directories.
1119
- }
1120
- }
1121
- return statuses.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
1122
- }
1123
- /**
1124
- * Ask a job to stop.
1125
- *
1126
- * Cancellation cannot be a signal here: jobs run inside POOLED supervisors,
1127
- * and the only pid recorded against a job belongs to a process that is also
1128
- * running other jobs, so signalling it would cancel work nobody asked to
1129
- * cancel. Instead this writes a marker the run itself honours — it drops out
1130
- * of its event stream, which triggers the dispatcher's teardown (killTree on
1131
- * the agent CLI and its children) and releases the workspace lock through the
1132
- * same path a normal finish uses.
1133
- *
1134
- * Two consequences worth stating plainly, because a caller who assumes
1135
- * otherwise will be surprised:
1136
- *
1137
- * 1. It is not instantaneous. A running job stops within about a second;
1138
- * `cancelling` means requested, not done. Poll job_status to see it land.
1139
- * 2. Work already done is NOT undone. A cancelled agent may have already
1140
- * edited files in the workspace, and those edits stay. Cancelling stops
1141
- * further work; it is not a rollback.
1142
- *
1143
- * A cancelled run is deliberately not recorded as a failure: the route's
1144
- * circuit breaker and failure count never see it, because the caller changing
1145
- * their mind says nothing about whether the route works.
1146
- */
1147
- export async function cancelJob(jobId, reason) {
1148
- const job = await getAsyncJob(jobId); // throws the friendly "No such job" for a stranger
1149
- const current = job.status.status;
1150
- if (current === "completed" || current === "failed" || current === "orphaned" || current === "cancelled") {
1151
- return {
1152
- jobId,
1153
- outcome: "already_finished",
1154
- status: current,
1155
- message: `Job ${jobId} had already finished (${current}); nothing to cancel.`,
1156
- };
1157
- }
1158
- const jobDir = path.join(jobsRoot(), jobId);
1159
- await requestCancel(jobDir, reason);
1160
- // A job still waiting for a slot has no runner to notice the marker, so
1161
- // stop it here. claimNextJob also refuses to claim a marked job, which
1162
- // closes the window where a supervisor picks it up between these two steps.
1163
- if (current === "queued") {
1164
- await updateStatus(jobDir, {
1165
- ...job.status,
1166
- status: "cancelled",
1167
- updatedAt: timestamp(),
1168
- success: false,
1169
- error: reason !== undefined ? `Cancelled: ${reason}` : "Cancelled before it started.",
1170
- });
1171
- // Deliberately NOT draining the slot queue here. Freeing this job's slot
1172
- // makes room for a waiting one, but drainSlotQueue can SPAWN supervisor
1173
- // processes, and a cancel — the operation whose whole point is to stop
1174
- // work — must not start any. Every dispatch and every runner exit already
1175
- // drains, which is the same "resumes on the next event" contract the
1176
- // queue documents elsewhere.
1177
- return {
1178
- jobId,
1179
- outcome: "cancelled",
1180
- status: "cancelled",
1181
- message: `Job ${jobId} was waiting for a slot and has been cancelled; it never started.`,
1182
- };
1183
- }
1184
- return {
1185
- jobId,
1186
- outcome: "cancelling",
1187
- status: current,
1188
- message: `Cancellation requested for ${jobId}. The run stops within a second or so — poll ` +
1189
- `job_status to confirm. Any files the agent already changed are NOT reverted.`,
1190
- };
1191
- }
1192
- /**
1193
- * Inspect or resolve the isolated workspace a finished job left behind.
1194
- *
1195
- * Looks the job up the same way job_status does, then hands off to
1196
- * workspace-resolve.ts. Kept here so the caller only ever needs a jobId —
1197
- * where the workspace lives, and which policy produced it, are details
1198
- * recorded in the job's own result.
1199
- */
1200
- export async function resolveJobWorkspace(jobId, action, opts = {}) {
1201
- const job = await getAsyncJob(jobId);
1202
- const run = job.result?.result?.workspace;
1203
- if (!isResolvable(run)) {
1204
- // Read through a separate binding: the type guard narrows `run` to never
1205
- // on this branch, which would make the diagnostic unable to say WHICH
1206
- // policy the caller actually got.
1207
- const raw = job.result?.result?.workspace;
1208
- const policy = raw?.policy ?? "shared";
1209
- throw new Error(`Job ${jobId} has no isolated workspace to ${action} (workspace policy: ${policy}). ` +
1210
- `Only 'copy' and 'git_worktree' dispatches produce one — a 'shared' or ` +
1211
- `'shared_locked' run edited ${raw?.originalWorkingDir ?? "the working directory"} ` +
1212
- `directly, so there is nothing separate to inspect, apply or throw away.`);
1213
- }
1214
- const jobDir = path.join(jobsRoot(), jobId);
1215
- if (action === "diff")
1216
- return workspaceDiff(jobId, jobDir, run);
1217
- if (action === "apply")
1218
- return applyWorkspace(jobId, jobDir, run, opts);
1219
- // force reaches discard too: it now refuses to destroy work the project
1220
- // does not have, and the caller needs the same override apply offers.
1221
- return discardWorkspace(jobId, run, opts);
1222
- }
1223
- /**
1224
- * Run a finished job's task again.
1225
- *
1226
- * The last verb missing from the job lifecycle: you could start work, watch
1227
- * it, stop it, and resolve its workspace — but if it failed, reproducing it
1228
- * meant reconstructing the prompt, the file list, the working directory and
1229
- * the hints by hand, from a job record that already holds all four. The
1230
- * machinery to execute a job bundle existed (executeJobDir) and simply was
1231
- * not reachable from outside.
1232
- *
1233
- * The prompt is taken from prompt.md, which is the FROZEN prompt — including
1234
- * any context preamble the original dispatch rendered in. A retry therefore
1235
- * reproduces what the delegate actually saw, not what the caller typed.
1236
- *
1237
- * `service` retargets the attempt, which is the common case rather than an
1238
- * afterthought: the reason a run failed is often the route, not the task
1239
- * ("codex hit its usage limit — try claude"). Omit it to reuse the original
1240
- * route, or to let the router pick again if the original had none.
1241
- */
1242
- export async function retryJob(jobId, deps, opts = {}) {
1243
- const prior = await getAsyncJob(jobId); // friendly "No such job" for a stranger
1244
- const state = prior.status.status;
1245
- if (state === "running" || state === "queued") {
1246
- throw new Error(`Job ${jobId} is still ${state}. Let it finish, or cancel it first with ` +
1247
- `cancel_job — retrying a live run would leave two attempts racing on the ` +
1248
- `same working directory.`);
1249
- }
1250
- const manifest = prior.manifest;
1251
- const prompt = await readFile(manifest.promptPath, "utf8");
1252
- if (opts.service !== undefined && !(opts.service in deps.holder.state.config.services)) {
1253
- throw new Error(`Unknown service: ${opts.service}. Valid route ids: ` +
1254
- `${Object.keys(deps.holder.state.config.services).join(", ")}.`);
1255
- }
1256
- const service = opts.service ?? manifest.service;
1257
- const { hints, droppedModel } = hintsForRetry(manifest.hints, prior.status.route ?? manifest.service, service, deps.holder.state.config.services);
1258
- const { status } = await startAsyncJobTracked(deps, {
1259
- prompt,
1260
- files: manifest.files.map((f) => f.originalPath),
1261
- workingDir: manifest.workingDir,
1262
- retryOf: jobId,
1263
- ...(hints !== undefined ? { hints } : {}),
1264
- ...(manifest.workspacePolicy !== undefined
1265
- ? { workspacePolicy: manifest.workspacePolicy }
1266
- : {}),
1267
- ...(service !== undefined ? { service } : {}),
1268
- });
1269
- return {
1270
- jobId: status.jobId,
1271
- retryOf: jobId,
1272
- ...(service !== undefined ? { service } : {}),
1273
- ...(droppedModel !== undefined ? { droppedModel } : {}),
1274
- reusedFrom: {
1275
- prompt: true,
1276
- files: manifest.files.length,
1277
- workingDir: manifest.workingDir,
1278
- },
1279
- message: `Started ${status.jobId} from ${jobId}'s prompt, files and working directory` +
1280
- `${opts.service !== undefined ? `, retargeted to ${opts.service}` : ""}. ` +
1281
- `${droppedModel !== undefined
1282
- ? `Left behind the original's model "${droppedModel}", which ${service} does ` +
1283
- `not declare — a model name belongs to the route it was chosen for. Pass ` +
1284
- `hints.model on a fresh dispatch if you want a specific model here. `
1285
- : ""}` +
1286
- `Check it with job_status; the original job is untouched.`,
1287
- };
1288
- }
1289
- /**
1290
- * Carry the original's hints into a retry — except a model that belonged to
1291
- * the route being left behind.
1292
- *
1293
- * Retrying somewhere else is the documented reason this tool exists ("the task
1294
- * was fine and the route was not"), and reusing the model verbatim defeated
1295
- * exactly that case: model names are route-scoped, so the retry failed for the
1296
- * same reason as the original. Observed end to end — a Cursor run that died on
1297
- * `Cannot use this model` was retried onto Claude and died on
1298
- * `unrecognized_model`, having never reached the task.
1299
- *
1300
- * Narrow on purpose. The model is kept when the retry stays on the original's
1301
- * route (that is a plain "try again"), and when the new route declares it
1302
- * anyway. Only a model the destination does not know is dropped, and the
1303
- * caller is told — a silently changed model is the failure this project keeps
1304
- * finding, so it is reported in the response rather than inferred from a
1305
- * different result.
1306
- */
1307
- function hintsForRetry(hints, priorRoute, service, services) {
1308
- if (hints === undefined)
1309
- return { hints };
1310
- const model = hints.model;
1311
- if (model === undefined || service === undefined || service === priorRoute)
1312
- return { hints };
1313
- const target = services[service];
1314
- if (target === undefined || declaresModel(target, model))
1315
- return { hints };
1316
- const { model: _dropped, ...rest } = hints;
1317
- return { hints: rest, droppedModel: model };
1318
- }
2
+ * The job surface, assembled from the modules that own each concern.
3
+ *
4
+ * This file was 1,600 lines holding all of them at once: running a job,
5
+ * admission and the supervisor pool, the start/read verbs, and the lifecycle
6
+ * verbs. It is a barrel now, which is what it had already half become — every
7
+ * consumer imports from here, so the split is invisible outside src/jobs/.
8
+ */
9
+ export { executeJobDir, resolveRunnerPath, runJob } from "./jobs/run.js";
10
+ export { activeCapacity, claimJobDir, countLiveSupervisorsForTest, drainSlotQueue, orphanStrandedSlotQueue, resourceWeightFor, runSupervisor, SUPERVISOR_POOL_SIZE, } from "./jobs/supervisor.js";
11
+ export { getAsyncJob, listAsyncJobs } from "./jobs/read.js";
12
+ export { startAsyncJob, startAsyncJobTracked } from "./jobs/start.js";
13
+ export { cancelJob, resolveJobWorkspace, retryJob, } from "./jobs/lifecycle.js";
14
+ export { buildContextPreamble } from "./jobs/context.js";
15
+ export { setJobRetentionDays } from "./jobs/store.js";
1319
16
  //# sourceMappingURL=jobs.js.map