@ryanfw/prompt-orchestration-pipeline 1.3.1 → 1.3.3

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 (67) hide show
  1. package/docs/http-api.md +66 -0
  2. package/docs/pop-task-guide.md +1 -0
  3. package/package.json +1 -2
  4. package/src/api/__tests__/index.test.ts +311 -3
  5. package/src/api/index.ts +94 -45
  6. package/src/config/__tests__/statuses.test.ts +41 -0
  7. package/src/config/statuses.ts +20 -1
  8. package/src/core/__tests__/agent-step.test.ts +322 -3
  9. package/src/core/__tests__/config.test.ts +31 -1
  10. package/src/core/__tests__/job-concurrency.test.ts +60 -0
  11. package/src/core/__tests__/job-view.test.ts +859 -0
  12. package/src/core/__tests__/orchestrator.test.ts +242 -0
  13. package/src/core/__tests__/runner-liveness.test.ts +865 -0
  14. package/src/core/__tests__/single-derivation.test.ts +159 -0
  15. package/src/core/__tests__/task-runner.test.ts +57 -1
  16. package/src/core/agent-step.ts +231 -14
  17. package/src/core/agent-types.ts +3 -0
  18. package/src/core/config.ts +19 -0
  19. package/src/core/file-io.ts +8 -74
  20. package/src/core/job-concurrency.ts +6 -1
  21. package/src/core/job-view.ts +329 -0
  22. package/src/core/orchestrator.ts +78 -6
  23. package/src/core/pipeline-runner.ts +54 -3
  24. package/src/core/runner-liveness.ts +276 -0
  25. package/src/core/status-writer.ts +68 -31
  26. package/src/core/task-runner.ts +27 -3
  27. package/src/harness/__tests__/discovery.test.ts +60 -8
  28. package/src/harness/discovery.ts +16 -6
  29. package/src/ui/client/__tests__/job-adapter.test.ts +78 -0
  30. package/src/ui/client/adapters/job-adapter.ts +10 -0
  31. package/src/ui/client/hooks/useJobListWithUpdates.ts +16 -1
  32. package/src/ui/client/types.ts +2 -0
  33. package/src/ui/components/JobTable.tsx +29 -12
  34. package/src/ui/components/__tests__/JobTable.test.tsx +54 -1
  35. package/src/ui/components/types.ts +2 -0
  36. package/src/ui/dist/assets/{index-CbS3OsW7.js → index-L6cvsCAx.js} +39 -13
  37. package/src/ui/dist/assets/{index-CbS3OsW7.js.map → index-L6cvsCAx.js.map} +1 -1
  38. package/src/ui/dist/assets/style-CSSKuMOe.css +2 -0
  39. package/src/ui/dist/index.html +2 -2
  40. package/src/ui/embedded-assets.js +6 -6
  41. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  42. package/src/ui/server/__tests__/gate-endpoints.test.ts +90 -0
  43. package/src/ui/server/__tests__/job-control-endpoints.test.ts +245 -1
  44. package/src/ui/server/__tests__/job-endpoints.test.ts +115 -1
  45. package/src/ui/server/__tests__/path-containment.test.ts +54 -0
  46. package/src/ui/server/__tests__/sse-enhancer.test.ts +31 -0
  47. package/src/ui/server/__tests__/status-corruption.test.ts +55 -0
  48. package/src/ui/server/config-bridge.ts +4 -4
  49. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  50. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +77 -0
  51. package/src/ui/server/endpoints/gate-endpoints.ts +19 -7
  52. package/src/ui/server/endpoints/job-control-endpoints.ts +43 -70
  53. package/src/ui/server/endpoints/job-endpoints.ts +6 -0
  54. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  55. package/src/ui/server/endpoints/upload-endpoints.ts +13 -3
  56. package/src/ui/server/utils/http-utils.ts +6 -1
  57. package/src/ui/server/utils/path-containment.ts +18 -0
  58. package/src/ui/server/utils/status-corruption.ts +27 -0
  59. package/src/ui/state/__tests__/snapshot.test.ts +51 -0
  60. package/src/ui/state/__tests__/types.test.ts +98 -5
  61. package/src/ui/state/snapshot.ts +1 -1
  62. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +43 -2
  63. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +208 -22
  64. package/src/ui/state/transformers/list-transformer.ts +7 -3
  65. package/src/ui/state/transformers/status-transformer.ts +36 -170
  66. package/src/ui/state/types.ts +9 -47
  67. package/src/ui/dist/assets/style-BUFg3Sth.css +0 -2
@@ -8,6 +8,7 @@ import {
8
8
  VALID_JOB_LOCATIONS,
9
9
  normalizeTaskState,
10
10
  normalizeJobStatus,
11
+ jobStateToStatus,
11
12
  deriveJobStatusFromTasks,
12
13
  } from "../statuses";
13
14
 
@@ -159,6 +160,10 @@ describe("normalizeJobStatus", () => {
159
160
  expect(normalizeJobStatus("completed")).toBe("complete");
160
161
  });
161
162
 
163
+ it("handles synonym: done → complete", () => {
164
+ expect(normalizeJobStatus("done")).toBe("complete");
165
+ });
166
+
162
167
  it("handles synonym: error → failed", () => {
163
168
  expect(normalizeJobStatus("error")).toBe("failed");
164
169
  });
@@ -193,6 +198,42 @@ describe("normalizeJobStatus", () => {
193
198
  });
194
199
  });
195
200
 
201
+ describe("jobStateToStatus", () => {
202
+ it("returns canonical for each recognized JobState", () => {
203
+ expect(jobStateToStatus("pending")).toBe("pending");
204
+ expect(jobStateToStatus("running")).toBe("running");
205
+ expect(jobStateToStatus("waiting")).toBe("waiting");
206
+ expect(jobStateToStatus("failed")).toBe("failed");
207
+ });
208
+
209
+ it("maps done (persisted JobState) to complete (canonical JobStatusValue)", () => {
210
+ expect(jobStateToStatus("done")).toBe("complete");
211
+ });
212
+
213
+ it("is case-insensitive and trims whitespace", () => {
214
+ expect(jobStateToStatus("DONE")).toBe("complete");
215
+ expect(jobStateToStatus(" failed ")).toBe("failed");
216
+ expect(jobStateToStatus("Waiting")).toBe("waiting");
217
+ });
218
+
219
+ it("returns null for non-string input", () => {
220
+ expect(jobStateToStatus(null)).toBeNull();
221
+ expect(jobStateToStatus(undefined)).toBeNull();
222
+ expect(jobStateToStatus(42)).toBeNull();
223
+ expect(jobStateToStatus({})).toBeNull();
224
+ });
225
+
226
+ it("returns null for unrecognized strings", () => {
227
+ expect(jobStateToStatus("bogus-state")).toBeNull();
228
+ });
229
+
230
+ it("does not accept JobStatus synonyms that are not JobState values", () => {
231
+ expect(jobStateToStatus("error")).toBeNull();
232
+ expect(jobStateToStatus("completed")).toBeNull();
233
+ expect(jobStateToStatus("complete")).toBeNull();
234
+ });
235
+ });
236
+
196
237
  describe("deriveJobStatusFromTasks", () => {
197
238
  it("returns failed when any task is failed (failed priority)", () => {
198
239
  expect(deriveJobStatusFromTasks([{ state: "failed" }, { state: "done" }])).toBe("failed");
@@ -53,8 +53,9 @@ const TASK_STATE_SYNONYMS: Readonly<Record<string, TaskStateValue>> = Object.fre
53
53
  succeeded: "done",
54
54
  });
55
55
 
56
- const JOB_STATUS_SYNONYMS: Readonly<Record<string, JobStatusValue>> = Object.freeze({
56
+ export const JOB_STATUS_SYNONYMS: Readonly<Record<string, JobStatusValue>> = Object.freeze({
57
57
  completed: "complete",
58
+ done: "complete",
58
59
  error: "failed",
59
60
  });
60
61
 
@@ -93,6 +94,24 @@ export function normalizeJobStatus(status: unknown): JobStatusValue {
93
94
  return "pending";
94
95
  }
95
96
 
97
+ export function jobStateToStatus(state: unknown): JobStatusValue | null {
98
+ if (typeof state !== "string") return null;
99
+ switch (state.toLowerCase().trim()) {
100
+ case "pending":
101
+ return "pending";
102
+ case "running":
103
+ return "running";
104
+ case "waiting":
105
+ return "waiting";
106
+ case "failed":
107
+ return "failed";
108
+ case "done":
109
+ return "complete";
110
+ default:
111
+ return null;
112
+ }
113
+ }
114
+
96
115
  export function deriveJobStatusFromTasks(
97
116
  tasks: ReadonlyArray<{ state: unknown }>,
98
117
  ): JobStatusValue {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test";
2
- import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import type { WriteOptions, TaskFileIO } from "../file-io.ts";
@@ -53,10 +53,11 @@ function createFakeIO(): TaskFileIO & { calls: string[] } {
53
53
 
54
54
  function createFakeMcpHandle(
55
55
  artifacts: string[] = [],
56
+ connection = { url: "http://127.0.0.1:9999/mcp", token: "fake-token" },
56
57
  ): McpIoServerHandle & { closeSpy: ReturnType<typeof mock> } {
57
58
  const closeSpy = mock(async () => {});
58
59
  return {
59
- connection: { url: "http://127.0.0.1:9999/mcp", token: "fake-token" },
60
+ connection,
60
61
  artifactsWritten() {
61
62
  return [...artifacts];
62
63
  },
@@ -106,6 +107,26 @@ function makeSuccessResult(overrides?: Partial<RunResult>): RunResult {
106
107
  };
107
108
  }
108
109
 
110
+ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (err: unknown) => void } {
111
+ let resolve!: (value: T) => void;
112
+ let reject!: (err: unknown) => void;
113
+ const promise = new Promise<T>((res, rej) => {
114
+ resolve = res;
115
+ reject = rej;
116
+ });
117
+ return { promise, resolve, reject };
118
+ }
119
+
120
+ async function waitFor(predicate: () => boolean, message: string): Promise<void> {
121
+ const deadline = Date.now() + 1_000;
122
+ while (!predicate()) {
123
+ if (Date.now() > deadline) {
124
+ throw new Error(message);
125
+ }
126
+ await new Promise((resolve) => setTimeout(resolve, 1));
127
+ }
128
+ }
129
+
109
130
  function makeDeps(result: RunResult | Error) {
110
131
  const run = mock(() => makeFakeHarnessRun(result));
111
132
  const startMcpIoServer = mock(async () => createFakeMcpHandle());
@@ -178,6 +199,281 @@ describe("executeAgent", () => {
178
199
 
179
200
  expect(startMcpIoServer).not.toHaveBeenCalled();
180
201
  });
202
+
203
+ it("passes adapter-native MCP servers for claude", async () => {
204
+ const io = createFakeIO();
205
+ let capturedMcpServers: unknown;
206
+ const run = mock((opts: { mcpServers?: unknown }) => {
207
+ capturedMcpServers = opts.mcpServers;
208
+ return makeFakeHarnessRun(makeSuccessResult());
209
+ });
210
+ const startMcpIoServer = mock(async () => createFakeMcpHandle());
211
+
212
+ await executeAgent(
213
+ { io, entry: { name: "a", harness: "claude", prompt: "p" } },
214
+ { run, startMcpIoServer },
215
+ );
216
+
217
+ expect(capturedMcpServers).toEqual([{
218
+ name: "popio",
219
+ url: "http://127.0.0.1:9999/mcp",
220
+ headers: { Authorization: "Bearer fake-token" },
221
+ }]);
222
+ });
223
+
224
+ it("bridges MCP for codex through a temporary binary wrapper", async () => {
225
+ const io = createFakeIO();
226
+ const previousPopBin = process.env.POP_CODEX_BIN;
227
+ const previousAdapterBin = process.env.LLM_ADAPTER_CODEX_BIN;
228
+ const previousRealBin = process.env.POP_REAL_CODEX_BIN;
229
+ const previousMcpUrlConfig = process.env.POP_MCP_URL_CONFIG;
230
+ const previousMcpToken = process.env.POP_MCP_TOKEN;
231
+ let wrapperPath = "";
232
+ let capturedMcpServers: unknown;
233
+ try {
234
+ process.env.POP_CODEX_BIN = "/custom/codex";
235
+ delete process.env.LLM_ADAPTER_CODEX_BIN;
236
+ delete process.env.POP_REAL_CODEX_BIN;
237
+ delete process.env.POP_MCP_URL_CONFIG;
238
+ delete process.env.POP_MCP_TOKEN;
239
+ const run = mock((opts: { mcpServers?: unknown; onEvent?: (event: HarnessEvent) => void }) => {
240
+ capturedMcpServers = opts.mcpServers;
241
+ wrapperPath = process.env.LLM_ADAPTER_CODEX_BIN ?? "";
242
+ expect(wrapperPath).toContain("codex-mcp-");
243
+ expect(readFileSync(wrapperPath, "utf8")).toContain("POP_MCP_URL_CONFIG");
244
+ expect(process.env.POP_REAL_CODEX_BIN).toBe("/custom/codex");
245
+ expect(process.env.POP_MCP_URL_CONFIG).toBe(
246
+ 'mcp_servers.popio.url="http://127.0.0.1:9999/mcp"',
247
+ );
248
+ expect(process.env.POP_MCP_TOKEN).toBe("fake-token");
249
+ opts.onEvent?.({
250
+ harness: "codex",
251
+ seq: 0,
252
+ at: Date.now(),
253
+ raw: { type: "result", total_cost_usd: 0.42 },
254
+ type: "raw",
255
+ });
256
+ return makeFakeHarnessRun(makeSuccessResult());
257
+ });
258
+ const startMcpIoServer = mock(async () => createFakeMcpHandle());
259
+
260
+ const result = await executeAgent(
261
+ { io, entry: { name: "a", harness: "codex", prompt: "p" } },
262
+ { run, startMcpIoServer },
263
+ );
264
+
265
+ expect(capturedMcpServers).toBeUndefined();
266
+ expect(result.costUsd).toBe(0.42);
267
+ expect(process.env.LLM_ADAPTER_CODEX_BIN).toBeUndefined();
268
+ expect(process.env.POP_REAL_CODEX_BIN).toBeUndefined();
269
+ expect(process.env.POP_MCP_URL_CONFIG).toBeUndefined();
270
+ expect(process.env.POP_MCP_TOKEN).toBeUndefined();
271
+ expect(existsSync(wrapperPath)).toBe(false);
272
+ } finally {
273
+ if (previousPopBin === undefined) {
274
+ delete process.env.POP_CODEX_BIN;
275
+ } else {
276
+ process.env.POP_CODEX_BIN = previousPopBin;
277
+ }
278
+ if (previousAdapterBin === undefined) {
279
+ delete process.env.LLM_ADAPTER_CODEX_BIN;
280
+ } else {
281
+ process.env.LLM_ADAPTER_CODEX_BIN = previousAdapterBin;
282
+ }
283
+ if (previousRealBin === undefined) {
284
+ delete process.env.POP_REAL_CODEX_BIN;
285
+ } else {
286
+ process.env.POP_REAL_CODEX_BIN = previousRealBin;
287
+ }
288
+ if (previousMcpUrlConfig === undefined) {
289
+ delete process.env.POP_MCP_URL_CONFIG;
290
+ } else {
291
+ process.env.POP_MCP_URL_CONFIG = previousMcpUrlConfig;
292
+ }
293
+ if (previousMcpToken === undefined) {
294
+ delete process.env.POP_MCP_TOKEN;
295
+ } else {
296
+ process.env.POP_MCP_TOKEN = previousMcpToken;
297
+ }
298
+ }
299
+ });
300
+
301
+ it("serializes env-backed MCP compatibility for concurrent codex runs", async () => {
302
+ const previousPopBin = process.env.POP_CODEX_BIN;
303
+ const previousAdapterBin = process.env.LLM_ADAPTER_CODEX_BIN;
304
+ const previousRealBin = process.env.POP_REAL_CODEX_BIN;
305
+ const previousMcpUrlConfig = process.env.POP_MCP_URL_CONFIG;
306
+ const previousMcpToken = process.env.POP_MCP_TOKEN;
307
+ const firstRunResult = deferred<RunResult>();
308
+ const secondRunResult = deferred<RunResult>();
309
+ const observedTokens: Array<string | undefined> = [];
310
+ let runCount = 0;
311
+ let startCount = 0;
312
+ try {
313
+ process.env.POP_CODEX_BIN = "/custom/codex";
314
+ delete process.env.LLM_ADAPTER_CODEX_BIN;
315
+ delete process.env.POP_REAL_CODEX_BIN;
316
+ delete process.env.POP_MCP_URL_CONFIG;
317
+ delete process.env.POP_MCP_TOKEN;
318
+ const run = mock(() => {
319
+ runCount += 1;
320
+ observedTokens.push(process.env.POP_MCP_TOKEN);
321
+ return {
322
+ result: runCount === 1 ? firstRunResult.promise : secondRunResult.promise,
323
+ sessionId: Promise.resolve(`sess-${runCount}`),
324
+ abort() {},
325
+ };
326
+ });
327
+ const startMcpIoServer = mock(async () => {
328
+ startCount += 1;
329
+ return createFakeMcpHandle([], {
330
+ url: `http://127.0.0.1:999${startCount}/mcp`,
331
+ token: `token-${startCount}`,
332
+ });
333
+ });
334
+
335
+ const first = executeAgent(
336
+ { io: createFakeIO(), entry: { name: "a", harness: "codex", prompt: "p" } },
337
+ { run, startMcpIoServer },
338
+ );
339
+ const second = executeAgent(
340
+ { io: createFakeIO(), entry: { name: "b", harness: "codex", prompt: "p" } },
341
+ { run, startMcpIoServer },
342
+ );
343
+
344
+ await waitFor(() => runCount === 1, "first codex run did not start");
345
+ expect(runCount).toBe(1);
346
+ expect(observedTokens).toHaveLength(1);
347
+ const firstObservedToken = observedTokens[0];
348
+ if (firstObservedToken === undefined) {
349
+ throw new Error("first codex run did not receive an MCP token");
350
+ }
351
+ expect(["token-1", "token-2"]).toContain(firstObservedToken);
352
+
353
+ firstRunResult.resolve(makeSuccessResult({ finalMessage: "run-1" }));
354
+ await waitFor(() => runCount === 2, "second codex run did not start");
355
+ expect(runCount).toBe(2);
356
+ expect(new Set(observedTokens)).toEqual(new Set(["token-1", "token-2"]));
357
+
358
+ secondRunResult.resolve(makeSuccessResult({ finalMessage: "run-2" }));
359
+ const results = await Promise.all([first, second]);
360
+ expect(new Set(results.map((r) => r.finalMessage))).toEqual(new Set(["run-1", "run-2"]));
361
+ expect(process.env.POP_MCP_TOKEN).toBeUndefined();
362
+ expect(process.env.POP_MCP_URL_CONFIG).toBeUndefined();
363
+ } finally {
364
+ firstRunResult.resolve(makeSuccessResult());
365
+ secondRunResult.resolve(makeSuccessResult());
366
+ if (previousPopBin === undefined) {
367
+ delete process.env.POP_CODEX_BIN;
368
+ } else {
369
+ process.env.POP_CODEX_BIN = previousPopBin;
370
+ }
371
+ if (previousAdapterBin === undefined) {
372
+ delete process.env.LLM_ADAPTER_CODEX_BIN;
373
+ } else {
374
+ process.env.LLM_ADAPTER_CODEX_BIN = previousAdapterBin;
375
+ }
376
+ if (previousRealBin === undefined) {
377
+ delete process.env.POP_REAL_CODEX_BIN;
378
+ } else {
379
+ process.env.POP_REAL_CODEX_BIN = previousRealBin;
380
+ }
381
+ if (previousMcpUrlConfig === undefined) {
382
+ delete process.env.POP_MCP_URL_CONFIG;
383
+ } else {
384
+ process.env.POP_MCP_URL_CONFIG = previousMcpUrlConfig;
385
+ }
386
+ if (previousMcpToken === undefined) {
387
+ delete process.env.POP_MCP_TOKEN;
388
+ } else {
389
+ process.env.POP_MCP_TOKEN = previousMcpToken;
390
+ }
391
+ }
392
+ });
393
+
394
+ it("bridges MCP for opencode through OPENCODE_CONFIG_DIR and extracts raw cost", async () => {
395
+ const io = createFakeIO();
396
+ const previousConfigDir = process.env.OPENCODE_CONFIG_DIR;
397
+ const sourceConfigDir = mkdtempSync(join(tmpdir(), "opencode-existing-"));
398
+ let configDir = "";
399
+ let capturedMcpServers: unknown;
400
+ try {
401
+ writeFileSync(
402
+ join(sourceConfigDir, "opencode.json"),
403
+ JSON.stringify({
404
+ provider: "kept",
405
+ mcp: {
406
+ existing: { type: "local", command: ["tool"] },
407
+ },
408
+ }),
409
+ );
410
+ process.env.OPENCODE_CONFIG_DIR = sourceConfigDir;
411
+ const run = mock((opts: { mcpServers?: unknown; onEvent?: (event: HarnessEvent) => void }) => {
412
+ capturedMcpServers = opts.mcpServers;
413
+ configDir = process.env.OPENCODE_CONFIG_DIR ?? "";
414
+ const config = JSON.parse(readFileSync(join(configDir, "opencode.json"), "utf8")) as Record<string, unknown>;
415
+ expect(config).toMatchObject({
416
+ provider: "kept",
417
+ mcp: {
418
+ existing: { type: "local", command: ["tool"] },
419
+ popio: {
420
+ type: "remote",
421
+ url: "http://127.0.0.1:9999/mcp",
422
+ headers: { Authorization: "Bearer fake-token" },
423
+ enabled: true,
424
+ },
425
+ },
426
+ });
427
+ opts.onEvent?.({
428
+ harness: "opencode",
429
+ seq: 0,
430
+ at: Date.now(),
431
+ raw: { type: "result", info: { costUsd: 0.13 } },
432
+ type: "raw",
433
+ });
434
+ return makeFakeHarnessRun(makeSuccessResult());
435
+ });
436
+ const startMcpIoServer = mock(async () => createFakeMcpHandle());
437
+
438
+ const result = await executeAgent(
439
+ { io, entry: { name: "a", harness: "opencode", prompt: "p" } },
440
+ { run, startMcpIoServer },
441
+ );
442
+
443
+ expect(capturedMcpServers).toBeUndefined();
444
+ expect(result.costUsd).toBe(0.13);
445
+ expect(process.env.OPENCODE_CONFIG_DIR).toBe(sourceConfigDir);
446
+ expect(existsSync(configDir)).toBe(false);
447
+ } finally {
448
+ rmSync(sourceConfigDir, { recursive: true, force: true });
449
+ if (previousConfigDir === undefined) {
450
+ delete process.env.OPENCODE_CONFIG_DIR;
451
+ } else {
452
+ process.env.OPENCODE_CONFIG_DIR = previousConfigDir;
453
+ }
454
+ }
455
+ });
456
+
457
+ it("preserves usage and cost when artifact writes fail after the harness run", async () => {
458
+ const io = createFakeIO();
459
+ io.writeArtifact = async () => {
460
+ throw new Error("disk full");
461
+ };
462
+ const run = mock(() => makeFakeHarnessRun(makeSuccessResult({ costUsd: 0.21 })));
463
+ const startMcpIoServer = mock(async () => createFakeMcpHandle(["agent-output.md"]));
464
+
465
+ const result = await executeAgent(
466
+ { io, entry: { name: "a", harness: "claude", prompt: "p" } },
467
+ { run, startMcpIoServer },
468
+ );
469
+
470
+ expect(result.ok).toBe(false);
471
+ expect(result.error).toBe("disk full");
472
+ expect(result.usage).toEqual({ inputTokens: 100, outputTokens: 50, totalTokens: 150 });
473
+ expect(result.costUsd).toBe(0.21);
474
+ expect(result.sessionId).toBe("sess-1");
475
+ expect(result.artifactsWritten).toContain("agent-output.md");
476
+ });
181
477
  });
182
478
 
183
479
  describe("runAgentStep", () => {
@@ -293,16 +589,39 @@ describe("runAgentStep", () => {
293
589
  it("MCP server is closed on timeout path", async () => {
294
590
  const mcpHandle = createFakeMcpHandle();
295
591
  const createTaskFileIO = mock(() => createFakeIO());
592
+ let capturedTimeoutMs: number | undefined;
593
+ let capturedIdleTimeoutMs: number | undefined;
296
594
  const deps = {
297
- run: mock(() => makeFakeHarnessRun(new Error('Harness "claude" timed out after 100ms'))),
595
+ run: mock((opts) => {
596
+ capturedTimeoutMs = opts.timeoutMs;
597
+ capturedIdleTimeoutMs = opts.idleTimeoutMs;
598
+ return makeFakeHarnessRun(new Error('Harness "claude" timed out after 100ms'));
599
+ }),
298
600
  startMcpIoServer: mock(async () => mcpHandle),
299
601
  createTaskFileIO,
300
602
  };
301
603
 
302
604
  await runAgentStep(makeArgs({ entry: { timeoutMs: 100 } }), deps);
605
+ expect(capturedTimeoutMs).toBe(100);
606
+ expect(capturedIdleTimeoutMs).toBeUndefined();
303
607
  expect(mcpHandle.closeSpy).toHaveBeenCalled();
304
608
  });
305
609
 
610
+ it("passes an explicit idle timeout through to the adapter", async () => {
611
+ let capturedIdleTimeoutMs: number | undefined;
612
+ const deps = {
613
+ run: mock((opts) => {
614
+ capturedIdleTimeoutMs = opts.idleTimeoutMs;
615
+ return makeFakeHarnessRun(makeSuccessResult());
616
+ }),
617
+ startMcpIoServer: mock(async () => createFakeMcpHandle()),
618
+ createTaskFileIO: mock(() => createFakeIO()),
619
+ };
620
+
621
+ await runAgentStep(makeArgs({ entry: { idleTimeoutMs: 250 } }), deps);
622
+ expect(capturedIdleTimeoutMs).toBe(250);
623
+ });
624
+
306
625
  it("does not start MCP server when io is false", async () => {
307
626
  const createTaskFileIO = mock(() => createFakeIO());
308
627
  const deps = {
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect, afterEach } from "bun:test";
2
- import { defaultConfig, resetConfig, loadConfig, getConfig, getConfigValue, getPipelineConfig } from "../config";
2
+ import { defaultConfig, resetConfig, loadConfig, getConfig, getConfigValue, getPipelineConfig, getOrchestratorConfig } from "../config";
3
3
  import type { AppConfig } from "../config";
4
4
  import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
@@ -194,6 +194,36 @@ describe("PO_MAX_RUNNING_JOBS env override", () => {
194
194
  });
195
195
  });
196
196
 
197
+ describe("PO_STALE_RUNNING_GRACE_MS env override", () => {
198
+ afterEach(() => {
199
+ delete process.env.PO_STALE_RUNNING_GRACE_MS;
200
+ resetConfig();
201
+ });
202
+
203
+ test("getOrchestratorConfig defaults staleRunningGraceMs to 30000", () => {
204
+ resetConfig();
205
+ expect(getOrchestratorConfig().staleRunningGraceMs).toBe(30000);
206
+ });
207
+
208
+ test("getOrchestratorConfig reads PO_STALE_RUNNING_GRACE_MS into orchestrator.staleRunningGraceMs", () => {
209
+ process.env.PO_STALE_RUNNING_GRACE_MS = "60000";
210
+ resetConfig();
211
+ expect(getOrchestratorConfig().staleRunningGraceMs).toBe(60000);
212
+ });
213
+
214
+ test("getOrchestratorConfig rejects non-positive PO_STALE_RUNNING_GRACE_MS", () => {
215
+ process.env.PO_STALE_RUNNING_GRACE_MS = "-1";
216
+ resetConfig();
217
+ expect(() => getOrchestratorConfig()).toThrow("orchestrator.staleRunningGraceMs");
218
+ });
219
+
220
+ test("getOrchestratorConfig rejects non-integer PO_STALE_RUNNING_GRACE_MS", () => {
221
+ process.env.PO_STALE_RUNNING_GRACE_MS = "1.5";
222
+ resetConfig();
223
+ expect(() => getOrchestratorConfig()).toThrow("orchestrator.staleRunningGraceMs");
224
+ });
225
+ });
226
+
197
227
  describe("getConfig", () => {
198
228
  afterEach(() => {
199
229
  resetConfig();
@@ -9,9 +9,11 @@ import {
9
9
  getJobConcurrencyStatus,
10
10
  listQueuedSeeds,
11
11
  pruneStaleJobSlots,
12
+ readJobSlotLease,
12
13
  releaseJobSlot,
13
14
  tryAcquireJobSlot,
14
15
  updateJobSlotPid,
16
+ withRuntimeLock,
15
17
  type JobSlotLease,
16
18
  } from "../job-concurrency";
17
19
  import { readFile } from "node:fs/promises";
@@ -552,3 +554,61 @@ describe("getJobConcurrencyStatus", () => {
552
554
  }
553
555
  });
554
556
  });
557
+
558
+ describe("readJobSlotLease", () => {
559
+ test("returns the lease written by tryAcquireJobSlot", async () => {
560
+ const dir = await setupJobsDir("read-lease-acquired-");
561
+ try {
562
+ await makeCurrent(dir, "job-a");
563
+ const acquired = await tryAcquireJobSlot({
564
+ dataDir: dir,
565
+ jobId: "job-a",
566
+ maxConcurrentJobs: 2,
567
+ source: "orchestrator",
568
+ pid: process.pid,
569
+ });
570
+ expect(acquired.ok).toBe(true);
571
+
572
+ const lease = await readJobSlotLease(dir, "job-a");
573
+ expect(lease).not.toBeNull();
574
+ expect(lease!.jobId).toBe("job-a");
575
+ expect(lease!.pid).toBe(process.pid);
576
+ expect(lease!.source).toBe("orchestrator");
577
+ } finally {
578
+ await rm(dir, { recursive: true });
579
+ }
580
+ });
581
+
582
+ test("returns null for an unknown job", async () => {
583
+ const dir = await setupJobsDir("read-lease-unknown-");
584
+ try {
585
+ expect(await readJobSlotLease(dir, "unknown")).toBeNull();
586
+ } finally {
587
+ await rm(dir, { recursive: true });
588
+ }
589
+ });
590
+ });
591
+
592
+ describe("withRuntimeLock", () => {
593
+ test("serializes two concurrent callers", async () => {
594
+ const dir = await setupJobsDir("runtime-lock-serialize-");
595
+ const markerPath = join(dir, "marker.txt");
596
+ const events: string[] = [];
597
+ try {
598
+ const a = withRuntimeLock(dir, 5000, async () => {
599
+ await new Promise((resolve) => setTimeout(resolve, 50));
600
+ await writeFile(markerPath, "from-a");
601
+ events.push("a-wrote");
602
+ });
603
+ const b = withRuntimeLock(dir, 5000, async () => {
604
+ const observed = await readFile(markerPath, "utf-8");
605
+ events.push(`b-read:${observed}`);
606
+ });
607
+ await Promise.all([a, b]);
608
+
609
+ expect(events).toEqual(["a-wrote", "b-read:from-a"]);
610
+ } finally {
611
+ await rm(dir, { recursive: true });
612
+ }
613
+ });
614
+ });