pi-orchestrator-config 4.2.1 → 4.3.2
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 +52 -61
- package/agents/git-expert.md +5 -1
- package/agents/github-expert.md +4 -1
- package/extensions/acpx-provider/runtime-models.ts +18 -6
- package/extensions/cli-provider/agents/claude.ts +3 -2
- package/extensions/cli-provider/agents/cursor.ts +3 -5
- package/extensions/cli-provider/agents/gemini.ts +3 -2
- package/extensions/cli-provider/discover.ts +7 -7
- package/extensions/cli-provider/index.ts +5 -0
- package/extensions/cli-provider/runtime-models.ts +18 -6
- package/extensions/cli-provider/session-reaper.ts +5 -7
- package/extensions/cli-provider/sessions.ts +4 -0
- package/extensions/cli-provider/shared/discover-cache.ts +17 -176
- package/extensions/coms/coms-p2p.ts +1719 -423
- package/extensions/coms/coms-shared.ts +261 -39
- package/extensions/coms/coms-wrapper.ts +100 -62
- package/extensions/coms/index.ts +8 -4
- package/extensions/coms/mixed-turn.ts +26 -0
- package/extensions/coms/probe-socket.ts +42 -0
- package/extensions/coms/themeMap.ts +0 -1
- package/extensions/image-gen/image-gen.ts +19 -1
- package/extensions/orchestrator/async-agents.ts +509 -85
- package/extensions/orchestrator/cron.ts +14 -8
- package/extensions/orchestrator/dreaming.ts +31 -24
- package/extensions/orchestrator/enforcement-helpers.ts +459 -17
- package/extensions/orchestrator/enforcement-rules.ts +5 -4
- package/extensions/orchestrator/enforcement.ts +79 -25
- package/extensions/orchestrator/extended-autocomplete.ts +7 -0
- package/extensions/orchestrator/git-helpers.ts +2 -2
- package/extensions/orchestrator/index.ts +23 -10
- package/extensions/orchestrator/markdown-transformer.ts +313 -0
- package/extensions/orchestrator/memory-embeddings.ts +36 -34
- package/extensions/orchestrator/memory-provenance.ts +29 -25
- package/extensions/orchestrator/memory-scoring.ts +23 -18
- package/extensions/orchestrator/memory-tools.ts +10 -3
- package/extensions/orchestrator/overlay-dashboard.ts +17 -5
- package/extensions/orchestrator/pi-config-review-state.ts +44 -33
- package/extensions/orchestrator/project-settings.ts +220 -21
- package/extensions/orchestrator/review-porcelain.ts +4 -0
- package/extensions/orchestrator/review-ui.ts +11 -5
- package/extensions/orchestrator/rule-placeholders.ts +384 -1
- package/extensions/orchestrator/rules.ts +80 -132
- package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
- package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
- package/extensions/orchestrator/settings-tui.ts +636 -0
- package/extensions/orchestrator/state-jsonl.ts +431 -0
- package/extensions/orchestrator/subagent-tool.ts +105 -50
- package/extensions/orchestrator/sync-limit.ts +23 -0
- package/extensions/orchestrator/task-lifecycle.ts +77 -67
- package/extensions/orchestrator/utils.ts +8 -5
- package/extensions/pidash/pidash.ts +193 -156
- package/extensions/pidiff/pidiff.ts +64 -38
- package/extensions/pidiff/spawn-lock.ts +62 -0
- package/extensions/pitasks/index.ts +430 -0
- package/extensions/pitasks/reminders.ts +37 -0
- package/extensions/pitasks/task-store.ts +467 -0
- package/extensions/pitasks/task-tools.ts +375 -0
- package/extensions/pitasks/task-widget.ts +181 -0
- package/extensions/pitasks/types.ts +38 -0
- package/extensions/providers/acpx-driver.ts +12 -2
- package/extensions/providers/built-in-drivers.ts +6 -6
- package/extensions/providers/claude-driver.ts +1 -1
- package/extensions/providers/cursor-cli-driver.ts +1 -1
- package/extensions/providers/gemini-driver.ts +1 -1
- package/extensions/providers/index.ts +144 -26
- package/extensions/providers/initialized-guard.ts +27 -0
- package/extensions/providers/restore-default-model.ts +596 -0
- package/extensions/providers/session-shutdown.ts +39 -0
- package/extensions/shared/coms-active.ts +23 -0
- package/extensions/shared/create-runtime-provider.ts +2 -0
- package/extensions/shared/daemon-manager.ts +0 -8
- package/extensions/shared/file-logger.ts +132 -25
- package/extensions/shared/logger.ts +50 -0
- package/extensions/shared/models-dev.ts +421 -0
- package/extensions/shared/oneshot.ts +116 -0
- package/extensions/shared/resolve-binary.ts +191 -0
- package/extensions/shared/types.ts +3 -0
- package/package.json +7 -2
- package/prompts/coderabbit-rate-limit.md +2 -0
- package/rules/00-orchestrator-core.md +3 -0
- package/rules/10-agent-routing.md +1 -1
- package/rules/20-code-review-loop.md +38 -16
- package/rules/30-prompt-templates.md +4 -0
- package/rules/35-memory.md +3 -3
- package/rules/40-critical-rules.md +3 -0
- package/rules/55-coms-protocol.md +84 -18
- package/rules/60-task-tracking.md +2 -0
- package/settings-keys.json +481 -20
- package/templates/coms-feature-manager-prompt.md +4 -6
- package/extensions/coms/coms-net-server.ts +0 -1642
- package/extensions/coms/coms-net-wrapper.ts +0 -521
- package/extensions/coms/coms-net.ts +0 -1589
|
@@ -15,7 +15,6 @@ import { createRequire } from "node:module";
|
|
|
15
15
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { hyperlink } from "@earendil-works/pi-tui";
|
|
17
17
|
import {
|
|
18
|
-
createLogger,
|
|
19
18
|
checkHealth,
|
|
20
19
|
ensureUiBuilt,
|
|
21
20
|
spawnDaemon,
|
|
@@ -26,16 +25,21 @@ import {
|
|
|
26
25
|
readLockfile,
|
|
27
26
|
removeLockfile,
|
|
28
27
|
} from "../shared/daemon-manager.js";
|
|
28
|
+
import { createLogger } from "../shared/logger.js";
|
|
29
29
|
import { setupHeartbeat, setupReconnectPoller } from "../shared/ws-client.js";
|
|
30
30
|
import { getSetting } from "../orchestrator/project-settings.js";
|
|
31
|
+
import { shouldSkipOneshotRegister } from "../shared/oneshot.js";
|
|
32
|
+
import { evaluateSpawnLock } from "./spawn-lock.js";
|
|
33
|
+
|
|
34
|
+
/** Re-export for callers/tests that import from pidiff. */
|
|
35
|
+
export { evaluateSpawnLock } from "./spawn-lock.js";
|
|
31
36
|
|
|
32
37
|
const RECONNECT_INTERVAL_MS = 5000;
|
|
33
38
|
const ICON_DIFF = "";
|
|
34
39
|
|
|
35
|
-
const log = createLogger(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
);
|
|
40
|
+
const log = createLogger("pidiff");
|
|
41
|
+
/** String logger for daemon-manager / ws-client APIs that take `(msg: string) => void`. */
|
|
42
|
+
const debugLog = (msg: string) => log.debug(msg);
|
|
39
43
|
|
|
40
44
|
function isGitRepo(cwd: string): boolean {
|
|
41
45
|
try { execFileSync("git", ["rev-parse", "--git-dir"], { cwd, stdio: "ignore" }); return true; } catch { return false; }
|
|
@@ -62,21 +66,22 @@ function isPortFree(port: number): Promise<boolean> {
|
|
|
62
66
|
async function resolveSpawnPort(preferred: number | null | undefined): Promise<number> {
|
|
63
67
|
if (!preferred) {
|
|
64
68
|
const port = await findFreePort();
|
|
65
|
-
log(`allocated new port: ${port}`);
|
|
69
|
+
log.debug(`allocated new port: ${port}`);
|
|
66
70
|
return port;
|
|
67
71
|
}
|
|
68
72
|
if (await isPortFree(preferred)) {
|
|
69
|
-
log(`reusing lockfile port: ${preferred}`);
|
|
73
|
+
log.debug(`reusing lockfile port: ${preferred}`);
|
|
70
74
|
return preferred;
|
|
71
75
|
}
|
|
72
|
-
log(`lockfile port ${preferred} in use, allocating new port`);
|
|
76
|
+
log.debug(`lockfile port ${preferred} in use, allocating new port`);
|
|
73
77
|
const port = await findFreePort();
|
|
74
|
-
log(`allocated new port: ${port}`);
|
|
78
|
+
log.debug(`allocated new port: ${port}`);
|
|
75
79
|
return port;
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
export function registerPidiff(pi: ExtensionAPI): void {
|
|
79
83
|
if (process.env.PI_SUBAGENT_CHILD === "1") return;
|
|
84
|
+
if (shouldSkipOneshotRegister(log)) return;
|
|
80
85
|
|
|
81
86
|
const projectCwd = process.cwd();
|
|
82
87
|
const pidiffDisabled = !getSetting(projectCwd, "pidiff_enable");
|
|
@@ -123,21 +128,21 @@ export function registerPidiff(pi: ExtensionAPI): void {
|
|
|
123
128
|
}
|
|
124
129
|
|
|
125
130
|
function doSpawn(port: number, cwd: string): void {
|
|
126
|
-
ensureUiBuilt(import.meta.url, "pidiff-ui",
|
|
131
|
+
ensureUiBuilt(import.meta.url, "pidiff-ui", debugLog);
|
|
127
132
|
spawnDaemon({
|
|
128
133
|
serverScript: "pidiff-server.ts",
|
|
129
134
|
logFile: path.join(process.env.HOME || "/tmp", ".pi", "pidiff-server.log"),
|
|
130
135
|
env: { PI_PIDIFF_PORT: String(port), PI_PIDIFF_CWD: cwd },
|
|
131
|
-
log,
|
|
136
|
+
log: debugLog,
|
|
132
137
|
});
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
async function connect(ctx: any) {
|
|
136
|
-
log(`connect() called, connected=${connected}, connecting=${connecting}`);
|
|
141
|
+
log.debug(`connect() called, connected=${connected}, connecting=${connecting}`);
|
|
137
142
|
if (connected || connecting || shuttingDown) return;
|
|
138
143
|
connecting = true;
|
|
139
144
|
lastCtx = ctx;
|
|
140
|
-
if (!lockDir) { log("connect: lockDir not set (session_start not fired yet)"); connecting = false; return; }
|
|
145
|
+
if (!lockDir) { log.debug("connect: lockDir not set (session_start not fired yet)"); connecting = false; return; }
|
|
141
146
|
|
|
142
147
|
// Try to discover port from lockfile
|
|
143
148
|
let port = activePort;
|
|
@@ -150,12 +155,12 @@ export function registerPidiff(pi: ExtensionAPI): void {
|
|
|
150
155
|
let running = false;
|
|
151
156
|
if (port) {
|
|
152
157
|
running = await checkHealth(port);
|
|
153
|
-
log(`lockfile port=${port}, healthy=${running}`);
|
|
158
|
+
log.debug(`lockfile port=${port}, healthy=${running}`);
|
|
154
159
|
}
|
|
155
160
|
|
|
156
161
|
if (!running) {
|
|
157
162
|
if (spawning) {
|
|
158
|
-
log("daemon already spawning, waiting...");
|
|
163
|
+
log.debug("daemon already spawning, waiting...");
|
|
159
164
|
connecting = false;
|
|
160
165
|
return;
|
|
161
166
|
}
|
|
@@ -165,8 +170,17 @@ export function registerPidiff(pi: ExtensionAPI): void {
|
|
|
165
170
|
try {
|
|
166
171
|
fs.writeFileSync(spawnLock, String(process.pid), { flag: "wx" });
|
|
167
172
|
} catch {
|
|
168
|
-
// Another session is already spawning —
|
|
169
|
-
|
|
173
|
+
// Another session is already spawning — check if spawner is still alive
|
|
174
|
+
const lockDecision = evaluateSpawnLock(spawnLock, getSetting(ctx.cwd, "pidiff_stale_lock_timeout_ms"));
|
|
175
|
+
if (lockDecision.action === "recover" || lockDecision.action === "recover_pid_reuse") {
|
|
176
|
+
log.info("stale_spawn_lock", lockDecision.reason);
|
|
177
|
+
try { fs.unlinkSync(spawnLock); } catch {}
|
|
178
|
+
connecting = false;
|
|
179
|
+
setTimeout(() => connect(ctx), 500);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Spawner is alive — wait for it
|
|
183
|
+
log.debug("another session is spawning pidiff, waiting for lockfile...");
|
|
170
184
|
for (let i = 0; i < 30; i++) {
|
|
171
185
|
await new Promise(r => setTimeout(r, 1000));
|
|
172
186
|
const lock = readLockfile(lockDir);
|
|
@@ -183,12 +197,12 @@ export function registerPidiff(pi: ExtensionAPI): void {
|
|
|
183
197
|
|
|
184
198
|
spawning = true;
|
|
185
199
|
try {
|
|
186
|
-
log("spawning daemon...");
|
|
200
|
+
log.debug("spawning daemon...");
|
|
187
201
|
// Reuse lockfile port if available — keeps URL stable across reload/restart
|
|
188
202
|
port = await resolveSpawnPort(port);
|
|
189
203
|
doSpawn(port, ctx.cwd);
|
|
190
|
-
writeLockfile(lockDir, port, null,
|
|
191
|
-
const ready = await waitForDaemon(port,
|
|
204
|
+
writeLockfile(lockDir, port, null, debugLog);
|
|
205
|
+
const ready = await waitForDaemon(port, getSetting(process.cwd(), "pidiff_daemon_startup_timeout_s"), debugLog);
|
|
192
206
|
if (!ready) { connecting = false; return; }
|
|
193
207
|
} finally {
|
|
194
208
|
spawning = false;
|
|
@@ -201,11 +215,11 @@ export function registerPidiff(pi: ExtensionAPI): void {
|
|
|
201
215
|
try {
|
|
202
216
|
const _require = createRequire(import.meta.url);
|
|
203
217
|
const WebSocket = _require("ws");
|
|
204
|
-
log(`connecting WebSocket to port ${activePort}...`);
|
|
218
|
+
log.debug(`connecting WebSocket to port ${activePort}...`);
|
|
205
219
|
const wsClient = new WebSocket(`ws://127.0.0.1:${activePort}/ws/pi`);
|
|
206
220
|
|
|
207
221
|
wsClient.on("open", () => {
|
|
208
|
-
log("WebSocket connected");
|
|
222
|
+
log.debug("WebSocket connected");
|
|
209
223
|
ws = wsClient;
|
|
210
224
|
connected = true;
|
|
211
225
|
connecting = false;
|
|
@@ -224,14 +238,14 @@ export function registerPidiff(pi: ExtensionAPI): void {
|
|
|
224
238
|
}));
|
|
225
239
|
} catch {
|
|
226
240
|
// ctx may be stale if session was replaced during WebSocket connect
|
|
227
|
-
log("skipped registration — ctx is stale");
|
|
241
|
+
log.debug("skipped registration — ctx is stale");
|
|
228
242
|
}
|
|
229
243
|
|
|
230
244
|
// Keepalive + dead connection detection
|
|
231
245
|
if (cleanupHeartbeat) cleanupHeartbeat();
|
|
232
246
|
cleanupHeartbeat = setupHeartbeat({
|
|
233
247
|
ws: wsClient,
|
|
234
|
-
log,
|
|
248
|
+
log: debugLog,
|
|
235
249
|
onDead: () => {
|
|
236
250
|
connected = false; ws = null;
|
|
237
251
|
if (!shuttingDown && lastCtx) setTimeout(() => connect(lastCtx), 1000);
|
|
@@ -243,7 +257,7 @@ export function registerPidiff(pi: ExtensionAPI): void {
|
|
|
243
257
|
try {
|
|
244
258
|
const parsed = JSON.parse(data.toString());
|
|
245
259
|
if (parsed.type === "publish-review" && parsed.comments?.length > 0) {
|
|
246
|
-
log(`review received: ${parsed.comments.length} comments`);
|
|
260
|
+
log.debug(`review received: ${parsed.comments.length} comments`);
|
|
247
261
|
const review = {
|
|
248
262
|
source: "pidiff",
|
|
249
263
|
type: "code-review",
|
|
@@ -262,25 +276,26 @@ export function registerPidiff(pi: ExtensionAPI): void {
|
|
|
262
276
|
const message = `## pidiff: Code Review Comments\n\n\`\`\`json\n${JSON.stringify(review, null, 2)}\n\`\`\`\n\nPlease address these review comments.`;
|
|
263
277
|
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
264
278
|
}
|
|
265
|
-
} catch (e: any) { log(`message handler error: ${e.message}`); }
|
|
279
|
+
} catch (e: any) { log.debug(`message handler error: ${e.message}`); }
|
|
266
280
|
});
|
|
267
281
|
|
|
268
282
|
wsClient.on("close", () => {
|
|
269
|
-
log("WebSocket closed");
|
|
283
|
+
log.debug("WebSocket closed");
|
|
270
284
|
connected = false; ws = null; connecting = false;
|
|
271
285
|
if (!shuttingDown) {
|
|
272
286
|
setTimeout(() => { if (lastCtx && !shuttingDown) connect(lastCtx); }, RECONNECT_INTERVAL_MS);
|
|
273
287
|
}
|
|
274
288
|
});
|
|
275
289
|
|
|
276
|
-
wsClient.on("error", (e: Error) => { log(`WebSocket error: ${e.message}`); });
|
|
290
|
+
wsClient.on("error", (e: Error) => { log.debug(`WebSocket error: ${e.message}`); });
|
|
277
291
|
} catch (e: any) {
|
|
278
|
-
log(`connect error: ${e.message}`);
|
|
292
|
+
log.debug(`connect error: ${e.message}`);
|
|
279
293
|
connecting = false;
|
|
280
294
|
}
|
|
281
295
|
}
|
|
282
296
|
|
|
283
297
|
// /pidiff command
|
|
298
|
+
let pidiffCmdHandler: any;
|
|
284
299
|
pi.registerCommand("pidiff", {
|
|
285
300
|
description: "Manage pidiff server — /pidiff start|stop|restart|status",
|
|
286
301
|
getArgumentCompletions: (prefix: string) => {
|
|
@@ -292,7 +307,7 @@ export function registerPidiff(pi: ExtensionAPI): void {
|
|
|
292
307
|
];
|
|
293
308
|
return items.filter(i => i.value.startsWith(prefix.toLowerCase()));
|
|
294
309
|
},
|
|
295
|
-
handler: async (args, ctx) => {
|
|
310
|
+
handler: pidiffCmdHandler = async (args: any, ctx: any) => {
|
|
296
311
|
lastCtx = ctx;
|
|
297
312
|
const cmd = (args || "").trim().toLowerCase();
|
|
298
313
|
|
|
@@ -300,8 +315,8 @@ export function registerPidiff(pi: ExtensionAPI): void {
|
|
|
300
315
|
if (ws) { try { ws.close(); } catch {} ws = null; }
|
|
301
316
|
connected = false;
|
|
302
317
|
const pidFile = path.join(lockDir, "pidiff.pid");
|
|
303
|
-
killDaemonByPid(pidFile,
|
|
304
|
-
removeLockfile(lockDir,
|
|
318
|
+
killDaemonByPid(pidFile, debugLog);
|
|
319
|
+
removeLockfile(lockDir, debugLog);
|
|
305
320
|
activePort = 0;
|
|
306
321
|
if (ctx.hasUI) { ctx.ui.setStatus("8-diff", undefined); ctx.ui.notify("pidiff server stopped", "info"); }
|
|
307
322
|
return;
|
|
@@ -318,9 +333,9 @@ export function registerPidiff(pi: ExtensionAPI): void {
|
|
|
318
333
|
// Reuse lockfile port to keep URL stable — fall back if taken
|
|
319
334
|
const port = await resolveSpawnPort(lock?.port);
|
|
320
335
|
doSpawn(port, ctx.cwd);
|
|
321
|
-
writeLockfile(lockDir, port, null,
|
|
336
|
+
writeLockfile(lockDir, port, null, debugLog);
|
|
322
337
|
if (ctx.hasUI) ctx.ui.notify("Starting pidiff server...", "info");
|
|
323
|
-
if (await waitForDaemon(port,
|
|
338
|
+
if (await waitForDaemon(port, getSetting(process.cwd(), "pidiff_daemon_startup_timeout_s"), debugLog)) {
|
|
324
339
|
activePort = port;
|
|
325
340
|
connect(ctx);
|
|
326
341
|
if (ctx.hasUI) ctx.ui.notify(`pidiff started at http://localhost:${activePort}`, "info");
|
|
@@ -337,15 +352,15 @@ export function registerPidiff(pi: ExtensionAPI): void {
|
|
|
337
352
|
// Reuse existing port to keep URL stable (read before removeLockfile)
|
|
338
353
|
const lock = readLockfile(lockDir);
|
|
339
354
|
const preferredPort = activePort || lock?.port || 0;
|
|
340
|
-
killDaemonByPid(pidFile,
|
|
341
|
-
removeLockfile(lockDir,
|
|
355
|
+
killDaemonByPid(pidFile, debugLog);
|
|
356
|
+
removeLockfile(lockDir, debugLog);
|
|
342
357
|
await new Promise(r => setTimeout(r, 1000));
|
|
343
358
|
// Verify preferred port is free after kill — fall back if taken by another process
|
|
344
359
|
const port = await resolveSpawnPort(preferredPort || null);
|
|
345
360
|
doSpawn(port, ctx.cwd);
|
|
346
|
-
writeLockfile(lockDir, port, null,
|
|
361
|
+
writeLockfile(lockDir, port, null, debugLog);
|
|
347
362
|
if (ctx.hasUI) ctx.ui.notify("Restarting pidiff server...", "info");
|
|
348
|
-
if (await waitForDaemon(port,
|
|
363
|
+
if (await waitForDaemon(port, getSetting(process.cwd(), "pidiff_daemon_startup_timeout_s"), debugLog)) {
|
|
349
364
|
activePort = port;
|
|
350
365
|
connect(ctx);
|
|
351
366
|
if (ctx.hasUI) ctx.ui.notify(`pidiff restarted at http://localhost:${activePort}`, "info");
|
|
@@ -365,6 +380,17 @@ export function registerPidiff(pi: ExtensionAPI): void {
|
|
|
365
380
|
},
|
|
366
381
|
});
|
|
367
382
|
|
|
383
|
+
// Expose handler to pidash for browser command dispatch
|
|
384
|
+
try {
|
|
385
|
+
if ((globalThis as any).__pidiff_pidash_listener) {
|
|
386
|
+
try { pi.events.removeListener("pidash:request-commands", (globalThis as any).__pidiff_pidash_listener); } catch {}
|
|
387
|
+
}
|
|
388
|
+
const registerWithPidash = () => pi.events.emit("pidash:register-command", { name: "pidiff", handler: pidiffCmdHandler });
|
|
389
|
+
(globalThis as any).__pidiff_pidash_listener = registerWithPidash;
|
|
390
|
+
registerWithPidash();
|
|
391
|
+
pi.events.on("pidash:request-commands", registerWithPidash);
|
|
392
|
+
} catch {}
|
|
393
|
+
|
|
368
394
|
// Session lifecycle
|
|
369
395
|
pi.on("session_start", async (_event, ctx) => {
|
|
370
396
|
lastCtx = ctx;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn-lock evaluation for pidiff daemon startup races.
|
|
3
|
+
* No pi-tui / ExtensionAPI deps — unit tests can import directly.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import { createLogger } from "../shared/logger.js";
|
|
7
|
+
|
|
8
|
+
const log = createLogger("pidiff");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Evaluate a stale spawn lockfile and decide the recovery action.
|
|
12
|
+
* Exported for testing.
|
|
13
|
+
*/
|
|
14
|
+
export function evaluateSpawnLock(
|
|
15
|
+
lockPath: string,
|
|
16
|
+
staleTimeoutMs: number,
|
|
17
|
+
): { action: "wait" | "recover" | "recover_pid_reuse"; reason: string } {
|
|
18
|
+
log.debug("evaluate_spawn_lock", "lockPath", lockPath, "staleTimeoutMs", staleTimeoutMs);
|
|
19
|
+
const effectiveTimeout = Math.max(staleTimeoutMs, 1000); // Floor at 1s to prevent thrashing
|
|
20
|
+
let result: { action: "wait" | "recover" | "recover_pid_reuse"; reason: string };
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const content = fs.readFileSync(lockPath, "utf-8").trim();
|
|
24
|
+
const spawnerPid = /^\d+$/.test(content) ? parseInt(content, 10) : NaN;
|
|
25
|
+
|
|
26
|
+
if (!spawnerPid || isNaN(spawnerPid)) {
|
|
27
|
+
// No valid PID — check age
|
|
28
|
+
const lockAge = Date.now() - fs.statSync(lockPath).mtimeMs;
|
|
29
|
+
if (lockAge > effectiveTimeout) {
|
|
30
|
+
result = { action: "recover", reason: `no valid PID, age ${Math.round(lockAge / 1000)}s > timeout` };
|
|
31
|
+
} else {
|
|
32
|
+
result = { action: "wait", reason: "no valid PID but lock is young" };
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
let isAlive = false;
|
|
36
|
+
try {
|
|
37
|
+
process.kill(spawnerPid, 0);
|
|
38
|
+
isAlive = true;
|
|
39
|
+
} catch (e: any) {
|
|
40
|
+
// EPERM = process exists but we can't signal it — treat as alive
|
|
41
|
+
isAlive = e?.code === "EPERM";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!isAlive) {
|
|
45
|
+
result = { action: "recover", reason: `PID ${spawnerPid} is dead` };
|
|
46
|
+
} else {
|
|
47
|
+
// PID alive — check for PID reuse (lock age > timeout)
|
|
48
|
+
const lockAge = Date.now() - fs.statSync(lockPath).mtimeMs;
|
|
49
|
+
if (lockAge > effectiveTimeout) {
|
|
50
|
+
result = { action: "recover_pid_reuse", reason: `PID ${spawnerPid} alive but lock age ${Math.round(lockAge / 1000)}s > timeout (likely PID reuse)` };
|
|
51
|
+
} else {
|
|
52
|
+
result = { action: "wait", reason: `PID ${spawnerPid} alive, lock age ${Math.round(lockAge / 1000)}s within threshold` };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
result = { action: "recover", reason: "cannot read/stat lockfile" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
log.debug("evaluate_spawn_lock", result.action, result.reason);
|
|
61
|
+
return result;
|
|
62
|
+
}
|