@ryanfw/prompt-orchestration-pipeline 1.2.7 → 1.2.9

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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/config/__tests__/models.test.ts +31 -1
  3. package/src/config/models.ts +81 -35
  4. package/src/config/paths.ts +13 -8
  5. package/src/core/__tests__/config.test.ts +121 -0
  6. package/src/core/__tests__/job-concurrency.test.ts +554 -0
  7. package/src/core/__tests__/orchestrator.test.ts +353 -0
  8. package/src/core/__tests__/pipeline-runner.test.ts +430 -2
  9. package/src/core/__tests__/task-runner.test.ts +1 -2
  10. package/src/core/config.ts +48 -1
  11. package/src/core/job-concurrency.ts +462 -0
  12. package/src/core/orchestrator.ts +370 -57
  13. package/src/core/pipeline-runner.ts +79 -15
  14. package/src/core/status-writer.ts +4 -0
  15. package/src/core/task-runner.ts +1 -1
  16. package/src/providers/__tests__/base.test.ts +1 -1
  17. package/src/ui/client/__tests__/api.test.ts +101 -1
  18. package/src/ui/client/__tests__/job-adapter.test.ts +12 -0
  19. package/src/ui/client/__tests__/useConcurrencyStatus.test.ts +126 -0
  20. package/src/ui/client/adapters/job-adapter.ts +1 -0
  21. package/src/ui/client/api.ts +77 -7
  22. package/src/ui/client/hooks/useConcurrencyStatus.ts +102 -0
  23. package/src/ui/client/types.ts +34 -1
  24. package/src/ui/components/DAGGrid.tsx +11 -1
  25. package/src/ui/components/JobDetail.tsx +2 -1
  26. package/src/ui/components/__tests__/DAGGrid.test.tsx +92 -0
  27. package/src/ui/components/__tests__/JobDetail.test.tsx +62 -0
  28. package/src/ui/components/types.ts +2 -0
  29. package/src/ui/dist/assets/{index-SKy2shWc.js → index-BnAqY4_n.js} +336 -52
  30. package/src/ui/dist/assets/index-BnAqY4_n.js.map +1 -0
  31. package/src/ui/dist/assets/style-BKG0bHu-.css +2 -0
  32. package/src/ui/dist/index.html +2 -2
  33. package/src/ui/embedded-assets.js +6 -6
  34. package/src/ui/pages/PromptPipelineDashboard.tsx +186 -4
  35. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +272 -1
  36. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +190 -0
  37. package/src/ui/server/__tests__/index.test.ts +92 -3
  38. package/src/ui/server/__tests__/job-control-endpoints.test.ts +660 -3
  39. package/src/ui/server/endpoints/concurrency-endpoint.ts +72 -0
  40. package/src/ui/server/endpoints/job-control-endpoints.ts +248 -37
  41. package/src/ui/server/index.ts +21 -2
  42. package/src/ui/server/router.ts +2 -0
  43. package/src/ui/state/__tests__/watcher.test.ts +31 -0
  44. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +15 -0
  45. package/src/ui/state/transformers/status-transformer.ts +1 -0
  46. package/src/ui/state/types.ts +3 -0
  47. package/src/ui/state/watcher.ts +9 -1
  48. package/src/utils/__tests__/dag.test.ts +35 -0
  49. package/src/utils/dag.ts +1 -0
  50. package/src/ui/dist/assets/index-SKy2shWc.js.map +0 -1
  51. package/src/ui/dist/assets/style-DA1Ma4YS.css +0 -2
@@ -19,6 +19,7 @@ interface ResolvedDirs {
19
19
  pending: string;
20
20
  current: string;
21
21
  complete: string;
22
+ rejected: string;
22
23
  }
23
24
 
24
25
  /** Parsed seed file content (fields consumed by orchestrator). */
@@ -91,19 +92,30 @@ interface ChildHandle {
91
92
  kill(signal?: number): void;
92
93
  }
93
94
 
95
+ interface SpawnedRunner {
96
+ pid: number;
97
+ kill?: () => void;
98
+ }
99
+
94
100
  /** Seed filename regex. Captures jobId from {jobId}-seed.json. */
95
101
  export const SEED_PATTERN = /^([A-Za-z0-9-_]+)-seed\.json$/;
96
102
 
97
103
  import { join, basename, dirname, resolve } from "node:path";
98
- import { mkdir, rename } from "node:fs/promises";
104
+ import { mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
99
105
  import { watch } from "chokidar";
100
106
  import { createLogger } from "./logger";
101
107
  import { createTaskFileIO, generateLogName } from "./file-io";
102
108
  import { LogEvent, LogFileExtension } from "../config/log-events";
103
- import { getConfig, getPipelineConfig } from "./config";
109
+ import { getConfig, getOrchestratorConfig, getPipelineConfig } from "./config";
104
110
  import { buildReexecArgs } from "../cli/self-reexec";
105
111
  import { writeJobStatus } from "./status-writer";
106
112
  import { initializeStatusFromArtifacts } from "./status-initializer";
113
+ import {
114
+ listQueuedSeeds,
115
+ releaseJobSlot,
116
+ tryAcquireJobSlot,
117
+ updateJobSlotPid,
118
+ } from "./job-concurrency";
107
119
 
108
120
  /**
109
121
  * Normalize any path that may already include `pipeline-data` (or subdirs
@@ -125,6 +137,7 @@ export function resolveDirs(dataDir: string): ResolvedDirs {
125
137
  pending: join(root, "pending"),
126
138
  current: join(root, "current"),
127
139
  complete: join(root, "complete"),
140
+ rejected: join(root, "rejected"),
128
141
  };
129
142
  }
130
143
 
@@ -165,7 +178,8 @@ export async function spawnRunner(
165
178
  dirs: ResolvedDirs,
166
179
  running: Map<string, ChildHandle>,
167
180
  logger: ReturnType<typeof createLogger>,
168
- spawnFn: SpawnFn
181
+ spawnFn: SpawnFn,
182
+ onExit?: (jobId: string) => void | Promise<void>,
169
183
  ): Promise<void> {
170
184
  if (!seed.pipeline) {
171
185
  throw new Error(`seed.pipeline is required for job ${jobId}`);
@@ -207,63 +221,48 @@ export async function spawnRunner(
207
221
 
208
222
  running.set(jobId, child);
209
223
 
210
- void child.exited.then((result) => {
211
- running.delete(jobId);
212
- logger.log(`job ${jobId} exited`, {
213
- code: result.code,
214
- signal: result.signal,
215
- completionType: result.completionType,
224
+ void child.exited
225
+ .then(async (result) => {
226
+ running.delete(jobId);
227
+ logger.log(`job ${jobId} exited`, {
228
+ code: result.code,
229
+ signal: result.signal,
230
+ completionType: result.completionType,
231
+ });
232
+ if (!onExit) return;
233
+ try {
234
+ await onExit(jobId);
235
+ } catch (err) {
236
+ logger.error(`child exit cleanup failed for job ${jobId}`, err);
237
+ }
238
+ })
239
+ .catch((err) => {
240
+ logger.error(`failed while waiting for job ${jobId} exit`, err);
216
241
  });
217
- });
218
242
  }
219
243
 
220
- export async function handleSeedAdd(
221
- filePath: string,
222
- dirs: ResolvedDirs,
223
- running: Map<string, ChildHandle>,
224
- logger: ReturnType<typeof createLogger>,
225
- opts: OrchestratorOptions
226
- ): Promise<void> {
227
- const filename = basename(filePath);
228
- const match = filename.match(SEED_PATTERN);
229
-
230
- if (!match) {
231
- logger.warn(`ignoring non-seed file: ${filename}`);
232
- return;
233
- }
234
-
235
- const jobId = match[1]!;
236
-
237
- let raw: string;
238
- try {
239
- raw = await Bun.file(filePath).text();
240
- } catch (err) {
241
- logger.warn(`failed to read ${filename}`, err);
242
- return;
243
- }
244
+ export interface HandleChildExitOptions {
245
+ dataDir: string;
246
+ jobId: string;
247
+ triggerDrain: () => void;
248
+ lockTimeoutMs?: number;
249
+ }
244
250
 
245
- let seed: SeedData;
251
+ export async function handleChildExit(opts: HandleChildExitOptions): Promise<void> {
246
252
  try {
247
- seed = JSON.parse(raw) as SeedData;
248
- } catch (err) {
249
- const message = err instanceof Error ? err.message : String(err);
250
- logger.warn(`invalid JSON in ${filename}: ${message}`);
251
- return;
253
+ await releaseJobSlot(opts.dataDir, opts.jobId, opts.lockTimeoutMs);
254
+ } finally {
255
+ opts.triggerDrain();
252
256
  }
257
+ }
253
258
 
254
- if (running.has(jobId)) return;
255
-
259
+ async function scaffoldJobDir(
260
+ jobId: string,
261
+ seed: SeedData,
262
+ dirs: ResolvedDirs,
263
+ logger: ReturnType<typeof createLogger>,
264
+ ): Promise<void> {
256
265
  const jobDir = join(dirs.current, jobId);
257
- const seedDest = join(jobDir, "seed.json");
258
- if (await Bun.file(seedDest).exists()) return;
259
-
260
- await mkdir(jobDir, { recursive: true });
261
- try {
262
- await rename(filePath, seedDest);
263
- } catch (err) {
264
- logger.error(`failed to move seed file for job ${jobId}`, err);
265
- throw err;
266
- }
267
266
  await mkdir(join(jobDir, "tasks"), { recursive: true });
268
267
 
269
268
  let pipelineTasks: string[] = [];
@@ -322,6 +321,56 @@ export async function handleSeedAdd(
322
321
 
323
322
  const logName = generateLogName("orchestrator", "init", LogEvent.START, LogFileExtension.JSON);
324
323
  await fileIO.writeLog(logName, JSON.stringify(startLog, null, 2), { mode: "replace" });
324
+ }
325
+
326
+ export async function handleSeedAdd(
327
+ filePath: string,
328
+ dirs: ResolvedDirs,
329
+ running: Map<string, ChildHandle>,
330
+ logger: ReturnType<typeof createLogger>,
331
+ opts: OrchestratorOptions
332
+ ): Promise<void> {
333
+ const filename = basename(filePath);
334
+ const match = filename.match(SEED_PATTERN);
335
+
336
+ if (!match) {
337
+ logger.warn(`ignoring non-seed file: ${filename}`);
338
+ return;
339
+ }
340
+
341
+ const jobId = match[1]!;
342
+
343
+ let raw: string;
344
+ try {
345
+ raw = await Bun.file(filePath).text();
346
+ } catch (err) {
347
+ logger.warn(`failed to read ${filename}`, err);
348
+ return;
349
+ }
350
+
351
+ let seed: SeedData;
352
+ try {
353
+ seed = JSON.parse(raw) as SeedData;
354
+ } catch (err) {
355
+ const message = err instanceof Error ? err.message : String(err);
356
+ logger.warn(`invalid JSON in ${filename}: ${message}`);
357
+ return;
358
+ }
359
+
360
+ if (running.has(jobId)) return;
361
+
362
+ const jobDir = join(dirs.current, jobId);
363
+ const seedDest = join(jobDir, "seed.json");
364
+ if (await Bun.file(seedDest).exists()) return;
365
+
366
+ await mkdir(jobDir, { recursive: true });
367
+ try {
368
+ await rename(filePath, seedDest);
369
+ } catch (err) {
370
+ logger.error(`failed to move seed file for job ${jobId}`, err);
371
+ throw err;
372
+ }
373
+ await scaffoldJobDir(jobId, seed, dirs, logger);
325
374
 
326
375
  const spawnFn = opts.spawn ?? createDefaultSpawn();
327
376
  try {
@@ -364,18 +413,284 @@ export async function stopChildren(
364
413
  running.clear();
365
414
  }
366
415
 
416
+ const MAX_PENDING_SEED_FAILURES = 3;
417
+ const pendingSeedFailures = new Map<string, { count: number; signature: string }>();
418
+
419
+ export interface DrainPendingQueueOptions {
420
+ dataDir: string;
421
+ maxConcurrentJobs: number;
422
+ lockTimeoutMs: number;
423
+ spawnRunner: (jobId: string, seed: SeedData) => Promise<SpawnedRunner>;
424
+ }
425
+
426
+ export interface DrainPendingQueueResult {
427
+ promoted: string[];
428
+ remaining: number;
429
+ }
430
+
431
+ async function dirExists(path: string): Promise<boolean> {
432
+ try {
433
+ const s = await stat(path);
434
+ return s.isDirectory();
435
+ } catch (err) {
436
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
437
+ throw err;
438
+ }
439
+ }
440
+
441
+ async function promoteSeedFile(
442
+ pendingSeedPath: string,
443
+ currentJobDir: string,
444
+ ): Promise<void> {
445
+ await mkdir(currentJobDir, { recursive: true });
446
+ await rename(pendingSeedPath, join(currentJobDir, "seed.json"));
447
+ }
448
+
449
+ async function restoreSeedFile(currentJobDir: string, pendingSeedPath: string): Promise<void> {
450
+ const currentSeedPath = join(currentJobDir, "seed.json");
451
+ try {
452
+ await rename(currentSeedPath, pendingSeedPath);
453
+ } catch (err) {
454
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
455
+ }
456
+ await rm(currentJobDir, { recursive: true, force: true });
457
+ }
458
+
459
+ async function rejectSeedFile(
460
+ dataDir: string,
461
+ jobId: string,
462
+ seedPath: string,
463
+ reason: string,
464
+ message: string,
465
+ ): Promise<void> {
466
+ const rejectedJobDir = join(dataDir, "rejected", jobId);
467
+ await mkdir(rejectedJobDir, { recursive: true });
468
+ try {
469
+ await rename(seedPath, join(rejectedJobDir, "seed.json"));
470
+ } catch (err) {
471
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
472
+ }
473
+ await writeFile(
474
+ join(rejectedJobDir, "rejection.json"),
475
+ JSON.stringify({
476
+ jobId,
477
+ reason,
478
+ message,
479
+ rejectedAt: new Date().toISOString(),
480
+ }),
481
+ );
482
+ pendingSeedFailures.delete(jobId);
483
+ }
484
+
485
+ function recordPendingSeedFailure(jobId: string, signature: string): number {
486
+ const current = pendingSeedFailures.get(jobId);
487
+ const count = current && current.signature === signature ? current.count + 1 : 1;
488
+ pendingSeedFailures.set(jobId, { count, signature });
489
+ return count;
490
+ }
491
+
492
+ function clearPendingSeedFailure(jobId: string): void {
493
+ pendingSeedFailures.delete(jobId);
494
+ }
495
+
496
+ async function readPendingSeed(seedPath: string): Promise<
497
+ | { ok: true; seed: SeedData }
498
+ | { ok: false; reason: "missing" | "invalid"; message: string }
499
+ > {
500
+ let raw: string;
501
+ try {
502
+ raw = await Bun.file(seedPath).text();
503
+ } catch (err) {
504
+ const message = err instanceof Error ? err.message : String(err);
505
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return { ok: false, reason: "missing", message };
506
+ return { ok: false, reason: "invalid", message };
507
+ }
508
+ try {
509
+ return { ok: true, seed: JSON.parse(raw) as SeedData };
510
+ } catch (err) {
511
+ return {
512
+ ok: false,
513
+ reason: "invalid",
514
+ message: err instanceof Error ? err.message : String(err),
515
+ };
516
+ }
517
+ }
518
+
519
+ export async function drainPendingQueue(
520
+ opts: DrainPendingQueueOptions,
521
+ ): Promise<DrainPendingQueueResult> {
522
+ const { dataDir, maxConcurrentJobs, lockTimeoutMs, spawnRunner } = opts;
523
+ const logger = createLogger("orchestrator");
524
+ const queued = await listQueuedSeeds(dataDir);
525
+ const promoted: string[] = [];
526
+
527
+ for (let i = 0; i < queued.length; i++) {
528
+ const { jobId, seedPath } = queued[i]!;
529
+
530
+ const parsedSeed = await readPendingSeed(seedPath);
531
+ if (!parsedSeed.ok) {
532
+ logger.warn(`failed to read queued seed ${jobId}-seed.json (${parsedSeed.reason}): ${parsedSeed.message}`);
533
+ const failures = recordPendingSeedFailure(jobId, `${parsedSeed.reason}:${queued[i]!.queuedAt}:${parsedSeed.message}`);
534
+ if (failures >= MAX_PENDING_SEED_FAILURES) {
535
+ try {
536
+ await rejectSeedFile(dataDir, jobId, seedPath, parsedSeed.reason, parsedSeed.message);
537
+ logger.warn(`rejected queued seed ${jobId}-seed.json after ${failures} failed reads`);
538
+ } catch (rejectErr) {
539
+ logger.error(`failed to reject queued seed ${jobId}-seed.json`, rejectErr);
540
+ }
541
+ }
542
+ continue;
543
+ }
544
+
545
+ const acquired = await tryAcquireJobSlot({
546
+ dataDir,
547
+ jobId,
548
+ maxConcurrentJobs,
549
+ source: "orchestrator",
550
+ lockTimeoutMs,
551
+ });
552
+ if (!acquired.ok) {
553
+ if (acquired.reason === "limit_reached") {
554
+ return { promoted, remaining: (await listQueuedSeeds(dataDir)).length };
555
+ }
556
+ logger.warn(`job slot already held for ${jobId}; skipping queued seed`);
557
+ continue;
558
+ }
559
+
560
+ const currentJobDir = join(dataDir, "current", jobId);
561
+ if (await dirExists(currentJobDir)) {
562
+ await releaseJobSlot(dataDir, jobId, lockTimeoutMs);
563
+ logger.warn(`current job directory already exists for ${jobId}; skipping promotion`);
564
+ continue;
565
+ }
566
+
567
+ try {
568
+ await promoteSeedFile(seedPath, currentJobDir);
569
+ } catch (err) {
570
+ await releaseJobSlot(dataDir, jobId, lockTimeoutMs);
571
+ logger.error(`failed to promote seed file for job ${jobId}`, err);
572
+ continue;
573
+ }
574
+
575
+ let spawned: SpawnedRunner;
576
+ try {
577
+ spawned = await spawnRunner(jobId, parsedSeed.seed);
578
+ } catch (err) {
579
+ await releaseJobSlot(dataDir, jobId, lockTimeoutMs);
580
+ try {
581
+ await restoreSeedFile(currentJobDir, seedPath);
582
+ } catch (restoreErr) {
583
+ logger.error(`failed to restore queued seed after spawn failure for job ${jobId}`, restoreErr);
584
+ }
585
+ const message = err instanceof Error ? err.message : String(err);
586
+ const failures = recordPendingSeedFailure(jobId, `spawn:${message}`);
587
+ if (failures >= MAX_PENDING_SEED_FAILURES) {
588
+ try {
589
+ await rejectSeedFile(dataDir, jobId, seedPath, "spawn_failed", message);
590
+ logger.error(`rejected queued seed ${jobId} after ${failures} spawn failures`, err);
591
+ } catch (rejectErr) {
592
+ logger.error(`failed to reject queued seed after spawn failures for job ${jobId}`, rejectErr);
593
+ }
594
+ }
595
+ logger.error(`failed to spawn queued job ${jobId}`, err);
596
+ continue;
597
+ }
598
+
599
+ try {
600
+ await updateJobSlotPid(dataDir, jobId, spawned.pid, lockTimeoutMs);
601
+ } catch (err) {
602
+ logger.error(`failed to record runner pid for job ${jobId}`, err);
603
+ spawned.kill?.();
604
+ try {
605
+ await releaseJobSlot(dataDir, jobId, lockTimeoutMs);
606
+ } catch (releaseErr) {
607
+ logger.error(`failed to release slot after pid update failure for job ${jobId}`, releaseErr);
608
+ }
609
+ try {
610
+ await restoreSeedFile(currentJobDir, seedPath);
611
+ } catch (restoreErr) {
612
+ logger.error(`failed to restore queued seed after pid update failure for job ${jobId}`, restoreErr);
613
+ }
614
+ const message = err instanceof Error ? err.message : String(err);
615
+ const failures = recordPendingSeedFailure(jobId, `pid:${message}`);
616
+ if (failures >= MAX_PENDING_SEED_FAILURES) {
617
+ try {
618
+ await rejectSeedFile(dataDir, jobId, seedPath, "pid_update_failed", message);
619
+ } catch (rejectErr) {
620
+ logger.error(`failed to reject queued seed after pid update failures for job ${jobId}`, rejectErr);
621
+ }
622
+ }
623
+ continue;
624
+ }
625
+ clearPendingSeedFailure(jobId);
626
+ promoted.push(jobId);
627
+ }
628
+
629
+ return { promoted, remaining: (await listQueuedSeeds(dataDir)).length };
630
+ }
631
+
367
632
  export function startOrchestrator(opts: OrchestratorOptions): Promise<OrchestratorHandle> {
368
633
  if (!opts.dataDir) throw new Error("dataDir is required");
369
634
 
370
635
  const dirs = resolveDirs(opts.dataDir);
371
636
  const factory = opts.watcherFactory ?? ((path, options) => watch(path, options) as unknown as Watcher);
372
637
  const logger = createLogger("orchestrator");
638
+ const orchestratorConfig = getOrchestratorConfig();
639
+ const maxConcurrentJobs = orchestratorConfig.maxConcurrentJobs;
640
+ const lockTimeoutMs = orchestratorConfig.lockFileTimeout;
373
641
 
374
642
  return mkdir(dirs.pending, { recursive: true })
375
643
  .then(() => mkdir(dirs.current, { recursive: true }))
376
644
  .then(() => mkdir(dirs.complete, { recursive: true }))
645
+ .then(() => mkdir(dirs.rejected, { recursive: true }))
377
646
  .then(() => new Promise<OrchestratorHandle>((resolve, reject) => {
378
647
  const running = new Map<string, ChildHandle>();
648
+ const spawnFn = opts.spawn ?? createDefaultSpawn();
649
+
650
+ let isDraining = false;
651
+ let pendingDrain = false;
652
+ let stopped = false;
653
+ let activeDrain: Promise<void> | null = null;
654
+ const triggerDrain = (): void => {
655
+ if (stopped) return;
656
+ if (isDraining) {
657
+ pendingDrain = true;
658
+ return;
659
+ }
660
+ isDraining = true;
661
+ activeDrain = (async () => {
662
+ try {
663
+ do {
664
+ if (stopped) return;
665
+ pendingDrain = false;
666
+ await drainPendingQueue({
667
+ dataDir: dirs.dataDir,
668
+ maxConcurrentJobs,
669
+ lockTimeoutMs,
670
+ spawnRunner: spawnRunnerForJob,
671
+ });
672
+ } while (pendingDrain);
673
+ } catch (err) {
674
+ logger.error("drainPendingQueue failed", err);
675
+ } finally {
676
+ isDraining = false;
677
+ activeDrain = null;
678
+ }
679
+ })();
680
+ };
681
+
682
+ const onChildExit = (jobId: string): Promise<void> => {
683
+ if (stopped) return Promise.resolve();
684
+ return handleChildExit({ dataDir: dirs.dataDir, jobId, triggerDrain, lockTimeoutMs });
685
+ };
686
+
687
+ const spawnRunnerForJob = async (jobId: string, seed: SeedData): Promise<SpawnedRunner> => {
688
+ await scaffoldJobDir(jobId, seed, dirs, logger);
689
+ await spawnRunner(jobId, seed, dirs, running, logger, spawnFn, onChildExit);
690
+ const child = running.get(jobId);
691
+ if (!child) throw new Error(`spawnRunner did not register child for ${jobId}`);
692
+ return { pid: child.pid, kill: () => child.kill(15) };
693
+ };
379
694
 
380
695
  const watcher = factory(join(dirs.pending, "*.json"), {
381
696
  ignoreInitial: false,
@@ -384,16 +699,14 @@ export function startOrchestrator(opts: OrchestratorOptions): Promise<Orchestrat
384
699
  });
385
700
 
386
701
  const stop = async (): Promise<void> => {
702
+ stopped = true;
387
703
  await watcher.close();
704
+ if (activeDrain) await activeDrain;
388
705
  await stopChildren(running, logger);
389
706
  };
390
707
 
391
708
  watcher
392
- .on("add", (filePath) => {
393
- handleSeedAdd(filePath, dirs, running, logger, opts).catch((err: unknown) => {
394
- logger.error(`unhandled error processing seed file ${filePath}`, err);
395
- });
396
- })
709
+ .on("add", () => triggerDrain())
397
710
  .on("error", reject)
398
711
  .on("ready", () => resolve({ stop }));
399
712
  }));
@@ -2,18 +2,19 @@ import { join, dirname, resolve, basename } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { unlink, mkdir, rename, appendFile } from "node:fs/promises";
4
4
  import { unlinkSync } from "node:fs";
5
- import { getPipelineConfig } from "./config";
5
+ import { getConfig, getPipelineConfig } from "./config";
6
6
  import { validatePipelineOrThrow } from "./validation";
7
7
  import { loadFreshModule } from "./module-loader";
8
8
  import { writeJobStatus } from "./status-writer";
9
9
  import { decideTransition } from "./lifecycle-policy";
10
10
  import { runPipeline } from "./task-runner";
11
- import type { AuditLogEntry } from "./task-runner";
11
+ import type { AuditLogEntry, PipelineResult } from "./task-runner";
12
12
  import { ensureTaskSymlinkBridge } from "./symlink-bridge";
13
13
  import { validateTaskSymlinks, repairTaskSymlinks, cleanupTaskSymlinks } from "./symlink-utils";
14
14
  import { createTaskFileIO, generateLogName } from "./file-io";
15
15
  import { LogEvent, LogFileExtension } from "../config/log-events";
16
16
  import { TaskState } from "../config/statuses";
17
+ import { releaseJobSlot } from "./job-concurrency";
17
18
 
18
19
  // ─── Type definitions ─────────────────────────────────────────────────────────
19
20
 
@@ -98,6 +99,7 @@ export interface TaskStatus {
98
99
  attempts?: number;
99
100
  executionTimeMs?: number;
100
101
  refinementAttempts?: number;
102
+ restartCount?: number;
101
103
  error?: NormalizedError;
102
104
  failedStage?: string;
103
105
  stageLogPath?: string;
@@ -223,21 +225,41 @@ export function cleanupPidFileSync(workDir: string): void {
223
225
  }
224
226
  }
225
227
 
226
- /** Registers SIGINT, SIGTERM, and process exit handlers to clean up the PID file. */
227
- export function installSignalHandlers(workDir: string): void {
228
- process.on("SIGINT", () => {
229
- cleanupPidFileSync(workDir);
230
- process.exit();
231
- });
232
- process.on("SIGTERM", () => {
233
- cleanupPidFileSync(workDir);
234
- process.exit();
235
- });
228
+ /** Registers SIGINT, SIGTERM, SIGHUP, and process exit handlers to release the job slot and clean up the PID file. */
229
+ export function installSignalHandlers(workDir: string, dataDir: string, jobId: string): void {
230
+ let shuttingDown = false;
231
+ const handle = async () => {
232
+ if (shuttingDown) return;
233
+ shuttingDown = true;
234
+ try {
235
+ await releaseJobSlot(dataDir, jobId);
236
+ } catch (err) {
237
+ console.error(`failed to release job slot for ${jobId} during shutdown`, err);
238
+ }
239
+ try {
240
+ cleanupPidFileSync(workDir);
241
+ } catch (err) {
242
+ console.error(`failed to clean runner pid for ${jobId} during shutdown`, err);
243
+ } finally {
244
+ process.exit();
245
+ }
246
+ };
247
+ process.once("SIGINT", handle);
248
+ process.once("SIGTERM", handle);
249
+ process.once("SIGHUP", handle);
236
250
  process.on("exit", () => {
237
251
  cleanupPidFileSync(workDir);
238
252
  });
239
253
  }
240
254
 
255
+ async function releaseJobSlotBestEffort(dataDir: string, jobId: string): Promise<void> {
256
+ try {
257
+ await releaseJobSlot(dataDir, jobId);
258
+ } catch (err) {
259
+ console.error(`failed to release job slot for ${jobId}`, err);
260
+ }
261
+ }
262
+
241
263
  // ─── Pipeline loading ─────────────────────────────────────────────────────────
242
264
 
243
265
  /** Reads, parses, and validates a pipeline.json file. */
@@ -256,14 +278,21 @@ export async function loadTaskRegistry(registryPath: string): Promise<TaskRegist
256
278
 
257
279
  // ─── Pipeline job entry point ─────────────────────────────────────────────────
258
280
 
281
+ const INITIAL_RETRY_DELAY_MS = 2_000;
282
+ const MAX_RETRY_DELAY_MS = 30_000;
283
+ const RETRY_BACKOFF_MULTIPLIER = 2;
284
+
259
285
  /** Runs a pipeline job end-to-end for the given job ID. */
260
286
  export async function runPipelineJob(jobId: string): Promise<void> {
261
287
  let workDir: string | undefined;
288
+ const poRoot = resolve(process.env["PO_ROOT"] ?? process.cwd());
289
+ let dataDir: string | undefined = resolve(poRoot, process.env["PO_DATA_DIR"] ?? "pipeline-data");
262
290
  try {
263
291
  const config = await resolveJobConfig(jobId);
264
292
  workDir = config.workDir;
293
+ dataDir = resolve(config.poRoot, config.dataDir);
265
294
  await writePidFile(config.workDir);
266
- installSignalHandlers(config.workDir);
295
+ installSignalHandlers(config.workDir, dataDir, jobId);
267
296
 
268
297
  const pipeline = await loadPipeline(config.pipelineJsonPath);
269
298
  const taskRegistry = await loadTaskRegistry(config.taskRegistryPath);
@@ -414,8 +443,38 @@ export async function runPipelineJob(jobId: string): Promise<void> {
414
443
  },
415
444
  };
416
445
 
417
- // Delegate to task runner
418
- const result = await runPipeline(relocatedEntryPath, taskExecutionContext);
446
+ // Delegate to task runner with bounded retry loop.
447
+ // Guard against partial test mocks or malformed runtime config.
448
+ const configuredMaxAttempts = getConfig().taskRunner?.maxAttempts;
449
+ const maxAttempts = Number.isInteger(configuredMaxAttempts) ? configuredMaxAttempts : 3;
450
+ const cap = Math.max(1, maxAttempts);
451
+
452
+ let result: PipelineResult | undefined;
453
+ for (let attempt = 1; attempt <= cap; attempt++) {
454
+ result = await runPipeline(relocatedEntryPath, taskExecutionContext);
455
+ if (result.ok) break;
456
+ if (attempt >= cap) break;
457
+
458
+ const delay = Math.min(
459
+ INITIAL_RETRY_DELAY_MS * RETRY_BACKOFF_MULTIPLIER ** (attempt - 1),
460
+ MAX_RETRY_DELAY_MS,
461
+ );
462
+
463
+ await writeJobStatus(config.workDir, (snapshot) => {
464
+ const entry = snapshot.tasks[taskName] ?? {};
465
+ const currentAttempts = typeof entry.attempts === "number" ? entry.attempts : attempt;
466
+ entry.state = "running";
467
+ entry.attempts = currentAttempts + 1;
468
+ entry.restartCount = (entry.restartCount ?? 0) + 1;
469
+ delete entry.failedStage;
470
+ delete entry.error;
471
+ snapshot.tasks[taskName] = entry;
472
+ });
473
+
474
+ await Bun.sleep(delay);
475
+ }
476
+
477
+ if (!result) throw new Error("Retry loop produced no result");
419
478
 
420
479
  if (result.ok) {
421
480
  // Compute execution time from logs
@@ -466,6 +525,7 @@ export async function runPipelineJob(jobId: string): Promise<void> {
466
525
  snapshot.tasks[taskName] = raw as typeof snapshot.tasks[string];
467
526
  });
468
527
 
528
+ await releaseJobSlotBestEffort(dataDir, jobId);
469
529
  process.exit(1);
470
530
  }
471
531
 
@@ -484,6 +544,7 @@ export async function runPipelineJob(jobId: string): Promise<void> {
484
544
  const finalStatus = JSON.parse(finalStatusText) as JobStatus;
485
545
  await completeJob(config, finalStatus, pipelineArtifacts);
486
546
  }
547
+ await releaseJobSlotBestEffort(dataDir, jobId);
487
548
  } catch (err) {
488
549
  const normalized = normalizeError(err);
489
550
  console.error(normalized.message);
@@ -512,6 +573,9 @@ export async function runPipelineJob(jobId: string): Promise<void> {
512
573
  // Do not mask the original failure if PID cleanup fails
513
574
  }
514
575
  }
576
+ if (dataDir !== undefined) {
577
+ await releaseJobSlotBestEffort(dataDir, jobId);
578
+ }
515
579
  process.exitCode = 1;
516
580
  setTimeout(() => process.exit(1), 5000).unref();
517
581
  process.exit(1);