pi-orchestrator-config 4.26.0 → 4.26.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.26.1] - 2026-11-06
4
+
5
+ - The tmp sweep carries no hardcoded control-file name list: it asks
6
+ `daemon-manager.isLiveLegacyDaemonFile()`, which owns the two legacy names, so a future daemon
7
+ writing to `.pi/run` can never require the sweep to be edited. A legacy `pidiff.port`/`pidiff.pid`
8
+ is kept only while its recorded PID is alive (`EPERM` counts as alive — another user's running
9
+ process), and a dead daemon's leftovers plus every `reload.*` marker are swept like any other
10
+ entry. Deleting a live daemon's lockfiles would make the next connect() spawn a duplicate on its
11
+ port.
12
+ - A control file can no longer block or exhaust a session: `readLockfile` accepts only regular
13
+ files, so a project FIFO at `pidiff.port`/`pidiff.pid` is ignored, and the pid read is capped at
14
+ 64 bytes so an oversized file is rejected without being read.
15
+
3
16
  ## [4.26.0] - 2026-11-06
4
17
 
5
18
  - Async `chain` mode: steps run in sequence and each step's result is substituted into `{previous}`,
@@ -15,6 +15,7 @@ import * as fs from "node:fs";
15
15
  import { promises as fsp } from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { isLiveLegacyDaemonFile } from "../shared/daemon-manager.js";
18
19
  import { createLogger } from "../shared/logger.js";
19
20
  import { isPiOneshotInvocation } from "../shared/oneshot.js";
20
21
  import { getGlobalSettingsPath, getSettingDynamic, getSettingsPath, SETTINGS_KEYS } from "./project-settings.js";
@@ -29,21 +30,6 @@ const DAY_MS = 24 * 60 * 60 * 1000;
29
30
  /** Defaults live in `settings-keys.json` only — never restated here. */
30
31
  export const DEFAULT_MAX_SIZE = String(SETTINGS_KEYS.project_tmp_max_size.default);
31
32
  export const DEFAULT_RETENTION_DAYS = Number(SETTINGS_KEYS.project_tmp_retention_days.default);
32
- /**
33
- * Transitional debris from pre-#1020 daemons: `.pi/tmp` was the control-file home
34
- * before `.pi/run`, so a daemon started before the upgrade still holds its pid/port and
35
- * reload handshake here, and the read-both fallback (#1025) needs these files to find it.
36
- * Sweeping them would make the next connect() miss the live daemon and spawn a duplicate
37
- * on the same port. Never deleted, whatever their age; the set disappears once no
38
- * pre-upgrade daemon remains.
39
- */
40
- export const TMP_PROTECTED_NAMES = new Set([
41
- "pidiff.pid",
42
- "pidiff.port",
43
- "reload.flag",
44
- "reload.done",
45
- "reload.pending",
46
- ]);
47
33
  /** Lives in `.pi/run`, never in the swept dir. */
48
34
  export const TMP_SWEEP_LOCK = ".tmp-sweep.lock";
49
35
  /** A crashed sweep would otherwise block every later session forever. */
@@ -171,7 +157,14 @@ export async function sweepProjectTmp(opts: TmpSweepOptions): Promise<TmpSweepRe
171
157
  let total = 0;
172
158
  for (const name of names) {
173
159
  const target = path.join(base, name);
174
- if (!isInside(base, target) || TMP_PROTECTED_NAMES.has(name)) continue;
160
+ if (!isInside(base, target)) continue;
161
+ // One exemption, decided by the daemon layer: a pre-`.pi/run` daemon that is still ALIVE
162
+ // owns its lockfile here, and sweeping it makes the next connect() spawn a duplicate on
163
+ // that port. A dead daemon's leftovers are throwaway like everything else (#1020).
164
+ if (isLiveLegacyDaemonFile(base, name)) {
165
+ log.debug("tmp_sweep_kept_live_legacy_daemon", { tmpDir: base, name });
166
+ continue;
167
+ }
175
168
  let stat: fs.Stats;
176
169
  try {
177
170
  stat = await fsp.lstat(target);
@@ -193,19 +193,36 @@ export function writeLockfile(lockDir: string, port: number, pid: number | null,
193
193
  } catch (e: any) { log(`writeLockfile error: ${e.message}`); }
194
194
  }
195
195
 
196
+ /** A pid file holds digits and a newline; anything bigger is not one. */
197
+ const MAX_PID_FILE_BYTES = 64;
198
+
199
+ /** True for a path that exists and is a regular file. Lockfiles are never FIFOs, and reading
200
+ * one would block the event loop, so every control-file read goes through this. */
201
+ function isRegularFile(target: string): boolean {
202
+ try {
203
+ return fs.lstatSync(target).isFile();
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
196
209
  /** Read port from lockfile. Returns port number or null.
197
210
  * `legacyDir` (`.pi/tmp`) is consulted only when the new dir has no lockfile:
198
- * a daemon already running at upgrade time wrote its lockfile there (#1020). */
211
+ * a daemon already running at upgrade time wrote its lockfile there (#1020).
212
+ * Reading never moves or deletes anything — moving a live daemon's record is what the
213
+ * sweep's liveness check exists to avoid. */
199
214
  export function readLockfile(lockDir: string, legacyDir?: string): { port: number; pid: number | null } | null {
200
215
  const read = (dir: string): { port: number; pid: number | null } | null => {
201
216
  try {
202
217
  const portFile = path.join(dir, "pidiff.port");
203
- if (!fs.existsSync(portFile)) return null;
218
+ // Only a regular file is a lockfile: the dirs are project-controlled, and reading a
219
+ // FIFO here would block the event loop for the whole session.
220
+ if (!isRegularFile(portFile)) return null;
204
221
  const port = parseInt(fs.readFileSync(portFile, "utf-8").trim(), 10);
205
222
  if (isNaN(port)) return null;
206
223
  let pid: number | null = null;
207
224
  const pidFile = path.join(dir, "pidiff.pid");
208
- if (fs.existsSync(pidFile)) {
225
+ if (isRegularFile(pidFile)) {
209
226
  pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
210
227
  if (isNaN(pid)) pid = null;
211
228
  }
@@ -215,6 +232,63 @@ export function readLockfile(lockDir: string, legacyDir?: string): { port: numbe
215
232
  return read(lockDir) ?? (legacyDir ? read(legacyDir) : null);
216
233
  }
217
234
 
235
+ /** Read the pid from a lockfile, refusing anything but a regular file: the legacy dir is
236
+ * project-controlled, and reading a FIFO there would block session startup. */
237
+ function readLegacyPid(pidFile: string): number | null {
238
+ try {
239
+ const st = fs.lstatSync(pidFile);
240
+ if (!st.isFile()) {
241
+ log.debug("legacy_pid_not_a_regular_file", { pidFile });
242
+ return null;
243
+ }
244
+ // The file is project-controlled: a pid is a few bytes, so refuse anything larger rather
245
+ // than reading an arbitrary file into memory during session start.
246
+ if (st.size > MAX_PID_FILE_BYTES) {
247
+ log.debug("legacy_pid_file_too_large", { pidFile, size: st.size });
248
+ return null;
249
+ }
250
+ const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
251
+ if (isNaN(pid) || pid <= 0) {
252
+ log.debug("legacy_pid_unparseable", { pidFile });
253
+ return null;
254
+ }
255
+ return pid;
256
+ } catch (e: any) {
257
+ log.debug("legacy_pid_unreadable", { pidFile, error: e?.message });
258
+ return null;
259
+ }
260
+ }
261
+
262
+ /** True when the legacy `.pi/tmp` lockfile belongs to a LIVE process. `EPERM` means the
263
+ * process exists under another user, so it counts as alive — treating it as dead would let
264
+ * the sweep delete a running daemon's files. */
265
+ export function legacyLockfileOwnerAlive(legacyDir: string): boolean {
266
+ const pid = readLegacyPid(path.join(legacyDir, "pidiff.pid"));
267
+ if (pid === null) return false;
268
+ try {
269
+ process.kill(pid, 0);
270
+ return true;
271
+ } catch (e: any) {
272
+ if (e?.code === "EPERM") {
273
+ log.debug("legacy_daemon_owned_by_another_user", { pid });
274
+ return true;
275
+ }
276
+ log.debug("legacy_daemon_not_running", { pid, code: e?.code });
277
+ return false;
278
+ }
279
+ }
280
+
281
+ /** Control files a pre-`.pi/run` daemon owns inside the legacy dir. Private: the sweep asks
282
+ * `isLiveLegacyDaemonFile`, so neither module keeps a second copy of the names (#1020). */
283
+ const LEGACY_CONTROL_FILES = new Set(["pidiff.port", "pidiff.pid"]);
284
+
285
+ /** True when `name` is a legacy control file whose owner is still alive — the sweep's only
286
+ * exemption. A dead daemon's leftovers are ordinary throwaway. */
287
+ export function isLiveLegacyDaemonFile(legacyDir: string, name: string): boolean {
288
+ if (!LEGACY_CONTROL_FILES.has(name)) return false;
289
+ return legacyLockfileOwnerAlive(legacyDir);
290
+ }
291
+
218
292
  /** First lockfile port (in `dirs` order) whose daemon answers /api/health, or
219
293
  * null. Exported for testing. */
220
294
  export async function findLiveLockfile(dirs: string[]): Promise<number | null> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.26.0",
3
+ "version": "4.26.1",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"