@evomap/evolver-core 2.0.0-beta.5 → 2.0.0-beta.7
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/dist/algo/conversationSniffer.js +6 -2
- package/dist/events/ingest.js +7 -0
- package/dist/events/paths.d.ts +9 -9
- package/dist/events/paths.js +18 -18
- package/dist/exec/claudeBridge.d.ts +15 -2
- package/dist/exec/claudeBridge.js +322 -36
- package/dist/exec/openPrRegistry.d.ts +8 -2
- package/dist/exec/openPrRegistry.js +32 -22
- package/dist/exec/runnerRegistry.d.ts +26 -0
- package/dist/exec/runnerRegistry.js +305 -47
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/issueReporter/index.d.ts +156 -0
- package/dist/issueReporter/index.js +1688 -0
- package/dist/personality/schema.d.ts +12 -12
- package/dist/util/fetchPort.d.ts +1 -0
- package/dist/util/fetchPort.js +11 -0
- package/dist/util/fileLock.d.ts +5 -3
- package/dist/util/fileLock.js +131 -50
- package/dist/util/index.d.ts +1 -0
- package/dist/util/index.js +1 -0
- package/dist/workflow/dsl.d.ts +24 -3
- package/dist/workflow/dsl.js +4 -0
- package/dist/workflow/engine.d.ts +5 -1
- package/dist/workflow/engine.js +3 -0
- package/dist/workflow/index.d.ts +3 -1
- package/dist/workflow/index.js +3 -1
- package/dist/workflow/runtime.d.ts +110 -0
- package/dist/workflow/runtime.js +1298 -0
- package/dist/workflow/stateStore.d.ts +172 -0
- package/dist/workflow/stateStore.js +1044 -0
- package/package.json +1 -1
|
@@ -44,9 +44,15 @@ export declare function findSignalHints(signals: readonly string[], prs: readonl
|
|
|
44
44
|
threshold?: number;
|
|
45
45
|
topN?: number;
|
|
46
46
|
}): SignalHint[];
|
|
47
|
+
export declare function parseGhOpenPrListResult(result: {
|
|
48
|
+
code: number | null;
|
|
49
|
+
stdout: string;
|
|
50
|
+
stdoutTruncated?: boolean;
|
|
51
|
+
termination?: 'exit' | 'timeout' | 'cancelled';
|
|
52
|
+
}): OpenPr[];
|
|
47
53
|
/**
|
|
48
|
-
* Default lister: `gh pr list --state=open --json number,title,headRefName,files --limit 50`.
|
|
49
|
-
*
|
|
54
|
+
* Default lister: `gh pr list --state=open --json number,title,headRefName,files --limit 50`.
|
|
55
|
+
* Legacy fetch/parse failures return []; proven incomplete bounded capture rejects so dedup fails closed. gh is a
|
|
50
56
|
* trusted infra tool, so its own auth (GH_TOKEN/GITHUB_TOKEN, or the gh config under $HOME) is passed through.
|
|
51
57
|
*/
|
|
52
58
|
export declare function makeGhPrLister(): OpenPrLister;
|
|
@@ -77,34 +77,44 @@ export function findSignalHints(signals, prs, opts = {}) {
|
|
|
77
77
|
}
|
|
78
78
|
// ── gh lister seam + TTL cache ─────────────────────────────────────────────
|
|
79
79
|
const GH_TIMEOUT_MS = 5000;
|
|
80
|
+
export function parseGhOpenPrListResult(result) {
|
|
81
|
+
if (result.termination !== undefined && result.termination !== 'exit') {
|
|
82
|
+
throw new Error(`gh open PR list did not complete (${result.termination})`);
|
|
83
|
+
}
|
|
84
|
+
if (result.stdoutTruncated)
|
|
85
|
+
throw new Error('gh open PR list exceeded the capture limit');
|
|
86
|
+
if (result.code !== 0)
|
|
87
|
+
return [];
|
|
88
|
+
try {
|
|
89
|
+
const arr = JSON.parse(result.stdout || '[]');
|
|
90
|
+
if (!Array.isArray(arr))
|
|
91
|
+
return [];
|
|
92
|
+
return arr.map((pr) => ({
|
|
93
|
+
number: Number(pr.number),
|
|
94
|
+
title: String(pr.title ?? ''),
|
|
95
|
+
headRefName: String(pr.headRefName ?? ''),
|
|
96
|
+
files: Array.isArray(pr.files) ? pr.files.map((f) => String(f.path ?? '')).filter(Boolean) : [],
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
80
103
|
/**
|
|
81
|
-
* Default lister: `gh pr list --state=open --json number,title,headRefName,files --limit 50`.
|
|
82
|
-
*
|
|
104
|
+
* Default lister: `gh pr list --state=open --json number,title,headRefName,files --limit 50`.
|
|
105
|
+
* Legacy fetch/parse failures return []; proven incomplete bounded capture rejects so dedup fails closed. gh is a
|
|
83
106
|
* trusted infra tool, so its own auth (GH_TOKEN/GITHUB_TOKEN, or the gh config under $HOME) is passed through.
|
|
84
107
|
*/
|
|
85
108
|
export function makeGhPrLister() {
|
|
86
109
|
return async (cwd) => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (r.code !== 0)
|
|
94
|
-
return [];
|
|
95
|
-
const arr = JSON.parse(r.stdout || '[]');
|
|
96
|
-
if (!Array.isArray(arr))
|
|
97
|
-
return [];
|
|
98
|
-
return arr.map((pr) => ({
|
|
99
|
-
number: Number(pr.number),
|
|
100
|
-
title: String(pr.title ?? ''),
|
|
101
|
-
headRefName: String(pr.headRefName ?? ''),
|
|
102
|
-
files: Array.isArray(pr.files) ? pr.files.map((f) => String(f.path ?? '')).filter(Boolean) : [],
|
|
103
|
-
}));
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
110
|
+
const r = await spawnCapture('gh', ['pr', 'list', '--state=open', '--json', 'number,title,headRefName,files', '--limit', '50'], {
|
|
111
|
+
cwd: cwd ?? process.cwd(),
|
|
112
|
+
timeoutMs: GH_TIMEOUT_MS,
|
|
113
|
+
env: scrubAgentEnv(process.env, { allowKeys: ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_HOST', 'GH_CONFIG_DIR'] }),
|
|
114
|
+
}).catch(() => null);
|
|
115
|
+
if (!r)
|
|
106
116
|
return [];
|
|
107
|
-
|
|
117
|
+
return parseGhOpenPrListResult(r);
|
|
108
118
|
};
|
|
109
119
|
}
|
|
110
120
|
/**
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export declare const DEFAULT_TIMEOUT_MS = 600000;
|
|
2
|
+
/** Per-stream stdout/stderr capture ceiling. A child can emit indefinitely without growing the parent heap. */
|
|
3
|
+
export declare const DEFAULT_MAX_CAPTURE_BYTES = 1048576;
|
|
2
4
|
export interface AgentRunContext {
|
|
3
5
|
cwd: string;
|
|
4
6
|
timeoutMs?: number;
|
|
@@ -73,6 +75,17 @@ export interface SpawnCaptureOptions {
|
|
|
73
75
|
signal?: AbortSignal;
|
|
74
76
|
/** Cleanup subprocesses can shield themselves from repeated SIGINT/SIGTERM instead of cancelling. */
|
|
75
77
|
processSignalMode?: 'cancel' | 'ignore';
|
|
78
|
+
/** Maximum retained bytes for each of stdout and stderr. The original byte count is still reported. */
|
|
79
|
+
maxOutputBytes?: number;
|
|
80
|
+
/** Stream stdout directly to a file when the complete artifact must outlive the subprocess. */
|
|
81
|
+
stdoutFile?: string;
|
|
82
|
+
/** Ownership hook fired only after an exclusive redirected stdout artifact is opened successfully. */
|
|
83
|
+
onStdoutFileOpened?: (path: string) => void;
|
|
84
|
+
/** Test seam for redirected stdout finalization; production callers should use the filesystem defaults. */
|
|
85
|
+
stdoutFileOps?: {
|
|
86
|
+
size(fd: number): number;
|
|
87
|
+
close(fd: number): void;
|
|
88
|
+
};
|
|
76
89
|
resolvePlatform?: NodeJS.Platform;
|
|
77
90
|
/** Test seam for Windows process behavior; production callers should use the default. */
|
|
78
91
|
processPlatform?: NodeJS.Platform;
|
|
@@ -84,6 +97,17 @@ export interface SpawnCaptureResult {
|
|
|
84
97
|
stdout: string;
|
|
85
98
|
stderr: string;
|
|
86
99
|
termination: 'exit' | 'timeout' | 'cancelled';
|
|
100
|
+
/** Present on real spawn results; optional so injected legacy test seams remain source-compatible. */
|
|
101
|
+
stdoutBytes?: number;
|
|
102
|
+
stderrBytes?: number;
|
|
103
|
+
stdoutTruncated?: boolean;
|
|
104
|
+
stderrTruncated?: boolean;
|
|
105
|
+
stdoutRedirected?: boolean;
|
|
106
|
+
}
|
|
107
|
+
/** A redirected stdout artifact could not be finalized; the subprocess outcome remains available for classification. */
|
|
108
|
+
export declare class SpawnCaptureFinalizeError extends Error {
|
|
109
|
+
readonly result: SpawnCaptureResult;
|
|
110
|
+
constructor(result: SpawnCaptureResult, cause?: unknown);
|
|
87
111
|
}
|
|
88
112
|
/**
|
|
89
113
|
* Promise wrapper over spawn (shell:false). Optionally writes `input` to stdin; resolves with stdout/exit.
|
|
@@ -136,6 +160,8 @@ export declare const claudeHeadlessRunner: AgentRunner;
|
|
|
136
160
|
export declare function codexRunnerArgs(opts?: AgentRunnerOptions): string[];
|
|
137
161
|
/** Headless `codex exec` runner. Working root pinned with `--cd`; prompt is the trailing positional arg (shell:false). */
|
|
138
162
|
export declare function makeCodexHeadlessRunner(opts?: AgentRunnerOptions): AgentRunner;
|
|
163
|
+
/** Interpret one bounded Gemini subprocess result. Structured output and diagnostics require complete capture. */
|
|
164
|
+
export declare function classifyGeminiRunnerResult(result: SpawnCaptureResult, timeoutMs: number): AgentRunResult;
|
|
139
165
|
/** Build verified Gemini CLI argv. The prompt is appended separately as one argv element with shell:false. */
|
|
140
166
|
export declare function geminiRunnerArgs(opts?: AgentRunnerOptions): string[];
|
|
141
167
|
/** Headless Gemini runner with structured failure classification; stdout text alone never proves execution success. */
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
// nothing here spawns a real agent in tests except through spawnCapture, which the bridge injects fakes around.
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import { join as joinPath, delimiter as pathDelimiter } from 'node:path';
|
|
8
|
-
import {
|
|
8
|
+
import { closeSync, existsSync, fstatSync, openSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
9
9
|
export const DEFAULT_TIMEOUT_MS = 600_000;
|
|
10
|
+
/** Per-stream stdout/stderr capture ceiling. A child can emit indefinitely without growing the parent heap. */
|
|
11
|
+
export const DEFAULT_MAX_CAPTURE_BYTES = 1_048_576;
|
|
12
|
+
const MIN_MAX_CAPTURE_BYTES = 256;
|
|
10
13
|
/** Thrown when permission bypass is requested without bounding the agent's tools (would be an unbounded autonomous agent). */
|
|
11
14
|
export class UnboundedSkipPermissionsError extends Error {
|
|
12
15
|
constructor() {
|
|
@@ -161,6 +164,132 @@ export function killWindowsProcessTree(pid, spawnCommand = spawn, timeoutMs = WI
|
|
|
161
164
|
}
|
|
162
165
|
});
|
|
163
166
|
}
|
|
167
|
+
/** A redirected stdout artifact could not be finalized; the subprocess outcome remains available for classification. */
|
|
168
|
+
export class SpawnCaptureFinalizeError extends Error {
|
|
169
|
+
result;
|
|
170
|
+
constructor(result, cause) {
|
|
171
|
+
const detail = cause instanceof Error ? `: ${cause.message}` : '';
|
|
172
|
+
super(`redirected stdout finalization failed${detail}`, { cause });
|
|
173
|
+
this.name = 'SpawnCaptureFinalizeError';
|
|
174
|
+
this.result = result;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Retain a bounded prefix and suffix while counting every byte received. Keeping raw buffers until rendering
|
|
179
|
+
* avoids corrupting multi-byte UTF-8 characters when Node splits a character across stream chunks.
|
|
180
|
+
*/
|
|
181
|
+
class BoundedStreamCapture {
|
|
182
|
+
maxBytes;
|
|
183
|
+
headCapacity;
|
|
184
|
+
tailCapacity;
|
|
185
|
+
head;
|
|
186
|
+
tail;
|
|
187
|
+
headLength = 0;
|
|
188
|
+
tailLength = 0;
|
|
189
|
+
tailWriteOffset = 0;
|
|
190
|
+
totalBytes = 0;
|
|
191
|
+
constructor(maxBytes) {
|
|
192
|
+
this.maxBytes = maxBytes;
|
|
193
|
+
this.headCapacity = Math.ceil(maxBytes / 2);
|
|
194
|
+
this.tailCapacity = maxBytes - this.headCapacity;
|
|
195
|
+
}
|
|
196
|
+
append(value) {
|
|
197
|
+
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
198
|
+
this.totalBytes += chunk.length;
|
|
199
|
+
let offset = 0;
|
|
200
|
+
if (this.headLength < this.headCapacity) {
|
|
201
|
+
const take = Math.min(this.headCapacity - this.headLength, chunk.length);
|
|
202
|
+
const head = this.ensureHeadCapacity(this.headLength + take);
|
|
203
|
+
chunk.copy(head, this.headLength, 0, take);
|
|
204
|
+
this.headLength += take;
|
|
205
|
+
offset = take;
|
|
206
|
+
}
|
|
207
|
+
if (offset < chunk.length)
|
|
208
|
+
this.appendTail(chunk.subarray(offset));
|
|
209
|
+
}
|
|
210
|
+
result() {
|
|
211
|
+
const head = this.head?.subarray(0, this.headLength) ?? Buffer.alloc(0);
|
|
212
|
+
const tail = this.orderedTail();
|
|
213
|
+
if (this.totalBytes <= this.maxBytes) {
|
|
214
|
+
return {
|
|
215
|
+
text: Buffer.concat([head, tail]).toString('utf8'),
|
|
216
|
+
bytes: this.totalBytes,
|
|
217
|
+
truncated: false,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const marker = Buffer.from(`\n...[evolver output truncated; total_bytes=${this.totalBytes}]...\n`);
|
|
221
|
+
const retainedBudget = this.maxBytes - marker.length;
|
|
222
|
+
const headBudget = Math.ceil(retainedBudget / 2);
|
|
223
|
+
const tailBudget = retainedBudget - headBudget;
|
|
224
|
+
const retainedHead = trimIncompleteUtf8Suffix(head.subarray(0, headBudget));
|
|
225
|
+
const tailStart = Math.max(0, tail.length - tailBudget);
|
|
226
|
+
const retainedTail = trimUtf8ContinuationPrefix(tail.subarray(tailStart));
|
|
227
|
+
return {
|
|
228
|
+
text: Buffer.concat([retainedHead, marker, retainedTail]).toString('utf8'),
|
|
229
|
+
bytes: this.totalBytes,
|
|
230
|
+
truncated: true,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
ensureHeadCapacity(required) {
|
|
234
|
+
const current = this.head;
|
|
235
|
+
if (current && current.length >= required)
|
|
236
|
+
return current;
|
|
237
|
+
let capacity = current?.length ?? Math.min(4_096, this.headCapacity);
|
|
238
|
+
while (capacity < required)
|
|
239
|
+
capacity = Math.min(this.headCapacity, capacity * 2);
|
|
240
|
+
const next = Buffer.allocUnsafe(capacity);
|
|
241
|
+
if (current)
|
|
242
|
+
current.copy(next, 0, 0, this.headLength);
|
|
243
|
+
this.head = next;
|
|
244
|
+
return next;
|
|
245
|
+
}
|
|
246
|
+
appendTail(incoming) {
|
|
247
|
+
const tail = this.tail ??= Buffer.allocUnsafe(this.tailCapacity);
|
|
248
|
+
if (incoming.length >= this.tailCapacity) {
|
|
249
|
+
incoming.copy(tail, 0, incoming.length - this.tailCapacity);
|
|
250
|
+
this.tailLength = this.tailCapacity;
|
|
251
|
+
this.tailWriteOffset = 0;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const first = Math.min(incoming.length, this.tailCapacity - this.tailWriteOffset);
|
|
255
|
+
incoming.copy(tail, this.tailWriteOffset, 0, first);
|
|
256
|
+
if (first < incoming.length)
|
|
257
|
+
incoming.copy(tail, 0, first);
|
|
258
|
+
this.tailWriteOffset = (this.tailWriteOffset + incoming.length) % this.tailCapacity;
|
|
259
|
+
this.tailLength = Math.min(this.tailCapacity, this.tailLength + incoming.length);
|
|
260
|
+
}
|
|
261
|
+
orderedTail() {
|
|
262
|
+
const tail = this.tail;
|
|
263
|
+
if (!tail || this.tailLength === 0)
|
|
264
|
+
return Buffer.alloc(0);
|
|
265
|
+
if (this.tailLength < this.tailCapacity)
|
|
266
|
+
return tail.subarray(0, this.tailLength);
|
|
267
|
+
if (this.tailWriteOffset === 0)
|
|
268
|
+
return tail;
|
|
269
|
+
return Buffer.concat([
|
|
270
|
+
tail.subarray(this.tailWriteOffset),
|
|
271
|
+
tail.subarray(0, this.tailWriteOffset),
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function trimIncompleteUtf8Suffix(value) {
|
|
276
|
+
if (value.length === 0)
|
|
277
|
+
return value;
|
|
278
|
+
let lead = value.length - 1;
|
|
279
|
+
while (lead >= 0 && (value[lead] & 0xc0) === 0x80)
|
|
280
|
+
lead -= 1;
|
|
281
|
+
if (lead < 0)
|
|
282
|
+
return Buffer.alloc(0);
|
|
283
|
+
const first = value[lead];
|
|
284
|
+
const expected = first < 0x80 ? 1 : first >= 0xf0 ? 4 : first >= 0xe0 ? 3 : first >= 0xc0 ? 2 : 1;
|
|
285
|
+
return value.length - lead < expected ? value.subarray(0, lead) : value;
|
|
286
|
+
}
|
|
287
|
+
function trimUtf8ContinuationPrefix(value) {
|
|
288
|
+
let offset = 0;
|
|
289
|
+
while (offset < value.length && (value[offset] & 0xc0) === 0x80)
|
|
290
|
+
offset += 1;
|
|
291
|
+
return value.subarray(offset);
|
|
292
|
+
}
|
|
164
293
|
/**
|
|
165
294
|
* Promise wrapper over spawn (shell:false). Optionally writes `input` to stdin; resolves with stdout/exit.
|
|
166
295
|
* On timeout the WHOLE process group is killed, not just the direct child (finding #39.5): an agent spawns
|
|
@@ -169,20 +298,87 @@ export function killWindowsProcessTree(pid, spawnCommand = spawn, timeoutMs = WI
|
|
|
169
298
|
* `taskkill.exe /PID <pid> /T /F` without a shell and waits for that command before resolving.
|
|
170
299
|
*/
|
|
171
300
|
export function spawnCapture(cmd, args, opts) {
|
|
301
|
+
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_CAPTURE_BYTES;
|
|
302
|
+
if (!Number.isSafeInteger(maxOutputBytes)
|
|
303
|
+
|| maxOutputBytes < MIN_MAX_CAPTURE_BYTES
|
|
304
|
+
|| maxOutputBytes > DEFAULT_MAX_CAPTURE_BYTES) {
|
|
305
|
+
throw new RangeError(`maxOutputBytes must be an integer between ${MIN_MAX_CAPTURE_BYTES} and ${DEFAULT_MAX_CAPTURE_BYTES}`);
|
|
306
|
+
}
|
|
172
307
|
return new Promise((resolve, reject) => {
|
|
173
308
|
if (opts.signal?.aborted) {
|
|
174
|
-
resolve({
|
|
309
|
+
resolve({
|
|
310
|
+
code: null,
|
|
311
|
+
stdout: '',
|
|
312
|
+
stderr: '',
|
|
313
|
+
termination: 'cancelled',
|
|
314
|
+
stdoutBytes: 0,
|
|
315
|
+
stderrBytes: 0,
|
|
316
|
+
stdoutTruncated: false,
|
|
317
|
+
stderrTruncated: false,
|
|
318
|
+
});
|
|
175
319
|
return;
|
|
176
320
|
}
|
|
177
321
|
const platform = opts.processPlatform ?? process.platform;
|
|
178
322
|
const detached = platform !== 'win32';
|
|
179
323
|
const r = resolveSpawnCommand(cmd, args, opts.env, opts.resolvePlatform ?? process.platform);
|
|
180
|
-
|
|
181
|
-
let
|
|
182
|
-
|
|
324
|
+
let stdoutFd;
|
|
325
|
+
let ownsStdoutFile = false;
|
|
326
|
+
const cleanupOwnedStdoutFile = () => {
|
|
327
|
+
if (!ownsStdoutFile || !opts.stdoutFile)
|
|
328
|
+
return;
|
|
329
|
+
try {
|
|
330
|
+
rmSync(opts.stdoutFile, { force: true });
|
|
331
|
+
ownsStdoutFile = false;
|
|
332
|
+
}
|
|
333
|
+
catch { /* best-effort; a caller ownership hook can retry */ }
|
|
334
|
+
};
|
|
335
|
+
try {
|
|
336
|
+
if (opts.stdoutFile) {
|
|
337
|
+
stdoutFd = openSync(opts.stdoutFile, 'wx', 0o600);
|
|
338
|
+
ownsStdoutFile = true;
|
|
339
|
+
opts.onStdoutFileOpened?.(opts.stdoutFile);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
if (stdoutFd !== undefined) {
|
|
344
|
+
try {
|
|
345
|
+
closeSync(stdoutFd);
|
|
346
|
+
}
|
|
347
|
+
catch { /* best-effort cleanup before the child exists */ }
|
|
348
|
+
stdoutFd = undefined;
|
|
349
|
+
}
|
|
350
|
+
cleanupOwnedStdoutFile();
|
|
351
|
+
reject(error);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
let child;
|
|
355
|
+
try {
|
|
356
|
+
child = spawn(r.cmd, r.args, {
|
|
357
|
+
cwd: opts.cwd,
|
|
358
|
+
shell: false,
|
|
359
|
+
detached,
|
|
360
|
+
...(opts.env ? { env: opts.env } : {}),
|
|
361
|
+
...(stdoutFd !== undefined ? { stdio: ['pipe', stdoutFd, 'pipe'] } : {}),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
if (stdoutFd !== undefined) {
|
|
366
|
+
try {
|
|
367
|
+
closeSync(stdoutFd);
|
|
368
|
+
}
|
|
369
|
+
catch { /* best-effort cleanup before rejection */ }
|
|
370
|
+
stdoutFd = undefined;
|
|
371
|
+
}
|
|
372
|
+
cleanupOwnedStdoutFile();
|
|
373
|
+
reject(error);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const stdoutCapture = new BoundedStreamCapture(maxOutputBytes);
|
|
377
|
+
const stderrCapture = new BoundedStreamCapture(maxOutputBytes);
|
|
183
378
|
let termination = 'exit';
|
|
184
379
|
let killPromise;
|
|
185
380
|
let settled = false;
|
|
381
|
+
let redirectedStdoutBytes;
|
|
186
382
|
const killTree = () => {
|
|
187
383
|
if (killPromise)
|
|
188
384
|
return killPromise;
|
|
@@ -241,7 +437,28 @@ export function spawnCapture(cmd, args, opts) {
|
|
|
241
437
|
if (killPromise)
|
|
242
438
|
await killPromise;
|
|
243
439
|
cleanup();
|
|
244
|
-
|
|
440
|
+
let stdoutFileError;
|
|
441
|
+
if (stdoutFd !== undefined) {
|
|
442
|
+
const fd = stdoutFd;
|
|
443
|
+
stdoutFd = undefined;
|
|
444
|
+
try {
|
|
445
|
+
redirectedStdoutBytes = opts.stdoutFileOps?.size(fd) ?? fstatSync(fd).size;
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
stdoutFileError = error;
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
(opts.stdoutFileOps?.close ?? closeSync)(fd);
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
stdoutFileError ??= error;
|
|
455
|
+
try {
|
|
456
|
+
closeSync(fd);
|
|
457
|
+
}
|
|
458
|
+
catch { /* retry a failed/injected close before removing our artifact */ }
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
finish(stdoutFileError);
|
|
245
462
|
};
|
|
246
463
|
const timer = setTimeout(timeout, opts.timeoutMs);
|
|
247
464
|
opts.signal?.addEventListener('abort', cancel, { once: true });
|
|
@@ -255,10 +472,37 @@ export function spawnCapture(cmd, args, opts) {
|
|
|
255
472
|
process.once('SIGINT', cancel);
|
|
256
473
|
process.once('SIGTERM', cancel);
|
|
257
474
|
}
|
|
258
|
-
child.stdout?.on('data', (d) => {
|
|
259
|
-
child.stderr?.on('data', (d) => {
|
|
260
|
-
child.on('error', (e) => {
|
|
261
|
-
|
|
475
|
+
child.stdout?.on('data', (d) => { stdoutCapture.append(d); });
|
|
476
|
+
child.stderr?.on('data', (d) => { stderrCapture.append(d); });
|
|
477
|
+
child.on('error', (e) => {
|
|
478
|
+
void settle(() => {
|
|
479
|
+
cleanupOwnedStdoutFile();
|
|
480
|
+
reject(e);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
child.on('close', (code) => {
|
|
484
|
+
void settle((stdoutFileError) => {
|
|
485
|
+
const stdout = stdoutCapture.result();
|
|
486
|
+
const stderr = stderrCapture.result();
|
|
487
|
+
const result = {
|
|
488
|
+
code,
|
|
489
|
+
stdout: stdout.text,
|
|
490
|
+
stderr: stderr.text,
|
|
491
|
+
termination,
|
|
492
|
+
stdoutBytes: redirectedStdoutBytes ?? stdout.bytes,
|
|
493
|
+
stderrBytes: stderr.bytes,
|
|
494
|
+
stdoutTruncated: stdout.truncated,
|
|
495
|
+
stderrTruncated: stderr.truncated,
|
|
496
|
+
...(opts.stdoutFile ? { stdoutRedirected: true } : {}),
|
|
497
|
+
};
|
|
498
|
+
if (stdoutFileError !== undefined) {
|
|
499
|
+
cleanupOwnedStdoutFile();
|
|
500
|
+
reject(new SpawnCaptureFinalizeError(result, stdoutFileError));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
resolve(result);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
262
506
|
if (opts.input !== undefined) {
|
|
263
507
|
child.stdin?.write(opts.input);
|
|
264
508
|
child.stdin?.end();
|
|
@@ -400,6 +644,54 @@ function geminiMessage(value) {
|
|
|
400
644
|
function geminiWarnings(value) {
|
|
401
645
|
return Array.isArray(value) ? value.map(geminiMessage).filter(Boolean) : [];
|
|
402
646
|
}
|
|
647
|
+
/** Interpret one bounded Gemini subprocess result. Structured output and diagnostics require complete capture. */
|
|
648
|
+
export function classifyGeminiRunnerResult(result, timeoutMs) {
|
|
649
|
+
if (result.termination === 'timeout') {
|
|
650
|
+
return { ok: false, output: result.stdout, error: `gemini timed out after ${timeoutMs}ms`, failureKind: 'timeout', exitCode: result.code };
|
|
651
|
+
}
|
|
652
|
+
if (result.termination === 'cancelled') {
|
|
653
|
+
return { ok: false, output: result.stdout, error: 'gemini execution cancelled', failureKind: 'cancelled', exitCode: result.code };
|
|
654
|
+
}
|
|
655
|
+
if (result.stdoutTruncated || result.stderrTruncated) {
|
|
656
|
+
return {
|
|
657
|
+
ok: false,
|
|
658
|
+
output: result.stdout,
|
|
659
|
+
error: 'gemini output exceeded the capture limit',
|
|
660
|
+
failureKind: 'invalid_output',
|
|
661
|
+
exitCode: result.code,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
let envelope;
|
|
665
|
+
try {
|
|
666
|
+
const parsed = JSON.parse(result.stdout);
|
|
667
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
668
|
+
throw new Error('JSON object required');
|
|
669
|
+
envelope = parsed;
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
const error = result.stderr || (result.code === 0 ? 'gemini returned invalid JSON output' : `gemini exited with code ${String(result.code)}`);
|
|
673
|
+
return { ok: false, output: result.stdout, error, failureKind: result.code === 0 ? 'invalid_output' : 'non_zero_exit', exitCode: result.code };
|
|
674
|
+
}
|
|
675
|
+
const structuredError = geminiMessage(envelope.error);
|
|
676
|
+
const warnings = geminiWarnings(envelope.warnings);
|
|
677
|
+
const terminationError = result.code === 0
|
|
678
|
+
? GEMINI_TERMINATION_WARNINGS.find(({ pattern }) => warnings.some((warning) => pattern.test(warning)))?.error
|
|
679
|
+
: undefined;
|
|
680
|
+
if (terminationError) {
|
|
681
|
+
return { ok: false, output: geminiMessage(envelope.response), error: terminationError, failureKind: 'runtime_error', exitCode: result.code };
|
|
682
|
+
}
|
|
683
|
+
const denial = [structuredError, ...warnings, result.stderr].find((message) => GEMINI_PERMISSION_DENIAL_RE.test(message));
|
|
684
|
+
if (denial) {
|
|
685
|
+
return { ok: false, output: geminiMessage(envelope.response), error: denial, failureKind: 'permission_denied', exitCode: result.code };
|
|
686
|
+
}
|
|
687
|
+
if (result.code !== 0) {
|
|
688
|
+
return { ok: false, output: geminiMessage(envelope.response), error: structuredError || result.stderr || `gemini exited with code ${String(result.code)}`, failureKind: 'non_zero_exit', exitCode: result.code };
|
|
689
|
+
}
|
|
690
|
+
if (structuredError) {
|
|
691
|
+
return { ok: false, output: geminiMessage(envelope.response), error: structuredError, failureKind: 'runtime_error', exitCode: result.code };
|
|
692
|
+
}
|
|
693
|
+
return { ok: true, output: geminiMessage(envelope.response), exitCode: result.code };
|
|
694
|
+
}
|
|
403
695
|
/** Build verified Gemini CLI argv. The prompt is appended separately as one argv element with shell:false. */
|
|
404
696
|
export function geminiRunnerArgs(opts = {}) {
|
|
405
697
|
if (opts.skipPermissions || (opts.allowedTools?.length ?? 0) > 0)
|
|
@@ -414,48 +706,14 @@ export function makeGeminiHeadlessRunner(opts = {}) {
|
|
|
414
706
|
const args = geminiRunnerArgs(opts);
|
|
415
707
|
return async (prompt, ctx) => {
|
|
416
708
|
try {
|
|
709
|
+
const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
417
710
|
const result = await spawnCapture('gemini', [...args, '--prompt', prompt], {
|
|
418
711
|
cwd: ctx.cwd,
|
|
419
|
-
timeoutMs
|
|
712
|
+
timeoutMs,
|
|
420
713
|
...(ctx.env ? { env: ctx.env } : {}),
|
|
421
714
|
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
422
715
|
});
|
|
423
|
-
|
|
424
|
-
return { ok: false, output: result.stdout, error: `gemini timed out after ${ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`, failureKind: 'timeout', exitCode: result.code };
|
|
425
|
-
}
|
|
426
|
-
if (result.termination === 'cancelled') {
|
|
427
|
-
return { ok: false, output: result.stdout, error: 'gemini execution cancelled', failureKind: 'cancelled', exitCode: result.code };
|
|
428
|
-
}
|
|
429
|
-
let envelope;
|
|
430
|
-
try {
|
|
431
|
-
const parsed = JSON.parse(result.stdout);
|
|
432
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
433
|
-
throw new Error('JSON object required');
|
|
434
|
-
envelope = parsed;
|
|
435
|
-
}
|
|
436
|
-
catch {
|
|
437
|
-
const error = result.stderr || (result.code === 0 ? 'gemini returned invalid JSON output' : `gemini exited with code ${String(result.code)}`);
|
|
438
|
-
return { ok: false, output: result.stdout, error, failureKind: result.code === 0 ? 'invalid_output' : 'non_zero_exit', exitCode: result.code };
|
|
439
|
-
}
|
|
440
|
-
const structuredError = geminiMessage(envelope.error);
|
|
441
|
-
const warnings = geminiWarnings(envelope.warnings);
|
|
442
|
-
const terminationError = result.code === 0
|
|
443
|
-
? GEMINI_TERMINATION_WARNINGS.find(({ pattern }) => warnings.some((warning) => pattern.test(warning)))?.error
|
|
444
|
-
: undefined;
|
|
445
|
-
if (terminationError) {
|
|
446
|
-
return { ok: false, output: geminiMessage(envelope.response), error: terminationError, failureKind: 'runtime_error', exitCode: result.code };
|
|
447
|
-
}
|
|
448
|
-
const denial = [structuredError, ...warnings, result.stderr].find((message) => GEMINI_PERMISSION_DENIAL_RE.test(message));
|
|
449
|
-
if (denial) {
|
|
450
|
-
return { ok: false, output: geminiMessage(envelope.response), error: denial, failureKind: 'permission_denied', exitCode: result.code };
|
|
451
|
-
}
|
|
452
|
-
if (result.code !== 0) {
|
|
453
|
-
return { ok: false, output: geminiMessage(envelope.response), error: structuredError || result.stderr || `gemini exited with code ${String(result.code)}`, failureKind: 'non_zero_exit', exitCode: result.code };
|
|
454
|
-
}
|
|
455
|
-
if (structuredError) {
|
|
456
|
-
return { ok: false, output: geminiMessage(envelope.response), error: structuredError, failureKind: 'runtime_error', exitCode: result.code };
|
|
457
|
-
}
|
|
458
|
-
return { ok: true, output: geminiMessage(envelope.response), exitCode: result.code };
|
|
716
|
+
return classifyGeminiRunnerResult(result, timeoutMs);
|
|
459
717
|
}
|
|
460
718
|
catch (error) {
|
|
461
719
|
return { ok: false, output: '', error: error instanceof Error ? error.message : String(error), failureKind: 'spawn_failed', exitCode: null };
|
package/dist/index.d.ts
CHANGED
|
@@ -24,4 +24,5 @@ export * as hub from './hub/index.js';
|
|
|
24
24
|
export * as shadow from './shadow/index.js';
|
|
25
25
|
export * as ops from './ops/index.js';
|
|
26
26
|
export * as util from './util/index.js';
|
|
27
|
-
export * as trace from './trace/index.js';
|
|
27
|
+
export * as trace from './trace/index.js';
|
|
28
|
+
export * as issueReporter from './issueReporter/index.js';
|
package/dist/index.js
CHANGED
|
@@ -24,4 +24,5 @@ export * as hub from './hub/index.js';
|
|
|
24
24
|
export * as shadow from './shadow/index.js';
|
|
25
25
|
export * as ops from './ops/index.js';
|
|
26
26
|
export * as util from './util/index.js';
|
|
27
|
-
export * as trace from './trace/index.js';
|
|
27
|
+
export * as trace from './trace/index.js';
|
|
28
|
+
export * as issueReporter from './issueReporter/index.js';
|