@evomap/evolver-core 2.0.0-beta.5 → 2.0.0-beta.6
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/events/ingest.js +7 -0
- package/dist/exec/claudeBridge.d.ts +12 -2
- package/dist/exec/claudeBridge.js +200 -29
- 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 +1668 -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/index.d.ts +1 -0
- package/dist/util/index.js +1 -0
- package/package.json +1 -1
package/dist/events/ingest.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
1
2
|
import { EventStore } from './eventStore.js';
|
|
3
|
+
import { createIssueDraftForEventBestEffort } from '../issueReporter/index.js';
|
|
2
4
|
/** 已知事件类型 (军杰 §9; 可 registerEventType 扩展). */
|
|
3
5
|
export const EVENT_TYPES = [
|
|
4
6
|
'cycle.started', 'cycle.signals_collected', 'cycle.solidified', 'cycle.failed',
|
|
@@ -51,6 +53,11 @@ export class Ingestor {
|
|
|
51
53
|
if (raw.actor?.kind === 'human' && !raw.actor.id)
|
|
52
54
|
throw new IngestValidationError('actor.kind=human 必带 actor.id (审计, 军杰 §9.7)');
|
|
53
55
|
const evt = await this.store.append(raw);
|
|
56
|
+
createIssueDraftForEventBestEffort(evt, {
|
|
57
|
+
rootDir: join(dirname(this.store.path), 'issue-reporter'),
|
|
58
|
+
workspaceScope: process.cwd(),
|
|
59
|
+
env: process.env,
|
|
60
|
+
});
|
|
54
61
|
this.sink?.dispatch(evt);
|
|
55
62
|
return evt;
|
|
56
63
|
}
|
|
@@ -4,13 +4,16 @@ import type { ExecutionResult } from '../algo/cycleEngine.js';
|
|
|
4
4
|
import { type GeneStrategyInfo } from './prompt.js';
|
|
5
5
|
import type { PersonalityStore } from '../personality/store.js';
|
|
6
6
|
import { type AgentRunner, type AgentRunnerOptions, type RunnerName } from './runnerRegistry.js';
|
|
7
|
-
export { resolveSpawnCommand, spawnCapture, UnboundedSkipPermissionsError, UnsupportedCursorSkipPermissionsError, UnsupportedGeminiPermissionOptionsError, claudeRunnerArgs, makeClaudeHeadlessRunner, claudeHeadlessRunner, codexRunnerArgs, makeCodexHeadlessRunner, cursorRunnerArgs, makeCursorHeadlessRunner, getRunnerSpec, geminiRunnerArgs, makeGeminiHeadlessRunner, } from './runnerRegistry.js';
|
|
7
|
+
export { resolveSpawnCommand, spawnCapture, DEFAULT_MAX_CAPTURE_BYTES, UnboundedSkipPermissionsError, UnsupportedCursorSkipPermissionsError, UnsupportedGeminiPermissionOptionsError, claudeRunnerArgs, makeClaudeHeadlessRunner, claudeHeadlessRunner, codexRunnerArgs, makeCodexHeadlessRunner, cursorRunnerArgs, makeCursorHeadlessRunner, getRunnerSpec, geminiRunnerArgs, makeGeminiHeadlessRunner, classifyGeminiRunnerResult, } from './runnerRegistry.js';
|
|
8
8
|
export type { AgentRunContext, AgentRunResult, AgentRunner, RunnerName, AgentRunnerOptions, ClaudeRunnerOptions, CodexRunnerOptions, AgentRunnerSpec, } from './runnerRegistry.js';
|
|
9
9
|
export interface GitRunnerOptions {
|
|
10
10
|
processSignalMode?: 'cancel' | 'ignore';
|
|
11
11
|
}
|
|
12
12
|
/** Run a git subcommand in cwd and return its stdout. */
|
|
13
13
|
export type GitRunner = (args: readonly string[], cwd: string, signal?: AbortSignal, options?: GitRunnerOptions) => Promise<string>;
|
|
14
|
+
export type GitPatchWriter = (args: readonly string[], cwd: string, destination: string, signal?: AbortSignal,
|
|
15
|
+
/** Call immediately after this writer exclusively creates `destination`. */
|
|
16
|
+
onDestinationOpened?: () => void) => Promise<void>;
|
|
14
17
|
/** Resolve the selected gene's learned strategy (for prompt enrichment). */
|
|
15
18
|
export type GeneResolver = (geneId: string) => Promise<GeneStrategyInfo | null> | GeneStrategyInfo | null;
|
|
16
19
|
/** Decide success from the post-run working tree (e.g. run the gene's validation plan). */
|
|
@@ -32,6 +35,12 @@ export interface ExecBridgeOptions {
|
|
|
32
35
|
agentOptions?: AgentRunnerOptions;
|
|
33
36
|
/** Default: spawns `git`. Inject a fake in tests. */
|
|
34
37
|
git?: GitRunner;
|
|
38
|
+
/** Optional complete-patch sink. The default streams built-in git output directly to disk. */
|
|
39
|
+
gitPatchWriter?: GitPatchWriter;
|
|
40
|
+
/** Test seam for fallback patch persistence; production callers should use the exclusive private writer. */
|
|
41
|
+
writePatchFile?: (path: string, patch: string) => void;
|
|
42
|
+
/** Test seam for temporary patch cleanup; production callers should use the filesystem default. */
|
|
43
|
+
removePatchFile?: (path: string) => void;
|
|
35
44
|
/** Default: EVOLVE_EXEC_BRIDGE === '1'. Set true to force-enable (e.g. integration tests). */
|
|
36
45
|
enabled?: boolean;
|
|
37
46
|
/**
|
|
@@ -108,8 +117,9 @@ export declare function scrubAgentEnv(env: NodeJS.ProcessEnv, opts?: {
|
|
|
108
117
|
allowKeys?: readonly string[];
|
|
109
118
|
allowPrefixes?: readonly string[];
|
|
110
119
|
}): NodeJS.ProcessEnv;
|
|
111
|
-
/** Default git runner:
|
|
120
|
+
/** Default git runner: every incomplete command result fails closed. */
|
|
112
121
|
export declare const defaultGitRunner: GitRunner;
|
|
122
|
+
export declare const defaultGitPatchWriter: GitPatchWriter;
|
|
113
123
|
/**
|
|
114
124
|
* Build the `execute` function CycleEngine/runEvolutionCycle consume. Default-off: throws
|
|
115
125
|
* ExecBridgeDisabledError on first call unless enabled.
|
|
@@ -11,19 +11,20 @@
|
|
|
11
11
|
// SAFETY: default-OFF. The factory throws ExecBridgeDisabledError unless explicitly enabled (opts.enabled)
|
|
12
12
|
// or EVOLVE_EXEC_BRIDGE === '1'. Wiring it in by accident must fail loudly rather than silently spawn an
|
|
13
13
|
// autonomous agent.
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
14
15
|
import { resolve as resolvePath, sep, join as joinPath } from 'node:path';
|
|
15
16
|
import { tmpdir } from 'node:os';
|
|
16
|
-
import { writeFileSync } from 'node:fs';
|
|
17
|
+
import { closeSync, existsSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
18
|
import { renderExecPrompt } from './prompt.js';
|
|
18
19
|
import { parseGitShortstat, gitDiffProof } from './proofOfWork.js';
|
|
19
20
|
// Policy enforcement core (#107): checkPolicy runs the always-on global guards (blast hard cap +
|
|
20
21
|
// protected paths + destructive deletes) on EVERY exec — gene or not — plus the per-gene constraints when a
|
|
21
22
|
// gene supplies them. It supersedes the old gene-gated `checkChangeConstraints` call (the no-gene-no-guard hole).
|
|
22
23
|
import { checkPolicy, summarizeViolations } from './policy/index.js';
|
|
23
|
-
import { spawnCapture, getRunnerSpec, DEFAULT_TIMEOUT_MS } from './runnerRegistry.js';
|
|
24
|
+
import { spawnCapture, SpawnCaptureFinalizeError, getRunnerSpec, DEFAULT_TIMEOUT_MS } from './runnerRegistry.js';
|
|
24
25
|
// Re-export the runner layer so existing importers of ./claudeBridge.js (and the `exec` namespace) keep their
|
|
25
26
|
// surface after the #91-6 split — the registry simply has a clearer home now.
|
|
26
|
-
export { resolveSpawnCommand, spawnCapture, UnboundedSkipPermissionsError, UnsupportedCursorSkipPermissionsError, UnsupportedGeminiPermissionOptionsError, claudeRunnerArgs, makeClaudeHeadlessRunner, claudeHeadlessRunner, codexRunnerArgs, makeCodexHeadlessRunner, cursorRunnerArgs, makeCursorHeadlessRunner, getRunnerSpec, geminiRunnerArgs, makeGeminiHeadlessRunner, } from './runnerRegistry.js';
|
|
27
|
+
export { resolveSpawnCommand, spawnCapture, DEFAULT_MAX_CAPTURE_BYTES, UnboundedSkipPermissionsError, UnsupportedCursorSkipPermissionsError, UnsupportedGeminiPermissionOptionsError, claudeRunnerArgs, makeClaudeHeadlessRunner, claudeHeadlessRunner, codexRunnerArgs, makeCodexHeadlessRunner, cursorRunnerArgs, makeCursorHeadlessRunner, getRunnerSpec, geminiRunnerArgs, makeGeminiHeadlessRunner, classifyGeminiRunnerResult, } from './runnerRegistry.js';
|
|
27
28
|
export class ExecBridgeDisabledError extends Error {
|
|
28
29
|
constructor() {
|
|
29
30
|
super('exec bridge is disabled — set EVOLVE_EXEC_BRIDGE=1 or pass { enabled: true } to enable agent execution');
|
|
@@ -130,6 +131,18 @@ class ExecBridgeRunCancelledError extends Error {
|
|
|
130
131
|
this.name = 'ExecBridgeRunCancelledError';
|
|
131
132
|
}
|
|
132
133
|
}
|
|
134
|
+
class GitProofError extends Error {
|
|
135
|
+
constructor(message) {
|
|
136
|
+
super(message);
|
|
137
|
+
this.name = 'GitProofError';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
class GitOutputTruncatedError extends GitProofError {
|
|
141
|
+
constructor(bytes) {
|
|
142
|
+
super(`git output exceeded the capture limit (${String(bytes ?? 'unknown')} bytes)`);
|
|
143
|
+
this.name = 'GitOutputTruncatedError';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
133
146
|
function cancelledExecutionResult(run) {
|
|
134
147
|
return {
|
|
135
148
|
outcome: { status: 'failed', score: 0.1, reason: 'execution cancelled' },
|
|
@@ -139,26 +152,85 @@ function cancelledExecutionResult(run) {
|
|
|
139
152
|
...(run ? { sessionLog: run.error ? `${run.output}\n${run.error}` : run.output } : {}),
|
|
140
153
|
};
|
|
141
154
|
}
|
|
142
|
-
|
|
155
|
+
function failedProofExecutionResult(run, error, proofOfWork) {
|
|
156
|
+
return {
|
|
157
|
+
outcome: { status: 'failed', score: 0.1, reason: `execution proof failed: ${error.message}` },
|
|
158
|
+
...(proofOfWork ? { proofOfWork } : {}),
|
|
159
|
+
strongEvidence: false,
|
|
160
|
+
failureKind: run.failureKind ?? 'runtime_error',
|
|
161
|
+
exitCode: run.exitCode ?? null,
|
|
162
|
+
sessionLog: run.error ? `${run.output}\n${run.error}` : run.output,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** Default git runner: every incomplete command result fails closed. */
|
|
143
166
|
export const defaultGitRunner = async (args, cwd, signal, options) => {
|
|
167
|
+
let result;
|
|
144
168
|
try {
|
|
145
|
-
|
|
169
|
+
result = await spawnCapture('git', args, {
|
|
146
170
|
cwd,
|
|
147
171
|
timeoutMs: 30_000,
|
|
148
172
|
env: scrubAgentEnv(process.env),
|
|
149
173
|
...(signal ? { signal } : {}),
|
|
150
174
|
...(options?.processSignalMode ? { processSignalMode: options.processSignalMode } : {}),
|
|
151
175
|
});
|
|
152
|
-
if (r.termination === 'cancelled')
|
|
153
|
-
throw new ExecBridgeRunCancelledError();
|
|
154
|
-
return r.stdout;
|
|
155
176
|
}
|
|
156
|
-
catch
|
|
157
|
-
|
|
158
|
-
throw error;
|
|
159
|
-
return '';
|
|
177
|
+
catch {
|
|
178
|
+
throw new GitProofError('git command failed to start or capture output');
|
|
160
179
|
}
|
|
180
|
+
if (result.termination === 'cancelled')
|
|
181
|
+
throw new ExecBridgeRunCancelledError();
|
|
182
|
+
if (result.termination === 'timeout')
|
|
183
|
+
throw new GitProofError('git command timed out');
|
|
184
|
+
if (result.stdoutTruncated)
|
|
185
|
+
throw new GitOutputTruncatedError(result.stdoutBytes);
|
|
186
|
+
if (result.code !== 0)
|
|
187
|
+
throw new GitProofError(`git command exited with code ${result.code}`);
|
|
188
|
+
return result.stdout;
|
|
161
189
|
};
|
|
190
|
+
/** Stream a complete git patch to disk so large diffs never need to be retained in the Node heap. */
|
|
191
|
+
async function writeDefaultGitPatch(args, cwd, destination, signal, onDestinationOpened) {
|
|
192
|
+
const result = await spawnCapture('git', args, {
|
|
193
|
+
cwd,
|
|
194
|
+
timeoutMs: 30_000,
|
|
195
|
+
env: scrubAgentEnv(process.env),
|
|
196
|
+
stdoutFile: destination,
|
|
197
|
+
...(onDestinationOpened ? { onStdoutFileOpened: onDestinationOpened } : {}),
|
|
198
|
+
...(signal ? { signal } : {}),
|
|
199
|
+
});
|
|
200
|
+
if (result.termination === 'cancelled')
|
|
201
|
+
throw new ExecBridgeRunCancelledError();
|
|
202
|
+
if (result.termination === 'timeout')
|
|
203
|
+
throw new GitProofError('git patch capture timed out');
|
|
204
|
+
if (result.code !== 0)
|
|
205
|
+
throw new GitProofError(`git patch capture exited with code ${result.code}`);
|
|
206
|
+
}
|
|
207
|
+
export const defaultGitPatchWriter = (args, cwd, destination, signal, onDestinationOpened) => (writeDefaultGitPatch(args, cwd, destination, signal, onDestinationOpened));
|
|
208
|
+
function writePrivatePatchFile(path, patch) {
|
|
209
|
+
let fd;
|
|
210
|
+
let ownsFile = false;
|
|
211
|
+
try {
|
|
212
|
+
fd = openSync(path, 'wx', 0o600);
|
|
213
|
+
ownsFile = true;
|
|
214
|
+
writeFileSync(fd, patch, { encoding: 'utf8' });
|
|
215
|
+
closeSync(fd);
|
|
216
|
+
fd = undefined;
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (fd !== undefined) {
|
|
220
|
+
try {
|
|
221
|
+
closeSync(fd);
|
|
222
|
+
}
|
|
223
|
+
catch { /* best-effort close before removing our artifact */ }
|
|
224
|
+
}
|
|
225
|
+
if (ownsFile) {
|
|
226
|
+
try {
|
|
227
|
+
rmSync(path, { force: true });
|
|
228
|
+
}
|
|
229
|
+
catch { /* preserve the persistence failure */ }
|
|
230
|
+
}
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
162
234
|
/**
|
|
163
235
|
* Build the `execute` function CycleEngine/runEvolutionCycle consume. Default-off: throws
|
|
164
236
|
* ExecBridgeDisabledError on first call unless enabled.
|
|
@@ -168,6 +240,7 @@ export function makeClaudeExecBridge(opts) {
|
|
|
168
240
|
const spec = getRunnerSpec(opts.runner); // #66: claude (default, byte-identical) | codex
|
|
169
241
|
const agent = opts.agent ?? spec.makeRunner(opts.agentOptions);
|
|
170
242
|
const git = opts.git ?? defaultGitRunner;
|
|
243
|
+
const gitPatchWriter = opts.gitPatchWriter ?? (git === defaultGitRunner ? defaultGitPatchWriter : undefined);
|
|
171
244
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
172
245
|
// secure by default; only THIS runner's auth env reaches it (claude never gets OPENAI_, codex never ANTHROPIC_, #66)
|
|
173
246
|
const agentEnv = opts.scrubEnv === false ? undefined : scrubAgentEnv(process.env, { allowPrefixes: spec.envAllow.prefixes, ...(spec.envAllow.keys ? { allowKeys: spec.envAllow.keys } : {}) });
|
|
@@ -207,21 +280,40 @@ export function makeClaudeExecBridge(opts) {
|
|
|
207
280
|
});
|
|
208
281
|
// Isolation: run in a throwaway git worktree so the agent's edits never touch the real working tree.
|
|
209
282
|
const isolate = opts.isolation === 'worktree';
|
|
210
|
-
const workDir = isolate ? joinPath(tmpdir(), `evolver-wt-${mutation.id}`) : opts.cwd;
|
|
211
283
|
if (opts.signal?.aborted)
|
|
212
284
|
return cancelledExecutionResult(undefined);
|
|
285
|
+
// Reserve a parent namespace atomically before handing its child path to Git. The parent stays owned until
|
|
286
|
+
// cleanup completes, so another run cannot reuse the worktree path between Git removal and disk cleanup.
|
|
287
|
+
const worktreeReservation = isolate ? mkdtempSync(joinPath(tmpdir(), 'evolver-wt-')) : undefined;
|
|
288
|
+
const workDir = worktreeReservation ? joinPath(worktreeReservation, 'worktree') : opts.cwd;
|
|
289
|
+
let worktreeRegistered = false;
|
|
213
290
|
let observedRun;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
291
|
+
let patchRef;
|
|
292
|
+
let ownsPatchRef = false;
|
|
293
|
+
let preservePatchRef = false;
|
|
294
|
+
let failedProof;
|
|
295
|
+
const proofGit = async (args, cwd, onResolved) => {
|
|
218
296
|
if (opts.signal?.aborted)
|
|
219
297
|
throw new ExecBridgeRunCancelledError();
|
|
220
|
-
|
|
298
|
+
try {
|
|
299
|
+
const output = await git(args, cwd, opts.signal);
|
|
300
|
+
onResolved?.();
|
|
301
|
+
if (opts.signal?.aborted)
|
|
302
|
+
throw new ExecBridgeRunCancelledError();
|
|
303
|
+
return output;
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
if (error instanceof ExecBridgeRunCancelledError || opts.signal?.aborted)
|
|
307
|
+
throw error;
|
|
308
|
+
if (error instanceof GitProofError)
|
|
309
|
+
throw error;
|
|
310
|
+
throw new GitProofError('git state proof failed');
|
|
311
|
+
}
|
|
221
312
|
};
|
|
222
313
|
try {
|
|
223
|
-
if (isolate)
|
|
224
|
-
await proofGit(['worktree', 'add', '--detach', workDir, 'HEAD'], opts.cwd);
|
|
314
|
+
if (isolate) {
|
|
315
|
+
await proofGit(['worktree', 'add', '--detach', workDir, 'HEAD'], opts.cwd, () => { worktreeRegistered = true; });
|
|
316
|
+
}
|
|
225
317
|
const run = await agent(prompt, {
|
|
226
318
|
cwd: workDir,
|
|
227
319
|
timeoutMs,
|
|
@@ -245,17 +337,69 @@ export function makeClaudeExecBridge(opts) {
|
|
|
245
337
|
let changedFiles;
|
|
246
338
|
let numstat;
|
|
247
339
|
let patch = '';
|
|
340
|
+
const resetIntentToAdd = async () => {
|
|
341
|
+
if (untrackedFiles.length > 0) {
|
|
342
|
+
await git(['reset', '--quiet', '--', ...untrackedFiles], workDir);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
248
345
|
try {
|
|
249
346
|
stat = parseGitShortstat(await proofGit(['diff', '--shortstat', 'HEAD'], workDir));
|
|
250
347
|
changedFiles = (await proofGit(['diff', '--name-only', 'HEAD'], workDir)).split('\n').map((s) => s.trim()).filter(Boolean);
|
|
251
348
|
numstat = await proofGit(['diff', '--numstat', 'HEAD'], workDir);
|
|
252
|
-
if (isolate && stat.files > 0)
|
|
253
|
-
|
|
349
|
+
if (isolate && stat.files > 0) {
|
|
350
|
+
if (gitPatchWriter) {
|
|
351
|
+
patchRef = joinPath(tmpdir(), `evolver-patch-${randomUUID()}.diff`);
|
|
352
|
+
try {
|
|
353
|
+
await gitPatchWriter(['diff', '--binary', '--full-index', 'HEAD'], workDir, patchRef, opts.signal, () => { ownsPatchRef = true; });
|
|
354
|
+
// A successful writer owns its result even when an older injected implementation ignores the
|
|
355
|
+
// optional callback. On rejection, only the callback can prove that a partial file is ours.
|
|
356
|
+
ownsPatchRef = true;
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (error instanceof ExecBridgeRunCancelledError || opts.signal?.aborted)
|
|
360
|
+
throw error;
|
|
361
|
+
if (error instanceof SpawnCaptureFinalizeError) {
|
|
362
|
+
if (error.result.termination === 'cancelled')
|
|
363
|
+
throw new ExecBridgeRunCancelledError();
|
|
364
|
+
if (error.result.termination === 'timeout')
|
|
365
|
+
throw new GitProofError('git patch capture timed out');
|
|
366
|
+
}
|
|
367
|
+
if (error instanceof GitProofError)
|
|
368
|
+
throw error;
|
|
369
|
+
throw new GitProofError('git patch capture failed');
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
patch = await proofGit(['diff', '--binary', '--full-index', 'HEAD'], workDir);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
254
376
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
await
|
|
377
|
+
catch (error) {
|
|
378
|
+
try {
|
|
379
|
+
await resetIntentToAdd();
|
|
380
|
+
}
|
|
381
|
+
catch (cleanupError) {
|
|
382
|
+
if (cleanupError instanceof ExecBridgeRunCancelledError || opts.signal?.aborted) {
|
|
383
|
+
throw new ExecBridgeRunCancelledError();
|
|
384
|
+
}
|
|
385
|
+
// Preserve the primary proof failure for non-cancellation cleanup errors.
|
|
386
|
+
}
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
await resetIntentToAdd();
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
if (error instanceof ExecBridgeRunCancelledError || opts.signal?.aborted)
|
|
394
|
+
throw error;
|
|
395
|
+
if (patchRef && ownsPatchRef) {
|
|
396
|
+
failedProof = gitDiffProof(stat, patchRef);
|
|
397
|
+
preservePatchRef = true;
|
|
398
|
+
}
|
|
399
|
+
throw new GitProofError('git index cleanup failed');
|
|
258
400
|
}
|
|
401
|
+
if (opts.signal?.aborted)
|
|
402
|
+
throw new ExecBridgeRunCancelledError();
|
|
259
403
|
// ENFORCE policy against the ACTUAL diff (finding: prompt.ts only ADVISES the agent "touch at most N
|
|
260
404
|
// file(s) / never modify X"; this is the hard gate). checkPolicy ALWAYS runs the global guards — the
|
|
261
405
|
// system blast hard cap (EVOLVER_HARD_CAP_FILES/LINES), the critical-protected paths (.env, MEMORY.md,
|
|
@@ -263,11 +407,19 @@ export function makeClaudeExecBridge(opts) {
|
|
|
263
407
|
// constraints run is no longer un-guarded. The gene's max_files/max_lines/forbidden_paths layer on top.
|
|
264
408
|
// Any violation fails the cycle no matter what the agent did — even when validation would pass.
|
|
265
409
|
const violations = checkPolicy({ stat, changedFiles, numstat, ...(gene?.constraints ? { constraints: gene.constraints } : {}) });
|
|
266
|
-
|
|
267
|
-
if (isolate && stat.files > 0) {
|
|
410
|
+
if (isolate && stat.files > 0 && !patchRef) {
|
|
268
411
|
// preserve the isolated edits as a patch (the worktree itself is removed); the real repo is untouched
|
|
269
|
-
|
|
270
|
-
|
|
412
|
+
const destination = joinPath(tmpdir(), `evolver-patch-${randomUUID()}.diff`);
|
|
413
|
+
try {
|
|
414
|
+
(opts.writePatchFile ?? writePrivatePatchFile)(destination, patch);
|
|
415
|
+
patchRef = destination;
|
|
416
|
+
ownsPatchRef = true;
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
if (error instanceof GitProofError)
|
|
420
|
+
throw error;
|
|
421
|
+
throw new GitProofError('git patch persistence failed');
|
|
422
|
+
}
|
|
271
423
|
}
|
|
272
424
|
const proof = gitDiffProof(stat, patchRef);
|
|
273
425
|
// Success: prefer the authoritative validation hook; otherwise "agent succeeded AND produced a diff".
|
|
@@ -290,6 +442,7 @@ export function makeClaudeExecBridge(opts) {
|
|
|
290
442
|
score = Math.min(score, 0.1);
|
|
291
443
|
reason = summarizeViolations(violations);
|
|
292
444
|
}
|
|
445
|
+
preservePatchRef = patchRef !== undefined && ownsPatchRef;
|
|
293
446
|
return {
|
|
294
447
|
outcome: { status: passed ? 'success' : 'failed', score, ...(reason ? { reason } : {}) },
|
|
295
448
|
proofOfWork: proof,
|
|
@@ -306,14 +459,32 @@ export function makeClaudeExecBridge(opts) {
|
|
|
306
459
|
if (error instanceof ExecBridgeRunCancelledError || opts.signal?.aborted) {
|
|
307
460
|
return cancelledExecutionResult(observedRun);
|
|
308
461
|
}
|
|
462
|
+
if (error instanceof GitProofError && observedRun) {
|
|
463
|
+
return failedProofExecutionResult(observedRun, error, failedProof);
|
|
464
|
+
}
|
|
309
465
|
throw error;
|
|
310
466
|
}
|
|
311
467
|
finally {
|
|
468
|
+
if (!preservePatchRef && ownsPatchRef && patchRef) {
|
|
469
|
+
try {
|
|
470
|
+
(opts.removePatchFile ?? ((path) => rmSync(path, { force: true })))(patchRef);
|
|
471
|
+
}
|
|
472
|
+
catch { /* best-effort cleanup must not replace the execution result or its primary failure */ }
|
|
473
|
+
}
|
|
312
474
|
if (isolate) {
|
|
475
|
+
let worktreeRemoved = false;
|
|
313
476
|
try {
|
|
314
477
|
await git(['worktree', 'remove', '--force', workDir], opts.cwd, undefined, { processSignalMode: 'ignore' });
|
|
478
|
+
worktreeRemoved = true;
|
|
315
479
|
}
|
|
316
480
|
catch { /* best-effort cleanup */ }
|
|
481
|
+
const addFailedBeforeCreatingWorktree = !worktreeRegistered && !existsSync(workDir);
|
|
482
|
+
if (worktreeReservation && (worktreeRemoved || addFailedBeforeCreatingWorktree)) {
|
|
483
|
+
try {
|
|
484
|
+
rmSync(worktreeReservation, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
485
|
+
}
|
|
486
|
+
catch { /* best-effort cleanup of the owned reservation */ }
|
|
487
|
+
}
|
|
317
488
|
}
|
|
318
489
|
}
|
|
319
490
|
};
|
|
@@ -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. */
|