next-issue 1.0.7 → 1.1.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
@@ -9,7 +9,11 @@ npx -y next-issue@latest
9
9
 
10
10
  ## What it does
11
11
 
12
- For each open issue, oldest first:
12
+ Before each issue, fetch the open issues again and take the oldest one not yet
13
+ seen in this run. An issue seen once in a run, also a skipped or failed one, is
14
+ not tried again in that run. The run ends when no unseen issue is left. When
15
+ the fetch fails, the run ends there, writes the summary of the issues it handled
16
+ and exits with code 1. For each issue:
13
17
 
14
18
  1. Claim the issue only when it holds the `status:todo` label and holds no
15
19
  `status:in-progress` or `status:in-review` label. A `status:done`,
@@ -354,6 +358,7 @@ that a label still holds back stays as it was.
354
358
  | `--once` | Stop after the first handled issue |
355
359
  | `--max <n>` | Handle at most n issues |
356
360
  | `--merge` | Squash-merge the pull request after the approval, delete the remote branch and fix a failure of the checks on the base branch |
361
+ | `--watch` | Keep running, and read the open issues again after each pass |
357
362
  | `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
358
363
  | `--json` | Print the machine summary instead of the text one |
359
364
  | `--verbose` | Show every command and all agent output |
@@ -363,6 +368,33 @@ that a label still holds back stays as it was.
363
368
  The exit code is 1 when an issue needs a person or failed, and 0 when it does
364
369
  not, so a cron job or a CI step can read the result of the run.
365
370
 
371
+ ## Watch for new issues
372
+
373
+ A run without `--watch` ends when no unseen issue is left. With `--watch`, the
374
+ harness then waits, reads the open issues again and runs another pass, so it
375
+ picks up an issue that someone opens during the run, or one whose blocker
376
+ merges. Each pass tries every open issue again, also one that an earlier pass
377
+ skipped.
378
+
379
+ The waits follow a fixed back-off in minutes: 1, 2, 3, 5, 10, then 10 from then
380
+ on. A pass that handles an issue, with any outcome other than `skipped`, resets
381
+ the back-off to 1 minute. A pass where every issue is skipped does not reset
382
+ it. Each wait prints a line on standard error with the minutes until the next
383
+ pass.
384
+
385
+ After each pass that handled an issue, the harness prints the summary of that
386
+ pass. The token totals in it cover that pass only. The exit code at the end
387
+ covers all passes. `--max <n>` counts the handled issues across all passes,
388
+ and `--once` is the same as `--max 1`.
389
+
390
+ The watch stops:
391
+
392
+ - at the first `Ctrl-C` during a wait, at once;
393
+ - at the first `Ctrl-C` during an issue, after that issue;
394
+ - after a pass that halts the run, for example when the base branch is broken
395
+ after `--merge`;
396
+ - when the open issues cannot be read.
397
+
366
398
  ## Development
367
399
 
368
400
  ```bash
package/dist/args.mjs CHANGED
@@ -6,6 +6,7 @@ export const USAGE = `next-issue [command] [options]
6
6
  --once stop after the first handled issue
7
7
  --max <n> handle at most n issues
8
8
  --merge squash-merge the pull request after the approval
9
+ --watch keep running and read the issues again after each pass
9
10
  --reset drop the saved budgets and findings first
10
11
  --json print the machine summary instead of the text one
11
12
  --force replace an existing config file, with init
@@ -22,6 +23,7 @@ export function parseArgs(argv) {
22
23
  reset: false,
23
24
  force: false,
24
25
  merge: false,
26
+ watch: false,
25
27
  level: "normal",
26
28
  };
27
29
  for (let index = 0; index < argv.length; index += 1) {
@@ -44,6 +46,9 @@ export function parseArgs(argv) {
44
46
  else if (arg === "--merge") {
45
47
  args.merge = true;
46
48
  }
49
+ else if (arg === "--watch") {
50
+ args.watch = true;
51
+ }
47
52
  else if (arg === "--reset") {
48
53
  args.reset = true;
49
54
  }
package/dist/cli.mjs CHANGED
@@ -7,7 +7,7 @@ import { setCommandObserver, setDefaultTimeout } from "./exec.mjs";
7
7
  import { detectRepo, ensureIgnored, repoRoot, WORKTREE_DIR } from "./git.mjs";
8
8
  import { defaultBranch } from "./github.mjs";
9
9
  import { listenForInterrupts } from "./interrupt.mjs";
10
- import { exitCode, runIssues } from "./loop.mjs";
10
+ import { exitCode, runIssues, watchIssues } from "./loop.mjs";
11
11
  import { message, pruneRuns, Recorder } from "./observe.mjs";
12
12
  import { pendingIssues, PORTS, processIssue } from "./pipeline.mjs";
13
13
  import { STATE_DIR } from "./state.mjs";
@@ -82,16 +82,34 @@ async function handle(args, config, repo, recorder) {
82
82
  base,
83
83
  log: recorder.logFile,
84
84
  }, "quiet");
85
- const issues = await pendingIssues(context);
86
- recorder.event("issues", { open: issues.length }, "quiet");
87
- const { reports, stoppedAt } = await runIssues(args, {
88
- issues,
85
+ const loop = {
86
+ issues: async () => {
87
+ const issues = await pendingIssues(context);
88
+ recorder.event("issues", { open: issues.length }, "quiet");
89
+ return issues;
90
+ },
89
91
  process: (issue) => processIssue(context, issue),
90
92
  stop: interrupts.afterIssue,
91
- });
93
+ };
94
+ const print = (summary) => write(process.stdout, args.json ? `${JSON.stringify(summary, null, 2)}\n` : formatSummary(summary));
95
+ let passStart = recorder.summary();
96
+ const { reports, stoppedAt, error } = args.watch
97
+ ? await watchIssues(args, {
98
+ ...loop,
99
+ wait: (minutes) => {
100
+ recorder.event("watch.wait", { minutes }, "quiet");
101
+ passStart = recorder.summary();
102
+ return interrupts.sleep(minutes * 60_000);
103
+ },
104
+ passEnd: (issues) => print({ ...recorder.summary(passStart), issues }),
105
+ })
106
+ : await runIssues(args, loop);
92
107
  if (stoppedAt !== undefined) {
93
108
  recorder.event("run.stop", { issue: stoppedAt }, "quiet");
94
109
  }
110
+ if (error !== undefined) {
111
+ recorder.event("issues.fail", { error: message(error) }, "quiet");
112
+ }
95
113
  recorder.event("run.end", {
96
114
  done: count(reports, "done"),
97
115
  needsHuman: count(reports, "needs-human"),
@@ -102,8 +120,10 @@ async function handle(args, config, repo, recorder) {
102
120
  const summary = { ...recorder.summary(), issues: reports };
103
121
  const summaryFile = join(repo.root, STATE_DIR, "runs", `${recorder.runId}.summary.json`);
104
122
  await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
105
- await write(process.stdout, args.json ? `${JSON.stringify(summary, null, 2)}\n` : formatSummary(summary));
106
- return exitCode(reports);
123
+ if (!args.watch) {
124
+ await print(summary);
125
+ }
126
+ return error === undefined ? exitCode(reports) : 1;
107
127
  }
108
128
  function count(reports, outcome) {
109
129
  return reports.filter((report) => report.outcome === outcome).length;
@@ -1,9 +1,14 @@
1
1
  import { stopChildren } from "./exec.mjs";
2
2
  export function listenForInterrupts(say) {
3
3
  let presses = 0;
4
+ let wake;
4
5
  const press = () => {
5
6
  presses += 1;
6
- if (presses === 1) {
7
+ if (presses === 1 && wake !== undefined) {
8
+ say("Stopping now.");
9
+ wake();
10
+ }
11
+ else if (presses === 1) {
7
12
  say("Stopping after this issue. Press Ctrl-C again to stop after this round.");
8
13
  }
9
14
  else if (presses === 2) {
@@ -18,5 +23,20 @@ export function listenForInterrupts(say) {
18
23
  return {
19
24
  afterIssue: () => presses >= 1,
20
25
  afterRound: () => presses >= 2,
26
+ sleep: (ms) => new Promise((resolve) => {
27
+ if (presses >= 1) {
28
+ resolve(false);
29
+ return;
30
+ }
31
+ const timer = setTimeout(() => {
32
+ wake = undefined;
33
+ resolve(true);
34
+ }, ms);
35
+ wake = () => {
36
+ clearTimeout(timer);
37
+ wake = undefined;
38
+ resolve(false);
39
+ };
40
+ }),
21
41
  };
22
42
  }
package/dist/loop.mjs CHANGED
@@ -1,9 +1,20 @@
1
+ export const BACK_OFF_MINUTES = [1, 2, 3, 5, 10];
1
2
  export async function runIssues(args, loop) {
2
3
  const reports = [];
3
- for (const issue of loop.issues) {
4
- if (args.max !== undefined && handled(reports) >= args.max) {
4
+ const seen = new Set();
5
+ while (args.max === undefined || handled(reports) < args.max) {
6
+ let issues;
7
+ try {
8
+ issues = await loop.issues();
9
+ }
10
+ catch (error) {
11
+ return { reports, error };
12
+ }
13
+ const issue = issues.find((candidate) => !seen.has(candidate.number));
14
+ if (issue === undefined) {
5
15
  break;
6
16
  }
17
+ seen.add(issue.number);
7
18
  const report = await loop.process(issue);
8
19
  reports.push(report);
9
20
  if (loop.stop() || report.halt === true) {
@@ -15,6 +26,30 @@ export async function runIssues(args, loop) {
15
26
  }
16
27
  return { reports };
17
28
  }
29
+ export async function watchIssues(args, watch) {
30
+ const limit = args.once ? 1 : args.max;
31
+ const reports = [];
32
+ let step = 0;
33
+ for (;;) {
34
+ const pass = await runIssues({ once: false, max: limit === undefined ? undefined : limit - handled(reports) }, watch);
35
+ reports.push(...pass.reports);
36
+ if (handled(pass.reports) > 0) {
37
+ await watch.passEnd(pass.reports);
38
+ step = 0;
39
+ }
40
+ if (pass.error !== undefined || pass.stoppedAt !== undefined) {
41
+ return { ...pass, reports };
42
+ }
43
+ if ((limit !== undefined && handled(reports) >= limit) || watch.stop()) {
44
+ return { reports };
45
+ }
46
+ const minutes = BACK_OFF_MINUTES[Math.min(step, BACK_OFF_MINUTES.length - 1)];
47
+ step += 1;
48
+ if (!(await watch.wait(minutes))) {
49
+ return { reports };
50
+ }
51
+ }
52
+ }
18
53
  export function exitCode(reports) {
19
54
  return reports.some((report) => report.outcome === "needs-human" || report.outcome === "error") ? 1 : 0;
20
55
  }
package/dist/observe.mjs CHANGED
@@ -13,6 +13,15 @@ export function addUsage(target, source) {
13
13
  target.cacheWrite += source.cacheWrite;
14
14
  target.total += source.total;
15
15
  }
16
+ function negate(usage) {
17
+ return {
18
+ input: -usage.input,
19
+ output: -usage.output,
20
+ cacheRead: -usage.cacheRead,
21
+ cacheWrite: -usage.cacheWrite,
22
+ total: -usage.total,
23
+ };
24
+ }
16
25
  class Sink {
17
26
  totals = new Map();
18
27
  counts = new Map();
@@ -98,20 +107,27 @@ export class Recorder {
98
107
  throw error;
99
108
  }
100
109
  }
101
- summary() {
110
+ summary(since) {
102
111
  const total = emptyUsage();
103
112
  const roles = {};
104
113
  for (const [role, value] of this.sink.totals) {
105
- roles[role] = value;
106
- addUsage(total, value);
114
+ const earlier = since?.roles[role] ?? { ...emptyUsage(), calls: 0, ms: 0 };
115
+ if (value.calls === earlier.calls) {
116
+ continue;
117
+ }
118
+ const usage = { ...value, calls: value.calls - earlier.calls, ms: value.ms - earlier.ms };
119
+ addUsage(usage, negate(earlier));
120
+ roles[role] = usage;
121
+ addUsage(total, usage);
107
122
  }
108
- return {
109
- runId: this.runId,
110
- logFile: this.sink.file,
111
- roles,
112
- counts: Object.fromEntries(this.sink.counts),
113
- total,
114
- };
123
+ const counts = {};
124
+ for (const [kind, value] of this.sink.counts) {
125
+ const count = value - (since?.counts[kind] ?? 0);
126
+ if (count > 0) {
127
+ counts[kind] = count;
128
+ }
129
+ }
130
+ return { runId: this.runId, logFile: this.sink.file, roles, counts, total };
115
131
  }
116
132
  async close() {
117
133
  await this.sink.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "1.0.7",
3
+ "version": "1.1.0",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",