@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.4

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 (206) hide show
  1. package/docs/http-api.md +80 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +443 -32
  4. package/src/api/index.ts +108 -127
  5. package/src/cli/__tests__/analyze-task.test.ts +40 -7
  6. package/src/cli/__tests__/index.test.ts +337 -17
  7. package/src/cli/__tests__/types.test.ts +0 -18
  8. package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
  9. package/src/cli/analyze-task.ts +3 -14
  10. package/src/cli/index.ts +73 -61
  11. package/src/cli/types.ts +0 -13
  12. package/src/cli/update-pipeline-json.ts +3 -14
  13. package/src/config/__tests__/paths.test.ts +46 -1
  14. package/src/config/__tests__/pipeline-registry.test.ts +767 -0
  15. package/src/config/__tests__/sse-events.test.ts +470 -0
  16. package/src/config/__tests__/statuses.test.ts +41 -0
  17. package/src/config/paths.ts +11 -2
  18. package/src/config/pipeline-registry.ts +258 -0
  19. package/src/config/sse-events.ts +122 -0
  20. package/src/config/statuses.ts +20 -1
  21. package/src/core/__tests__/agent-step.test.ts +115 -0
  22. package/src/core/__tests__/config.test.ts +545 -105
  23. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  24. package/src/core/__tests__/job-concurrency.test.ts +260 -0
  25. package/src/core/__tests__/job-submission.test.ts +396 -0
  26. package/src/core/__tests__/job-view.test.ts +1341 -0
  27. package/src/core/__tests__/json-file.test.ts +111 -0
  28. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  29. package/src/core/__tests__/logger.test.ts +22 -0
  30. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  31. package/src/core/__tests__/orchestrator.test.ts +615 -2
  32. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  33. package/src/core/__tests__/redact.test.ts +153 -0
  34. package/src/core/__tests__/runner-liveness.test.ts +910 -0
  35. package/src/core/__tests__/seed-naming.test.ts +57 -0
  36. package/src/core/__tests__/single-derivation.test.ts +159 -0
  37. package/src/core/__tests__/task-runner.test.ts +594 -3
  38. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  39. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  40. package/src/core/agent-step.ts +4 -1
  41. package/src/core/agent-types.ts +5 -4
  42. package/src/core/config.ts +134 -222
  43. package/src/core/control.ts +3 -0
  44. package/src/core/job-concurrency.ts +56 -20
  45. package/src/core/job-submission.ts +133 -0
  46. package/src/core/job-view.ts +473 -0
  47. package/src/core/json-file.ts +45 -0
  48. package/src/core/lifecycle-policy.ts +23 -8
  49. package/src/core/logger.ts +0 -29
  50. package/src/core/orchestrator.ts +200 -74
  51. package/src/core/pipeline-runner.ts +85 -53
  52. package/src/core/redact.ts +40 -0
  53. package/src/core/runner-liveness.ts +280 -0
  54. package/src/core/seed-naming.ts +9 -0
  55. package/src/core/status-writer.ts +27 -33
  56. package/src/core/task-runner.ts +356 -319
  57. package/src/core/task-telemetry.ts +107 -0
  58. package/src/harness/__tests__/subprocess.test.ts +112 -1
  59. package/src/harness/subprocess.ts +55 -16
  60. package/src/llm/__tests__/index.test.ts +684 -33
  61. package/src/llm/index.ts +310 -67
  62. package/src/providers/__tests__/alibaba.test.ts +67 -6
  63. package/src/providers/__tests__/anthropic.test.ts +35 -14
  64. package/src/providers/__tests__/base.test.ts +62 -0
  65. package/src/providers/__tests__/claude-code.test.ts +99 -14
  66. package/src/providers/__tests__/deepseek.test.ts +16 -6
  67. package/src/providers/__tests__/gemini.test.ts +47 -25
  68. package/src/providers/__tests__/moonshot.test.ts +27 -0
  69. package/src/providers/__tests__/openai.test.ts +262 -74
  70. package/src/providers/__tests__/opencode.test.ts +77 -0
  71. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  72. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  73. package/src/providers/__tests__/types.test.ts +85 -0
  74. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  75. package/src/providers/__tests__/zhipu.test.ts +27 -14
  76. package/src/providers/alibaba.ts +20 -39
  77. package/src/providers/anthropic.ts +23 -11
  78. package/src/providers/base.ts +19 -0
  79. package/src/providers/claude-code.ts +27 -18
  80. package/src/providers/deepseek.ts +9 -28
  81. package/src/providers/gemini.ts +20 -58
  82. package/src/providers/moonshot.ts +15 -11
  83. package/src/providers/openai.ts +79 -61
  84. package/src/providers/opencode.ts +16 -33
  85. package/src/providers/stream-accumulator.ts +27 -21
  86. package/src/providers/types.ts +29 -4
  87. package/src/providers/zhipu.ts +15 -44
  88. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  89. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  90. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  91. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  92. package/src/task-analysis/__tests__/types.test.ts +63 -130
  93. package/src/task-analysis/analyzer.ts +91 -0
  94. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  95. package/src/task-analysis/index.ts +2 -36
  96. package/src/task-analysis/repository.ts +45 -0
  97. package/src/task-analysis/types.ts +42 -58
  98. package/src/ui/client/__tests__/api.test.ts +143 -1
  99. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  100. package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
  101. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  102. package/src/ui/client/__tests__/types.test.ts +66 -3
  103. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  104. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  105. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  106. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  107. package/src/ui/client/adapters/job-adapter.ts +41 -16
  108. package/src/ui/client/api.ts +38 -15
  109. package/src/ui/client/bootstrap.ts +19 -14
  110. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  111. package/src/ui/client/hooks/useJobList.ts +26 -31
  112. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  113. package/src/ui/client/load-state.ts +20 -0
  114. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  115. package/src/ui/client/reducers/job-events.ts +137 -0
  116. package/src/ui/client/types.ts +16 -20
  117. package/src/ui/components/DAGGrid.tsx +6 -1
  118. package/src/ui/components/JobDetail.tsx +12 -4
  119. package/src/ui/components/JobTable.tsx +41 -13
  120. package/src/ui/components/StageTimeline.tsx +8 -20
  121. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  122. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  123. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  124. package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
  125. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  126. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  127. package/src/ui/components/types.ts +35 -15
  128. package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
  129. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  130. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  131. package/src/ui/dist/index.html +2 -2
  132. package/src/ui/pages/PipelineDetail.tsx +60 -4
  133. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  134. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  135. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  136. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  137. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  138. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  139. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  140. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  141. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  142. package/src/ui/server/__tests__/index.test.ts +63 -0
  143. package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
  144. package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
  145. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  146. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  147. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  148. package/src/ui/server/__tests__/router.test.ts +104 -0
  149. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  150. package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
  151. package/src/ui/server/config-bridge-node.ts +8 -26
  152. package/src/ui/server/config-bridge.ts +3 -4
  153. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  154. package/src/ui/server/embedded-assets.ts +13 -2
  155. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  156. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  157. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  158. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  159. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  160. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  161. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  162. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  163. package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
  164. package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
  165. package/src/ui/server/endpoints/job-endpoints.ts +7 -0
  166. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  167. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  168. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  169. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  170. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  171. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  172. package/src/ui/server/index.ts +19 -14
  173. package/src/ui/server/job-reader.ts +14 -2
  174. package/src/ui/server/router.ts +33 -10
  175. package/src/ui/server/sse-broadcast.ts +14 -3
  176. package/src/ui/server/sse-enhancer.ts +12 -2
  177. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  178. package/src/ui/state/__tests__/snapshot.test.ts +120 -14
  179. package/src/ui/state/__tests__/types.test.ts +104 -5
  180. package/src/ui/state/snapshot.ts +2 -2
  181. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
  182. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
  183. package/src/ui/state/transformers/list-transformer.ts +13 -3
  184. package/src/ui/state/transformers/status-transformer.ts +36 -170
  185. package/src/ui/state/types.ts +15 -48
  186. package/src/utils/__tests__/path-containment.test.ts +160 -0
  187. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  188. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  189. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  190. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  191. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  192. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  193. package/src/task-analysis/__tests__/index.test.ts +0 -143
  194. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  195. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  196. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  197. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  198. package/src/task-analysis/extractors/artifacts.ts +0 -143
  199. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  200. package/src/task-analysis/extractors/stages.ts +0 -45
  201. package/src/task-analysis/parser.ts +0 -20
  202. package/src/task-analysis/utils/ast.ts +0 -45
  203. package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
  204. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  205. package/src/ui/embedded-assets.js +0 -12
  206. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
@@ -1,7 +1,7 @@
1
1
  /** Options for starting the orchestrator. */
2
2
  export interface OrchestratorOptions {
3
- /** Root directory for pipeline data. Normalized to canonical pipeline-data/ root. */
4
- dataDir: string;
3
+ /** Workspace root (not the `pipeline-data` subdir); omitted values fall back to `PO_ROOT` at boot. */
4
+ dataDir?: string;
5
5
  /** Injection point for process spawner. Defaults to Bun.spawn. */
6
6
  spawn?: SpawnFn;
7
7
  /** Injection point for filesystem watcher factory. Defaults to chokidar.watch. */
@@ -76,7 +76,7 @@ type SpawnFn = (cmd: string[], options: {
76
76
  }) => ChildHandle;
77
77
 
78
78
  /** Result of a child process exit, capturing all diagnostic fields. */
79
- interface ChildExitResult {
79
+ export interface ChildExitResult {
80
80
  /** Exit code, or null if terminated by signal. */
81
81
  code: number | null;
82
82
  /** Signal name (e.g. "SIGTERM"), or null if exited normally. */
@@ -86,7 +86,7 @@ interface ChildExitResult {
86
86
  }
87
87
 
88
88
  /** Minimal child process handle for tracking. */
89
- interface ChildHandle {
89
+ export interface ChildHandle {
90
90
  readonly pid: number;
91
91
  /** Resolves with structured exit details when the process terminates. */
92
92
  readonly exited: Promise<ChildExitResult>;
@@ -98,10 +98,22 @@ interface SpawnedRunner {
98
98
  kill?: () => void;
99
99
  }
100
100
 
101
- /** Seed filename regex. Captures jobId from {jobId}-seed.json. */
102
- export const SEED_PATTERN = /^([A-Za-z0-9-_]+)-seed\.json$/;
101
+ function describeCleanupError(err: unknown): unknown {
102
+ if (!(err instanceof AggregateError)) return err;
103
+ return {
104
+ name: err.name,
105
+ message: err.message,
106
+ errors: err.errors.map((cause) => {
107
+ if (cause instanceof Error) {
108
+ return { name: cause.name, message: cause.message, stack: cause.stack };
109
+ }
110
+ return { message: String(cause) };
111
+ }),
112
+ };
113
+ }
103
114
 
104
- import { join, basename, dirname, resolve } from "node:path";
115
+ import { join, dirname, resolve } from "node:path";
116
+ import { readFileSync } from "node:fs";
105
117
  import { mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
106
118
  import { watch } from "chokidar";
107
119
  import { createLogger } from "./logger";
@@ -115,11 +127,15 @@ import { materializeNormalizedPipelineDefinition } from "./pipeline-definition";
115
127
  import { discoverHarnesses } from "../harness/index.ts";
116
128
  import { applyHarnessDiscovery } from "../harness/discovery.ts";
117
129
  import {
130
+ listActiveJobLeases,
118
131
  listQueuedSeeds,
132
+ pruneStaleJobSlots,
119
133
  releaseJobSlot,
120
134
  tryAcquireJobSlot,
121
135
  updateJobSlotPid,
122
136
  } from "./job-concurrency";
137
+ import { isProcessAlive, reconcileInterruptedJob, reconcileOrphanedCurrentJobs } from "./runner-liveness";
138
+ import { resolveWorkspaceRoot } from "../config/paths";
123
139
 
124
140
  /**
125
141
  * Normalize any path that may already include `pipeline-data` (or subdirs
@@ -184,7 +200,7 @@ export async function spawnRunner(
184
200
  running: Map<string, ChildHandle>,
185
201
  logger: ReturnType<typeof createLogger>,
186
202
  spawnFn: SpawnFn,
187
- onExit?: (jobId: string) => void | Promise<void>,
203
+ onExit?: (jobId: string, result: ChildExitResult) => void | Promise<void>,
188
204
  ): Promise<void> {
189
205
  if (!seed.pipeline) {
190
206
  throw new Error(`seed.pipeline is required for job ${jobId}`);
@@ -236,9 +252,9 @@ export async function spawnRunner(
236
252
  });
237
253
  if (!onExit) return;
238
254
  try {
239
- await onExit(jobId);
255
+ await onExit(jobId, result);
240
256
  } catch (err) {
241
- logger.error(`child exit cleanup failed for job ${jobId}`, err);
257
+ logger.error(`child exit cleanup failed for job ${jobId}`, describeCleanupError(err));
242
258
  }
243
259
  })
244
260
  .catch((err) => {
@@ -251,11 +267,46 @@ export interface HandleChildExitOptions {
251
267
  jobId: string;
252
268
  triggerDrain: () => void;
253
269
  lockTimeoutMs?: number;
270
+ code?: number | null;
271
+ signal?: string | null;
272
+ releaseSlot?: (dataDir: string, jobId: string, lockTimeoutMs?: number) => Promise<void>;
273
+ reconcileJob?: typeof reconcileInterruptedJob;
254
274
  }
255
275
 
256
276
  export async function handleChildExit(opts: HandleChildExitOptions): Promise<void> {
277
+ const lockTimeoutMs = opts.lockTimeoutMs ?? getOrchestratorConfig().lockFileTimeout;
278
+ const releaseSlot = opts.releaseSlot ?? releaseJobSlot;
279
+ const reconcileJob = opts.reconcileJob ?? reconcileInterruptedJob;
257
280
  try {
258
- await releaseJobSlot(opts.dataDir, opts.jobId, opts.lockTimeoutMs);
281
+ let releaseError: unknown;
282
+ let reconcileError: unknown;
283
+
284
+ try {
285
+ await releaseSlot(opts.dataDir, opts.jobId, lockTimeoutMs);
286
+ } catch (err) {
287
+ releaseError = err;
288
+ }
289
+
290
+ const jobDir = join(opts.dataDir, "current", opts.jobId);
291
+ try {
292
+ await reconcileJob({
293
+ dataDir: opts.dataDir,
294
+ jobId: opts.jobId,
295
+ jobDir,
296
+ source: "child-exit",
297
+ code: opts.code,
298
+ signal: opts.signal,
299
+ lockTimeoutMs,
300
+ });
301
+ } catch (err) {
302
+ reconcileError = err;
303
+ }
304
+
305
+ if (releaseError && reconcileError) {
306
+ throw new AggregateError([releaseError, reconcileError], "child exit cleanup failed");
307
+ }
308
+ if (releaseError) throw releaseError;
309
+ if (reconcileError) throw reconcileError;
259
310
  } finally {
260
311
  opts.triggerDrain();
261
312
  }
@@ -327,63 +378,6 @@ async function scaffoldJobDir(
327
378
  await fileIO.writeLog(logName, JSON.stringify(startLog, null, 2), { mode: "replace" });
328
379
  }
329
380
 
330
- export async function handleSeedAdd(
331
- filePath: string,
332
- dirs: ResolvedDirs,
333
- running: Map<string, ChildHandle>,
334
- logger: ReturnType<typeof createLogger>,
335
- opts: OrchestratorOptions
336
- ): Promise<void> {
337
- const filename = basename(filePath);
338
- const match = filename.match(SEED_PATTERN);
339
-
340
- if (!match) {
341
- logger.warn(`ignoring non-seed file: ${filename}`);
342
- return;
343
- }
344
-
345
- const jobId = match[1]!;
346
-
347
- let raw: string;
348
- try {
349
- raw = await Bun.file(filePath).text();
350
- } catch (err) {
351
- logger.warn(`failed to read ${filename}`, err);
352
- return;
353
- }
354
-
355
- let seed: SeedData;
356
- try {
357
- seed = JSON.parse(raw) as SeedData;
358
- } catch (err) {
359
- const message = err instanceof Error ? err.message : String(err);
360
- logger.warn(`invalid JSON in ${filename}: ${message}`);
361
- return;
362
- }
363
-
364
- if (running.has(jobId)) return;
365
-
366
- const jobDir = join(dirs.current, jobId);
367
- const seedDest = join(jobDir, "seed.json");
368
- if (await Bun.file(seedDest).exists()) return;
369
-
370
- await mkdir(jobDir, { recursive: true });
371
- try {
372
- await rename(filePath, seedDest);
373
- } catch (err) {
374
- logger.error(`failed to move seed file for job ${jobId}`, err);
375
- throw err;
376
- }
377
- await scaffoldJobDir(jobId, seed, dirs, logger);
378
-
379
- const spawnFn = opts.spawn ?? createDefaultSpawn();
380
- try {
381
- await spawnRunner(jobId, seed, dirs, running, logger, spawnFn);
382
- } catch (err) {
383
- logger.error(`failed to spawn runner for job ${jobId}`, err);
384
- }
385
- }
386
-
387
381
  export async function stopChildren(
388
382
  running: Map<string, ChildHandle>,
389
383
  logger: ReturnType<typeof createLogger>
@@ -417,6 +411,90 @@ export async function stopChildren(
417
411
  running.clear();
418
412
  }
419
413
 
414
+ export interface AdoptOrphanedRunnersOptions {
415
+ dataDir: string;
416
+ currentDir: string;
417
+ running: Map<string, ChildHandle>;
418
+ isAlive?: (pid: number) => boolean;
419
+ }
420
+
421
+ function synthesizeAdoptedHandle(opts: {
422
+ pid: number;
423
+ jobId: string;
424
+ currentDir: string;
425
+ isAlive: (pid: number) => boolean;
426
+ }): ChildHandle {
427
+ const { pid, jobId, currentDir, isAlive } = opts;
428
+ let exitedPromise: Promise<ChildExitResult> | null = null;
429
+ return {
430
+ pid,
431
+ kill: (signal) => {
432
+ const runnerPidPath = join(currentDir, jobId, "runner.pid");
433
+ let raw: string;
434
+ try {
435
+ raw = readFileSync(runnerPidPath, "utf-8");
436
+ } catch (err) {
437
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
438
+ return;
439
+ }
440
+ const current = Number(raw.trim());
441
+ if (!Number.isInteger(current) || current !== pid) return;
442
+ try {
443
+ if (signal === undefined) process.kill(pid);
444
+ else process.kill(pid, signal);
445
+ } catch {
446
+ // ESRCH: already exited. EPERM: not ours. Either way, no-op.
447
+ }
448
+ },
449
+ get exited(): Promise<ChildExitResult> {
450
+ if (exitedPromise) return exitedPromise;
451
+ // Adopted handles are stop handles, not a background supervisor. Reading
452
+ // `exited` starts lazy polling only during shutdown/stop, and the signal
453
+ // completion sentinel is intentionally generic because the original exit
454
+ // code/signal is unavailable for a process we did not spawn.
455
+ exitedPromise = new Promise<ChildExitResult>((resolve) => {
456
+ const interval = setInterval(() => {
457
+ if (!isAlive(pid)) {
458
+ clearInterval(interval);
459
+ resolve({ code: null, signal: null, completionType: "signal" });
460
+ }
461
+ }, 100);
462
+ interval.unref();
463
+ });
464
+ return exitedPromise;
465
+ },
466
+ };
467
+ }
468
+
469
+ export async function adoptOrphanedRunners(opts: AdoptOrphanedRunnersOptions): Promise<string[]> {
470
+ const alive = opts.isAlive ?? isProcessAlive;
471
+ const leases = await listActiveJobLeases(opts.dataDir);
472
+ const adopted: string[] = [];
473
+ for (const lease of leases) {
474
+ const pid = lease.pid;
475
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) continue;
476
+ if (!alive(pid)) continue;
477
+ const runnerPidPath = join(opts.currentDir, lease.jobId, "runner.pid");
478
+ let raw: string;
479
+ try {
480
+ raw = readFileSync(runnerPidPath, "utf-8");
481
+ } catch (err) {
482
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
483
+ continue;
484
+ }
485
+ const filePid = Number(raw.trim());
486
+ if (!Number.isInteger(filePid) || filePid !== pid) continue;
487
+ opts.running.set(lease.jobId, synthesizeAdoptedHandle({
488
+ pid,
489
+ jobId: lease.jobId,
490
+ currentDir: opts.currentDir,
491
+ isAlive: alive,
492
+ }));
493
+ adopted.push(lease.jobId);
494
+ }
495
+ return adopted;
496
+ }
497
+
420
498
  const MAX_PENDING_SEED_FAILURES = 3;
421
499
  const pendingSeedFailures = new Map<string, { count: number; signature: string }>();
422
500
 
@@ -424,6 +502,7 @@ export interface DrainPendingQueueOptions {
424
502
  dataDir: string;
425
503
  maxConcurrentJobs: number;
426
504
  lockTimeoutMs: number;
505
+ staleLeaseTimeoutMs: number;
427
506
  spawnRunner: (jobId: string, seed: SeedData) => Promise<SpawnedRunner>;
428
507
  }
429
508
 
@@ -540,7 +619,7 @@ async function readPendingSeed(seedPath: string): Promise<
540
619
  export async function drainPendingQueue(
541
620
  opts: DrainPendingQueueOptions,
542
621
  ): Promise<DrainPendingQueueResult> {
543
- const { dataDir, maxConcurrentJobs, lockTimeoutMs, spawnRunner } = opts;
622
+ const { dataDir, maxConcurrentJobs, lockTimeoutMs, staleLeaseTimeoutMs, spawnRunner } = opts;
544
623
  const logger = createLogger("orchestrator");
545
624
  const queued = await listQueuedSeeds(dataDir);
546
625
  const promoted: string[] = [];
@@ -569,6 +648,7 @@ export async function drainPendingQueue(
569
648
  maxConcurrentJobs,
570
649
  source: "orchestrator",
571
650
  lockTimeoutMs,
651
+ staleLeaseTimeoutMs,
572
652
  });
573
653
  if (!acquired.ok) {
574
654
  if (acquired.reason === "limit_reached") {
@@ -670,14 +750,17 @@ async function harnessPreflight(logger: ReturnType<typeof createLogger>): Promis
670
750
  }
671
751
 
672
752
  export function startOrchestrator(opts: OrchestratorOptions): Promise<OrchestratorHandle> {
673
- if (!opts.dataDir) throw new Error("dataDir is required");
753
+ const root = resolveWorkspaceRoot(opts.dataDir);
754
+ getConfig();
674
755
 
675
- const dirs = resolveDirs(opts.dataDir);
756
+ const dirs = resolveDirs(root);
676
757
  const factory = opts.watcherFactory ?? ((path, options) => watch(path, options) as unknown as Watcher);
677
758
  const logger = createLogger("orchestrator");
678
759
  const orchestratorConfig = getOrchestratorConfig();
679
760
  const maxConcurrentJobs = orchestratorConfig.maxConcurrentJobs;
680
761
  const lockTimeoutMs = orchestratorConfig.lockFileTimeout;
762
+ const staleLeaseTimeoutMs = orchestratorConfig.staleLeaseTimeoutMs;
763
+ const running = new Map<string, ChildHandle>();
681
764
 
682
765
  return mkdir(dirs.pending, { recursive: true })
683
766
  .then(() => mkdir(dirs.current, { recursive: true }))
@@ -685,8 +768,43 @@ export function startOrchestrator(opts: OrchestratorOptions): Promise<Orchestrat
685
768
  .then(() => mkdir(dirs.rejected, { recursive: true }))
686
769
  .then(() => mkdir(dirs.staging, { recursive: true }))
687
770
  .then(() => harnessPreflight(logger))
771
+ .then(async () => {
772
+ try {
773
+ const pruned = await pruneStaleJobSlots(dirs.dataDir, lockTimeoutMs, staleLeaseTimeoutMs);
774
+ logger.log("boot prune complete", { pruned });
775
+ } catch (err) {
776
+ logger.warn(
777
+ `boot prune failed; continuing: ${err instanceof Error ? err.message : String(err)}`,
778
+ );
779
+ }
780
+ try {
781
+ const summary = await reconcileOrphanedCurrentJobs({
782
+ dataDir: dirs.dataDir,
783
+ currentDir: dirs.current,
784
+ lockTimeoutMs,
785
+ staleLeaseTimeoutMs,
786
+ staleAfterMs: getOrchestratorConfig().staleRunningGraceMs,
787
+ });
788
+ logger.log("boot reconciliation complete", summary);
789
+ } catch (err) {
790
+ logger.warn(
791
+ `boot reconciliation failed; continuing: ${err instanceof Error ? err.message : String(err)}`,
792
+ );
793
+ }
794
+ try {
795
+ const adopted = await adoptOrphanedRunners({
796
+ dataDir: dirs.dataDir,
797
+ currentDir: dirs.current,
798
+ running,
799
+ });
800
+ if (adopted.length > 0) logger.log("adopted orphaned runners", { adopted });
801
+ } catch (err) {
802
+ logger.warn(
803
+ `runner re-adoption failed; continuing: ${err instanceof Error ? err.message : String(err)}`,
804
+ );
805
+ }
806
+ })
688
807
  .then(() => new Promise<OrchestratorHandle>((resolve, reject) => {
689
- const running = new Map<string, ChildHandle>();
690
808
  const spawnFn = opts.spawn ?? createDefaultSpawn();
691
809
 
692
810
  let isDraining = false;
@@ -709,6 +827,7 @@ export function startOrchestrator(opts: OrchestratorOptions): Promise<Orchestrat
709
827
  dataDir: dirs.dataDir,
710
828
  maxConcurrentJobs,
711
829
  lockTimeoutMs,
830
+ staleLeaseTimeoutMs,
712
831
  spawnRunner: spawnRunnerForJob,
713
832
  });
714
833
  } while (pendingDrain);
@@ -721,9 +840,16 @@ export function startOrchestrator(opts: OrchestratorOptions): Promise<Orchestrat
721
840
  })();
722
841
  };
723
842
 
724
- const onChildExit = (jobId: string): Promise<void> => {
843
+ const onChildExit = (jobId: string, result: ChildExitResult): Promise<void> => {
725
844
  if (stopped) return Promise.resolve();
726
- return handleChildExit({ dataDir: dirs.dataDir, jobId, triggerDrain, lockTimeoutMs });
845
+ return handleChildExit({
846
+ dataDir: dirs.dataDir,
847
+ jobId,
848
+ triggerDrain,
849
+ lockTimeoutMs,
850
+ code: result.code,
851
+ signal: result.signal,
852
+ });
727
853
  };
728
854
 
729
855
  const spawnRunnerForJob = async (jobId: string, seed: SeedData): Promise<SpawnedRunner> => {