@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
@@ -15,10 +15,11 @@ import { loadEnvironment } from "./environment";
15
15
  import { createJobLogger } from "./logger";
16
16
  import { createHighLevelLLM, createLLMWithOverride, getLLMEvents } from "../llm/index";
17
17
  import { executeAgent } from "./agent-step";
18
+ import { withConsoleCapture, withTaskTelemetry } from "./task-telemetry";
18
19
  import { TaskState } from "../config/statuses";
19
20
  import { LogEvent, LogFileExtension } from "../config/log-events";
20
21
  // LLMClient: src/llm/index.ts exports HighLevelLLM (no LLMClient type exists there).
21
- import type { HighLevelLLM } from "../providers/types";
22
+ import type { HighLevelLLM, UsageSource } from "../providers/types";
22
23
  import type { AgentStepResult, TaskAgentOptions, TaskAgentRunner } from "./agent-types";
23
24
 
24
25
  /** Opaque alias — the LLM client passed into execution contexts. */
@@ -165,7 +166,15 @@ export interface LLMMetricRecord {
165
166
  }
166
167
 
167
168
  /** Token usage tuple written to the job status file. */
168
- export type TokenUsageTuple = [modelKey: string, inputTokens: number, outputTokens: number, cost: number];
169
+ export type TokenUsageTuple = [
170
+ modelKey: string,
171
+ inputTokens: number,
172
+ outputTokens: number,
173
+ cost: number,
174
+ source: UsageSource,
175
+ failed?: boolean,
176
+ costEstimated?: boolean,
177
+ ];
169
178
 
170
179
  // ─── Initial context ─────────────────────────────────────────────────────────
171
180
 
@@ -251,22 +260,84 @@ export function deriveModelKeyAndTokens(metric: Record<string, unknown>): TokenU
251
260
  const rawCost = metric["cost"];
252
261
  const cost = typeof rawCost === "number" && Number.isFinite(rawCost) ? rawCost : 0;
253
262
 
254
- return [modelKey, inputTokens, outputTokens, cost];
263
+ const rawSource = metric["usageSource"];
264
+ const source: UsageSource =
265
+ rawSource === "reported" ||
266
+ rawSource === "estimated" ||
267
+ rawSource === "unavailable" ||
268
+ rawSource === "zero"
269
+ ? rawSource
270
+ : "reported";
271
+
272
+ const rawCostEstimated = metric["costEstimated"];
273
+ const costEstimated = typeof rawCostEstimated === "boolean"
274
+ ? rawCostEstimated
275
+ : source !== "reported";
276
+
277
+ return [modelKey, inputTokens, outputTokens, cost, source, false, costEstimated];
255
278
  }
256
279
 
257
- function deriveAgentUsageTuple(options: TaskAgentOptions, result: AgentStepResult): TokenUsageTuple | null {
280
+ /**
281
+ * Builds a source-aware {@link TokenUsageTuple} from an agent step result.
282
+ *
283
+ * Classification (#18 `UsageSource` model — the agent lane never estimates
284
+ * tokens, so it keys on the result shape rather than a provider/harness table):
285
+ * - `result.usage` object present -> `"reported"` (even when token counts are
286
+ * zero); `costUsd` (when finite) is preserved as the cost, `costEstimated`
287
+ * stays `false`.
288
+ * - no `result.usage` but a finite `costUsd` -> `"unavailable"`: tokens are 0,
289
+ * the harness-reported `costUsd` is preserved as the cost, `costEstimated` is
290
+ * `true` (agent spend stops lying as measured).
291
+ * - neither `usage` nor `costUsd` -> `null` (nothing to record).
292
+ *
293
+ * `opts.failed` sets the tuple's `failed` flag (index 5).
294
+ */
295
+ export function deriveAgentUsageTuple(
296
+ options: { harness: string; model?: TaskAgentOptions["model"] },
297
+ result: AgentStepResult,
298
+ opts?: { failed?: boolean },
299
+ ): TokenUsageTuple | null {
258
300
  const costUsd =
259
301
  typeof result.costUsd === "number" && Number.isFinite(result.costUsd)
260
302
  ? result.costUsd
261
303
  : undefined;
262
304
  if (!result.usage && costUsd === undefined) return null;
263
305
 
264
- return [
265
- `${options.harness}:${options.model ?? "default"}`,
266
- result.usage?.inputTokens ?? 0,
267
- result.usage?.outputTokens ?? 0,
268
- costUsd ?? 0,
269
- ];
306
+ const modelId =
307
+ typeof options.model === "string" ? options.model : options.model?.model;
308
+ const modelKey = `${options.harness}:${modelId ?? "default"}`;
309
+ const failed = opts?.failed === true;
310
+
311
+ if (result.usage) {
312
+ return [
313
+ modelKey,
314
+ result.usage.inputTokens ?? 0,
315
+ result.usage.outputTokens ?? 0,
316
+ costUsd ?? 0,
317
+ "reported",
318
+ failed,
319
+ false,
320
+ ];
321
+ }
322
+
323
+ return [modelKey, 0, 0, costUsd ?? 0, "unavailable", failed, true];
324
+ }
325
+
326
+ /**
327
+ * Records agent usage by building a {@link TokenUsageTuple} via
328
+ * {@link deriveAgentUsageTuple} and pushing it through the injected `push`
329
+ * sink only when a tuple exists. The skip-on-null decision lives here, so
330
+ * callers never branch on null. Owns the recording decision, not the storage
331
+ * mechanism (the persistence primitive is injected as `push`).
332
+ */
333
+ export function recordAgentUsage(
334
+ options: { harness: string; model?: TaskAgentOptions["model"] },
335
+ result: AgentStepResult,
336
+ push: (tuple: TokenUsageTuple) => void,
337
+ opts?: { failed?: boolean },
338
+ ): void {
339
+ const tuple = deriveAgentUsageTuple(options, result, opts);
340
+ if (tuple) push(tuple);
270
341
  }
271
342
 
272
343
  // ─── Safe clone ─────────────────────────────────────────────────────────────
@@ -417,47 +488,8 @@ export function checkFlagTypeConflicts(
417
488
 
418
489
  // ─── Console capture ──────────────────────────────────────────────────────────
419
490
 
420
- /**
421
- * Replaces console.log/error/warn/info/debug with functions that write to a
422
- * string buffer with appropriate prefixes. Returns a restore function that
423
- * flushes the buffer to `logPath` via Bun.write() and restores originals.
424
- * Creates the log directory before capturing.
425
- */
426
- export function captureConsoleOutput(logPath: string): () => Promise<void> {
427
- const origLog = console.log;
428
- const origError = console.error;
429
- const origWarn = console.warn;
430
- const origInfo = console.info;
431
- const origDebug = console.debug;
432
-
433
- let buffer = "";
434
-
435
- const append = (prefix: string, args: unknown[]) => {
436
- buffer += prefix + args.map(String).join(" ") + "\n";
437
- };
438
-
439
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
440
- console.log = (...args: any[]) => append("", args);
441
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
442
- console.error = (...args: any[]) => append("[ERROR] ", args);
443
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
444
- console.warn = (...args: any[]) => append("[WARN] ", args);
445
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
446
- console.info = (...args: any[]) => append("[INFO] ", args);
447
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
448
- console.debug = (...args: any[]) => append("[DEBUG] ", args);
449
-
450
- return async () => {
451
- console.log = origLog;
452
- console.error = origError;
453
- console.warn = origWarn;
454
- console.info = origInfo;
455
- console.debug = origDebug;
456
-
457
- await mkdir(dirname(logPath), { recursive: true });
458
- await Bun.write(logPath, buffer);
459
- };
460
- }
491
+ // `captureConsoleOutput` moved to ./task-telemetry; re-exported below so existing
492
+ // importers keep working. The per-stage usage at line ~733 is replaced in step 4.
461
493
 
462
494
  // ─── Pipeline stage factory ───────────────────────────────────────────────────
463
495
 
@@ -532,6 +564,11 @@ export async function runPipeline(
532
564
  let tokenWriteQueue: Promise<void> = Promise.resolve();
533
565
  const events = getLLMEvents();
534
566
 
567
+ // `context` is built inside the withTaskTelemetry body below, but the LLM
568
+ // handlers (defined here so they can be passed into the wrapper) read
569
+ // `context.currentStage`. Declare it at this scope so both see the same value.
570
+ let context: ExecutionContext;
571
+
535
572
  const onComplete = (metric: Record<string, unknown>) => {
536
573
  const record: LLMMetricRecord = {
537
574
  ...metric,
@@ -564,304 +601,303 @@ export async function runPipeline(
564
601
  stage: context.currentStage ?? undefined,
565
602
  failed: true,
566
603
  });
567
- };
568
-
569
- events.on("llm:request:complete", onComplete);
570
- events.on("llm:request:error", onError);
571
-
572
- // 7. Load task module
573
- const taskModule = await loadFreshModule(pathToFileURL(modulePath));
574
604
 
575
- // 8. Create per-invocation stage array
576
- const stages = createPipelineStages(taskModule, initialContext.tasksOverride);
577
-
578
- // 9. Create file I/O adapter
579
- let currentStageRef: StageName | null = null;
580
- const io = createTaskFileIO({
581
- workDir,
582
- taskName,
583
- getStage: () => currentStageRef ?? "",
584
- statusPath,
585
- });
586
-
587
- // 9b. Build the CLI-agent harness runner injected into stages. It reuses the
588
- // task's own `io`, so an agent reads and writes the same artifacts the task
589
- // sees. A caller may override it (tests/custom hosts).
590
- const runAgentCore: TaskAgentRunner =
591
- initialContext.runAgent ??
592
- ((options: TaskAgentOptions) =>
593
- executeAgent({ io, entry: { ...options, name: taskName } }));
594
- const runAgent: TaskAgentRunner = async (options: TaskAgentOptions) => {
595
- const result = await runAgentCore(options);
596
- const tuple = deriveAgentUsageTuple(options, result);
597
- if (tuple) enqueueTokenUsage(tuple);
598
- return result;
605
+ const tuple = deriveModelKeyAndTokens(metric);
606
+ tuple[5] = true;
607
+ enqueueTokenUsage(tuple);
599
608
  };
600
609
 
601
- // 10. Build execution context
602
- const pipelineTasks = initialContext.pipelineTasks ?? initialContext.meta?.pipelineTasks;
603
-
604
- // Seed handling: if initialContext.seed is falsy, use initialContext itself.
605
- // JSON round-trip strips non-cloneable fields (functions, proxies) which matches
606
- // the original JS behavior and ensures structuredClone works later.
607
- const seedData = initialContext.seed || JSON.parse(JSON.stringify(initialContext));
608
-
609
- const context: ExecutionContext = {
610
- io,
611
- llm,
612
- runAgent,
613
- meta: {
614
- taskName,
610
+ // INVARIANT: ONE runPipeline at a time per process (sequential stages, one
611
+ // runner per job; see task-telemetry.ts). withTaskTelemetry attaches the two
612
+ // LLM listeners, runs the body below, and in `finally` removes both listeners
613
+ // and flushes the latest tokenWriteQueue (thunked) on every exit — return or
614
+ // throw. The body owns module-load through the final return.
615
+ return withTaskTelemetry(events, { onComplete, onError }, () => tokenWriteQueue, async () => {
616
+ // 7. Load task module
617
+ const taskModule = await loadFreshModule(pathToFileURL(modulePath));
618
+
619
+ // 8. Create per-invocation stage array
620
+ const stages = createPipelineStages(taskModule, initialContext.tasksOverride);
621
+
622
+ // 9. Create file I/O adapter
623
+ let currentStageRef: StageName | null = null;
624
+ const io = createTaskFileIO({
615
625
  workDir,
626
+ taskName,
627
+ getStage: () => currentStageRef ?? "",
616
628
  statusPath,
617
- jobId: initialContext.jobId,
618
- envLoaded: true,
619
- modelConfig: initialContext.modelConfig,
620
- pipelineTasks,
621
- },
622
- data: {
623
- seed: seedData,
624
- },
625
- flags: {},
626
- logs: [],
627
- currentStage: null,
628
- validators: { validateWithSchema },
629
- };
630
-
631
- // 11. Ensure log directory exists
632
- const logsDir = join(workDir, "files", "logs");
633
- await mkdir(logsDir, { recursive: true });
634
- const registerStageLog = async (fileName: string): Promise<void> => {
635
- await trackFile(jobDir, "logs", fileName, taskName, true);
636
- };
637
-
638
- // Track returned logs (separate from context.logs)
639
- const returnedLogs: AuditLogEntry[] = [];
640
- let lastStageOutput: unknown = context.data["seed"];
641
- let previousStage = "seed";
642
-
643
- // 12. Execute each stage sequentially
644
- for (const stage of stages) {
645
- const stageName = stage.name;
646
-
647
- // Check skipIf -> skip if true (log to context.logs only)
648
- if (stage.skipIf && stage.skipIf(context.flags)) {
649
- context.logs.push({ stage: stageName, skipped: true });
650
- continue;
651
- }
652
-
653
- // Check handler -> skip if null (log to returned logs)
654
- if (stage.handler === null) {
655
- returnedLogs.push({ stage: stageName, skipped: true });
656
- continue;
657
- }
658
-
659
- // Capture console output
660
- const logFileName = generateLogName(taskName, stageName, LogEvent.START, LogFileExtension.TEXT);
661
- const logPath = join(logsDir, logFileName);
662
- const restoreConsole = captureConsoleOutput(logPath);
629
+ });
663
630
 
664
- let stageError: { err: unknown } | null = null;
631
+ // 9b. Build the CLI-agent harness runner injected into stages. It reuses the
632
+ // task's own `io`, so an agent reads and writes the same artifacts the task
633
+ // sees. A caller may override it (tests/custom hosts).
634
+ const runAgentCore: TaskAgentRunner =
635
+ initialContext.runAgent ??
636
+ ((options: TaskAgentOptions) =>
637
+ executeAgent({ io, entry: { ...options, name: taskName } }));
638
+ const runAgent: TaskAgentRunner = async (options: TaskAgentOptions) => {
639
+ const result = await runAgentCore(options);
640
+ recordAgentUsage(options, result, enqueueTokenUsage);
641
+ return result;
642
+ };
665
643
 
666
- try {
667
- // Set currentStage
668
- context.currentStage = stageName;
669
- currentStageRef = stageName;
644
+ // 10. Build execution context
645
+ const pipelineTasks = initialContext.pipelineTasks ?? initialContext.meta?.pipelineTasks;
670
646
 
671
- // Write stage-start status (swallow errors)
672
- try {
673
- await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
674
- if (!snapshot.tasks[taskName]) snapshot.tasks[taskName] = {};
675
- snapshot.tasks[taskName]!.state = TaskState.RUNNING;
676
- snapshot.tasks[taskName]!.currentStage = stageName;
677
- });
678
- } catch {
679
- // Best-effort: swallow status write errors
680
- }
647
+ // Seed handling: if initialContext.seed is falsy, use initialContext itself.
648
+ // JSON round-trip strips non-cloneable fields (functions, proxies) which matches
649
+ // the original JS behavior and ensures structuredClone works later.
650
+ const seedData = initialContext.seed || JSON.parse(JSON.stringify(initialContext));
681
651
 
682
- // Clone data, flags, output into StageContext.
683
- // structuredClone throws on non-cloneable values (functions, streams,
684
- // class instances with internal slots). Fall back to JSON round-trip
685
- // which silently strips those fields.
686
- const stageContext: StageContext = {
687
- io,
688
- llm,
689
- runAgent,
690
- meta: context.meta,
691
- data: safeClone(context.data),
692
- flags: safeClone(context.flags),
693
- currentStage: stageName,
694
- output: safeClone(lastStageOutput),
695
- previousStage,
696
- validators: context.validators,
697
- };
698
-
699
- // Write context snapshot
700
- const snapshotFileName = generateLogName(
652
+ context = {
653
+ io,
654
+ llm,
655
+ runAgent,
656
+ meta: {
701
657
  taskName,
702
- stageName,
703
- LogEvent.CONTEXT,
704
- LogFileExtension.JSON,
705
- );
706
- const snapshotPath = join(logsDir, snapshotFileName);
707
- try {
708
- await Bun.write(snapshotPath, JSON.stringify(stageContext, null, 2));
709
- await registerStageLog(snapshotFileName);
710
- } catch {
711
- // Best-effort
712
- }
713
-
714
- // Validate prerequisite flags
715
- validateFlagTypes(stageName, context.flags, "requires");
716
-
717
- // Execute handler, time with performance.now()
718
- const t0 = performance.now();
719
- const result = await stage.handler(stageContext);
720
- const elapsed = performance.now() - t0;
658
+ workDir,
659
+ statusPath,
660
+ jobId: initialContext.jobId,
661
+ envLoaded: true,
662
+ modelConfig: initialContext.modelConfig,
663
+ pipelineTasks,
664
+ },
665
+ data: {
666
+ seed: seedData,
667
+ },
668
+ flags: {},
669
+ logs: [],
670
+ currentStage: null,
671
+ validators: { validateWithSchema },
672
+ };
721
673
 
722
- // Validate result shape
723
- assertStageResult(result, stageName);
674
+ // 11. Ensure log directory exists
675
+ const logsDir = join(workDir, "files", "logs");
676
+ await mkdir(logsDir, { recursive: true });
677
+ const registerStageLog = async (fileName: string): Promise<void> => {
678
+ await trackFile(jobDir, "logs", fileName, taskName, true);
679
+ };
724
680
 
725
- // Validate produced flag types
726
- validateFlagTypes(stageName, result.flags, "produces");
681
+ // Track returned logs (separate from context.logs)
682
+ const returnedLogs: AuditLogEntry[] = [];
683
+ let lastStageOutput: unknown = context.data["seed"];
684
+ let previousStage = "seed";
727
685
 
728
- // Check flag type conflicts
729
- checkFlagTypeConflicts(context.flags, result.flags);
686
+ // 12. Execute each stage sequentially
687
+ for (const stage of stages) {
688
+ const stageName = stage.name;
730
689
 
731
- // Store output in context.data[stageName]
732
- context.data[stageName] = result.output;
690
+ // Check skipIf -> skip if true (log to context.logs only)
691
+ if (stage.skipIf && stage.skipIf(context.flags)) {
692
+ context.logs.push({ stage: stageName, skipped: true });
693
+ continue;
694
+ }
733
695
 
734
- // Update lastStageOutput if not a validation stage
735
- if (!VALIDATION_STAGES.has(stageName)) {
736
- lastStageOutput = result.output;
737
- previousStage = stageName;
696
+ // Check handler -> skip if null (log to returned logs)
697
+ if (stage.handler === null) {
698
+ returnedLogs.push({ stage: stageName, skipped: true });
699
+ continue;
738
700
  }
739
701
 
740
- // Merge flags
741
- Object.assign(context.flags, result.flags);
702
+ // Capture console output
703
+ const logFileName = generateLogName(taskName, stageName, LogEvent.START, LogFileExtension.TEXT);
704
+ const logPath = join(logsDir, logFileName);
742
705
 
743
- // Log completion audit entry
744
- const auditEntry: AuditLogEntry = { stage: stageName, ok: true, ms: elapsed };
745
- context.logs.push(auditEntry);
746
- returnedLogs.push(auditEntry);
706
+ let stageError: { err: unknown } | null = null;
747
707
 
748
- // Write stage-completion status (swallow errors)
708
+ // withConsoleCapture restores every captured console method (and writes the
709
+ // buffered log file) in its own finally on both success and throw, then
710
+ // re-throws — so a stage throw is captured here as stageError. registerStageLog
711
+ // then runs once on every path, after console restore, matching the original
712
+ // "finally restores console then registers log" ordering.
749
713
  try {
750
- await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
751
- if (!snapshot.tasks[taskName]) snapshot.tasks[taskName] = {};
752
- snapshot.tasks[taskName]!.currentStage = stageName;
714
+ await withConsoleCapture(logPath, async () => {
715
+ // Set currentStage
716
+ context.currentStage = stageName;
717
+ currentStageRef = stageName;
718
+
719
+ // Write stage-start status (swallow errors)
720
+ try {
721
+ await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
722
+ if (!snapshot.tasks[taskName]) snapshot.tasks[taskName] = {};
723
+ snapshot.tasks[taskName]!.state = TaskState.RUNNING;
724
+ snapshot.tasks[taskName]!.currentStage = stageName;
725
+ });
726
+ } catch {
727
+ // Best-effort: swallow status write errors
728
+ }
729
+
730
+ // Clone data, flags, output into StageContext.
731
+ // structuredClone throws on non-cloneable values (functions, streams,
732
+ // class instances with internal slots). Fall back to JSON round-trip
733
+ // which silently strips those fields.
734
+ const stageContext: StageContext = {
735
+ io,
736
+ llm,
737
+ runAgent,
738
+ meta: context.meta,
739
+ data: safeClone(context.data),
740
+ flags: safeClone(context.flags),
741
+ currentStage: stageName,
742
+ output: safeClone(lastStageOutput),
743
+ previousStage,
744
+ validators: context.validators,
745
+ };
746
+
747
+ // Write context snapshot
748
+ const snapshotFileName = generateLogName(
749
+ taskName,
750
+ stageName,
751
+ LogEvent.CONTEXT,
752
+ LogFileExtension.JSON,
753
+ );
754
+ const snapshotPath = join(logsDir, snapshotFileName);
755
+ try {
756
+ await Bun.write(snapshotPath, JSON.stringify(stageContext, null, 2));
757
+ await registerStageLog(snapshotFileName);
758
+ } catch {
759
+ // Best-effort
760
+ }
761
+
762
+ // Validate prerequisite flags
763
+ validateFlagTypes(stageName, context.flags, "requires");
764
+
765
+ // Execute handler, time with performance.now()
766
+ const t0 = performance.now();
767
+ const result = await stage.handler!(stageContext);
768
+ const elapsed = performance.now() - t0;
769
+
770
+ // Validate result shape
771
+ assertStageResult(result, stageName);
772
+
773
+ // Validate produced flag types
774
+ validateFlagTypes(stageName, result.flags, "produces");
775
+
776
+ // Check flag type conflicts
777
+ checkFlagTypeConflicts(context.flags, result.flags);
778
+
779
+ // Store output in context.data[stageName]
780
+ context.data[stageName] = result.output;
781
+
782
+ // Update lastStageOutput if not a validation stage
783
+ if (!VALIDATION_STAGES.has(stageName)) {
784
+ lastStageOutput = result.output;
785
+ previousStage = stageName;
786
+ }
787
+
788
+ // Merge flags
789
+ Object.assign(context.flags, result.flags);
790
+
791
+ // Log completion audit entry
792
+ const auditEntry: AuditLogEntry = { stage: stageName, ok: true, ms: elapsed };
793
+ context.logs.push(auditEntry);
794
+ returnedLogs.push(auditEntry);
795
+
796
+ // Write stage-completion status (swallow errors)
797
+ try {
798
+ await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
799
+ if (!snapshot.tasks[taskName]) snapshot.tasks[taskName] = {};
800
+ snapshot.tasks[taskName]!.currentStage = stageName;
801
+ });
802
+ } catch {
803
+ // Best-effort
804
+ }
805
+
806
+ // Write completion log marker
807
+ const completeFileName = generateLogName(
808
+ taskName,
809
+ stageName,
810
+ LogEvent.COMPLETE,
811
+ LogFileExtension.TEXT,
812
+ );
813
+ try {
814
+ await Bun.write(join(logsDir, completeFileName), `Stage ${stageName} completed in ${elapsed.toFixed(1)}ms\n`);
815
+ await registerStageLog(completeFileName);
816
+ } catch {
817
+ // Best-effort
818
+ }
753
819
  });
754
- } catch {
755
- // Best-effort
820
+ } catch (err: unknown) {
821
+ // 13. On stage error — capture error; withConsoleCapture already restored console
822
+ stageError = { err };
756
823
  }
757
824
 
758
- // Write completion log marker
759
- const completeFileName = generateLogName(
760
- taskName,
761
- stageName,
762
- LogEvent.COMPLETE,
763
- LogFileExtension.TEXT,
764
- );
765
- try {
766
- await Bun.write(join(logsDir, completeFileName), `Stage ${stageName} completed in ${elapsed.toFixed(1)}ms\n`);
767
- await registerStageLog(completeFileName);
768
- } catch {
769
- // Best-effort
770
- }
771
- } catch (err: unknown) {
772
- // 13. On stage error — capture error, let finally restore console
773
- stageError = { err };
774
- } finally {
775
- // Always restore console, regardless of success or failure
776
- await restoreConsole();
825
+ // Register the stage start log on every path, after console restore.
777
826
  await registerStageLog(logFileName).catch(() => {});
778
- }
779
827
 
780
- if (stageError !== null) {
781
- const { err } = stageError;
782
- const base = normalizeError(err);
783
- const logFileName2 = generateLogName(taskName, stageName, LogEvent.START, LogFileExtension.TEXT);
784
- const snapshotFileName2 = generateLogName(taskName, stageName, LogEvent.CONTEXT, LogFileExtension.JSON);
785
-
786
- const normalized: NormalizedError = {
787
- ...base,
788
- debug: {
789
- stage: stageName,
790
- previousStage,
791
- logPath: join(logsDir, logFileName2),
792
- snapshotPath: join(logsDir, snapshotFileName2),
793
- dataHasSeed: "seed" in context.data,
794
- seedHasData: context.data["seed"] != null && typeof context.data["seed"] === "object" && "data" in (context.data["seed"] as Record<string, unknown>),
795
- flagsKeys: Object.keys(context.flags),
796
- },
797
- };
798
-
799
- const errorEntry: AuditLogEntry = { stage: stageName, ok: false, ms: 0, error: normalized };
800
- context.logs.push(errorEntry);
801
- returnedLogs.push(errorEntry);
802
-
803
- // Write failure status (swallow errors)
804
- try {
805
- await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
806
- if (!snapshot.tasks[taskName]) snapshot.tasks[taskName] = {};
807
- snapshot.tasks[taskName]!.state = TaskState.FAILED;
808
- snapshot.tasks[taskName]!.failedStage = stageName;
809
- snapshot.tasks[taskName]!.error = normalized.message;
810
- });
811
- } catch {
812
- // Best-effort
828
+ if (stageError !== null) {
829
+ const { err } = stageError;
830
+ const base = normalizeError(err);
831
+ const logFileName2 = generateLogName(taskName, stageName, LogEvent.START, LogFileExtension.TEXT);
832
+ const snapshotFileName2 = generateLogName(taskName, stageName, LogEvent.CONTEXT, LogFileExtension.JSON);
833
+
834
+ const normalized: NormalizedError = {
835
+ ...base,
836
+ debug: {
837
+ stage: stageName,
838
+ previousStage,
839
+ logPath: join(logsDir, logFileName2),
840
+ snapshotPath: join(logsDir, snapshotFileName2),
841
+ dataHasSeed: "seed" in context.data,
842
+ seedHasData: context.data["seed"] != null && typeof context.data["seed"] === "object" && "data" in (context.data["seed"] as Record<string, unknown>),
843
+ flagsKeys: Object.keys(context.flags),
844
+ },
845
+ };
846
+
847
+ const errorEntry: AuditLogEntry = { stage: stageName, ok: false, ms: 0, error: normalized };
848
+ context.logs.push(errorEntry);
849
+ returnedLogs.push(errorEntry);
850
+
851
+ // Write failure status (swallow errors)
852
+ try {
853
+ await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
854
+ if (!snapshot.tasks[taskName]) snapshot.tasks[taskName] = {};
855
+ snapshot.tasks[taskName]!.state = TaskState.FAILED;
856
+ snapshot.tasks[taskName]!.failedStage = stageName;
857
+ snapshot.tasks[taskName]!.error = normalized.message;
858
+ });
859
+ } catch {
860
+ // Best-effort
861
+ }
862
+
863
+ return {
864
+ ok: false,
865
+ failedStage: stageName,
866
+ error: normalized,
867
+ logs: returnedLogs,
868
+ context,
869
+ };
813
870
  }
814
-
815
- // Flush token write queue
816
- await tokenWriteQueue.catch(() => {});
817
-
818
- // Remove LLM listeners
819
- events.removeListener("llm:request:complete", onComplete);
820
- events.removeListener("llm:request:error", onError);
821
-
822
- return {
823
- ok: false,
824
- failedStage: stageName,
825
- error: normalized,
826
- logs: returnedLogs,
827
- context,
828
- };
829
871
  }
830
- }
831
-
832
- // 14. After all stages: flush token write queue
833
- await tokenWriteQueue.catch(() => {});
834
872
 
835
- // Remove LLM listeners
836
- events.removeListener("llm:request:complete", onComplete);
837
- events.removeListener("llm:request:error", onError);
838
-
839
- // Write done status (best-effort)
840
- try {
841
- const lastStage = KNOWN_STAGES.at(-1)!;
842
- const doneProgress = computeDeterministicProgress(
843
- pipelineTasks ?? [taskName],
844
- taskName,
845
- lastStage,
846
- );
847
- await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
848
- snapshot.progress = doneProgress;
849
- if (!snapshot.tasks[taskName]) snapshot.tasks[taskName] = {};
850
- snapshot.tasks[taskName]!.state = TaskState.DONE;
851
- snapshot.tasks[taskName]!.currentStage = null;
852
- });
853
- } catch {
854
- // Best-effort
855
- }
873
+ // 14. After all stages: write done status (best-effort).
874
+ // Token-queue flush + listener removal are handled by withTaskTelemetry's finally.
875
+ try {
876
+ const lastStage = KNOWN_STAGES.at(-1)!;
877
+ const doneProgress = computeDeterministicProgress(
878
+ pipelineTasks ?? [taskName],
879
+ taskName,
880
+ lastStage,
881
+ );
882
+ await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
883
+ snapshot.progress = doneProgress;
884
+ if (!snapshot.tasks[taskName]) snapshot.tasks[taskName] = {};
885
+ snapshot.tasks[taskName]!.state = TaskState.DONE;
886
+ snapshot.tasks[taskName]!.currentStage = null;
887
+ });
888
+ } catch {
889
+ // Best-effort
890
+ }
856
891
 
857
- context.currentStage = null;
892
+ context.currentStage = null;
858
893
 
859
- return {
860
- ok: true,
861
- logs: returnedLogs,
862
- context,
863
- llmMetrics,
864
- };
894
+ return {
895
+ ok: true,
896
+ logs: returnedLogs,
897
+ context,
898
+ llmMetrics,
899
+ };
900
+ });
865
901
  }
866
902
 
867
903
  // ─── runPipelineWithModelRouting ──────────────────────────────────────────────
@@ -884,5 +920,6 @@ export async function runPipelineWithModelRouting(
884
920
 
885
921
  // ─── Re-exports ───────────────────────────────────────────────────────────────
886
922
 
923
+ export { captureConsoleOutput } from "./task-telemetry";
887
924
  export { decideTransition } from "./lifecycle-policy";
888
925
  export { computeDeterministicProgress, KNOWN_STAGES } from "./progress";