@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
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { describe, test, expect, mock, spyOn, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import { mkdtemp, writeFile, mkdir, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, writeFile, mkdir, readFile, rm, access } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
getConcurrencyRuntimePaths,
|
|
7
|
+
releaseJobSlot,
|
|
8
|
+
tryAcquireJobSlot,
|
|
9
|
+
} from "../job-concurrency";
|
|
5
10
|
|
|
6
11
|
// ─── Mocks: NON-target externals only ─────────────────────────────────────────
|
|
7
12
|
// We leave status-writer and lifecycle-policy REAL so the stale-snapshot bug
|
|
@@ -9,12 +14,14 @@ import { join } from "node:path";
|
|
|
9
14
|
// deterministic no-ops; config/validation/module-loader are replaced so we
|
|
10
15
|
// don't need a real pipelines directory on disk.
|
|
11
16
|
|
|
17
|
+
const mockGetConfig = mock(() => ({ taskRunner: { maxAttempts: 3 } }));
|
|
18
|
+
|
|
12
19
|
mock.module("../config", () => ({
|
|
13
20
|
getPipelineConfig: mock((_slug: string) => ({
|
|
14
21
|
pipelineJsonPath: "/mock/pipeline.json",
|
|
15
22
|
tasksDir: "/mock/tasks",
|
|
16
23
|
})),
|
|
17
|
-
getConfig:
|
|
24
|
+
getConfig: mockGetConfig,
|
|
18
25
|
loadConfig: mock(async () => ({})),
|
|
19
26
|
resetConfig: mock(() => {}),
|
|
20
27
|
}));
|
|
@@ -95,6 +102,7 @@ const PO_ENV_KEYS = [
|
|
|
95
102
|
"PO_TASK_REGISTRY",
|
|
96
103
|
"PO_START_FROM_TASK",
|
|
97
104
|
"PO_RUN_SINGLE_TASK",
|
|
105
|
+
"PO_TASK_MAX_ATTEMPTS",
|
|
98
106
|
] as const;
|
|
99
107
|
|
|
100
108
|
interface MultiTaskFixture {
|
|
@@ -359,3 +367,423 @@ describe("runPipelineJob — outer-catch failure surfacing", () => {
|
|
|
359
367
|
expect(stderrContainsMessage).toBe(true);
|
|
360
368
|
});
|
|
361
369
|
});
|
|
370
|
+
|
|
371
|
+
describe("runPipelineJob — bounded retry loop", () => {
|
|
372
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
373
|
+
const cleanupDirs: string[] = [];
|
|
374
|
+
let originalSleep: typeof Bun.sleep;
|
|
375
|
+
let sleepDelays: number[];
|
|
376
|
+
|
|
377
|
+
beforeEach(() => {
|
|
378
|
+
for (const key of Object.keys(savedEnv)) delete savedEnv[key];
|
|
379
|
+
for (const key of PO_ENV_KEYS) {
|
|
380
|
+
savedEnv[key] = process.env[key];
|
|
381
|
+
delete process.env[key];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
mockRunPipeline.mockClear();
|
|
385
|
+
mockEnsureTaskSymlinkBridge.mockClear();
|
|
386
|
+
mockValidateTaskSymlinks.mockClear();
|
|
387
|
+
mockRepairTaskSymlinks.mockClear();
|
|
388
|
+
mockCleanupTaskSymlinks.mockClear();
|
|
389
|
+
mockLoadFreshModule.mockClear();
|
|
390
|
+
mockGetConfig.mockReset();
|
|
391
|
+
mockGetConfig.mockImplementation(() => ({ taskRunner: { maxAttempts: 3 } }));
|
|
392
|
+
|
|
393
|
+
sleepDelays = [];
|
|
394
|
+
originalSleep = Bun.sleep;
|
|
395
|
+
(Bun as unknown as { sleep: (ms: number) => Promise<void> }).sleep = async (ms: number) => {
|
|
396
|
+
sleepDelays.push(ms);
|
|
397
|
+
};
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
afterEach(async () => {
|
|
401
|
+
(Bun as unknown as { sleep: typeof Bun.sleep }).sleep = originalSleep;
|
|
402
|
+
|
|
403
|
+
for (const key of PO_ENV_KEYS) {
|
|
404
|
+
if (savedEnv[key] === undefined) {
|
|
405
|
+
delete process.env[key];
|
|
406
|
+
} else {
|
|
407
|
+
process.env[key] = savedEnv[key];
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
process.exitCode = 0;
|
|
411
|
+
|
|
412
|
+
await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
function makeFailureResult() {
|
|
416
|
+
return {
|
|
417
|
+
ok: false as const,
|
|
418
|
+
failedStage: "generate",
|
|
419
|
+
error: {
|
|
420
|
+
name: "TaskFailure",
|
|
421
|
+
message: "stub failure",
|
|
422
|
+
stack: "stack",
|
|
423
|
+
debug: { stage: "generate", logPath: "/tmp/log" },
|
|
424
|
+
},
|
|
425
|
+
logs: [{ stage: "generate", ok: false as const, ms: 5, error: "stub" }],
|
|
426
|
+
context: {} as Record<string, unknown>,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function makeSuccessResult() {
|
|
431
|
+
return {
|
|
432
|
+
ok: true as const,
|
|
433
|
+
logs: [{ stage: "generate", ok: true as const, ms: 5 }],
|
|
434
|
+
context: {} as Record<string, unknown>,
|
|
435
|
+
llmMetrics: [],
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
test("maxAttempts: 1 — failing task runs once and exits non-zero", async () => {
|
|
440
|
+
mockGetConfig.mockImplementation(() => ({ taskRunner: { maxAttempts: 1 } }));
|
|
441
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
442
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
443
|
+
|
|
444
|
+
mockRunPipeline.mockImplementation(async () => makeFailureResult() as never);
|
|
445
|
+
|
|
446
|
+
const exitCalls: Array<number | undefined> = [];
|
|
447
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
448
|
+
exitCalls.push(code);
|
|
449
|
+
throw new Error(`__test_exit__:${String(code)}`);
|
|
450
|
+
}) as typeof process.exit);
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
await runPipelineJob(fixture.jobId);
|
|
454
|
+
} catch (e) {
|
|
455
|
+
if (!(e instanceof Error) || !/^__test_exit__:/.test(e.message)) throw e;
|
|
456
|
+
} finally {
|
|
457
|
+
exitSpy.mockRestore();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
expect(mockRunPipeline.mock.calls.length).toBe(1);
|
|
461
|
+
expect(sleepDelays).toEqual([]);
|
|
462
|
+
expect(exitCalls).toContain(1);
|
|
463
|
+
|
|
464
|
+
const statusText = await readFile(join(fixture.jobDir, "tasks-status.json"), "utf-8");
|
|
465
|
+
const status = JSON.parse(statusText) as {
|
|
466
|
+
tasks: Record<string, { state?: string; attempts?: number; restartCount?: number }>;
|
|
467
|
+
};
|
|
468
|
+
expect(status.tasks["task-a"]?.state).toBe("failed");
|
|
469
|
+
expect(status.tasks["task-a"]?.restartCount).toBeUndefined();
|
|
470
|
+
expect(status.tasks["task-a"]?.attempts).toBe(1);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("maxAttempts: 3 — fails twice then succeeds: three calls, restartCount=2, exits zero", async () => {
|
|
474
|
+
mockGetConfig.mockImplementation(() => ({ taskRunner: { maxAttempts: 3 } }));
|
|
475
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
476
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
477
|
+
|
|
478
|
+
let call = 0;
|
|
479
|
+
mockRunPipeline.mockImplementation(async () => {
|
|
480
|
+
call += 1;
|
|
481
|
+
return (call <= 2 ? makeFailureResult() : makeSuccessResult()) as never;
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const exitCalls: Array<number | undefined> = [];
|
|
485
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
486
|
+
exitCalls.push(code);
|
|
487
|
+
throw new Error(`__test_exit__:${String(code)}`);
|
|
488
|
+
}) as typeof process.exit);
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
await runPipelineJob(fixture.jobId);
|
|
492
|
+
} catch (e) {
|
|
493
|
+
if (!(e instanceof Error) || !/^__test_exit__:/.test(e.message)) throw e;
|
|
494
|
+
} finally {
|
|
495
|
+
exitSpy.mockRestore();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
expect(mockRunPipeline.mock.calls.length).toBe(3);
|
|
499
|
+
expect(sleepDelays).toEqual([2000, 4000]);
|
|
500
|
+
expect(exitCalls).toEqual([]);
|
|
501
|
+
|
|
502
|
+
const statusText = await readFile(fixture.statusPath, "utf-8");
|
|
503
|
+
const status = JSON.parse(statusText) as {
|
|
504
|
+
tasks: Record<string, { state?: string; attempts?: number; restartCount?: number }>;
|
|
505
|
+
};
|
|
506
|
+
expect(status.tasks["task-a"]?.state).toBe("done");
|
|
507
|
+
expect(status.tasks["task-a"]?.attempts).toBe(3);
|
|
508
|
+
expect(status.tasks["task-a"]?.restartCount).toBe(2);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("maxAttempts: 3 — always fails: three calls, restartCount=2, exits non-zero", async () => {
|
|
512
|
+
mockGetConfig.mockImplementation(() => ({ taskRunner: { maxAttempts: 3 } }));
|
|
513
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
514
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
515
|
+
|
|
516
|
+
mockRunPipeline.mockImplementation(async () => makeFailureResult() as never);
|
|
517
|
+
|
|
518
|
+
const exitCalls: Array<number | undefined> = [];
|
|
519
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
520
|
+
exitCalls.push(code);
|
|
521
|
+
throw new Error(`__test_exit__:${String(code)}`);
|
|
522
|
+
}) as typeof process.exit);
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
await runPipelineJob(fixture.jobId);
|
|
526
|
+
} catch (e) {
|
|
527
|
+
if (!(e instanceof Error) || !/^__test_exit__:/.test(e.message)) throw e;
|
|
528
|
+
} finally {
|
|
529
|
+
exitSpy.mockRestore();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
expect(mockRunPipeline.mock.calls.length).toBe(3);
|
|
533
|
+
expect(sleepDelays).toEqual([2000, 4000]);
|
|
534
|
+
expect(exitCalls).toContain(1);
|
|
535
|
+
|
|
536
|
+
const statusText = await readFile(join(fixture.jobDir, "tasks-status.json"), "utf-8");
|
|
537
|
+
const status = JSON.parse(statusText) as {
|
|
538
|
+
tasks: Record<string, { state?: string; attempts?: number; restartCount?: number }>;
|
|
539
|
+
};
|
|
540
|
+
expect(status.tasks["task-a"]?.state).toBe("failed");
|
|
541
|
+
expect(status.tasks["task-a"]?.attempts).toBe(3);
|
|
542
|
+
expect(status.tasks["task-a"]?.restartCount).toBe(2);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("interim status between attempts: state=running, no failedStage/error, restartCount incremented", async () => {
|
|
546
|
+
mockGetConfig.mockImplementation(() => ({ taskRunner: { maxAttempts: 3 } }));
|
|
547
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
548
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
549
|
+
|
|
550
|
+
let call = 0;
|
|
551
|
+
let interimSnapshot: { state?: string; attempts?: number; failedStage?: unknown; error?: unknown; restartCount?: number } | undefined;
|
|
552
|
+
|
|
553
|
+
// Capture the snapshot from disk *during* the second call (after the first failure
|
|
554
|
+
// and the interim writeJobStatus). At call #2 we read tasks-status.json, then
|
|
555
|
+
// return success so the test ends cleanly.
|
|
556
|
+
mockRunPipeline.mockImplementation(async () => {
|
|
557
|
+
call += 1;
|
|
558
|
+
if (call === 2) {
|
|
559
|
+
const text = await readFile(join(fixture.jobDir, "tasks-status.json"), "utf-8");
|
|
560
|
+
const parsed = JSON.parse(text) as {
|
|
561
|
+
tasks: Record<string, { state?: string; attempts?: number; failedStage?: unknown; error?: unknown; restartCount?: number }>;
|
|
562
|
+
};
|
|
563
|
+
interimSnapshot = parsed.tasks["task-a"];
|
|
564
|
+
return makeSuccessResult() as never;
|
|
565
|
+
}
|
|
566
|
+
return makeFailureResult() as never;
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
570
|
+
throw new Error(`__test_exit__:${String(code)}`);
|
|
571
|
+
}) as typeof process.exit);
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
await runPipelineJob(fixture.jobId);
|
|
575
|
+
} catch (e) {
|
|
576
|
+
if (!(e instanceof Error) || !/^__test_exit__:/.test(e.message)) throw e;
|
|
577
|
+
} finally {
|
|
578
|
+
exitSpy.mockRestore();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
expect(interimSnapshot).toBeDefined();
|
|
582
|
+
expect(interimSnapshot?.state).toBe("running");
|
|
583
|
+
expect(interimSnapshot?.failedStage).toBeUndefined();
|
|
584
|
+
expect(interimSnapshot?.error).toBeUndefined();
|
|
585
|
+
expect(interimSnapshot?.attempts).toBe(2);
|
|
586
|
+
expect(interimSnapshot?.restartCount).toBe(1);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("missing taskRunner config falls back to the default retry cap", async () => {
|
|
590
|
+
mockGetConfig.mockImplementation(() => ({} as never));
|
|
591
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
592
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
593
|
+
|
|
594
|
+
let call = 0;
|
|
595
|
+
mockRunPipeline.mockImplementation(async () => {
|
|
596
|
+
call += 1;
|
|
597
|
+
return (call === 1 ? makeFailureResult() : makeSuccessResult()) as never;
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
await runPipelineJob(fixture.jobId);
|
|
601
|
+
|
|
602
|
+
expect(mockRunPipeline.mock.calls.length).toBe(2);
|
|
603
|
+
expect(sleepDelays).toEqual([2000]);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("exceptions from runPipeline bypass result retries and surface through outer failure handling", async () => {
|
|
607
|
+
mockGetConfig.mockImplementation(() => ({ taskRunner: { maxAttempts: 3 } }));
|
|
608
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
609
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
610
|
+
|
|
611
|
+
mockRunPipeline.mockImplementation(async () => {
|
|
612
|
+
throw new Error("task module exploded");
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const exitCalls: Array<number | undefined> = [];
|
|
616
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
617
|
+
exitCalls.push(code);
|
|
618
|
+
throw new Error(`__test_exit__:${String(code)}`);
|
|
619
|
+
}) as typeof process.exit);
|
|
620
|
+
|
|
621
|
+
try {
|
|
622
|
+
await runPipelineJob(fixture.jobId);
|
|
623
|
+
} catch (e) {
|
|
624
|
+
if (!(e instanceof Error) || !/^__test_exit__:/.test(e.message)) throw e;
|
|
625
|
+
} finally {
|
|
626
|
+
exitSpy.mockRestore();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
expect(mockRunPipeline.mock.calls.length).toBe(1);
|
|
630
|
+
expect(sleepDelays).toEqual([]);
|
|
631
|
+
expect(exitCalls).toContain(1);
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
describe("runPipelineJob — releases job slot", () => {
|
|
636
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
637
|
+
const cleanupDirs: string[] = [];
|
|
638
|
+
|
|
639
|
+
beforeEach(() => {
|
|
640
|
+
for (const key of Object.keys(savedEnv)) delete savedEnv[key];
|
|
641
|
+
for (const key of PO_ENV_KEYS) {
|
|
642
|
+
savedEnv[key] = process.env[key];
|
|
643
|
+
delete process.env[key];
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
mockRunPipeline.mockClear();
|
|
647
|
+
mockRunPipeline.mockImplementation(async (_modulePath: string, _ctx: unknown) => ({
|
|
648
|
+
ok: true as const,
|
|
649
|
+
logs: [{ stage: "generate", ok: true as const, ms: 10 }],
|
|
650
|
+
context: {} as Record<string, unknown>,
|
|
651
|
+
llmMetrics: [],
|
|
652
|
+
}));
|
|
653
|
+
mockEnsureTaskSymlinkBridge.mockClear();
|
|
654
|
+
mockValidateTaskSymlinks.mockClear();
|
|
655
|
+
mockRepairTaskSymlinks.mockClear();
|
|
656
|
+
mockCleanupTaskSymlinks.mockClear();
|
|
657
|
+
mockLoadFreshModule.mockClear();
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
afterEach(async () => {
|
|
661
|
+
for (const key of PO_ENV_KEYS) {
|
|
662
|
+
if (savedEnv[key] === undefined) {
|
|
663
|
+
delete process.env[key];
|
|
664
|
+
} else {
|
|
665
|
+
process.env[key] = savedEnv[key];
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
process.exitCode = 0;
|
|
669
|
+
|
|
670
|
+
await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
674
|
+
try {
|
|
675
|
+
await access(path);
|
|
676
|
+
return true;
|
|
677
|
+
} catch {
|
|
678
|
+
return false;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
test("successful completion releases the lease", async () => {
|
|
683
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
684
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
685
|
+
|
|
686
|
+
const acquire = await tryAcquireJobSlot({
|
|
687
|
+
dataDir: fixture.tmpDir,
|
|
688
|
+
jobId: fixture.jobId,
|
|
689
|
+
maxConcurrentJobs: 1,
|
|
690
|
+
source: "orchestrator",
|
|
691
|
+
pid: process.pid,
|
|
692
|
+
});
|
|
693
|
+
expect(acquire.ok).toBe(true);
|
|
694
|
+
|
|
695
|
+
const { runningJobsDir } = getConcurrencyRuntimePaths(fixture.tmpDir);
|
|
696
|
+
const slotPath = join(runningJobsDir, `${fixture.jobId}.json`);
|
|
697
|
+
expect(await fileExists(slotPath)).toBe(true);
|
|
698
|
+
|
|
699
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
700
|
+
throw new Error(`process.exit called with ${String(code)}`);
|
|
701
|
+
}) as typeof process.exit);
|
|
702
|
+
|
|
703
|
+
try {
|
|
704
|
+
await runPipelineJob(fixture.jobId);
|
|
705
|
+
} finally {
|
|
706
|
+
exitSpy.mockRestore();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
expect(await fileExists(slotPath)).toBe(false);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test("catch-path failure releases the lease and surfaces the original error", async () => {
|
|
713
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
714
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
715
|
+
|
|
716
|
+
const injectedMessage = "release-on-catch-injected";
|
|
717
|
+
mockLoadFreshModule.mockImplementation(async () => {
|
|
718
|
+
throw new Error(injectedMessage);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
const acquire = await tryAcquireJobSlot({
|
|
722
|
+
dataDir: fixture.tmpDir,
|
|
723
|
+
jobId: fixture.jobId,
|
|
724
|
+
maxConcurrentJobs: 1,
|
|
725
|
+
source: "orchestrator",
|
|
726
|
+
pid: process.pid,
|
|
727
|
+
});
|
|
728
|
+
expect(acquire.ok).toBe(true);
|
|
729
|
+
|
|
730
|
+
const { runningJobsDir } = getConcurrencyRuntimePaths(fixture.tmpDir);
|
|
731
|
+
const slotPath = join(runningJobsDir, `${fixture.jobId}.json`);
|
|
732
|
+
expect(await fileExists(slotPath)).toBe(true);
|
|
733
|
+
|
|
734
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
735
|
+
throw new Error(`__test_exit__:${String(code)}`);
|
|
736
|
+
}) as typeof process.exit);
|
|
737
|
+
const fakeTimer = { unref: () => fakeTimer, ref: () => fakeTimer };
|
|
738
|
+
const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(
|
|
739
|
+
((() => fakeTimer as unknown as ReturnType<typeof setTimeout>) as unknown) as typeof setTimeout,
|
|
740
|
+
);
|
|
741
|
+
const consoleErrorMessages: unknown[][] = [];
|
|
742
|
+
const consoleErrorSpy = spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
|
743
|
+
consoleErrorMessages.push(args);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
try {
|
|
747
|
+
await runPipelineJob(fixture.jobId);
|
|
748
|
+
} catch (e) {
|
|
749
|
+
if (!(e instanceof Error) || !/^__test_exit__:/.test(e.message)) throw e;
|
|
750
|
+
} finally {
|
|
751
|
+
exitSpy.mockRestore();
|
|
752
|
+
setTimeoutSpy.mockRestore();
|
|
753
|
+
consoleErrorSpy.mockRestore();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
expect(await fileExists(slotPath)).toBe(false);
|
|
757
|
+
|
|
758
|
+
const stderrContainsMessage = consoleErrorMessages.some((args) =>
|
|
759
|
+
args.some((a) => typeof a === "string" && a.includes(injectedMessage)),
|
|
760
|
+
);
|
|
761
|
+
expect(stderrContainsMessage).toBe(true);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("a second release after runPipelineJob is a no-op", async () => {
|
|
765
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
766
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
767
|
+
|
|
768
|
+
const acquire = await tryAcquireJobSlot({
|
|
769
|
+
dataDir: fixture.tmpDir,
|
|
770
|
+
jobId: fixture.jobId,
|
|
771
|
+
maxConcurrentJobs: 1,
|
|
772
|
+
source: "orchestrator",
|
|
773
|
+
pid: process.pid,
|
|
774
|
+
});
|
|
775
|
+
expect(acquire.ok).toBe(true);
|
|
776
|
+
|
|
777
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
778
|
+
throw new Error(`process.exit called with ${String(code)}`);
|
|
779
|
+
}) as typeof process.exit);
|
|
780
|
+
|
|
781
|
+
try {
|
|
782
|
+
await runPipelineJob(fixture.jobId);
|
|
783
|
+
} finally {
|
|
784
|
+
exitSpy.mockRestore();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
await releaseJobSlot(fixture.tmpDir, fixture.jobId);
|
|
788
|
+
});
|
|
789
|
+
});
|
|
@@ -91,11 +91,10 @@ describe("task-runner does not write job-level status fields", () => {
|
|
|
91
91
|
|
|
92
92
|
const status = JSON.parse(await readFile(path.join(workDir, "tasks-status.json"), "utf8")) as StatusSnapshot;
|
|
93
93
|
|
|
94
|
-
//
|
|
94
|
+
// Lifecycle fields are owned by pipeline-runner; task-runner persists progress only.
|
|
95
95
|
expect(status.state).toBe("pending");
|
|
96
96
|
expect(status.current).toBeNull();
|
|
97
97
|
expect(status.currentStage).toBeNull();
|
|
98
|
-
expect(status.progress).toBe(100);
|
|
99
98
|
});
|
|
100
99
|
|
|
101
100
|
it("does not set snapshot.state on task failure", async () => {
|
package/src/core/config.ts
CHANGED
|
@@ -10,12 +10,14 @@ export interface OrchestratorConfig {
|
|
|
10
10
|
watchDebounce: number;
|
|
11
11
|
watchStabilityThreshold: number;
|
|
12
12
|
watchPollInterval: number;
|
|
13
|
+
maxConcurrentJobs: number;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export interface TaskRunnerConfig {
|
|
16
17
|
maxRefinementAttempts: number;
|
|
17
18
|
stageTimeout: number;
|
|
18
19
|
llmRequestTimeout: number;
|
|
20
|
+
maxAttempts: number;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export interface LLMConfig {
|
|
@@ -90,11 +92,13 @@ export const defaultConfig = {
|
|
|
90
92
|
watchDebounce: 500,
|
|
91
93
|
watchStabilityThreshold: 1000,
|
|
92
94
|
watchPollInterval: 100,
|
|
95
|
+
maxConcurrentJobs: 3,
|
|
93
96
|
},
|
|
94
97
|
taskRunner: {
|
|
95
98
|
maxRefinementAttempts: 3,
|
|
96
99
|
stageTimeout: 300000,
|
|
97
100
|
llmRequestTimeout: 3600000,
|
|
101
|
+
maxAttempts: 3,
|
|
98
102
|
},
|
|
99
103
|
llm: {
|
|
100
104
|
defaultProvider: "openai",
|
|
@@ -184,9 +188,38 @@ function loadFromEnvironment(config: AppConfig): AppConfig {
|
|
|
184
188
|
overrides["llm"] = { maxConcurrency: parseInt(maxConcurrencyRaw, 10) };
|
|
185
189
|
}
|
|
186
190
|
|
|
191
|
+
const maxAttemptsRaw = process.env["PO_TASK_MAX_ATTEMPTS"];
|
|
192
|
+
if (maxAttemptsRaw) {
|
|
193
|
+
const maxAttempts = Number(maxAttemptsRaw);
|
|
194
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
195
|
+
throw new Error(`PO_TASK_MAX_ATTEMPTS must be an integer >= 1, got ${maxAttemptsRaw}`);
|
|
196
|
+
}
|
|
197
|
+
overrides["taskRunner"] = {
|
|
198
|
+
...((overrides["taskRunner"] as PlainObject | undefined) ?? {}),
|
|
199
|
+
maxAttempts,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
187
203
|
const shutdownTimeoutRaw = process.env["PO_SHUTDOWN_TIMEOUT"];
|
|
188
204
|
if (shutdownTimeoutRaw) {
|
|
189
|
-
overrides["orchestrator"] = {
|
|
205
|
+
overrides["orchestrator"] = {
|
|
206
|
+
...((overrides["orchestrator"] as PlainObject | undefined) ?? {}),
|
|
207
|
+
shutdownTimeout: parseInt(shutdownTimeoutRaw, 10),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const maxRunningJobsRaw = process.env["PO_MAX_RUNNING_JOBS"];
|
|
212
|
+
if (maxRunningJobsRaw !== undefined) {
|
|
213
|
+
const maxConcurrentJobs = Number(maxRunningJobsRaw);
|
|
214
|
+
if (!Number.isInteger(maxConcurrentJobs) || maxConcurrentJobs < 1) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`orchestrator.maxConcurrentJobs must be a positive integer (PO_MAX_RUNNING_JOBS), got ${maxRunningJobsRaw}`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
overrides["orchestrator"] = {
|
|
220
|
+
...((overrides["orchestrator"] as PlainObject | undefined) ?? {}),
|
|
221
|
+
maxConcurrentJobs,
|
|
222
|
+
};
|
|
190
223
|
}
|
|
191
224
|
|
|
192
225
|
const logLevel = process.env["PO_LOG_LEVEL"];
|
|
@@ -227,6 +260,12 @@ function validateConfig(config: AppConfig): void {
|
|
|
227
260
|
if (config.taskRunner.maxRefinementAttempts < 1) {
|
|
228
261
|
errors.push(`taskRunner.maxRefinementAttempts must be >= 1, got ${config.taskRunner.maxRefinementAttempts}`);
|
|
229
262
|
}
|
|
263
|
+
if (!Number.isInteger(config.taskRunner.maxAttempts) || config.taskRunner.maxAttempts < 1) {
|
|
264
|
+
errors.push(`taskRunner.maxAttempts must be an integer >= 1, got ${config.taskRunner.maxAttempts}`);
|
|
265
|
+
}
|
|
266
|
+
if (!Number.isInteger(config.orchestrator.maxConcurrentJobs) || config.orchestrator.maxConcurrentJobs < 1) {
|
|
267
|
+
errors.push(`orchestrator.maxConcurrentJobs must be a positive integer, got ${config.orchestrator.maxConcurrentJobs}`);
|
|
268
|
+
}
|
|
230
269
|
if (!(VALID_LOG_LEVELS as readonly string[]).includes(config.logging.level)) {
|
|
231
270
|
errors.push(`logging.level must be one of ${VALID_LOG_LEVELS.join(", ")}, got ${config.logging.level}`);
|
|
232
271
|
}
|
|
@@ -375,6 +414,14 @@ export function getConfig(): AppConfig {
|
|
|
375
414
|
return cachedConfig;
|
|
376
415
|
}
|
|
377
416
|
|
|
417
|
+
export function getOrchestratorConfig(): OrchestratorConfig {
|
|
418
|
+
const config = getConfig();
|
|
419
|
+
return {
|
|
420
|
+
...defaultConfig.orchestrator,
|
|
421
|
+
...(config.orchestrator ?? {}),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
378
425
|
export function getConfigValue(dotPath: string, defaultValue?: unknown): unknown {
|
|
379
426
|
const config = getConfig();
|
|
380
427
|
const parts = dotPath.split(".");
|