next-issue 2.18.0 → 2.20.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
@@ -304,12 +304,13 @@ Three levels of console output:
304
304
  - Node 24 or later
305
305
  - `gh`, authenticated with write access to the repository
306
306
  - pi, logged in to a model provider. The agents use the model, the thinking
307
- level and the extensions from your pi settings, except the `pi-next-issue`
308
- extension. The implementer and the fixer get the default tools of your pi
309
- settings, the `grep`, `find` and `ls` tools and the tools of your
310
- extensions. The reviewer gets all of them except `edit` and `write`. The
311
- agents run with `GIT_OPTIONAL_LOCKS=0`, so a `git status` from an agent or
312
- an extension does not hold the index lock while the harness commits.
307
+ level, the extensions and the skills from your pi settings, except the
308
+ extension and the skills of `pi-next-issue`. The implementer and the fixer
309
+ get the default tools of your pi settings, the `grep`, `find` and `ls` tools
310
+ and the tools of your extensions. The reviewer gets all of them except
311
+ `edit` and `write`. The agents run with `GIT_OPTIONAL_LOCKS=0`, so a
312
+ `git status` from an agent or an extension does not hold the index lock
313
+ while the harness commits.
313
314
 
314
315
  ## Configuration
315
316
 
@@ -451,8 +452,8 @@ state of an issue that a label still holds back stays as it was.
451
452
  | Argument | Effect |
452
453
  | ----------- | ----------------------------------------------------------------------------------------------------------------------------- |
453
454
  | `init` | Write the config file with the defaults |
454
- | `serve` | Run the worker that pi extensions talk to, see [Worker](#worker) |
455
- | `stop` | Stop the worker, see [Worker](#worker) |
455
+ | `serve` | Run the worker of this repository that pi extensions talk to, see [Worker](#worker) |
456
+ | `stop` | Stop the worker of this repository, see [Worker](#worker) |
456
457
  | `logs` | Show the latest run log and follow it, see [Observability](#observability) |
457
458
  | `--force` | Replace an existing config file, with `init` |
458
459
  | `--once` | Stop after the first handled issue |
@@ -498,20 +499,20 @@ The watch stops:
498
499
 
499
500
  ## Worker
500
501
 
501
- `next-issue serve` runs one worker for the machine. The
502
+ `next-issue serve` runs one worker for the repository it is run in. The
502
503
  [`@ravshansbox/pi-next-issue`](../pi-next-issue) extension starts it and talks
503
- to it. The worker:
504
-
505
- - listens with WebSocket on the socket file `~/.pi/agent/next-issue.sock`, or
506
- in the folder that `PI_CODING_AGENT_DIR` names. Only your user can open the
507
- file. It works on macOS and Linux;
508
- - holds `next-issue.lock` in the same folder, with its process id, so only one
509
- worker runs. A lock whose process is no longer alive is taken over;
510
- - keeps the repositories it knows in `next-issue.roots.json` in the same
511
- folder, so it still knows them after a restart;
512
- - runs at most one run per repository, and runs in several repositories at
513
- once. Each run is like `next-issue` without `--watch`, and the client says
514
- whether it merges;
504
+ to it. Each repository has its own worker, so the runs, restarts and upgrades
505
+ of one repository never affect another. The worker:
506
+
507
+ - listens with WebSocket on the socket file `.next-issue/worker.sock` in the
508
+ repository. Only your user can open the file. It works on macOS and Linux.
509
+ When the path of the socket file is too long for the system, about 103
510
+ bytes on macOS and 107 on Linux, it exits with an error that says so;
511
+ - holds `.next-issue/worker.lock` in the repository, with its process id, so
512
+ only one worker runs per repository. A lock whose process is no longer alive
513
+ is taken over;
514
+ - runs at most one run at a time. Each run is like `next-issue` without
515
+ `--watch`, and the client says whether it merges;
515
516
  - sends every client the progress of every run when it connects and on every
516
517
  change: for each running issue the issue, the step, the tool count and the
517
518
  last tool, and the handled issues;
@@ -521,7 +522,7 @@ to it. The worker:
521
522
  repository, the pull request when there is one, and the `needsHuman` and
522
523
  `skip` labels. It reads them
523
524
  from GitHub, so the list is the same after a restart. It sends them when a
524
- client connects, for each repository it knows, after each issue of a run
525
+ client connects, for its repository, after each issue of a run
525
526
  ends, and when a client sends `{ "type": "questions", "root": "<path>" }`.
526
527
  An issue that no longer has the label leaves the list. An issue with the
527
528
  label but no hand-over comment is in the list with an empty reason and url.
@@ -531,17 +532,34 @@ to it. The worker:
531
532
  `{ "type": "questions-error", "root": "<path>", "message": "<text>" }`;
532
533
  - stops a run, when a client asks, like a second `Ctrl-C`: each running issue
533
534
  after its current round, with the state kept;
534
- - stays when all clients close;
535
+ - exits, and removes its socket and lock files, when no run is going and no
536
+ client is connected, or when no client connects in the 30 seconds after it
537
+ starts;
538
+ - waits up to 5 seconds for the lock when a worker that is closing still
539
+ holds it;
535
540
  - closes once no run is going when a client with a newer version connects, so
536
541
  that the client can start the newer worker;
537
542
  - stops every agent and command and exits on `SIGTERM` or `SIGINT`, and
538
543
  removes its socket and lock files.
539
544
 
540
- Stop it with `next-issue stop`. This sends `SIGTERM` to the process in the
541
- lock file, so the worker stops at once and a run that is going resumes from
545
+ Stop the worker of a repository with `next-issue stop` in that repository.
546
+ Workers of other repositories carry on. This sends `SIGTERM` to the process in
547
+ the lock file, so the worker stops at once and a run that is going resumes from
542
548
  its saved state next time. When no worker is running, the command says so and
543
549
  exits with 0.
544
550
 
551
+ Versions before one worker per repository ran one worker for the machine,
552
+ with `next-issue.sock`, `next-issue.lock` and `next-issue.roots.json` in
553
+ `~/.pi/agent`, or in the folder that `PI_CODING_AGENT_DIR` names. That worker
554
+ does not stop by itself, `next-issue stop` does not reach it, and a run in it
555
+ can claim issues alongside the new worker of the same repository. Stop it once
556
+ after you upgrade:
557
+
558
+ ```sh
559
+ kill "$(cat ~/.pi/agent/next-issue.lock)"
560
+ rm -f ~/.pi/agent/next-issue.roots.json
561
+ ```
562
+
545
563
  ## Development
546
564
 
547
565
  ```bash
package/dist/agents.mjs CHANGED
@@ -9,7 +9,7 @@ const CODING = {
9
9
  tools: ['+grep', '+find', '+ls'],
10
10
  };
11
11
  export { CODING, REVIEWING };
12
- export function isOwnExtension(path) {
12
+ export function isOwnResource(path) {
13
13
  return path.split(sep).includes('pi-next-issue');
14
14
  }
15
15
  export async function runAgent(recorder, request) {
@@ -24,7 +24,11 @@ export async function runAgent(recorder, request) {
24
24
  agentDir,
25
25
  extensionsOverride: (base) => ({
26
26
  ...base,
27
- extensions: base.extensions.filter((extension) => !isOwnExtension(extension.resolvedPath)),
27
+ extensions: base.extensions.filter((extension) => !isOwnResource(extension.resolvedPath)),
28
+ }),
29
+ skillsOverride: (base) => ({
30
+ ...base,
31
+ skills: base.skills.filter((skill) => !isOwnResource(skill.filePath)),
28
32
  }),
29
33
  });
30
34
  await resourceLoader.reload();
package/dist/args.mjs CHANGED
@@ -2,8 +2,8 @@ import { CONFIG_FILE } from './config.mjs';
2
2
  export const USAGE = `next-issue [command] [options]
3
3
 
4
4
  init write ${CONFIG_FILE} with the defaults
5
- serve run the worker that pi extensions talk to
6
- stop stop the worker
5
+ serve run the worker of this repository that pi extensions talk to
6
+ stop stop the worker of this repository
7
7
  logs show the latest run log and follow it
8
8
 
9
9
  --once stop after the first handled issue
package/dist/cli.mjs CHANGED
@@ -6,7 +6,7 @@ import { listenForInterrupts } from './interrupt.mjs';
6
6
  import { followLog, newestRunLog } from './logs.mjs';
7
7
  import { message } from './observe.mjs';
8
8
  import { runRepo } from './run.mjs';
9
- import { serve, socketPaths, stopWorker } from './serve.mjs';
9
+ import { serve, stopWorker, workerPaths } from './serve.mjs';
10
10
  import { STATE_DIR } from './state.mjs';
11
11
  import { formatSummary } from './summary.mjs';
12
12
  function write(stream, text) {
@@ -20,17 +20,17 @@ async function main() {
20
20
  await write(process.stdout, USAGE);
21
21
  return 0;
22
22
  }
23
+ const root = await repoRoot(process.cwd());
23
24
  if (args.command === 'serve') {
24
- return serve();
25
+ return serve(root);
25
26
  }
26
27
  if (args.command === 'stop') {
27
- const pid = await stopWorker(socketPaths().lockPath);
28
+ const pid = await stopWorker(workerPaths(root).lockPath);
28
29
  await write(process.stdout, pid === undefined
29
30
  ? 'next-issue: no worker is running\n'
30
31
  : `next-issue: stopped the worker with process id ${pid}\n`);
31
32
  return 0;
32
33
  }
33
- const root = await repoRoot(process.cwd());
34
34
  if (args.command === 'init') {
35
35
  return init(root, args.force);
36
36
  }
package/dist/serve.mjs CHANGED
@@ -1,8 +1,7 @@
1
1
  import { rmSync } from 'node:fs';
2
- import { readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { createServer } from 'node:http';
4
- import { join } from 'node:path';
5
- import { getAgentDir } from '@earendil-works/pi-coding-agent';
4
+ import { dirname, join } from 'node:path';
6
5
  import { WebSocketServer } from 'ws';
7
6
  import { parseArgs } from './args.mjs';
8
7
  import { loadConfig } from './config.mjs';
@@ -14,14 +13,24 @@ import { applyEvent, startProgress } from './progress.mjs';
14
13
  import { PORTS } from './ports.mjs';
15
14
  import { isNewer, } from './protocol.mjs';
16
15
  import { runRepo } from './run.mjs';
16
+ import { STATE_DIR } from './state.mjs';
17
17
  import { packageVersion } from './version.mjs';
18
- export function socketPaths(agentDir = getAgentDir()) {
18
+ const LOCK_WAIT_MS = 5_000;
19
+ const LOCK_POLL_MS = 100;
20
+ const IDLE_MS = 30_000;
21
+ export function workerPaths(root) {
19
22
  return {
20
- socketPath: join(agentDir, 'next-issue.sock'),
21
- lockPath: join(agentDir, 'next-issue.lock'),
22
- rootsPath: join(agentDir, 'next-issue.roots.json'),
23
+ socketPath: join(root, STATE_DIR, 'worker.sock'),
24
+ lockPath: join(root, STATE_DIR, 'worker.lock'),
23
25
  };
24
26
  }
27
+ export function checkSocketPath(path, platform = process.platform) {
28
+ const size = platform === 'linux' ? 108 : 104;
29
+ const length = Buffer.byteLength(path);
30
+ if (length >= size) {
31
+ throw new Error(`the socket path ${path} is too long for this system: ${length} bytes, at most ${size - 1}`);
32
+ }
33
+ }
25
34
  function alive(pid) {
26
35
  try {
27
36
  process.kill(pid, 0);
@@ -42,8 +51,9 @@ export async function stopWorker(lockPath) {
42
51
  }
43
52
  return pid;
44
53
  }
45
- async function lock(path) {
46
- for (let attempt = 0; attempt < 2; attempt += 1) {
54
+ async function lock(path, waitMs) {
55
+ const deadline = Date.now() + waitMs;
56
+ for (;;) {
47
57
  try {
48
58
  await writeFile(path, `${process.pid}\n`, { flag: 'wx', mode: 0o600 });
49
59
  return true;
@@ -52,21 +62,16 @@ async function lock(path) {
52
62
  if (error.code !== 'EEXIST') {
53
63
  throw error;
54
64
  }
55
- if ((await holder(path)) !== undefined) {
56
- return false;
57
- }
65
+ }
66
+ if ((await holder(path)) === undefined) {
58
67
  await rm(path, { force: true });
59
68
  }
60
- }
61
- return false;
62
- }
63
- async function readRoots(path) {
64
- try {
65
- const value = JSON.parse(await readFile(path, 'utf8'));
66
- return new Set(Array.isArray(value) ? value.filter((root) => typeof root === 'string') : []);
67
- }
68
- catch {
69
- return new Set();
69
+ else if (Date.now() >= deadline) {
70
+ return false;
71
+ }
72
+ else {
73
+ await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
74
+ }
70
75
  }
71
76
  }
72
77
  export async function listQuestions(root) {
@@ -74,23 +79,13 @@ export async function listQuestions(root) {
74
79
  return waitingQuestions(await detectRepo(root, config.remote), config, PORTS);
75
80
  }
76
81
  export async function startWorker(options) {
77
- if (!(await lock(options.lockPath))) {
82
+ checkSocketPath(options.socketPath);
83
+ await mkdir(dirname(options.lockPath), { recursive: true });
84
+ if (!(await lock(options.lockPath, options.lockWaitMs ?? LOCK_WAIT_MS))) {
78
85
  return undefined;
79
86
  }
80
87
  await rm(options.socketPath, { force: true });
81
88
  const runs = new Map();
82
- const roots = await readRoots(options.rootsPath);
83
- let saving = Promise.resolve();
84
- const remember = (root) => {
85
- if (roots.has(root)) {
86
- return;
87
- }
88
- roots.add(root);
89
- const value = `${JSON.stringify([...roots], null, 2)}\n`;
90
- saving = saving
91
- .then(() => writeFile(options.rootsPath, value, { mode: 0o600 }))
92
- .catch(() => undefined);
93
- };
94
89
  const reads = new Map();
95
90
  const delivered = new WeakMap();
96
91
  let restart = false;
@@ -114,7 +109,6 @@ export async function startWorker(options) {
114
109
  }
115
110
  };
116
111
  const sendQuestions = async (root, targets, id) => {
117
- remember(root);
118
112
  const read = (reads.get(root) ?? 0) + 1;
119
113
  reads.set(root, read);
120
114
  let value;
@@ -138,19 +132,24 @@ export async function startWorker(options) {
138
132
  }
139
133
  };
140
134
  const busy = () => [...runs.values()].some((run) => run.progress.status === 'running' || run.progress.status === 'stopping');
141
- const close = async () => {
142
- for (const socket of sockets.clients) {
143
- socket.terminate();
144
- }
145
- await new Promise((resolve) => sockets.close(() => resolve()));
146
- await new Promise((resolve) => server.close(() => resolve()));
147
- await rm(options.socketPath, { force: true });
148
- await saving;
149
- await rm(options.lockPath, { force: true });
150
- finish();
135
+ let closing;
136
+ let idleTimer;
137
+ const close = () => {
138
+ closing ??= (async () => {
139
+ clearTimeout(idleTimer);
140
+ for (const socket of sockets.clients) {
141
+ socket.terminate();
142
+ }
143
+ await new Promise((resolve) => sockets.close(() => resolve()));
144
+ await new Promise((resolve) => server.close(() => resolve()));
145
+ await rm(options.socketPath, { force: true });
146
+ await rm(options.lockPath, { force: true });
147
+ finish();
148
+ })();
149
+ return closing;
151
150
  };
152
- const restartWhenIdle = () => {
153
- if (restart && !busy()) {
151
+ const closeWhenIdle = () => {
152
+ if ((restart || sockets.clients.size === 0) && !busy()) {
154
153
  void close();
155
154
  }
156
155
  };
@@ -166,7 +165,6 @@ export async function startWorker(options) {
166
165
  }
167
166
  const run = { progress: startProgress(), stop: false };
168
167
  runs.set(root, run);
169
- remember(root);
170
168
  const interrupts = {
171
169
  afterIssue: () => run.stop,
172
170
  afterRound: () => run.stop,
@@ -205,7 +203,7 @@ export async function startWorker(options) {
205
203
  : 'failed';
206
204
  update({ ...run.progress, status });
207
205
  }, (error) => update({ ...run.progress, status: 'failed', error: message(error) }))
208
- .finally(restartWhenIdle);
206
+ .finally(closeWhenIdle);
209
207
  };
210
208
  const stop = (root) => {
211
209
  const run = runs.get(root);
@@ -220,9 +218,8 @@ export async function startWorker(options) {
220
218
  sockets.on('connection', (socket) => {
221
219
  send(socket, { type: 'hello', version: options.version });
222
220
  send(socket, snapshot());
223
- for (const root of roots) {
224
- void sendQuestions(root, () => [socket]);
225
- }
221
+ void sendQuestions(options.root, () => [socket]);
222
+ socket.on('close', closeWhenIdle);
226
223
  socket.on('message', (data) => {
227
224
  let value;
228
225
  try {
@@ -234,7 +231,7 @@ export async function startWorker(options) {
234
231
  if (value.type === 'hello') {
235
232
  if (isNewer(value.version, options.version)) {
236
233
  restart = true;
237
- restartWhenIdle();
234
+ closeWhenIdle();
238
235
  }
239
236
  }
240
237
  else if (value.type === 'start') {
@@ -261,11 +258,13 @@ export async function startWorker(options) {
261
258
  process.umask(umask);
262
259
  }
263
260
  });
261
+ idleTimer = setTimeout(closeWhenIdle, options.idleMs ?? IDLE_MS);
264
262
  return { closed, close };
265
263
  }
266
- export async function serve() {
267
- const paths = socketPaths();
264
+ export async function serve(root) {
265
+ const paths = workerPaths(root);
268
266
  const worker = await startWorker({
267
+ root,
269
268
  ...paths,
270
269
  version: await packageVersion(),
271
270
  runRepo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "2.18.0",
3
+ "version": "2.20.0",
4
4
  "description": "Autonomous issue harness driven by the pi SDK",
5
5
  "keywords": [
6
6
  "github",