@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.4

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 (206) hide show
  1. package/docs/http-api.md +80 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +443 -32
  4. package/src/api/index.ts +108 -127
  5. package/src/cli/__tests__/analyze-task.test.ts +40 -7
  6. package/src/cli/__tests__/index.test.ts +337 -17
  7. package/src/cli/__tests__/types.test.ts +0 -18
  8. package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
  9. package/src/cli/analyze-task.ts +3 -14
  10. package/src/cli/index.ts +73 -61
  11. package/src/cli/types.ts +0 -13
  12. package/src/cli/update-pipeline-json.ts +3 -14
  13. package/src/config/__tests__/paths.test.ts +46 -1
  14. package/src/config/__tests__/pipeline-registry.test.ts +767 -0
  15. package/src/config/__tests__/sse-events.test.ts +470 -0
  16. package/src/config/__tests__/statuses.test.ts +41 -0
  17. package/src/config/paths.ts +11 -2
  18. package/src/config/pipeline-registry.ts +258 -0
  19. package/src/config/sse-events.ts +122 -0
  20. package/src/config/statuses.ts +20 -1
  21. package/src/core/__tests__/agent-step.test.ts +115 -0
  22. package/src/core/__tests__/config.test.ts +545 -105
  23. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  24. package/src/core/__tests__/job-concurrency.test.ts +260 -0
  25. package/src/core/__tests__/job-submission.test.ts +396 -0
  26. package/src/core/__tests__/job-view.test.ts +1341 -0
  27. package/src/core/__tests__/json-file.test.ts +111 -0
  28. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  29. package/src/core/__tests__/logger.test.ts +22 -0
  30. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  31. package/src/core/__tests__/orchestrator.test.ts +615 -2
  32. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  33. package/src/core/__tests__/redact.test.ts +153 -0
  34. package/src/core/__tests__/runner-liveness.test.ts +910 -0
  35. package/src/core/__tests__/seed-naming.test.ts +57 -0
  36. package/src/core/__tests__/single-derivation.test.ts +159 -0
  37. package/src/core/__tests__/task-runner.test.ts +594 -3
  38. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  39. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  40. package/src/core/agent-step.ts +4 -1
  41. package/src/core/agent-types.ts +5 -4
  42. package/src/core/config.ts +134 -222
  43. package/src/core/control.ts +3 -0
  44. package/src/core/job-concurrency.ts +56 -20
  45. package/src/core/job-submission.ts +133 -0
  46. package/src/core/job-view.ts +473 -0
  47. package/src/core/json-file.ts +45 -0
  48. package/src/core/lifecycle-policy.ts +23 -8
  49. package/src/core/logger.ts +0 -29
  50. package/src/core/orchestrator.ts +200 -74
  51. package/src/core/pipeline-runner.ts +85 -53
  52. package/src/core/redact.ts +40 -0
  53. package/src/core/runner-liveness.ts +280 -0
  54. package/src/core/seed-naming.ts +9 -0
  55. package/src/core/status-writer.ts +27 -33
  56. package/src/core/task-runner.ts +356 -319
  57. package/src/core/task-telemetry.ts +107 -0
  58. package/src/harness/__tests__/subprocess.test.ts +112 -1
  59. package/src/harness/subprocess.ts +55 -16
  60. package/src/llm/__tests__/index.test.ts +684 -33
  61. package/src/llm/index.ts +310 -67
  62. package/src/providers/__tests__/alibaba.test.ts +67 -6
  63. package/src/providers/__tests__/anthropic.test.ts +35 -14
  64. package/src/providers/__tests__/base.test.ts +62 -0
  65. package/src/providers/__tests__/claude-code.test.ts +99 -14
  66. package/src/providers/__tests__/deepseek.test.ts +16 -6
  67. package/src/providers/__tests__/gemini.test.ts +47 -25
  68. package/src/providers/__tests__/moonshot.test.ts +27 -0
  69. package/src/providers/__tests__/openai.test.ts +262 -74
  70. package/src/providers/__tests__/opencode.test.ts +77 -0
  71. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  72. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  73. package/src/providers/__tests__/types.test.ts +85 -0
  74. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  75. package/src/providers/__tests__/zhipu.test.ts +27 -14
  76. package/src/providers/alibaba.ts +20 -39
  77. package/src/providers/anthropic.ts +23 -11
  78. package/src/providers/base.ts +19 -0
  79. package/src/providers/claude-code.ts +27 -18
  80. package/src/providers/deepseek.ts +9 -28
  81. package/src/providers/gemini.ts +20 -58
  82. package/src/providers/moonshot.ts +15 -11
  83. package/src/providers/openai.ts +79 -61
  84. package/src/providers/opencode.ts +16 -33
  85. package/src/providers/stream-accumulator.ts +27 -21
  86. package/src/providers/types.ts +29 -4
  87. package/src/providers/zhipu.ts +15 -44
  88. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  89. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  90. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  91. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  92. package/src/task-analysis/__tests__/types.test.ts +63 -130
  93. package/src/task-analysis/analyzer.ts +91 -0
  94. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  95. package/src/task-analysis/index.ts +2 -36
  96. package/src/task-analysis/repository.ts +45 -0
  97. package/src/task-analysis/types.ts +42 -58
  98. package/src/ui/client/__tests__/api.test.ts +143 -1
  99. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  100. package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
  101. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  102. package/src/ui/client/__tests__/types.test.ts +66 -3
  103. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  104. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  105. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  106. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  107. package/src/ui/client/adapters/job-adapter.ts +41 -16
  108. package/src/ui/client/api.ts +38 -15
  109. package/src/ui/client/bootstrap.ts +19 -14
  110. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  111. package/src/ui/client/hooks/useJobList.ts +26 -31
  112. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  113. package/src/ui/client/load-state.ts +20 -0
  114. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  115. package/src/ui/client/reducers/job-events.ts +137 -0
  116. package/src/ui/client/types.ts +16 -20
  117. package/src/ui/components/DAGGrid.tsx +6 -1
  118. package/src/ui/components/JobDetail.tsx +12 -4
  119. package/src/ui/components/JobTable.tsx +41 -13
  120. package/src/ui/components/StageTimeline.tsx +8 -20
  121. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  122. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  123. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  124. package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
  125. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  126. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  127. package/src/ui/components/types.ts +35 -15
  128. package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
  129. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  130. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  131. package/src/ui/dist/index.html +2 -2
  132. package/src/ui/pages/PipelineDetail.tsx +60 -4
  133. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  134. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  135. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  136. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  137. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  138. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  139. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  140. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  141. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  142. package/src/ui/server/__tests__/index.test.ts +63 -0
  143. package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
  144. package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
  145. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  146. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  147. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  148. package/src/ui/server/__tests__/router.test.ts +104 -0
  149. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  150. package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
  151. package/src/ui/server/config-bridge-node.ts +8 -26
  152. package/src/ui/server/config-bridge.ts +3 -4
  153. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  154. package/src/ui/server/embedded-assets.ts +13 -2
  155. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  156. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  157. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  158. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  159. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  160. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  161. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  162. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  163. package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
  164. package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
  165. package/src/ui/server/endpoints/job-endpoints.ts +7 -0
  166. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  167. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  168. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  169. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  170. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  171. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  172. package/src/ui/server/index.ts +19 -14
  173. package/src/ui/server/job-reader.ts +14 -2
  174. package/src/ui/server/router.ts +33 -10
  175. package/src/ui/server/sse-broadcast.ts +14 -3
  176. package/src/ui/server/sse-enhancer.ts +12 -2
  177. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  178. package/src/ui/state/__tests__/snapshot.test.ts +120 -14
  179. package/src/ui/state/__tests__/types.test.ts +104 -5
  180. package/src/ui/state/snapshot.ts +2 -2
  181. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
  182. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
  183. package/src/ui/state/transformers/list-transformer.ts +13 -3
  184. package/src/ui/state/transformers/status-transformer.ts +36 -170
  185. package/src/ui/state/types.ts +15 -48
  186. package/src/utils/__tests__/path-containment.test.ts +160 -0
  187. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  188. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  189. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  190. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  191. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  192. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  193. package/src/task-analysis/__tests__/index.test.ts +0 -143
  194. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  195. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  196. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  197. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  198. package/src/task-analysis/extractors/artifacts.ts +0 -143
  199. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  200. package/src/task-analysis/extractors/stages.ts +0 -45
  201. package/src/task-analysis/parser.ts +0 -20
  202. package/src/task-analysis/utils/ast.ts +0 -45
  203. package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
  204. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  205. package/src/ui/embedded-assets.js +0 -12
  206. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
@@ -7,11 +7,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
7
7
 
8
8
  import { initPATHS, resetPATHS } from "../config-bridge-node";
9
9
  import {
10
- classifyStaleRunningStatus,
11
10
  handleJobRestart,
12
11
  handleJobStop,
13
12
  handleTaskStart,
14
13
  } from "../endpoints/job-control-endpoints";
14
+ import { classifyStaleRunningStatus } from "../../../core/runner-liveness";
15
15
  import { resetConfig } from "../../../core/config";
16
16
  import {
17
17
  getConcurrencyRuntimePaths,
@@ -56,8 +56,9 @@ async function setupPipelineConfig(
56
56
  await mkdir(configDir, { recursive: true });
57
57
  await writeFile(path.join(configDir, "pipeline.json"), JSON.stringify({ name: slug, tasks }));
58
58
  await writeFile(path.join(root, "pipeline-config", "registry.json"), JSON.stringify({
59
+ version: 1,
59
60
  pipelines: {
60
- [slug]: {},
61
+ [slug]: { name: slug, description: "test pipeline" },
61
62
  },
62
63
  }));
63
64
  }
@@ -122,6 +123,8 @@ afterEach(async () => {
122
123
  vi.restoreAllMocks();
123
124
  resetPATHS();
124
125
  delete process.env["PO_MAX_RUNNING_JOBS"];
126
+ delete process.env["PO_STALE_RUNNING_GRACE_MS"];
127
+ delete process.env["PO_STALE_LEASE_TIMEOUT_MS"];
125
128
  resetConfig();
126
129
  for (const proc of childProcs.splice(0)) {
127
130
  try { proc.kill(); } catch {}
@@ -514,6 +517,120 @@ describe("handleJobStop", () => {
514
517
  });
515
518
  });
516
519
 
520
+ describe("handleJobStop slot release (AC-1, AC-9)", () => {
521
+ it("releases the active slot lease after stopping (AC-1)", async () => {
522
+ const root = await makeTempRoot();
523
+ initPATHS(root);
524
+
525
+ await setupJob(root, "stop-release-active", {
526
+ id: "stop-release-active",
527
+ state: "running",
528
+ current: "research",
529
+ currentStage: null,
530
+ tasks: { research: { state: "running", currentStage: null } },
531
+ files: { artifacts: [], logs: [], tmp: [] },
532
+ }, 999999);
533
+
534
+ const acquired = await tryAcquireJobSlot({
535
+ dataDir: path.join(root, "pipeline-data"),
536
+ jobId: "stop-release-active",
537
+ maxConcurrentJobs: 2,
538
+ source: "task-start",
539
+ pid: 999999,
540
+ });
541
+ expect(acquired.ok).toBe(true);
542
+
543
+ const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
544
+ const leasePath = path.join(runningJobsDir, "stop-release-active.json");
545
+ expect(await Bun.file(leasePath).exists()).toBe(true);
546
+
547
+ const req = new Request("http://localhost/api/jobs/stop-release-active/stop", { method: "POST" });
548
+ const res = await handleJobStop(req, "stop-release-active", root);
549
+ const body = await res.json() as Record<string, unknown>;
550
+
551
+ expect(res.status).toBe(202);
552
+ expect(body["ok"]).toBe(true);
553
+ expect(body["stopped"]).toBe(true);
554
+
555
+ expect(await Bun.file(leasePath).exists()).toBe(false);
556
+ });
557
+
558
+ it("does not throw and returns 202 when no lease exists (AC-9)", async () => {
559
+ const root = await makeTempRoot();
560
+ initPATHS(root);
561
+
562
+ await setupJob(root, "stop-release-no-lease", {
563
+ id: "stop-release-no-lease",
564
+ state: "running",
565
+ current: "research",
566
+ currentStage: null,
567
+ tasks: { research: { state: "running", currentStage: null } },
568
+ files: { artifacts: [], logs: [], tmp: [] },
569
+ });
570
+
571
+ const req = new Request("http://localhost/api/jobs/stop-release-no-lease/stop", { method: "POST" });
572
+ const res = await handleJobStop(req, "stop-release-no-lease", root);
573
+ const body = await res.json() as Record<string, unknown>;
574
+
575
+ expect(res.status).toBe(202);
576
+ expect(body["ok"]).toBe(true);
577
+ expect(body["stopped"]).toBe(false);
578
+ });
579
+
580
+ it("does not throw and returns 202 when stopping a complete/ job (AC-9)", async () => {
581
+ const root = await makeTempRoot();
582
+ initPATHS(root);
583
+
584
+ const completeDir = path.join(root, "pipeline-data", "complete", "stop-release-complete");
585
+ await mkdir(completeDir, { recursive: true });
586
+ await writeFile(path.join(completeDir, "tasks-status.json"), JSON.stringify({
587
+ id: "stop-release-complete",
588
+ state: "complete",
589
+ current: null,
590
+ currentStage: null,
591
+ tasks: { research: { state: "done", currentStage: null } },
592
+ files: { artifacts: [], logs: [], tmp: [] },
593
+ }));
594
+
595
+ const req = new Request("http://localhost/api/jobs/stop-release-complete/stop", { method: "POST" });
596
+ const res = await handleJobStop(req, "stop-release-complete", root);
597
+ const body = await res.json() as Record<string, unknown>;
598
+
599
+ expect(res.status).toBe(202);
600
+ expect(body["ok"]).toBe(true);
601
+ });
602
+
603
+ it("still releases the slot when the status is corrupt (AC-1)", async () => {
604
+ const root = await makeTempRoot();
605
+ initPATHS(root);
606
+
607
+ const jobDir = path.join(root, "pipeline-data", "current", "stop-release-corrupt");
608
+ await mkdir(jobDir, { recursive: true });
609
+ await writeFile(path.join(jobDir, "tasks-status.json"), "not-valid-json{{{");
610
+
611
+ const acquired = await tryAcquireJobSlot({
612
+ dataDir: path.join(root, "pipeline-data"),
613
+ jobId: "stop-release-corrupt",
614
+ maxConcurrentJobs: 2,
615
+ source: "task-start",
616
+ });
617
+ expect(acquired.ok).toBe(true);
618
+
619
+ const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
620
+ const leasePath = path.join(runningJobsDir, "stop-release-corrupt.json");
621
+ expect(await Bun.file(leasePath).exists()).toBe(true);
622
+
623
+ const req = new Request("http://localhost/api/jobs/stop-release-corrupt/stop", { method: "POST" });
624
+ const res = await handleJobStop(req, "stop-release-corrupt", root);
625
+ const body = await res.json() as Record<string, unknown>;
626
+
627
+ expect(res.status).toBe(409);
628
+ expect(body["code"]).toBe("STATUS_CORRUPT");
629
+
630
+ expect(await Bun.file(leasePath).exists()).toBe(false);
631
+ });
632
+ });
633
+
517
634
  describe("handleJobRestart", () => {
518
635
  it("returns 409 when runner PID is alive", async () => {
519
636
  const root = await makeTempRoot();
@@ -566,6 +683,33 @@ describe("handleJobRestart", () => {
566
683
  expect(body["code"]).toBe("job_running");
567
684
  });
568
685
 
686
+ it("uses configured staleRunningGraceMs when deciding whether restart can recover a running task", async () => {
687
+ const root = await makeTempRoot();
688
+ initPATHS(root);
689
+ process.env["PO_STALE_RUNNING_GRACE_MS"] = "60000";
690
+ resetConfig();
691
+
692
+ vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-04-01T10:00:45.000Z"));
693
+ await setupJob(root, "restart-configured-grace", {
694
+ id: "restart-configured-grace",
695
+ state: "running",
696
+ current: "research",
697
+ currentStage: "prompt",
698
+ lastUpdated: "2026-04-01T10:00:00.000Z",
699
+ tasks: {
700
+ research: { state: "running", currentStage: "prompt" },
701
+ },
702
+ files: { artifacts: [], logs: [], tmp: [] },
703
+ }, 999999);
704
+
705
+ const req = new Request("http://localhost/api/jobs/restart-configured-grace/restart", { method: "POST" });
706
+ const res = await handleJobRestart(req, "restart-configured-grace", root);
707
+ const body = await res.json() as Record<string, unknown>;
708
+
709
+ expect(res.status).toBe(409);
710
+ expect(body["code"]).toBe("job_running");
711
+ });
712
+
569
713
  it("proceeds when task-derived running status is stale and the runner is gone", async () => {
570
714
  const root = await makeTempRoot();
571
715
  initPATHS(root);
@@ -813,6 +957,265 @@ describe("handleJobRestart", () => {
813
957
  });
814
958
  });
815
959
 
960
+ describe("handleJobRestart dependency gate (AC-5 restart, AC-8, AC-9)", () => {
961
+ it("returns 412 and leaves status/lease untouched when a from-task predecessor is pending", async () => {
962
+ const root = await makeTempRoot();
963
+ initPATHS(root);
964
+ await setupPipelineConfig(root, "restart-deps-pending-pipeline", ["research", "analysis"]);
965
+
966
+ const statusObj = {
967
+ id: "restart-deps-pending",
968
+ pipeline: "restart-deps-pending-pipeline",
969
+ state: "failed",
970
+ current: "analysis",
971
+ currentStage: null,
972
+ lastUpdated: "2026-04-01T10:00:00.000Z",
973
+ tasks: {
974
+ research: { state: "pending", currentStage: null },
975
+ analysis: { state: "pending", currentStage: null },
976
+ },
977
+ files: { artifacts: [], logs: [], tmp: [] },
978
+ };
979
+ const jobDir = await setupJob(root, "restart-deps-pending", statusObj);
980
+
981
+ const statusPath = path.join(jobDir, "tasks-status.json");
982
+ const snapshotBefore = await Bun.file(statusPath).text();
983
+ const spawnSpy = vi.spyOn(Bun, "spawn");
984
+
985
+ const req = new Request("http://localhost/api/jobs/restart-deps-pending/restart", {
986
+ method: "POST",
987
+ body: JSON.stringify({ fromTask: "analysis" }),
988
+ });
989
+ const res = await handleJobRestart(req, "restart-deps-pending", root);
990
+ const body = await res.json() as Record<string, unknown>;
991
+
992
+ expect(res.status).toBe(412);
993
+ expect(body["code"]).toBe("dependencies_not_satisfied");
994
+ expect(body["message"]).toContain("research");
995
+
996
+ expect(await Bun.file(statusPath).text()).toBe(snapshotBefore);
997
+ expect(spawnSpy).not.toHaveBeenCalled();
998
+
999
+ const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
1000
+ expect(await Bun.file(path.join(runningJobsDir, "restart-deps-pending.json")).exists()).toBe(false);
1001
+ });
1002
+
1003
+ it("proceeds when from-task predecessor is done", async () => {
1004
+ const root = await makeTempRoot();
1005
+ initPATHS(root);
1006
+ await setupPipelineConfig(root, "restart-deps-done-pipeline", ["research", "analysis"]);
1007
+ mockRunnerSpawn();
1008
+
1009
+ await setupJob(root, "restart-deps-done", {
1010
+ id: "restart-deps-done",
1011
+ pipeline: "restart-deps-done-pipeline",
1012
+ state: "failed",
1013
+ current: "analysis",
1014
+ currentStage: null,
1015
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1016
+ tasks: {
1017
+ research: { state: "done", currentStage: null },
1018
+ analysis: { state: "pending", currentStage: null },
1019
+ },
1020
+ files: { artifacts: [], logs: [], tmp: [] },
1021
+ });
1022
+
1023
+ const req = new Request("http://localhost/api/jobs/restart-deps-done/restart", {
1024
+ method: "POST",
1025
+ body: JSON.stringify({ fromTask: "analysis" }),
1026
+ });
1027
+ const res = await handleJobRestart(req, "restart-deps-done", root);
1028
+ const body = await res.json() as Record<string, unknown>;
1029
+
1030
+ expect(res.status).toBe(202);
1031
+ expect(body["ok"]).toBe(true);
1032
+ expect(body["mode"]).toBe("from-task");
1033
+ });
1034
+
1035
+ it("proceeds when from-task predecessor is skipped", async () => {
1036
+ const root = await makeTempRoot();
1037
+ initPATHS(root);
1038
+ await setupPipelineConfig(root, "restart-deps-skipped-pipeline", ["research", "analysis"]);
1039
+ mockRunnerSpawn();
1040
+
1041
+ await setupJob(root, "restart-deps-skipped", {
1042
+ id: "restart-deps-skipped",
1043
+ pipeline: "restart-deps-skipped-pipeline",
1044
+ state: "failed",
1045
+ current: "analysis",
1046
+ currentStage: null,
1047
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1048
+ tasks: {
1049
+ research: { state: "skipped", currentStage: null },
1050
+ analysis: { state: "pending", currentStage: null },
1051
+ },
1052
+ files: { artifacts: [], logs: [], tmp: [] },
1053
+ });
1054
+
1055
+ const req = new Request("http://localhost/api/jobs/restart-deps-skipped/restart", {
1056
+ method: "POST",
1057
+ body: JSON.stringify({ fromTask: "analysis" }),
1058
+ });
1059
+ const res = await handleJobRestart(req, "restart-deps-skipped", root);
1060
+ const body = await res.json() as Record<string, unknown>;
1061
+
1062
+ expect(res.status).toBe(202);
1063
+ expect(body["ok"]).toBe(true);
1064
+ expect(body["mode"]).toBe("from-task");
1065
+ });
1066
+
1067
+ it("gates single-task restart identically when a predecessor is pending", async () => {
1068
+ const root = await makeTempRoot();
1069
+ initPATHS(root);
1070
+ await setupPipelineConfig(root, "restart-single-deps-pending-pipeline", ["review", "deploy"]);
1071
+
1072
+ const statusObj = {
1073
+ id: "restart-single-deps-pending",
1074
+ pipeline: "restart-single-deps-pending-pipeline",
1075
+ state: "failed",
1076
+ current: "deploy",
1077
+ currentStage: null,
1078
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1079
+ tasks: {
1080
+ review: { state: "pending", currentStage: null },
1081
+ deploy: { state: "pending", currentStage: null },
1082
+ },
1083
+ files: { artifacts: [], logs: [], tmp: [] },
1084
+ };
1085
+ const jobDir = await setupJob(root, "restart-single-deps-pending", statusObj);
1086
+
1087
+ const statusPath = path.join(jobDir, "tasks-status.json");
1088
+ const snapshotBefore = await Bun.file(statusPath).text();
1089
+ const spawnSpy = vi.spyOn(Bun, "spawn");
1090
+
1091
+ const req = new Request("http://localhost/api/jobs/restart-single-deps-pending/restart", {
1092
+ method: "POST",
1093
+ body: JSON.stringify({ fromTask: "deploy", singleTask: true }),
1094
+ });
1095
+ const res = await handleJobRestart(req, "restart-single-deps-pending", root);
1096
+ const body = await res.json() as Record<string, unknown>;
1097
+
1098
+ expect(res.status).toBe(412);
1099
+ expect(body["code"]).toBe("dependencies_not_satisfied");
1100
+ expect(body["message"]).toContain("review");
1101
+
1102
+ expect(await Bun.file(statusPath).text()).toBe(snapshotBefore);
1103
+ expect(spawnSpy).not.toHaveBeenCalled();
1104
+
1105
+ const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
1106
+ expect(await Bun.file(path.join(runningJobsDir, "restart-single-deps-pending.json")).exists()).toBe(false);
1107
+ });
1108
+
1109
+ it("does not gate clean-slate restart (no fromTask) even when a predecessor is pending", async () => {
1110
+ const root = await makeTempRoot();
1111
+ initPATHS(root);
1112
+ await setupPipelineConfig(root, "restart-clean-ungated-pipeline", ["research", "analysis"]);
1113
+ mockRunnerSpawn();
1114
+
1115
+ await setupJob(root, "restart-clean-ungated", {
1116
+ id: "restart-clean-ungated",
1117
+ pipeline: "restart-clean-ungated-pipeline",
1118
+ state: "failed",
1119
+ current: "analysis",
1120
+ currentStage: null,
1121
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1122
+ tasks: {
1123
+ research: { state: "pending", currentStage: null },
1124
+ analysis: { state: "pending", currentStage: null },
1125
+ },
1126
+ files: { artifacts: [], logs: [], tmp: [] },
1127
+ });
1128
+
1129
+ const req = new Request("http://localhost/api/jobs/restart-clean-ungated/restart", { method: "POST" });
1130
+ const res = await handleJobRestart(req, "restart-clean-ungated", root);
1131
+ const body = await res.json() as Record<string, unknown>;
1132
+
1133
+ expect(res.status).toBe(202);
1134
+ expect(body["ok"]).toBe(true);
1135
+ expect(body["mode"]).toBe("clean-slate");
1136
+ });
1137
+
1138
+ it("returns 500 status_unavailable when the pipeline task order cannot be loaded", async () => {
1139
+ const root = await makeTempRoot();
1140
+ initPATHS(root);
1141
+
1142
+ const statusObj = {
1143
+ id: "restart-no-order",
1144
+ state: "failed",
1145
+ current: "analysis",
1146
+ currentStage: null,
1147
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1148
+ tasks: {
1149
+ research: { state: "done", currentStage: null },
1150
+ analysis: { state: "pending", currentStage: null },
1151
+ },
1152
+ files: { artifacts: [], logs: [], tmp: [] },
1153
+ };
1154
+ const jobDir = await setupJob(root, "restart-no-order", statusObj);
1155
+
1156
+ const statusPath = path.join(jobDir, "tasks-status.json");
1157
+ const snapshotBefore = await Bun.file(statusPath).text();
1158
+ const spawnSpy = vi.spyOn(Bun, "spawn");
1159
+
1160
+ const req = new Request("http://localhost/api/jobs/restart-no-order/restart", {
1161
+ method: "POST",
1162
+ body: JSON.stringify({ fromTask: "analysis" }),
1163
+ });
1164
+ const res = await handleJobRestart(req, "restart-no-order", root);
1165
+ const body = await res.json() as Record<string, unknown>;
1166
+
1167
+ expect(res.status).toBe(500);
1168
+ expect(body["code"]).toBe("status_unavailable");
1169
+
1170
+ expect(await Bun.file(statusPath).text()).toBe(snapshotBefore);
1171
+ expect(spawnSpy).not.toHaveBeenCalled();
1172
+
1173
+ const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
1174
+ expect(await Bun.file(path.join(runningJobsDir, "restart-no-order.json")).exists()).toBe(false);
1175
+ });
1176
+
1177
+ it("returns 404 task_not_found when fromTask is absent from the pipeline order", async () => {
1178
+ const root = await makeTempRoot();
1179
+ initPATHS(root);
1180
+ await setupPipelineConfig(root, "restart-unknown-task-pipeline", ["research", "analysis"]);
1181
+
1182
+ const statusObj = {
1183
+ id: "restart-unknown-task",
1184
+ pipeline: "restart-unknown-task-pipeline",
1185
+ state: "failed",
1186
+ current: "analysis",
1187
+ currentStage: null,
1188
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1189
+ tasks: {
1190
+ research: { state: "done", currentStage: null },
1191
+ analysis: { state: "pending", currentStage: null },
1192
+ },
1193
+ files: { artifacts: [], logs: [], tmp: [] },
1194
+ };
1195
+ const jobDir = await setupJob(root, "restart-unknown-task", statusObj);
1196
+
1197
+ const statusPath = path.join(jobDir, "tasks-status.json");
1198
+ const snapshotBefore = await Bun.file(statusPath).text();
1199
+ const spawnSpy = vi.spyOn(Bun, "spawn");
1200
+
1201
+ const req = new Request("http://localhost/api/jobs/restart-unknown-task/restart", {
1202
+ method: "POST",
1203
+ body: JSON.stringify({ fromTask: "synthesis" }),
1204
+ });
1205
+ const res = await handleJobRestart(req, "restart-unknown-task", root);
1206
+ const body = await res.json() as Record<string, unknown>;
1207
+
1208
+ expect(res.status).toBe(404);
1209
+ expect(body["code"]).toBe("task_not_found");
1210
+
1211
+ expect(await Bun.file(statusPath).text()).toBe(snapshotBefore);
1212
+ expect(spawnSpy).not.toHaveBeenCalled();
1213
+
1214
+ const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
1215
+ expect(await Bun.file(path.join(runningJobsDir, "restart-unknown-task.json")).exists()).toBe(false);
1216
+ });
1217
+ });
1218
+
816
1219
  describe("handleTaskStart", () => {
817
1220
  it("allows restart/start when snapshot has no valid lastUpdated and no live PID (crashed runner scenario)", async () => {
818
1221
  // A runner that crashed before writing a valid lastUpdated should not permanently block
@@ -939,6 +1342,34 @@ describe("handleTaskStart", () => {
939
1342
  expect(body["code"]).toBe("job_running");
940
1343
  });
941
1344
 
1345
+ it("uses configured staleRunningGraceMs when deciding whether task start can recover a running task", async () => {
1346
+ const root = await makeTempRoot();
1347
+ initPATHS(root);
1348
+ process.env["PO_STALE_RUNNING_GRACE_MS"] = "60000";
1349
+ resetConfig();
1350
+
1351
+ vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-04-01T10:00:45.000Z"));
1352
+ await setupJob(root, "task-start-configured-grace", {
1353
+ id: "task-start-configured-grace",
1354
+ state: "running",
1355
+ current: "research",
1356
+ currentStage: "prompt",
1357
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1358
+ tasks: {
1359
+ research: { state: "running", currentStage: "prompt" },
1360
+ analysis: { state: "pending", currentStage: null },
1361
+ },
1362
+ files: { artifacts: [], logs: [], tmp: [] },
1363
+ }, 999999);
1364
+
1365
+ const req = new Request("http://localhost/api/jobs/task-start-configured-grace/tasks/analysis/start", { method: "POST" });
1366
+ const res = await handleTaskStart(req, "task-start-configured-grace", "analysis", root);
1367
+ const body = await res.json() as Record<string, unknown>;
1368
+
1369
+ expect(res.status).toBe(409);
1370
+ expect(body["code"]).toBe("job_running");
1371
+ });
1372
+
942
1373
  it("returns 202 when stale-running status is the only blocker for an eligible pending task", async () => {
943
1374
  const root = await makeTempRoot();
944
1375
  initPATHS(root);
@@ -1239,6 +1670,7 @@ describe("handleTaskStart", () => {
1239
1670
 
1240
1671
  expect(res.status).toBe(412);
1241
1672
  expect(body["code"]).toBe("dependencies_not_satisfied");
1673
+ expect(body["message"]).toContain("research");
1242
1674
  });
1243
1675
 
1244
1676
  it("returns 412 dependencies_not_satisfied when an earlier task is \"failed\"", async () => {
@@ -1265,6 +1697,80 @@ describe("handleTaskStart", () => {
1265
1697
 
1266
1698
  expect(res.status).toBe(412);
1267
1699
  expect(body["code"]).toBe("dependencies_not_satisfied");
1700
+ expect(body["message"]).toContain("research");
1701
+ });
1702
+
1703
+ it("returns 202 when an earlier task is \"skipped\" (done-or-skipped dependency rule)", async () => {
1704
+ const root = await makeTempRoot();
1705
+ initPATHS(root);
1706
+ await setupPipelineConfig(root, "task-start-skipped-deps-pipeline", ["research", "analysis"]);
1707
+ const spawnSpy = mockRunnerSpawn(88888);
1708
+
1709
+ await setupJob(root, "task-start-skipped-deps", {
1710
+ id: "task-start-skipped-deps",
1711
+ pipeline: "task-start-skipped-deps-pipeline",
1712
+ state: "pending",
1713
+ current: null,
1714
+ currentStage: null,
1715
+ tasks: {
1716
+ research: { state: "skipped", currentStage: null },
1717
+ analysis: { state: "pending", currentStage: null },
1718
+ },
1719
+ files: { artifacts: [], logs: [], tmp: [] },
1720
+ });
1721
+
1722
+ const req = new Request("http://localhost/api/jobs/task-start-skipped-deps/tasks/analysis/start", { method: "POST" });
1723
+ const res = await handleTaskStart(req, "task-start-skipped-deps", "analysis", root);
1724
+ const body = await res.json() as Record<string, unknown>;
1725
+
1726
+ expect(res.status).toBe(202);
1727
+ expect(body["ok"]).toBe(true);
1728
+ expect(body["taskId"]).toBe("analysis");
1729
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
1730
+ });
1731
+ });
1732
+
1733
+ describe("acquireConcurrencySlot stale-lease age (AC-5)", () => {
1734
+ it("does not prune a fresh null-pid lease when lockFileTimeout is short relative to PO_STALE_LEASE_TIMEOUT_MS", async () => {
1735
+ const root = await makeTempRoot();
1736
+ initPATHS(root);
1737
+ process.env["PO_STALE_LEASE_TIMEOUT_MS"] = "60000";
1738
+ resetConfig();
1739
+
1740
+ const now = Date.parse("2026-04-01T10:01:00.000Z");
1741
+ vi.spyOn(Date, "now").mockReturnValue(now);
1742
+
1743
+ const jobDir = await setupJob(root, "ac-5-held", {
1744
+ id: "ac-5-held",
1745
+ state: "running",
1746
+ current: "research",
1747
+ currentStage: "prompt",
1748
+ lastUpdated: "2026-04-01T10:00:15.000Z",
1749
+ tasks: {
1750
+ research: { state: "running", currentStage: "prompt" },
1751
+ },
1752
+ files: { artifacts: [], logs: [], tmp: [] },
1753
+ }, 999999);
1754
+
1755
+ const runningJobsDir = path.join(root, "pipeline-data", "runtime", "running-jobs");
1756
+ await mkdir(runningJobsDir, { recursive: true });
1757
+ const slotPath = path.join(runningJobsDir, "ac-5-held.json");
1758
+ await writeFile(slotPath, JSON.stringify({
1759
+ jobId: "ac-5-held",
1760
+ pid: null,
1761
+ acquiredAt: new Date(now - 40_000).toISOString(),
1762
+ source: "restart",
1763
+ slotPath,
1764
+ }));
1765
+
1766
+ const req = new Request("http://localhost/api/jobs/ac-5-held/restart", { method: "POST" });
1767
+ const res = await handleJobRestart(req, "ac-5-held", root);
1768
+ const body = await res.json() as Record<string, unknown>;
1769
+
1770
+ expect(res.status).toBe(409);
1771
+ expect(body["code"]).toBe("job_running");
1772
+
1773
+ expect(await Bun.file(slotPath).text()).toBeDefined();
1268
1774
  });
1269
1775
  });
1270
1776
 
@@ -1811,6 +2317,10 @@ describe("corrupt status handling", () => {
1811
2317
  tasks: { research: { state: "pending" } },
1812
2318
  files: { artifacts: [], logs: [], tmp: [] },
1813
2319
  });
2320
+ await writeFile(path.join(jobDir, "pipeline.json"), JSON.stringify({
2321
+ name: "write-corrupt-restart-pipeline",
2322
+ tasks: [{ name: "research" }],
2323
+ }));
1814
2324
  const statusPath = path.join(jobDir, "tasks-status.json");
1815
2325
  corruptStatusOnTextRead(statusPath, 3);
1816
2326