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 +36 -19
- package/dist/args.mjs +2 -2
- package/dist/cli.mjs +4 -4
- package/dist/serve.mjs +55 -56
- package/package.json +1 -1
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
|
|
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.
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
file. It works on macOS and Linux
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
542
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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(
|
|
21
|
-
lockPath: join(
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
65
|
+
}
|
|
66
|
+
if ((await holder(path)) === undefined) {
|
|
58
67
|
await rm(path, { force: true });
|
|
59
68
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|