next-issue 2.0.0 → 2.1.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 +28 -0
- package/dist/args.mjs +3 -2
- package/dist/cli.mjs +12 -97
- package/dist/observe.mjs +6 -3
- package/dist/progress.mjs +48 -0
- package/dist/run.mjs +109 -0
- package/dist/serve.mjs +213 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -356,6 +356,7 @@ that a label still holds back stays as it was.
|
|
|
356
356
|
| Argument | Effect |
|
|
357
357
|
| --- | --- |
|
|
358
358
|
| `init` | Write the config file with the defaults |
|
|
359
|
+
| `serve` | Run the worker that pi extensions talk to, see [Worker](#worker) |
|
|
359
360
|
| `--force` | Replace an existing config file, with `init` |
|
|
360
361
|
| `--once` | Stop after the first handled issue |
|
|
361
362
|
| `--max <n>` | Handle at most n issues |
|
|
@@ -397,6 +398,33 @@ The watch stops:
|
|
|
397
398
|
after `--merge`;
|
|
398
399
|
- when the open issues cannot be read.
|
|
399
400
|
|
|
401
|
+
## Worker
|
|
402
|
+
|
|
403
|
+
`next-issue serve` runs one worker for the machine. The
|
|
404
|
+
[`@ravshansbox/pi-next-issue`](../pi-next-issue) extension starts it and talks
|
|
405
|
+
to it. The worker:
|
|
406
|
+
|
|
407
|
+
- listens with WebSocket on the socket file `~/.pi/agent/next-issue.sock`, or
|
|
408
|
+
in the folder that `PI_CODING_AGENT_DIR` names. Only your user can open the
|
|
409
|
+
file. It works on macOS and Linux;
|
|
410
|
+
- holds `next-issue.lock` in the same folder, with its process id, so only one
|
|
411
|
+
worker runs. A lock whose process is no longer alive is taken over;
|
|
412
|
+
- runs at most one run per repository, and runs in several repositories at
|
|
413
|
+
once. Each run is like `next-issue` without `--watch`, and the client says
|
|
414
|
+
whether it merges;
|
|
415
|
+
- sends every client the progress of every run when it connects and on every
|
|
416
|
+
change: the issue, the step, the tool count, the last tool and the handled
|
|
417
|
+
issues;
|
|
418
|
+
- stops a run, when a client asks, like a second `Ctrl-C`: after the current
|
|
419
|
+
round, with the state kept;
|
|
420
|
+
- stays when all clients close;
|
|
421
|
+
- closes once no run is going when a client with a newer version connects, so
|
|
422
|
+
that the client can start the newer worker;
|
|
423
|
+
- stops every agent and command and exits on `SIGTERM` or `SIGINT`, and
|
|
424
|
+
removes its socket and lock files.
|
|
425
|
+
|
|
426
|
+
Stop it by hand with `kill $(cat ~/.pi/agent/next-issue.lock)`.
|
|
427
|
+
|
|
400
428
|
## Development
|
|
401
429
|
|
|
402
430
|
```bash
|
package/dist/args.mjs
CHANGED
|
@@ -2,6 +2,7 @@ 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
|
|
5
6
|
|
|
6
7
|
--once stop after the first handled issue
|
|
7
8
|
--max <n> handle at most n issues
|
|
@@ -29,10 +30,10 @@ export function parseArgs(argv) {
|
|
|
29
30
|
for (let index = 0; index < argv.length; index += 1) {
|
|
30
31
|
const arg = argv[index];
|
|
31
32
|
if (index === 0 && !arg.startsWith("-")) {
|
|
32
|
-
if (arg !== "init") {
|
|
33
|
+
if (arg !== "init" && arg !== "serve") {
|
|
33
34
|
throw new Error(`Unknown command: ${arg}`);
|
|
34
35
|
}
|
|
35
|
-
args.command =
|
|
36
|
+
args.command = arg;
|
|
36
37
|
}
|
|
37
38
|
else if (arg === "--force") {
|
|
38
39
|
args.force = true;
|
package/dist/cli.mjs
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { writeFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
4
2
|
import { parseArgs, USAGE } from "./args.mjs";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { detectRepo, ensureIgnored, repoRoot, WORKTREE_DIR } from "./git.mjs";
|
|
8
|
-
import { defaultBranch } from "./github.mjs";
|
|
3
|
+
import { writeDefaultConfig } from "./config.mjs";
|
|
4
|
+
import { ensureIgnored, repoRoot, WORKTREE_DIR } from "./git.mjs";
|
|
9
5
|
import { listenForInterrupts } from "./interrupt.mjs";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
6
|
+
import { message } from "./observe.mjs";
|
|
7
|
+
import { runRepo } from "./run.mjs";
|
|
8
|
+
import { serve } from "./serve.mjs";
|
|
13
9
|
import { STATE_DIR } from "./state.mjs";
|
|
14
10
|
import { formatSummary } from "./summary.mjs";
|
|
15
|
-
import { packageVersion } from "./version.mjs";
|
|
16
11
|
function write(stream, text) {
|
|
17
12
|
return new Promise((resolve, reject) => {
|
|
18
13
|
stream.write(text, (error) => (error === null || error === undefined ? resolve() : reject(error)));
|
|
@@ -24,33 +19,18 @@ async function main() {
|
|
|
24
19
|
await write(process.stdout, USAGE);
|
|
25
20
|
return 0;
|
|
26
21
|
}
|
|
22
|
+
if (args.command === "serve") {
|
|
23
|
+
return serve();
|
|
24
|
+
}
|
|
27
25
|
const root = await repoRoot(process.cwd());
|
|
28
26
|
if (args.command === "init") {
|
|
29
27
|
return init(root, args.force);
|
|
30
28
|
}
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const pruned = await pruneRuns(repo.root, config.keepRunDays);
|
|
36
|
-
if (pruned > 0) {
|
|
37
|
-
recorder.event("runs.pruned", { files: pruned, keepDays: config.keepRunDays }, "verbose");
|
|
38
|
-
}
|
|
39
|
-
setCommandObserver((record) => {
|
|
40
|
-
recorder.event(record.code === 0 ? "command" : "command.fail", {
|
|
41
|
-
cmd: `${record.command} ${record.args.join(" ")}`,
|
|
42
|
-
code: record.code,
|
|
43
|
-
ms: record.ms,
|
|
44
|
-
stderr: record.stderr.length > 0 ? record.stderr : undefined,
|
|
45
|
-
timedOut: record.timedOut ? true : undefined,
|
|
46
|
-
}, record.code === 0 ? "verbose" : "quiet");
|
|
29
|
+
const interrupts = listenForInterrupts((text) => void write(process.stderr, `${text}\n`));
|
|
30
|
+
return runRepo(root, args, {
|
|
31
|
+
interrupts,
|
|
32
|
+
print: (summary) => write(process.stdout, args.json ? `${JSON.stringify(summary, null, 2)}\n` : formatSummary(summary)),
|
|
47
33
|
});
|
|
48
|
-
try {
|
|
49
|
-
return await handle(args, config, repo, recorder);
|
|
50
|
-
}
|
|
51
|
-
finally {
|
|
52
|
-
await recorder.close();
|
|
53
|
-
}
|
|
54
34
|
}
|
|
55
35
|
async function init(root, force) {
|
|
56
36
|
const path = await writeDefaultConfig(root, force);
|
|
@@ -63,71 +43,6 @@ async function init(root, force) {
|
|
|
63
43
|
}
|
|
64
44
|
return 0;
|
|
65
45
|
}
|
|
66
|
-
async function handle(args, config, repo, recorder) {
|
|
67
|
-
const interrupts = listenForInterrupts((text) => void write(process.stderr, `${text}\n`));
|
|
68
|
-
const base = await defaultBranch(repo);
|
|
69
|
-
const context = {
|
|
70
|
-
repo,
|
|
71
|
-
config,
|
|
72
|
-
base,
|
|
73
|
-
reset: args.reset,
|
|
74
|
-
merge: args.merge,
|
|
75
|
-
stopAfterRound: interrupts.afterRound,
|
|
76
|
-
recorder,
|
|
77
|
-
ports: PORTS,
|
|
78
|
-
};
|
|
79
|
-
recorder.event("run.start", {
|
|
80
|
-
version: await packageVersion(),
|
|
81
|
-
repo: `${repo.owner}/${repo.name}`,
|
|
82
|
-
base,
|
|
83
|
-
log: recorder.logFile,
|
|
84
|
-
}, "quiet");
|
|
85
|
-
const loop = {
|
|
86
|
-
issues: async () => {
|
|
87
|
-
const issues = await pendingIssues(context);
|
|
88
|
-
recorder.event("issues", { open: issues.length }, "quiet");
|
|
89
|
-
return issues;
|
|
90
|
-
},
|
|
91
|
-
process: (issue) => processIssue(context, issue),
|
|
92
|
-
stop: interrupts.afterIssue,
|
|
93
|
-
};
|
|
94
|
-
const print = (summary) => write(process.stdout, args.json ? `${JSON.stringify(summary, null, 2)}\n` : formatSummary(summary));
|
|
95
|
-
let passStart = recorder.summary();
|
|
96
|
-
const { reports, stoppedAt, error } = args.watch
|
|
97
|
-
? await watchIssues(args, {
|
|
98
|
-
...loop,
|
|
99
|
-
wait: (minutes) => {
|
|
100
|
-
recorder.event("watch.wait", { minutes }, "quiet");
|
|
101
|
-
passStart = recorder.summary();
|
|
102
|
-
return interrupts.sleep(minutes * 60_000);
|
|
103
|
-
},
|
|
104
|
-
passEnd: (issues) => print({ ...recorder.summary(passStart), issues }),
|
|
105
|
-
})
|
|
106
|
-
: await runIssues(args, loop);
|
|
107
|
-
if (stoppedAt !== undefined) {
|
|
108
|
-
recorder.event("run.stop", { issue: stoppedAt }, "quiet");
|
|
109
|
-
}
|
|
110
|
-
if (error !== undefined) {
|
|
111
|
-
recorder.event("issues.fail", { error: message(error) }, "quiet");
|
|
112
|
-
}
|
|
113
|
-
recorder.event("run.end", {
|
|
114
|
-
done: count(reports, "done"),
|
|
115
|
-
needsHuman: count(reports, "needs-human"),
|
|
116
|
-
failed: count(reports, "error"),
|
|
117
|
-
skipped: count(reports, "skipped"),
|
|
118
|
-
tokens: recorder.summary().total.total,
|
|
119
|
-
}, "quiet");
|
|
120
|
-
const summary = { ...recorder.summary(), issues: reports };
|
|
121
|
-
const summaryFile = join(repo.root, STATE_DIR, "runs", `${recorder.runId}.summary.json`);
|
|
122
|
-
await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
|
|
123
|
-
if (!args.watch) {
|
|
124
|
-
await print(summary);
|
|
125
|
-
}
|
|
126
|
-
return error === undefined ? exitCode(reports) : 1;
|
|
127
|
-
}
|
|
128
|
-
function count(reports, outcome) {
|
|
129
|
-
return reports.filter((report) => report.outcome === outcome).length;
|
|
130
|
-
}
|
|
131
46
|
main().then((code) => process.exit(code), async (error) => {
|
|
132
47
|
await write(process.stderr, `next-issue failed: ${message(error)}\n`);
|
|
133
48
|
process.exit(1);
|
package/dist/observe.mjs
CHANGED
|
@@ -28,13 +28,16 @@ class Sink {
|
|
|
28
28
|
stream;
|
|
29
29
|
level;
|
|
30
30
|
file;
|
|
31
|
-
|
|
31
|
+
listener;
|
|
32
|
+
constructor(stream, level, file, listener) {
|
|
32
33
|
this.stream = stream;
|
|
33
34
|
this.level = level;
|
|
34
35
|
this.file = file;
|
|
36
|
+
this.listener = listener;
|
|
35
37
|
}
|
|
36
38
|
write(record) {
|
|
37
39
|
this.stream.write(`${JSON.stringify(record)}\n`);
|
|
40
|
+
this.listener?.(record);
|
|
38
41
|
}
|
|
39
42
|
count(kind) {
|
|
40
43
|
this.counts.set(kind, (this.counts.get(kind) ?? 0) + 1);
|
|
@@ -59,12 +62,12 @@ export class Recorder {
|
|
|
59
62
|
this.base = base;
|
|
60
63
|
this.runId = runId;
|
|
61
64
|
}
|
|
62
|
-
static async create(root, level) {
|
|
65
|
+
static async create(root, level, listener) {
|
|
63
66
|
const runId = new Date().toISOString().replace(/[:.]/g, "-");
|
|
64
67
|
const dir = join(root, STATE_DIR, "runs");
|
|
65
68
|
await mkdir(dir, { recursive: true });
|
|
66
69
|
const file = join(dir, `${runId}.jsonl`);
|
|
67
|
-
const sink = new Sink(createWriteStream(file, { flags: "a" }), level, file);
|
|
70
|
+
const sink = new Sink(createWriteStream(file, { flags: "a" }), level, file, listener);
|
|
68
71
|
return new Recorder(sink, {}, runId);
|
|
69
72
|
}
|
|
70
73
|
get logFile() {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const TOOL_LINE_MAX = 80;
|
|
2
|
+
const NOT_STAGES = new Set(["issue.start", "agent.start", "run.start"]);
|
|
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 };
|
|
9
|
+
}
|
|
10
|
+
function toolLine(record) {
|
|
11
|
+
const tool = String(record["tool"]);
|
|
12
|
+
const command = record["command"];
|
|
13
|
+
const line = typeof command === "string" ? `${tool} ${command.split("\n")[0]}` : tool;
|
|
14
|
+
return line.length > TOOL_LINE_MAX ? `${line.slice(0, TOOL_LINE_MAX - 1)}…` : line;
|
|
15
|
+
}
|
|
16
|
+
export function applyEvent(progress, record) {
|
|
17
|
+
const kind = String(record["kind"]);
|
|
18
|
+
if (kind === "issue.start") {
|
|
19
|
+
const next = {
|
|
20
|
+
...idle(progress),
|
|
21
|
+
issue: Number(record["issue"]),
|
|
22
|
+
issueStartedAt: Date.parse(String(record["ts"])),
|
|
23
|
+
};
|
|
24
|
+
if (typeof record["title"] === "string") {
|
|
25
|
+
next.title = record["title"];
|
|
26
|
+
}
|
|
27
|
+
return next;
|
|
28
|
+
}
|
|
29
|
+
if (kind === "tool") {
|
|
30
|
+
return { ...progress, tools: progress.tools + 1, lastTool: toolLine(record) };
|
|
31
|
+
}
|
|
32
|
+
if (kind === "issue.end" || kind === "issue.error") {
|
|
33
|
+
const outcome = kind === "issue.error" ? "error" : record["outcome"];
|
|
34
|
+
const handled = outcome === "done" || outcome === "needs-human" || outcome === "error"
|
|
35
|
+
? [...progress.handled, { issue: Number(record["issue"]), outcome }]
|
|
36
|
+
: progress.handled;
|
|
37
|
+
return { ...idle(progress), handled };
|
|
38
|
+
}
|
|
39
|
+
if (kind === "issues.fail") {
|
|
40
|
+
return { ...progress, error: String(record["error"]) };
|
|
41
|
+
}
|
|
42
|
+
if (kind.endsWith(".start") && !NOT_STAGES.has(kind) && progress.issue !== undefined) {
|
|
43
|
+
const stage = kind.slice(0, -".start".length);
|
|
44
|
+
const round = record["round"];
|
|
45
|
+
return { ...progress, stage: typeof round === "number" ? `${stage} ${round}` : stage };
|
|
46
|
+
}
|
|
47
|
+
return progress;
|
|
48
|
+
}
|
package/dist/run.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, sep } from "node:path";
|
|
3
|
+
import { loadConfig } from "./config.mjs";
|
|
4
|
+
import { setCommandObserver, setDefaultTimeout } from "./exec.mjs";
|
|
5
|
+
import { detectRepo } from "./git.mjs";
|
|
6
|
+
import { defaultBranch } from "./github.mjs";
|
|
7
|
+
import { exitCode, runIssues, watchIssues } from "./loop.mjs";
|
|
8
|
+
import { message, pruneRuns, Recorder } from "./observe.mjs";
|
|
9
|
+
import { pendingIssues, PORTS, processIssue } from "./pipeline.mjs";
|
|
10
|
+
import { STATE_DIR } from "./state.mjs";
|
|
11
|
+
import { packageVersion } from "./version.mjs";
|
|
12
|
+
const recorders = new Map();
|
|
13
|
+
setCommandObserver((record) => {
|
|
14
|
+
for (const [root, recorder] of recorders) {
|
|
15
|
+
if (record.cwd !== undefined && record.cwd !== root && !record.cwd.startsWith(root + sep)) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
recorder.event(record.code === 0 ? "command" : "command.fail", {
|
|
19
|
+
cmd: `${record.command} ${record.args.join(" ")}`,
|
|
20
|
+
code: record.code,
|
|
21
|
+
ms: record.ms,
|
|
22
|
+
stderr: record.stderr.length > 0 ? record.stderr : undefined,
|
|
23
|
+
timedOut: record.timedOut ? true : undefined,
|
|
24
|
+
}, record.code === 0 ? "verbose" : "quiet");
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
export async function runRepo(root, args, controls) {
|
|
28
|
+
const config = await loadConfig(root);
|
|
29
|
+
setDefaultTimeout(config.commandTimeoutMinutes * 60_000);
|
|
30
|
+
const repo = await detectRepo(root, config.remote);
|
|
31
|
+
const recorder = await Recorder.create(repo.root, args.level, controls.onEvent);
|
|
32
|
+
const pruned = await pruneRuns(repo.root, config.keepRunDays);
|
|
33
|
+
if (pruned > 0) {
|
|
34
|
+
recorder.event("runs.pruned", { files: pruned, keepDays: config.keepRunDays }, "verbose");
|
|
35
|
+
}
|
|
36
|
+
recorders.set(repo.root, recorder);
|
|
37
|
+
try {
|
|
38
|
+
return await handle(args, config, repo, recorder, controls);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
recorders.delete(repo.root);
|
|
42
|
+
await recorder.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function handle(args, config, repo, recorder, controls) {
|
|
46
|
+
const { interrupts } = controls;
|
|
47
|
+
const base = await defaultBranch(repo);
|
|
48
|
+
const context = {
|
|
49
|
+
repo,
|
|
50
|
+
config,
|
|
51
|
+
base,
|
|
52
|
+
reset: args.reset,
|
|
53
|
+
merge: args.merge,
|
|
54
|
+
stopAfterRound: interrupts.afterRound,
|
|
55
|
+
recorder,
|
|
56
|
+
ports: PORTS,
|
|
57
|
+
};
|
|
58
|
+
recorder.event("run.start", {
|
|
59
|
+
version: await packageVersion(),
|
|
60
|
+
repo: `${repo.owner}/${repo.name}`,
|
|
61
|
+
base,
|
|
62
|
+
log: recorder.logFile,
|
|
63
|
+
}, "quiet");
|
|
64
|
+
const loop = {
|
|
65
|
+
issues: async () => {
|
|
66
|
+
const issues = await pendingIssues(context);
|
|
67
|
+
recorder.event("issues", { open: issues.length }, "quiet");
|
|
68
|
+
return issues;
|
|
69
|
+
},
|
|
70
|
+
process: (issue) => processIssue(context, issue),
|
|
71
|
+
stop: interrupts.afterIssue,
|
|
72
|
+
};
|
|
73
|
+
const print = controls.print ?? (async () => undefined);
|
|
74
|
+
let passStart = recorder.summary();
|
|
75
|
+
const { reports, stoppedAt, error } = args.watch
|
|
76
|
+
? await watchIssues(args, {
|
|
77
|
+
...loop,
|
|
78
|
+
wait: (minutes) => {
|
|
79
|
+
recorder.event("watch.wait", { minutes }, "quiet");
|
|
80
|
+
passStart = recorder.summary();
|
|
81
|
+
return interrupts.sleep(minutes * 60_000);
|
|
82
|
+
},
|
|
83
|
+
passEnd: (issues) => print({ ...recorder.summary(passStart), issues }),
|
|
84
|
+
})
|
|
85
|
+
: await runIssues(args, loop);
|
|
86
|
+
if (stoppedAt !== undefined) {
|
|
87
|
+
recorder.event("run.stop", { issue: stoppedAt }, "quiet");
|
|
88
|
+
}
|
|
89
|
+
if (error !== undefined) {
|
|
90
|
+
recorder.event("issues.fail", { error: message(error) }, "quiet");
|
|
91
|
+
}
|
|
92
|
+
recorder.event("run.end", {
|
|
93
|
+
done: count(reports, "done"),
|
|
94
|
+
needsHuman: count(reports, "needs-human"),
|
|
95
|
+
failed: count(reports, "error"),
|
|
96
|
+
skipped: count(reports, "skipped"),
|
|
97
|
+
tokens: recorder.summary().total.total,
|
|
98
|
+
}, "quiet");
|
|
99
|
+
const summary = { ...recorder.summary(), issues: reports };
|
|
100
|
+
const summaryFile = join(repo.root, STATE_DIR, "runs", `${recorder.runId}.summary.json`);
|
|
101
|
+
await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
|
|
102
|
+
if (!args.watch) {
|
|
103
|
+
await print(summary);
|
|
104
|
+
}
|
|
105
|
+
return error === undefined ? exitCode(reports) : 1;
|
|
106
|
+
}
|
|
107
|
+
function count(reports, outcome) {
|
|
108
|
+
return reports.filter((report) => report.outcome === outcome).length;
|
|
109
|
+
}
|
package/dist/serve.mjs
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
import { chmod, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { WebSocketServer } from "ws";
|
|
7
|
+
import { parseArgs } from "./args.mjs";
|
|
8
|
+
import { stopChildren } from "./exec.mjs";
|
|
9
|
+
import { message } from "./observe.mjs";
|
|
10
|
+
import { applyEvent, startProgress } from "./progress.mjs";
|
|
11
|
+
import { runRepo } from "./run.mjs";
|
|
12
|
+
import { packageVersion } from "./version.mjs";
|
|
13
|
+
export function socketPaths(agentDir = getAgentDir()) {
|
|
14
|
+
return {
|
|
15
|
+
socketPath: join(agentDir, "next-issue.sock"),
|
|
16
|
+
lockPath: join(agentDir, "next-issue.lock"),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function isNewer(candidate, current) {
|
|
20
|
+
const left = candidate.split(".").map(Number);
|
|
21
|
+
const right = current.split(".").map(Number);
|
|
22
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
23
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
24
|
+
if (difference !== 0) {
|
|
25
|
+
return difference > 0;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
function alive(pid) {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return error.code === "EPERM";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function lock(path) {
|
|
40
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
41
|
+
try {
|
|
42
|
+
await writeFile(path, `${process.pid}\n`, { flag: "wx", mode: 0o600 });
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code !== "EEXIST") {
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
const pid = Number((await readFile(path, "utf8").catch(() => "")).trim());
|
|
50
|
+
if (pid > 0 && alive(pid)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
await rm(path, { force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
export async function startWorker(options) {
|
|
59
|
+
if (!(await lock(options.lockPath))) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
await rm(options.socketPath, { force: true });
|
|
63
|
+
const runs = new Map();
|
|
64
|
+
let restart = false;
|
|
65
|
+
let finish = () => undefined;
|
|
66
|
+
const closed = new Promise((resolve) => {
|
|
67
|
+
finish = resolve;
|
|
68
|
+
});
|
|
69
|
+
const server = createServer();
|
|
70
|
+
const sockets = new WebSocketServer({ server });
|
|
71
|
+
const snapshot = () => ({
|
|
72
|
+
type: "runs",
|
|
73
|
+
runs: Object.fromEntries([...runs].map(([root, run]) => [root, run.progress])),
|
|
74
|
+
});
|
|
75
|
+
const send = (socket, value) => {
|
|
76
|
+
socket.send(JSON.stringify(value));
|
|
77
|
+
};
|
|
78
|
+
const broadcast = () => {
|
|
79
|
+
const value = snapshot();
|
|
80
|
+
for (const socket of sockets.clients) {
|
|
81
|
+
send(socket, value);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const busy = () => [...runs.values()].some((run) => run.progress.status === "running" || run.progress.status === "stopping");
|
|
85
|
+
const close = async () => {
|
|
86
|
+
for (const socket of sockets.clients) {
|
|
87
|
+
socket.terminate();
|
|
88
|
+
}
|
|
89
|
+
await new Promise((resolve) => sockets.close(() => resolve()));
|
|
90
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
91
|
+
await rm(options.socketPath, { force: true });
|
|
92
|
+
await rm(options.lockPath, { force: true });
|
|
93
|
+
finish();
|
|
94
|
+
};
|
|
95
|
+
const restartWhenIdle = () => {
|
|
96
|
+
if (restart && !busy()) {
|
|
97
|
+
void close();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const start = (socket, root, merge) => {
|
|
101
|
+
const current = runs.get(root);
|
|
102
|
+
if (current?.progress.status === "running" || current?.progress.status === "stopping") {
|
|
103
|
+
send(socket, { type: "error", root, message: "a run is going in this repository already" });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (restart) {
|
|
107
|
+
send(socket, { type: "error", root, message: "the worker is restarting, try again soon" });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const run = { progress: startProgress(), stop: false };
|
|
111
|
+
runs.set(root, run);
|
|
112
|
+
const interrupts = {
|
|
113
|
+
afterIssue: () => run.stop,
|
|
114
|
+
afterRound: () => run.stop,
|
|
115
|
+
sleep: (ms) => new Promise((resolve) => {
|
|
116
|
+
if (run.stop) {
|
|
117
|
+
resolve(false);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const timer = setTimeout(() => resolve(true), ms);
|
|
121
|
+
run.wake = () => {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
resolve(false);
|
|
124
|
+
};
|
|
125
|
+
}),
|
|
126
|
+
};
|
|
127
|
+
const update = (next) => {
|
|
128
|
+
run.progress = next;
|
|
129
|
+
broadcast();
|
|
130
|
+
};
|
|
131
|
+
broadcast();
|
|
132
|
+
options
|
|
133
|
+
.runRepo(root, { ...parseArgs([]), merge }, {
|
|
134
|
+
interrupts,
|
|
135
|
+
onEvent: (record) => update(applyEvent(run.progress, record)),
|
|
136
|
+
})
|
|
137
|
+
.then(() => {
|
|
138
|
+
const status = run.stop ? "stopped" : run.progress.error === undefined ? "finished" : "failed";
|
|
139
|
+
update({ ...run.progress, status });
|
|
140
|
+
}, (error) => update({ ...run.progress, status: "failed", error: message(error) }))
|
|
141
|
+
.finally(restartWhenIdle);
|
|
142
|
+
};
|
|
143
|
+
const stop = (root) => {
|
|
144
|
+
const run = runs.get(root);
|
|
145
|
+
if (run === undefined || run.progress.status !== "running") {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
run.stop = true;
|
|
149
|
+
run.wake?.();
|
|
150
|
+
run.progress = { ...run.progress, status: "stopping" };
|
|
151
|
+
broadcast();
|
|
152
|
+
};
|
|
153
|
+
sockets.on("connection", (socket) => {
|
|
154
|
+
send(socket, { type: "hello", version: options.version });
|
|
155
|
+
send(socket, snapshot());
|
|
156
|
+
socket.on("message", (data) => {
|
|
157
|
+
let value;
|
|
158
|
+
try {
|
|
159
|
+
value = JSON.parse(String(data));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (value.type === "hello") {
|
|
165
|
+
if (isNewer(value.version, options.version)) {
|
|
166
|
+
restart = true;
|
|
167
|
+
restartWhenIdle();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else if (value.type === "start") {
|
|
171
|
+
start(socket, value.root, value.merge === true);
|
|
172
|
+
}
|
|
173
|
+
else if (value.type === "stop") {
|
|
174
|
+
stop(value.root);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
await new Promise((resolve, reject) => {
|
|
179
|
+
server.once("error", reject);
|
|
180
|
+
server.listen(options.socketPath, () => {
|
|
181
|
+
server.off("error", reject);
|
|
182
|
+
resolve();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
await chmod(options.socketPath, 0o600);
|
|
186
|
+
return { closed, close };
|
|
187
|
+
}
|
|
188
|
+
export async function serve() {
|
|
189
|
+
const paths = socketPaths();
|
|
190
|
+
const worker = await startWorker({
|
|
191
|
+
...paths,
|
|
192
|
+
version: await packageVersion(),
|
|
193
|
+
runRepo,
|
|
194
|
+
});
|
|
195
|
+
if (worker === undefined) {
|
|
196
|
+
process.stderr.write("next-issue: another worker is running\n");
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
const end = (signal) => {
|
|
200
|
+
process.off("SIGTERM", onTerminate);
|
|
201
|
+
process.off("SIGINT", onInterrupt);
|
|
202
|
+
rmSync(paths.socketPath, { force: true });
|
|
203
|
+
rmSync(paths.lockPath, { force: true });
|
|
204
|
+
stopChildren(signal);
|
|
205
|
+
};
|
|
206
|
+
const onTerminate = () => end("SIGTERM");
|
|
207
|
+
const onInterrupt = () => end("SIGINT");
|
|
208
|
+
process.on("SIGTERM", onTerminate);
|
|
209
|
+
process.on("SIGINT", onInterrupt);
|
|
210
|
+
process.stderr.write(`next-issue: worker listening on ${paths.socketPath}\n`);
|
|
211
|
+
await worker.closed;
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "next-issue",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Autonomous issue harness driven by the pi SDK",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github",
|
|
@@ -39,10 +39,12 @@
|
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@earendil-works/pi-ai": "^1.5.0",
|
|
41
41
|
"@types/node": "^26.6.6",
|
|
42
|
+
"@types/ws": "^8.18.2",
|
|
42
43
|
"typescript": "^7.0.2"
|
|
43
44
|
},
|
|
44
45
|
"dependencies": {
|
|
45
46
|
"@earendil-works/pi-coding-agent": "^1.5.0",
|
|
46
|
-
"typebox": "^1.4.3"
|
|
47
|
+
"typebox": "^1.4.3",
|
|
48
|
+
"ws": "^8.22.1"
|
|
47
49
|
}
|
|
48
50
|
}
|