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 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 after the current issue
116
+ ## Stop a run
114
117
 
115
- Run `touch .next-issue/stop` in a second terminal. The harness reads the file
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
- `Ctrl-C` is different: the harness passes the signal on to the command that runs
121
- and to every child of it, so the run dies in the middle of a step. The saved
122
- state lets the next run continue the issue, but the issue keeps
123
- `status:in-progress` and the run writes no summary.
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, takeStop } from "./state.mjs";
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 = { repo, config, base, reset: args.reset, merge: args.merge, recorder, ports: PORTS };
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: () => takeStop(repo),
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 forward(signal) {
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 onInterrupt = () => forward("SIGINT");
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 gh(repo, args, input) {
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 must("gh", ["repo", "view", slug(repo), "--json", "defaultBranchRef"], {
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 must("gh", [
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 gh(repo, [
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 gh(repo, ["pr", "comment", String(pr), "--body-file", "-"], body);
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 (await loop.stop()) {
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}`];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",