@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
@@ -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,11 +7,14 @@ import { spawnSync } from "node:child_process";
7
7
  import {
8
8
  getConcurrencyRuntimePaths,
9
9
  getJobConcurrencyStatus,
10
+ listActiveJobLeases,
10
11
  listQueuedSeeds,
11
12
  pruneStaleJobSlots,
13
+ readJobSlotLease,
12
14
  releaseJobSlot,
13
15
  tryAcquireJobSlot,
14
16
  updateJobSlotPid,
17
+ withRuntimeLock,
15
18
  type JobSlotLease,
16
19
  } from "../job-concurrency";
17
20
  import { readFile } from "node:fs/promises";
@@ -138,6 +141,21 @@ describe("listQueuedSeeds", () => {
138
141
  await rm(dir, { recursive: true });
139
142
  }
140
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
+ });
141
159
  });
142
160
 
143
161
  async function writeLease(
@@ -333,6 +351,121 @@ describe("pruneStaleJobSlots", () => {
333
351
  });
334
352
  });
335
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
+
336
469
  async function setupJobsDir(prefix: string): Promise<string> {
337
470
  const dir = await mkdtemp(join(tmpdir(), prefix));
338
471
  const { runningJobsDir } = getConcurrencyRuntimePaths(dir);
@@ -552,3 +685,130 @@ describe("getJobConcurrencyStatus", () => {
552
685
  }
553
686
  });
554
687
  });
688
+
689
+ describe("readJobSlotLease", () => {
690
+ test("returns the lease written by tryAcquireJobSlot", async () => {
691
+ const dir = await setupJobsDir("read-lease-acquired-");
692
+ try {
693
+ await makeCurrent(dir, "job-a");
694
+ const acquired = await tryAcquireJobSlot({
695
+ dataDir: dir,
696
+ jobId: "job-a",
697
+ maxConcurrentJobs: 2,
698
+ source: "orchestrator",
699
+ pid: process.pid,
700
+ });
701
+ expect(acquired.ok).toBe(true);
702
+
703
+ const lease = await readJobSlotLease(dir, "job-a");
704
+ expect(lease).not.toBeNull();
705
+ expect(lease!.jobId).toBe("job-a");
706
+ expect(lease!.pid).toBe(process.pid);
707
+ expect(lease!.source).toBe("orchestrator");
708
+ } finally {
709
+ await rm(dir, { recursive: true });
710
+ }
711
+ });
712
+
713
+ test("returns null for an unknown job", async () => {
714
+ const dir = await setupJobsDir("read-lease-unknown-");
715
+ try {
716
+ expect(await readJobSlotLease(dir, "unknown")).toBeNull();
717
+ } finally {
718
+ await rm(dir, { recursive: true });
719
+ }
720
+ });
721
+ });
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
+
764
+ describe("withRuntimeLock", () => {
765
+ test("serializes two concurrent callers", async () => {
766
+ const dir = await setupJobsDir("runtime-lock-serialize-");
767
+ const markerPath = join(dir, "marker.txt");
768
+ const events: string[] = [];
769
+ try {
770
+ let markAEntered!: () => void;
771
+ const aEntered = new Promise<void>((resolve) => {
772
+ markAEntered = resolve;
773
+ });
774
+ const a = withRuntimeLock(dir, 5000, async () => {
775
+ markAEntered();
776
+ await new Promise((resolve) => setTimeout(resolve, 50));
777
+ await writeFile(markerPath, "from-a");
778
+ events.push("a-wrote");
779
+ });
780
+ await aEntered;
781
+ const b = withRuntimeLock(dir, 5000, async () => {
782
+ const observed = await readFile(markerPath, "utf-8");
783
+ events.push(`b-read:${observed}`);
784
+ });
785
+ await Promise.all([a, b]);
786
+
787
+ expect(events).toEqual(["a-wrote", "b-read:from-a"]);
788
+ } finally {
789
+ await rm(dir, { recursive: true });
790
+ }
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
+ });
814
+ });