@ryanfw/prompt-orchestration-pipeline 1.3.3 → 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 (198) hide show
  1. package/docs/http-api.md +14 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +144 -41
  4. package/src/api/index.ts +15 -83
  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/paths.ts +11 -2
  17. package/src/config/pipeline-registry.ts +258 -0
  18. package/src/config/sse-events.ts +122 -0
  19. package/src/core/__tests__/agent-step.test.ts +115 -0
  20. package/src/core/__tests__/config.test.ts +517 -107
  21. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  22. package/src/core/__tests__/job-concurrency.test.ts +200 -0
  23. package/src/core/__tests__/job-submission.test.ts +396 -0
  24. package/src/core/__tests__/job-view.test.ts +485 -3
  25. package/src/core/__tests__/json-file.test.ts +111 -0
  26. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  27. package/src/core/__tests__/logger.test.ts +22 -0
  28. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  29. package/src/core/__tests__/orchestrator.test.ts +373 -2
  30. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  31. package/src/core/__tests__/redact.test.ts +153 -0
  32. package/src/core/__tests__/runner-liveness.test.ts +45 -0
  33. package/src/core/__tests__/seed-naming.test.ts +57 -0
  34. package/src/core/__tests__/single-derivation.test.ts +1 -1
  35. package/src/core/__tests__/task-runner.test.ts +594 -3
  36. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  37. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  38. package/src/core/agent-step.ts +4 -1
  39. package/src/core/agent-types.ts +5 -4
  40. package/src/core/config.ts +127 -234
  41. package/src/core/control.ts +3 -0
  42. package/src/core/job-concurrency.ts +50 -19
  43. package/src/core/job-submission.ts +133 -0
  44. package/src/core/job-view.ts +162 -18
  45. package/src/core/json-file.ts +45 -0
  46. package/src/core/lifecycle-policy.ts +23 -8
  47. package/src/core/logger.ts +0 -29
  48. package/src/core/orchestrator.ts +124 -70
  49. package/src/core/pipeline-runner.ts +85 -53
  50. package/src/core/redact.ts +40 -0
  51. package/src/core/runner-liveness.ts +8 -4
  52. package/src/core/seed-naming.ts +9 -0
  53. package/src/core/status-writer.ts +3 -28
  54. package/src/core/task-runner.ts +356 -319
  55. package/src/core/task-telemetry.ts +107 -0
  56. package/src/harness/__tests__/subprocess.test.ts +112 -1
  57. package/src/harness/subprocess.ts +55 -16
  58. package/src/llm/__tests__/index.test.ts +684 -33
  59. package/src/llm/index.ts +310 -67
  60. package/src/providers/__tests__/alibaba.test.ts +67 -6
  61. package/src/providers/__tests__/anthropic.test.ts +35 -14
  62. package/src/providers/__tests__/base.test.ts +62 -0
  63. package/src/providers/__tests__/claude-code.test.ts +99 -14
  64. package/src/providers/__tests__/deepseek.test.ts +16 -6
  65. package/src/providers/__tests__/gemini.test.ts +47 -25
  66. package/src/providers/__tests__/moonshot.test.ts +27 -0
  67. package/src/providers/__tests__/openai.test.ts +262 -74
  68. package/src/providers/__tests__/opencode.test.ts +77 -0
  69. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  70. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  71. package/src/providers/__tests__/types.test.ts +85 -0
  72. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  73. package/src/providers/__tests__/zhipu.test.ts +27 -14
  74. package/src/providers/alibaba.ts +20 -39
  75. package/src/providers/anthropic.ts +23 -11
  76. package/src/providers/base.ts +19 -0
  77. package/src/providers/claude-code.ts +27 -18
  78. package/src/providers/deepseek.ts +9 -28
  79. package/src/providers/gemini.ts +20 -58
  80. package/src/providers/moonshot.ts +15 -11
  81. package/src/providers/openai.ts +79 -61
  82. package/src/providers/opencode.ts +16 -33
  83. package/src/providers/stream-accumulator.ts +27 -21
  84. package/src/providers/types.ts +29 -4
  85. package/src/providers/zhipu.ts +15 -44
  86. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  87. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  88. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  89. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  90. package/src/task-analysis/__tests__/types.test.ts +63 -130
  91. package/src/task-analysis/analyzer.ts +91 -0
  92. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  93. package/src/task-analysis/index.ts +2 -36
  94. package/src/task-analysis/repository.ts +45 -0
  95. package/src/task-analysis/types.ts +42 -58
  96. package/src/ui/client/__tests__/api.test.ts +143 -1
  97. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  98. package/src/ui/client/__tests__/job-adapter.test.ts +125 -2
  99. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  100. package/src/ui/client/__tests__/types.test.ts +66 -3
  101. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  102. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  103. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  104. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  105. package/src/ui/client/adapters/job-adapter.ts +31 -16
  106. package/src/ui/client/api.ts +38 -15
  107. package/src/ui/client/bootstrap.ts +19 -14
  108. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  109. package/src/ui/client/hooks/useJobList.ts +26 -31
  110. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  111. package/src/ui/client/load-state.ts +20 -0
  112. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  113. package/src/ui/client/reducers/job-events.ts +137 -0
  114. package/src/ui/client/types.ts +14 -20
  115. package/src/ui/components/DAGGrid.tsx +6 -1
  116. package/src/ui/components/JobDetail.tsx +12 -4
  117. package/src/ui/components/JobTable.tsx +13 -2
  118. package/src/ui/components/StageTimeline.tsx +8 -20
  119. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  120. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  121. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  122. package/src/ui/components/__tests__/JobTable.test.tsx +83 -0
  123. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  124. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  125. package/src/ui/components/types.ts +33 -15
  126. package/src/ui/dist/assets/{index-L6cvsCAx.js → index-DN3-zvtP.js} +4299 -251
  127. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  128. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  129. package/src/ui/dist/index.html +2 -2
  130. package/src/ui/pages/PipelineDetail.tsx +60 -4
  131. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  132. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  133. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  134. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  135. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  136. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  137. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  138. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  139. package/src/ui/server/__tests__/index.test.ts +63 -0
  140. package/src/ui/server/__tests__/job-control-endpoints.test.ts +455 -1
  141. package/src/ui/server/__tests__/job-endpoints.test.ts +43 -1
  142. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  143. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  144. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  145. package/src/ui/server/__tests__/router.test.ts +104 -0
  146. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  147. package/src/ui/server/__tests__/sse-enhancer.test.ts +39 -0
  148. package/src/ui/server/config-bridge-node.ts +8 -26
  149. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  150. package/src/ui/server/embedded-assets.ts +13 -2
  151. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  152. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  153. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  154. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  155. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  156. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  157. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  158. package/src/ui/server/endpoints/job-control-endpoints.ts +122 -73
  159. package/src/ui/server/endpoints/job-endpoints.ts +1 -0
  160. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  161. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  162. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  163. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  164. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  165. package/src/ui/server/index.ts +19 -14
  166. package/src/ui/server/job-reader.ts +14 -2
  167. package/src/ui/server/router.ts +33 -10
  168. package/src/ui/server/sse-broadcast.ts +14 -3
  169. package/src/ui/server/sse-enhancer.ts +12 -2
  170. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  171. package/src/ui/state/__tests__/snapshot.test.ts +70 -15
  172. package/src/ui/state/__tests__/types.test.ts +6 -0
  173. package/src/ui/state/snapshot.ts +1 -1
  174. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +42 -0
  175. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +45 -3
  176. package/src/ui/state/transformers/list-transformer.ts +6 -0
  177. package/src/ui/state/types.ts +6 -1
  178. package/src/utils/__tests__/path-containment.test.ts +160 -0
  179. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  180. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  181. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  182. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  183. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  184. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  185. package/src/task-analysis/__tests__/index.test.ts +0 -143
  186. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  187. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  188. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  189. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  190. package/src/task-analysis/extractors/artifacts.ts +0 -143
  191. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  192. package/src/task-analysis/extractors/stages.ts +0 -45
  193. package/src/task-analysis/parser.ts +0 -20
  194. package/src/task-analysis/utils/ast.ts +0 -45
  195. package/src/ui/dist/assets/index-L6cvsCAx.js.map +0 -1
  196. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  197. package/src/ui/embedded-assets.js +0 -12
  198. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
@@ -0,0 +1,181 @@
1
+ import { describe, test, expect, afterEach } from "bun:test";
2
+ import { existsSync, mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve as resolvePath } from "node:path";
5
+ import { mkdir, rm, writeFile } from "node:fs/promises";
6
+ import { startOrchestrator } from "../orchestrator";
7
+ import { resolveJobConfig, runPipelineJob } from "../pipeline-runner";
8
+ import { resetConfig } from "../config";
9
+
10
+ // PO_* keys this test mutates (capture original values in beforeEach-equivalent,
11
+ // restore by deletion-or-assignment in afterEach per unit-testing.md §6).
12
+ const PO_ENV_KEYS = [
13
+ "PO_ROOT",
14
+ "PO_DATA_DIR",
15
+ "PO_CURRENT_DIR",
16
+ "PO_COMPLETE_DIR",
17
+ "PO_PIPELINE_PATH",
18
+ ] as const;
19
+ const savedEnv: Record<string, string | undefined> = {};
20
+ const cleanupDirs: string[] = [];
21
+
22
+ function snapshotEnv(): void {
23
+ for (const key of PO_ENV_KEYS) {
24
+ savedEnv[key] = process.env[key];
25
+ }
26
+ }
27
+
28
+ function restoreEnv(): void {
29
+ for (const key of PO_ENV_KEYS) {
30
+ if (savedEnv[key] === undefined) delete process.env[key];
31
+ else process.env[key] = savedEnv[key];
32
+ }
33
+ resetConfig();
34
+ }
35
+
36
+ interface FakeWatcher {
37
+ on(event: "add", cb: (path: string) => void): FakeWatcher;
38
+ on(event: "ready", cb: () => void): FakeWatcher;
39
+ on(event: "error", cb: (err: Error) => void): FakeWatcher;
40
+ close(): Promise<void>;
41
+ }
42
+
43
+ function fakeWatcherFactory(): (
44
+ path: string,
45
+ options: Record<string, unknown>,
46
+ ) => FakeWatcher {
47
+ const watcher = {
48
+ on(event: "add" | "ready" | "error", cb: () => void): FakeWatcher {
49
+ if (event === "ready") queueMicrotask(() => cb());
50
+ return watcher;
51
+ },
52
+ close(): Promise<void> {
53
+ return Promise.resolve();
54
+ },
55
+ } as unknown as FakeWatcher;
56
+ return (_p: string, _o: Record<string, unknown>) => watcher;
57
+ }
58
+
59
+ describe("core boot resolution (AC-7)", () => {
60
+ afterEach(async () => {
61
+ restoreEnv();
62
+ await Promise.all(
63
+ cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
64
+ );
65
+ });
66
+
67
+ describe("startOrchestrator", () => {
68
+ test("with explicit dataDir, creates pipeline-data subdirs under the resolved root", async () => {
69
+ snapshotEnv();
70
+ delete process.env["PO_ROOT"];
71
+
72
+ const root = mkdtempSync(join(tmpdir(), "boot-resolve-explicit-"));
73
+ cleanupDirs.push(root);
74
+ const dataDir = resolvePath(join(root, "pipeline-data"));
75
+
76
+ const handle = await startOrchestrator({
77
+ dataDir,
78
+ watcherFactory: fakeWatcherFactory(),
79
+ });
80
+
81
+ try {
82
+ for (const sub of ["pending", "current", "complete", "rejected", "staging"]) {
83
+ expect(existsSync(join(root, "pipeline-data", sub))).toBe(true);
84
+ }
85
+ } finally {
86
+ await handle.stop();
87
+ }
88
+ });
89
+
90
+ test("with PO_ROOT set and no explicit dataDir, resolves under PO_ROOT", async () => {
91
+ snapshotEnv();
92
+ const root = mkdtempSync(join(tmpdir(), "boot-resolve-envroot-"));
93
+ cleanupDirs.push(root);
94
+ process.env["PO_ROOT"] = root;
95
+
96
+ const handle = await startOrchestrator({
97
+ dataDir: undefined,
98
+ watcherFactory: fakeWatcherFactory(),
99
+ });
100
+
101
+ try {
102
+ for (const sub of ["pending", "current", "complete", "rejected", "staging"]) {
103
+ expect(existsSync(join(root, "pipeline-data", sub))).toBe(true);
104
+ }
105
+ } finally {
106
+ await handle.stop();
107
+ }
108
+ });
109
+
110
+ test("with neither explicit dataDir nor PO_ROOT, throws the resolver message", () => {
111
+ snapshotEnv();
112
+ delete process.env["PO_ROOT"];
113
+
114
+ expect(() =>
115
+ startOrchestrator({
116
+ dataDir: undefined,
117
+ watcherFactory: fakeWatcherFactory(),
118
+ }),
119
+ ).toThrow(/Workspace root is required/);
120
+ });
121
+ });
122
+
123
+ describe("resolveJobConfig", () => {
124
+ async function setupJobDir(tmpRoot: string, jobId: string): Promise<void> {
125
+ const currentDir = join(tmpRoot, "pipeline-data", "current");
126
+ await mkdir(currentDir, { recursive: true });
127
+ const jobDir = join(currentDir, jobId);
128
+ await mkdir(jobDir, { recursive: true });
129
+ await writeFile(
130
+ join(jobDir, "seed.json"),
131
+ JSON.stringify({ pipeline: "boot-test" }),
132
+ );
133
+ await writeFile(
134
+ join(jobDir, "tasks-status.json"),
135
+ JSON.stringify({
136
+ id: jobId,
137
+ state: "pending",
138
+ current: null,
139
+ currentStage: null,
140
+ lastUpdated: new Date().toISOString(),
141
+ tasks: {},
142
+ files: { artifacts: [], logs: [], tmp: [] },
143
+ }),
144
+ );
145
+ }
146
+
147
+ test("with a resolved root, poRoot equals the supplied root", async () => {
148
+ snapshotEnv();
149
+ const root = mkdtempSync(join(tmpdir(), "boot-resolvejob-po-"));
150
+ cleanupDirs.push(root);
151
+ process.env["PO_PIPELINE_PATH"] = join(root, "pipeline.json");
152
+ await setupJobDir(root, "boot-test-job");
153
+
154
+ const config = await resolveJobConfig("boot-test-job", resolvePath(root));
155
+ expect(config.poRoot).toBe(resolvePath(root));
156
+ });
157
+
158
+ test("with PO_ROOT deleted, still uses the supplied resolved root", async () => {
159
+ snapshotEnv();
160
+ const root = mkdtempSync(join(tmpdir(), "boot-resolvejob-explicit-"));
161
+ cleanupDirs.push(root);
162
+ delete process.env["PO_ROOT"];
163
+ process.env["PO_PIPELINE_PATH"] = join(root, "pipeline.json");
164
+ await setupJobDir(root, "boot-test-job-explicit");
165
+
166
+ const config = await resolveJobConfig("boot-test-job-explicit", resolvePath(root));
167
+ expect(config.poRoot).toBe(resolvePath(root));
168
+ });
169
+ });
170
+
171
+ describe("runPipelineJob", () => {
172
+ test("with PO_ROOT deleted, rejects with the resolver message at boot", async () => {
173
+ snapshotEnv();
174
+ delete process.env["PO_ROOT"];
175
+
176
+ await expect(runPipelineJob("any-job-id")).rejects.toThrow(
177
+ /Workspace root is required/,
178
+ );
179
+ });
180
+ });
181
+ });
@@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process";
7
7
  import {
8
8
  getConcurrencyRuntimePaths,
9
9
  getJobConcurrencyStatus,
10
+ listActiveJobLeases,
10
11
  listQueuedSeeds,
11
12
  pruneStaleJobSlots,
12
13
  readJobSlotLease,
@@ -140,6 +141,21 @@ describe("listQueuedSeeds", () => {
140
141
  await rm(dir, { recursive: true });
141
142
  }
142
143
  });
144
+
145
+ test("matches a UUID <id>-seed.json and ignores foo.json under the shared regex", async () => {
146
+ const dir = await mkdtemp(join(tmpdir(), "queued-seeds-shared-"));
147
+ const pendingDir = join(dir, "pending");
148
+ await mkdir(pendingDir, { recursive: true });
149
+ const uuid = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
150
+ await writeFile(join(pendingDir, `${uuid}-seed.json`), JSON.stringify({ pipeline: "p" }));
151
+ await writeFile(join(pendingDir, "foo.json"), "{}");
152
+ try {
153
+ const result = await listQueuedSeeds(dir);
154
+ expect(result.map((s) => s.jobId)).toEqual([uuid]);
155
+ } finally {
156
+ await rm(dir, { recursive: true });
157
+ }
158
+ });
143
159
  });
144
160
 
145
161
  async function writeLease(
@@ -335,6 +351,121 @@ describe("pruneStaleJobSlots", () => {
335
351
  });
336
352
  });
337
353
 
354
+ describe("pruneStaleJobSlots — stale_runner backstop", () => {
355
+ test("reclaims an aged live-pid lease when runner.pid is absent (AC-3)", async () => {
356
+ const dir = await mkdtemp(join(tmpdir(), "prune-stale-runner-"));
357
+ const { runningJobsDir } = getConcurrencyRuntimePaths(dir);
358
+ await mkdir(runningJobsDir, { recursive: true });
359
+ await mkdir(join(dir, "current", "job-reuse"), { recursive: true });
360
+ const slotPath = await writeLease(runningJobsDir, "job-reuse", {
361
+ jobId: "job-reuse",
362
+ pid: process.pid,
363
+ acquiredAt: new Date(Date.now() - 60_000).toISOString(),
364
+ source: "orchestrator",
365
+ slotPath: join(runningJobsDir, "job-reuse.json"),
366
+ });
367
+ try {
368
+ const result = await pruneStaleJobSlots(dir, 1000, 60_000);
369
+ expect(result).toEqual([
370
+ { jobId: "job-reuse", slotPath, reason: "stale_runner" },
371
+ ]);
372
+ expect(existsSync(slotPath)).toBe(false);
373
+ } finally {
374
+ await rm(dir, { recursive: true });
375
+ }
376
+ });
377
+
378
+ test("retains an aged live-pid lease when runner.pid matches (AC-8)", async () => {
379
+ const dir = await mkdtemp(join(tmpdir(), "prune-stale-runner-live-"));
380
+ const { runningJobsDir } = getConcurrencyRuntimePaths(dir);
381
+ await mkdir(runningJobsDir, { recursive: true });
382
+ await mkdir(join(dir, "current", "job-running"), { recursive: true });
383
+ await writeFile(join(dir, "current", "job-running", "runner.pid"), `${process.pid}\n`);
384
+ const slotPath = await writeLease(runningJobsDir, "job-running", {
385
+ jobId: "job-running",
386
+ pid: process.pid,
387
+ acquiredAt: new Date(Date.now() - 60_000).toISOString(),
388
+ source: "orchestrator",
389
+ slotPath: join(runningJobsDir, "job-running.json"),
390
+ });
391
+ try {
392
+ const result = await pruneStaleJobSlots(dir, 1000, 60_000);
393
+ expect(result).toEqual([]);
394
+ expect(existsSync(slotPath)).toBe(true);
395
+ } finally {
396
+ await rm(dir, { recursive: true });
397
+ }
398
+ });
399
+
400
+ test("does not prune a null-pid lease aged past lockTimeoutMs but within staleLeaseTimeoutMs (AC-5)", async () => {
401
+ const dir = await mkdtemp(join(tmpdir(), "prune-stale-split-"));
402
+ const { runningJobsDir } = getConcurrencyRuntimePaths(dir);
403
+ await mkdir(runningJobsDir, { recursive: true });
404
+ await mkdir(join(dir, "current", "job-inflight"), { recursive: true });
405
+ const slotPath = await writeLease(runningJobsDir, "job-inflight", {
406
+ jobId: "job-inflight",
407
+ pid: null,
408
+ acquiredAt: new Date(Date.now() - 5_000).toISOString(),
409
+ source: "orchestrator",
410
+ slotPath: join(runningJobsDir, "job-inflight.json"),
411
+ });
412
+ try {
413
+ const result = await pruneStaleJobSlots(dir, 1000, 60_000);
414
+ expect(result).toEqual([]);
415
+ expect(existsSync(slotPath)).toBe(true);
416
+ } finally {
417
+ await rm(dir, { recursive: true });
418
+ }
419
+ });
420
+
421
+ test("omitting staleLeaseTimeoutMs reproduces current missing_pid behavior (AC-6)", async () => {
422
+ const dir = await mkdtemp(join(tmpdir(), "prune-stale-default-"));
423
+ const { runningJobsDir } = getConcurrencyRuntimePaths(dir);
424
+ await mkdir(runningJobsDir, { recursive: true });
425
+ await mkdir(join(dir, "current", "job-default"), { recursive: true });
426
+ const slotPath = await writeLease(runningJobsDir, "job-default", {
427
+ jobId: "job-default",
428
+ pid: null,
429
+ acquiredAt: new Date(Date.now() - 60_000).toISOString(),
430
+ source: "orchestrator",
431
+ slotPath: join(runningJobsDir, "job-default.json"),
432
+ });
433
+ try {
434
+ const result = await pruneStaleJobSlots(dir, 1000);
435
+ expect(result).toEqual([
436
+ { jobId: "job-default", slotPath, reason: "missing_pid" },
437
+ ]);
438
+ expect(existsSync(slotPath)).toBe(false);
439
+ } finally {
440
+ await rm(dir, { recursive: true });
441
+ }
442
+ });
443
+
444
+ test("omitting staleLeaseTimeoutMs reproduces current dead_pid behavior (AC-6)", async () => {
445
+ const dir = await mkdtemp(join(tmpdir(), "prune-stale-default-dead-"));
446
+ const { runningJobsDir } = getConcurrencyRuntimePaths(dir);
447
+ await mkdir(runningJobsDir, { recursive: true });
448
+ await mkdir(join(dir, "current", "job-dead-default"), { recursive: true });
449
+ const deadPid = getDeadPid();
450
+ const slotPath = await writeLease(runningJobsDir, "job-dead-default", {
451
+ jobId: "job-dead-default",
452
+ pid: deadPid,
453
+ acquiredAt: new Date().toISOString(),
454
+ source: "orchestrator",
455
+ slotPath: join(runningJobsDir, "job-dead-default.json"),
456
+ });
457
+ try {
458
+ const result = await pruneStaleJobSlots(dir, 1000);
459
+ expect(result).toEqual([
460
+ { jobId: "job-dead-default", slotPath, reason: "dead_pid" },
461
+ ]);
462
+ expect(existsSync(slotPath)).toBe(false);
463
+ } finally {
464
+ await rm(dir, { recursive: true });
465
+ }
466
+ });
467
+ });
468
+
338
469
  async function setupJobsDir(prefix: string): Promise<string> {
339
470
  const dir = await mkdtemp(join(tmpdir(), prefix));
340
471
  const { runningJobsDir } = getConcurrencyRuntimePaths(dir);
@@ -589,17 +720,64 @@ describe("readJobSlotLease", () => {
589
720
  });
590
721
  });
591
722
 
723
+ describe("listActiveJobLeases", () => {
724
+ test("returns the leases written by tryAcquireJobSlot", async () => {
725
+ const dir = await setupJobsDir("list-active-acquired-");
726
+ try {
727
+ await makeCurrent(dir, "job-a");
728
+ await makeCurrent(dir, "job-b");
729
+ await tryAcquireJobSlot({
730
+ dataDir: dir,
731
+ jobId: "job-a",
732
+ maxConcurrentJobs: 2,
733
+ source: "orchestrator",
734
+ pid: process.pid,
735
+ });
736
+ await tryAcquireJobSlot({
737
+ dataDir: dir,
738
+ jobId: "job-b",
739
+ maxConcurrentJobs: 2,
740
+ source: "restart",
741
+ pid: process.pid,
742
+ });
743
+
744
+ const leases = await listActiveJobLeases(dir);
745
+ expect(leases.map((l) => l.jobId)).toEqual(["job-a", "job-b"]);
746
+ const first = leases[0]!;
747
+ expect(first.pid).toBe(process.pid);
748
+ expect(first.source).toBe("orchestrator");
749
+ } finally {
750
+ await rm(dir, { recursive: true });
751
+ }
752
+ });
753
+
754
+ test("returns [] for an absent runtime dir", async () => {
755
+ const dir = await mkdtemp(join(tmpdir(), "list-active-absent-"));
756
+ try {
757
+ expect(await listActiveJobLeases(dir)).toEqual([]);
758
+ } finally {
759
+ await rm(dir, { recursive: true });
760
+ }
761
+ });
762
+ });
763
+
592
764
  describe("withRuntimeLock", () => {
593
765
  test("serializes two concurrent callers", async () => {
594
766
  const dir = await setupJobsDir("runtime-lock-serialize-");
595
767
  const markerPath = join(dir, "marker.txt");
596
768
  const events: string[] = [];
597
769
  try {
770
+ let markAEntered!: () => void;
771
+ const aEntered = new Promise<void>((resolve) => {
772
+ markAEntered = resolve;
773
+ });
598
774
  const a = withRuntimeLock(dir, 5000, async () => {
775
+ markAEntered();
599
776
  await new Promise((resolve) => setTimeout(resolve, 50));
600
777
  await writeFile(markerPath, "from-a");
601
778
  events.push("a-wrote");
602
779
  });
780
+ await aEntered;
603
781
  const b = withRuntimeLock(dir, 5000, async () => {
604
782
  const observed = await readFile(markerPath, "utf-8");
605
783
  events.push(`b-read:${observed}`);
@@ -611,4 +789,26 @@ describe("withRuntimeLock", () => {
611
789
  await rm(dir, { recursive: true });
612
790
  }
613
791
  });
792
+
793
+ test("reclaims a stale owner file without a numeric pid using the stale age, not the acquire wait", async () => {
794
+ const dir = await setupJobsDir("runtime-lock-stale-owner-");
795
+ const { lockDir, runtimeDir } = getConcurrencyRuntimePaths(dir);
796
+ await mkdir(runtimeDir, { recursive: true });
797
+ await mkdir(lockDir, { recursive: false });
798
+ await writeFile(
799
+ join(lockDir, "owner.json"),
800
+ JSON.stringify({ acquiredAt: new Date(Date.now() - 60_000).toISOString() }),
801
+ );
802
+ try {
803
+ const result = await withRuntimeLock(
804
+ dir,
805
+ 100,
806
+ async () => "reclaimed",
807
+ 60_000,
808
+ );
809
+ expect(result).toBe("reclaimed");
810
+ } finally {
811
+ await rm(dir, { recursive: true });
812
+ }
813
+ });
614
814
  });