@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
@@ -4,7 +4,11 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
6
  import { runPipeline } from "../task-runner";
7
+ import { deriveModelKeyAndTokens, deriveAgentUsageTuple, recordAgentUsage } from "../task-runner";
8
+ import type { AgentStepResult } from "../agent-types";
9
+ import { getLLMEvents } from "../../llm/index";
7
10
  import type { StatusSnapshot } from "../status-writer";
11
+ import type { TokenUsageTuple } from "../task-runner";
8
12
 
9
13
  const tempRoots: string[] = [];
10
14
 
@@ -266,11 +270,11 @@ describe("runPipeline runAgent injection", () => {
266
270
 
267
271
  const status = JSON.parse(await readFile(path.join(workDir, "tasks-status.json"), "utf8")) as StatusSnapshot;
268
272
  expect(status.tasks["agent-task"]?.tokenUsage).toEqual([
269
- ["claude:default", 12, 7, 0.03],
273
+ ["claude:default", 12, 7, 0.03, "reported", false, false],
270
274
  ]);
271
275
  });
272
276
 
273
- it("records runAgent cost when normalized usage is absent", async () => {
277
+ it("records runAgent cost as unavailable when normalized usage is absent", async () => {
274
278
  const root = await makeTempRoot();
275
279
  const workDir = path.join(root, "job-agent-cost");
276
280
  await mkdir(workDir, { recursive: true });
@@ -311,7 +315,594 @@ describe("runPipeline runAgent injection", () => {
311
315
 
312
316
  const status = JSON.parse(await readFile(path.join(workDir, "tasks-status.json"), "utf8")) as StatusSnapshot;
313
317
  expect(status.tasks["agent-cost-task"]?.tokenUsage).toEqual([
314
- ["opencode:gpt-4o", 0, 0, 0.12],
318
+ ["opencode:gpt-4o", 0, 0, 0.12, "unavailable", false, true],
315
319
  ]);
316
320
  });
317
321
  });
322
+
323
+ describe("TokenUsageTuple", () => {
324
+ it("accepts a 5-element success tuple", () => {
325
+ const tuple: TokenUsageTuple = ["openai:gpt-4o", 10, 20, 0.05, "reported"];
326
+ expect(tuple[0]).toBe("openai:gpt-4o");
327
+ expect(tuple[4]).toBe("reported");
328
+ expect(tuple[5]).toBeUndefined();
329
+ });
330
+
331
+ it("accepts a 6-element failed tuple", () => {
332
+ const tuple: TokenUsageTuple = ["openai:gpt-4o", 10, 0, 0.01, "estimated", true];
333
+ expect(tuple[4]).toBe("estimated");
334
+ expect(tuple[5]).toBe(true);
335
+ });
336
+
337
+ it("accepts a 7-element tuple with costEstimated after the failed marker", () => {
338
+ const tuple: TokenUsageTuple = ["openai:gpt-4o", 10, 20, 0, "reported", false, true];
339
+ expect(tuple[4]).toBe("reported");
340
+ expect(tuple[5]).toBe(false);
341
+ expect(tuple[6]).toBe(true);
342
+ });
343
+ });
344
+
345
+ describe("deriveModelKeyAndTokens", () => {
346
+ it("reads usageSource from a complete-event metric", () => {
347
+ const metric = {
348
+ provider: "openai",
349
+ model: "gpt-4o",
350
+ metadata: { alias: "gpt-4o" },
351
+ promptTokens: 100,
352
+ completionTokens: 20,
353
+ totalTokens: 120,
354
+ cost: 0.005,
355
+ usageSource: "reported",
356
+ };
357
+
358
+ expect(deriveModelKeyAndTokens(metric)).toEqual([
359
+ "gpt-4o",
360
+ 100,
361
+ 20,
362
+ 0.005,
363
+ "reported",
364
+ false,
365
+ false,
366
+ ]);
367
+ });
368
+
369
+ it("preserves reported usage with estimated cost", () => {
370
+ const tuple = deriveModelKeyAndTokens({
371
+ provider: "openai",
372
+ model: "new-model",
373
+ promptTokens: 10,
374
+ completionTokens: 5,
375
+ cost: 0,
376
+ usageSource: "reported",
377
+ costEstimated: true,
378
+ });
379
+
380
+ expect(tuple).toEqual([
381
+ "openai:new-model",
382
+ 10,
383
+ 5,
384
+ 0,
385
+ "reported",
386
+ false,
387
+ true,
388
+ ]);
389
+ });
390
+
391
+ it("preserves estimated/unavailable/zero sources", () => {
392
+ for (const source of ["estimated", "unavailable", "zero"] as const) {
393
+ const tuple = deriveModelKeyAndTokens({
394
+ provider: "openai",
395
+ model: "gpt-4o",
396
+ promptTokens: 1,
397
+ completionTokens: 2,
398
+ cost: 0,
399
+ usageSource: source,
400
+ });
401
+ expect(tuple[4]).toBe(source);
402
+ }
403
+ });
404
+
405
+ it("falls back to reported when usageSource is absent (legacy metric)", () => {
406
+ const tuple = deriveModelKeyAndTokens({
407
+ provider: "openai",
408
+ model: "gpt-4o",
409
+ promptTokens: 1,
410
+ completionTokens: 2,
411
+ cost: 0,
412
+ });
413
+ expect(tuple[4]).toBe("reported");
414
+ });
415
+
416
+ it("falls back to reported when usageSource is unrecognized", () => {
417
+ const tuple = deriveModelKeyAndTokens({
418
+ provider: "openai",
419
+ model: "gpt-4o",
420
+ promptTokens: 1,
421
+ completionTokens: 2,
422
+ cost: 0,
423
+ usageSource: "bogus",
424
+ });
425
+ expect(tuple[4]).toBe("reported");
426
+ });
427
+ });
428
+
429
+ describe("usage source and failed-marker persistence", () => {
430
+ it("writes costEstimated after the failed marker on llm:request:complete", async () => {
431
+ const root = await makeTempRoot();
432
+ const workDir = path.join(root, "job-complete");
433
+ await mkdir(workDir, { recursive: true });
434
+ await writeFile(
435
+ path.join(workDir, "tasks-status.json"),
436
+ JSON.stringify({ id: "job-complete", tasks: {} }),
437
+ );
438
+
439
+ const modulePath = path.join(root, "task-noop.mjs");
440
+ await writeFile(
441
+ modulePath,
442
+ "export const ingestion = async ({ flags }) => ({ output: { ok: true }, flags });",
443
+ );
444
+
445
+ const result = await runPipeline(modulePath, {
446
+ workDir,
447
+ taskName: "task-complete",
448
+ statusPath: path.join(workDir, "tasks-status.json"),
449
+ jobId: "job-complete",
450
+ envLoaded: true,
451
+ seed: { data: {} },
452
+ pipelineTasks: ["task-complete"],
453
+ llm: {} as never,
454
+ tasksOverride: {
455
+ ingestion: async ({ flags }) => {
456
+ getLLMEvents().emit("llm:request:complete", {
457
+ id: "evt-1",
458
+ provider: "openai",
459
+ model: "gpt-4o",
460
+ metadata: { alias: "gpt-4o" },
461
+ timestamp: new Date().toISOString(),
462
+ duration: 50,
463
+ promptTokens: 100,
464
+ completionTokens: 20,
465
+ totalTokens: 120,
466
+ cost: 0.005,
467
+ usageSource: "reported",
468
+ costEstimated: false,
469
+ });
470
+ return { output: { ok: true }, flags };
471
+ },
472
+ },
473
+ });
474
+
475
+ expect(result.ok).toBe(true);
476
+
477
+ const status = JSON.parse(
478
+ await readFile(path.join(workDir, "tasks-status.json"), "utf8"),
479
+ ) as StatusSnapshot;
480
+
481
+ expect(status.tasks["task-complete"]?.tokenUsage).toEqual([
482
+ ["gpt-4o", 100, 20, 0.005, "reported", false, false],
483
+ ]);
484
+ });
485
+
486
+ it("writes failed=true and costEstimated on llm:request:error", async () => {
487
+ const root = await makeTempRoot();
488
+ const workDir = path.join(root, "job-error");
489
+ await mkdir(workDir, { recursive: true });
490
+ await writeFile(
491
+ path.join(workDir, "tasks-status.json"),
492
+ JSON.stringify({ id: "job-error", tasks: {} }),
493
+ );
494
+
495
+ const modulePath = path.join(root, "task-noop.mjs");
496
+ await writeFile(
497
+ modulePath,
498
+ "export const ingestion = async ({ flags }) => ({ output: { ok: true }, flags });",
499
+ );
500
+
501
+ const result = await runPipeline(modulePath, {
502
+ workDir,
503
+ taskName: "task-error",
504
+ statusPath: path.join(workDir, "tasks-status.json"),
505
+ jobId: "job-error",
506
+ envLoaded: true,
507
+ seed: { data: {} },
508
+ pipelineTasks: ["task-error"],
509
+ llm: {} as never,
510
+ tasksOverride: {
511
+ ingestion: async ({ flags }) => {
512
+ getLLMEvents().emit("llm:request:error", {
513
+ id: "evt-err",
514
+ provider: "openai",
515
+ model: "gpt-4o",
516
+ metadata: { alias: "gpt-4o" },
517
+ timestamp: new Date().toISOString(),
518
+ duration: 12,
519
+ error: "boom",
520
+ promptTokens: 80,
521
+ completionTokens: 0,
522
+ totalTokens: 80,
523
+ cost: 0.002,
524
+ usageSource: "estimated",
525
+ costEstimated: true,
526
+ });
527
+ return { output: { ok: true }, flags };
528
+ },
529
+ },
530
+ });
531
+
532
+ expect(result.ok).toBe(true);
533
+
534
+ const status = JSON.parse(
535
+ await readFile(path.join(workDir, "tasks-status.json"), "utf8"),
536
+ ) as StatusSnapshot;
537
+
538
+ expect(status.tasks["task-error"]?.tokenUsage).toEqual([
539
+ ["gpt-4o", 80, 0, 0.002, "estimated", true, true],
540
+ ]);
541
+ });
542
+
543
+ it("preserves a legacy 4-element tokenUsage tuple across a run", async () => {
544
+ const root = await makeTempRoot();
545
+ const workDir = path.join(root, "job-legacy");
546
+ await mkdir(workDir, { recursive: true });
547
+ const statusPath = path.join(workDir, "tasks-status.json");
548
+ await writeFile(
549
+ statusPath,
550
+ JSON.stringify({
551
+ id: "job-legacy",
552
+ tasks: {
553
+ "task-legacy": {
554
+ tokenUsage: [["openai:gpt-3.5", 5, 5, 0.0001]],
555
+ },
556
+ },
557
+ }),
558
+ );
559
+
560
+ const modulePath = path.join(root, "task-legacy.mjs");
561
+ await writeFile(
562
+ modulePath,
563
+ "export const ingestion = async ({ flags }) => ({ output: { ok: true }, flags });",
564
+ );
565
+
566
+ const result = await runPipeline(modulePath, {
567
+ workDir,
568
+ taskName: "task-legacy",
569
+ statusPath,
570
+ jobId: "job-legacy",
571
+ envLoaded: true,
572
+ seed: { data: {} },
573
+ pipelineTasks: ["task-legacy"],
574
+ llm: {} as never,
575
+ tasksOverride: {
576
+ ingestion: async ({ flags }) => {
577
+ getLLMEvents().emit("llm:request:complete", {
578
+ id: "evt-new",
579
+ provider: "openai",
580
+ model: "gpt-4o",
581
+ metadata: { alias: "gpt-4o" },
582
+ timestamp: new Date().toISOString(),
583
+ duration: 1,
584
+ promptTokens: 10,
585
+ completionTokens: 4,
586
+ totalTokens: 14,
587
+ cost: 0.001,
588
+ usageSource: "reported",
589
+ costEstimated: false,
590
+ });
591
+ return { output: { ok: true }, flags };
592
+ },
593
+ },
594
+ });
595
+
596
+ expect(result.ok).toBe(true);
597
+
598
+ const status = JSON.parse(
599
+ await readFile(statusPath, "utf8"),
600
+ ) as StatusSnapshot;
601
+
602
+ expect(status.tasks["task-legacy"]?.tokenUsage).toEqual([
603
+ ["openai:gpt-3.5", 5, 5, 0.0001],
604
+ ["gpt-4o", 10, 4, 0.001, "reported", false, false],
605
+ ]);
606
+ });
607
+ });
608
+
609
+ describe("deriveAgentUsageTuple", () => {
610
+ const baseResult: AgentStepResult = {
611
+ ok: true,
612
+ finalMessage: "",
613
+ artifactsWritten: [],
614
+ };
615
+
616
+ it("classifies a present usage object as reported", () => {
617
+ const result: AgentStepResult = {
618
+ ...baseResult,
619
+ usage: { inputTokens: 100, outputTokens: 40, totalTokens: 140 },
620
+ costUsd: 0.0123,
621
+ };
622
+
623
+ expect(deriveAgentUsageTuple({ harness: "claude", model: "opus" }, result)).toEqual([
624
+ "claude:opus",
625
+ 100,
626
+ 40,
627
+ 0.0123,
628
+ "reported",
629
+ false,
630
+ false,
631
+ ]);
632
+ });
633
+
634
+ it("classifies a zero-token usage object as reported", () => {
635
+ const result: AgentStepResult = {
636
+ ...baseResult,
637
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
638
+ };
639
+
640
+ expect(deriveAgentUsageTuple({ harness: "codex" }, result)).toEqual([
641
+ "codex:default",
642
+ 0,
643
+ 0,
644
+ 0,
645
+ "reported",
646
+ false,
647
+ false,
648
+ ]);
649
+ });
650
+
651
+ it("classifies cost-only result as unavailable with tokens zeroed and cost preserved", () => {
652
+ const result: AgentStepResult = { ...baseResult, costUsd: 0.5 };
653
+
654
+ expect(deriveAgentUsageTuple({ harness: "opencode", model: "gpt-4o" }, result)).toEqual([
655
+ "opencode:gpt-4o",
656
+ 0,
657
+ 0,
658
+ 0.5,
659
+ "unavailable",
660
+ false,
661
+ true,
662
+ ]);
663
+ });
664
+
665
+ it("returns null when neither usage nor costUsd is present", () => {
666
+ expect(deriveAgentUsageTuple({ harness: "claude" }, baseResult)).toBeNull();
667
+ });
668
+
669
+ it("returns null when costUsd is non-finite and no usage is present", () => {
670
+ const result: AgentStepResult = { ...baseResult, costUsd: Number.NaN };
671
+
672
+ expect(deriveAgentUsageTuple({ harness: "claude" }, result)).toBeNull();
673
+ });
674
+
675
+ it("sets the failed flag when opts.failed is requested", () => {
676
+ const usageResult: AgentStepResult = {
677
+ ...baseResult,
678
+ ok: false,
679
+ usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6 },
680
+ };
681
+ const costResult: AgentStepResult = { ...baseResult, ok: false, costUsd: 0.2 };
682
+
683
+ expect(
684
+ deriveAgentUsageTuple({ harness: "claude" }, usageResult, { failed: true })?.[5],
685
+ ).toBe(true);
686
+ expect(
687
+ deriveAgentUsageTuple({ harness: "claude" }, costResult, { failed: true })?.[5],
688
+ ).toBe(true);
689
+ });
690
+ });
691
+
692
+ describe("recordAgentUsage", () => {
693
+ const baseResult: AgentStepResult = {
694
+ ok: true,
695
+ finalMessage: "",
696
+ artifactsWritten: [],
697
+ };
698
+
699
+ it("pushes the derived tuple exactly once when non-null", () => {
700
+ const result: AgentStepResult = {
701
+ ...baseResult,
702
+ usage: { inputTokens: 100, outputTokens: 40, totalTokens: 140 },
703
+ costUsd: 0.0123,
704
+ };
705
+ const pushed: TokenUsageTuple[] = [];
706
+
707
+ recordAgentUsage({ harness: "claude", model: "opus" }, result, (t) => pushed.push(t));
708
+
709
+ expect(pushed).toEqual([["claude:opus", 100, 40, 0.0123, "reported", false, false]]);
710
+ });
711
+
712
+ it("does not push when the derived tuple is null", () => {
713
+ const pushed: TokenUsageTuple[] = [];
714
+
715
+ recordAgentUsage({ harness: "claude" }, baseResult, (t) => pushed.push(t));
716
+
717
+ expect(pushed).toEqual([]);
718
+ });
719
+ });
720
+
721
+ describe("runPipeline console capture lifecycle", () => {
722
+ async function makeJob(): Promise<{ workDir: string; statusPath: string }> {
723
+ const root = await makeTempRoot();
724
+ const workDir = path.join(root, "job-console");
725
+ await mkdir(workDir, { recursive: true });
726
+ const statusPath = path.join(workDir, "tasks-status.json");
727
+ await writeFile(statusPath, JSON.stringify({ id: "job-console", tasks: {} }));
728
+ return { workDir, statusPath };
729
+ }
730
+
731
+ function run(modulePath: string, workDir: string, statusPath: string) {
732
+ return runPipeline(modulePath, {
733
+ workDir,
734
+ taskName: "research",
735
+ statusPath,
736
+ jobId: "job-console",
737
+ envLoaded: true,
738
+ seed: { data: { topic: "x" } },
739
+ pipelineTasks: ["research"],
740
+ llm: {} as never,
741
+ });
742
+ }
743
+
744
+ const succeeds =
745
+ "export const ingestion = async ({ flags }) => ({ output: { ok: true }, flags });";
746
+ const stageThrows = 'export const ingestion = async () => { throw new Error("boom"); };';
747
+
748
+ it("restores console methods and listener counts after a successful run", async () => {
749
+ const events = getLLMEvents();
750
+ const baseComplete = events.listenerCount("llm:request:complete");
751
+ const baseError = events.listenerCount("llm:request:error");
752
+
753
+ const origLog = console.log;
754
+ const origError = console.error;
755
+ const origWarn = console.warn;
756
+ const origInfo = console.info;
757
+ const origDebug = console.debug;
758
+
759
+ const { workDir, statusPath } = await makeJob();
760
+ const modulePath = path.join(workDir, "mod.mjs");
761
+ await writeFile(modulePath, succeeds);
762
+
763
+ const result = await run(modulePath, workDir, statusPath);
764
+ expect(result.ok).toBe(true);
765
+
766
+ expect(console.log).toBe(origLog);
767
+ expect(console.error).toBe(origError);
768
+ expect(console.warn).toBe(origWarn);
769
+ expect(console.info).toBe(origInfo);
770
+ expect(console.debug).toBe(origDebug);
771
+
772
+ expect(events.listenerCount("llm:request:complete")).toBe(baseComplete);
773
+ expect(events.listenerCount("llm:request:error")).toBe(baseError);
774
+ });
775
+
776
+ it("restores console methods and listener counts after a throwing stage", async () => {
777
+ const events = getLLMEvents();
778
+ const baseComplete = events.listenerCount("llm:request:complete");
779
+ const baseError = events.listenerCount("llm:request:error");
780
+
781
+ const origLog = console.log;
782
+ const origError = console.error;
783
+ const origWarn = console.warn;
784
+ const origInfo = console.info;
785
+ const origDebug = console.debug;
786
+
787
+ const { workDir, statusPath } = await makeJob();
788
+ const modulePath = path.join(workDir, "mod.mjs");
789
+ await writeFile(modulePath, stageThrows);
790
+
791
+ const result = await run(modulePath, workDir, statusPath);
792
+ expect(result.ok).toBe(false);
793
+
794
+ expect(console.log).toBe(origLog);
795
+ expect(console.error).toBe(origError);
796
+ expect(console.warn).toBe(origWarn);
797
+ expect(console.info).toBe(origInfo);
798
+ expect(console.debug).toBe(origDebug);
799
+
800
+ expect(events.listenerCount("llm:request:complete")).toBe(baseComplete);
801
+ expect(events.listenerCount("llm:request:error")).toBe(baseError);
802
+ });
803
+
804
+ it("preserves the stage failure return shape and registers the stage log", async () => {
805
+ const { workDir, statusPath } = await makeJob();
806
+ const modulePath = path.join(workDir, "mod.mjs");
807
+ await writeFile(modulePath, stageThrows);
808
+
809
+ const result = await run(modulePath, workDir, statusPath);
810
+
811
+ expect(result.ok).toBe(false);
812
+ if (!result.ok) {
813
+ expect(result.failedStage).toBe("ingestion");
814
+ expect(result.error.message).toBe("boom");
815
+ expect(result.error.debug.stage).toBe("ingestion");
816
+ }
817
+
818
+ const status = JSON.parse(await readFile(statusPath, "utf8")) as {
819
+ tasks?: Record<string, { files?: { logs?: string[] } }>;
820
+ };
821
+ expect(status.tasks?.["research"]?.files?.logs).toContain("research-ingestion-start.log");
822
+ });
823
+ });
824
+
825
+ describe("runPipeline LLM listener lifecycle", () => {
826
+ async function makeJob(): Promise<{ workDir: string; statusPath: string }> {
827
+ const root = await makeTempRoot();
828
+ const workDir = path.join(root, "job-lifecycle");
829
+ await mkdir(workDir, { recursive: true });
830
+ await writeFile(path.join(workDir, "seed.json"), JSON.stringify({ topic: "x" }));
831
+ const statusPath = path.join(workDir, "tasks-status.json");
832
+ await writeFile(statusPath, JSON.stringify({ id: "job-lifecycle", tasks: {} }));
833
+ return { workDir, statusPath };
834
+ }
835
+
836
+ async function writeModule(workDir: string, body: string): Promise<string> {
837
+ const modulePath = path.join(workDir, `mod-${Math.random().toString(36).slice(2)}.mjs`);
838
+ await writeFile(modulePath, body);
839
+ return modulePath;
840
+ }
841
+
842
+ const succeeds = "export const ingestion = async ({ flags }) => ({ output: { ok: true }, flags });";
843
+ const stageThrows = 'export const ingestion = async () => { throw new Error("boom"); };';
844
+ // A syntax error makes loadFreshModule reject before any stage runs.
845
+ const loadThrows = "export const ingestion = async ({ flags }) => {{{ ";
846
+
847
+ function run(modulePath: string, workDir: string, statusPath: string) {
848
+ return runPipeline(modulePath, {
849
+ workDir,
850
+ taskName: "research",
851
+ statusPath,
852
+ jobId: "job-lifecycle",
853
+ envLoaded: true,
854
+ seed: { data: { topic: "x" } },
855
+ pipelineTasks: ["research"],
856
+ llm: {} as never,
857
+ });
858
+ }
859
+
860
+ it("leaves listener counts at baseline when module load throws", async () => {
861
+ const events = getLLMEvents();
862
+ const baseComplete = events.listenerCount("llm:request:complete");
863
+ const baseError = events.listenerCount("llm:request:error");
864
+
865
+ const { workDir, statusPath } = await makeJob();
866
+ const modulePath = await writeModule(workDir, loadThrows);
867
+
868
+ await expect(run(modulePath, workDir, statusPath)).rejects.toThrow();
869
+
870
+ expect(events.listenerCount("llm:request:complete")).toBe(baseComplete);
871
+ expect(events.listenerCount("llm:request:error")).toBe(baseError);
872
+ });
873
+
874
+ it("does not grow listener counts or warn across repeated runs including throwing ones", async () => {
875
+ const events = getLLMEvents();
876
+ const baseComplete = events.listenerCount("llm:request:complete");
877
+ const baseError = events.listenerCount("llm:request:error");
878
+
879
+ const warnings: string[] = [];
880
+ const onWarning = (w: Error) => warnings.push(w.name);
881
+ process.on("warning", onWarning);
882
+
883
+ try {
884
+ for (let i = 0; i < 12; i++) {
885
+ const { workDir, statusPath } = await makeJob();
886
+ if (i % 3 === 0) {
887
+ const modulePath = await writeModule(workDir, loadThrows);
888
+ await expect(run(modulePath, workDir, statusPath)).rejects.toThrow();
889
+ } else if (i % 3 === 1) {
890
+ const modulePath = await writeModule(workDir, stageThrows);
891
+ const result = await run(modulePath, workDir, statusPath);
892
+ expect(result.ok).toBe(false);
893
+ } else {
894
+ const modulePath = await writeModule(workDir, succeeds);
895
+ const result = await run(modulePath, workDir, statusPath);
896
+ expect(result.ok).toBe(true);
897
+ }
898
+
899
+ expect(events.listenerCount("llm:request:complete")).toBe(baseComplete);
900
+ expect(events.listenerCount("llm:request:error")).toBe(baseError);
901
+ }
902
+ } finally {
903
+ process.removeListener("warning", onWarning);
904
+ }
905
+
906
+ expect(warnings).not.toContain("MaxListenersExceededWarning");
907
+ });
908
+ });