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
package/dist/jobs.js CHANGED
@@ -12,50 +12,22 @@
12
12
  */
13
13
  import { spawn } from "node:child_process";
14
14
  import { randomUUID } from "node:crypto";
15
- import { closeSync, existsSync, openSync } from "node:fs";
16
- import { appendFile, chmod, copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile, } from "node:fs/promises";
17
- import { homedir } from "node:os";
15
+ import { closeSync, existsSync, mkdirSync, openSync, writeFileSync } from "node:fs";
16
+ import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
18
17
  import path from "node:path";
19
18
  import { setTimeout as delay } from "node:timers/promises";
20
19
  import { fileURLToPath } from "node:url";
21
- import { resolveWorkingDir, workingDirWarning } from "./working-dir.js";
22
- /**
23
- * Dispatcher error strings are unbounded (a corrupted downstream config once
24
- * produced a 173KB parse error). Full text always lands in stderr.log; the
25
- * JSON surfaces returned over MCP carry a bounded copy.
26
- */
27
- const MAX_JSON_ERROR_CHARS = 4000;
28
- /** Suggested delay before an agent checks `job_status` again. */
29
- const SUGGESTED_POLL_SECONDS = 300;
20
+ import { resolveWorkingDir, validateWorkingDir, workingDirWarning } from "./working-dir.js";
21
+ import { acquireWorkspaceLock } from "./workspace-lock.js";
22
+ import { applyWorkspace, discardWorkspace, isResolvable, workspaceDiff, } from "./workspace-resolve.js";
23
+ import { buildContextPreamble } from "./jobs/context.js";
24
+ 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";
25
+ // Re-exported so existing importers (mcp/tools.ts, http/server.ts, the job
26
+ // runner, and the tests) keep their current import paths through the split.
27
+ export { buildContextPreamble };
28
+ export { setJobRetentionDays };
30
29
  /** How often a live background run bumps its status file's updatedAt. */
31
30
  const HEARTBEAT_INTERVAL_MS = 15_000;
32
- /**
33
- * A "running" status whose updatedAt is older than this is a lie — the
34
- * process that owned the run is gone (several missed heartbeats), so
35
- * readers report the job as orphaned instead of keeping callers polling a
36
- * corpse forever. Generous multiple of the heartbeat so an event-loop
37
- * stall can't produce false orphans.
38
- */
39
- const ORPHAN_THRESHOLD_MS = 90_000;
40
- /**
41
- * Compute-on-read orphan detection. Never writes the verdict back — the
42
- * status file stays whatever the (dead) owner last wrote, so a future
43
- * attach/recovery feature keeps its evidence intact.
44
- */
45
- function withOrphanCheck(status) {
46
- if (status.status !== "running" && status.status !== "queued")
47
- return status;
48
- const beat = Date.parse(status.updatedAt);
49
- if (Number.isFinite(beat) && Date.now() - beat <= ORPHAN_THRESHOLD_MS)
50
- return status;
51
- return {
52
- ...status,
53
- status: "orphaned",
54
- success: false,
55
- error: "The dispatch server that started this job exited before the run finished — " +
56
- "the background run died with it. Re-dispatch the task; this job will never complete.",
57
- };
58
- }
59
31
  /**
60
32
  * Fallback dispatch timeout for jobs. Dispatchers hard-code a short default
61
33
  * (10 min for CLI harnesses, 2 min for openai_compatible) meant to catch a
@@ -73,145 +45,6 @@ function withOrphanCheck(status) {
73
45
  * could burn up to 3x this value before failing conclusively.
74
46
  */
75
47
  const JOB_DEFAULT_TIMEOUT_MS = 60 * 60 * 1000;
76
- function boundedError(error) {
77
- if (error === undefined)
78
- return undefined;
79
- if (error.length <= MAX_JSON_ERROR_CHARS)
80
- return error;
81
- return (error.slice(0, MAX_JSON_ERROR_CHARS) +
82
- ` … [truncated ${error.length - MAX_JSON_ERROR_CHARS} chars — full text in output/stderr.log]`);
83
- }
84
- function pollInstructions(jobId) {
85
- return (`Job runs in the background; CLI harnesses typically take 3-15 minutes. ` +
86
- `Wait ~${Math.round(SUGGESTED_POLL_SECONDS / 60)} minutes (e.g. sleep), then call ` +
87
- `job_status with jobId=${jobId}. While status is "running", partialOutput shows ` +
88
- `progress; check again until status is "completed" or "failed". Results persist ` +
89
- `on disk, so checking late loses nothing.`);
90
- }
91
- function jobsRoot() {
92
- return (process.env.HARNESS_DISPATCH_JOBS_DIR ??
93
- path.join(homedir(), ".harness-dispatch", "jobs"));
94
- }
95
- const DEFAULT_JOB_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
96
- let configuredJobMaxAgeMs;
97
- /**
98
- * Config-driven retention (`retention: { jobs_days: N }` in config.yaml) —
99
- * set at runtime bootstrap and on every hot reload. Precedence:
100
- * HARNESS_DISPATCH_JOB_MAX_AGE_MS env > config > 7-day default.
101
- */
102
- export function setJobRetentionDays(days) {
103
- configuredJobMaxAgeMs =
104
- days !== undefined && Number.isFinite(days) && days >= 0
105
- ? days * 24 * 60 * 60 * 1000
106
- : undefined;
107
- }
108
- function jobMaxAgeMs() {
109
- const raw = process.env.HARNESS_DISPATCH_JOB_MAX_AGE_MS;
110
- const parsed = raw ? Number(raw) : NaN;
111
- if (Number.isFinite(parsed) && parsed >= 0)
112
- return parsed;
113
- return configuredJobMaxAgeMs ?? DEFAULT_JOB_MAX_AGE_MS;
114
- }
115
- /**
116
- * Nothing ever pruned old job directories — status.json/result.json/output
117
- * logs and every snapshotted context file accumulated under jobsRoot()
118
- * forever. Prune anything with no activity for the retention window
119
- * (default 7 days, override via HARNESS_DISPATCH_JOB_MAX_AGE_MS) each time a
120
- * new job is about to start. Job directory mtime is a reasonable proxy for
121
- * "last activity": writeJson's tmp-then-rename touches the job dir on every
122
- * status update, so a running (or freshly completed but unpolled) job keeps
123
- * bumping it — only genuinely abandoned jobs go stale. Best effort: a prune
124
- * failure must never block starting the job that was actually requested.
125
- */
126
- async function pruneStaleJobs() {
127
- const root = jobsRoot();
128
- let entries;
129
- try {
130
- entries = await readdir(root, { withFileTypes: true });
131
- }
132
- catch {
133
- return;
134
- }
135
- const maxAgeMs = jobMaxAgeMs();
136
- const now = Date.now();
137
- for (const entry of entries) {
138
- if (!entry.isDirectory())
139
- continue;
140
- const jobDir = path.join(root, entry.name);
141
- try {
142
- const info = await stat(jobDir);
143
- if (now - info.mtimeMs > maxAgeMs) {
144
- await rm(jobDir, { recursive: true, force: true });
145
- }
146
- }
147
- catch {
148
- // best effort — a locked/already-gone/permission-denied entry is skipped
149
- }
150
- }
151
- }
152
- function timestamp() {
153
- return new Date().toISOString();
154
- }
155
- function safeBaseName(filePath) {
156
- return path.basename(filePath).replace(/[^A-Za-z0-9_.-]/g, "_");
157
- }
158
- async function writeJson(filePath, value) {
159
- const tmpPath = `${filePath}.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}.tmp`;
160
- await writeFile(tmpPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
161
- await renameWithRetry(tmpPath, filePath);
162
- }
163
- async function renameWithRetry(tmpPath, filePath) {
164
- for (let attempt = 0; attempt < 8; attempt += 1) {
165
- try {
166
- await rename(tmpPath, filePath);
167
- return;
168
- }
169
- catch (err) {
170
- const code = typeof err === "object" && err !== null ? err.code : undefined;
171
- if (code !== "EPERM" && code !== "EACCES" && code !== "EBUSY")
172
- throw err;
173
- await delay(25 * (attempt + 1));
174
- }
175
- }
176
- await rename(tmpPath, filePath);
177
- }
178
- async function readJson(filePath) {
179
- return JSON.parse(await readFile(filePath, "utf8"));
180
- }
181
- async function updateStatus(jobDir, status) {
182
- await writeJson(path.join(jobDir, "status.json"), {
183
- ...status,
184
- updatedAt: timestamp(),
185
- });
186
- }
187
- async function snapshotFiles(jobDir, files) {
188
- const out = [];
189
- const filesDir = path.join(jobDir, "context", "files");
190
- await mkdir(filesDir, { recursive: true, mode: 0o700 });
191
- for (const [index, originalPath] of files.entries()) {
192
- const item = { originalPath };
193
- try {
194
- const fileStat = await stat(originalPath);
195
- if (!fileStat.isFile()) {
196
- item.error = "not a regular file";
197
- out.push(item);
198
- continue;
199
- }
200
- const snapshotName = `${String(index + 1).padStart(3, "0")}-${safeBaseName(originalPath)}`;
201
- const snapshotPath = path.join(filesDir, snapshotName);
202
- await copyFile(originalPath, snapshotPath);
203
- await chmod(snapshotPath, 0o600);
204
- item.snapshotPath = snapshotPath;
205
- item.sizeBytes = fileStat.size;
206
- }
207
- catch (err) {
208
- item.error = err instanceof Error ? err.message : String(err);
209
- }
210
- out.push(item);
211
- }
212
- await writeJson(path.join(jobDir, "context", "files.json"), out);
213
- return out;
214
- }
215
48
  async function runJob(deps, jobDir, manifest, input) {
216
49
  const started = Date.now();
217
50
  const runningStatus = () => ({
@@ -229,13 +62,17 @@ async function runJob(deps, jobDir, manifest, input) {
229
62
  // stale status file" (getAsyncJob reports the latter as "orphaned").
230
63
  // unref'd so an exiting process never lingers on it — which is exactly
231
64
  // the scenario the heartbeat exists to expose. The `finished` flag stops
232
- // a beat that fires between the terminal status write and clearInterval
233
- // from resurrecting "running".
65
+ // a beat that FIRES after the terminal write; `pendingBeat` covers the
66
+ // beat that fired BEFORE it and is still mid-write — updateStatus's rename
67
+ // can back off ~900ms on Windows EPERM, long enough to land after the
68
+ // terminal status and re-mark a completed job "running" (then "orphaned"
69
+ // forever in the list view). The terminal paths await it before writing.
234
70
  let finished = false;
71
+ let pendingBeat = Promise.resolve();
235
72
  const heartbeat = setInterval(() => {
236
73
  if (finished)
237
74
  return;
238
- void updateStatus(jobDir, runningStatus()).catch(() => undefined);
75
+ pendingBeat = updateStatus(jobDir, runningStatus()).catch(() => undefined);
239
76
  }, HEARTBEAT_INTERVAL_MS);
240
77
  heartbeat.unref?.();
241
78
  try {
@@ -251,6 +88,12 @@ async function runJob(deps, jobDir, manifest, input) {
251
88
  // Stream the dispatch so agents polling action=get can watch progress in
252
89
  // stdout.partial.log instead of waiting blind for the final result.
253
90
  const partialPath = path.join(jobDir, "output", "stdout.partial.log");
91
+ // Cancellation travels DOWN to the child process, not up through the
92
+ // iterator. Returning from an async generator that is suspended at an
93
+ // `await` does not take effect until that await settles — which for an
94
+ // agent CLI gone quiet is never — so the only thing that reliably stops a
95
+ // silent run is aborting the subprocess (or fetch) directly.
96
+ const cancelController = new AbortController();
254
97
  const events = input.service
255
98
  ? state.router.streamTo(input.service, input.prompt, files, workingDir, {
256
99
  ...(hints.safetyProfile !== undefined
@@ -266,15 +109,64 @@ async function runJob(deps, jobDir, manifest, input) {
266
109
  ...(hints.taskType !== undefined ? { taskType: hints.taskType } : {}),
267
110
  ...(hints.timeoutMs !== undefined ? { timeoutMs: hints.timeoutMs } : {}),
268
111
  defaultTimeoutMs: JOB_DEFAULT_TIMEOUT_MS,
112
+ signal: cancelController.signal,
269
113
  })
270
114
  : state.router.stream(input.prompt, files, workingDir, {
271
115
  hints,
272
116
  maxFallbacks: 2,
273
117
  defaultTimeoutMs: JOB_DEFAULT_TIMEOUT_MS,
118
+ signal: cancelController.signal,
274
119
  });
275
120
  let finalResult = null;
276
121
  let finalDecision = null;
277
- for await (const { event, decision } of events) {
122
+ let cancelled = false;
123
+ // Driven through an explicit iterator rather than `for await`, so a
124
+ // cancellation can interrupt a stream that is producing NOTHING. A
125
+ // for-await body only runs when an event arrives, and the case that most
126
+ // needs cancelling is the agent that has gone quiet for twenty minutes.
127
+ // Racing next() against a poll lets us stop either way, and calling
128
+ // return() on the iterator is what tears the child process down —
129
+ // stream-subprocess's return() runs killTree, which on POSIX now signals
130
+ // the whole process group.
131
+ const iterator = events[Symbol.asyncIterator]();
132
+ const CANCEL_POLL_MS = 1_000;
133
+ // The in-flight next() is held ACROSS polls rather than re-issued.
134
+ // Racing a fresh iterator.next() each time round drops events: when the
135
+ // poll wins, the previous next() is still pending, and calling next()
136
+ // again queues a second pull whose result is the one we read — the first
137
+ // event resolves into nothing. Losing a `completion` that way leaves a
138
+ // finished run with no result.json, so the job never reaches a terminal
139
+ // state and the caller polls a corpse. Caught by the slot-queue test,
140
+ // which waits for a queued job to actually complete.
141
+ let pending;
142
+ for (;;) {
143
+ pending ??= iterator.next();
144
+ const winner = await Promise.race([
145
+ pending.then((r) => ({ kind: "event", r })),
146
+ delay(CANCEL_POLL_MS, { kind: "poll" }, { ref: false }),
147
+ ]);
148
+ if (winner.kind === "poll") {
149
+ if (!cancelRequested(jobDir))
150
+ continue; // `pending` deliberately kept
151
+ cancelled = true;
152
+ cancelController.abort();
153
+ // Not awaited: the generator is parked on an await that only settles
154
+ // once the abort above kills the child, so awaiting return() here
155
+ // would deadlock on the very thing it is trying to stop.
156
+ void iterator.return?.().catch(() => undefined);
157
+ break;
158
+ }
159
+ pending = undefined;
160
+ const next = winner.r;
161
+ if (next.done)
162
+ break;
163
+ if (cancelRequested(jobDir)) {
164
+ cancelled = true;
165
+ cancelController.abort();
166
+ void iterator.return?.().catch(() => undefined);
167
+ break;
168
+ }
169
+ const { event, decision } = next.value;
278
170
  if (decision)
279
171
  finalDecision = decision;
280
172
  if (input.onEvent) {
@@ -298,6 +190,28 @@ async function runJob(deps, jobDir, manifest, input) {
298
190
  finalResult = event.result;
299
191
  }
300
192
  }
193
+ if (cancelled) {
194
+ // Terminal, and deliberately NOT routed through the result/failure path:
195
+ // no result.json is written and the router never sees a failure, so a
196
+ // cancellation cannot charge the route's breaker or failure count for
197
+ // the caller changing their mind.
198
+ finished = true;
199
+ await pendingBeat;
200
+ const reason = await cancelReason(jobDir);
201
+ await updateStatus(jobDir, {
202
+ jobId: manifest.jobId,
203
+ status: "cancelled",
204
+ createdAt: manifest.createdAt,
205
+ updatedAt: timestamp(),
206
+ jobDir,
207
+ ...(input.service !== undefined ? { service: input.service } : {}),
208
+ success: false,
209
+ error: reason !== undefined ? `Cancelled: ${reason}` : "Cancelled before it finished.",
210
+ ...(manifest.warning !== undefined ? { warning: manifest.warning } : {}),
211
+ durationMs: Date.now() - started,
212
+ });
213
+ return;
214
+ }
301
215
  const result = finalResult ?? {
302
216
  output: "",
303
217
  service: input.service ?? "none",
@@ -305,6 +219,7 @@ async function runJob(deps, jobDir, manifest, input) {
305
219
  error: "Router stream ended without a completion event",
306
220
  };
307
221
  finished = true;
222
+ await pendingBeat;
308
223
  const payload = {
309
224
  jobId: manifest.jobId,
310
225
  result: { ...result, ...(result.error !== undefined ? { error: boundedError(result.error) } : {}) },
@@ -330,20 +245,33 @@ async function runJob(deps, jobDir, manifest, input) {
330
245
  }
331
246
  catch (err) {
332
247
  finished = true;
248
+ await pendingBeat;
333
249
  const message = err instanceof Error ? err.message : String(err);
334
- await writeFile(path.join(jobDir, "output", "stderr.log"), message, { encoding: "utf8", mode: 0o600 });
335
- await updateStatus(jobDir, {
336
- jobId: manifest.jobId,
337
- status: "failed",
338
- createdAt: manifest.createdAt,
339
- updatedAt: timestamp(),
340
- jobDir,
341
- ...(input.service !== undefined ? { service: input.service } : {}),
342
- success: false,
343
- error: boundedError(message),
344
- ...(manifest.warning !== undefined ? { warning: manifest.warning } : {}),
345
- durationMs: Date.now() - started,
346
- });
250
+ try {
251
+ await writeFile(path.join(jobDir, "output", "stderr.log"), message, {
252
+ encoding: "utf8",
253
+ mode: 0o600,
254
+ });
255
+ await updateStatus(jobDir, {
256
+ jobId: manifest.jobId,
257
+ status: "failed",
258
+ createdAt: manifest.createdAt,
259
+ updatedAt: timestamp(),
260
+ jobDir,
261
+ ...(input.service !== undefined ? { service: input.service } : {}),
262
+ success: false,
263
+ error: boundedError(message),
264
+ ...(manifest.warning !== undefined ? { warning: manifest.warning } : {}),
265
+ durationMs: Date.now() - started,
266
+ });
267
+ }
268
+ catch {
269
+ // The job directory can be GONE by the time a failure is recorded —
270
+ // retention pruning, or a caller that tore down its state mid-run.
271
+ // There is nowhere to write and no reader left to care; throwing here
272
+ // would reject `completion`, which is documented to never reject (and
273
+ // surfaced in CI as an unhandled rejection out of a finished test).
274
+ }
347
275
  }
348
276
  finally {
349
277
  clearInterval(heartbeat);
@@ -391,13 +319,25 @@ const TERMINAL_WATCH_INTERVAL_MS = 300;
391
319
  async function watchUntilTerminal(jobDir) {
392
320
  const deadline = Date.now() + JOB_DEFAULT_TIMEOUT_MS + 10 * 60 * 1000;
393
321
  while (Date.now() < deadline) {
394
- if (existsSync(path.join(jobDir, "output", "result.json")))
395
- return;
322
+ // Waits for a terminal STATUS, deliberately not for result.json.
323
+ //
324
+ // runJob writes result.json and then updates the status, so returning on
325
+ // result.json alone let this resolve in the window between the two: a
326
+ // caller could `await` a job and then read `status: "running"` from the
327
+ // job it had just been told was finished. Observed on Windows CI as
328
+ // "expected 'running' to be 'completed'".
329
+ //
330
+ // Because the status write comes last, a terminal status implies the
331
+ // result is already on disk — the ordering does the synchronising, so no
332
+ // extra check is needed here. A runner that dies between the two writes
333
+ // is covered by withOrphanCheck below, which is the same exit path as any
334
+ // other dead runner.
396
335
  try {
397
336
  const status = withOrphanCheck(await readJson(path.join(jobDir, "status.json")));
398
337
  if (status.status === "completed" ||
399
338
  status.status === "failed" ||
400
- status.status === "orphaned") {
339
+ status.status === "orphaned" ||
340
+ status.status === "cancelled") {
401
341
  return;
402
342
  }
403
343
  }
@@ -407,6 +347,465 @@ async function watchUntilTerminal(jobDir) {
407
347
  await delay(TERMINAL_WATCH_INTERVAL_MS, undefined, { ref: false });
408
348
  }
409
349
  }
350
+ /**
351
+ * Default ceiling on agent CLIs running at once, machine-wide.
352
+ *
353
+ * 4 is a resource guard, not a throughput target. Measured 2026-08-03: 20
354
+ * dispatches to one route, 13 running concurrently, 10 of the 20 failing, one
355
+ * killed outright by a Rust OOM inside Codex. Agent CLIs each carry a model
356
+ * runtime; the binding constraint is memory, not cores, so this does NOT
357
+ * scale with CPU count. Override with `max_concurrent_runs:` in config.yaml
358
+ * (0 disables the bound).
359
+ */
360
+ const DEFAULT_MAX_CONCURRENT_RUNS = 4;
361
+ /** A CLI harness is a whole agent process; an endpoint call is one HTTP request. */
362
+ const DEFAULT_CLI_WEIGHT = 1.0;
363
+ const DEFAULT_ENDPOINT_WEIGHT = 0.1;
364
+ function maxConcurrentRuns(config) {
365
+ const configured = config?.maxConcurrentRuns;
366
+ if (configured !== undefined && Number.isFinite(configured) && configured >= 0) {
367
+ return configured;
368
+ }
369
+ return DEFAULT_MAX_CONCURRENT_RUNS;
370
+ }
371
+ /** Job dirs, oldest first by name — jobIds embed Date.now(), so name order is start order. */
372
+ async function readJobStatuses() {
373
+ const root = jobsRoot();
374
+ if (!existsSync(root))
375
+ return [];
376
+ const entries = await readdir(root, { withFileTypes: true });
377
+ const out = [];
378
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
379
+ if (!entry.isDirectory())
380
+ continue;
381
+ const jobDir = path.join(root, entry.name);
382
+ try {
383
+ out.push({ jobDir, status: await readJson(path.join(jobDir, "status.json")) });
384
+ }
385
+ catch {
386
+ // Half-written or pruned mid-scan — not a live run either way.
387
+ }
388
+ }
389
+ return out;
390
+ }
391
+ /**
392
+ * Occupied slots: jobs actually executing right now. Counts `running` (and
393
+ * plain `queued` — a runner spawned but not yet started) only while the
394
+ * heartbeat is fresh, so a crashed runner's slot is reclaimed by the same
395
+ * ORPHAN_THRESHOLD_MS rule that already frees its status. Slot-queued jobs
396
+ * are waiting for a slot, not holding one.
397
+ */
398
+ /**
399
+ * What one run of a route costs against the concurrency budget.
400
+ *
401
+ * Unknown routes count as a full 1.0 on purpose. A job that has not been
402
+ * routed yet (no forced `service`) has no weight to look up, and this bound
403
+ * exists because a measured burst of 13 concurrent CLIs exhausted memory —
404
+ * so the safe assumption for "might be anything" is "might be heavy".
405
+ */
406
+ export function resourceWeightFor(status, config) {
407
+ const routeId = status.route ?? status.service;
408
+ const svc = routeId !== undefined ? config?.services?.[routeId] : undefined;
409
+ if (svc?.resourceWeight !== undefined && Number.isFinite(svc.resourceWeight) && svc.resourceWeight >= 0) {
410
+ return svc.resourceWeight;
411
+ }
412
+ if (svc?.type === "openai_compatible")
413
+ return DEFAULT_ENDPOINT_WEIGHT;
414
+ return DEFAULT_CLI_WEIGHT;
415
+ }
416
+ /** In-flight jobs, counted. Used for supervisor pool sizing, not for the budget. */
417
+ function countActiveJobs(statuses) {
418
+ let n = 0;
419
+ for (const { status } of statuses) {
420
+ if (status.slotQueued)
421
+ continue;
422
+ if (status.status !== "running" && status.status !== "queued")
423
+ continue;
424
+ const beat = Date.parse(status.updatedAt);
425
+ if (Number.isFinite(beat) && Date.now() - beat > ORPHAN_THRESHOLD_MS)
426
+ continue;
427
+ n += 1;
428
+ }
429
+ return n;
430
+ }
431
+ /**
432
+ * Capacity currently in use, as a weighted sum rather than a job count.
433
+ *
434
+ * With every weight at 1.0 this is exactly the old count, so an existing
435
+ * `max_concurrent_runs` keeps its previous meaning.
436
+ */
437
+ export function activeCapacity(statuses, config) {
438
+ let active = 0;
439
+ for (const { status } of statuses) {
440
+ if (status.slotQueued)
441
+ continue;
442
+ if (status.status !== "running" && status.status !== "queued")
443
+ continue;
444
+ const beat = Date.parse(status.updatedAt);
445
+ if (Number.isFinite(beat) && Date.now() - beat > ORPHAN_THRESHOLD_MS)
446
+ continue;
447
+ active += resourceWeightFor(status, config);
448
+ }
449
+ return active;
450
+ }
451
+ // ---------------------------------------------------------------------------
452
+ // Supervisor pool
453
+ // ---------------------------------------------------------------------------
454
+ /**
455
+ * How many supervisor PROCESSES may exist, regardless of how many jobs run.
456
+ *
457
+ * Previously every job got its own detached Node process. Measured on Windows
458
+ * with Node 24: a bare node process is 52 MB RSS and one that has bootstrapped
459
+ * a runtime is 65 MB, against ~54 MB for the agent CLI it exists to supervise.
460
+ * So more than half the memory of a concurrent run was wrapper, and it scaled
461
+ * linearly — 13 concurrent jobs meant 845 MB of supervision before any agent
462
+ * had read a file. That is the concurrency ceiling.
463
+ *
464
+ * A supervisor is almost entirely idle: it waits on a child process and writes
465
+ * the result. One can watch several at once for the cost of async I/O, so
466
+ * wrapper memory becomes O(1) in the number of jobs instead of O(N), capped
467
+ * here at ~260 MB.
468
+ *
469
+ * Four rather than one purely to bound blast radius: a supervisor crash strands
470
+ * only the jobs it held. Those are recoverable anyway — the job directory is
471
+ * the source of truth and the heartbeat check already marks stranded jobs
472
+ * orphaned — but losing a quarter of in-flight work beats losing all of it.
473
+ */
474
+ export const SUPERVISOR_POOL_SIZE = 4;
475
+ /** Poll interval while a supervisor waits for claimable work. */
476
+ const SUPERVISOR_POLL_MS = 250;
477
+ /** How long a supervisor stays alive with nothing to do before exiting. */
478
+ const SUPERVISOR_IDLE_EXIT_MS = 5_000;
479
+ /** Jobs one supervisor may run at once, so the pool can reach the global limit. */
480
+ function jobsPerSupervisor(limit) {
481
+ return Math.max(1, Math.ceil(limit / SUPERVISOR_POOL_SIZE));
482
+ }
483
+ /**
484
+ * Take exclusive ownership of a job directory.
485
+ *
486
+ * `wx` fails if the file exists, atomically, on both Windows and POSIX — which
487
+ * is what stops two supervisors racing onto the same job. A claim left behind
488
+ * by a crashed supervisor is reclaimed once that job's heartbeat has gone
489
+ * stale, by the same ORPHAN_THRESHOLD_MS rule used everywhere else.
490
+ *
491
+ * Exported for tests: the one-winner property under concurrent reclaim is the
492
+ * invariant, and it is only checkable by calling this directly.
493
+ */
494
+ export async function claimJobDir(jobDir, status) {
495
+ const claimPath = path.join(jobDir, "claim.json");
496
+ try {
497
+ await writeFile(claimPath, JSON.stringify({ pid: process.pid, at: timestamp() }), {
498
+ encoding: "utf8",
499
+ mode: 0o600,
500
+ flag: "wx",
501
+ });
502
+ return true;
503
+ }
504
+ catch {
505
+ const beat = Date.parse(status.updatedAt);
506
+ if (!Number.isFinite(beat) || Date.now() - beat <= ORPHAN_THRESHOLD_MS)
507
+ return false;
508
+ // Reclaiming a crashed supervisor's claim must pick exactly ONE winner.
509
+ // This path used to rewrite claim.json WITHOUT `wx`, so two supervisors
510
+ // deciding "stale" in the same window both succeeded — the job ran twice,
511
+ // a duplicate CLI execution billed twice. Renaming the stale claim aside
512
+ // is atomic: the loser gets ENOENT and leaves the job alone, and the
513
+ // winner still has to win the `wx` create below like any first claimant.
514
+ const tomb = path.join(path.dirname(claimPath), `claim.stale-${process.pid}-${Date.now().toString(36)}`);
515
+ try {
516
+ await rename(claimPath, tomb);
517
+ }
518
+ catch {
519
+ return false; // Another supervisor reclaimed it first.
520
+ }
521
+ await rm(tomb, { force: true }).catch(() => undefined);
522
+ try {
523
+ await writeFile(claimPath, JSON.stringify({ pid: process.pid, at: timestamp() }), {
524
+ encoding: "utf8",
525
+ mode: 0o600,
526
+ flag: "wx",
527
+ });
528
+ return true;
529
+ }
530
+ catch {
531
+ return false;
532
+ }
533
+ }
534
+ }
535
+ /**
536
+ * Oldest released-but-unstarted job this supervisor can take, or undefined.
537
+ *
538
+ * "Released" means drainSlotQueue already granted it a slot and cleared
539
+ * slotQueued; it is waiting for a supervisor rather than for capacity. A job
540
+ * that is still slotQueued is deliberately NOT claimable here — that would let
541
+ * a supervisor jump the FIFO order the drainer exists to enforce.
542
+ */
543
+ async function claimNextJob() {
544
+ const statuses = await readJobStatuses();
545
+ for (const { jobDir, status } of statuses) {
546
+ if (status.slotQueued)
547
+ continue;
548
+ if (status.status !== "queued")
549
+ continue;
550
+ // Cancelled before a supervisor ever picked it up: claiming it would
551
+ // start work someone has already asked not to happen.
552
+ if (cancelRequested(jobDir))
553
+ continue;
554
+ if (!(await claimJobDir(jobDir, status)))
555
+ continue;
556
+ return jobDir;
557
+ }
558
+ return undefined;
559
+ }
560
+ /**
561
+ * Supervisor main loop: claim work, run several jobs at once, exit when idle.
562
+ *
563
+ * Exiting on idle keeps the no-jobs steady state at zero processes, same as
564
+ * before — the pool is a way to share supervision cost while work exists, not
565
+ * a daemon.
566
+ */
567
+ export async function runSupervisor(deps, supervisorId) {
568
+ const inflight = new Set();
569
+ let idleSince = Date.now();
570
+ // Heartbeat so drainSlotQueue can tell how many supervisors already exist
571
+ // and avoid piling on. Same staleness rule as jobs, so a killed supervisor
572
+ // stops being counted without anything having to clean up after it.
573
+ const beatDir = path.join(jobsRoot(), ".supervisors");
574
+ // Adopt the file the spawning process already created for this slot, so the
575
+ // slot is continuously accounted for rather than briefly disappearing
576
+ // between the parent's registration and the child's first beat.
577
+ const beatFile = path.join(beatDir, `${supervisorId ?? process.pid}.txt`);
578
+ await mkdir(beatDir, { recursive: true, mode: 0o700 });
579
+ const beat = async () => {
580
+ try {
581
+ await writeFile(beatFile, timestamp(), { encoding: "utf8", mode: 0o600 });
582
+ }
583
+ catch {
584
+ // A missing heartbeat only risks an extra supervisor, which exits idle.
585
+ }
586
+ };
587
+ await beat();
588
+ const beatTimer = setInterval(() => void beat(), SUPERVISOR_POLL_MS * 4);
589
+ const cleanup = async () => {
590
+ clearInterval(beatTimer);
591
+ try {
592
+ await rm(beatFile, { force: true });
593
+ }
594
+ catch {
595
+ // Stale file ages out of the liveness count on its own.
596
+ }
597
+ };
598
+ try {
599
+ for (;;) {
600
+ const limit = maxConcurrentRuns(deps.holder.state.config);
601
+ if (limit === 0)
602
+ return;
603
+ // Exit at once if the jobs root has gone. A per-job runner died with its
604
+ // job, so a deleted jobs directory could never strand one; a pooled
605
+ // supervisor outlives individual jobs and would otherwise sit polling a
606
+ // path that no longer exists — spinning in the field, and in tests
607
+ // interfering with whatever creates the next jobs root.
608
+ if (!existsSync(jobsRoot()))
609
+ return;
610
+ if (inflight.size < jobsPerSupervisor(limit)) {
611
+ // Promote waiting jobs into released ones first. The old per-job
612
+ // runner called drainSlotQueue as it exited, which is what kept the
613
+ // queue moving; a pooled supervisor outlives individual jobs, so it
614
+ // has to do the same thing on every pass or a slot freed by a job it
615
+ // just finished never reaches the next job in line.
616
+ try {
617
+ await drainSlotQueue(deps.holder.state.config, deps.holder.state.configPath);
618
+ }
619
+ catch {
620
+ // Next pass retries; a drain failure must not kill the supervisor.
621
+ }
622
+ const jobDir = await claimNextJob();
623
+ if (jobDir !== undefined) {
624
+ idleSince = Date.now();
625
+ const run = executeJobDir(deps, jobDir)
626
+ .catch(() => undefined)
627
+ .finally(() => inflight.delete(run));
628
+ inflight.add(run);
629
+ continue; // Try to fill the remaining slots before waiting.
630
+ }
631
+ }
632
+ if (inflight.size === 0) {
633
+ if (Date.now() - idleSince > SUPERVISOR_IDLE_EXIT_MS)
634
+ return;
635
+ await new Promise((r) => setTimeout(r, SUPERVISOR_POLL_MS));
636
+ continue;
637
+ }
638
+ idleSince = Date.now();
639
+ await Promise.race([...inflight, new Promise((r) => setTimeout(r, SUPERVISOR_POLL_MS))]);
640
+ }
641
+ }
642
+ finally {
643
+ await cleanup();
644
+ }
645
+ }
646
+ /**
647
+ * Start slot-queued jobs, oldest first, until the machine is at its limit.
648
+ *
649
+ * Deliberately has no daemon behind it: this runs on every new dispatch and
650
+ * again as each runner exits, which between them covers every moment a slot
651
+ * can free. The cost of that choice is that if every runner dies while jobs
652
+ * are queued, the queue resumes on the next dispatch rather than immediately.
653
+ * Bounded waiting was the explicit alternative and was not chosen — a queued
654
+ * job keeps its jobId and its artifacts either way, so nothing is lost.
655
+ */
656
+ export async function drainSlotQueue(config, configPath) {
657
+ const limit = maxConcurrentRuns(config);
658
+ if (limit === 0)
659
+ return;
660
+ const runnerPath = resolveRunnerPath();
661
+ if (runnerPath === undefined)
662
+ return;
663
+ // ONE drainer at a time, across processes. The body below is a
664
+ // read-count-release: two drainers (every dispatch AND every runner exit
665
+ // calls this) whose reads interleaved with each other's releases could
666
+ // each release a job at active = limit-1 and exceed the cap — the cap that
667
+ // exists because of a measured OOM. The FIFO comment below also assumes a
668
+ // single drainer decides the order; this is what enforces that assumption.
669
+ let releaseDrainLock;
670
+ try {
671
+ releaseDrainLock = await acquireWorkspaceLock(path.join(jobsRoot(), ".slot-drain"), DRAIN_LOCK_TIMEOUT_MS);
672
+ }
673
+ catch {
674
+ // Another process is mid-drain and sees the same queue; this call's
675
+ // trigger is covered by that drain or by the next one (every dispatch and
676
+ // every runner exit re-runs this), so skipping is safe — waiting is not
677
+ // worth blocking a dispatch for.
678
+ return;
679
+ }
680
+ try {
681
+ await drainSlotQueueLocked(limit, runnerPath, configPath, config);
682
+ }
683
+ finally {
684
+ releaseDrainLock();
685
+ }
686
+ }
687
+ /** How long a drain waits for a concurrent drainer before ceding to it. */
688
+ const DRAIN_LOCK_TIMEOUT_MS = 5_000;
689
+ async function drainSlotQueueLocked(limit, runnerPath, configPath, config) {
690
+ const statuses = await readJobStatuses();
691
+ let active = activeCapacity(statuses, config);
692
+ // Supervisors are sized by how many JOBS there are, not by how much budget
693
+ // they consume. Once `active` became a weighted sum these had to part
694
+ // company: ten endpoint calls are 1.0 of capacity but still ten jobs, and
695
+ // sizing the pool off the weight would hand all ten to one supervisor that
696
+ // runs them a few at a time.
697
+ let activeJobs = countActiveJobs(statuses);
698
+ const waiting = statuses.filter((s) => s.status.slotQueued);
699
+ // Release stays HERE, synchronously and oldest-first, even though a
700
+ // supervisor is what will actually run the job. Two reasons: the caller's
701
+ // returned status must still distinguish "got a slot" from "waiting", which
702
+ // it cannot if clearing the flag is deferred to whichever supervisor wakes
703
+ // first; and FIFO across concurrent dispatches is only guaranteed while one
704
+ // drainer decides the order. Supervisors then pick up released work.
705
+ let released = 0;
706
+ for (const { jobDir, status } of waiting) {
707
+ const weight = resourceWeightFor(status, config);
708
+ // The `active > 0` guard prevents a deadlock the plain count could not
709
+ // produce: a single job heavier than the whole budget (weight 1.0 against
710
+ // a capacity of 0.5) would otherwise wait forever for room that can never
711
+ // exist. When nothing is running, the next job always goes — the same
712
+ // reasoning as the earlier fix for a job whose own queued status counted
713
+ // against its own admission.
714
+ if (active > 0 && active + weight > limit)
715
+ break;
716
+ const { slotQueued: _dropped, ...cleared } = status;
717
+ await updateStatus(jobDir, {
718
+ ...cleared,
719
+ updatedAt: timestamp(),
720
+ instructions: pollInstructions(status.jobId),
721
+ });
722
+ active += weight;
723
+ activeJobs += 1;
724
+ released += 1;
725
+ }
726
+ if (released === 0)
727
+ return;
728
+ // Size the pool against ALL outstanding work, not just the jobs released on
729
+ // this call. Dispatches arrive one at a time, so `released` is usually 1;
730
+ // sizing on that gave a single supervisor for twelve jobs, which then ran
731
+ // them three at a time because each supervisor takes only
732
+ // jobsPerSupervisor(limit). The cap must come from the pool size, never from
733
+ // how the work happened to arrive.
734
+ const outstanding = activeJobs;
735
+ const wanted = Math.min(SUPERVISOR_POOL_SIZE, Math.ceil(outstanding / jobsPerSupervisor(limit)));
736
+ const running = await countLiveSupervisors();
737
+ for (let i = running; i < wanted; i += 1) {
738
+ spawnDetachedSupervisor(runnerPath, configPath);
739
+ }
740
+ }
741
+ /**
742
+ * Supervisors currently alive, counted from their heartbeat files.
743
+ *
744
+ * Approximate on purpose: over-counting briefly means the pool runs one short
745
+ * until the next drain, and under-counting means one extra supervisor that
746
+ * finds no work and exits within SUPERVISOR_IDLE_EXIT_MS. Neither warrants a
747
+ * lock, and both self-correct.
748
+ */
749
+ async function countLiveSupervisors() {
750
+ const dir = path.join(jobsRoot(), ".supervisors");
751
+ let entries;
752
+ try {
753
+ entries = await readdir(dir);
754
+ }
755
+ catch {
756
+ return 0;
757
+ }
758
+ let live = 0;
759
+ for (const entry of entries) {
760
+ try {
761
+ const beat = await readFile(path.join(dir, entry), "utf8");
762
+ if (Date.now() - Date.parse(beat) <= ORPHAN_THRESHOLD_MS)
763
+ live += 1;
764
+ }
765
+ catch {
766
+ // Vanished mid-read: it is not live.
767
+ }
768
+ }
769
+ return live;
770
+ }
771
+ /**
772
+ * Start one detached supervisor; it finds its own work.
773
+ *
774
+ * Output goes to a log beside the heartbeats, for the same reason the per-job
775
+ * runner logged to its job dir: a supervisor that dies during bootstrap (bad
776
+ * config, missing module) is otherwise completely silent, and the only symptom
777
+ * is jobs that never start.
778
+ */
779
+ function spawnDetachedSupervisor(runnerPath, configPath) {
780
+ const dir = path.join(jobsRoot(), ".supervisors");
781
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
782
+ const id = `${Date.now()}-${randomUUID().slice(0, 8)}`;
783
+ // Register the slot HERE, before spawning, and hand the id to the child.
784
+ //
785
+ // Letting the supervisor write its own first heartbeat looks tidier and does
786
+ // not work: booting a Node process takes a few hundred ms, so a burst of
787
+ // dispatches all counted zero live supervisors and each spawned another.
788
+ // Measured at 12 concurrent jobs: 12 supervisors, 748 MB — the pool capping
789
+ // nothing at all. The parent claiming the slot synchronously is what makes
790
+ // the cap real.
791
+ writeFileSync(path.join(dir, `${id}.txt`), timestamp(), { encoding: "utf8", mode: 0o600 });
792
+ const logFd = openSync(path.join(dir, `spawn-${id}.log`), "a");
793
+ try {
794
+ const child = spawn(process.execPath, [runnerPath, "--supervisor", id], {
795
+ detached: true,
796
+ stdio: ["ignore", logFd, logFd],
797
+ windowsHide: true,
798
+ env: {
799
+ ...process.env,
800
+ ...(configPath !== undefined ? { HARNESS_DISPATCH_CONFIG: configPath } : {}),
801
+ },
802
+ });
803
+ child.unref();
804
+ }
805
+ finally {
806
+ closeSync(logFd);
807
+ }
808
+ }
410
809
  function spawnDetachedRunner(runnerPath, jobDir, configPath) {
411
810
  // The runner's own stdout/stderr go to a log inside the job dir so a
412
811
  // bootstrap crash (bad config, missing module) leaves evidence.
@@ -431,14 +830,26 @@ export async function startAsyncJob(deps, input) {
431
830
  return (await startAsyncJobTracked(deps, input)).status;
432
831
  }
433
832
  export async function startAsyncJobTracked(deps, input) {
833
+ // Before anything is created on disk. Every dispatch path — MCP, HTTP,
834
+ // fanout — funnels through here, so this is the one place that catches a bad
835
+ // workingDir while the error can still name the real cause, and the only
836
+ // point at which failing leaves no half-built job directory behind.
837
+ const workingDirError = validateWorkingDir(input.workingDir);
838
+ if (workingDirError !== undefined)
839
+ throw new Error(workingDirError);
434
840
  await pruneStaleJobs();
435
- const jobId = `job-${Date.now()}-${randomUUID().slice(0, 8)}`;
841
+ const jobId = newJobId();
436
842
  const root = jobsRoot();
437
843
  const jobDir = path.join(root, jobId);
438
844
  await mkdir(path.join(jobDir, "context"), { recursive: true, mode: 0o700 });
439
845
  await mkdir(path.join(jobDir, "output"), { recursive: true, mode: 0o700 });
440
846
  const promptPath = path.join(jobDir, "prompt.md");
441
- await writeFile(promptPath, input.prompt, { encoding: "utf8", mode: 0o600 });
847
+ // Prepend prior-job context before the prompt is frozen to disk, so the
848
+ // runner, the manifest and any later inspection all see exactly what the
849
+ // delegate was given.
850
+ const preamble = await buildContextPreamble(input.contextJobs ?? []);
851
+ const effectivePrompt = preamble + input.prompt;
852
+ await writeFile(promptPath, effectivePrompt, { encoding: "utf8", mode: 0o600 });
442
853
  const fileSnapshots = await snapshotFiles(jobDir, input.files ?? []);
443
854
  const createdAt = timestamp();
444
855
  const resolvedWorkingDir = resolveWorkingDir(input.workingDir);
@@ -452,6 +863,7 @@ export async function startAsyncJobTracked(deps, input) {
452
863
  ...(input.hints !== undefined ? { hints: input.hints } : {}),
453
864
  ...(input.workspacePolicy !== undefined ? { workspacePolicy: input.workspacePolicy } : {}),
454
865
  ...(input.service !== undefined ? { service: input.service } : {}),
866
+ ...(input.retryOf !== undefined ? { retryOf: input.retryOf } : {}),
455
867
  ...(warning !== undefined ? { warning } : {}),
456
868
  };
457
869
  await writeJson(path.join(jobDir, "manifest.json"), manifest);
@@ -478,20 +890,67 @@ export async function startAsyncJobTracked(deps, input) {
478
890
  console.error("harness-dispatch: dist/job-runner.js not found (unbuilt checkout?) — " +
479
891
  "running the job in-process; it will not survive a server restart.");
480
892
  }
481
- const completion = runJob(deps, jobDir, manifest, input);
893
+ // The DETACHED runner re-reads prompt.md, which carries the context
894
+ // preamble — so the in-process run must dispatch the same frozen prompt,
895
+ // not input.prompt. Passing the raw prompt here silently dropped
896
+ // contextJobs for every in-process run (unit tests with injected fakes,
897
+ // and the unbuilt-checkout fallback).
898
+ const completion = runJob(deps, jobDir, manifest, { ...input, prompt: effectivePrompt });
482
899
  return { status, completion };
483
900
  }
484
- spawnDetachedRunner(runnerPath, jobDir, deps.holder.state.configPath);
485
- return { status, completion: watchUntilTerminal(jobDir) };
901
+ // Concurrency gate. Every dispatch spawns its own detached runner, so an
902
+ // in-process semaphore would bound nothing — the count has to come off
903
+ // disk. The caller still gets its jobId back immediately either way, so the
904
+ // API contract is unchanged and only the start time can move.
905
+ const limit = maxConcurrentRuns(deps.holder.state.config);
906
+ if (limit === 0) {
907
+ spawnDetachedRunner(runnerPath, jobDir, deps.holder.state.configPath);
908
+ return { status, completion: watchUntilTerminal(jobDir) };
909
+ }
910
+ // Enqueue first, then let drainSlotQueue decide — rather than testing the
911
+ // limit here and spawning inline. Two reasons, both learned the hard way:
912
+ // this job's own `queued` status is already on disk, so an inline count
913
+ // included itself and deadlocked at limit 1; and a fresh dispatch arriving
914
+ // while others wait must not jump the queue, which only one FIFO drainer
915
+ // can guarantee. Whether this job starts now is then just "did the drain
916
+ // reach it".
917
+ await updateStatus(jobDir, { ...status, slotQueued: true });
918
+ await drainSlotQueue(deps.holder.state.config, deps.holder.state.configPath);
919
+ const settled = await readJson(path.join(jobDir, "status.json"));
920
+ return { status: settled, completion: watchUntilTerminal(jobDir) };
486
921
  }
487
922
  const MAX_PARTIAL_OUTPUT_CHARS = 4000;
488
923
  export async function getAsyncJob(jobId) {
924
+ assertValidJobId(jobId);
489
925
  const jobDir = path.join(jobsRoot(), jobId);
490
- const manifest = await readJson(path.join(jobDir, "manifest.json"));
491
- const status = withOrphanCheck(await readJson(path.join(jobDir, "status.json")));
492
- const resultPath = path.join(jobDir, "output", "result.json");
493
- if (existsSync(resultPath)) {
494
- return { manifest, status, result: await readJson(resultPath) };
926
+ // A well-formed id for a job that is gone is the ORDINARY case, not an
927
+ // internal error: retention prunes finished jobs, so any caller holding an
928
+ // id long enough will hit this. It used to surface as a raw Node ENOENT
929
+ // quoting an absolute path inside the jobs directory, which tells the caller
930
+ // nothing actionable and leaks the layout.
931
+ const noSuchJob = () => new Error(`No such job: ${jobId}. It may have been pruned by the retention window, ` +
932
+ `or it was never started on this machine.`);
933
+ if (!existsSync(path.join(jobDir, "manifest.json")))
934
+ throw noSuchJob();
935
+ let manifest;
936
+ let status;
937
+ let result;
938
+ try {
939
+ manifest = await readJson(path.join(jobDir, "manifest.json"));
940
+ status = withOrphanCheck(await readJson(path.join(jobDir, "status.json")));
941
+ const resultPath = path.join(jobDir, "output", "result.json");
942
+ result = existsSync(resultPath) ? await readJson(resultPath) : undefined;
943
+ }
944
+ catch (err) {
945
+ // existsSync-then-read is a TOCTOU window: retention pruning can delete
946
+ // the directory between the two calls, resurfacing the exact raw-ENOENT-
947
+ // with-an-absolute-path error this function's message exists to replace.
948
+ if (err?.code === "ENOENT")
949
+ throw noSuchJob();
950
+ throw err;
951
+ }
952
+ if (result !== undefined) {
953
+ return { manifest, status, result };
495
954
  }
496
955
  if (status.status === "orphaned") {
497
956
  // Terminal: no poll guidance — polling will never resolve this job.
@@ -539,4 +998,161 @@ export async function listAsyncJobs() {
539
998
  }
540
999
  return statuses.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
541
1000
  }
1001
+ /**
1002
+ * Ask a job to stop.
1003
+ *
1004
+ * Cancellation cannot be a signal here: jobs run inside POOLED supervisors,
1005
+ * and the only pid recorded against a job belongs to a process that is also
1006
+ * running other jobs, so signalling it would cancel work nobody asked to
1007
+ * cancel. Instead this writes a marker the run itself honours — it drops out
1008
+ * of its event stream, which triggers the dispatcher's teardown (killTree on
1009
+ * the agent CLI and its children) and releases the workspace lock through the
1010
+ * same path a normal finish uses.
1011
+ *
1012
+ * Two consequences worth stating plainly, because a caller who assumes
1013
+ * otherwise will be surprised:
1014
+ *
1015
+ * 1. It is not instantaneous. A running job stops within about a second;
1016
+ * `cancelling` means requested, not done. Poll job_status to see it land.
1017
+ * 2. Work already done is NOT undone. A cancelled agent may have already
1018
+ * edited files in the workspace, and those edits stay. Cancelling stops
1019
+ * further work; it is not a rollback.
1020
+ *
1021
+ * A cancelled run is deliberately not recorded as a failure: the route's
1022
+ * circuit breaker and failure count never see it, because the caller changing
1023
+ * their mind says nothing about whether the route works.
1024
+ */
1025
+ export async function cancelJob(jobId, reason) {
1026
+ const job = await getAsyncJob(jobId); // throws the friendly "No such job" for a stranger
1027
+ const current = job.status.status;
1028
+ if (current === "completed" || current === "failed" || current === "orphaned" || current === "cancelled") {
1029
+ return {
1030
+ jobId,
1031
+ outcome: "already_finished",
1032
+ status: current,
1033
+ message: `Job ${jobId} had already finished (${current}); nothing to cancel.`,
1034
+ };
1035
+ }
1036
+ const jobDir = path.join(jobsRoot(), jobId);
1037
+ await requestCancel(jobDir, reason);
1038
+ // A job still waiting for a slot has no runner to notice the marker, so
1039
+ // stop it here. claimNextJob also refuses to claim a marked job, which
1040
+ // closes the window where a supervisor picks it up between these two steps.
1041
+ if (current === "queued") {
1042
+ await updateStatus(jobDir, {
1043
+ ...job.status,
1044
+ status: "cancelled",
1045
+ updatedAt: timestamp(),
1046
+ success: false,
1047
+ error: reason !== undefined ? `Cancelled: ${reason}` : "Cancelled before it started.",
1048
+ });
1049
+ // Deliberately NOT draining the slot queue here. Freeing this job's slot
1050
+ // makes room for a waiting one, but drainSlotQueue can SPAWN supervisor
1051
+ // processes, and a cancel — the operation whose whole point is to stop
1052
+ // work — must not start any. Every dispatch and every runner exit already
1053
+ // drains, which is the same "resumes on the next event" contract the
1054
+ // queue documents elsewhere.
1055
+ return {
1056
+ jobId,
1057
+ outcome: "cancelled",
1058
+ status: "cancelled",
1059
+ message: `Job ${jobId} was waiting for a slot and has been cancelled; it never started.`,
1060
+ };
1061
+ }
1062
+ return {
1063
+ jobId,
1064
+ outcome: "cancelling",
1065
+ status: current,
1066
+ message: `Cancellation requested for ${jobId}. The run stops within a second or so — poll ` +
1067
+ `job_status to confirm. Any files the agent already changed are NOT reverted.`,
1068
+ };
1069
+ }
1070
+ /**
1071
+ * Inspect or resolve the isolated workspace a finished job left behind.
1072
+ *
1073
+ * Looks the job up the same way job_status does, then hands off to
1074
+ * workspace-resolve.ts. Kept here so the caller only ever needs a jobId —
1075
+ * where the workspace lives, and which policy produced it, are details
1076
+ * recorded in the job's own result.
1077
+ */
1078
+ export async function resolveJobWorkspace(jobId, action, opts = {}) {
1079
+ const job = await getAsyncJob(jobId);
1080
+ const run = job.result?.result?.workspace;
1081
+ if (!isResolvable(run)) {
1082
+ // Read through a separate binding: the type guard narrows `run` to never
1083
+ // on this branch, which would make the diagnostic unable to say WHICH
1084
+ // policy the caller actually got.
1085
+ const raw = job.result?.result?.workspace;
1086
+ const policy = raw?.policy ?? "shared";
1087
+ throw new Error(`Job ${jobId} has no isolated workspace to ${action} (workspace policy: ${policy}). ` +
1088
+ `Only 'copy' and 'git_worktree' dispatches produce one — a 'shared' or ` +
1089
+ `'shared_locked' run edited ${raw?.originalWorkingDir ?? "the working directory"} ` +
1090
+ `directly, so there is nothing separate to inspect, apply or throw away.`);
1091
+ }
1092
+ const jobDir = path.join(jobsRoot(), jobId);
1093
+ if (action === "diff")
1094
+ return workspaceDiff(jobId, jobDir, run);
1095
+ if (action === "apply")
1096
+ return applyWorkspace(jobId, jobDir, run, opts);
1097
+ return discardWorkspace(jobId, run);
1098
+ }
1099
+ /**
1100
+ * Run a finished job's task again.
1101
+ *
1102
+ * The last verb missing from the job lifecycle: you could start work, watch
1103
+ * it, stop it, and resolve its workspace — but if it failed, reproducing it
1104
+ * meant reconstructing the prompt, the file list, the working directory and
1105
+ * the hints by hand, from a job record that already holds all four. The
1106
+ * machinery to execute a job bundle existed (executeJobDir) and simply was
1107
+ * not reachable from outside.
1108
+ *
1109
+ * The prompt is taken from prompt.md, which is the FROZEN prompt — including
1110
+ * any context preamble the original dispatch rendered in. A retry therefore
1111
+ * reproduces what the delegate actually saw, not what the caller typed.
1112
+ *
1113
+ * `service` retargets the attempt, which is the common case rather than an
1114
+ * afterthought: the reason a run failed is often the route, not the task
1115
+ * ("codex hit its usage limit — try claude"). Omit it to reuse the original
1116
+ * route, or to let the router pick again if the original had none.
1117
+ */
1118
+ export async function retryJob(jobId, deps, opts = {}) {
1119
+ const prior = await getAsyncJob(jobId); // friendly "No such job" for a stranger
1120
+ const state = prior.status.status;
1121
+ if (state === "running" || state === "queued") {
1122
+ throw new Error(`Job ${jobId} is still ${state}. Let it finish, or cancel it first with ` +
1123
+ `cancel_job — retrying a live run would leave two attempts racing on the ` +
1124
+ `same working directory.`);
1125
+ }
1126
+ const manifest = prior.manifest;
1127
+ const prompt = await readFile(manifest.promptPath, "utf8");
1128
+ if (opts.service !== undefined && !(opts.service in deps.holder.state.config.services)) {
1129
+ throw new Error(`Unknown service: ${opts.service}. Valid route ids: ` +
1130
+ `${Object.keys(deps.holder.state.config.services).join(", ")}.`);
1131
+ }
1132
+ const service = opts.service ?? manifest.service;
1133
+ const { status } = await startAsyncJobTracked(deps, {
1134
+ prompt,
1135
+ files: manifest.files.map((f) => f.originalPath),
1136
+ workingDir: manifest.workingDir,
1137
+ retryOf: jobId,
1138
+ ...(manifest.hints !== undefined ? { hints: manifest.hints } : {}),
1139
+ ...(manifest.workspacePolicy !== undefined
1140
+ ? { workspacePolicy: manifest.workspacePolicy }
1141
+ : {}),
1142
+ ...(service !== undefined ? { service } : {}),
1143
+ });
1144
+ return {
1145
+ jobId: status.jobId,
1146
+ retryOf: jobId,
1147
+ ...(service !== undefined ? { service } : {}),
1148
+ reusedFrom: {
1149
+ prompt: true,
1150
+ files: manifest.files.length,
1151
+ workingDir: manifest.workingDir,
1152
+ },
1153
+ message: `Started ${status.jobId} from ${jobId}'s prompt, files and working directory` +
1154
+ `${opts.service !== undefined ? `, retargeted to ${opts.service}` : ""}. ` +
1155
+ `Check it with job_status; the original job is untouched.`,
1156
+ };
1157
+ }
542
1158
  //# sourceMappingURL=jobs.js.map