next-issue 1.0.8 → 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
@@ -358,6 +358,7 @@ that a label still holds back stays as it was.
358
358
  | `--once` | Stop after the first handled issue |
359
359
  | `--max <n>` | Handle at most n issues |
360
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 |
361
362
  | `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
362
363
  | `--json` | Print the machine summary instead of the text one |
363
364
  | `--verbose` | Show every command and all agent output |
@@ -367,6 +368,33 @@ that a label still holds back stays as it was.
367
368
  The exit code is 1 when an issue needs a person or failed, and 0 when it does
368
369
  not, so a cron job or a CI step can read the result of the run.
369
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
+
370
398
  ## Development
371
399
 
372
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,7 +82,7 @@ async function handle(args, config, repo, recorder) {
82
82
  base,
83
83
  log: recorder.logFile,
84
84
  }, "quiet");
85
- const { reports, stoppedAt, error } = await runIssues(args, {
85
+ const loop = {
86
86
  issues: async () => {
87
87
  const issues = await pendingIssues(context);
88
88
  recorder.event("issues", { open: issues.length }, "quiet");
@@ -90,7 +90,20 @@ async function handle(args, config, repo, recorder) {
90
90
  },
91
91
  process: (issue) => processIssue(context, issue),
92
92
  stop: interrupts.afterIssue,
93
- });
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);
94
107
  if (stoppedAt !== undefined) {
95
108
  recorder.event("run.stop", { issue: stoppedAt }, "quiet");
96
109
  }
@@ -107,7 +120,9 @@ async function handle(args, config, repo, recorder) {
107
120
  const summary = { ...recorder.summary(), issues: reports };
108
121
  const summaryFile = join(repo.root, STATE_DIR, "runs", `${recorder.runId}.summary.json`);
109
122
  await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
110
- await write(process.stdout, args.json ? `${JSON.stringify(summary, null, 2)}\n` : formatSummary(summary));
123
+ if (!args.watch) {
124
+ await print(summary);
125
+ }
111
126
  return error === undefined ? exitCode(reports) : 1;
112
127
  }
113
128
  function count(reports, outcome) {
@@ -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,3 +1,4 @@
1
+ export const BACK_OFF_MINUTES = [1, 2, 3, 5, 10];
1
2
  export async function runIssues(args, loop) {
2
3
  const reports = [];
3
4
  const seen = new Set();
@@ -25,6 +26,30 @@ export async function runIssues(args, loop) {
25
26
  }
26
27
  return { reports };
27
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
+ }
28
53
  export function exitCode(reports) {
29
54
  return reports.some((report) => report.outcome === "needs-human" || report.outcome === "error") ? 1 : 0;
30
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.8",
3
+ "version": "1.1.0",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",