@a2f0/agent-tool 0.1.44 → 0.1.46
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/package.json +1 -1
- package/src/commands/group.ts +56 -16
- package/src/commands/run.ts +8 -4
- package/src/harnesses.ts +4 -4
- package/src/review/diagnostics.ts +4 -4
- package/src/review/pinnedReviewInput.ts +16 -3
- package/src/review/reviewSnapshot.ts +4 -4
- package/src/review/reviewerProcess.ts +162 -0
- package/src/review/runReview.ts +4 -4
- package/src/review/solicitClaudeCodeReview.ts +8 -9
- package/src/review/solicitCodexReview.ts +8 -9
- package/src/review/solicitOpencodeReview.ts +8 -9
- package/src/runAgentToolAction.ts +4 -4
package/package.json
CHANGED
package/src/commands/group.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { constants } from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
|
-
import { runCommand, type RunReceipt } from "./run";
|
|
6
|
+
import { FORWARDED_SIGNALS, runCommand, type RunReceipt } from "./run";
|
|
6
7
|
import { assert, git, head, oid, option, output } from "../workflows/core";
|
|
7
8
|
import { gitEnvironment } from "../git/environment";
|
|
8
9
|
|
|
@@ -60,6 +61,7 @@ export type GroupEvent =
|
|
|
60
61
|
| { type: "stepStarted"; index: number }
|
|
61
62
|
| { type: "stepFinished"; index: number; receipt: RunReceipt }
|
|
62
63
|
| { type: "failed"; error: string }
|
|
64
|
+
| { type: "interrupted"; signal: NodeJS.Signals }
|
|
63
65
|
| { type: "finished"; passed: boolean; endedAt: string; durationMs: number }
|
|
64
66
|
| { type: "settled"; after: SourceState }
|
|
65
67
|
| { type: "settleFailed"; error: string };
|
|
@@ -69,7 +71,11 @@ export type GroupEvent =
|
|
|
69
71
|
* ignored: a step starts only from pending, finishes only while running, and
|
|
70
72
|
* nothing but the final source read changes a finished group. A failure while
|
|
71
73
|
* a step runs (its runner could not start) finishes that step as failed with
|
|
72
|
-
* no receipt, so a finished report never shows a step still running.
|
|
74
|
+
* no receipt, so a finished report never shows a step still running. An
|
|
75
|
+
* interruption counts only while the group runs: it records its signal as the
|
|
76
|
+
* error (unless one is already recorded), lets the running step finish with its
|
|
77
|
+
* receipt, and keeps the group from passing. A finished group ignores it, so
|
|
78
|
+
* its report and exit status stand.
|
|
73
79
|
*/
|
|
74
80
|
export function groupTransition(record: GroupRecord, event: GroupEvent): GroupRecord {
|
|
75
81
|
const step = (index: number, next: GroupStep) => ({ ...record, steps: record.steps.map((item, at) => at === index ? next : item) });
|
|
@@ -85,8 +91,10 @@ export function groupTransition(record: GroupRecord, event: GroupEvent): GroupRe
|
|
|
85
91
|
case "failed":
|
|
86
92
|
if (record.status !== "running") return record;
|
|
87
93
|
return { ...record, steps: record.steps.map(item => item.status === "running" ? { label: item.label, status: "failed", receipt: null } : item), error: event.error };
|
|
94
|
+
case "interrupted":
|
|
95
|
+
return record.status === "running" && record.error === null ? { ...record, error: `Interrupted by ${event.signal}.` } : record;
|
|
88
96
|
case "finished":
|
|
89
|
-
return record.status === "running" ? { ...record, status: event.passed ? "passed" : "failed", endedAt: event.endedAt, durationMs: event.durationMs } : record;
|
|
97
|
+
return record.status === "running" ? { ...record, status: event.passed && record.error === null ? "passed" : "failed", endedAt: event.endedAt, durationMs: event.durationMs } : record;
|
|
90
98
|
case "settled":
|
|
91
99
|
return record.status !== "running" && record.after === null ? { ...record, after: event.after } : record;
|
|
92
100
|
case "settleFailed":
|
|
@@ -108,21 +116,53 @@ export async function runGroup(args: string[]): Promise<number> {
|
|
|
108
116
|
const started = performance.now();
|
|
109
117
|
let record: GroupRecord = { schemaVersion: 1, id, label: plan.label, startedAt: new Date().toISOString(), endedAt: null, durationMs: 0, status: "running", before, after: null, steps: plan.steps.map(step => ({ label: step.label, status: "pending", receipt: null })), error: null };
|
|
110
118
|
const send = (event: GroupEvent) => { record = groupTransition(record, event); };
|
|
111
|
-
|
|
119
|
+
// A signal must not end the process before the report is finished: the step's runner forwards it
|
|
120
|
+
// to the step and returns, the group stops, and the signal is raised again once the report is saved.
|
|
121
|
+
// Only a running group is interrupted; a signal handled after it finished changes nothing.
|
|
122
|
+
const interrupt: { signal: NodeJS.Signals | null } = { signal: null };
|
|
123
|
+
const handlers = FORWARDED_SIGNALS.map(value => [value, () => {
|
|
124
|
+
if (record.status !== "running") return;
|
|
125
|
+
interrupt.signal ??= value;
|
|
126
|
+
send({ type: "interrupted", signal: value });
|
|
127
|
+
}] as const);
|
|
128
|
+
for (const [value, handler] of handlers) process.on(value, handler);
|
|
129
|
+
// Handlers run only between tasks, and a signal still queued when they are removed is lost.
|
|
130
|
+
// Fingerprinting and saving are synchronous, so yield to let a pending signal apply first.
|
|
131
|
+
const handleSignals = () => new Promise(resolve => setImmediate(resolve));
|
|
112
132
|
let code = 0;
|
|
113
133
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
134
|
+
save(report, record);
|
|
135
|
+
try {
|
|
136
|
+
for (const [index, step] of plan.steps.entries()) {
|
|
137
|
+
await handleSignals();
|
|
138
|
+
if (interrupt.signal) break;
|
|
139
|
+
send({ type: "stepStarted", index }); save(report, record);
|
|
140
|
+
code = await runCommand(["--label", step.label, "--", ...step.command], receipt => { send({ type: "stepFinished", index, receipt }); save(report, record); }, { quiet: true, reraiseChildSignal: false, reraiseInterrupt: false });
|
|
141
|
+
const after = sourceState();
|
|
142
|
+
assert(JSON.stringify(after) === JSON.stringify(before), "Source state changed during validation; results do not establish the current checkout.");
|
|
143
|
+
if (code !== 0) break;
|
|
144
|
+
}
|
|
145
|
+
} catch (error) { code = 1; send({ type: "failed", error: (error as Error).message }); }
|
|
146
|
+
const endedAt = new Date().toISOString(), durationMs = Math.round(performance.now() - started);
|
|
147
|
+
let settle: GroupEvent;
|
|
148
|
+
try { settle = { type: "settled", after: sourceState() }; } catch (error) { code = 1; settle = { type: "settleFailed", error: (error as Error).message }; }
|
|
149
|
+
await handleSignals();
|
|
150
|
+
send({ type: "finished", passed: code === 0, endedAt, durationMs }); send(settle);
|
|
151
|
+
save(report, record);
|
|
152
|
+
output({ ...record, report });
|
|
153
|
+
// Signals that arrived while publishing reach the handlers, which ignore them now.
|
|
154
|
+
await handleSignals();
|
|
155
|
+
} finally {
|
|
156
|
+
for (const [value, handler] of handlers) process.removeListener(value, handler);
|
|
157
|
+
}
|
|
158
|
+
const { signal } = interrupt;
|
|
159
|
+
if (signal) {
|
|
160
|
+
// Preserve signal termination for calling shells and supervisors, as `run` does.
|
|
161
|
+
process.kill(process.pid, signal);
|
|
162
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
163
|
+
return 128 + (constants.signals[signal] ?? 0);
|
|
164
|
+
}
|
|
165
|
+
return code;
|
|
126
166
|
}
|
|
127
167
|
|
|
128
168
|
export function listRuns(args: string[]): number {
|
package/src/commands/run.ts
CHANGED
|
@@ -4,12 +4,16 @@ import { constants } from "node:os";
|
|
|
4
4
|
import { DEFAULT_LIMITS, outputLimit, plainText, printLogTail } from "./output";
|
|
5
5
|
import { createLog } from "./logFile";
|
|
6
6
|
|
|
7
|
-
const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
|
|
7
|
+
export const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
|
|
8
8
|
|
|
9
9
|
export interface RunReceipt { status: string; label?: string; exitCode: number; signal: string | null; durationMs: number; outputBytes: number; log: string; leftoverProcessGroup?: number }
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Run directly into a private file: child output never accumulates in memory.
|
|
13
|
+
* A caller that must record the interruption before exiting passes
|
|
14
|
+
* `reraiseInterrupt: false` and re-raises the signal itself.
|
|
15
|
+
*/
|
|
16
|
+
export async function runCommand(args: string[], record?: (receipt: RunReceipt) => void, options: { quiet?: boolean; reraiseChildSignal?: boolean; reraiseInterrupt?: boolean } = {}): Promise<number> {
|
|
13
17
|
const limits = { ...DEFAULT_LIMITS };
|
|
14
18
|
let label: string | undefined;
|
|
15
19
|
while (args[0] !== "--" && args.length) {
|
|
@@ -99,7 +103,7 @@ export async function runCommand(args: string[], record?: (receipt: RunReceipt)
|
|
|
99
103
|
}
|
|
100
104
|
// Preserve signal termination for calling shells/supervisors, after the receipt.
|
|
101
105
|
if (signal) {
|
|
102
|
-
if (interrupted
|
|
106
|
+
if (interrupted ? options.reraiseInterrupt !== false : options.reraiseChildSignal !== false) {
|
|
103
107
|
process.kill(process.pid, signal);
|
|
104
108
|
// Let the runtime deliver its signal before main sets process.exitCode
|
|
105
109
|
// can turn signal termination into an ordinary numeric exit under load.
|
package/src/harnesses.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface Harness {
|
|
|
12
12
|
skillDirectory: string;
|
|
13
13
|
defaultEffort: ReviewEffort;
|
|
14
14
|
accessNote: string;
|
|
15
|
-
review: (prompt: string, effort: ReviewEffort, snapshotRoot: string) => number
|
|
15
|
+
review: (prompt: string, effort: ReviewEffort, snapshotRoot: string) => Promise<number>;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** Harness-specific discovery and invocation stay behind this small boundary. */
|
|
@@ -29,7 +29,7 @@ export function harnessNamed(name: string): Harness {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** An explicit local base enables review without GitHub or network access. */
|
|
32
|
-
export function review(rootDir: string, name: string, effortArg?: string, base?: string): number {
|
|
32
|
+
export function review(rootDir: string, name: string, effortArg?: string, base?: string): Promise<number> {
|
|
33
33
|
const harness = harnessNamed(name);
|
|
34
34
|
const effort = resolveReviewEffort(effortArg, harness.defaultEffort);
|
|
35
35
|
const context: PrContext = base === undefined ? resolveReviewContext() : {
|
|
@@ -39,8 +39,8 @@ export function review(rootDir: string, name: string, effortArg?: string, base?:
|
|
|
39
39
|
title: "",
|
|
40
40
|
baseRef: run("git", ["rev-parse", "--verify", "--end-of-options", `${base}^{commit}`]),
|
|
41
41
|
};
|
|
42
|
-
return withPinnedReviewInput(rootDir, context, input => {
|
|
43
|
-
const exitCode = harness.review(buildReviewPrompt({
|
|
42
|
+
return withPinnedReviewInput(rootDir, context, async input => {
|
|
43
|
+
const exitCode = await harness.review(buildReviewPrompt({
|
|
44
44
|
context,
|
|
45
45
|
diff: input.diff,
|
|
46
46
|
reviewInstructions: input.reviewInstructions,
|
|
@@ -4,12 +4,12 @@ import { DEFAULT_LIMITS, printLogTail } from "../commands/output";
|
|
|
4
4
|
|
|
5
5
|
/** Reviewer stderr is a transcript, not the review. Keep it off the model's
|
|
6
6
|
* context while retaining complete local evidence and bounded failure details. */
|
|
7
|
-
export function withReviewDiagnostics<T extends { status: number | null; error?: Error }>(
|
|
8
|
-
reviewer: string, run: (fd: number) => T
|
|
9
|
-
): T {
|
|
7
|
+
export async function withReviewDiagnostics<T extends { status: number | null; error?: Error }>(
|
|
8
|
+
reviewer: string, run: (fd: number) => T | Promise<T>,
|
|
9
|
+
): Promise<T> {
|
|
10
10
|
const { directory, log, fd } = createLog("agent-tool-review-log-");
|
|
11
11
|
try {
|
|
12
|
-
const result = run(fd);
|
|
12
|
+
const result = await run(fd);
|
|
13
13
|
const outputBytes = fstatSync(fd).size;
|
|
14
14
|
if (outputBytes > 0) {
|
|
15
15
|
writeSync(2, `${JSON.stringify({ reviewer, outputBytes, log })}\n`);
|
|
@@ -3,6 +3,7 @@ import { execFileSync, spawnSync } from "node:child_process";
|
|
|
3
3
|
|
|
4
4
|
import { type PrContext } from "../git/prContext";
|
|
5
5
|
import { readReviewInstructions } from "./reviewPrompt";
|
|
6
|
+
import { withReviewInterrupts } from "./reviewerProcess";
|
|
6
7
|
import { withReviewSnapshot } from "./reviewSnapshot";
|
|
7
8
|
|
|
8
9
|
interface PinnedReviewInput {
|
|
@@ -61,12 +62,24 @@ function ensureChanges(
|
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* Resolve refs once, then derive every review artifact from those commit IDs.
|
|
67
|
+
* An interruption stops the reviewer and removes the snapshot before the
|
|
68
|
+
* signal ends the process.
|
|
69
|
+
*/
|
|
65
70
|
export function withPinnedReviewInput<T>(
|
|
66
71
|
rootDir: string,
|
|
67
72
|
context: PrContext,
|
|
68
|
-
callback: (input: PinnedReviewInput) => T
|
|
69
|
-
): T {
|
|
73
|
+
callback: (input: PinnedReviewInput) => T | Promise<T>,
|
|
74
|
+
): Promise<T> {
|
|
75
|
+
return withReviewInterrupts(() => pinnedReviewInput(rootDir, context, callback));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function pinnedReviewInput<T>(
|
|
79
|
+
rootDir: string,
|
|
80
|
+
context: PrContext,
|
|
81
|
+
callback: (input: PinnedReviewInput) => T | Promise<T>,
|
|
82
|
+
): Promise<T> {
|
|
70
83
|
const baseCommit = resolveCommit(rootDir, context.baseRef);
|
|
71
84
|
const headCommit = resolveCommit(rootDir, "HEAD");
|
|
72
85
|
ensureChanges(rootDir, baseCommit, headCommit);
|
|
@@ -48,11 +48,11 @@ function removeSnapshot(tempRoot: string, repositoryRoot: string): void {
|
|
|
48
48
|
* Run a callback against immutable raw blobs from an exact commit.
|
|
49
49
|
* Working-tree edits, untracked files, and `.git` metadata never enter it.
|
|
50
50
|
*/
|
|
51
|
-
export function withReviewSnapshot<T>(
|
|
51
|
+
export async function withReviewSnapshot<T>(
|
|
52
52
|
rootDir: string,
|
|
53
53
|
commit: string,
|
|
54
|
-
callback: (repositoryRoot: string) => T
|
|
55
|
-
): T {
|
|
54
|
+
callback: (repositoryRoot: string) => T | Promise<T>,
|
|
55
|
+
): Promise<T> {
|
|
56
56
|
const tempRoot = mkdtempSync(path.join(tmpdir(), "agent-tool-review-"));
|
|
57
57
|
const repositoryRoot = path.join(tempRoot, "repository");
|
|
58
58
|
mkdirSync(repositoryRoot);
|
|
@@ -60,7 +60,7 @@ export function withReviewSnapshot<T>(
|
|
|
60
60
|
try {
|
|
61
61
|
materializeRawGitTree(rootDir, commit, repositoryRoot);
|
|
62
62
|
makeTreeReadOnly(repositoryRoot);
|
|
63
|
-
return callback(repositoryRoot);
|
|
63
|
+
return await callback(repositoryRoot);
|
|
64
64
|
} finally {
|
|
65
65
|
removeSnapshot(tempRoot, repositoryRoot);
|
|
66
66
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { FORWARDED_SIGNALS } from "../commands/run";
|
|
4
|
+
import type { ReviewerEnv } from "./runReview";
|
|
5
|
+
|
|
6
|
+
/** How long a reviewer asked to stop may take before it is killed. */
|
|
7
|
+
export const STOP_GRACE_MS = 5000;
|
|
8
|
+
/** How long to wait for a reviewer's stdout to close after it exits, in case a descendant holds it. */
|
|
9
|
+
const CLOSE_GRACE_MS = 2000;
|
|
10
|
+
|
|
11
|
+
/** One reviewer run, shaped like `spawnSync`'s result so `spawnExitCode` reads either. */
|
|
12
|
+
export interface ReviewerResult {
|
|
13
|
+
readonly status: number | null;
|
|
14
|
+
readonly signal: NodeJS.Signals | null;
|
|
15
|
+
readonly stdout: string;
|
|
16
|
+
readonly error?: Error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A started reviewer, as the interruption handler sees it. */
|
|
20
|
+
interface Reviewer { stop(signal: NodeJS.Signals): void }
|
|
21
|
+
|
|
22
|
+
interface ReviewScope {
|
|
23
|
+
signal: NodeJS.Signals | null;
|
|
24
|
+
readonly reviewers: Set<Reviewer>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let scope: ReviewScope | null = null;
|
|
28
|
+
|
|
29
|
+
/** Signal handlers run only between tasks; let one that is already pending run first. */
|
|
30
|
+
const handlePendingSignals = () => new Promise(resolve => setImmediate(resolve));
|
|
31
|
+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
32
|
+
|
|
33
|
+
// Each reviewer leads its own process group, so stopping it reaches every descendant that stays
|
|
34
|
+
// in the group (a CLI wrapper's native binary, a shell's children), as `agent-tool run` does.
|
|
35
|
+
const grouped = process.platform !== "win32";
|
|
36
|
+
|
|
37
|
+
function signalGroup(child: ChildProcess, signal: NodeJS.Signals | 0): boolean {
|
|
38
|
+
if (!child.pid) return false;
|
|
39
|
+
try {
|
|
40
|
+
if (grouped) process.kill(-child.pid, signal);
|
|
41
|
+
else if (signal) child.kill(signal);
|
|
42
|
+
else return child.exitCode === null && child.signalCode === null;
|
|
43
|
+
return true;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// EPERM: the group still exists, now holding a process this user cannot signal.
|
|
46
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Runs review work so that SIGINT, SIGTERM, and SIGHUP stop it instead of
|
|
52
|
+
* ending agent-tool at once: the running reviewer is stopped (and killed after
|
|
53
|
+
* a grace period), no further attempt starts, the work's own cleanup runs (the
|
|
54
|
+
* snapshot and temporary directories), and only then is the signal raised
|
|
55
|
+
* again, so callers still see termination by that signal.
|
|
56
|
+
*/
|
|
57
|
+
export async function withReviewInterrupts<T>(work: () => Promise<T>): Promise<T> {
|
|
58
|
+
if (scope) return work();
|
|
59
|
+
const current: ReviewScope = { signal: null, reviewers: new Set() };
|
|
60
|
+
const handlers = FORWARDED_SIGNALS.map(value => [value, () => {
|
|
61
|
+
current.signal ??= value;
|
|
62
|
+
for (const reviewer of current.reviewers) reviewer.stop(value);
|
|
63
|
+
}] as const);
|
|
64
|
+
scope = current;
|
|
65
|
+
for (const [value, handler] of handlers) process.on(value, handler);
|
|
66
|
+
try {
|
|
67
|
+
return await work();
|
|
68
|
+
} finally {
|
|
69
|
+
// Cleanup is synchronous; a signal that arrived during it is still pending.
|
|
70
|
+
await handlePendingSignals();
|
|
71
|
+
for (const [value, handler] of handlers) process.removeListener(value, handler);
|
|
72
|
+
scope = null;
|
|
73
|
+
if (current.signal) {
|
|
74
|
+
process.kill(process.pid, current.signal);
|
|
75
|
+
// Let the runtime deliver the signal before the caller sets an exit code.
|
|
76
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Run a reviewer CLI without blocking the event loop, so an interruption can
|
|
83
|
+
* stop it. The prompt goes to stdin, stdout is captured up to `maxBuffer`, and
|
|
84
|
+
* stderr goes to the `stderr` descriptor. Past `timeoutMs` or `maxBuffer` the
|
|
85
|
+
* reviewer is stopped, and the result carries an `ETIMEDOUT` or `ENOBUFS`
|
|
86
|
+
* error as `spawnSync` reports them. A stopped reviewer's process group gets
|
|
87
|
+
* the signal, then SIGKILL after `STOP_GRACE_MS`; the run settles only once
|
|
88
|
+
* that group is gone, and a group the reviewer left behind when it exited is
|
|
89
|
+
* stopped the same way. Inside an interrupted `withReviewInterrupts`, no
|
|
90
|
+
* reviewer starts.
|
|
91
|
+
*/
|
|
92
|
+
export async function spawnReviewer(
|
|
93
|
+
command: string,
|
|
94
|
+
args: string[],
|
|
95
|
+
options: { input: string; cwd: string; env: ReviewerEnv; timeoutMs: number; maxBuffer: number; stderr: number },
|
|
96
|
+
): Promise<ReviewerResult> {
|
|
97
|
+
const owner = scope;
|
|
98
|
+
if (owner) await handlePendingSignals();
|
|
99
|
+
if (owner?.signal) {
|
|
100
|
+
return { status: null, signal: null, stdout: "", error: new Error(`review interrupted by ${owner.signal}`) };
|
|
101
|
+
}
|
|
102
|
+
let child: ChildProcess;
|
|
103
|
+
try {
|
|
104
|
+
child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["pipe", "pipe", options.stderr], detached: grouped });
|
|
105
|
+
} catch (spawnError) {
|
|
106
|
+
return { status: null, signal: null, stdout: "", error: spawnError as Error };
|
|
107
|
+
}
|
|
108
|
+
const chunks: Buffer[] = [];
|
|
109
|
+
let size = 0;
|
|
110
|
+
let error: Error | undefined;
|
|
111
|
+
let killAt: number | null = null;
|
|
112
|
+
const reviewer: Reviewer = {
|
|
113
|
+
stop(signal) {
|
|
114
|
+
signalGroup(child, signal);
|
|
115
|
+
killAt ??= Date.now() + STOP_GRACE_MS;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
const fail = (code: string, message: string) => {
|
|
119
|
+
error ??= Object.assign(new Error(message), { code });
|
|
120
|
+
reviewer.stop("SIGTERM");
|
|
121
|
+
};
|
|
122
|
+
owner?.reviewers.add(reviewer);
|
|
123
|
+
const timer = setTimeout(() => fail("ETIMEDOUT", `${command} timed out after ${options.timeoutMs} ms`), options.timeoutMs);
|
|
124
|
+
// While stopping, escalate to SIGKILL at the deadline even if the reviewer has not exited yet.
|
|
125
|
+
const escalation = setInterval(() => { if (killAt !== null && Date.now() >= killAt) signalGroup(child, "SIGKILL"); }, 50);
|
|
126
|
+
try {
|
|
127
|
+
child.stdout!.on("data", (chunk: Buffer) => {
|
|
128
|
+
size += chunk.length;
|
|
129
|
+
if (size > options.maxBuffer) fail("ENOBUFS", `${command} wrote more than ${options.maxBuffer} bytes to stdout`);
|
|
130
|
+
else chunks.push(chunk);
|
|
131
|
+
});
|
|
132
|
+
const closed = new Promise<void>(resolve => child.stdout!.once("close", () => resolve()));
|
|
133
|
+
// A reviewer may exit without reading the whole prompt.
|
|
134
|
+
child.stdin!.on("error", () => {});
|
|
135
|
+
child.stdin!.end(options.input);
|
|
136
|
+
const exit = await new Promise<{ status: number | null; signal: NodeJS.Signals | null }>(resolve => {
|
|
137
|
+
child.once("error", spawnError => {
|
|
138
|
+
error ??= spawnError;
|
|
139
|
+
if (child.pid === undefined) resolve({ status: null, signal: null });
|
|
140
|
+
});
|
|
141
|
+
child.once("exit", (status, signal) => resolve({ status, signal }));
|
|
142
|
+
});
|
|
143
|
+
if (child.pid !== undefined) {
|
|
144
|
+
// The review is over once its process group is: stop whatever the reviewer left in it.
|
|
145
|
+
if (signalGroup(child, 0)) reviewer.stop("SIGTERM");
|
|
146
|
+
while (signalGroup(child, 0)) {
|
|
147
|
+
if (killAt !== null && Date.now() >= killAt + STOP_GRACE_MS) break;
|
|
148
|
+
await sleep(25);
|
|
149
|
+
}
|
|
150
|
+
// A descendant outside the group may still hold stdout; do not wait on it forever.
|
|
151
|
+
let grace: ReturnType<typeof setTimeout> | undefined;
|
|
152
|
+
await Promise.race([closed, new Promise(resolve => { grace = setTimeout(resolve, CLOSE_GRACE_MS); })]);
|
|
153
|
+
clearTimeout(grace);
|
|
154
|
+
child.stdout?.destroy();
|
|
155
|
+
}
|
|
156
|
+
return { status: exit.status, signal: exit.signal, stdout: Buffer.concat(chunks).toString("utf8"), ...(error ? { error } : {}) };
|
|
157
|
+
} finally {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
clearInterval(escalation);
|
|
160
|
+
owner?.reviewers.delete(reviewer);
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/review/runReview.ts
CHANGED
|
@@ -37,13 +37,13 @@ export const MAX_REVIEW_ATTEMPTS = 2;
|
|
|
37
37
|
* line with them while the in-memory check below still saw a complete review
|
|
38
38
|
* and exited 0.
|
|
39
39
|
*/
|
|
40
|
-
export function relayReviewWithRetry(
|
|
40
|
+
export async function relayReviewWithRetry(
|
|
41
41
|
command: string,
|
|
42
|
-
runAttempt: () => ReviewAttempt
|
|
43
|
-
): number {
|
|
42
|
+
runAttempt: () => ReviewAttempt | Promise<ReviewAttempt>,
|
|
43
|
+
): Promise<number> {
|
|
44
44
|
let last: ReviewAttempt = { exitCode: 1, review: "" };
|
|
45
45
|
for (let attempt = 1; attempt <= MAX_REVIEW_ATTEMPTS; attempt += 1) {
|
|
46
|
-
last = runAttempt();
|
|
46
|
+
last = await runAttempt();
|
|
47
47
|
if (last.exitCode !== 0) {
|
|
48
48
|
writeSync(1, last.review);
|
|
49
49
|
return last.exitCode;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { reviewExecutable } from "./executable";
|
|
2
2
|
import { loadConfig } from "../config";
|
|
3
|
-
import { spawnSync } from "node:child_process";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
|
|
6
5
|
import {
|
|
@@ -17,6 +16,7 @@ import {
|
|
|
17
16
|
import { buildReviewPrompt, CLAUDE_ACCESS_NOTE } from "./reviewPrompt";
|
|
18
17
|
import { type ReviewerEnv, relayReviewWithRetry } from "./runReview";
|
|
19
18
|
import { withReviewDiagnostics } from "./diagnostics";
|
|
19
|
+
import { spawnReviewer } from "./reviewerProcess";
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Tools the reviewer gets. Read-only, and deliberately not empty: the diff alone
|
|
@@ -77,30 +77,29 @@ export function buildClaudeReviewArgs(
|
|
|
77
77
|
* that failure is stochastic — so a caller's fallback chain treats a degenerate
|
|
78
78
|
* review the same as a crashed one.
|
|
79
79
|
*/
|
|
80
|
-
export function spawnClaudeReview(
|
|
80
|
+
export async function spawnClaudeReview(
|
|
81
81
|
prompt: string,
|
|
82
82
|
effort: ReviewEffort,
|
|
83
83
|
snapshotRoot: string,
|
|
84
84
|
env: ReviewerEnv = process.env,
|
|
85
|
-
): number {
|
|
85
|
+
): Promise<number> {
|
|
86
86
|
const executable = reviewExecutable("claude", env);
|
|
87
87
|
if (executable === undefined) {
|
|
88
88
|
process.stderr.write("Claude executable unavailable outside the repository.\n");
|
|
89
89
|
return 1;
|
|
90
90
|
}
|
|
91
|
-
return relayReviewWithRetry("claude", () => {
|
|
91
|
+
return await relayReviewWithRetry("claude", async () => {
|
|
92
92
|
// Captured rather than inherited: a review has to be read to be judged, and
|
|
93
93
|
// `claude` exits 0 whether it reviewed the diff or merely said it would.
|
|
94
94
|
// `--print` emits the review in one final block, so nothing streams anyway.
|
|
95
|
-
const result = withReviewDiagnostics("claude", fd =>
|
|
95
|
+
const result = await withReviewDiagnostics("claude", fd => spawnReviewer(
|
|
96
96
|
executable,
|
|
97
97
|
buildClaudeReviewArgs(effort, snapshotRoot, loadConfig().review.claudeModel),
|
|
98
98
|
{
|
|
99
|
-
|
|
99
|
+
stderr: fd,
|
|
100
100
|
input: prompt,
|
|
101
|
-
encoding: "utf8",
|
|
102
101
|
maxBuffer: MAX_BUFFER_BYTES,
|
|
103
|
-
|
|
102
|
+
timeoutMs: loadConfig().review.timeoutMs,
|
|
104
103
|
cwd: snapshotRoot,
|
|
105
104
|
// Passed explicitly so `claude` resolves against this PATH rather than
|
|
106
105
|
// the one the runtime snapshotted at startup.
|
|
@@ -124,7 +123,7 @@ export function spawnClaudeReview(
|
|
|
124
123
|
export function solicitClaudeCodeReview(
|
|
125
124
|
rootDir: string,
|
|
126
125
|
effortArg?: string,
|
|
127
|
-
): number {
|
|
126
|
+
): Promise<number> {
|
|
128
127
|
const effort = resolveReviewEffort(effortArg, DEFAULT_CLAUDE_EFFORT);
|
|
129
128
|
const context = resolveReviewContext();
|
|
130
129
|
return withPinnedReviewInput(rootDir, context, (input) => {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { reviewExecutable } from "./executable";
|
|
2
2
|
import { loadConfig } from "../config";
|
|
3
|
-
import { spawnSync } from "node:child_process";
|
|
4
3
|
import {
|
|
5
4
|
mkdtempSync,
|
|
6
5
|
readFileSync,
|
|
@@ -23,6 +22,7 @@ import {
|
|
|
23
22
|
import { buildReviewPrompt, CODEX_ACCESS_NOTE } from "./reviewPrompt";
|
|
24
23
|
import { type ReviewerEnv, relayReviewWithRetry } from "./runReview";
|
|
25
24
|
import { withReviewDiagnostics } from "./diagnostics";
|
|
25
|
+
import { spawnReviewer } from "./reviewerProcess";
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Build the `codex exec` argv. `exec`, not `review`: `codex review` writes its
|
|
@@ -123,12 +123,12 @@ function readLastMessage(lastMessageFile: string): string {
|
|
|
123
123
|
* Degenerate exit-0 output is retried once via
|
|
124
124
|
* the shared gate, same as the Claude path.
|
|
125
125
|
*/
|
|
126
|
-
export function spawnCodexReview(
|
|
126
|
+
export async function spawnCodexReview(
|
|
127
127
|
prompt: string,
|
|
128
128
|
effort: ReviewEffort,
|
|
129
129
|
snapshotRoot: string,
|
|
130
130
|
env: ReviewerEnv = process.env,
|
|
131
|
-
): number {
|
|
131
|
+
): Promise<number> {
|
|
132
132
|
const codexExecutable = reviewExecutable("codex", env);
|
|
133
133
|
if (codexExecutable === undefined) {
|
|
134
134
|
process.stderr.write("Codex executable unavailable outside the repository.\n");
|
|
@@ -138,12 +138,12 @@ export function spawnCodexReview(
|
|
|
138
138
|
const codexRuntimeRoot = path.dirname(codexExecutable);
|
|
139
139
|
let attempt = 0;
|
|
140
140
|
try {
|
|
141
|
-
return relayReviewWithRetry("codex", () => {
|
|
141
|
+
return await relayReviewWithRetry("codex", async () => {
|
|
142
142
|
// A fresh file per attempt, so a retry that crashes before writing can
|
|
143
143
|
// never be read as the previous attempt's stale message.
|
|
144
144
|
attempt += 1;
|
|
145
145
|
const lastMessageFile = path.join(outDir, `review-${attempt}.md`);
|
|
146
|
-
const result = withReviewDiagnostics("codex", fd =>
|
|
146
|
+
const result = await withReviewDiagnostics("codex", fd => spawnReviewer(
|
|
147
147
|
codexExecutable,
|
|
148
148
|
buildCodexReviewArgs(
|
|
149
149
|
effort,
|
|
@@ -153,11 +153,10 @@ export function spawnCodexReview(
|
|
|
153
153
|
loadConfig().review.codexModel,
|
|
154
154
|
),
|
|
155
155
|
{
|
|
156
|
-
|
|
156
|
+
stderr: fd,
|
|
157
157
|
input: prompt,
|
|
158
|
-
encoding: "utf8",
|
|
159
158
|
maxBuffer: MAX_BUFFER_BYTES,
|
|
160
|
-
|
|
159
|
+
timeoutMs: loadConfig().review.timeoutMs,
|
|
161
160
|
// Codex loads project AGENTS.md from its cwd. Keep cwd neutral and
|
|
162
161
|
// grant read-only access to the reviewed repository explicitly.
|
|
163
162
|
cwd: outDir,
|
|
@@ -181,7 +180,7 @@ export function spawnCodexReview(
|
|
|
181
180
|
export function solicitCodexReview(
|
|
182
181
|
rootDir: string,
|
|
183
182
|
effortArg?: string,
|
|
184
|
-
): number {
|
|
183
|
+
): Promise<number> {
|
|
185
184
|
const effort = resolveReviewEffort(effortArg, DEFAULT_CODEX_EFFORT);
|
|
186
185
|
const context = resolveReviewContext();
|
|
187
186
|
return withPinnedReviewInput(rootDir, context, (input) => {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { reviewExecutable } from "./executable";
|
|
2
2
|
import { loadConfig, DEFAULT_CONFIG } from "../config";
|
|
3
|
-
import { spawnSync } from "node:child_process";
|
|
4
3
|
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
5
4
|
import { tmpdir } from "node:os";
|
|
6
5
|
import path from "node:path";
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
import { buildReviewPrompt, OPENCODE_ACCESS_NOTE } from "./reviewPrompt";
|
|
20
19
|
import { type ReviewerEnv, relayReviewWithRetry } from "./runReview";
|
|
21
20
|
import { withReviewDiagnostics } from "./diagnostics";
|
|
21
|
+
import { spawnReviewer } from "./reviewerProcess";
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* The model every opencode review runs under. Pinned so the review never
|
|
@@ -150,12 +150,12 @@ export function buildOpencodeReviewArgs(variant: string, model = OPENCODE_MODEL)
|
|
|
150
150
|
* needs still resolves. Global config still loads, but the inline config's
|
|
151
151
|
* agent-scoped deny rules take precedence over whatever it sets.
|
|
152
152
|
*/
|
|
153
|
-
export function spawnOpencodeReview(
|
|
153
|
+
export async function spawnOpencodeReview(
|
|
154
154
|
prompt: string,
|
|
155
155
|
effort: ReviewEffort,
|
|
156
156
|
snapshotRoot: string,
|
|
157
157
|
env: ReviewerEnv = process.env,
|
|
158
|
-
): number {
|
|
158
|
+
): Promise<number> {
|
|
159
159
|
const executable = reviewExecutable("opencode", env);
|
|
160
160
|
if (executable === undefined) {
|
|
161
161
|
process.stderr.write("OpenCode executable unavailable outside the repository.\n");
|
|
@@ -164,16 +164,15 @@ export function spawnOpencodeReview(
|
|
|
164
164
|
const model = loadConfig().review.opencodeModel;
|
|
165
165
|
const outDir = mkdtempSync(path.join(tmpdir(), "agent-tool-opencode-"));
|
|
166
166
|
try {
|
|
167
|
-
return relayReviewWithRetry("opencode", () => {
|
|
168
|
-
const result = withReviewDiagnostics("opencode", fd =>
|
|
167
|
+
return await relayReviewWithRetry("opencode", async () => {
|
|
168
|
+
const result = await withReviewDiagnostics("opencode", fd => spawnReviewer(
|
|
169
169
|
executable,
|
|
170
170
|
buildOpencodeReviewArgs(resolveOpencodeVariant(effort, loadConfig().review.opencodeVariants), model),
|
|
171
171
|
{
|
|
172
|
-
|
|
172
|
+
stderr: fd,
|
|
173
173
|
input: prompt,
|
|
174
|
-
encoding: "utf8",
|
|
175
174
|
maxBuffer: MAX_BUFFER_BYTES,
|
|
176
|
-
|
|
175
|
+
timeoutMs: loadConfig().review.timeoutMs,
|
|
177
176
|
// Neutral cwd: opencode would otherwise load the snapshot's
|
|
178
177
|
// branch-controlled config and instructions into the reviewer.
|
|
179
178
|
cwd: outDir,
|
|
@@ -206,7 +205,7 @@ export function spawnOpencodeReview(
|
|
|
206
205
|
export function solicitOpencodeReview(
|
|
207
206
|
rootDir: string,
|
|
208
207
|
effortArg?: string,
|
|
209
|
-
): number {
|
|
208
|
+
): Promise<number> {
|
|
210
209
|
const effort = resolveReviewEffort(effortArg, DEFAULT_OPENCODE_EFFORT);
|
|
211
210
|
const context = resolveReviewContext();
|
|
212
211
|
return withPinnedReviewInput(rootDir, context, (input) => {
|
|
@@ -17,9 +17,9 @@ export interface AgentToolActions {
|
|
|
17
17
|
readonly solicitClaudeCodeReview: (
|
|
18
18
|
rootDir: string,
|
|
19
19
|
effort?: string,
|
|
20
|
-
) => number
|
|
21
|
-
readonly solicitCodexReview: (rootDir: string, effort?: string) => number
|
|
22
|
-
readonly solicitOpencodeReview: (rootDir: string, effort?: string) => number
|
|
20
|
+
) => number | Promise<number>;
|
|
21
|
+
readonly solicitCodexReview: (rootDir: string, effort?: string) => number | Promise<number>;
|
|
22
|
+
readonly solicitOpencodeReview: (rootDir: string, effort?: string) => number | Promise<number>;
|
|
23
23
|
readonly squashMerge: (
|
|
24
24
|
rootDir: string,
|
|
25
25
|
subject?: string,
|
|
@@ -56,7 +56,7 @@ export function runAgentToolAction(
|
|
|
56
56
|
rootDir: string,
|
|
57
57
|
args: readonly string[],
|
|
58
58
|
actions: AgentToolActions = defaultActions,
|
|
59
|
-
): number {
|
|
59
|
+
): number | Promise<number> {
|
|
60
60
|
const [action, ...positionals] = args;
|
|
61
61
|
const [first, second, third] = positionals;
|
|
62
62
|
switch (action) {
|