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)
|
|
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
|
-
|
|
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 (
|
|
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> {
|