next-issue 2.9.0 → 2.11.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 +30 -6
- package/dist/fix.mjs +1 -1
- package/dist/github.mjs +35 -0
- package/dist/handover.mjs +54 -2
- package/dist/pipeline.mjs +8 -4
- package/dist/ports.mjs +3 -1
- package/dist/prompts.mjs +16 -3
- package/dist/serve.mjs +68 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -167,7 +167,9 @@ comment: `### Review: approved`, `### Review: changes requested` and
|
|
|
167
167
|
`### Fixer: no change`. The account is the same for all of them, so the heading,
|
|
168
168
|
not the author, tells them apart. The agents read the comments of the issue, not
|
|
169
169
|
of the pull request, so a comment of an agent never returns to a prompt: the
|
|
170
|
-
dispute of the fixer reaches the reviewer through the state file.
|
|
170
|
+
dispute of the fixer reaches the reviewer through the state file. The one
|
|
171
|
+
exception is the answer to a hand-over, see
|
|
172
|
+
[Retry an issue that waits for a person](#retry-an-issue-that-waits-for-a-person).
|
|
171
173
|
|
|
172
174
|
## Stop a run
|
|
173
175
|
|
|
@@ -233,11 +235,11 @@ A short text summary of the same data goes to standard output. Use `--json` to
|
|
|
233
235
|
get the full summary object there instead, so the harness fits in a pipe. All
|
|
234
236
|
progress goes to standard error.
|
|
235
237
|
|
|
236
|
-
Recorded for each step: `claim`, `comments`, `
|
|
237
|
-
`push`, `pull-request`, `checks`, `review` and `fix`, each with a
|
|
238
|
-
`.end` or `.error` event and a duration in `ms`. Every `git` and `gh`
|
|
239
|
-
is recorded with its exit code and duration. A `tool` event holds the
|
|
240
|
-
name, and for a `bash` tool the command too. Each agent call adds a `usage`
|
|
238
|
+
Recorded for each step: `claim`, `comments`, `reply`, `worktree`, `setup`,
|
|
239
|
+
`implement`, `push`, `pull-request`, `checks`, `review` and `fix`, each with a
|
|
240
|
+
`.start`, `.end` or `.error` event and a duration in `ms`. Every `git` and `gh`
|
|
241
|
+
command is recorded with its exit code and duration. A `tool` event holds the
|
|
242
|
+
tool name, and for a `bash` tool the command too. Each agent call adds a `usage`
|
|
241
243
|
event with the tokens, the cost estimate in dollars, the turn count, the
|
|
242
244
|
tool-call count, the model and the session id.
|
|
243
245
|
|
|
@@ -386,6 +388,15 @@ the pull request and the step that the last run reached, so the work carries on
|
|
|
386
388
|
where it stopped. A commit that you pushed to the pull request in the meantime
|
|
387
389
|
is fetched first. An issue that still has the label is not claimed.
|
|
388
390
|
|
|
391
|
+
To give guidance, answer the hand-over with a comment in the same place: on the
|
|
392
|
+
pull request, or on the issue when there is no pull request. When the run
|
|
393
|
+
resumes the issue, it reads the comments posted after its last
|
|
394
|
+
`next-issue needs help:` comment there. Only comments from users with write
|
|
395
|
+
access to the repository count, for the hand-over comment too. The implementer
|
|
396
|
+
and every fixer of that run get them in their prompt, under the heading
|
|
397
|
+
`Answer of a person to the hand-over`. A short reply such as "ready, take it" is
|
|
398
|
+
enough. With no such comment, the run resumes with no guidance.
|
|
399
|
+
|
|
389
400
|
The `--reset` flag does the same for any issue that the run handles, whether or
|
|
390
401
|
not it was handed over. It touches only an issue that the run handles, so the
|
|
391
402
|
state of an issue that a label still holds back stays as it was.
|
|
@@ -450,12 +461,25 @@ to it. The worker:
|
|
|
450
461
|
file. It works on macOS and Linux;
|
|
451
462
|
- holds `next-issue.lock` in the same folder, with its process id, so only one
|
|
452
463
|
worker runs. A lock whose process is no longer alive is taken over;
|
|
464
|
+
- keeps the repositories it knows in `next-issue.roots.json` in the same
|
|
465
|
+
folder, so it still knows them after a restart;
|
|
453
466
|
- runs at most one run per repository, and runs in several repositories at
|
|
454
467
|
once. Each run is like `next-issue` without `--watch`, and the client says
|
|
455
468
|
whether it merges;
|
|
456
469
|
- sends every client the progress of every run when it connects and on every
|
|
457
470
|
change: the issue, the step, the tool count, the last tool and the handled
|
|
458
471
|
issues;
|
|
472
|
+
- sends every client a `questions` message for each repository: the open
|
|
473
|
+
issues with the `needsHuman` label, each with its number, title, the reason
|
|
474
|
+
of the hand-over and the url of the comment that holds it. It reads them
|
|
475
|
+
from GitHub, so the list is the same after a restart. It sends them when a
|
|
476
|
+
client connects, for each repository it knows, after each issue of a run
|
|
477
|
+
ends, and when a client sends `{ "type": "questions", "root": "<path>" }`.
|
|
478
|
+
An issue that no longer has the label leaves the list. An issue with the
|
|
479
|
+
label but no hand-over comment is in the list with an empty reason and url.
|
|
480
|
+
The issues are read one at a time. A client never gets an older list after
|
|
481
|
+
a newer one. When the list cannot be read, the client gets
|
|
482
|
+
`{ "type": "questions-error", "root": "<path>", "message": "<text>" }`;
|
|
459
483
|
- stops a run, when a client asks, like a second `Ctrl-C`: after the current
|
|
460
484
|
round, with the state kept;
|
|
461
485
|
- stays when all clients close;
|
package/dist/fix.mjs
CHANGED
|
@@ -10,7 +10,7 @@ export async function fixRound(job, reason, detail, earlier, kind, force = false
|
|
|
10
10
|
const result = await log.step('fix', { reason }, () => ports.runAgent(log, {
|
|
11
11
|
name: 'fixer',
|
|
12
12
|
cwd: worktree,
|
|
13
|
-
prompt: fixPrompt(issue, reason, detail, earlier, kind),
|
|
13
|
+
prompt: fixPrompt(issue, reason, detail, earlier, kind, job.reply),
|
|
14
14
|
profile: CODING,
|
|
15
15
|
model: context.config.models.fixer,
|
|
16
16
|
timeoutMs: context.config.agentTimeoutMinutes * 60_000,
|
package/dist/github.mjs
CHANGED
|
@@ -74,6 +74,41 @@ export async function issueComments(repo, issue) {
|
|
|
74
74
|
const parsed = JSON.parse(raw);
|
|
75
75
|
return parsed.comments.map((comment) => `@${comment.author.login}: ${comment.body}`);
|
|
76
76
|
}
|
|
77
|
+
export async function threadComments(repo, number) {
|
|
78
|
+
const raw = await repeatable([
|
|
79
|
+
'api',
|
|
80
|
+
'--paginate',
|
|
81
|
+
`repos/${slug(repo)}/issues/${number}/comments`,
|
|
82
|
+
'--jq',
|
|
83
|
+
'.[] | [.user.login, .body, .html_url] | @json',
|
|
84
|
+
], { cwd: repo.root });
|
|
85
|
+
return raw
|
|
86
|
+
.split('\n')
|
|
87
|
+
.filter((line) => line.length > 0)
|
|
88
|
+
.map((line) => {
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(line);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new Error(`gh gave a comment of #${number} that is not valid JSON: ${line}`);
|
|
95
|
+
}
|
|
96
|
+
const [author, body, url] = parsed;
|
|
97
|
+
return url === undefined ? { author, body: body ?? '' } : { author, body: body ?? '', url };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export async function canWrite(repo, login) {
|
|
101
|
+
try {
|
|
102
|
+
const permission = await repeatable(['api', `repos/${slug(repo)}/collaborators/${login}/permission`, '--jq', '.permission'], { cwd: repo.root });
|
|
103
|
+
return ['admin', 'write'].includes(permission.trim());
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error instanceof Error && /HTTP 404/.test(error.message)) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
77
112
|
export async function openBlockers(repo, issue) {
|
|
78
113
|
const raw = await repeatable([
|
|
79
114
|
'api',
|
package/dist/handover.mjs
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
import { setStatus } from './job.mjs';
|
|
2
2
|
import { message } from './observe.mjs';
|
|
3
|
-
import { dropState, writeState } from './state.mjs';
|
|
3
|
+
import { dropState, readState, writeState } from './state.mjs';
|
|
4
|
+
export const HAND_OVER = 'next-issue needs help:';
|
|
5
|
+
function writers(repo, ports) {
|
|
6
|
+
const access = new Map();
|
|
7
|
+
return (author) => {
|
|
8
|
+
if (!access.has(author)) {
|
|
9
|
+
access.set(author, ports.canWrite(repo, author));
|
|
10
|
+
}
|
|
11
|
+
return access.get(author);
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
async function lastHandOver(comments, writer) {
|
|
15
|
+
for (let index = comments.length - 1; index >= 0; index -= 1) {
|
|
16
|
+
const comment = comments[index];
|
|
17
|
+
if (comment.body.startsWith(HAND_OVER) && (await writer(comment.author))) {
|
|
18
|
+
return index;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return -1;
|
|
22
|
+
}
|
|
23
|
+
export async function waitingQuestions(repo, config, ports) {
|
|
24
|
+
const writer = writers(repo, ports);
|
|
25
|
+
const issues = await ports.openIssues(repo, config.issueLimit, [config.labels.needsHuman]);
|
|
26
|
+
const questions = [];
|
|
27
|
+
for (const issue of issues) {
|
|
28
|
+
const pr = (await readState(repo, issue.number))?.pr;
|
|
29
|
+
const comments = await ports.threadComments(repo, pr ?? issue.number);
|
|
30
|
+
const comment = comments[await lastHandOver(comments, writer)];
|
|
31
|
+
questions.push({
|
|
32
|
+
issue: issue.number,
|
|
33
|
+
title: issue.title,
|
|
34
|
+
reason: comment?.body.slice(HAND_OVER.length).trim() ?? '',
|
|
35
|
+
url: comment?.url ?? '',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return questions;
|
|
39
|
+
}
|
|
40
|
+
export async function handOverReply(context, issue, pr) {
|
|
41
|
+
const { repo, ports } = context;
|
|
42
|
+
const comments = await ports.threadComments(repo, pr ?? issue);
|
|
43
|
+
const writer = writers(repo, ports);
|
|
44
|
+
const last = await lastHandOver(comments, writer);
|
|
45
|
+
if (last === -1) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const reply = [];
|
|
49
|
+
for (const comment of comments.slice(last + 1)) {
|
|
50
|
+
if (await writer(comment.author)) {
|
|
51
|
+
reply.push(`@${comment.author}: ${comment.body}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return reply;
|
|
55
|
+
}
|
|
4
56
|
export function escalate(job, reason) {
|
|
5
57
|
return handOver(job.context, job.log, job.issue.number, job.state, reason);
|
|
6
58
|
}
|
|
@@ -57,7 +109,7 @@ export async function handOver(context, log, issue, state, reason) {
|
|
|
57
109
|
log.event('hand-over', { reason }, 'quiet');
|
|
58
110
|
await writeState(context.repo, { ...state, handedOver: true });
|
|
59
111
|
await setStatus(context, issue, state.pr, context.config.labels.needsHuman);
|
|
60
|
-
const body =
|
|
112
|
+
const body = `${HAND_OVER} ${reason}`;
|
|
61
113
|
if (state.pr === undefined) {
|
|
62
114
|
await context.ports.commentOnIssue(context.repo, issue, body);
|
|
63
115
|
}
|
package/dist/pipeline.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { CODING } from './agents.mjs';
|
|
|
2
2
|
import { commitWork } from './fix.mjs';
|
|
3
3
|
import { checkGate } from './gate.mjs';
|
|
4
4
|
import { branchName } from './git.mjs';
|
|
5
|
-
import { cleanUp, escalate, finish, handOver, skipped, stop } from './handover.mjs';
|
|
5
|
+
import { cleanUp, escalate, finish, handOver, handOverReply, skipped, stop } from './handover.mjs';
|
|
6
6
|
import { openPr, setStatus } from './job.mjs';
|
|
7
7
|
import { afterMerge, fixBase } from './merge.mjs';
|
|
8
8
|
import { message } from './observe.mjs';
|
|
@@ -112,7 +112,7 @@ export async function processIssue(context, issue) {
|
|
|
112
112
|
? resetState(saved)
|
|
113
113
|
: saved;
|
|
114
114
|
try {
|
|
115
|
-
const report = await work(context, issue, log, state, saved === undefined);
|
|
115
|
+
const report = await work(context, issue, log, state, saved === undefined, saved?.handedOver === true);
|
|
116
116
|
log.event('issue.end', { outcome: report.outcome, reason: report.reason, ms: report.ms }, 'quiet');
|
|
117
117
|
return report;
|
|
118
118
|
}
|
|
@@ -136,7 +136,7 @@ export async function processIssue(context, issue) {
|
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
|
-
async function work(context, issue, log, state, start) {
|
|
139
|
+
async function work(context, issue, log, state, start, handedOver) {
|
|
140
140
|
const started = Date.now();
|
|
141
141
|
const { repo, config, ports } = context;
|
|
142
142
|
const branch = state.branch;
|
|
@@ -145,6 +145,9 @@ async function work(context, issue, log, state, start) {
|
|
|
145
145
|
await writeState(repo, state);
|
|
146
146
|
});
|
|
147
147
|
const comments = await log.step('comments', {}, () => ports.issueComments(repo, issue.number));
|
|
148
|
+
const reply = handedOver
|
|
149
|
+
? await log.step('reply', {}, () => handOverReply(context, issue.number, state.pr))
|
|
150
|
+
: [];
|
|
148
151
|
const added = await log.step('worktree', { branch }, () => ports.addWorktree(repo, issue.number, branch, context.base, config.remote, start));
|
|
149
152
|
const worktree = added.path;
|
|
150
153
|
if (!added.baseUpdated) {
|
|
@@ -158,6 +161,7 @@ async function work(context, issue, log, state, start) {
|
|
|
158
161
|
branch,
|
|
159
162
|
worktree,
|
|
160
163
|
comments,
|
|
164
|
+
reply,
|
|
161
165
|
started,
|
|
162
166
|
noChecks: false,
|
|
163
167
|
mainBroken: false,
|
|
@@ -179,7 +183,7 @@ async function work(context, issue, log, state, start) {
|
|
|
179
183
|
const result = await log.step('implement', {}, () => ports.runAgent(log, {
|
|
180
184
|
name: 'implementer',
|
|
181
185
|
cwd: worktree,
|
|
182
|
-
prompt: implementPrompt(issue, comments),
|
|
186
|
+
prompt: implementPrompt(issue, comments, reply),
|
|
183
187
|
profile: CODING,
|
|
184
188
|
model: config.models.implementer,
|
|
185
189
|
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
package/dist/ports.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { runAgent } from './agents.mjs';
|
|
2
2
|
import { run } from './exec.mjs';
|
|
3
3
|
import { addWorktree, commitAll, deleteBranch, diff, discardChanges, disputeBranches, findWorktree, isDirty, latestSubject, push, removeWorktree, resetToBase, revision, setAside, } from './git.mjs';
|
|
4
|
-
import { commentOnIssue, commentOnPr, createPr, failedBaseLogs, failedCheckLogs, findPr, issueComments, markPrReady, mergeCommit, mergePr, openBlockers, openIssues, prConflicts, prMerged, reopenIssue, setLabel, viewIssue, waitForBaseChecks, waitForChecks, } from './github.mjs';
|
|
4
|
+
import { canWrite, commentOnIssue, commentOnPr, createPr, failedBaseLogs, failedCheckLogs, findPr, issueComments, markPrReady, mergeCommit, mergePr, openBlockers, openIssues, prConflicts, prMerged, reopenIssue, setLabel, threadComments, viewIssue, waitForBaseChecks, waitForChecks, } from './github.mjs';
|
|
5
5
|
import { detectSetupCommand } from './setup.mjs';
|
|
6
6
|
export const PORTS = {
|
|
7
7
|
addWorktree,
|
|
8
|
+
canWrite,
|
|
8
9
|
commentOnIssue,
|
|
9
10
|
commentOnPr,
|
|
10
11
|
commitAll,
|
|
@@ -37,6 +38,7 @@ export const PORTS = {
|
|
|
37
38
|
runSetup: async (command, cwd, timeoutMs) => run('bash', ['-lc', command], { cwd, timeoutMs }),
|
|
38
39
|
setAside,
|
|
39
40
|
setLabel,
|
|
41
|
+
threadComments,
|
|
40
42
|
viewIssue,
|
|
41
43
|
waitForBaseChecks,
|
|
42
44
|
waitForChecks,
|
package/dist/prompts.mjs
CHANGED
|
@@ -11,15 +11,27 @@ function issueBlock(issue, comments) {
|
|
|
11
11
|
.filter((part) => part.length > 0)
|
|
12
12
|
.join('\n\n');
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
function replyBlock(reply) {
|
|
15
|
+
return reply.length > 0
|
|
16
|
+
? [
|
|
17
|
+
'## Answer of a person to the hand-over',
|
|
18
|
+
'The last run handed the issue to a person. A person with write access to the repository answered as below. Follow this guidance.',
|
|
19
|
+
reply.join('\n\n'),
|
|
20
|
+
].join('\n\n')
|
|
21
|
+
: '';
|
|
22
|
+
}
|
|
23
|
+
export function implementPrompt(issue, comments, reply = []) {
|
|
15
24
|
return [
|
|
16
25
|
'You are the implementer. Solve the issue below in this worktree.',
|
|
17
26
|
issueBlock(issue, comments),
|
|
27
|
+
replyBlock(reply),
|
|
18
28
|
'## Rules',
|
|
19
29
|
'Read the repository conventions first. Keep the change minimal and focused on the issue.',
|
|
20
30
|
'Add or update tests when the repository has a test suite.',
|
|
21
31
|
COMMIT_RULES,
|
|
22
|
-
]
|
|
32
|
+
]
|
|
33
|
+
.filter((part) => part.length > 0)
|
|
34
|
+
.join('\n\n');
|
|
23
35
|
}
|
|
24
36
|
export function hookPrompt(issue, comments, role, output) {
|
|
25
37
|
return [
|
|
@@ -75,7 +87,7 @@ const DISPUTE_RULE = [
|
|
|
75
87
|
'If only some of the findings are wrong, fix the correct ones and change nothing for the wrong ones. Write no `unrelated` line in that round. The next round holds only the findings that are left, and you can dispute them there.',
|
|
76
88
|
'The reviewer reads that line in the next round. Do not make a change that you hold to be wrong only to end the round.',
|
|
77
89
|
].join('\n');
|
|
78
|
-
export function fixPrompt(issue, reason, detail, earlier, kind) {
|
|
90
|
+
export function fixPrompt(issue, reason, detail, earlier, kind, reply = []) {
|
|
79
91
|
return [
|
|
80
92
|
`You are the fixer. Correct the work for issue #${issue.number}: ${issue.title}.`,
|
|
81
93
|
`## Reason\n${reason}`,
|
|
@@ -87,6 +99,7 @@ export function fixPrompt(issue, reason, detail, earlier, kind) {
|
|
|
87
99
|
'Keep those earlier fixes. Do not undo work that a later round did not question.',
|
|
88
100
|
].join('\n')
|
|
89
101
|
: '',
|
|
102
|
+
replyBlock(reply),
|
|
90
103
|
'## Rules',
|
|
91
104
|
'Fix the cause, not the symptom. Keep the change minimal.',
|
|
92
105
|
kind === 'checks' ? UNRELATED_RULE : DISPUTE_RULE,
|
package/dist/serve.mjs
CHANGED
|
@@ -5,16 +5,21 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
6
6
|
import { WebSocketServer } from 'ws';
|
|
7
7
|
import { parseArgs } from './args.mjs';
|
|
8
|
+
import { loadConfig } from './config.mjs';
|
|
8
9
|
import { stopChildren } from './exec.mjs';
|
|
10
|
+
import { detectRepo } from './git.mjs';
|
|
11
|
+
import { waitingQuestions } from './handover.mjs';
|
|
9
12
|
import { message } from './observe.mjs';
|
|
10
13
|
import { applyEvent, startProgress } from './progress.mjs';
|
|
11
|
-
import {
|
|
14
|
+
import { PORTS } from './ports.mjs';
|
|
15
|
+
import { isNewer, } from './protocol.mjs';
|
|
12
16
|
import { runRepo } from './run.mjs';
|
|
13
17
|
import { packageVersion } from './version.mjs';
|
|
14
18
|
export function socketPaths(agentDir = getAgentDir()) {
|
|
15
19
|
return {
|
|
16
20
|
socketPath: join(agentDir, 'next-issue.sock'),
|
|
17
21
|
lockPath: join(agentDir, 'next-issue.lock'),
|
|
22
|
+
rootsPath: join(agentDir, 'next-issue.roots.json'),
|
|
18
23
|
};
|
|
19
24
|
}
|
|
20
25
|
function alive(pid) {
|
|
@@ -55,12 +60,39 @@ async function lock(path) {
|
|
|
55
60
|
}
|
|
56
61
|
return false;
|
|
57
62
|
}
|
|
63
|
+
async function readRoots(path) {
|
|
64
|
+
try {
|
|
65
|
+
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
66
|
+
return new Set(Array.isArray(value) ? value.filter((root) => typeof root === 'string') : []);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return new Set();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export async function listQuestions(root) {
|
|
73
|
+
const config = await loadConfig(root);
|
|
74
|
+
return waitingQuestions(await detectRepo(root, config.remote), config, PORTS);
|
|
75
|
+
}
|
|
58
76
|
export async function startWorker(options) {
|
|
59
77
|
if (!(await lock(options.lockPath))) {
|
|
60
78
|
return undefined;
|
|
61
79
|
}
|
|
62
80
|
await rm(options.socketPath, { force: true });
|
|
63
81
|
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
|
+
const reads = new Map();
|
|
95
|
+
const delivered = new WeakMap();
|
|
64
96
|
let restart = false;
|
|
65
97
|
let finish = () => undefined;
|
|
66
98
|
const closed = new Promise((resolve) => {
|
|
@@ -81,6 +113,26 @@ export async function startWorker(options) {
|
|
|
81
113
|
send(socket, value);
|
|
82
114
|
}
|
|
83
115
|
};
|
|
116
|
+
const sendQuestions = async (root, targets) => {
|
|
117
|
+
remember(root);
|
|
118
|
+
const read = (reads.get(root) ?? 0) + 1;
|
|
119
|
+
reads.set(root, read);
|
|
120
|
+
let value;
|
|
121
|
+
try {
|
|
122
|
+
value = { type: 'questions', root, questions: await options.questions(root) };
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
value = { type: 'questions-error', root, message: message(error) };
|
|
126
|
+
}
|
|
127
|
+
for (const socket of targets()) {
|
|
128
|
+
const seen = delivered.get(socket) ?? new Map();
|
|
129
|
+
if ((seen.get(root) ?? 0) < read) {
|
|
130
|
+
seen.set(root, read);
|
|
131
|
+
delivered.set(socket, seen);
|
|
132
|
+
send(socket, value);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
84
136
|
const busy = () => [...runs.values()].some((run) => run.progress.status === 'running' || run.progress.status === 'stopping');
|
|
85
137
|
const close = async () => {
|
|
86
138
|
for (const socket of sockets.clients) {
|
|
@@ -89,6 +141,7 @@ export async function startWorker(options) {
|
|
|
89
141
|
await new Promise((resolve) => sockets.close(() => resolve()));
|
|
90
142
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
91
143
|
await rm(options.socketPath, { force: true });
|
|
144
|
+
await saving;
|
|
92
145
|
await rm(options.lockPath, { force: true });
|
|
93
146
|
finish();
|
|
94
147
|
};
|
|
@@ -109,6 +162,7 @@ export async function startWorker(options) {
|
|
|
109
162
|
}
|
|
110
163
|
const run = { progress: startProgress(), stop: false };
|
|
111
164
|
runs.set(root, run);
|
|
165
|
+
remember(root);
|
|
112
166
|
const interrupts = {
|
|
113
167
|
afterIssue: () => run.stop,
|
|
114
168
|
afterRound: () => run.stop,
|
|
@@ -132,7 +186,12 @@ export async function startWorker(options) {
|
|
|
132
186
|
options
|
|
133
187
|
.runRepo(root, { ...parseArgs([]), merge }, {
|
|
134
188
|
interrupts,
|
|
135
|
-
onEvent: (record) =>
|
|
189
|
+
onEvent: (record) => {
|
|
190
|
+
update(applyEvent(run.progress, record));
|
|
191
|
+
if (record['kind'] === 'issue.end' || record['kind'] === 'issue.error') {
|
|
192
|
+
void sendQuestions(root, () => sockets.clients);
|
|
193
|
+
}
|
|
194
|
+
},
|
|
136
195
|
})
|
|
137
196
|
.then(() => {
|
|
138
197
|
const status = run.stop
|
|
@@ -157,6 +216,9 @@ export async function startWorker(options) {
|
|
|
157
216
|
sockets.on('connection', (socket) => {
|
|
158
217
|
send(socket, { type: 'hello', version: options.version });
|
|
159
218
|
send(socket, snapshot());
|
|
219
|
+
for (const root of roots) {
|
|
220
|
+
void sendQuestions(root, () => [socket]);
|
|
221
|
+
}
|
|
160
222
|
socket.on('message', (data) => {
|
|
161
223
|
let value;
|
|
162
224
|
try {
|
|
@@ -177,6 +239,9 @@ export async function startWorker(options) {
|
|
|
177
239
|
else if (value.type === 'stop') {
|
|
178
240
|
stop(value.root);
|
|
179
241
|
}
|
|
242
|
+
else if (value.type === 'questions') {
|
|
243
|
+
void sendQuestions(value.root, () => [socket]);
|
|
244
|
+
}
|
|
180
245
|
});
|
|
181
246
|
});
|
|
182
247
|
await new Promise((resolve, reject) => {
|
|
@@ -200,6 +265,7 @@ export async function serve() {
|
|
|
200
265
|
...paths,
|
|
201
266
|
version: await packageVersion(),
|
|
202
267
|
runRepo,
|
|
268
|
+
questions: listQuestions,
|
|
203
269
|
});
|
|
204
270
|
if (worker === undefined) {
|
|
205
271
|
process.stderr.write('next-issue: another worker is running\n');
|