@ryanfw/prompt-orchestration-pipeline 1.2.7 → 1.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/src/config/__tests__/models.test.ts +31 -1
  3. package/src/config/models.ts +81 -35
  4. package/src/config/paths.ts +13 -8
  5. package/src/core/__tests__/config.test.ts +121 -0
  6. package/src/core/__tests__/job-concurrency.test.ts +554 -0
  7. package/src/core/__tests__/orchestrator.test.ts +353 -0
  8. package/src/core/__tests__/pipeline-runner.test.ts +430 -2
  9. package/src/core/__tests__/task-runner.test.ts +1 -2
  10. package/src/core/config.ts +48 -1
  11. package/src/core/job-concurrency.ts +462 -0
  12. package/src/core/orchestrator.ts +370 -57
  13. package/src/core/pipeline-runner.ts +79 -15
  14. package/src/core/status-writer.ts +4 -0
  15. package/src/core/task-runner.ts +1 -1
  16. package/src/providers/__tests__/base.test.ts +1 -1
  17. package/src/ui/client/__tests__/api.test.ts +101 -1
  18. package/src/ui/client/__tests__/job-adapter.test.ts +12 -0
  19. package/src/ui/client/__tests__/useConcurrencyStatus.test.ts +126 -0
  20. package/src/ui/client/adapters/job-adapter.ts +1 -0
  21. package/src/ui/client/api.ts +77 -7
  22. package/src/ui/client/hooks/useConcurrencyStatus.ts +102 -0
  23. package/src/ui/client/types.ts +34 -1
  24. package/src/ui/components/DAGGrid.tsx +11 -1
  25. package/src/ui/components/JobDetail.tsx +2 -1
  26. package/src/ui/components/__tests__/DAGGrid.test.tsx +92 -0
  27. package/src/ui/components/__tests__/JobDetail.test.tsx +62 -0
  28. package/src/ui/components/types.ts +2 -0
  29. package/src/ui/dist/assets/{index-SKy2shWc.js → index-BnAqY4_n.js} +336 -52
  30. package/src/ui/dist/assets/index-BnAqY4_n.js.map +1 -0
  31. package/src/ui/dist/assets/style-BKG0bHu-.css +2 -0
  32. package/src/ui/dist/index.html +2 -2
  33. package/src/ui/embedded-assets.js +6 -6
  34. package/src/ui/pages/PromptPipelineDashboard.tsx +186 -4
  35. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +272 -1
  36. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +190 -0
  37. package/src/ui/server/__tests__/index.test.ts +92 -3
  38. package/src/ui/server/__tests__/job-control-endpoints.test.ts +660 -3
  39. package/src/ui/server/endpoints/concurrency-endpoint.ts +72 -0
  40. package/src/ui/server/endpoints/job-control-endpoints.ts +248 -37
  41. package/src/ui/server/index.ts +21 -2
  42. package/src/ui/server/router.ts +2 -0
  43. package/src/ui/state/__tests__/watcher.test.ts +31 -0
  44. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +15 -0
  45. package/src/ui/state/transformers/status-transformer.ts +1 -0
  46. package/src/ui/state/types.ts +3 -0
  47. package/src/ui/state/watcher.ts +9 -1
  48. package/src/utils/__tests__/dag.test.ts +35 -0
  49. package/src/utils/dag.ts +1 -0
  50. package/src/ui/dist/assets/index-SKy2shWc.js.map +0 -1
  51. package/src/ui/dist/assets/style-DA1Ma4YS.css +0 -2
@@ -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
+ });