next-issue 2.19.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
@@ -452,8 +452,8 @@ state of an issue that a label still holds back stays as it was.
452
452
  | Argument | Effect |
453
453
  | ----------- | ----------------------------------------------------------------------------------------------------------------------------- |
454
454
  | `init` | Write the config file with the defaults |
455
- | `serve` | Run the worker that pi extensions talk to, see [Worker](#worker) |
456
- | `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) |
457
457
  | `logs` | Show the latest run log and follow it, see [Observability](#observability) |
458
458
  | `--force` | Replace an existing config file, with `init` |
459
459
  | `--once` | Stop after the first handled issue |
@@ -499,20 +499,20 @@ The watch stops:
499
499
 
500
500
  ## Worker
501
501
 
502
- `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
503
503
  [`@ravshansbox/pi-next-issue`](../pi-next-issue) extension starts it and talks
504
- to it. The worker:
505
-
506
- - listens with WebSocket on the socket file `~/.pi/agent/next-issue.sock`, or
507
- in the folder that `PI_CODING_AGENT_DIR` names. Only your user can open the
508
- file. It works on macOS and Linux;
509
- - holds `next-issue.lock` in the same folder, with its process id, so only one
510
- worker runs. A lock whose process is no longer alive is taken over;
511
- - keeps the repositories it knows in `next-issue.roots.json` in the same
512
- folder, so it still knows them after a restart;
513
- - runs at most one run per repository, and runs in several repositories at
514
- once. Each run is like `next-issue` without `--watch`, and the client says
515
- 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;
516
516
  - sends every client the progress of every run when it connects and on every
517
517
  change: for each running issue the issue, the step, the tool count and the
518
518
  last tool, and the handled issues;
@@ -522,7 +522,7 @@ to it. The worker:
522
522
  repository, the pull request when there is one, and the `needsHuman` and
523
523
  `skip` labels. It reads them
524
524
  from GitHub, so the list is the same after a restart. It sends them when a
525
- 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
526
526
  ends, and when a client sends `{ "type": "questions", "root": "<path>" }`.
527
527
  An issue that no longer has the label leaves the list. An issue with the
528
528
  label but no hand-over comment is in the list with an empty reason and url.
@@ -532,17 +532,34 @@ to it. The worker:
532
532
  `{ "type": "questions-error", "root": "<path>", "message": "<text>" }`;
533
533
  - stops a run, when a client asks, like a second `Ctrl-C`: each running issue
534
534
  after its current round, with the state kept;
535
- - 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;
536
540
  - closes once no run is going when a client with a newer version connects, so
537
541
  that the client can start the newer worker;
538
542
  - stops every agent and command and exits on `SIGTERM` or `SIGINT`, and
539
543
  removes its socket and lock files.
540
544
 
541
- Stop it with `next-issue stop`. This sends `SIGTERM` to the process in the
542
- 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
543
548
  its saved state next time. When no worker is running, the command says so and
544
549
  exits with 0.
545
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
+
546
563
  ## Development
547
564
 
548
565
  ```bash
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.19.0",
3
+ "version": "2.20.0",
4
4
  "description": "Autonomous issue harness driven by the pi SDK",
5
5
  "keywords": [
6
6
  "github",