@ravshansbox/pi-next-issue 2.19.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,12 +47,13 @@ Pi does not implement the issue in that session. The worker does the work. The b
47
47
 
48
48
  ## Worker
49
49
 
50
- The extension does no work itself. On `/next-issue` or `next_issue_start` it connects to the `next-issue` worker on the socket file `~/.pi/agent/next-issue.sock`. When no worker answers, it starts one with `npx -y next-issue@<version> serve`, where `<version>` is the version of the extension, or `latest` when the extension runs from a source checkout with version `0.0.0`, in the background, and writes the output of the worker to `~/.pi/agent/next-issue-worker.log`.
50
+ The extension does no work itself. On `/next-issue` or `next_issue_start` it connects to the `next-issue` worker of the repository on the socket file `.next-issue/worker.sock` in the repository. When no worker answers, it starts one in the repository with `npx -y next-issue@<version> serve`, where `<version>` is the version of the extension, or `latest` when the extension runs from a source checkout with version `0.0.0`, in the background, and writes the output of the worker to `.next-issue/worker.log` in the repository. When the path of the socket file is too long for the system, the extension says so and starts nothing.
51
51
 
52
- - Every pi on the machine shares the one worker, and every pi in a repository shows the run of that repository, whoever started it.
52
+ - Each repository has its own worker. Every pi in a repository shares the worker of that repository and shows its run, whoever started it. A run, restart or upgrade in one repository never affects another.
53
53
  - Opening pi does not start a worker. It only shows a run when a worker is going.
54
54
  - The run does not belong to pi: it goes on after `/new`, `/resume`, a fork, a reload or quitting pi.
55
- - The worker stays when every pi is closed. Stop it with `npx next-issue stop`.
56
- - When the extension is newer than the worker, the worker closes once no run is going, and the extension starts the newer worker.
55
+ - The worker exits when no run is going and no pi is connected. Stop it at once with `npx next-issue stop` in the repository.
56
+ - Versions before one worker per repository ran one worker for the machine, which does not stop by itself and can keep claiming issues alongside the new worker. Stop it once after you upgrade, as the [`next-issue` README](../next-issue/README.md#worker) says.
57
+ - When the extension is newer than the worker of a repository, that worker closes once no run is going, and the extension starts the newer worker. Workers of other repositories carry on.
57
58
 
58
59
  Each run asks the worker to merge the pull requests. [ADR 0002](https://github.com/ravshansbox/next-issue/blob/main/docs/adr/0002-merge-setting.md) compares where the merge setting could live. See the [`next-issue` README](../next-issue/README.md) for what a run does, its labels, its state and its config.
package/index.ts CHANGED
@@ -1,11 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import {
5
- type ExtensionAPI,
6
- type ExtensionContext,
7
- getAgentDir,
8
- } from '@earendil-works/pi-coding-agent';
4
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
9
5
  import { Type } from 'typebox';
10
6
  import { must, type RunOptions } from './src/exec.ts';
11
7
  import { repoRoot } from './src/git.ts';
@@ -15,10 +11,11 @@ import {
15
11
  spawnWorker,
16
12
  WorkerClient,
17
13
  type WorkerEvent,
14
+ workerPaths,
18
15
  } from './src/worker.ts';
19
16
 
20
17
  export type Options = {
21
- client: () => WorkerClient;
18
+ client: (root: string) => WorkerClient;
22
19
  root: (cwd: string) => Promise<string>;
23
20
  gh: (args: string[], options: RunOptions) => Promise<string>;
24
21
  };
@@ -39,10 +36,15 @@ function errorText(error: unknown): string {
39
36
  return error instanceof Error ? error.message : String(error);
40
37
  }
41
38
 
42
- function shared(create: () => WorkerClient): WorkerClient {
43
- const store = globalThis as Record<symbol, WorkerClient | undefined>;
44
- store[KEY] ??= create();
45
- return store[KEY];
39
+ function shared(create: (root: string) => WorkerClient, root: string): WorkerClient {
40
+ const store = globalThis as Record<symbol, Map<string, WorkerClient> | undefined>;
41
+ const clients = (store[KEY] ??= new Map());
42
+ let client = clients.get(root);
43
+ if (client === undefined) {
44
+ client = create(root);
45
+ clients.set(root, client);
46
+ }
47
+ return client;
46
48
  }
47
49
 
48
50
  function duration(ms: number): string {
@@ -103,14 +105,12 @@ function packageVersion(): string {
103
105
  }
104
106
 
105
107
  const fromMachine: Options = {
106
- client: () => {
107
- const agentDir = getAgentDir();
108
- return new WorkerClient({
109
- socketPath: join(agentDir, 'next-issue.sock'),
108
+ client: (root) =>
109
+ new WorkerClient({
110
+ socketPath: workerPaths(root).socketPath,
110
111
  version: packageVersion(),
111
- spawnWorker: (version) => spawnWorker(agentDir, version),
112
- });
113
- },
112
+ spawnWorker: (version) => spawnWorker(root, version),
113
+ }),
114
114
  root: repoRoot,
115
115
  gh: (args, options) => must('gh', args, options),
116
116
  };
@@ -129,7 +129,7 @@ export function createExtension(options: Options) {
129
129
  let baseline = false;
130
130
 
131
131
  const current = (): Progress | undefined =>
132
- root === undefined ? undefined : shared(options.client).runs[root];
132
+ root === undefined ? undefined : shared(options.client, root).runs[root];
133
133
 
134
134
  const render = () => {
135
135
  const progress = current();
@@ -164,7 +164,7 @@ export function createExtension(options: Options) {
164
164
 
165
165
  const askQuestions = () => {
166
166
  if (root !== undefined) {
167
- shared(options.client).send({ type: 'questions', root });
167
+ shared(options.client, root).send({ type: 'questions', root });
168
168
  }
169
169
  };
170
170
 
@@ -214,15 +214,16 @@ export function createExtension(options: Options) {
214
214
 
215
215
  const follow = () => {
216
216
  unsubscribe?.();
217
+ if (root === undefined) {
218
+ return;
219
+ }
220
+ const client = shared(options.client, root);
217
221
  const progress = current();
218
222
  handled = progress?.handled.length ?? 0;
219
223
  status = progress?.status;
220
- waiting =
221
- root === undefined
222
- ? undefined
223
- : shared(options.client).questions[root]?.map((question) => question.issue);
224
- baseline = !shared(options.client).connected;
225
- unsubscribe = shared(options.client).subscribe(onEvent);
224
+ waiting = client.questions[root]?.map((question) => question.issue);
225
+ baseline = !client.connected;
226
+ unsubscribe = client.subscribe(onEvent);
226
227
  render();
227
228
  paintQuestions();
228
229
  if (!baseline) {
@@ -249,7 +250,7 @@ export function createExtension(options: Options) {
249
250
  return;
250
251
  }
251
252
  follow();
252
- await shared(options.client).attach();
253
+ await shared(options.client, root).attach();
253
254
  });
254
255
 
255
256
  pi.on('session_shutdown', async () => {
@@ -272,7 +273,7 @@ export function createExtension(options: Options) {
272
273
  show(ctx);
273
274
  follow();
274
275
  }
275
- const client = shared(options.client);
276
+ const client = shared(options.client, root);
276
277
  await client.ensure();
277
278
  return { client, root, progress: client.runs[root] };
278
279
  };
@@ -352,7 +353,7 @@ export function createExtension(options: Options) {
352
353
  parameters: Type.Object({}),
353
354
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
354
355
  const root = await options.root(ctx.cwd);
355
- const client = shared(options.client);
356
+ const client = shared(options.client, root);
356
357
  await client.attach();
357
358
  if (!client.connected) {
358
359
  return report('no worker is running', root);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ravshansbox/pi-next-issue",
3
- "version": "2.19.0",
3
+ "version": "2.20.0",
4
4
  "description": "Pi extension that works through GitHub issues with the next-issue worker",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/worker.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { closeSync, openSync } from 'node:fs';
3
- import { join } from 'node:path';
2
+ import { closeSync, mkdirSync, openSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
4
  import WebSocket from 'ws';
5
5
 
6
6
  export type RunStatus = 'running' | 'stopping' | 'finished' | 'stopped' | 'failed';
@@ -108,10 +108,30 @@ export function workerArgs(version: string): string[] {
108
108
  return ['-y', `next-issue@${version === '0.0.0' ? 'latest' : version}`, 'serve'];
109
109
  }
110
110
 
111
- export function spawnWorker(agentDir: string, version: string): void {
112
- const log = openSync(join(agentDir, 'next-issue-worker.log'), 'a');
111
+ export function workerPaths(root: string): { socketPath: string; logPath: string } {
112
+ return {
113
+ socketPath: join(root, '.next-issue', 'worker.sock'),
114
+ logPath: join(root, '.next-issue', 'worker.log'),
115
+ };
116
+ }
117
+
118
+ export function checkSocketPath(path: string, platform = process.platform): void {
119
+ const size = platform === 'linux' ? 108 : 104;
120
+ const length = Buffer.byteLength(path);
121
+ if (length >= size) {
122
+ throw new Error(
123
+ `the socket path ${path} is too long for this system: ${length} bytes, at most ${size - 1}`,
124
+ );
125
+ }
126
+ }
127
+
128
+ export function spawnWorker(root: string, version: string): void {
129
+ const { logPath } = workerPaths(root);
130
+ mkdirSync(dirname(logPath), { recursive: true });
131
+ const log = openSync(logPath, 'a');
113
132
  try {
114
133
  const child = spawn('npx', workerArgs(version), {
134
+ cwd: root,
115
135
  detached: true,
116
136
  stdio: ['ignore', log, log],
117
137
  });
@@ -191,6 +211,7 @@ export class WorkerClient {
191
211
  }
192
212
 
193
213
  private async start(): Promise<void> {
214
+ checkSocketPath(this.options.socketPath);
194
215
  try {
195
216
  await this.open();
196
217
  return;