@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.
- package/docs/http-api.md +80 -4
- package/package.json +1 -4
- package/src/api/__tests__/index.test.ts +443 -32
- package/src/api/index.ts +108 -127
- package/src/cli/__tests__/analyze-task.test.ts +40 -7
- package/src/cli/__tests__/index.test.ts +337 -17
- package/src/cli/__tests__/types.test.ts +0 -18
- package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
- package/src/cli/analyze-task.ts +3 -14
- package/src/cli/index.ts +73 -61
- package/src/cli/types.ts +0 -13
- package/src/cli/update-pipeline-json.ts +3 -14
- package/src/config/__tests__/paths.test.ts +46 -1
- package/src/config/__tests__/pipeline-registry.test.ts +767 -0
- package/src/config/__tests__/sse-events.test.ts +470 -0
- package/src/config/__tests__/statuses.test.ts +41 -0
- package/src/config/paths.ts +11 -2
- package/src/config/pipeline-registry.ts +258 -0
- package/src/config/sse-events.ts +122 -0
- package/src/config/statuses.ts +20 -1
- package/src/core/__tests__/agent-step.test.ts +115 -0
- package/src/core/__tests__/config.test.ts +545 -105
- package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
- package/src/core/__tests__/job-concurrency.test.ts +260 -0
- package/src/core/__tests__/job-submission.test.ts +396 -0
- package/src/core/__tests__/job-view.test.ts +1341 -0
- package/src/core/__tests__/json-file.test.ts +111 -0
- package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
- package/src/core/__tests__/logger.test.ts +22 -0
- package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
- package/src/core/__tests__/orchestrator.test.ts +615 -2
- package/src/core/__tests__/pipeline-runner.test.ts +946 -9
- package/src/core/__tests__/redact.test.ts +153 -0
- package/src/core/__tests__/runner-liveness.test.ts +910 -0
- package/src/core/__tests__/seed-naming.test.ts +57 -0
- package/src/core/__tests__/single-derivation.test.ts +159 -0
- package/src/core/__tests__/task-runner.test.ts +594 -3
- package/src/core/__tests__/task-telemetry.test.ts +241 -0
- package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
- package/src/core/agent-step.ts +4 -1
- package/src/core/agent-types.ts +5 -4
- package/src/core/config.ts +134 -222
- package/src/core/control.ts +3 -0
- package/src/core/job-concurrency.ts +56 -20
- package/src/core/job-submission.ts +133 -0
- package/src/core/job-view.ts +473 -0
- package/src/core/json-file.ts +45 -0
- package/src/core/lifecycle-policy.ts +23 -8
- package/src/core/logger.ts +0 -29
- package/src/core/orchestrator.ts +200 -74
- package/src/core/pipeline-runner.ts +85 -53
- package/src/core/redact.ts +40 -0
- package/src/core/runner-liveness.ts +280 -0
- package/src/core/seed-naming.ts +9 -0
- package/src/core/status-writer.ts +27 -33
- package/src/core/task-runner.ts +356 -319
- package/src/core/task-telemetry.ts +107 -0
- package/src/harness/__tests__/subprocess.test.ts +112 -1
- package/src/harness/subprocess.ts +55 -16
- package/src/llm/__tests__/index.test.ts +684 -33
- package/src/llm/index.ts +310 -67
- package/src/providers/__tests__/alibaba.test.ts +67 -6
- package/src/providers/__tests__/anthropic.test.ts +35 -14
- package/src/providers/__tests__/base.test.ts +62 -0
- package/src/providers/__tests__/claude-code.test.ts +99 -14
- package/src/providers/__tests__/deepseek.test.ts +16 -6
- package/src/providers/__tests__/gemini.test.ts +47 -25
- package/src/providers/__tests__/moonshot.test.ts +27 -0
- package/src/providers/__tests__/openai.test.ts +262 -74
- package/src/providers/__tests__/opencode.test.ts +77 -0
- package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
- package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
- package/src/providers/__tests__/types.test.ts +85 -0
- package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
- package/src/providers/__tests__/zhipu.test.ts +27 -14
- package/src/providers/alibaba.ts +20 -39
- package/src/providers/anthropic.ts +23 -11
- package/src/providers/base.ts +19 -0
- package/src/providers/claude-code.ts +27 -18
- package/src/providers/deepseek.ts +9 -28
- package/src/providers/gemini.ts +20 -58
- package/src/providers/moonshot.ts +15 -11
- package/src/providers/openai.ts +79 -61
- package/src/providers/opencode.ts +16 -33
- package/src/providers/stream-accumulator.ts +27 -21
- package/src/providers/types.ts +29 -4
- package/src/providers/zhipu.ts +15 -44
- package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
- package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
- package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
- package/src/task-analysis/__tests__/repository.test.ts +65 -0
- package/src/task-analysis/__tests__/types.test.ts +63 -130
- package/src/task-analysis/analyzer.ts +91 -0
- package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
- package/src/task-analysis/index.ts +2 -36
- package/src/task-analysis/repository.ts +45 -0
- package/src/task-analysis/types.ts +42 -58
- package/src/ui/client/__tests__/api.test.ts +143 -1
- package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
- package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
- package/src/ui/client/__tests__/load-state.test.ts +70 -0
- package/src/ui/client/__tests__/types.test.ts +66 -3
- package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
- package/src/ui/client/__tests__/useJobList.test.ts +198 -23
- package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
- package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
- package/src/ui/client/adapters/job-adapter.ts +41 -16
- package/src/ui/client/api.ts +38 -15
- package/src/ui/client/bootstrap.ts +19 -14
- package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
- package/src/ui/client/hooks/useJobList.ts +26 -31
- package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
- package/src/ui/client/load-state.ts +20 -0
- package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
- package/src/ui/client/reducers/job-events.ts +137 -0
- package/src/ui/client/types.ts +16 -20
- package/src/ui/components/DAGGrid.tsx +6 -1
- package/src/ui/components/JobDetail.tsx +12 -4
- package/src/ui/components/JobTable.tsx +41 -13
- package/src/ui/components/StageTimeline.tsx +8 -20
- package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
- package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
- package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
- package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
- package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
- package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
- package/src/ui/components/types.ts +35 -15
- package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
- package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
- package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/pages/PipelineDetail.tsx +60 -4
- package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
- package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
- package/src/ui/pages/__tests__/pages.test.tsx +236 -42
- package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
- package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
- package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
- package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
- package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
- package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
- package/src/ui/server/__tests__/index.test.ts +63 -0
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
- package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
- package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
- package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
- package/src/ui/server/__tests__/router-threading.test.ts +148 -0
- package/src/ui/server/__tests__/router.test.ts +104 -0
- package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
- package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
- package/src/ui/server/config-bridge-node.ts +8 -26
- package/src/ui/server/config-bridge.ts +3 -4
- package/src/ui/server/embedded-assets-imports.d.ts +44 -0
- package/src/ui/server/embedded-assets.ts +13 -2
- package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
- package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
- package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
- package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
- package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
- package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
- package/src/ui/server/endpoints/file-endpoints.ts +15 -10
- package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
- package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
- package/src/ui/server/endpoints/job-endpoints.ts +7 -0
- package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
- package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
- package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
- package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
- package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
- package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
- package/src/ui/server/index.ts +19 -14
- package/src/ui/server/job-reader.ts +14 -2
- package/src/ui/server/router.ts +33 -10
- package/src/ui/server/sse-broadcast.ts +14 -3
- package/src/ui/server/sse-enhancer.ts +12 -2
- package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
- package/src/ui/state/__tests__/snapshot.test.ts +120 -14
- package/src/ui/state/__tests__/types.test.ts +104 -5
- package/src/ui/state/snapshot.ts +2 -2
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
- package/src/ui/state/transformers/list-transformer.ts +13 -3
- package/src/ui/state/transformers/status-transformer.ts +36 -170
- package/src/ui/state/types.ts +15 -48
- package/src/utils/__tests__/path-containment.test.ts +160 -0
- package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
- package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
- package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
- package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
- package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
- package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
- package/src/task-analysis/__tests__/index.test.ts +0 -143
- package/src/task-analysis/__tests__/parser.test.ts +0 -41
- package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
- package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
- package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
- package/src/task-analysis/extractors/artifacts.ts +0 -143
- package/src/task-analysis/extractors/llm-calls.ts +0 -117
- package/src/task-analysis/extractors/stages.ts +0 -45
- package/src/task-analysis/parser.ts +0 -20
- package/src/task-analysis/utils/ast.ts +0 -45
- package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
- package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
- package/src/ui/embedded-assets.js +0 -12
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
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
|
}
|