@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,12 +1,19 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import type { Subprocess } from "bun";
|
|
5
5
|
|
|
6
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
7
|
|
|
8
8
|
import { initPATHS, resetPATHS } from "../config-bridge-node";
|
|
9
|
-
import { handleJobRestart, handleJobStop } from "../endpoints/job-control-endpoints";
|
|
9
|
+
import { handleJobRestart, handleJobStop, handleTaskStart } from "../endpoints/job-control-endpoints";
|
|
10
|
+
import { resetConfig } from "../../../core/config";
|
|
11
|
+
import {
|
|
12
|
+
getConcurrencyRuntimePaths,
|
|
13
|
+
releaseJobSlot,
|
|
14
|
+
tryAcquireJobSlot,
|
|
15
|
+
type JobSlotLease,
|
|
16
|
+
} from "../../../core/job-concurrency";
|
|
10
17
|
import { readJobStatus } from "../../../core/status-writer";
|
|
11
18
|
|
|
12
19
|
const tempRoots: string[] = [];
|
|
@@ -34,6 +41,25 @@ async function setupJob(
|
|
|
34
41
|
return jobDir;
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
async function setupPipelineConfig(root: string, slug: string, tasks: string[]): Promise<void> {
|
|
45
|
+
const configDir = path.join(root, "pipeline-config", slug);
|
|
46
|
+
await mkdir(configDir, { recursive: true });
|
|
47
|
+
await writeFile(path.join(configDir, "pipeline.json"), JSON.stringify({ name: slug, tasks }));
|
|
48
|
+
await writeFile(path.join(root, "pipeline-config", "registry.json"), JSON.stringify({
|
|
49
|
+
pipelines: {
|
|
50
|
+
[slug]: {},
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function mockRunnerSpawn(pid = 424242) {
|
|
56
|
+
const proc = {
|
|
57
|
+
pid,
|
|
58
|
+
unref: vi.fn(),
|
|
59
|
+
} as unknown as Subprocess;
|
|
60
|
+
return vi.spyOn(Bun, "spawn").mockReturnValue(proc);
|
|
61
|
+
}
|
|
62
|
+
|
|
37
63
|
function spawnSleeper(): Subprocess {
|
|
38
64
|
const proc = Bun.spawn(["sleep", "60"], {
|
|
39
65
|
stdout: "ignore",
|
|
@@ -45,7 +71,10 @@ function spawnSleeper(): Subprocess {
|
|
|
45
71
|
}
|
|
46
72
|
|
|
47
73
|
afterEach(async () => {
|
|
74
|
+
vi.restoreAllMocks();
|
|
48
75
|
resetPATHS();
|
|
76
|
+
delete process.env["PO_MAX_RUNNING_JOBS"];
|
|
77
|
+
resetConfig();
|
|
49
78
|
for (const proc of childProcs.splice(0)) {
|
|
50
79
|
try { proc.kill(); } catch {}
|
|
51
80
|
}
|
|
@@ -201,6 +230,73 @@ describe("handleJobStop", () => {
|
|
|
201
230
|
expect(analysis.tokenUsage).toEqual([]);
|
|
202
231
|
});
|
|
203
232
|
|
|
233
|
+
it("resets restartCount on the running task that gets reset to pending", async () => {
|
|
234
|
+
const root = await makeTempRoot();
|
|
235
|
+
initPATHS(root);
|
|
236
|
+
|
|
237
|
+
const jobDir = await setupJob(root, "job-restart-count", {
|
|
238
|
+
id: "job-restart-count",
|
|
239
|
+
state: "running",
|
|
240
|
+
current: "analysis",
|
|
241
|
+
currentStage: "stage-2",
|
|
242
|
+
tasks: {
|
|
243
|
+
research: { state: "done", currentStage: null },
|
|
244
|
+
analysis: {
|
|
245
|
+
state: "running",
|
|
246
|
+
currentStage: "stage-2",
|
|
247
|
+
attempts: 1,
|
|
248
|
+
restartCount: 2,
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const req = new Request("http://localhost/api/jobs/job-restart-count/stop", { method: "POST" });
|
|
255
|
+
const res = await handleJobStop(req, "job-restart-count", root);
|
|
256
|
+
const body = await res.json() as Record<string, unknown>;
|
|
257
|
+
|
|
258
|
+
expect(res.status).toBe(202);
|
|
259
|
+
expect(body["resetTask"]).toBe("analysis");
|
|
260
|
+
|
|
261
|
+
const snapshot = await readJobStatus(jobDir);
|
|
262
|
+
const analysis = snapshot!.tasks["analysis"]!;
|
|
263
|
+
expect(analysis.state).toBe("pending");
|
|
264
|
+
expect(analysis.restartCount).toBe(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("does not modify restartCount on tasks that are not reset", async () => {
|
|
268
|
+
const root = await makeTempRoot();
|
|
269
|
+
initPATHS(root);
|
|
270
|
+
|
|
271
|
+
const jobDir = await setupJob(root, "job-restart-count-untouched", {
|
|
272
|
+
id: "job-restart-count-untouched",
|
|
273
|
+
state: "running",
|
|
274
|
+
current: "analysis",
|
|
275
|
+
currentStage: "stage-2",
|
|
276
|
+
tasks: {
|
|
277
|
+
research: { state: "done", currentStage: null, restartCount: 4 },
|
|
278
|
+
analysis: {
|
|
279
|
+
state: "running",
|
|
280
|
+
currentStage: "stage-2",
|
|
281
|
+
attempts: 1,
|
|
282
|
+
restartCount: 1,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const req = new Request("http://localhost/api/jobs/job-restart-count-untouched/stop", { method: "POST" });
|
|
289
|
+
const res = await handleJobStop(req, "job-restart-count-untouched", root);
|
|
290
|
+
const body = await res.json() as Record<string, unknown>;
|
|
291
|
+
|
|
292
|
+
expect(res.status).toBe(202);
|
|
293
|
+
expect(body["resetTask"]).toBe("analysis");
|
|
294
|
+
|
|
295
|
+
const snapshot = await readJobStatus(jobDir);
|
|
296
|
+
expect(snapshot!.tasks["research"]!.restartCount).toBe(4);
|
|
297
|
+
expect(snapshot!.tasks["analysis"]!.restartCount).toBe(0);
|
|
298
|
+
});
|
|
299
|
+
|
|
204
300
|
it("recovers a stale job by resetting the first non-terminal task after partial progress", async () => {
|
|
205
301
|
const root = await makeTempRoot();
|
|
206
302
|
initPATHS(root);
|
|
@@ -340,6 +436,8 @@ describe("handleJobRestart", () => {
|
|
|
340
436
|
files: { artifacts: [], logs: [], tmp: [] },
|
|
341
437
|
});
|
|
342
438
|
|
|
439
|
+
mockRunnerSpawn();
|
|
440
|
+
|
|
343
441
|
const req = new Request("http://localhost/api/jobs/restart-3/restart", { method: "POST" });
|
|
344
442
|
const res = await handleJobRestart(req, "restart-3", root);
|
|
345
443
|
const body = await res.json() as Record<string, unknown>;
|
|
@@ -350,6 +448,297 @@ describe("handleJobRestart", () => {
|
|
|
350
448
|
});
|
|
351
449
|
});
|
|
352
450
|
|
|
451
|
+
describe("handleTaskStart", () => {
|
|
452
|
+
it("returns 404 JOB_NOT_FOUND when job exists in neither current/ nor complete/", async () => {
|
|
453
|
+
const root = await makeTempRoot();
|
|
454
|
+
initPATHS(root);
|
|
455
|
+
|
|
456
|
+
const req = new Request("http://localhost/api/jobs/missing/tasks/research/start", { method: "POST" });
|
|
457
|
+
const res = await handleTaskStart(req, "missing", "research", root);
|
|
458
|
+
const body = await res.json() as Record<string, unknown>;
|
|
459
|
+
|
|
460
|
+
expect(res.status).toBe(404);
|
|
461
|
+
expect(body["code"]).toBe("JOB_NOT_FOUND");
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("returns 409 unsupported_lifecycle when job exists only in complete/, leaving job directory in place", async () => {
|
|
465
|
+
const root = await makeTempRoot();
|
|
466
|
+
initPATHS(root);
|
|
467
|
+
|
|
468
|
+
const completeDir = path.join(root, "pipeline-data", "complete", "task-start-complete");
|
|
469
|
+
await mkdir(completeDir, { recursive: true });
|
|
470
|
+
await writeFile(path.join(completeDir, "tasks-status.json"), JSON.stringify({
|
|
471
|
+
id: "task-start-complete",
|
|
472
|
+
state: "complete",
|
|
473
|
+
current: null,
|
|
474
|
+
currentStage: null,
|
|
475
|
+
tasks: {
|
|
476
|
+
research: { state: "done", currentStage: null },
|
|
477
|
+
},
|
|
478
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
479
|
+
}));
|
|
480
|
+
|
|
481
|
+
const req = new Request("http://localhost/api/jobs/task-start-complete/tasks/research/start", { method: "POST" });
|
|
482
|
+
const res = await handleTaskStart(req, "task-start-complete", "research", root);
|
|
483
|
+
const body = await res.json() as Record<string, unknown>;
|
|
484
|
+
|
|
485
|
+
expect(res.status).toBe(409);
|
|
486
|
+
expect(body["code"]).toBe("unsupported_lifecycle");
|
|
487
|
+
|
|
488
|
+
const stillThere = await Bun.file(path.join(completeDir, "tasks-status.json")).exists();
|
|
489
|
+
expect(stillThere).toBe(true);
|
|
490
|
+
|
|
491
|
+
const movedToCurrent = await Bun.file(path.join(root, "pipeline-data", "current", "task-start-complete", "tasks-status.json")).exists();
|
|
492
|
+
expect(movedToCurrent).toBe(false);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("returns 409 job_running when runner.pid points to a live process", async () => {
|
|
496
|
+
const root = await makeTempRoot();
|
|
497
|
+
initPATHS(root);
|
|
498
|
+
|
|
499
|
+
const proc = spawnSleeper();
|
|
500
|
+
|
|
501
|
+
await setupJob(root, "task-start-live", {
|
|
502
|
+
id: "task-start-live",
|
|
503
|
+
state: "running",
|
|
504
|
+
current: "research",
|
|
505
|
+
currentStage: "prompt",
|
|
506
|
+
tasks: {
|
|
507
|
+
research: { state: "running", currentStage: "prompt" },
|
|
508
|
+
},
|
|
509
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
510
|
+
}, proc.pid);
|
|
511
|
+
|
|
512
|
+
const req = new Request("http://localhost/api/jobs/task-start-live/tasks/research/start", { method: "POST" });
|
|
513
|
+
const res = await handleTaskStart(req, "task-start-live", "research", root);
|
|
514
|
+
const body = await res.json() as Record<string, unknown>;
|
|
515
|
+
|
|
516
|
+
expect(res.status).toBe(409);
|
|
517
|
+
expect(body["code"]).toBe("job_running");
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("returns 409 job_running when snapshot has a task in \"running\" state and PID is dead", async () => {
|
|
521
|
+
const root = await makeTempRoot();
|
|
522
|
+
initPATHS(root);
|
|
523
|
+
|
|
524
|
+
await setupJob(root, "task-start-stale", {
|
|
525
|
+
id: "task-start-stale",
|
|
526
|
+
state: "running",
|
|
527
|
+
current: "research",
|
|
528
|
+
currentStage: "prompt",
|
|
529
|
+
tasks: {
|
|
530
|
+
research: { state: "running", currentStage: "prompt" },
|
|
531
|
+
analysis: { state: "pending", currentStage: null },
|
|
532
|
+
},
|
|
533
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
534
|
+
}, 999999);
|
|
535
|
+
|
|
536
|
+
const req = new Request("http://localhost/api/jobs/task-start-stale/tasks/analysis/start", { method: "POST" });
|
|
537
|
+
const res = await handleTaskStart(req, "task-start-stale", "analysis", root);
|
|
538
|
+
const body = await res.json() as Record<string, unknown>;
|
|
539
|
+
|
|
540
|
+
expect(res.status).toBe(409);
|
|
541
|
+
expect(body["code"]).toBe("job_running");
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("returns 404 task_not_found for an unknown taskId", async () => {
|
|
545
|
+
const root = await makeTempRoot();
|
|
546
|
+
initPATHS(root);
|
|
547
|
+
|
|
548
|
+
await setupJob(root, "task-start-unknown", {
|
|
549
|
+
id: "task-start-unknown",
|
|
550
|
+
state: "pending",
|
|
551
|
+
current: null,
|
|
552
|
+
currentStage: null,
|
|
553
|
+
tasks: {
|
|
554
|
+
research: { state: "done", currentStage: null },
|
|
555
|
+
analysis: { state: "pending", currentStage: null },
|
|
556
|
+
},
|
|
557
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
const req = new Request("http://localhost/api/jobs/task-start-unknown/tasks/synthesis/start", { method: "POST" });
|
|
561
|
+
const res = await handleTaskStart(req, "task-start-unknown", "synthesis", root);
|
|
562
|
+
const body = await res.json() as Record<string, unknown>;
|
|
563
|
+
|
|
564
|
+
expect(res.status).toBe(404);
|
|
565
|
+
expect(body["code"]).toBe("task_not_found");
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("returns 422 task_not_pending when target task is \"done\"", async () => {
|
|
569
|
+
const root = await makeTempRoot();
|
|
570
|
+
initPATHS(root);
|
|
571
|
+
|
|
572
|
+
await setupJob(root, "task-start-done", {
|
|
573
|
+
id: "task-start-done",
|
|
574
|
+
state: "pending",
|
|
575
|
+
current: null,
|
|
576
|
+
currentStage: null,
|
|
577
|
+
tasks: {
|
|
578
|
+
research: { state: "done", currentStage: null },
|
|
579
|
+
analysis: { state: "pending", currentStage: null },
|
|
580
|
+
},
|
|
581
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const req = new Request("http://localhost/api/jobs/task-start-done/tasks/research/start", { method: "POST" });
|
|
585
|
+
const res = await handleTaskStart(req, "task-start-done", "research", root);
|
|
586
|
+
const body = await res.json() as Record<string, unknown>;
|
|
587
|
+
|
|
588
|
+
expect(res.status).toBe(422);
|
|
589
|
+
expect(body["code"]).toBe("task_not_pending");
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it("returns 202 and spawns runner when target task is pending and dependencies are done", async () => {
|
|
593
|
+
const root = await makeTempRoot();
|
|
594
|
+
initPATHS(root);
|
|
595
|
+
await setupPipelineConfig(root, "task-start-ok-pipeline", ["research", "analysis"]);
|
|
596
|
+
const spawnSpy = mockRunnerSpawn(12345);
|
|
597
|
+
|
|
598
|
+
const jobDir = await setupJob(root, "task-start-ok", {
|
|
599
|
+
id: "task-start-ok",
|
|
600
|
+
pipeline: "task-start-ok-pipeline",
|
|
601
|
+
state: "pending",
|
|
602
|
+
current: null,
|
|
603
|
+
currentStage: null,
|
|
604
|
+
tasks: {
|
|
605
|
+
analysis: { state: "pending", currentStage: null },
|
|
606
|
+
research: { state: "done", currentStage: null },
|
|
607
|
+
},
|
|
608
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const statusPath = path.join(jobDir, "tasks-status.json");
|
|
612
|
+
const snapshotBefore = await Bun.file(statusPath).text();
|
|
613
|
+
|
|
614
|
+
const req = new Request("http://localhost/api/jobs/task-start-ok/tasks/analysis/start", { method: "POST" });
|
|
615
|
+
const originalRunSingleTask = process.env["PO_RUN_SINGLE_TASK"];
|
|
616
|
+
process.env["PO_RUN_SINGLE_TASK"] = "true";
|
|
617
|
+
let res: Response;
|
|
618
|
+
try {
|
|
619
|
+
res = await handleTaskStart(req, "task-start-ok", "analysis", root);
|
|
620
|
+
} finally {
|
|
621
|
+
if (originalRunSingleTask === undefined) {
|
|
622
|
+
delete process.env["PO_RUN_SINGLE_TASK"];
|
|
623
|
+
} else {
|
|
624
|
+
process.env["PO_RUN_SINGLE_TASK"] = originalRunSingleTask;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const body = await res.json() as Record<string, unknown>;
|
|
628
|
+
|
|
629
|
+
expect(res.status).toBe(202);
|
|
630
|
+
expect(body).toEqual({
|
|
631
|
+
ok: true,
|
|
632
|
+
jobId: "task-start-ok",
|
|
633
|
+
taskId: "analysis",
|
|
634
|
+
action: "start",
|
|
635
|
+
lifecycle: "current",
|
|
636
|
+
spawned: true,
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
const snapshotAfter = await Bun.file(statusPath).text();
|
|
640
|
+
expect(snapshotAfter).toBe(snapshotBefore);
|
|
641
|
+
|
|
642
|
+
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
643
|
+
const spawnOptions = spawnSpy.mock.calls[0]![0] as unknown as {
|
|
644
|
+
cmd: string[];
|
|
645
|
+
env: Record<string, string | undefined>;
|
|
646
|
+
};
|
|
647
|
+
expect(spawnOptions.cmd[0]).toBe("bun");
|
|
648
|
+
expect(spawnOptions.cmd[1]).toBe("run");
|
|
649
|
+
expect(spawnOptions.cmd[2]).toContain("pipeline-runner.ts");
|
|
650
|
+
expect(spawnOptions.cmd[3]).toBe("task-start-ok");
|
|
651
|
+
expect(spawnOptions.env["PO_ROOT"]).toBe(root);
|
|
652
|
+
expect(spawnOptions.env["PO_START_FROM_TASK"]).toBe("analysis");
|
|
653
|
+
expect(spawnOptions.env["PO_RUN_SINGLE_TASK"]).toBeUndefined();
|
|
654
|
+
await expect(Bun.file(path.join(jobDir, "runner.pid")).text()).resolves.toBe("12345\n");
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("returns 409 job_running on an immediate second start after writing runner.pid", async () => {
|
|
658
|
+
const root = await makeTempRoot();
|
|
659
|
+
initPATHS(root);
|
|
660
|
+
await setupPipelineConfig(root, "task-start-duplicate-pipeline", ["research", "analysis"]);
|
|
661
|
+
const spawnSpy = mockRunnerSpawn(process.pid);
|
|
662
|
+
|
|
663
|
+
await setupJob(root, "task-start-duplicate", {
|
|
664
|
+
id: "task-start-duplicate",
|
|
665
|
+
pipeline: "task-start-duplicate-pipeline",
|
|
666
|
+
state: "pending",
|
|
667
|
+
current: null,
|
|
668
|
+
currentStage: null,
|
|
669
|
+
tasks: {
|
|
670
|
+
research: { state: "done", currentStage: null },
|
|
671
|
+
analysis: { state: "pending", currentStage: null },
|
|
672
|
+
},
|
|
673
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const firstReq = new Request("http://localhost/api/jobs/task-start-duplicate/tasks/analysis/start", { method: "POST" });
|
|
677
|
+
const firstRes = await handleTaskStart(firstReq, "task-start-duplicate", "analysis", root);
|
|
678
|
+
expect(firstRes.status).toBe(202);
|
|
679
|
+
|
|
680
|
+
const secondReq = new Request("http://localhost/api/jobs/task-start-duplicate/tasks/analysis/start", { method: "POST" });
|
|
681
|
+
const secondRes = await handleTaskStart(secondReq, "task-start-duplicate", "analysis", root);
|
|
682
|
+
const secondBody = await secondRes.json() as Record<string, unknown>;
|
|
683
|
+
|
|
684
|
+
expect(secondRes.status).toBe(409);
|
|
685
|
+
expect(secondBody["code"]).toBe("job_running");
|
|
686
|
+
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("returns 412 dependencies_not_satisfied when an earlier task is \"pending\"", async () => {
|
|
690
|
+
const root = await makeTempRoot();
|
|
691
|
+
initPATHS(root);
|
|
692
|
+
await setupPipelineConfig(root, "task-start-deps-pipeline", ["research", "analysis"]);
|
|
693
|
+
|
|
694
|
+
await setupJob(root, "task-start-deps", {
|
|
695
|
+
id: "task-start-deps",
|
|
696
|
+
pipeline: "task-start-deps-pipeline",
|
|
697
|
+
state: "pending",
|
|
698
|
+
current: null,
|
|
699
|
+
currentStage: null,
|
|
700
|
+
tasks: {
|
|
701
|
+
analysis: { state: "pending", currentStage: null },
|
|
702
|
+
research: { state: "pending", currentStage: null },
|
|
703
|
+
},
|
|
704
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const req = new Request("http://localhost/api/jobs/task-start-deps/tasks/analysis/start", { method: "POST" });
|
|
708
|
+
const res = await handleTaskStart(req, "task-start-deps", "analysis", root);
|
|
709
|
+
const body = await res.json() as Record<string, unknown>;
|
|
710
|
+
|
|
711
|
+
expect(res.status).toBe(412);
|
|
712
|
+
expect(body["code"]).toBe("dependencies_not_satisfied");
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it("returns 412 dependencies_not_satisfied when an earlier task is \"failed\"", async () => {
|
|
716
|
+
const root = await makeTempRoot();
|
|
717
|
+
initPATHS(root);
|
|
718
|
+
await setupPipelineConfig(root, "task-start-failed-deps-pipeline", ["research", "analysis"]);
|
|
719
|
+
|
|
720
|
+
await setupJob(root, "task-start-failed-deps", {
|
|
721
|
+
id: "task-start-failed-deps",
|
|
722
|
+
pipeline: "task-start-failed-deps-pipeline",
|
|
723
|
+
state: "failed",
|
|
724
|
+
current: null,
|
|
725
|
+
currentStage: null,
|
|
726
|
+
tasks: {
|
|
727
|
+
analysis: { state: "pending", currentStage: null },
|
|
728
|
+
research: { state: "failed", currentStage: null },
|
|
729
|
+
},
|
|
730
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
const req = new Request("http://localhost/api/jobs/task-start-failed-deps/tasks/analysis/start", { method: "POST" });
|
|
734
|
+
const res = await handleTaskStart(req, "task-start-failed-deps", "analysis", root);
|
|
735
|
+
const body = await res.json() as Record<string, unknown>;
|
|
736
|
+
|
|
737
|
+
expect(res.status).toBe(412);
|
|
738
|
+
expect(body["code"]).toBe("dependencies_not_satisfied");
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
353
742
|
describe("stop → restart integration", () => {
|
|
354
743
|
it("stop moves running to pending, then restart proceeds without 409", async () => {
|
|
355
744
|
const root = await makeTempRoot();
|
|
@@ -379,6 +768,7 @@ describe("stop → restart integration", () => {
|
|
|
379
768
|
expect(snapshot!.tasks["research"]!.state).toBe("pending");
|
|
380
769
|
|
|
381
770
|
// Restart should now succeed (no 409 deadlock)
|
|
771
|
+
mockRunnerSpawn();
|
|
382
772
|
const restartReq = new Request("http://localhost/api/jobs/integ-1/restart", { method: "POST" });
|
|
383
773
|
const restartRes = await handleJobRestart(restartReq, "integ-1", root);
|
|
384
774
|
const restartBody = await restartRes.json() as Record<string, unknown>;
|
|
@@ -405,6 +795,8 @@ describe("stop → restart integration", () => {
|
|
|
405
795
|
files: { artifacts: [], logs: [], tmp: [] },
|
|
406
796
|
}, 999999);
|
|
407
797
|
|
|
798
|
+
mockRunnerSpawn();
|
|
799
|
+
|
|
408
800
|
const req = new Request("http://localhost/api/jobs/integ-2/restart", { method: "POST" });
|
|
409
801
|
const res = await handleJobRestart(req, "integ-2", root);
|
|
410
802
|
const body = await res.json() as Record<string, unknown>;
|
|
@@ -464,6 +856,94 @@ describe("stop → restart integration", () => {
|
|
|
464
856
|
expect(body["code"]).toBe("job_running");
|
|
465
857
|
});
|
|
466
858
|
|
|
859
|
+
it("restart returns 409 job_running while runner is mid-retry-sleep (alive PID, task running, restartCount > 0)", async () => {
|
|
860
|
+
const root = await makeTempRoot();
|
|
861
|
+
initPATHS(root);
|
|
862
|
+
|
|
863
|
+
// Simulate the runner being asleep between automatic retries: the runner
|
|
864
|
+
// process is alive, the task it owns is still in "running" state, and
|
|
865
|
+
// restartCount has already been incremented by the prior failed attempt.
|
|
866
|
+
const proc = spawnSleeper();
|
|
867
|
+
|
|
868
|
+
await setupJob(root, "retry-sleep-restart", {
|
|
869
|
+
id: "retry-sleep-restart",
|
|
870
|
+
state: "running",
|
|
871
|
+
current: "research",
|
|
872
|
+
currentStage: null,
|
|
873
|
+
tasks: {
|
|
874
|
+
research: {
|
|
875
|
+
state: "running",
|
|
876
|
+
currentStage: null,
|
|
877
|
+
attempts: 2,
|
|
878
|
+
restartCount: 1,
|
|
879
|
+
},
|
|
880
|
+
},
|
|
881
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
882
|
+
}, proc.pid);
|
|
883
|
+
|
|
884
|
+
const req = new Request("http://localhost/api/jobs/retry-sleep-restart/restart", { method: "POST" });
|
|
885
|
+
const res = await handleJobRestart(req, "retry-sleep-restart", root);
|
|
886
|
+
const body = await res.json() as Record<string, unknown>;
|
|
887
|
+
|
|
888
|
+
// The PID-liveness check fires before any task-state inspection, so the
|
|
889
|
+
// in-flight Bun.sleep cannot race with a manual restart.
|
|
890
|
+
expect(res.status).toBe(409);
|
|
891
|
+
expect(body["code"]).toBe("job_running");
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it("stop terminates a runner mid-retry-sleep within KILL_GRACE_MS + KILL_POLL_MS via SIGTERM", async () => {
|
|
895
|
+
const root = await makeTempRoot();
|
|
896
|
+
initPATHS(root);
|
|
897
|
+
|
|
898
|
+
// Same scenario as above: runner alive, task running, retry counter set.
|
|
899
|
+
// The stop path must signal the runner via the existing PID/SIGTERM contract.
|
|
900
|
+
const proc = spawnSleeper();
|
|
901
|
+
const pid = proc.pid;
|
|
902
|
+
|
|
903
|
+
const jobDir = await setupJob(root, "retry-sleep-stop", {
|
|
904
|
+
id: "retry-sleep-stop",
|
|
905
|
+
state: "running",
|
|
906
|
+
current: "research",
|
|
907
|
+
currentStage: null,
|
|
908
|
+
tasks: {
|
|
909
|
+
research: {
|
|
910
|
+
state: "running",
|
|
911
|
+
currentStage: null,
|
|
912
|
+
attempts: 2,
|
|
913
|
+
restartCount: 1,
|
|
914
|
+
},
|
|
915
|
+
},
|
|
916
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
917
|
+
}, pid);
|
|
918
|
+
|
|
919
|
+
// KILL_GRACE_MS = 1500, KILL_POLL_MS = 100 in job-control-endpoints.ts.
|
|
920
|
+
// Allow a generous margin for filesystem and signal-delivery jitter while
|
|
921
|
+
// still asserting the bound holds.
|
|
922
|
+
const KILL_BUDGET_MS = 1500 + 100 + 500;
|
|
923
|
+
|
|
924
|
+
const start = Date.now();
|
|
925
|
+
const req = new Request("http://localhost/api/jobs/retry-sleep-stop/stop", { method: "POST" });
|
|
926
|
+
const res = await handleJobStop(req, "retry-sleep-stop", root);
|
|
927
|
+
const elapsed = Date.now() - start;
|
|
928
|
+
const body = await res.json() as Record<string, unknown>;
|
|
929
|
+
|
|
930
|
+
expect(res.status).toBe(202);
|
|
931
|
+
expect(body["ok"]).toBe(true);
|
|
932
|
+
expect(body["stopped"]).toBe(true);
|
|
933
|
+
expect(body["signal"]).toBe("SIGTERM");
|
|
934
|
+
expect(body["resetTask"]).toBe("research");
|
|
935
|
+
expect(elapsed).toBeLessThan(KILL_BUDGET_MS);
|
|
936
|
+
|
|
937
|
+
// PID file is removed by cleanupRunnerPid in handleJobStop.
|
|
938
|
+
const pidFileExists = await Bun.file(path.join(jobDir, "runner.pid")).exists();
|
|
939
|
+
expect(pidFileExists).toBe(false);
|
|
940
|
+
|
|
941
|
+
// Reset restored the task to pending and zeroed restartCount (criterion 10).
|
|
942
|
+
const snapshot = await readJobStatus(jobDir);
|
|
943
|
+
expect(snapshot!.tasks["research"]!.state).toBe("pending");
|
|
944
|
+
expect(snapshot!.tasks["research"]!.restartCount).toBe(0);
|
|
945
|
+
});
|
|
946
|
+
|
|
467
947
|
it("full cycle: stop clears running task, restart resets all tasks to pending", async () => {
|
|
468
948
|
const root = await makeTempRoot();
|
|
469
949
|
initPATHS(root);
|
|
@@ -498,6 +978,7 @@ describe("stop → restart integration", () => {
|
|
|
498
978
|
expect(afterStop!.tasks["synthesis"]!.state).toBe("pending");
|
|
499
979
|
|
|
500
980
|
// Restart with clean-slate resets everything
|
|
981
|
+
mockRunnerSpawn();
|
|
501
982
|
const restartReq = new Request("http://localhost/api/jobs/integ-5/restart", { method: "POST" });
|
|
502
983
|
const restartRes = await handleJobRestart(restartReq, "integ-5", root);
|
|
503
984
|
const restartBody = await restartRes.json() as Record<string, unknown>;
|
|
@@ -512,3 +993,179 @@ describe("stop → restart integration", () => {
|
|
|
512
993
|
expect(afterRestart!.tasks["synthesis"]!.state).toBe("pending");
|
|
513
994
|
});
|
|
514
995
|
});
|
|
996
|
+
|
|
997
|
+
describe("concurrency enforcement", () => {
|
|
998
|
+
function setMaxConcurrentJobs(value: number): void {
|
|
999
|
+
process.env["PO_MAX_RUNNING_JOBS"] = String(value);
|
|
1000
|
+
resetConfig();
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
async function readLeaseFile(root: string, jobId: string): Promise<JobSlotLease> {
|
|
1004
|
+
const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
|
|
1005
|
+
const raw = await readFile(path.join(runningJobsDir, `${jobId}.json`), "utf-8");
|
|
1006
|
+
return JSON.parse(raw) as JobSlotLease;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
it("returns 409 concurrency_limit_reached on restart when capacity is full", async () => {
|
|
1010
|
+
const root = await makeTempRoot();
|
|
1011
|
+
initPATHS(root);
|
|
1012
|
+
setMaxConcurrentJobs(1);
|
|
1013
|
+
|
|
1014
|
+
await setupJob(root, "blocking-job", {
|
|
1015
|
+
id: "blocking-job",
|
|
1016
|
+
state: "pending",
|
|
1017
|
+
current: null,
|
|
1018
|
+
currentStage: null,
|
|
1019
|
+
tasks: {},
|
|
1020
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
1021
|
+
});
|
|
1022
|
+
const blocking = await tryAcquireJobSlot({
|
|
1023
|
+
dataDir: path.join(root, "pipeline-data"),
|
|
1024
|
+
jobId: "blocking-job",
|
|
1025
|
+
maxConcurrentJobs: 1,
|
|
1026
|
+
source: "orchestrator",
|
|
1027
|
+
pid: process.pid,
|
|
1028
|
+
});
|
|
1029
|
+
expect(blocking.ok).toBe(true);
|
|
1030
|
+
|
|
1031
|
+
await setupJob(root, "restart-full", {
|
|
1032
|
+
id: "restart-full",
|
|
1033
|
+
state: "pending",
|
|
1034
|
+
current: null,
|
|
1035
|
+
currentStage: null,
|
|
1036
|
+
tasks: {
|
|
1037
|
+
research: { state: "done", currentStage: null },
|
|
1038
|
+
analysis: { state: "pending", currentStage: null },
|
|
1039
|
+
},
|
|
1040
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
const spawnSpy = vi.spyOn(Bun, "spawn");
|
|
1044
|
+
const req = new Request("http://localhost/api/jobs/restart-full/restart", { method: "POST" });
|
|
1045
|
+
const res = await handleJobRestart(req, "restart-full", root);
|
|
1046
|
+
const body = await res.json() as Record<string, unknown>;
|
|
1047
|
+
|
|
1048
|
+
expect(res.status).toBe(409);
|
|
1049
|
+
expect(body["code"]).toBe("concurrency_limit_reached");
|
|
1050
|
+
expect(body["ok"]).toBe(false);
|
|
1051
|
+
expect(spawnSpy).not.toHaveBeenCalled();
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it("returns 409 concurrency_limit_reached on task-start when capacity is full", async () => {
|
|
1055
|
+
const root = await makeTempRoot();
|
|
1056
|
+
initPATHS(root);
|
|
1057
|
+
await setupPipelineConfig(root, "ts-full-pipeline", ["research", "analysis"]);
|
|
1058
|
+
setMaxConcurrentJobs(1);
|
|
1059
|
+
|
|
1060
|
+
await setupJob(root, "blocking-job", {
|
|
1061
|
+
id: "blocking-job",
|
|
1062
|
+
state: "pending",
|
|
1063
|
+
current: null,
|
|
1064
|
+
currentStage: null,
|
|
1065
|
+
tasks: {},
|
|
1066
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
1067
|
+
});
|
|
1068
|
+
const blocking = await tryAcquireJobSlot({
|
|
1069
|
+
dataDir: path.join(root, "pipeline-data"),
|
|
1070
|
+
jobId: "blocking-job",
|
|
1071
|
+
maxConcurrentJobs: 1,
|
|
1072
|
+
source: "orchestrator",
|
|
1073
|
+
pid: process.pid,
|
|
1074
|
+
});
|
|
1075
|
+
expect(blocking.ok).toBe(true);
|
|
1076
|
+
|
|
1077
|
+
await setupJob(root, "task-start-full", {
|
|
1078
|
+
id: "task-start-full",
|
|
1079
|
+
pipeline: "ts-full-pipeline",
|
|
1080
|
+
state: "pending",
|
|
1081
|
+
current: null,
|
|
1082
|
+
currentStage: null,
|
|
1083
|
+
tasks: {
|
|
1084
|
+
research: { state: "done", currentStage: null },
|
|
1085
|
+
analysis: { state: "pending", currentStage: null },
|
|
1086
|
+
},
|
|
1087
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
const spawnSpy = vi.spyOn(Bun, "spawn");
|
|
1091
|
+
const req = new Request("http://localhost/api/jobs/task-start-full/tasks/analysis/start", { method: "POST" });
|
|
1092
|
+
const res = await handleTaskStart(req, "task-start-full", "analysis", root);
|
|
1093
|
+
const body = await res.json() as Record<string, unknown>;
|
|
1094
|
+
|
|
1095
|
+
expect(res.status).toBe(409);
|
|
1096
|
+
expect(body["code"]).toBe("concurrency_limit_reached");
|
|
1097
|
+
expect(body["ok"]).toBe(false);
|
|
1098
|
+
expect(spawnSpy).not.toHaveBeenCalled();
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
it("releases the slot when the runner spawn throws after acquisition", async () => {
|
|
1102
|
+
const root = await makeTempRoot();
|
|
1103
|
+
initPATHS(root);
|
|
1104
|
+
await setupPipelineConfig(root, "spawn-throw-pipeline", ["research", "analysis"]);
|
|
1105
|
+
setMaxConcurrentJobs(2);
|
|
1106
|
+
|
|
1107
|
+
await setupJob(root, "spawn-throw", {
|
|
1108
|
+
id: "spawn-throw",
|
|
1109
|
+
pipeline: "spawn-throw-pipeline",
|
|
1110
|
+
state: "pending",
|
|
1111
|
+
current: null,
|
|
1112
|
+
currentStage: null,
|
|
1113
|
+
tasks: {
|
|
1114
|
+
research: { state: "done", currentStage: null },
|
|
1115
|
+
analysis: { state: "pending", currentStage: null },
|
|
1116
|
+
},
|
|
1117
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
vi.spyOn(Bun, "spawn").mockImplementation(() => {
|
|
1121
|
+
throw new Error("simulated spawn failure");
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
const req = new Request("http://localhost/api/jobs/spawn-throw/tasks/analysis/start", { method: "POST" });
|
|
1125
|
+
await expect(handleTaskStart(req, "spawn-throw", "analysis", root)).rejects.toThrow(/spawn failure/);
|
|
1126
|
+
|
|
1127
|
+
const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
|
|
1128
|
+
const leasePath = path.join(runningJobsDir, "spawn-throw.json");
|
|
1129
|
+
await expect(readFile(leasePath, "utf-8")).rejects.toMatchObject({ code: "ENOENT" });
|
|
1130
|
+
|
|
1131
|
+
// Slot is freed: a fresh acquisition for the same job succeeds.
|
|
1132
|
+
const reacquired = await tryAcquireJobSlot({
|
|
1133
|
+
dataDir: path.join(root, "pipeline-data"),
|
|
1134
|
+
jobId: "spawn-throw",
|
|
1135
|
+
maxConcurrentJobs: 2,
|
|
1136
|
+
source: "orchestrator",
|
|
1137
|
+
});
|
|
1138
|
+
expect(reacquired.ok).toBe(true);
|
|
1139
|
+
await releaseJobSlot(path.join(root, "pipeline-data"), "spawn-throw");
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
it("records the spawned PID in the lease via updateJobSlotPid on successful task-start", async () => {
|
|
1143
|
+
const root = await makeTempRoot();
|
|
1144
|
+
initPATHS(root);
|
|
1145
|
+
await setupPipelineConfig(root, "lease-pid-pipeline", ["research", "analysis"]);
|
|
1146
|
+
setMaxConcurrentJobs(2);
|
|
1147
|
+
mockRunnerSpawn(54321);
|
|
1148
|
+
|
|
1149
|
+
await setupJob(root, "lease-pid", {
|
|
1150
|
+
id: "lease-pid",
|
|
1151
|
+
pipeline: "lease-pid-pipeline",
|
|
1152
|
+
state: "pending",
|
|
1153
|
+
current: null,
|
|
1154
|
+
currentStage: null,
|
|
1155
|
+
tasks: {
|
|
1156
|
+
research: { state: "done", currentStage: null },
|
|
1157
|
+
analysis: { state: "pending", currentStage: null },
|
|
1158
|
+
},
|
|
1159
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
const req = new Request("http://localhost/api/jobs/lease-pid/tasks/analysis/start", { method: "POST" });
|
|
1163
|
+
const res = await handleTaskStart(req, "lease-pid", "analysis", root);
|
|
1164
|
+
expect(res.status).toBe(202);
|
|
1165
|
+
|
|
1166
|
+
const lease = await readLeaseFile(root, "lease-pid");
|
|
1167
|
+
expect(lease.pid).toBe(54321);
|
|
1168
|
+
expect(lease.source).toBe("task-start");
|
|
1169
|
+
expect(lease.jobId).toBe("lease-pid");
|
|
1170
|
+
});
|
|
1171
|
+
});
|