next-issue 1.1.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 +43 -13
- package/dist/agents.mjs +118 -157
- package/dist/args.mjs +3 -2
- package/dist/cli.mjs +12 -97
- package/dist/exec.mjs +9 -0
- package/dist/observe.mjs +6 -3
- package/dist/progress.mjs +48 -0
- package/dist/prompts.mjs +1 -1
- package/dist/run.mjs +109 -0
- package/dist/serve.mjs +213 -0
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# next-issue
|
|
2
2
|
|
|
3
3
|
A small harness that works through the open issues of a GitHub repository with
|
|
4
|
-
the
|
|
4
|
+
the [pi](https://github.com/earendil-works/pi) SDK. Run it inside a git repository.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
7
|
npx -y next-issue@latest
|
|
@@ -54,7 +54,7 @@ and exits with code 1. For each issue:
|
|
|
54
54
|
reviewer reads files and runs commands in the worktree, so it can prove a
|
|
55
55
|
finding with the tests or the type check. The harness then throws away every
|
|
56
56
|
change that the reviewer left, so no such change can reach a later commit.
|
|
57
|
-
The reviewer returns a
|
|
57
|
+
The reviewer returns its verdict with a `submit_result` tool: a summary and one entry per
|
|
58
58
|
finding, each rated `blocking` or `minor`. The findings are the only ground
|
|
59
59
|
truth. The reviewer gives no approve or reject field, because such a field
|
|
60
60
|
can contradict the findings. The harness puts the result on the pull
|
|
@@ -146,14 +146,14 @@ Press `Ctrl-C` to stop the run step by step:
|
|
|
146
146
|
one checks round (wait, then fix) or one review round (review, then fix). The
|
|
147
147
|
issue ends as `stopped`. Its state, worktree and labels stay, so the next run
|
|
148
148
|
carries on from that point.
|
|
149
|
-
3. The third press kills the command that runs and every
|
|
150
|
-
dies in the middle of a step. The saved state lets the next run continue the
|
|
149
|
+
3. The third press stops every agent, kills the command that runs and every
|
|
150
|
+
child of it, so the run dies in the middle of a step. The saved state lets the next run continue the
|
|
151
151
|
issue, but the issue keeps `status:in-progress` and the run writes no
|
|
152
152
|
summary.
|
|
153
153
|
|
|
154
154
|
Each of the first two presses prints a line that says what happens next. The
|
|
155
|
-
agents
|
|
156
|
-
the harness.
|
|
155
|
+
agents run inside the harness, and their commands and the harness commands run
|
|
156
|
+
in a process group of their own, so a press reaches only the harness.
|
|
157
157
|
|
|
158
158
|
A status label that the harness puts on the issue goes on the pull request too,
|
|
159
159
|
but only if the pull request exists at that moment. The `status:in-progress`
|
|
@@ -202,10 +202,9 @@ Recorded for each step: `claim`, `comments`, `worktree`, `setup`, `implement`,
|
|
|
202
202
|
`push`, `pull-request`, `checks`, `review` and `fix`, each with a `.start`,
|
|
203
203
|
`.end` or `.error` event and a duration in `ms`. Every `git` and `gh` command
|
|
204
204
|
is recorded with its exit code and duration. A `tool` event holds the tool
|
|
205
|
-
name, and for a `
|
|
205
|
+
name, and for a `bash` tool the command too. Each agent call adds a `usage`
|
|
206
206
|
event with the tokens, the cost estimate in dollars, the turn count, the
|
|
207
|
-
tool-call count, the model and the session id
|
|
208
|
-
again and read the full transcript.
|
|
207
|
+
tool-call count, the model and the session id.
|
|
209
208
|
|
|
210
209
|
```bash
|
|
211
210
|
jq -r 'select(.kind=="usage") | [.issue,.role,.turns,.total] | @tsv' \
|
|
@@ -224,8 +223,11 @@ Three levels of console output:
|
|
|
224
223
|
|
|
225
224
|
- Node 24 or later
|
|
226
225
|
- `gh`, authenticated with write access to the repository
|
|
227
|
-
-
|
|
228
|
-
|
|
226
|
+
- pi, logged in to a model provider. The agents use the model, the thinking
|
|
227
|
+
level and the extensions from your pi settings, except the `pi-next-issue`
|
|
228
|
+
extension. The implementer and the fixer get the `read`, `bash`, `edit`,
|
|
229
|
+
`write`, `grep`, `find` and `ls` tools, and the reviewer gets all of them
|
|
230
|
+
except `edit` and `write`.
|
|
229
231
|
|
|
230
232
|
## Configuration
|
|
231
233
|
|
|
@@ -297,7 +299,7 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
297
299
|
| `keepRunDays` | `7` | The number of days to keep the run files; `0` keeps them all |
|
|
298
300
|
| `draftPullRequest` | `true` | Open the pull request as a draft, until the review approves |
|
|
299
301
|
| `setupCommand` | from the lock file | A shell command to run in a new worktree, before the implementer |
|
|
300
|
-
| `models` | `null` per role | The model per agent role
|
|
302
|
+
| `models` | `null` per role | The pi model per agent role, as `provider/model`: `implementer`, `reviewer`, `fixer` |
|
|
301
303
|
| `labels` | see above | The names of the labels that the harness reads and sets |
|
|
302
304
|
|
|
303
305
|
An empty `labels.ready` turns the ready requirement off. The harness then
|
|
@@ -307,7 +309,7 @@ The log gives the reason for a skipped issue as `stop-label`, `not-ready`,
|
|
|
307
309
|
`in-flight` or `blocked`, the last one with the open blockers.
|
|
308
310
|
|
|
309
311
|
A role set to `null`, or with no entry in `models`, uses the default model of
|
|
310
|
-
|
|
312
|
+
your pi settings.
|
|
311
313
|
|
|
312
314
|
Without a `setupCommand`, the harness looks in the new worktree for a lock file
|
|
313
315
|
and runs the install command that goes with it:
|
|
@@ -354,6 +356,7 @@ that a label still holds back stays as it was.
|
|
|
354
356
|
| Argument | Effect |
|
|
355
357
|
| --- | --- |
|
|
356
358
|
| `init` | Write the config file with the defaults |
|
|
359
|
+
| `serve` | Run the worker that pi extensions talk to, see [Worker](#worker) |
|
|
357
360
|
| `--force` | Replace an existing config file, with `init` |
|
|
358
361
|
| `--once` | Stop after the first handled issue |
|
|
359
362
|
| `--max <n>` | Handle at most n issues |
|
|
@@ -395,6 +398,33 @@ The watch stops:
|
|
|
395
398
|
after `--merge`;
|
|
396
399
|
- when the open issues cannot be read.
|
|
397
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
|
+
|
|
398
428
|
## Development
|
|
399
429
|
|
|
400
430
|
```bash
|
package/dist/agents.mjs
CHANGED
|
@@ -1,186 +1,147 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
-
import { track } from "./exec.mjs";
|
|
1
|
+
import { join, sep } from "node:path";
|
|
2
|
+
import { createAgentSession, DefaultResourceLoader, defineTool, getAgentDir, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { onStop } from "./exec.mjs";
|
|
6
4
|
import { emptyUsage } from "./observe.mjs";
|
|
7
5
|
const REVIEWING = {
|
|
8
|
-
tools: ["
|
|
9
|
-
permissionMode: "dontAsk",
|
|
6
|
+
tools: ["read", "bash", "grep", "find", "ls"],
|
|
10
7
|
};
|
|
11
8
|
const CODING = {
|
|
12
|
-
tools: ["
|
|
13
|
-
permissionMode: "bypassPermissions",
|
|
14
|
-
allowDangerouslySkipPermissions: true,
|
|
9
|
+
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
|
15
10
|
};
|
|
16
11
|
export { CODING, REVIEWING };
|
|
17
|
-
const
|
|
18
|
-
|
|
12
|
+
export const SUBMIT_TOOL = "submit_result";
|
|
13
|
+
export function isOwnExtension(path) {
|
|
14
|
+
return path.split(sep).includes("pi-next-issue");
|
|
15
|
+
}
|
|
19
16
|
export async function runAgent(recorder, request) {
|
|
20
17
|
const log = recorder.scope({ role: request.name });
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
let text = "";
|
|
24
|
-
let toolCalls = 0;
|
|
25
|
-
let turns = 0;
|
|
26
|
-
let costUsd = 0;
|
|
27
|
-
let model = request.model;
|
|
28
|
-
let sessionId;
|
|
29
|
-
let structured;
|
|
30
|
-
let stderr = "";
|
|
31
|
-
let stderrClosed = Promise.resolve();
|
|
18
|
+
const runtime = request.runtime ?? {};
|
|
19
|
+
const agentDir = runtime.agentDir ?? getAgentDir();
|
|
32
20
|
const started = Date.now();
|
|
33
21
|
log.event("agent.start", { model: request.model, timeoutMs: request.timeoutMs }, "normal");
|
|
34
|
-
|
|
22
|
+
let structured;
|
|
23
|
+
const customTools = [];
|
|
24
|
+
if (request.outputSchema !== undefined) {
|
|
25
|
+
customTools.push(defineTool({
|
|
26
|
+
name: SUBMIT_TOOL,
|
|
27
|
+
label: "Submit result",
|
|
28
|
+
description: "Give the result. Call it once, as your last action.",
|
|
29
|
+
parameters: request.outputSchema,
|
|
30
|
+
async execute(_toolCallId, params) {
|
|
31
|
+
structured = params;
|
|
32
|
+
return {
|
|
33
|
+
content: [{ type: "text", text: "Result saved." }],
|
|
34
|
+
details: {},
|
|
35
|
+
terminate: true,
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
41
|
+
cwd: request.cwd,
|
|
42
|
+
agentDir,
|
|
43
|
+
extensionsOverride: (base) => ({
|
|
44
|
+
...base,
|
|
45
|
+
extensions: base.extensions.filter((extension) => !isOwnExtension(extension.resolvedPath)),
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
await resourceLoader.reload();
|
|
49
|
+
const modelRuntime = runtime.modelRuntime ?? (await ModelRuntime.create({
|
|
50
|
+
authPath: join(agentDir, "auth.json"),
|
|
51
|
+
modelsPath: join(agentDir, "models.json"),
|
|
52
|
+
}));
|
|
53
|
+
const model = request.model === undefined ? undefined : await findModel(modelRuntime, request.model);
|
|
54
|
+
const { session, modelFallbackMessage } = await createAgentSession({
|
|
55
|
+
cwd: request.cwd,
|
|
56
|
+
agentDir,
|
|
57
|
+
resourceLoader,
|
|
58
|
+
sessionManager: SessionManager.inMemory(request.cwd),
|
|
59
|
+
tools: [...request.profile.tools, ...customTools.map((tool) => tool.name)],
|
|
60
|
+
customTools,
|
|
61
|
+
modelRuntime,
|
|
62
|
+
...(runtime.settingsManager === undefined ? {} : { settingsManager: runtime.settingsManager }),
|
|
63
|
+
...(model === undefined ? {} : { model }),
|
|
64
|
+
});
|
|
65
|
+
if (modelFallbackMessage !== undefined) {
|
|
66
|
+
log.event("agent.model", { note: modelFallbackMessage }, "quiet");
|
|
67
|
+
}
|
|
68
|
+
let timedOut = false;
|
|
35
69
|
const timer = request.timeoutMs === undefined
|
|
36
70
|
? undefined
|
|
37
|
-
: setTimeout(() =>
|
|
38
|
-
|
|
71
|
+
: setTimeout(() => {
|
|
72
|
+
timedOut = true;
|
|
73
|
+
void session.abort();
|
|
74
|
+
}, request.timeoutMs);
|
|
75
|
+
const unsubscribe = session.subscribe((event) => observe(log, event));
|
|
76
|
+
const release = onStop(() => void session.abort());
|
|
39
77
|
try {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
model: request.model,
|
|
45
|
-
tools: request.profile.tools,
|
|
46
|
-
allowedTools: request.profile.tools,
|
|
47
|
-
permissionMode: request.profile.permissionMode,
|
|
48
|
-
allowDangerouslySkipPermissions: request.profile.allowDangerouslySkipPermissions,
|
|
49
|
-
systemPrompt: { type: "preset", preset: "claude_code" },
|
|
50
|
-
abortController: controller,
|
|
51
|
-
pathToClaudeCodeExecutable: request.executable,
|
|
52
|
-
spawnClaudeCodeProcess: (options) => {
|
|
53
|
-
const child = spawnClaude(options, (chunk) => {
|
|
54
|
-
stderr = (stderr + chunk).slice(-STDERR_TAIL);
|
|
55
|
-
});
|
|
56
|
-
stderrClosed = once(child.stderr, "close").catch(() => undefined);
|
|
57
|
-
return child;
|
|
58
|
-
},
|
|
59
|
-
outputFormat: request.outputSchema === undefined
|
|
60
|
-
? undefined
|
|
61
|
-
: { type: "json_schema", schema: request.outputSchema },
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
for await (const message of stream) {
|
|
65
|
-
if (message.type === "assistant") {
|
|
66
|
-
for (const block of message.message.content) {
|
|
67
|
-
if (block.type === "tool_use") {
|
|
68
|
-
toolCalls += 1;
|
|
69
|
-
toolNames.set(block.id, block.name);
|
|
70
|
-
log.event("tool", toolFields(block.name, block.input));
|
|
71
|
-
}
|
|
72
|
-
if (block.type === "text") {
|
|
73
|
-
text += block.text;
|
|
74
|
-
if (log.level === "verbose") {
|
|
75
|
-
process.stderr.write(block.text);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (message.type === "user" && Array.isArray(message.message.content)) {
|
|
81
|
-
for (const block of message.message.content) {
|
|
82
|
-
if (block.type === "tool_result" && block.is_error === true) {
|
|
83
|
-
log.event("tool.error", { tool: toolNames.get(block.tool_use_id) }, "quiet");
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (message.type === "result") {
|
|
88
|
-
sessionId = message.session_id;
|
|
89
|
-
turns = message.num_turns;
|
|
90
|
-
costUsd = message.total_cost_usd;
|
|
91
|
-
model = main(message.modelUsage) ?? model;
|
|
92
|
-
for (const entry of Object.values(message.modelUsage)) {
|
|
93
|
-
usage.input += entry.inputTokens;
|
|
94
|
-
usage.output += entry.outputTokens;
|
|
95
|
-
usage.cacheRead += entry.cacheReadInputTokens;
|
|
96
|
-
usage.cacheWrite += entry.cacheCreationInputTokens;
|
|
97
|
-
usage.total +=
|
|
98
|
-
entry.inputTokens +
|
|
99
|
-
entry.outputTokens +
|
|
100
|
-
entry.cacheReadInputTokens +
|
|
101
|
-
entry.cacheCreationInputTokens;
|
|
102
|
-
}
|
|
103
|
-
if (message.subtype === "error_during_execution") {
|
|
104
|
-
throw new Error(`The ${request.name} agent failed: ${message.errors.join("; ")}`);
|
|
105
|
-
}
|
|
106
|
-
if (message.subtype === "success") {
|
|
107
|
-
text = message.result.length > 0 ? message.result : text;
|
|
108
|
-
structured = message.structured_output;
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
log.event("agent.incomplete", { subtype: message.subtype }, "quiet");
|
|
112
|
-
}
|
|
113
|
-
}
|
|
78
|
+
await session.bindExtensions({});
|
|
79
|
+
await session.prompt(request.prompt, { expandPromptTemplates: false });
|
|
80
|
+
if (timedOut) {
|
|
81
|
+
throw new Error(`The ${request.name} agent did not finish in ${request.timeoutMs} ms.`);
|
|
114
82
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
throw late();
|
|
83
|
+
const last = session.messages.findLast((message) => message.role === "assistant");
|
|
84
|
+
if (last !== undefined && "stopReason" in last && last.stopReason === "error") {
|
|
85
|
+
throw new Error(`The ${request.name} agent failed: ${last.errorMessage ?? "no reason given"}`);
|
|
119
86
|
}
|
|
120
|
-
|
|
121
|
-
|
|
87
|
+
const stats = session.getSessionStats();
|
|
88
|
+
const usage = { ...emptyUsage(), ...stats.tokens };
|
|
89
|
+
const name = session.model === undefined ? undefined : `${session.model.provider}/${session.model.id}`;
|
|
90
|
+
const ms = Date.now() - started;
|
|
91
|
+
recorder.usage(request.name, usage, ms, {
|
|
92
|
+
turns: stats.assistantMessages,
|
|
93
|
+
toolCalls: stats.toolCalls,
|
|
94
|
+
model: name,
|
|
95
|
+
session: stats.sessionId,
|
|
96
|
+
costUsd: stats.cost,
|
|
97
|
+
});
|
|
98
|
+
return {
|
|
99
|
+
text: (session.getLastAssistantText() ?? "").trim(),
|
|
100
|
+
usage,
|
|
101
|
+
turns: stats.assistantMessages,
|
|
102
|
+
toolCalls: stats.toolCalls,
|
|
103
|
+
ms,
|
|
104
|
+
costUsd: stats.cost,
|
|
105
|
+
model: name,
|
|
106
|
+
sessionId: stats.sessionId,
|
|
107
|
+
structured,
|
|
108
|
+
};
|
|
122
109
|
}
|
|
123
110
|
finally {
|
|
124
111
|
clearTimeout(timer);
|
|
112
|
+
release();
|
|
113
|
+
unsubscribe();
|
|
114
|
+
session.dispose();
|
|
125
115
|
}
|
|
126
|
-
|
|
127
|
-
|
|
116
|
+
}
|
|
117
|
+
async function findModel(modelRuntime, name) {
|
|
118
|
+
const slash = name.indexOf("/");
|
|
119
|
+
if (slash <= 0) {
|
|
120
|
+
throw new Error(`The model ${name} is not in the form provider/model.`);
|
|
128
121
|
}
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
session: sessionId,
|
|
135
|
-
costUsd,
|
|
136
|
-
});
|
|
137
|
-
return {
|
|
138
|
-
text: text.trim(),
|
|
139
|
-
usage,
|
|
140
|
-
turns,
|
|
141
|
-
toolCalls,
|
|
142
|
-
ms,
|
|
143
|
-
costUsd,
|
|
144
|
-
model,
|
|
145
|
-
sessionId,
|
|
146
|
-
structured,
|
|
147
|
-
};
|
|
122
|
+
const found = modelRuntime.getModel(name.slice(0, slash), name.slice(slash + 1));
|
|
123
|
+
if (found === undefined) {
|
|
124
|
+
throw new Error(`The model ${name} is not known to pi.`);
|
|
125
|
+
}
|
|
126
|
+
return found;
|
|
148
127
|
}
|
|
149
|
-
function
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
128
|
+
function observe(log, event) {
|
|
129
|
+
if (event.type === "tool_execution_start") {
|
|
130
|
+
log.event("tool", toolFields(event.toolName, event.args));
|
|
131
|
+
}
|
|
132
|
+
else if (event.type === "tool_execution_end" && event.isError) {
|
|
133
|
+
log.event("tool.error", { tool: event.toolName }, "quiet");
|
|
134
|
+
}
|
|
135
|
+
else if (event.type === "message_update" &&
|
|
136
|
+
event.assistantMessageEvent.type === "text_delta" &&
|
|
137
|
+
log.level === "verbose") {
|
|
138
|
+
process.stderr.write(event.assistantMessageEvent.delta);
|
|
157
139
|
}
|
|
158
|
-
return name;
|
|
159
140
|
}
|
|
160
141
|
export function toolFields(name, input) {
|
|
161
|
-
if (name !== "
|
|
142
|
+
if (name !== "bash" || typeof input !== "object" || input === null) {
|
|
162
143
|
return { tool: name };
|
|
163
144
|
}
|
|
164
145
|
const { command } = input;
|
|
165
146
|
return typeof command === "string" ? { tool: name, command } : { tool: name };
|
|
166
147
|
}
|
|
167
|
-
export function withStderr(error, stderr) {
|
|
168
|
-
const tail = stderr.trim();
|
|
169
|
-
if (tail.length === 0 || !(error instanceof Error)) {
|
|
170
|
-
return error;
|
|
171
|
-
}
|
|
172
|
-
return new Error(`${error.message}\nClaude CLI stderr:\n${tail}`, { cause: error });
|
|
173
|
-
}
|
|
174
|
-
export function spawnClaude(options, onStderr = () => undefined) {
|
|
175
|
-
const child = spawn(options.command, options.args, {
|
|
176
|
-
cwd: options.cwd,
|
|
177
|
-
env: options.env,
|
|
178
|
-
signal: options.signal,
|
|
179
|
-
detached: true,
|
|
180
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
181
|
-
});
|
|
182
|
-
child.stderr.setEncoding("utf8");
|
|
183
|
-
child.stderr.on("data", onStderr);
|
|
184
|
-
track(child);
|
|
185
|
-
return child;
|
|
186
|
-
}
|
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/exec.mjs
CHANGED
|
@@ -3,7 +3,16 @@ let observer;
|
|
|
3
3
|
let defaultTimeoutMs = 10 * 60_000;
|
|
4
4
|
const STDERR_TAIL = 200_000;
|
|
5
5
|
const groups = new Set();
|
|
6
|
+
const stoppers = new Set();
|
|
7
|
+
export function onStop(stopper) {
|
|
8
|
+
stoppers.add(stopper);
|
|
9
|
+
return () => stoppers.delete(stopper);
|
|
10
|
+
}
|
|
6
11
|
export function stopChildren(signal) {
|
|
12
|
+
for (const stopper of stoppers) {
|
|
13
|
+
stopper();
|
|
14
|
+
}
|
|
15
|
+
stoppers.clear();
|
|
7
16
|
for (const pid of groups) {
|
|
8
17
|
try {
|
|
9
18
|
process.kill(-pid, signal);
|
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/prompts.mjs
CHANGED
|
@@ -60,7 +60,7 @@ export function reviewPrompt(issue, comments, diff, earlier) {
|
|
|
60
60
|
"The harness discards each change that you make, so a change is only lost work.",
|
|
61
61
|
scope,
|
|
62
62
|
"Mark a finding blocking only for a wrong result, a missing part of the issue, a regression or a broken convention.",
|
|
63
|
-
"Give the result
|
|
63
|
+
"Give the result with the submit_result tool: a short summary and one entry per finding. The findings decide the outcome, so a problem that you leave out counts as no problem.",
|
|
64
64
|
].join("\n\n");
|
|
65
65
|
}
|
|
66
66
|
const UNRELATED_RULE = [
|
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,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "next-issue",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Autonomous issue harness driven by the
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Autonomous issue harness driven by the pi SDK",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github",
|
|
7
7
|
"issues",
|
|
8
8
|
"agent",
|
|
9
|
-
"
|
|
9
|
+
"pi",
|
|
10
10
|
"automation",
|
|
11
11
|
"cli"
|
|
12
12
|
],
|
|
@@ -37,10 +37,14 @@
|
|
|
37
37
|
"prepublishOnly": "npm run typecheck && npm test"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
+
"@earendil-works/pi-ai": "^1.5.0",
|
|
40
41
|
"@types/node": "^26.6.6",
|
|
42
|
+
"@types/ws": "^8.18.2",
|
|
41
43
|
"typescript": "^7.0.2"
|
|
42
44
|
},
|
|
43
45
|
"dependencies": {
|
|
44
|
-
"@
|
|
46
|
+
"@earendil-works/pi-coding-agent": "^1.5.0",
|
|
47
|
+
"typebox": "^1.4.3",
|
|
48
|
+
"ws": "^8.22.1"
|
|
45
49
|
}
|
|
46
50
|
}
|