@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.
- package/package.json +1 -1
- package/src/config/__tests__/models.test.ts +31 -1
- package/src/config/models.ts +81 -35
- package/src/config/paths.ts +13 -8
- package/src/core/__tests__/config.test.ts +121 -0
- package/src/core/__tests__/job-concurrency.test.ts +554 -0
- package/src/core/__tests__/orchestrator.test.ts +353 -0
- package/src/core/__tests__/pipeline-runner.test.ts +430 -2
- package/src/core/__tests__/task-runner.test.ts +1 -2
- package/src/core/config.ts +48 -1
- package/src/core/job-concurrency.ts +462 -0
- package/src/core/orchestrator.ts +370 -57
- package/src/core/pipeline-runner.ts +79 -15
- package/src/core/status-writer.ts +4 -0
- package/src/core/task-runner.ts +1 -1
- package/src/providers/__tests__/base.test.ts +1 -1
- package/src/ui/client/__tests__/api.test.ts +101 -1
- package/src/ui/client/__tests__/job-adapter.test.ts +12 -0
- package/src/ui/client/__tests__/useConcurrencyStatus.test.ts +126 -0
- package/src/ui/client/adapters/job-adapter.ts +1 -0
- package/src/ui/client/api.ts +77 -7
- package/src/ui/client/hooks/useConcurrencyStatus.ts +102 -0
- package/src/ui/client/types.ts +34 -1
- package/src/ui/components/DAGGrid.tsx +11 -1
- package/src/ui/components/JobDetail.tsx +2 -1
- package/src/ui/components/__tests__/DAGGrid.test.tsx +92 -0
- package/src/ui/components/__tests__/JobDetail.test.tsx +62 -0
- package/src/ui/components/types.ts +2 -0
- package/src/ui/dist/assets/{index-SKy2shWc.js → index-BnAqY4_n.js} +336 -52
- package/src/ui/dist/assets/index-BnAqY4_n.js.map +1 -0
- package/src/ui/dist/assets/style-BKG0bHu-.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/pages/PromptPipelineDashboard.tsx +186 -4
- package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +272 -1
- package/src/ui/server/__tests__/concurrency-endpoint.test.ts +190 -0
- package/src/ui/server/__tests__/index.test.ts +92 -3
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +660 -3
- package/src/ui/server/endpoints/concurrency-endpoint.ts +72 -0
- package/src/ui/server/endpoints/job-control-endpoints.ts +248 -37
- package/src/ui/server/index.ts +21 -2
- package/src/ui/server/router.ts +2 -0
- package/src/ui/state/__tests__/watcher.test.ts +31 -0
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +15 -0
- package/src/ui/state/transformers/status-transformer.ts +1 -0
- package/src/ui/state/types.ts +3 -0
- package/src/ui/state/watcher.ts +9 -1
- package/src/utils/__tests__/dag.test.ts +35 -0
- package/src/utils/dag.ts +1 -0
- package/src/ui/dist/assets/index-SKy2shWc.js.map +0 -1
- package/src/ui/dist/assets/style-DA1Ma4YS.css +0 -2
package/src/core/orchestrator.ts
CHANGED
|
@@ -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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
251
|
+
export async function handleChildExit(opts: HandleChildExitOptions): Promise<void> {
|
|
246
252
|
try {
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
|
|
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
|
-
|
|
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", (
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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);
|