next-issue 2.14.1 → 2.16.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
@@ -12,8 +12,16 @@ npx -y next-issue@latest
12
12
  Before each issue, fetch the open issues again and take the oldest one not yet
13
13
  seen in this run. An issue seen once in a run, also a skipped or failed one, is
14
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:
15
+ the fetch fails, the run claims no new issue, ends once the running issues end,
16
+ writes the summary of the issues it handled and exits with code 1.
17
+
18
+ A run handles up to `concurrency` issues at the same time, 1 by default. When an
19
+ issue ends, the run fetches the open issues again and claims the oldest unseen
20
+ one for the free slot. Each issue has its own worktree and branch. Step 3 below
21
+ and the removal of a worktree or branch run for one issue at a time, so two
22
+ issues never change the git refs or worktrees of the repository at once. An issue blocked by a running issue is skipped and not tried again in
23
+ that run, even when the blocker closes before the run ends. `--max <n>` counts the handled issues across the run, and with `--once`
24
+ the run handles one issue. For each issue:
17
25
 
18
26
  1. Claim the issue only when it holds the `status:todo` label and holds no
19
27
  `status:in-progress` or `status:in-review` label. A `status:done`,
@@ -120,7 +128,9 @@ The harness hands the issue to a person, with the `status:needs-human` label,
120
128
  and comments the reason on the pull request, or on the issue when there is no
121
129
  pull request yet, when:
122
130
 
123
- - the check budget or the review budget runs out;
131
+ - the check budget or the review budget runs out. A finding that the reviewer
132
+ repeats, even one that the fixer disputed, goes back to the fixer until the
133
+ review budget runs out;
124
134
  - a fixer round adds no commit and gives no reason, so there is no progress;
125
135
  - the fixer reports that the checks fail for a reason the change did not cause,
126
136
  such as a test already broken on the base branch or one that fails only under
@@ -138,9 +148,6 @@ pull request yet, when:
138
148
  holds the reason of the dispute, and the change is on the machine that ran
139
149
  the harness. The `<n>-dispute-<commit>` branches go away with the `<n>`
140
150
  branch when the issue is done;
141
- - the reviewer repeats a finding set from an earlier round, which shows a
142
- ping-pong between the fixer and the reviewer, or a finding that the fixer
143
- disputed and the reviewer holds to. The person then decides;
144
151
  - the reviewer gives no verdict, or the checks do not finish in time;
145
152
  - someone pushes to the pull request while the harness waits for the checks,
146
153
  so the pull request no longer shows the commit that the harness pushed;
@@ -150,7 +157,8 @@ pull request yet, when:
150
157
  the pull request closes without a merge;
151
158
  - with `--merge`, the checks on the base branch fail after the merge and the
152
159
  fixer cannot fix them, or they do not finish in time. The base branch is
153
- then broken, so the run stops after this issue;
160
+ then broken, so the run claims no new issue and ends once the running
161
+ issues end;
154
162
  - an agent does not finish in `agentTimeoutMinutes`;
155
163
  - the setup command fails, or does not finish in `setupTimeoutMinutes`;
156
164
  - a step of the run fails with an error. A `gh` call that is safe to repeat,
@@ -175,12 +183,12 @@ exception is the answer to a hand-over, see
175
183
 
176
184
  Press `Ctrl-C` to stop the run step by step:
177
185
 
178
- 1. The first press stops the run after the current issue. The harness finishes
179
- the issue that it holds and then writes the summary as usual.
180
- 2. The second press stops the run after the current round: the implementation,
181
- one checks round (wait, then fix) or one review round (review, then fix). The
182
- issue ends as `stopped`. Its state, worktree and labels stay, so the next run
183
- carries on from that point.
186
+ 1. The first press stops new claims. The harness finishes every issue that it
187
+ holds and then writes the summary as usual.
188
+ 2. The second press stops every running issue after its current round: the
189
+ implementation, one checks round (wait, then fix) or one review round
190
+ (review, then fix). The issue ends as `stopped`. Its state, worktree and
191
+ labels stay, so the next run carries on from that point.
184
192
  3. The third press stops every agent, kills the command that runs and every
185
193
  child of it, so the run dies in the middle of a step. The saved state lets the next run continue the
186
194
  issue, but the issue keeps `status:in-progress` and the run writes no
@@ -218,6 +226,9 @@ Thus a person who can write an issue, or a writer who can comment, can try to
218
226
  give an instruction to the agent. Use the harness only on a repository where you
219
227
  trust the issue authors, or run it in a container.
220
228
 
229
+ [ADR 0001](https://github.com/ravshansbox/next-issue/blob/main/docs/adr/0001-reviewer-isolation.md) records why the reviewer
230
+ keeps a full shell, and which other options were considered.
231
+
221
232
  ## Observability
222
233
 
223
234
  Every run has an id, `<timestamp>`, and writes these under `.next-issue/runs/`:
@@ -301,6 +312,7 @@ does not know all stop the run, so a typo cannot pass without a word.
301
312
  {
302
313
  "remote": "origin",
303
314
  "issueLimit": 100,
315
+ "concurrency": 1,
304
316
  "maxCiFixes": 5,
305
317
  "maxCommitFixes": 10,
306
318
  "maxReviewRounds": 5,
@@ -334,6 +346,7 @@ does not know all stop the run, so a typo cannot pass without a word.
334
346
  | ----------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
335
347
  | `remote` | `origin` | The git remote for the repository and the branches |
336
348
  | `issueLimit` | `100` | The maximum number of open issues to read. With `labels.ready` set, only issues with the ready, in-progress or in-review label count |
349
+ | `concurrency` | `1` | The most issues that a run handles at the same time |
337
350
  | `maxCiFixes` | `5` | The budget for check fixes per issue |
338
351
  | `maxCommitFixes` | `10` | The budget for fixes of a commit that a git hook stopped, per issue |
339
352
  | `maxReviewRounds` | `5` | The budget for review rounds per issue |
@@ -456,7 +469,7 @@ and `--once` is the same as `--max 1`.
456
469
  The watch stops:
457
470
 
458
471
  - at the first `Ctrl-C` during a wait, at once;
459
- - at the first `Ctrl-C` during an issue, after that issue;
472
+ - at the first `Ctrl-C` during an issue, after the running issues;
460
473
  - after a pass that halts the run, for example when the base branch is broken
461
474
  after `--merge`;
462
475
  - when the open issues cannot be read.
@@ -478,8 +491,8 @@ to it. The worker:
478
491
  once. Each run is like `next-issue` without `--watch`, and the client says
479
492
  whether it merges;
480
493
  - sends every client the progress of every run when it connects and on every
481
- change: the issue, the step, the tool count, the last tool and the handled
482
- issues;
494
+ change: for each running issue the issue, the step, the tool count and the
495
+ last tool, and the handled issues;
483
496
  - sends every client a `questions` message for each repository: the open
484
497
  issues with the `needsHuman` label, each with its number, title, the reason
485
498
  of the hand-over, the url of the comment that holds it, the `owner/name`
@@ -494,8 +507,8 @@ to it. The worker:
494
507
  a newer one. When the client adds an `"id"` to its message, the list that
495
508
  answers it carries the same `id` and is read after the message arrived. When the list cannot be read, the client gets
496
509
  `{ "type": "questions-error", "root": "<path>", "message": "<text>" }`;
497
- - stops a run, when a client asks, like a second `Ctrl-C`: after the current
498
- round, with the state kept;
510
+ - stops a run, when a client asks, like a second `Ctrl-C`: each running issue
511
+ after its current round, with the state kept;
499
512
  - stays when all clients close;
500
513
  - closes once no run is going when a client with a newer version connects, so
501
514
  that the client can start the newer worker;
package/dist/config.mjs CHANGED
@@ -4,6 +4,7 @@ import { message } from './observe.mjs';
4
4
  const DEFAULTS = {
5
5
  remote: 'origin',
6
6
  issueLimit: 100,
7
+ concurrency: 1,
7
8
  maxCiFixes: 5,
8
9
  maxCommitFixes: 10,
9
10
  maxReviewRounds: 5,
@@ -33,6 +34,7 @@ function defaults() {
33
34
  const FIELDS = [...Object.keys(DEFAULTS), 'setupCommand'];
34
35
  const WHOLE_FIELDS = [
35
36
  ['issueLimit', 1],
37
+ ['concurrency', 1],
36
38
  ['maxCiFixes', 0],
37
39
  ['maxCommitFixes', 0],
38
40
  ['maxReviewRounds', 1],
package/dist/handover.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { setStatus } from './job.mjs';
1
+ import { oneAtATime, setStatus, } from './job.mjs';
2
2
  import { message } from './observe.mjs';
3
3
  import { dropState, readState, writeState } from './state.mjs';
4
4
  export const HAND_OVER = 'next-issue needs help:';
@@ -91,7 +91,7 @@ export async function finish(job, outcome, reason) {
91
91
  await cleanUp(context, log, branch);
92
92
  if (outcome === 'done') {
93
93
  for (const name of [branch, ...(await ports.disputeBranches(repo, branch))]) {
94
- if (!(await ports.deleteBranch(repo, name))) {
94
+ if (!(await oneAtATime(repo.root, () => ports.deleteBranch(repo, name)))) {
95
95
  log.event('branch.kept', { branch: name }, 'quiet');
96
96
  }
97
97
  }
@@ -114,7 +114,7 @@ export async function cleanUp(context, log, branch) {
114
114
  if (path === undefined) {
115
115
  return;
116
116
  }
117
- if (!(await context.ports.removeWorktree(context.repo, path))) {
117
+ if (!(await oneAtATime(context.repo.root, () => context.ports.removeWorktree(context.repo, path)))) {
118
118
  log.event('worktree.kept', { path }, 'quiet');
119
119
  }
120
120
  }
@@ -9,10 +9,10 @@ export function listenForInterrupts(say) {
9
9
  wake();
10
10
  }
11
11
  else if (presses === 1) {
12
- say('Stopping after this issue. Press Ctrl-C again to stop after this round.');
12
+ say('Stopping after the running issues. Press Ctrl-C again to stop after the current round.');
13
13
  }
14
14
  else if (presses === 2) {
15
- say('Stopping after this round. Press Ctrl-C again to stop now.');
15
+ say('Stopping after the current round. Press Ctrl-C again to stop now.');
16
16
  }
17
17
  else {
18
18
  process.off('SIGINT', press);
package/dist/job.mjs CHANGED
@@ -1,5 +1,11 @@
1
1
  import { managedLabels } from './config.mjs';
2
2
  import { writeState } from './state.mjs';
3
+ const worktreeQueues = new Map();
4
+ export function oneAtATime(root, work) {
5
+ const result = (worktreeQueues.get(root) ?? Promise.resolve()).then(work);
6
+ worktreeQueues.set(root, result.catch(() => undefined));
7
+ return result;
8
+ }
3
9
  export async function openPr(job) {
4
10
  const { context, issue, log, state, branch } = job;
5
11
  const { repo, config, ports } = context;
package/dist/loop.mjs CHANGED
@@ -1,29 +1,55 @@
1
1
  export const BACK_OFF_MINUTES = [1, 2, 3, 5, 10];
2
2
  export async function runIssues(args, loop, seen = new Set()) {
3
+ const limit = args.once ? Math.min(1, args.max ?? 1) : args.max;
4
+ const concurrency = loop.concurrency ?? 1;
3
5
  const reports = [];
4
- while (args.max === undefined || handled(reports) < args.max) {
5
- let issues;
6
- try {
7
- issues = await loop.issues();
8
- }
9
- catch (error) {
10
- return { reports, error };
6
+ const running = new Map();
7
+ let stoppedAt;
8
+ let error;
9
+ let crash;
10
+ let claiming = true;
11
+ const room = () => running.size < concurrency && (limit === undefined || handled(reports) + running.size < limit);
12
+ for (;;) {
13
+ if (claiming && room()) {
14
+ try {
15
+ const issues = await loop.issues();
16
+ for (const issue of issues.filter((candidate) => !seen.has(candidate.number))) {
17
+ if (!room()) {
18
+ break;
19
+ }
20
+ seen.add(issue.number);
21
+ running.set(issue.number, loop.process(issue).then((report) => ({ issue: issue.number, report }), (failure) => ({ issue: issue.number, failure })));
22
+ }
23
+ }
24
+ catch (failure) {
25
+ error = failure;
26
+ claiming = false;
27
+ }
11
28
  }
12
- const issue = issues.find((candidate) => !seen.has(candidate.number));
13
- if (issue === undefined) {
29
+ if (running.size === 0) {
14
30
  break;
15
31
  }
16
- seen.add(issue.number);
17
- const report = await loop.process(issue);
18
- reports.push(report);
19
- if (loop.stop() || report.halt === true) {
20
- return { reports, stoppedAt: issue.number };
32
+ const { issue, report, failure } = await Promise.race(running.values());
33
+ running.delete(issue);
34
+ if (report === undefined) {
35
+ crash ??= { failure };
36
+ claiming = false;
37
+ continue;
21
38
  }
22
- if (args.once && report.outcome !== 'skipped') {
23
- break;
39
+ reports.push(report);
40
+ if (claiming && (loop.stop() || report.halt === true)) {
41
+ stoppedAt = issue;
42
+ claiming = false;
24
43
  }
25
44
  }
26
- return { reports };
45
+ if (crash !== undefined) {
46
+ throw crash.failure;
47
+ }
48
+ return {
49
+ reports,
50
+ ...(stoppedAt === undefined ? {} : { stoppedAt }),
51
+ ...(error === undefined ? {} : { error }),
52
+ };
27
53
  }
28
54
  export async function watchIssues(args, watch) {
29
55
  const limit = args.once ? 1 : args.max;
package/dist/pipeline.mjs CHANGED
@@ -3,7 +3,7 @@ import { commitWork } from './fix.mjs';
3
3
  import { checkGate } from './gate.mjs';
4
4
  import { branchName } from './git.mjs';
5
5
  import { cleanUp, escalate, finish, handOver, handOverReply, skipped, stop, writerComments, } from './handover.mjs';
6
- import { openPr, setStatus } from './job.mjs';
6
+ import { oneAtATime, openPr, setStatus } from './job.mjs';
7
7
  import { afterMerge, fixBase } from './merge.mjs';
8
8
  import { message } from './observe.mjs';
9
9
  import { implementPrompt, parseCommitSubject } from './prompts.mjs';
@@ -148,7 +148,7 @@ async function work(context, issue, log, state, start, handedOver) {
148
148
  const reply = handedOver
149
149
  ? await log.step('reply', {}, () => handOverReply(context, issue.number, state.pr))
150
150
  : [];
151
- const added = await log.step('worktree', { branch }, () => ports.addWorktree(repo, issue.number, branch, context.base, config.remote, start));
151
+ const added = await log.step('worktree', { branch }, () => oneAtATime(repo.root, () => ports.addWorktree(repo, issue.number, branch, context.base, config.remote, start)));
152
152
  const worktree = added.path;
153
153
  if (!added.baseUpdated) {
154
154
  log.event('base.behind', { base: context.base }, 'quiet');
package/dist/progress.mjs CHANGED
@@ -1,11 +1,7 @@
1
1
  const TOOL_LINE_MAX = 80;
2
2
  const NOT_STAGES = new Set(['issue.start', 'agent.start', 'run.start']);
3
3
  export function startProgress() {
4
- return { status: 'running', tools: 0, handled: [] };
5
- }
6
- function idle(progress) {
7
- const { issue: _issue, title: _title, stage: _stage, lastTool: _lastTool, issueStartedAt: _at, ...rest } = progress;
8
- return { ...rest, tools: 0 };
4
+ return { status: 'running', issues: [], handled: [] };
9
5
  }
10
6
  function toolLine(record) {
11
7
  const tool = String(record['tool']);
@@ -13,36 +9,52 @@ function toolLine(record) {
13
9
  const line = typeof command === 'string' ? `${tool} ${command.split('\n')[0]}` : tool;
14
10
  return line.length > TOOL_LINE_MAX ? `${line.slice(0, TOOL_LINE_MAX - 1)}…` : line;
15
11
  }
12
+ function change(progress, issue, update) {
13
+ return {
14
+ ...progress,
15
+ issues: progress.issues.map((current) => (current.issue === issue ? update(current) : current)),
16
+ };
17
+ }
16
18
  export function applyEvent(progress, record) {
17
19
  const kind = String(record['kind']);
20
+ const issue = Number(record['issue']);
18
21
  if (kind === 'issue.start') {
19
22
  const next = {
20
- ...idle(progress),
21
- issue: Number(record['issue']),
22
- issueStartedAt: Date.parse(String(record['ts'])),
23
+ issue,
24
+ tools: 0,
25
+ startedAt: Date.parse(String(record['ts'])),
23
26
  };
24
27
  if (typeof record['title'] === 'string') {
25
28
  next.title = record['title'];
26
29
  }
27
- return next;
30
+ const others = progress.issues.filter((current) => current.issue !== issue);
31
+ return { ...progress, issues: [...others, next] };
28
32
  }
29
33
  if (kind === 'tool') {
30
- return { ...progress, tools: progress.tools + 1, lastTool: toolLine(record) };
34
+ return change(progress, issue, (current) => ({
35
+ ...current,
36
+ tools: current.tools + 1,
37
+ lastTool: toolLine(record),
38
+ }));
31
39
  }
32
40
  if (kind === 'issue.end' || kind === 'issue.error') {
33
41
  const outcome = kind === 'issue.error' ? 'error' : record['outcome'];
34
42
  const handled = outcome === 'done' || outcome === 'needs-human' || outcome === 'error'
35
- ? [...progress.handled, { issue: Number(record['issue']), outcome }]
43
+ ? [...progress.handled, { issue, outcome }]
36
44
  : progress.handled;
37
- return { ...idle(progress), handled };
45
+ const issues = progress.issues.filter((current) => current.issue !== issue);
46
+ return { ...progress, issues, handled };
38
47
  }
39
48
  if (kind === 'issues.fail') {
40
49
  return { ...progress, error: String(record['error']) };
41
50
  }
42
- if (kind.endsWith('.start') && !NOT_STAGES.has(kind) && progress.issue !== undefined) {
51
+ if (kind.endsWith('.start') && !NOT_STAGES.has(kind)) {
43
52
  const stage = kind.slice(0, -'.start'.length);
44
53
  const round = record['round'];
45
- return { ...progress, stage: typeof round === 'number' ? `${stage} ${round}` : stage };
54
+ return change(progress, issue, (current) => ({
55
+ ...current,
56
+ stage: typeof round === 'number' ? `${stage} ${round}` : stage,
57
+ }));
46
58
  }
47
59
  return progress;
48
60
  }
package/dist/review.mjs CHANGED
@@ -6,7 +6,7 @@ import { history, setStatus } from './job.mjs';
6
6
  import { afterMerge } from './merge.mjs';
7
7
  import { capDiff, reviewPrompt } from './prompts.mjs';
8
8
  import { writeState } from './state.mjs';
9
- import { blockingFindings, fingerprint, formatFindings, formatVerdict, openFindings, parseVerdict, } from './verdict.mjs';
9
+ import { blockingFindings, formatFindings, formatVerdict, openFindings, parseVerdict, } from './verdict.mjs';
10
10
  export async function reviewRound(job) {
11
11
  const { context, issue, log, state, worktree, comments } = job;
12
12
  const { repo, config, ports } = context;
@@ -73,15 +73,9 @@ export async function reviewRound(job) {
73
73
  await setStatus(context, issue.number, pr, config.labels.done);
74
74
  return { done: true, report: await finish(job, 'done') };
75
75
  }
76
- const mark = fingerprint(open);
77
- if (state.reviewLog.some((round) => round.fingerprint === mark)) {
78
- await escalate(job, 'The reviewer repeated findings that an earlier round did not fix.');
79
- return { done: true, report: await finish(job, 'needs-human', 'repeated findings') };
80
- }
81
76
  const earlier = history(state.reviewLog);
82
77
  state.reviewLog.push({
83
78
  round: state.reviewRounds,
84
- fingerprint: mark,
85
79
  findings: formatFindings(open),
86
80
  });
87
81
  await writeState(repo, state);
package/dist/run.mjs CHANGED
@@ -68,6 +68,7 @@ async function handle(args, config, repo, recorder, controls) {
68
68
  },
69
69
  process: (issue) => processIssue(context, issue),
70
70
  stop: interrupts.afterIssue,
71
+ concurrency: config.concurrency,
71
72
  };
72
73
  const print = controls.print ?? (async () => undefined);
73
74
  let passStart = recorder.summary();
package/dist/state.mjs CHANGED
@@ -44,7 +44,6 @@ function isBaseFix(value) {
44
44
  function isReviewRound(value) {
45
45
  return (isRecord(value) &&
46
46
  typeof value.round === 'number' &&
47
- typeof value.fingerprint === 'string' &&
48
47
  typeof value.findings === 'string' &&
49
48
  isOptional(value.dispute, 'string'));
50
49
  }
package/dist/verdict.mjs CHANGED
@@ -54,12 +54,3 @@ export function formatVerdict(verdict, open) {
54
54
  export function formatFindings(findings) {
55
55
  return findings.map((finding) => `- ${finding.detail}`).join('\n');
56
56
  }
57
- export function fingerprint(findings) {
58
- return findings
59
- .map((finding) => finding.detail
60
- .toLowerCase()
61
- .replace(/[^a-z0-9]+/g, ' ')
62
- .trim())
63
- .sort()
64
- .join('|');
65
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "2.14.1",
3
+ "version": "2.16.0",
4
4
  "description": "Autonomous issue harness driven by the pi SDK",
5
5
  "keywords": [
6
6
  "github",