next-issue 0.17.0 → 0.18.0
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/README.md +20 -10
- package/dist/agents.mjs +14 -0
- package/dist/cli.mjs +14 -3
- package/dist/exec.mjs +7 -6
- package/dist/github.mjs +23 -5
- package/dist/interrupt.mjs +22 -0
- package/dist/loop.mjs +1 -2
- package/dist/pipeline.mjs +21 -0
- package/dist/state.mjs +0 -9
- package/dist/summary.mjs +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,7 +97,10 @@ when:
|
|
|
97
97
|
because branch protection asks for a human approval;
|
|
98
98
|
- an agent does not finish in `agentTimeoutMinutes`;
|
|
99
99
|
- the setup command fails, or does not finish in `setupTimeoutMinutes`;
|
|
100
|
-
- a step of the run fails with an error.
|
|
100
|
+
- a step of the run fails with an error. A `gh` call that is safe to repeat,
|
|
101
|
+
such as a read or a label edit, is tried up to three times first when the
|
|
102
|
+
network fails. A `gh pr create` or `gh pr comment` is tried once, so a lost
|
|
103
|
+
reply never makes a duplicate.
|
|
101
104
|
|
|
102
105
|
The fixer sees the findings of all earlier rounds, not only the last one, with
|
|
103
106
|
its own disputes next to them. From round two, the reviewer judges only the
|
|
@@ -110,17 +113,24 @@ not the author, tells them apart. The agents read the comments of the issue, not
|
|
|
110
113
|
of the pull request, so a comment of an agent never returns to a prompt: the
|
|
111
114
|
dispute of the fixer reaches the reviewer through the state file.
|
|
112
115
|
|
|
113
|
-
## Stop
|
|
116
|
+
## Stop a run
|
|
114
117
|
|
|
115
|
-
|
|
116
|
-
only between two issues, finishes the issue that it holds, deletes the file and
|
|
117
|
-
then writes the summary as usual. A file from an earlier run is dropped at the
|
|
118
|
-
start, so it stops nothing.
|
|
118
|
+
Press `Ctrl-C` to stop the run step by step:
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
1. The first press stops the run after the current issue. The harness finishes
|
|
121
|
+
the issue that it holds and then writes the summary as usual.
|
|
122
|
+
2. The second press stops the run after the current round: the implementation,
|
|
123
|
+
one checks round (wait, then fix) or one review round (review, then fix). The
|
|
124
|
+
issue ends as `stopped`. Its state, worktree and labels stay, so the next run
|
|
125
|
+
carries on from that point.
|
|
126
|
+
3. The third press kills the command that runs and every child of it, so the run
|
|
127
|
+
dies in the middle of a step. The saved state lets the next run continue the
|
|
128
|
+
issue, but the issue keeps `status:in-progress` and the run writes no
|
|
129
|
+
summary.
|
|
130
|
+
|
|
131
|
+
Each of the first two presses prints a line that says what happens next. The
|
|
132
|
+
agents and commands run in a process group of their own, so a press reaches only
|
|
133
|
+
the harness.
|
|
124
134
|
|
|
125
135
|
A status label that the harness puts on the issue goes on the pull request too,
|
|
126
136
|
but only if the pull request exists at that moment. The `status:in-progress`
|
package/dist/agents.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { track } from "./exec.mjs";
|
|
2
4
|
import { emptyUsage } from "./observe.mjs";
|
|
3
5
|
import { runPi } from "./pi.mjs";
|
|
4
6
|
const REVIEWING = {
|
|
@@ -45,6 +47,7 @@ export async function runAgent(recorder, request) {
|
|
|
45
47
|
allowDangerouslySkipPermissions: request.profile.allowDangerouslySkipPermissions,
|
|
46
48
|
systemPrompt: { type: "preset", preset: "claude_code" },
|
|
47
49
|
abortController: controller,
|
|
50
|
+
spawnClaudeCodeProcess: spawnClaude,
|
|
48
51
|
outputFormat: request.outputSchema === undefined
|
|
49
52
|
? undefined
|
|
50
53
|
: { type: "json_schema", schema: request.outputSchema },
|
|
@@ -149,3 +152,14 @@ export function toolFields(name, input) {
|
|
|
149
152
|
const { command } = input;
|
|
150
153
|
return typeof command === "string" ? { tool: name, command } : { tool: name };
|
|
151
154
|
}
|
|
155
|
+
export function spawnClaude(options) {
|
|
156
|
+
const child = spawn(options.command, options.args, {
|
|
157
|
+
cwd: options.cwd,
|
|
158
|
+
env: options.env,
|
|
159
|
+
signal: options.signal,
|
|
160
|
+
detached: true,
|
|
161
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
162
|
+
});
|
|
163
|
+
track(child);
|
|
164
|
+
return child;
|
|
165
|
+
}
|
package/dist/cli.mjs
CHANGED
|
@@ -6,10 +6,11 @@ import { loadConfig, writeDefaultConfig } from "./config.mjs";
|
|
|
6
6
|
import { setCommandObserver, setDefaultTimeout } from "./exec.mjs";
|
|
7
7
|
import { detectRepo, ensureIgnored, repoRoot } from "./git.mjs";
|
|
8
8
|
import { defaultBranch, openIssues } from "./github.mjs";
|
|
9
|
+
import { listenForInterrupts } from "./interrupt.mjs";
|
|
9
10
|
import { exitCode, runIssues } from "./loop.mjs";
|
|
10
11
|
import { message, pruneRuns, Recorder } from "./observe.mjs";
|
|
11
12
|
import { PORTS, processIssue } from "./pipeline.mjs";
|
|
12
|
-
import { STATE_DIR
|
|
13
|
+
import { STATE_DIR } from "./state.mjs";
|
|
13
14
|
import { formatSummary } from "./summary.mjs";
|
|
14
15
|
import { packageVersion } from "./version.mjs";
|
|
15
16
|
function write(stream, text) {
|
|
@@ -61,8 +62,18 @@ async function init(root, force) {
|
|
|
61
62
|
return 0;
|
|
62
63
|
}
|
|
63
64
|
async function handle(args, config, repo, recorder) {
|
|
65
|
+
const interrupts = listenForInterrupts((text) => void write(process.stderr, `${text}\n`));
|
|
64
66
|
const base = await defaultBranch(repo);
|
|
65
|
-
const context = {
|
|
67
|
+
const context = {
|
|
68
|
+
repo,
|
|
69
|
+
config,
|
|
70
|
+
base,
|
|
71
|
+
reset: args.reset,
|
|
72
|
+
merge: args.merge,
|
|
73
|
+
stopAfterRound: interrupts.afterRound,
|
|
74
|
+
recorder,
|
|
75
|
+
ports: PORTS,
|
|
76
|
+
};
|
|
66
77
|
recorder.event("run.start", {
|
|
67
78
|
version: await packageVersion(),
|
|
68
79
|
repo: `${repo.owner}/${repo.name}`,
|
|
@@ -74,7 +85,7 @@ async function handle(args, config, repo, recorder) {
|
|
|
74
85
|
const { reports, stoppedAt } = await runIssues(args, {
|
|
75
86
|
issues,
|
|
76
87
|
process: (issue) => processIssue(context, issue),
|
|
77
|
-
stop:
|
|
88
|
+
stop: interrupts.afterIssue,
|
|
78
89
|
});
|
|
79
90
|
if (stoppedAt !== undefined) {
|
|
80
91
|
recorder.event("run.stop", { issue: stoppedAt }, "quiet");
|
package/dist/exec.mjs
CHANGED
|
@@ -3,7 +3,7 @@ let observer;
|
|
|
3
3
|
let defaultTimeoutMs = 10 * 60_000;
|
|
4
4
|
const STDERR_TAIL = 200_000;
|
|
5
5
|
const groups = new Set();
|
|
6
|
-
function
|
|
6
|
+
export function stopChildren(signal) {
|
|
7
7
|
for (const pid of groups) {
|
|
8
8
|
try {
|
|
9
9
|
process.kill(-pid, signal);
|
|
@@ -11,18 +11,15 @@ function forward(signal) {
|
|
|
11
11
|
catch { }
|
|
12
12
|
}
|
|
13
13
|
groups.clear();
|
|
14
|
-
process.off("SIGINT", onInterrupt);
|
|
15
14
|
process.off("SIGTERM", onTerminate);
|
|
16
15
|
process.kill(process.pid, signal);
|
|
17
16
|
}
|
|
18
|
-
const
|
|
19
|
-
const onTerminate = () => forward("SIGTERM");
|
|
17
|
+
const onTerminate = () => stopChildren("SIGTERM");
|
|
20
18
|
function watch(pid) {
|
|
21
19
|
if (pid === undefined) {
|
|
22
20
|
return;
|
|
23
21
|
}
|
|
24
22
|
if (groups.size === 0) {
|
|
25
|
-
process.on("SIGINT", onInterrupt);
|
|
26
23
|
process.on("SIGTERM", onTerminate);
|
|
27
24
|
}
|
|
28
25
|
groups.add(pid);
|
|
@@ -33,10 +30,14 @@ function unwatch(pid) {
|
|
|
33
30
|
}
|
|
34
31
|
groups.delete(pid);
|
|
35
32
|
if (groups.size === 0) {
|
|
36
|
-
process.off("SIGINT", onInterrupt);
|
|
37
33
|
process.off("SIGTERM", onTerminate);
|
|
38
34
|
}
|
|
39
35
|
}
|
|
36
|
+
export function track(child) {
|
|
37
|
+
watch(child.pid);
|
|
38
|
+
child.on("close", () => unwatch(child.pid));
|
|
39
|
+
child.on("error", () => unwatch(child.pid));
|
|
40
|
+
}
|
|
40
41
|
export function setCommandObserver(next) {
|
|
41
42
|
observer = next;
|
|
42
43
|
}
|
package/dist/github.mjs
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
import { must, run } from "./exec.mjs";
|
|
2
2
|
const CHECK_PENDING_CODE = 8;
|
|
3
3
|
const READ_TRIES = 3;
|
|
4
|
+
const NETWORK_TRIES = 3;
|
|
5
|
+
const NETWORK_FAULT = /unexpected EOF|connection reset|connection refused|i\/o timeout|TLS handshake timeout|HTTP 50[234]/i;
|
|
4
6
|
const KNOWN_LABELS = new Map();
|
|
5
7
|
const NO_CHECKS = /no checks reported/i;
|
|
6
8
|
function slug(repo) {
|
|
7
9
|
return `${repo.owner}/${repo.name}`;
|
|
8
10
|
}
|
|
9
|
-
async function
|
|
11
|
+
async function repeatable(args, options) {
|
|
12
|
+
for (let attempt = 1;; attempt += 1) {
|
|
13
|
+
try {
|
|
14
|
+
return await must("gh", args, options);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (attempt >= NETWORK_TRIES || !(error instanceof Error) || !NETWORK_FAULT.test(error.message)) {
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function gh(repo, args) {
|
|
25
|
+
return repeatable([...args, "--repo", slug(repo)], { cwd: repo.root });
|
|
26
|
+
}
|
|
27
|
+
async function ghOnce(repo, args, input) {
|
|
10
28
|
return must("gh", [...args, "--repo", slug(repo)], { cwd: repo.root, input });
|
|
11
29
|
}
|
|
12
30
|
export async function defaultBranch(repo) {
|
|
13
|
-
const raw = await
|
|
31
|
+
const raw = await repeatable(["repo", "view", slug(repo), "--json", "defaultBranchRef"], {
|
|
14
32
|
cwd: repo.root,
|
|
15
33
|
});
|
|
16
34
|
return JSON.parse(raw).defaultBranchRef.name;
|
|
@@ -45,7 +63,7 @@ export async function issueComments(repo, issue) {
|
|
|
45
63
|
return parsed.comments.map((comment) => `@${comment.author.login}: ${comment.body}`);
|
|
46
64
|
}
|
|
47
65
|
export async function openBlockers(repo, issue) {
|
|
48
|
-
const raw = await
|
|
66
|
+
const raw = await repeatable([
|
|
49
67
|
"api",
|
|
50
68
|
"--paginate",
|
|
51
69
|
`repos/${slug(repo)}/issues/${issue}/dependencies/blocked_by`,
|
|
@@ -100,7 +118,7 @@ export async function findPr(repo, branch) {
|
|
|
100
118
|
return parsed[0]?.number;
|
|
101
119
|
}
|
|
102
120
|
export async function createPr(repo, branch, base, title, body, draft) {
|
|
103
|
-
const created = await
|
|
121
|
+
const created = await ghOnce(repo, [
|
|
104
122
|
"pr",
|
|
105
123
|
"create",
|
|
106
124
|
"--head",
|
|
@@ -245,5 +263,5 @@ export async function mergePr(repo, pr) {
|
|
|
245
263
|
return result.code === 0;
|
|
246
264
|
}
|
|
247
265
|
export async function commentOnPr(repo, pr, body) {
|
|
248
|
-
await
|
|
266
|
+
await ghOnce(repo, ["pr", "comment", String(pr), "--body-file", "-"], body);
|
|
249
267
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { stopChildren } from "./exec.mjs";
|
|
2
|
+
export function listenForInterrupts(say) {
|
|
3
|
+
let presses = 0;
|
|
4
|
+
const press = () => {
|
|
5
|
+
presses += 1;
|
|
6
|
+
if (presses === 1) {
|
|
7
|
+
say("Stopping after this issue. Press Ctrl-C again to stop after this round.");
|
|
8
|
+
}
|
|
9
|
+
else if (presses === 2) {
|
|
10
|
+
say("Stopping after this round. Press Ctrl-C again to stop now.");
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
process.off("SIGINT", press);
|
|
14
|
+
stopChildren("SIGINT");
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
process.on("SIGINT", press);
|
|
18
|
+
return {
|
|
19
|
+
afterIssue: () => presses >= 1,
|
|
20
|
+
afterRound: () => presses >= 2,
|
|
21
|
+
};
|
|
22
|
+
}
|
package/dist/loop.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export async function runIssues(args, loop) {
|
|
2
|
-
await loop.stop();
|
|
3
2
|
const reports = [];
|
|
4
3
|
for (const issue of loop.issues) {
|
|
5
4
|
if (args.max !== undefined && handled(reports) >= args.max) {
|
|
@@ -7,7 +6,7 @@ export async function runIssues(args, loop) {
|
|
|
7
6
|
}
|
|
8
7
|
const report = await loop.process(issue);
|
|
9
8
|
reports.push(report);
|
|
10
|
-
if (
|
|
9
|
+
if (loop.stop()) {
|
|
11
10
|
return { reports, stoppedAt: issue.number };
|
|
12
11
|
}
|
|
13
12
|
if (args.once && report.outcome !== "skipped") {
|
package/dist/pipeline.mjs
CHANGED
|
@@ -167,6 +167,9 @@ async function work(context, issue, log, state) {
|
|
|
167
167
|
state.phase = "implemented";
|
|
168
168
|
await writeState(repo, state);
|
|
169
169
|
}
|
|
170
|
+
if (context.stopAfterRound()) {
|
|
171
|
+
return stop(job);
|
|
172
|
+
}
|
|
170
173
|
await log.step("push", { branch }, () => ports.push(worktree, config.remote, branch));
|
|
171
174
|
const pr = await log.step("pull-request", { branch }, async () => {
|
|
172
175
|
const existing = await ports.findPr(repo, branch);
|
|
@@ -178,6 +181,9 @@ async function work(context, issue, log, state) {
|
|
|
178
181
|
await writeState(repo, state);
|
|
179
182
|
log.event("pull-request.ready", { pr }, "quiet");
|
|
180
183
|
for (;;) {
|
|
184
|
+
if (context.stopAfterRound()) {
|
|
185
|
+
return stop(job);
|
|
186
|
+
}
|
|
181
187
|
const gate = await checkGate(job);
|
|
182
188
|
if (gate.done) {
|
|
183
189
|
return gate.report;
|
|
@@ -185,6 +191,9 @@ async function work(context, issue, log, state) {
|
|
|
185
191
|
if (gate.fixed) {
|
|
186
192
|
continue;
|
|
187
193
|
}
|
|
194
|
+
if (context.stopAfterRound()) {
|
|
195
|
+
return stop(job);
|
|
196
|
+
}
|
|
188
197
|
const round = await reviewRound(job);
|
|
189
198
|
if (round.done) {
|
|
190
199
|
return round.report;
|
|
@@ -324,6 +333,18 @@ async function reviewRound(job) {
|
|
|
324
333
|
function escalate(job, reason) {
|
|
325
334
|
return handOver(job.context, job.log, job.issue.number, job.state, reason);
|
|
326
335
|
}
|
|
336
|
+
function stop(job) {
|
|
337
|
+
const { issue, log, state } = job;
|
|
338
|
+
log.event("issue.stop", { phase: state.phase }, "quiet");
|
|
339
|
+
return {
|
|
340
|
+
issue: issue.number,
|
|
341
|
+
outcome: "stopped",
|
|
342
|
+
pr: state.pr,
|
|
343
|
+
ciFixes: state.ciFixes,
|
|
344
|
+
reviewRounds: state.reviewRounds,
|
|
345
|
+
ms: Date.now() - job.started,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
327
348
|
async function finish(job, outcome, reason) {
|
|
328
349
|
const { context, issue, log, state, branch } = job;
|
|
329
350
|
const { repo, ports } = context;
|
package/dist/state.mjs
CHANGED
|
@@ -22,15 +22,6 @@ export async function writeState(repo, state) {
|
|
|
22
22
|
await mkdir(dir(repo), { recursive: true });
|
|
23
23
|
await writeFile(file(repo, state.issue), `${JSON.stringify(state, null, 2)}\n`);
|
|
24
24
|
}
|
|
25
|
-
export async function takeStop(repo) {
|
|
26
|
-
try {
|
|
27
|
-
await rm(join(dir(repo), "stop"));
|
|
28
|
-
return true;
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
25
|
export async function dropState(repo, issue) {
|
|
35
26
|
await rm(file(repo, issue), { force: true });
|
|
36
27
|
}
|
package/dist/summary.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const OUTCOMES = ["done", "needs-human", "error", "skipped"];
|
|
1
|
+
const OUTCOMES = ["done", "needs-human", "error", "skipped", "stopped"];
|
|
2
2
|
export function formatSummary(summary) {
|
|
3
3
|
const tally = OUTCOMES.map((outcome) => `${summary.issues.filter((report) => report.outcome === outcome).length} ${outcome}`).join(", ");
|
|
4
4
|
const lines = [`run ${summary.runId}`, `issues ${summary.issues.length}: ${tally}`];
|