@ravshansbox/pi-next-issue 1.0.3 → 1.0.4

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
@@ -39,7 +39,7 @@ The implementer and the reviewer read the issue title, the body and the comments
39
39
 
40
40
  Each agent is a pi session in the worktree. It uses the model and thinking level from your pi settings, and loads all of your extensions except this one.
41
41
 
42
- The state of each issue is saved in `.worktrees/<n>.json`. The extension adds `/.worktrees/` to `.git/info/exclude`.
42
+ The state of each issue is saved in `.worktrees/<n>.json`. A run holds `.worktrees/run.lock`, which keeps the process id, so that only one pi at a time works on a repository. A second run fails at once. A lock whose process is no longer alive is taken over. If the operating system gives that process id to another process, the lock stays; remove `.worktrees/run.lock` by hand when no run is going. The extension adds `/.worktrees/` to `.git/info/exclude`.
43
43
 
44
44
  ## Config
45
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ravshansbox/pi-next-issue",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Pi extension that works through GitHub issues in the background",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ports.ts CHANGED
@@ -32,5 +32,7 @@ export async function createPorts(root: string, config: Config): Promise<Ports>
32
32
  writeState: (value) => state.writeState(root, value),
33
33
  dropState: (issue) => state.dropState(root, issue),
34
34
  savedIssues: () => state.savedIssues(root),
35
+ lock: () => state.lock(root),
36
+ unlock: () => state.unlock(root),
35
37
  };
36
38
  }
package/src/runner.ts CHANGED
@@ -60,6 +60,8 @@ export type Ports = {
60
60
  writeState(state: IssueState): Promise<void>;
61
61
  dropState(issue: number): Promise<void>;
62
62
  savedIssues(): Promise<number[]>;
63
+ lock(): Promise<void>;
64
+ unlock(): Promise<void>;
63
65
  };
64
66
 
65
67
  export type Outcome = 'merged' | 'needs-human';
@@ -123,7 +125,10 @@ export class Runner {
123
125
  async run(): Promise<void> {
124
126
  this.update({ status: 'running' });
125
127
  const seen = new Set<number>();
128
+ let locked = false;
126
129
  try {
130
+ await this.ports.lock();
131
+ locked = true;
127
132
  for (;;) {
128
133
  this.controller.signal.throwIfAborted();
129
134
  const issues = await this.ports.openIssues();
@@ -148,6 +153,10 @@ export class Runner {
148
153
  error: error instanceof Error ? error.message : String(error),
149
154
  });
150
155
  }
156
+ } finally {
157
+ if (locked) {
158
+ await this.ports.unlock().catch(() => {});
159
+ }
151
160
  }
152
161
  }
153
162
 
package/src/state.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
1
+ import { link, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { WORKTREE_DIR } from './git.ts';
4
4
  import type { IssueState } from './runner.ts';
@@ -38,3 +38,84 @@ export async function savedIssues(root: string): Promise<number[]> {
38
38
  return match === null ? [] : [Number(match[1])];
39
39
  });
40
40
  }
41
+
42
+ function lockFile(root: string): string {
43
+ return join(dir(root), 'run.lock');
44
+ }
45
+
46
+ function alive(pid: number): boolean {
47
+ try {
48
+ process.kill(pid, 0);
49
+ return true;
50
+ } catch (error) {
51
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
52
+ }
53
+ }
54
+
55
+ async function holder(path: string): Promise<number> {
56
+ return Number((await readFile(path, 'utf8').catch(() => '')).trim());
57
+ }
58
+
59
+ function busy(pid: number, cause: unknown): Error {
60
+ return new Error(`another run works on this repository (pid ${pid})`, { cause });
61
+ }
62
+
63
+ async function place(root: string): Promise<void> {
64
+ const draft = `${lockFile(root)}.${process.pid}.draft`;
65
+ await writeFile(draft, `${process.pid}\n`);
66
+ try {
67
+ await link(draft, lockFile(root));
68
+ } finally {
69
+ await rm(draft, { force: true });
70
+ }
71
+ }
72
+
73
+ async function claim(root: string): Promise<void> {
74
+ try {
75
+ await place(root);
76
+ } catch (error) {
77
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
78
+ throw busy(await holder(lockFile(root)), error);
79
+ }
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ export async function lock(root: string): Promise<void> {
85
+ await mkdir(dir(root), { recursive: true });
86
+ try {
87
+ await place(root);
88
+ return;
89
+ } catch (error) {
90
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
91
+ throw error;
92
+ }
93
+ const pid = await holder(lockFile(root));
94
+ if (Number.isInteger(pid) && pid > 0 && alive(pid)) {
95
+ throw busy(pid, error);
96
+ }
97
+ const stale = `${lockFile(root)}.${process.pid}`;
98
+ try {
99
+ await rename(lockFile(root), stale);
100
+ } catch (renameError) {
101
+ if ((renameError as NodeJS.ErrnoException).code !== 'ENOENT') {
102
+ throw renameError;
103
+ }
104
+ return claim(root);
105
+ }
106
+ const moved = await holder(stale);
107
+ if (!Object.is(moved, pid)) {
108
+ await link(stale, lockFile(root)).catch(() => undefined);
109
+ await rm(stale, { force: true });
110
+ throw busy(moved, error);
111
+ }
112
+ await rm(stale, { force: true });
113
+ }
114
+ await claim(root);
115
+ }
116
+
117
+ export async function unlock(root: string): Promise<void> {
118
+ if ((await holder(lockFile(root))) === process.pid) {
119
+ await rm(lockFile(root), { force: true });
120
+ }
121
+ }