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 +43 -25
- package/dist/agents.mjs +6 -2
- 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
|
@@ -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
|
|
308
|
-
extension
|
|
309
|
-
settings, the `grep`, `find` and `ls` tools
|
|
310
|
-
extensions. The reviewer gets all of them except
|
|
311
|
-
agents run with `GIT_OPTIONAL_LOCKS=0`, so a
|
|
312
|
-
an extension does not hold the index lock
|
|
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
|
|
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.
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
file. It works on macOS and Linux
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
541
|
-
|
|
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
|
|
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) => !
|
|
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,
|
|
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,
|