@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/docs/http-api.md +80 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +443 -32
  4. package/src/api/index.ts +108 -127
  5. package/src/cli/__tests__/analyze-task.test.ts +40 -7
  6. package/src/cli/__tests__/index.test.ts +337 -17
  7. package/src/cli/__tests__/types.test.ts +0 -18
  8. package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
  9. package/src/cli/analyze-task.ts +3 -14
  10. package/src/cli/index.ts +73 -61
  11. package/src/cli/types.ts +0 -13
  12. package/src/cli/update-pipeline-json.ts +3 -14
  13. package/src/config/__tests__/paths.test.ts +46 -1
  14. package/src/config/__tests__/pipeline-registry.test.ts +767 -0
  15. package/src/config/__tests__/sse-events.test.ts +470 -0
  16. package/src/config/__tests__/statuses.test.ts +41 -0
  17. package/src/config/paths.ts +11 -2
  18. package/src/config/pipeline-registry.ts +258 -0
  19. package/src/config/sse-events.ts +122 -0
  20. package/src/config/statuses.ts +20 -1
  21. package/src/core/__tests__/agent-step.test.ts +115 -0
  22. package/src/core/__tests__/config.test.ts +545 -105
  23. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  24. package/src/core/__tests__/job-concurrency.test.ts +260 -0
  25. package/src/core/__tests__/job-submission.test.ts +396 -0
  26. package/src/core/__tests__/job-view.test.ts +1341 -0
  27. package/src/core/__tests__/json-file.test.ts +111 -0
  28. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  29. package/src/core/__tests__/logger.test.ts +22 -0
  30. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  31. package/src/core/__tests__/orchestrator.test.ts +615 -2
  32. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  33. package/src/core/__tests__/redact.test.ts +153 -0
  34. package/src/core/__tests__/runner-liveness.test.ts +910 -0
  35. package/src/core/__tests__/seed-naming.test.ts +57 -0
  36. package/src/core/__tests__/single-derivation.test.ts +159 -0
  37. package/src/core/__tests__/task-runner.test.ts +594 -3
  38. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  39. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  40. package/src/core/agent-step.ts +4 -1
  41. package/src/core/agent-types.ts +5 -4
  42. package/src/core/config.ts +134 -222
  43. package/src/core/control.ts +3 -0
  44. package/src/core/job-concurrency.ts +56 -20
  45. package/src/core/job-submission.ts +133 -0
  46. package/src/core/job-view.ts +473 -0
  47. package/src/core/json-file.ts +45 -0
  48. package/src/core/lifecycle-policy.ts +23 -8
  49. package/src/core/logger.ts +0 -29
  50. package/src/core/orchestrator.ts +200 -74
  51. package/src/core/pipeline-runner.ts +85 -53
  52. package/src/core/redact.ts +40 -0
  53. package/src/core/runner-liveness.ts +280 -0
  54. package/src/core/seed-naming.ts +9 -0
  55. package/src/core/status-writer.ts +27 -33
  56. package/src/core/task-runner.ts +356 -319
  57. package/src/core/task-telemetry.ts +107 -0
  58. package/src/harness/__tests__/subprocess.test.ts +112 -1
  59. package/src/harness/subprocess.ts +55 -16
  60. package/src/llm/__tests__/index.test.ts +684 -33
  61. package/src/llm/index.ts +310 -67
  62. package/src/providers/__tests__/alibaba.test.ts +67 -6
  63. package/src/providers/__tests__/anthropic.test.ts +35 -14
  64. package/src/providers/__tests__/base.test.ts +62 -0
  65. package/src/providers/__tests__/claude-code.test.ts +99 -14
  66. package/src/providers/__tests__/deepseek.test.ts +16 -6
  67. package/src/providers/__tests__/gemini.test.ts +47 -25
  68. package/src/providers/__tests__/moonshot.test.ts +27 -0
  69. package/src/providers/__tests__/openai.test.ts +262 -74
  70. package/src/providers/__tests__/opencode.test.ts +77 -0
  71. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  72. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  73. package/src/providers/__tests__/types.test.ts +85 -0
  74. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  75. package/src/providers/__tests__/zhipu.test.ts +27 -14
  76. package/src/providers/alibaba.ts +20 -39
  77. package/src/providers/anthropic.ts +23 -11
  78. package/src/providers/base.ts +19 -0
  79. package/src/providers/claude-code.ts +27 -18
  80. package/src/providers/deepseek.ts +9 -28
  81. package/src/providers/gemini.ts +20 -58
  82. package/src/providers/moonshot.ts +15 -11
  83. package/src/providers/openai.ts +79 -61
  84. package/src/providers/opencode.ts +16 -33
  85. package/src/providers/stream-accumulator.ts +27 -21
  86. package/src/providers/types.ts +29 -4
  87. package/src/providers/zhipu.ts +15 -44
  88. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  89. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  90. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  91. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  92. package/src/task-analysis/__tests__/types.test.ts +63 -130
  93. package/src/task-analysis/analyzer.ts +91 -0
  94. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  95. package/src/task-analysis/index.ts +2 -36
  96. package/src/task-analysis/repository.ts +45 -0
  97. package/src/task-analysis/types.ts +42 -58
  98. package/src/ui/client/__tests__/api.test.ts +143 -1
  99. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  100. package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
  101. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  102. package/src/ui/client/__tests__/types.test.ts +66 -3
  103. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  104. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  105. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  106. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  107. package/src/ui/client/adapters/job-adapter.ts +41 -16
  108. package/src/ui/client/api.ts +38 -15
  109. package/src/ui/client/bootstrap.ts +19 -14
  110. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  111. package/src/ui/client/hooks/useJobList.ts +26 -31
  112. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  113. package/src/ui/client/load-state.ts +20 -0
  114. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  115. package/src/ui/client/reducers/job-events.ts +137 -0
  116. package/src/ui/client/types.ts +16 -20
  117. package/src/ui/components/DAGGrid.tsx +6 -1
  118. package/src/ui/components/JobDetail.tsx +12 -4
  119. package/src/ui/components/JobTable.tsx +41 -13
  120. package/src/ui/components/StageTimeline.tsx +8 -20
  121. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  122. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  123. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  124. package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
  125. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  126. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  127. package/src/ui/components/types.ts +35 -15
  128. package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
  129. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  130. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  131. package/src/ui/dist/index.html +2 -2
  132. package/src/ui/pages/PipelineDetail.tsx +60 -4
  133. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  134. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  135. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  136. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  137. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  138. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  139. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  140. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  141. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  142. package/src/ui/server/__tests__/index.test.ts +63 -0
  143. package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
  144. package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
  145. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  146. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  147. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  148. package/src/ui/server/__tests__/router.test.ts +104 -0
  149. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  150. package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
  151. package/src/ui/server/config-bridge-node.ts +8 -26
  152. package/src/ui/server/config-bridge.ts +3 -4
  153. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  154. package/src/ui/server/embedded-assets.ts +13 -2
  155. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  156. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  157. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  158. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  159. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  160. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  161. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  162. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  163. package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
  164. package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
  165. package/src/ui/server/endpoints/job-endpoints.ts +7 -0
  166. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  167. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  168. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  169. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  170. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  171. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  172. package/src/ui/server/index.ts +19 -14
  173. package/src/ui/server/job-reader.ts +14 -2
  174. package/src/ui/server/router.ts +33 -10
  175. package/src/ui/server/sse-broadcast.ts +14 -3
  176. package/src/ui/server/sse-enhancer.ts +12 -2
  177. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  178. package/src/ui/state/__tests__/snapshot.test.ts +120 -14
  179. package/src/ui/state/__tests__/types.test.ts +104 -5
  180. package/src/ui/state/snapshot.ts +2 -2
  181. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
  182. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
  183. package/src/ui/state/transformers/list-transformer.ts +13 -3
  184. package/src/ui/state/transformers/status-transformer.ts +36 -170
  185. package/src/ui/state/types.ts +15 -48
  186. package/src/utils/__tests__/path-containment.test.ts +160 -0
  187. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  188. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  189. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  190. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  191. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  192. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  193. package/src/task-analysis/__tests__/index.test.ts +0 -143
  194. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  195. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  196. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  197. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  198. package/src/task-analysis/extractors/artifacts.ts +0 -143
  199. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  200. package/src/task-analysis/extractors/stages.ts +0 -45
  201. package/src/task-analysis/parser.ts +0 -20
  202. package/src/task-analysis/utils/ast.ts +0 -45
  203. package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
  204. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  205. package/src/ui/embedded-assets.js +0 -12
  206. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
@@ -0,0 +1,107 @@
1
+ import type { EventEmitter } from "node:events";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+
5
+ /**
6
+ * INVARIANT: correct attribution + console capture assume ONE runTask at a time
7
+ * per process (sequential stages, one runner per job). Lifetime-scoped listener
8
+ * attribution and global console capture are valid only under it; if in-process
9
+ * task concurrency is introduced, switch to an event correlation id
10
+ * (jobId/taskId/requestId) the handlers filter on.
11
+ *
12
+ * Attach the two LLM telemetry listeners, run `body`, then in `finally` remove
13
+ * both listeners and flush the token-write queue with best-effort swallow
14
+ * semantics so a flush failure cannot mask the original task result/error.
15
+ *
16
+ * `flush` is a thunk: it is evaluated at finally-time, not captured eagerly, so
17
+ * it awaits the latest token-write queue rather than a stale snapshot.
18
+ */
19
+ export async function withTaskTelemetry<T>(
20
+ events: EventEmitter,
21
+ handlers: {
22
+ onComplete: (m: Record<string, unknown>) => void;
23
+ onError: (m: Record<string, unknown>) => void;
24
+ },
25
+ flush: () => Promise<void>,
26
+ body: () => Promise<T>,
27
+ ): Promise<T> {
28
+ const { onComplete, onError } = handlers;
29
+
30
+ events.on("llm:request:complete", onComplete);
31
+ events.on("llm:request:error", onError);
32
+
33
+ try {
34
+ return await body();
35
+ } finally {
36
+ events.removeListener("llm:request:complete", onComplete);
37
+ events.removeListener("llm:request:error", onError);
38
+ await flush().catch(() => {});
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Replaces console.log/error/warn/info/debug with functions that write to a
44
+ * string buffer with appropriate prefixes. Returns a restore function that
45
+ * flushes the buffer to `logPath` via Bun.write() and restores originals.
46
+ * Creates the log directory before capturing.
47
+ *
48
+ * The restore is idempotent: a `restored` flag guards it so a double-call is a
49
+ * no-op and cannot reinstate stale originals over methods reassigned since.
50
+ */
51
+ export function captureConsoleOutput(logPath: string): () => Promise<void> {
52
+ const origLog = console.log;
53
+ const origError = console.error;
54
+ const origWarn = console.warn;
55
+ const origInfo = console.info;
56
+ const origDebug = console.debug;
57
+
58
+ let buffer = "";
59
+ let restored = false;
60
+
61
+ const append = (prefix: string, args: unknown[]) => {
62
+ buffer += prefix + args.map(String).join(" ") + "\n";
63
+ };
64
+
65
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
66
+ console.log = (...args: any[]) => append("", args);
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ console.error = (...args: any[]) => append("[ERROR] ", args);
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ console.warn = (...args: any[]) => append("[WARN] ", args);
71
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
+ console.info = (...args: any[]) => append("[INFO] ", args);
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ console.debug = (...args: any[]) => append("[DEBUG] ", args);
75
+
76
+ return async () => {
77
+ if (restored) return;
78
+ restored = true;
79
+
80
+ console.log = origLog;
81
+ console.error = origError;
82
+ console.warn = origWarn;
83
+ console.info = origInfo;
84
+ console.debug = origDebug;
85
+
86
+ await mkdir(dirname(logPath), { recursive: true });
87
+ await Bun.write(logPath, buffer);
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Captures console output to `logPath` for the duration of `body`, then in
93
+ * `finally` restores every console method the capture mutated. The restore is
94
+ * guarded by `captureConsoleOutput`'s `restored` flag, so it runs exactly once
95
+ * regardless of how `body` exits.
96
+ */
97
+ export async function withConsoleCapture<T>(
98
+ logPath: string,
99
+ body: () => Promise<T>,
100
+ ): Promise<T> {
101
+ const restore = captureConsoleOutput(logPath);
102
+ try {
103
+ return await body();
104
+ } finally {
105
+ await restore();
106
+ }
107
+ }
@@ -1,5 +1,8 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { runJsonlSubprocess } from "../subprocess.ts";
2
+ import {
3
+ runJsonlSubprocess,
4
+ SUBPROCESS_KILL_GRACE_MS,
5
+ } from "../subprocess.ts";
3
6
 
4
7
  describe("runJsonlSubprocess", () => {
5
8
  it("parses two JSONL lines into two events", async () => {
@@ -58,4 +61,112 @@ describe("runJsonlSubprocess", () => {
58
61
  expect(result.stderr).toContain("oops");
59
62
  expect(result.timedOut).toBe(false);
60
63
  });
64
+
65
+ // AC-7: concurrent drain — child floods stderr past the OS pipe buffer
66
+ // (~64KB) while stdout stays open; sequential reads would deadlock.
67
+ it("drains >64KB stderr with pending stdout without deadlocking", async () => {
68
+ const result = await runJsonlSubprocess({
69
+ argv: [
70
+ "bash",
71
+ "-c",
72
+ // 100KB of stderr, then a stdout JSONL line, then exit.
73
+ 'yes x | head -c 102400 >&2; echo "{\\"done\\":true}"',
74
+ ],
75
+ env: {},
76
+ timeoutMs: 10000,
77
+ });
78
+
79
+ expect(result.stderr.length).toBeGreaterThanOrEqual(102400);
80
+ expect(result.events).toEqual([{ done: true }]);
81
+ expect(result.exitCode).toBe(0);
82
+ expect(result.timedOut).toBe(false);
83
+ }, 15000);
84
+
85
+ // AC-8: SIGTERM-ignoring child is escalated to SIGKILL after the grace
86
+ // window; timedOut is set and partial output captured.
87
+ it("escalates to SIGKILL on timeout and captures partial output", async () => {
88
+ const start = Date.now();
89
+ const result = await runJsonlSubprocess({
90
+ argv: [
91
+ "bash",
92
+ "-c",
93
+ // exec replaces bash so the trapping process itself holds the pipe;
94
+ // tree-kill of grandchildren is out of scope (single-child contract).
95
+ 'trap "" TERM; echo "{\\"partial\\":true}"; echo warn >&2; exec sleep 30',
96
+ ],
97
+ env: {},
98
+ timeoutMs: 150,
99
+ });
100
+ const elapsed = Date.now() - start;
101
+
102
+ expect(result.timedOut).toBe(true);
103
+ expect(result.exitCode).not.toBe(0);
104
+ expect(result.events).toEqual([{ partial: true }]);
105
+ expect(result.stderr).toContain("warn");
106
+ // SIGTERM was trapped, so resolution proves SIGKILL fired after the grace.
107
+ expect(elapsed).toBeGreaterThanOrEqual(150 + SUBPROCESS_KILL_GRACE_MS - 50);
108
+ expect(elapsed).toBeLessThan(150 + SUBPROCESS_KILL_GRACE_MS + 4000);
109
+ }, 10000);
110
+
111
+ // AC-9: abort during run mirrors the timeout kill + partial capture.
112
+ it("kills and captures partial output when aborted mid-run", async () => {
113
+ const controller = new AbortController();
114
+ setTimeout(() => controller.abort(), 150);
115
+
116
+ const result = await runJsonlSubprocess({
117
+ argv: [
118
+ "bash",
119
+ "-c",
120
+ // exec replaces bash so the trapping process itself holds the pipe;
121
+ // tree-kill of grandchildren is out of scope (single-child contract).
122
+ 'trap "" TERM; echo "{\\"partial\\":true}"; echo warn >&2; exec sleep 30',
123
+ ],
124
+ env: {},
125
+ timeoutMs: 30000,
126
+ signal: controller.signal,
127
+ });
128
+
129
+ expect(result.timedOut).toBe(true);
130
+ expect(result.events).toEqual([{ partial: true }]);
131
+ expect(result.stderr).toContain("warn");
132
+ expect(result.exitCode).not.toBe(0);
133
+ }, 10000);
134
+
135
+ // AC-9: an already-aborted signal kills immediately.
136
+ it("kills immediately when the signal is already aborted", async () => {
137
+ const result = await runJsonlSubprocess({
138
+ argv: ["sleep", "30"],
139
+ env: {},
140
+ timeoutMs: 30000,
141
+ signal: AbortSignal.abort(),
142
+ });
143
+
144
+ expect(result.timedOut).toBe(true);
145
+ expect(result.exitCode).not.toBe(0);
146
+ }, 10000);
147
+
148
+ // AC-10: the abort listener is removed on every exit, so aborting the same
149
+ // signal after completion does not re-kill (no leak).
150
+ it("removes the abort listener after completion", async () => {
151
+ const controller = new AbortController();
152
+ let abortObserved = false;
153
+ controller.signal.addEventListener("abort", () => {
154
+ abortObserved = true;
155
+ });
156
+
157
+ const result = await runJsonlSubprocess({
158
+ argv: ["bun", "-e", 'console.log(JSON.stringify({ok:true}))'],
159
+ env: {},
160
+ timeoutMs: 5000,
161
+ signal: controller.signal,
162
+ });
163
+
164
+ expect(result.exitCode).toBe(0);
165
+
166
+ // The helper's listener must be gone; only our own observer remains.
167
+ controller.abort();
168
+ expect(abortObserved).toBe(true);
169
+ // No throw / no late kill side effects: the resolved result is unchanged.
170
+ expect(result.events).toEqual([{ ok: true }]);
171
+ }, 10000);
61
172
  });
@@ -28,6 +28,9 @@ function parseJsonl(stdout: string): unknown[] {
28
28
  return events;
29
29
  }
30
30
 
31
+ /** Grace period after SIGTERM before escalating to SIGKILL on timeout/abort. */
32
+ export const SUBPROCESS_KILL_GRACE_MS = 250;
33
+
31
34
  export async function runJsonlSubprocess(
32
35
  args: RunJsonlSubprocessArgs,
33
36
  ): Promise<RunJsonlSubprocessResult> {
@@ -40,33 +43,61 @@ export async function runJsonlSubprocess(
40
43
  ...(cwd !== undefined ? { cwd } : {}),
41
44
  });
42
45
 
46
+ // Drain both streams concurrently so neither pipe buffer can deadlock the
47
+ // other. Capture the latest text so the throw path can still report partial
48
+ // output; the wrapped drains never reject.
49
+ const captured = { stdout: "", stderr: "" };
50
+ const stdoutP = new Response(proc.stdout)
51
+ .text()
52
+ .then((text) => {
53
+ captured.stdout = text;
54
+ return text;
55
+ });
56
+ const stderrP = new Response(proc.stderr)
57
+ .text()
58
+ .then((text) => {
59
+ captured.stderr = text;
60
+ return text;
61
+ });
62
+
63
+ let exited = false;
64
+ void proc.exited.then(() => {
65
+ exited = true;
66
+ });
67
+
43
68
  let timedOut = false;
69
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
70
+
71
+ const killEscalate = (): void => {
72
+ proc.kill("SIGTERM");
73
+ if (graceTimer === undefined) {
74
+ graceTimer = setTimeout(() => {
75
+ if (!exited) proc.kill("SIGKILL");
76
+ }, SUBPROCESS_KILL_GRACE_MS);
77
+ }
78
+ };
79
+
44
80
  const timer = setTimeout(() => {
45
81
  timedOut = true;
46
- proc.kill();
82
+ killEscalate();
47
83
  }, timeoutMs);
48
84
 
85
+ const onAbort = (): void => {
86
+ timedOut = true;
87
+ killEscalate();
88
+ };
89
+
49
90
  if (signal !== undefined) {
50
91
  if (signal.aborted) {
51
- timedOut = true;
52
- proc.kill();
92
+ onAbort();
53
93
  } else {
54
- signal.addEventListener(
55
- "abort",
56
- () => {
57
- proc.kill();
58
- },
59
- { once: true },
60
- );
94
+ signal.addEventListener("abort", onAbort, { once: true });
61
95
  }
62
96
  }
63
97
 
64
98
  try {
65
- const stdout = await new Response(proc.stdout).text();
99
+ const [stdout, stderr] = await Promise.all([stdoutP, stderrP]);
66
100
  await proc.exited;
67
- clearTimeout(timer);
68
-
69
- const stderr = await new Response(proc.stderr).text();
70
101
 
71
102
  return {
72
103
  events: parseJsonl(stdout),
@@ -76,8 +107,16 @@ export async function runJsonlSubprocess(
76
107
  timedOut,
77
108
  };
78
109
  } catch (err) {
79
- clearTimeout(timer);
80
- proc.kill();
110
+ if (typeof err === "object" && err !== null) {
111
+ Object.assign(err, {
112
+ stdout: captured.stdout,
113
+ stderr: captured.stderr,
114
+ });
115
+ }
81
116
  throw err;
117
+ } finally {
118
+ clearTimeout(timer);
119
+ if (graceTimer !== undefined) clearTimeout(graceTimer);
120
+ if (signal !== undefined) signal.removeEventListener("abort", onAbort);
82
121
  }
83
122
  }