@lincoln504/pi-research 1.1.2 → 1.2.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.
Files changed (74) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +1287 -540
  3. package/dist/thread-worker.mjs +30 -11
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +19 -9
  6. package/docs/CHANGELOG.md +81 -1
  7. package/docs/CONFIGURATION.md +9 -7
  8. package/docs/KNOWLEDGE-STORE.md +4 -3
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +55 -0
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/cleanup.cjs +50 -10
  14. package/scripts/prepare.cjs +15 -5
  15. package/scripts/setup.cjs +24 -4
  16. package/skills/pi-research/README.md +9 -2
  17. package/skills/pi-research/SKILL.md +6 -3
  18. package/skills/pi-research/references/configuration.md +6 -2
  19. package/skills/pi-research/scripts/run.mjs +24 -2
  20. package/src/cli.ts +149 -21
  21. package/src/config.ts +56 -15
  22. package/src/constants.ts +31 -0
  23. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  24. package/src/core/interfaces/state-manager-interfaces.ts +1 -1
  25. package/src/core/pi-version.ts +136 -0
  26. package/src/core/service-registry.ts +117 -8
  27. package/src/index.ts +11 -18
  28. package/src/infrastructure/browser/browser-client.ts +16 -4
  29. package/src/infrastructure/browser/browser-error-utils.ts +6 -1
  30. package/src/infrastructure/browser/browser-server.ts +17 -5
  31. package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
  32. package/src/infrastructure/browser/cleanup-utils.ts +45 -7
  33. package/src/infrastructure/browser/client-agent.ts +4 -3
  34. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  35. package/src/infrastructure/browser/scheduler-factory.ts +26 -1
  36. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  37. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  38. package/src/infrastructure/browser/thread-worker.mjs +30 -11
  39. package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
  40. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  41. package/src/infrastructure/embedding/embedding-factory.ts +28 -11
  42. package/src/infrastructure/embedding/embedding-server.ts +65 -4
  43. package/src/infrastructure/file-lock-service.ts +223 -26
  44. package/src/infrastructure/process-lifecycle-service.ts +108 -31
  45. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  46. package/src/infrastructure/state/state-manager-service.ts +1 -1
  47. package/src/infrastructure/state/state-manager.ts +19 -20
  48. package/src/infrastructure/types/state-types.ts +9 -0
  49. package/src/knowledge/embedder-init.ts +1 -1
  50. package/src/knowledge/embedder.ts +52 -3
  51. package/src/knowledge/index.ts +1 -1
  52. package/src/knowledge/store.ts +35 -9
  53. package/src/knowledge/webgpu-viability.ts +9 -4
  54. package/src/knowledge/writer-queue.ts +88 -46
  55. package/src/orchestration/deep-research-orchestrator.ts +13 -2
  56. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  57. package/src/orchestration/research-orchestration-service.ts +2 -4
  58. package/src/orchestration/research-synthesis-service.ts +33 -11
  59. package/src/security/cisa-kev.ts +10 -3
  60. package/src/security/github-advisories.ts +18 -7
  61. package/src/security/nvd-client.ts +17 -6
  62. package/src/security/osv-client.ts +14 -4
  63. package/src/skill-install/skill-installer.ts +135 -9
  64. package/src/stackexchange/rest-client.ts +6 -2
  65. package/src/tools/research-knowledge-search.ts +15 -0
  66. package/src/tools/search.ts +6 -1
  67. package/src/utils/atomic-replace.ts +101 -0
  68. package/src/utils/citation-utils.ts +3 -1
  69. package/src/utils/http-body.ts +176 -0
  70. package/src/web-research/browser-search.ts +50 -4
  71. package/src/web-research/search.ts +20 -5
  72. package/src/web-research/types.ts +17 -4
  73. package/src/web-research/web-scraper.ts +7 -50
  74. package/src/youtube/transcript-client.ts +5 -1
@@ -24,6 +24,52 @@ import { cleanupOrphanedCamoufoxProcesses, getBrowserPidsForWorkers, killBrowser
24
24
  import { raceWithDeadline } from '../../utils/safe-unref.ts';
25
25
  import { PriorityTaskQueue } from './priority-task-queue.ts';
26
26
 
27
+ /**
28
+ * Grace added to the worker's own task budget before the dead-worker backstop
29
+ * fires, so a live worker's more informative timeout always answers first.
30
+ */
31
+ export const WORKER_DEATH_BACKSTOP_GRACE_MS = 5000;
32
+
33
+ /**
34
+ * Execute a pool task with a dead-worker backstop.
35
+ *
36
+ * poolifier (measured on 5.3.2) never settles an in-flight execute() whose worker
37
+ * dies — not when the worker exits, not when a replacement is respawned, not even
38
+ * when the pool is destroyed. The worker enforces taskTimeoutMs on itself, but only
39
+ * while it is alive; an OOM-killed worker leaves the promise pending forever, and
40
+ * with it the PriorityTaskQueue concurrency slot the dispatched closure holds. Each
41
+ * such death would permanently shrink the queue until the leader serves nothing but
42
+ * timeouts.
43
+ *
44
+ * The backstop timer is created inside the dispatched closure, so it shares no
45
+ * lifetime with the caller's outer race — whose `finally` clearing a shared timer
46
+ * is exactly how a QUEUED task once lost its guard. Armed at dispatch, it fires
47
+ * only when nothing is left to answer; its rejection frees the queue slot (the
48
+ * caller has already been answered by the outer race).
49
+ */
50
+ export async function executePoolTaskWithDeathBackstop<T>(
51
+ execute: () => Promise<T>,
52
+ taskTimeoutMs: number,
53
+ label: string,
54
+ ): Promise<T> {
55
+ const backstopMs = taskTimeoutMs + WORKER_DEATH_BACKSTOP_GRACE_MS;
56
+ let backstopId: NodeJS.Timeout | undefined;
57
+ try {
58
+ return await Promise.race([
59
+ execute(),
60
+ new Promise<never>((_, reject) => {
61
+ backstopId = setTimeout(
62
+ () => reject(new Error(`${label} task unanswered after ${backstopMs}ms — worker likely died mid-task`)),
63
+ backstopMs,
64
+ );
65
+ if (backstopId.unref) backstopId.unref();
66
+ }),
67
+ ]);
68
+ } finally {
69
+ if (backstopId) clearTimeout(backstopId);
70
+ }
71
+ }
72
+
27
73
  /**
28
74
  * Browser task scheduler - manages the worker pool and executes tasks.
29
75
  * Only the leader process has an instance of this scheduler.
@@ -192,17 +238,29 @@ export class BrowserTaskScheduler implements IScheduler {
192
238
 
193
239
  logger.debug(`[BrowserTaskScheduler] Executing search: "${query}" (Timeout: ${timeoutMs}ms)`);
194
240
  let result: any;
241
+ // Settling the outer race must also release the task's queue claim: a task
242
+ // still QUEUED when the caller is answered is cancelled before it consumes
243
+ // a slot, and a RUNNING one frees its slot instead of keeping it (plus a
244
+ // fresh worker budget) for a result nobody will consume. The abort-while-
245
+ // queued/running semantics are the queue's own, pinned by its tests.
246
+ const settled = new AbortController();
247
+ const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
195
248
  try {
196
249
  const queue = this.getPriorityQueue(config);
197
250
  // Race the enqueue call against the timeoutPromise.
198
251
  // This ensures that even if the queue is saturated, we won't hang forever.
199
252
  result = await Promise.race([
200
253
  queue.enqueue('search', async () => {
201
- return await Promise.race([
202
- pool.execute({ type: 'search', query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
203
- timeoutPromise
204
- ]);
205
- }, signal),
254
+ // The shared timeoutPromise is deliberately NOT raced in here (its timer
255
+ // dies with the outer race); the backstop below carries its own timer, so
256
+ // a QUEUED task can never be dispatched against a disarmed guard, and a
257
+ // worker dying mid-task cannot pin the queue slot forever.
258
+ return await executePoolTaskWithDeathBackstop(
259
+ () => pool.execute({ type: 'search', query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
260
+ timeoutMs,
261
+ 'Search',
262
+ );
263
+ }, queueSignal),
206
264
  timeoutPromise
207
265
  ]);
208
266
  logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${Date.now() - startTime}ms`);
@@ -226,6 +284,7 @@ export class BrowserTaskScheduler implements IScheduler {
226
284
  throw error;
227
285
  } finally {
228
286
  if (timeoutId) clearTimeout(timeoutId);
287
+ settled.abort();
229
288
  }
230
289
 
231
290
  const duration = Date.now() - startTime;
@@ -265,15 +324,20 @@ export class BrowserTaskScheduler implements IScheduler {
265
324
  });
266
325
 
267
326
  let result: any;
327
+ // See runSearch: outer settle releases the queue claim; the backstop bounds
328
+ // a dead worker's never-settling execute().
329
+ const settled = new AbortController();
330
+ const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
268
331
  try {
269
332
  const queue = this.getPriorityQueue(config);
270
333
  result = await Promise.race([
271
334
  queue.enqueue('scrape', async () => {
272
- return await Promise.race([
273
- pool.execute({ type: 'scrape', url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
274
- timeoutPromise
275
- ]);
276
- }, signal),
335
+ return await executePoolTaskWithDeathBackstop(
336
+ () => pool.execute({ type: 'scrape', url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
337
+ timeoutMs,
338
+ 'Scrape',
339
+ );
340
+ }, queueSignal),
277
341
  timeoutPromise
278
342
  ]);
279
343
  } catch (error) {
@@ -292,6 +356,7 @@ export class BrowserTaskScheduler implements IScheduler {
292
356
  throw error;
293
357
  } finally {
294
358
  if (timeoutId) clearTimeout(timeoutId);
359
+ settled.abort();
295
360
  }
296
361
 
297
362
  const duration = Date.now() - startTime;
@@ -324,17 +389,20 @@ export class BrowserTaskScheduler implements IScheduler {
324
389
  });
325
390
 
326
391
  let result: { success: boolean; error?: string };
392
+ // See runSearch: outer settle releases the queue claim; the backstop bounds
393
+ // a dead worker's never-settling execute().
394
+ const settled = new AbortController();
395
+ const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
327
396
  try {
328
397
  const queue = this.getPriorityQueue(config);
329
398
  result = await Promise.race([
330
399
  queue.enqueue('healthcheck', async () => {
331
- const execPromise = pool.execute({ type: 'healthcheck', queuedAt: startTime, taskTimeoutMs: timeoutMs });
332
- execPromise.catch((err: Error) => logger.debug(`[BrowserTaskScheduler] Background healthcheck task rejection: ${err.message}`));
333
- return await Promise.race([
334
- execPromise,
335
- timeoutPromise
336
- ]);
337
- }, signal),
400
+ return await executePoolTaskWithDeathBackstop(() => {
401
+ const execPromise = pool.execute({ type: 'healthcheck', queuedAt: startTime, taskTimeoutMs: timeoutMs });
402
+ execPromise.catch((err: Error) => logger.debug(`[BrowserTaskScheduler] Background healthcheck task rejection: ${err.message}`));
403
+ return execPromise;
404
+ }, timeoutMs, 'Healthcheck');
405
+ }, queueSignal),
338
406
  timeoutPromise
339
407
  ]) as { success: boolean; error?: string };
340
408
  } catch (error) {
@@ -353,6 +421,7 @@ export class BrowserTaskScheduler implements IScheduler {
353
421
  throw error;
354
422
  } finally {
355
423
  if (timeoutId) clearTimeout(timeoutId);
424
+ settled.abort();
356
425
  }
357
426
 
358
427
  const duration = Date.now() - startTime;
@@ -411,12 +480,21 @@ export class BrowserTaskScheduler implements IScheduler {
411
480
  // reference would be moot. Guarding here keeps shutdown from logging a
412
481
  // spurious "Cannot get service 'scheduler' during container disposal".
413
482
  if (!this.container.isDisposing) {
414
- const schedulerService = await getService<ISchedulerInternals>(ServiceNames.SCHEDULER, undefined, this.container);
415
- const currentScheduler = schedulerService.getSchedulerInstance();
416
- if (currentScheduler && 'schedulerId' in currentScheduler && currentScheduler.schedulerId === this.schedulerId) {
417
- schedulerService.setSchedulerInstance(null);
418
- schedulerService.setSchedulerVersion(null);
419
- schedulerService.setSchedulerInitializationPromise(null);
483
+ try {
484
+ const schedulerService = await getService<ISchedulerInternals>(ServiceNames.SCHEDULER, undefined, this.container);
485
+ const currentScheduler = schedulerService.getSchedulerInstance();
486
+ if (currentScheduler && 'schedulerId' in currentScheduler && currentScheduler.schedulerId === this.schedulerId) {
487
+ schedulerService.setSchedulerInstance(null);
488
+ schedulerService.setSchedulerVersion(null);
489
+ schedulerService.setSchedulerInitializationPromise(null);
490
+ }
491
+ } catch (err) {
492
+ // Disposal can begin between the isDisposing check above and the
493
+ // getService call. isShuttingDown is already latched true, so a
494
+ // throw here would abort shutdown permanently — drain, state-clear,
495
+ // server-stop and pool teardown would all be skipped. The reference
496
+ // being unclearable is the same moot case the guard above covers.
497
+ logger.debug('[Scheduler] Could not clear scheduler service reference during shutdown:', err);
420
498
  }
421
499
  }
422
500
 
@@ -9,8 +9,9 @@ const DEFAULT_STALE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
9
9
  * Firefox/Camoufox profile lock files, cross-platform. A live browser holds:
10
10
  * - Unix: `lock` — a SYMLINK whose target encodes the owner: "<ip>:+<pid>" or "+<pid>",
11
11
  * plus `.parentlock` (an fcntl-locked empty file).
12
- * - Windows: `parent.lock` — a file kept OPEN by the live process (so the OS refuses to
13
- * delete it, which is itself a safety net against reclaiming an in-use profile).
12
+ * - Windows: `parent.lock` — a file kept OPEN by the live process. That is a liveness
13
+ * SIGNAL (see isWindowsParentLockHeld), not a safety net: a recursive delete
14
+ * has already removed the rest of the profile before it reaches this file.
14
15
  * The previous code checked for `.lock`, which Firefox/Camoufox never creates, so its
15
16
  * lock-awareness was inert. These are the real names.
16
17
  */
@@ -20,6 +21,37 @@ const LOCK_FILE_NAMES = ['lock', '.parentlock', 'parent.lock'];
20
21
  * so a just-starting concurrent run is not reclaimed out from under itself. */
21
22
  const LOCK_FRESHNESS_MS = 2 * 60 * 1000;
22
23
 
24
+ /**
25
+ * Windows: is `parent.lock` still held open by a live browser?
26
+ *
27
+ * Firefox opens this file with no sharing, so while the browser runs ANY other
28
+ * open of it fails with a sharing violation. That makes the probe positive
29
+ * evidence of a live owner — the same standing as the Unix `lock` symlink's PID.
30
+ *
31
+ * Recency cannot substitute for it here: `parent.lock`'s mtime is stamped once at
32
+ * browser startup and never refreshed, so every browser alive longer than
33
+ * LOCK_FRESHNESS_MS read as dead — the normal case for a minutes-long research
34
+ * run. Nor does "the OS refuses to delete a held-open file" save the profile:
35
+ * `fs.rm({recursive:true})` unlinks entries one at a time, so prefs, cookies and
36
+ * places are already gone by the time it reaches the locked file and fails.
37
+ *
38
+ * Returns null when the answer is not knowable, which callers treat as live —
39
+ * sparing a leaked profile costs disk, deleting a live one corrupts a running run.
40
+ */
41
+ async function isWindowsParentLockHeld(lockPath: string): Promise<boolean | null> {
42
+ let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
43
+ try {
44
+ handle = await fs.open(lockPath, 'r');
45
+ } catch (e) {
46
+ const code = (e as NodeJS.ErrnoException)?.code;
47
+ if (code === 'ENOENT') return false; // vanished — no live owner
48
+ if (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') return true; // held open
49
+ return null; // unexpected — don't guess
50
+ }
51
+ await handle.close().catch(() => {});
52
+ return false;
53
+ }
54
+
23
55
  /** Parse the owner PID out of a Firefox `lock` symlink target ("<ip>:+<pid>" / "+<pid>"). */
24
56
  function parsePidFromLockTarget(target: string): number | null {
25
57
  const m = /\+(\d+)\s*$/.exec(target);
@@ -62,9 +94,15 @@ async function isProfileLive(profileDir: string): Promise<boolean> {
62
94
  // unreadable symlink — fall back to freshness below
63
95
  }
64
96
  }
65
- // `.parentlock` / `parent.lock` (no PID encoded), or an unreadable `lock`: use recency
66
- // as the liveness proxy. On Windows the file is also held open, so an attempted delete
67
- // of a genuinely-live profile fails harmlessly regardless of this heuristic.
97
+ // Windows has no `lock` symlink, so `parent.lock` is the only signal there. Probe
98
+ // whether it is still held open — authoritative, unlike recency, which reads every
99
+ // browser older than LOCK_FRESHNESS_MS as dead because the mtime is never refreshed.
100
+ if (process.platform === 'win32' && name === 'parent.lock') {
101
+ const held = await isWindowsParentLockHeld(lockPath);
102
+ if (held !== null) return held;
103
+ return true; // indeterminate — spare the profile
104
+ }
105
+ // `.parentlock` (no PID encoded) or an unreadable `lock`: use recency as the proxy.
68
106
  return Date.now() - lst.mtimeMs < LOCK_FRESHNESS_MS;
69
107
  }
70
108
  return false;
@@ -127,7 +165,7 @@ export async function cleanupStaleProfiles(
127
165
  logger.debug(`[Cleanup] Skipping profile ${entry} — owned by a live browser`);
128
166
  continue;
129
167
  }
130
- await fs.rm(fullPath, { recursive: true, force: true });
168
+ await fs.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
131
169
  removed++;
132
170
  continue;
133
171
  }
@@ -135,7 +173,7 @@ export async function cleanupStaleProfiles(
135
173
  // Unrecognized entry (only reachable with an explicit baseDir): be conservative —
136
174
  // only reclaim if both old AND not live, so we never delete something unknown promptly.
137
175
  if (now - stats.mtimeMs > staleMs && !(await isProfileLive(fullPath))) {
138
- await fs.rm(fullPath, { recursive: true, force: true });
176
+ await fs.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
139
177
  removed++;
140
178
  }
141
179
  } catch (_e) {
@@ -10,14 +10,15 @@ import * as http from 'node:http';
10
10
  /**
11
11
  * Global HTTP Agent for high-concurrency client requests
12
12
  *
13
- * IMPORTANT: The socket timeout must be longer than the BrowserClient request timeout (120s)
14
- * to prevent premature socket closure when the browser is slow or the queue is deep.
13
+ * IMPORTANT: The socket timeout must be longer than the BrowserClient request timeout (60s,
14
+ * REQUEST_TIMEOUT_MS in browser-client.ts) to prevent premature socket closure when the
15
+ * browser is slow or the queue is deep.
15
16
  */
16
17
  const clientAgent = new http.Agent({
17
18
  keepAlive: true,
18
19
  maxSockets: 100, // Allow up to 100 concurrent requests to the leader
19
20
  maxFreeSockets: 10,
20
- // Set timeout to 180s (3x the client timeout) to handle slow browser responses
21
+ // 180s (3x the client timeout) to handle slow browser responses
21
22
  // and prevent "socket hang up" errors during peak load
22
23
  timeout: 180000
23
24
  });
@@ -149,11 +149,18 @@ export class PriorityTaskQueue {
149
149
  // on its own; we swallow its late settlement and free the slot now so the
150
150
  // queue can dispatch the next task immediately.
151
151
  let onAbort: (() => void) | undefined;
152
- const fnPromise = task.fn();
153
- // Prevent an unhandled rejection if abort wins the race and fn() rejects later.
154
- fnPromise.catch(() => { /* handled via the race below or abandoned on abort */ });
155
152
 
156
153
  try {
154
+ // task.fn() is invoked INSIDE the try: a non-async fn that throws
155
+ // synchronously would otherwise escape before the finally that decrements
156
+ // activeCount, permanently inflating the active count (starving the queue
157
+ // by one slot each time) and leaving the caller's enqueue() promise
158
+ // unsettled. Every fn today is async, so this is a latent shape defect
159
+ // rather than a live one — but the correct placement costs nothing.
160
+ const fnPromise = task.fn();
161
+ // Prevent an unhandled rejection if abort wins the race and fn() rejects later.
162
+ fnPromise.catch(() => { /* handled via the race below or abandoned on abort */ });
163
+
157
164
  let result: any;
158
165
  if (task.signal) {
159
166
  const abortPromise = new Promise<never>((_, rej) => {
@@ -282,6 +282,25 @@ export async function getScheduler(config?: Config, container: ServiceContainer
282
282
  let wonElection = false;
283
283
  let winnerPort = port;
284
284
  let winnerAuthSecret: string | undefined;
285
+ // Read our start-time BEFORE taking the state lock (the lookup can shell
286
+ // out on macOS/Windows; getCurrentProcessStartTime caches, so this is a
287
+ // single cheap read). Recording it alongside the PID is what makes
288
+ // StateManager.isPidAlive's PID-reuse guard work: that guard passes
289
+ // `state.browserServer.startTime` as expectedStartTime, so an election
290
+ // that writes only the PID leaves it permanently undefined and the guard
291
+ // degrades to a bare signal-0 check — which a recycled PID passes,
292
+ // making every follower adopt an unrelated process as the browser leader.
293
+ // Null on a platform where it cannot be read → the entry omits it and
294
+ // liveness falls back to that bare check, same as pre-existing entries.
295
+ const myStartTime = await (async () => {
296
+ try {
297
+ const processLifecycle = await getService<IProcessLifecycle>(ServiceNames.PROCESS_LIFECYCLE, undefined, container);
298
+ return await processLifecycle.getCurrentProcessStartTime();
299
+ } catch (err) {
300
+ logger.debug('[Scheduler] Could not read own process start time (PID-reuse guard degrades to signal-0):', err);
301
+ return null;
302
+ }
303
+ })();
285
304
  try {
286
305
  await stateManager.updateState(async (state) => {
287
306
  if (state.browserServer) {
@@ -294,7 +313,13 @@ export async function getScheduler(config?: Config, container: ServiceContainer
294
313
  }
295
314
  }
296
315
  const secret = getBrowserServerAuthSecret();
297
- state.browserServer = { port, pid: process.pid, schedulerId, authSecret: secret };
316
+ state.browserServer = {
317
+ port,
318
+ pid: process.pid,
319
+ schedulerId,
320
+ authSecret: secret,
321
+ ...(myStartTime !== null ? { startTime: myStartTime } : {}),
322
+ };
298
323
  state.schedulerVersion = schedulerVersion;
299
324
  wonElection = true;
300
325
  return state;
@@ -17,6 +17,9 @@ import { getServiceContainer, getService } from '../../core/service-registry.ts'
17
17
  import type { ServiceContainer } from '../../core/service-registry.ts';
18
18
  import { ServiceNames } from '../../core/service-interfaces.ts';
19
19
  import type { ISchedulerFactory, IScheduler } from '../../core/scheduler-factory.ts';
20
+ import type { ISchedulerInternals } from '../../core/interfaces/scheduler-interfaces.ts';
21
+ import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
22
+ import { BrowserClient } from './browser-client.ts';
20
23
 
21
24
  // Cooldown to prevent cascading scheduler restarts (thundering herd)
22
25
  let lastRestartTime = 0;
@@ -62,6 +65,92 @@ async function recoverFromLeaderHandover(container: ServiceContainer): Promise<v
62
65
  );
63
66
  }
64
67
 
68
+ /**
69
+ * Is the leader currently registered in state still a live process — and is it
70
+ * the one our cached handle actually targets?
71
+ *
72
+ * Used only to decide whether the restart cooldown below may be honoured.
73
+ * Anything that prevents a confident "yes" (no registered leader, an unreadable
74
+ * state file, a service-resolution failure, a cached client aimed at a different
75
+ * port than the registered leader's) answers `false`, because the cost of an
76
+ * unnecessary re-resolve is one cheap round-trip while the cost of skipping a
77
+ * needed one is a task failure against a dead port.
78
+ */
79
+ async function isRegisteredLeaderAliveUncached(container: ServiceContainer): Promise<boolean> {
80
+ try {
81
+ const stateManager = await getService<IStateManager>(ServiceNames.STATE_MANAGER, undefined, container);
82
+ const info = await stateManager.getBrowserServer();
83
+ if (!info) return false;
84
+ // "Leader alive" is not enough: the cooldown protects the CACHED handle, so
85
+ // that handle must also target the registered leader. If our leader B died
86
+ // and another process already elected C, C is alive — but our cached client
87
+ // still aims at B's dead port, and honouring the cooldown would leave every
88
+ // retry failing against the corpse until the window expired.
89
+ const schedulerService = await getService<ISchedulerInternals>(ServiceNames.SCHEDULER, undefined, container);
90
+ const cached = schedulerService.getSchedulerInstance();
91
+ if (cached instanceof BrowserClient && cached.targetPort !== info.port) return false;
92
+ return await stateManager.isPidAlive(info.pid, info.schedulerId);
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Coalesce concurrent liveness probes.
100
+ *
101
+ * This runs on the failure path, where a whole query burst fails at once: a
102
+ * 20-query search losing its leader calls it up to 19 times within milliseconds
103
+ * (the first caller restarts and the rest land inside the cooldown). The probe is
104
+ * cheap on Linux — a `/proc/<pid>/stat` read — but on Windows the PID-reuse guard
105
+ * resolves the process start time by spawning `powershell -NoProfile`, so an
106
+ * uncoalesced burst would fan out into ~19 concurrent PowerShell processes on the
107
+ * slowest platform we support. The window is deliberately far shorter than
108
+ * RESTART_COOLDOWN_MS: it exists to collapse one burst, not to cache a verdict.
109
+ */
110
+ const LEADER_LIVENESS_COALESCE_MS = 1000;
111
+ let leaderLivenessProbe: { startedAt: number; result: Promise<boolean> } | null = null;
112
+
113
+ async function isRegisteredLeaderAlive(container: ServiceContainer): Promise<boolean> {
114
+ const now = Date.now();
115
+ if (leaderLivenessProbe && now - leaderLivenessProbe.startedAt < LEADER_LIVENESS_COALESCE_MS) {
116
+ return leaderLivenessProbe.result;
117
+ }
118
+ const result = isRegisteredLeaderAliveUncached(container);
119
+ leaderLivenessProbe = { startedAt: now, result };
120
+ return result;
121
+ }
122
+
123
+ /** Test seam: drop the coalesced liveness probe. */
124
+ export function _resetLeaderLivenessProbeForTests(): void {
125
+ leaderLivenessProbe = null;
126
+ }
127
+
128
+ /**
129
+ * Drop the scheduler handle and wait for the pool to settle, subject to a
130
+ * cooldown that stops N concurrent task failures from each restarting.
131
+ *
132
+ * The cooldown is only safe while the registered leader is still alive: then the
133
+ * cached handle points at a working process and skipping the restart costs
134
+ * nothing. It is NOT safe when the leader is gone. A hard leader kill (SIGKILL,
135
+ * OOM) surfaces to clients as ECONNREFUSED, which `isTransientSocketError`
136
+ * matches but `isPoolShutdownError` does not — so it lands here rather than on
137
+ * the handover path, and a bare wait leaves the cached client aimed at a corpse.
138
+ * The retry then re-resolves to the same dead port and fails identically: the
139
+ * exact stale-handle failure the leader-handover work fixed for the *graceful*
140
+ * shutdown case. So the cooldown yields whenever the leader is not confirmed alive.
141
+ */
142
+ async function restartSchedulerWithHerdGuard(container: ServiceContainer): Promise<void> {
143
+ const now = Date.now();
144
+ if (now - lastRestartTime > RESTART_COOLDOWN_MS || !(await isRegisteredLeaderAlive(container))) {
145
+ lastRestartTime = now;
146
+ logger.warn('[BrowserManager] Forcing scheduler restart and retrying...');
147
+ await forceSchedulerRestart(false, container);
148
+ } else {
149
+ logger.warn('[BrowserManager] Scheduler restart triggered recently and the registered leader is still alive — waiting for pool idle instead...');
150
+ }
151
+ await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
152
+ }
153
+
65
154
  /**
66
155
  * Get the scheduler from the factory, optionally using a specific container.
67
156
  */
@@ -163,16 +252,7 @@ export async function runBrowserTask<T>(
163
252
  // PID+start-time liveness probe confirms the registered leader is
164
253
  // actually gone; it only skips clearing when that probe finds the
165
254
  // leader alive, which is exactly the case we must not disturb.
166
- const now = Date.now();
167
- if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
168
- lastRestartTime = now;
169
- logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
170
- await forceSchedulerRestart(false, container);
171
- await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
172
- } else {
173
- logger.warn(`[BrowserManager] Scheduler restart recently triggered, waiting for pool idle...`);
174
- await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
175
- }
255
+ await restartSchedulerWithHerdGuard(container);
176
256
  }
177
257
 
178
258
  // Retry with jitter (100-500ms) to prevent thundering herd
@@ -211,15 +291,7 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
211
291
  // See runBrowserTask: don't force-clear remote leader state on a bare
212
292
  // transient socket error — let the liveness probe inside
213
293
  // forceSchedulerRestart decide.
214
- const now = Date.now();
215
- if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
216
- lastRestartTime = now;
217
- logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
218
- await forceSchedulerRestart(false, container);
219
- await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
220
- } else {
221
- await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
222
- }
294
+ await restartSchedulerWithHerdGuard(container);
223
295
  }
224
296
 
225
297
  const jitter = 100 + Math.floor(Math.random() * 400);
@@ -278,15 +350,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
278
350
  // See runBrowserTask: don't force-clear remote leader state on a bare
279
351
  // transient socket error — let the liveness probe inside
280
352
  // forceSchedulerRestart decide.
281
- const now = Date.now();
282
- if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
283
- lastRestartTime = now;
284
- logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
285
- await forceSchedulerRestart(false, container);
286
- await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
287
- } else {
288
- await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
289
- }
353
+ await restartSchedulerWithHerdGuard(container);
290
354
  }
291
355
 
292
356
  const jitter = 100 + Math.floor(Math.random() * 400);
@@ -206,14 +206,60 @@ export function setupOrphanProtection(): void {
206
206
  return;
207
207
  }
208
208
 
209
- if (!process.ppid) {
209
+ // Capture the parent PID ONCE, at setup. `process.ppid` is re-read from the OS
210
+ // on every access, so on POSIX it changes the moment we are orphaned — and that
211
+ // change, not a liveness probe, is the reliable signal (see checkOrphan).
212
+ const originalPpid = process.ppid;
213
+ if (!originalPpid) {
210
214
  return;
211
215
  }
212
216
 
217
+ /** Tear down the browser and exit. Shared by both orphan signals below. */
218
+ const exitAsOrphan = async () => {
219
+ // Await cleanup to prevent browser/context leaks — but bounded. The browser is
220
+ // the plausible thing to be wedged after the parent died abruptly, and an
221
+ // unbounded browser.close() hang here would keep the orphan (and its browser)
222
+ // alive forever: the precise leak this guard exists to stop. The exit deadline
223
+ // wins over a perfect teardown.
224
+ if (cleanupBrowser) {
225
+ await Promise.race([
226
+ cleanupBrowser().catch(err => {
227
+ logToDebugFile('WARN', `[Worker-${workerId}] Browser cleanup failed during orphan exit:`, err);
228
+ }),
229
+ new Promise<void>(resolve => {
230
+ const t = setTimeout(resolve, 10_000);
231
+ if (t.unref) t.unref();
232
+ }),
233
+ ]);
234
+ }
235
+ // Clear the orphan check timer
236
+ cleanupOrphanProtection();
237
+ process.env['PI_PROCESS_EXITING'] = '1';
238
+ process.exit(1);
239
+ };
240
+
213
241
  const checkOrphan = async () => {
214
242
  try {
215
- // signal 0 checks if the process is alive
216
- process.kill(process.ppid, 0);
243
+ // Re-parenting is the authoritative orphan signal on POSIX. When the parent
244
+ // dies the kernel immediately re-parents us — to PID 1, or to the nearest
245
+ // subreaper (a user systemd instance, a container init). Probing the CURRENT
246
+ // ppid cannot see that: PID 1 always exists, and signalling it from an
247
+ // unprivileged process raises EPERM, which the handler below correctly reads
248
+ // as "alive but not ours to signal". Net effect on Linux was that this guard
249
+ // never fired at all and orphaned workers kept their browsers running.
250
+ // Comparing against the PID captured at setup detects it directly.
251
+ // Windows does not re-parent, so the liveness probe below remains the
252
+ // mechanism there and this check simply never trips.
253
+ if (process.ppid !== originalPpid) {
254
+ logToDebugFile('WARN', `[Worker-${workerId}] Re-parented (${originalPpid} -> ${process.ppid}): parent died, shutting down...`);
255
+ await exitAsOrphan();
256
+ return;
257
+ }
258
+
259
+ // signal 0 checks if the process is alive. Probe the ORIGINAL ppid, never
260
+ // `process.ppid` — after a re-parent the latter points at init, not at the
261
+ // process whose liveness we actually care about.
262
+ process.kill(originalPpid, 0);
217
263
 
218
264
  // Schedule next check
219
265
  orphanCheckTimer = setTimeout(checkOrphan, 10000);
@@ -231,16 +277,7 @@ export function setupOrphanProtection(): void {
231
277
  }
232
278
  // If error is thrown, the parent process is likely dead or unreachable
233
279
  logToDebugFile('WARN', `[Worker-${workerId}] Parent process died or unreachable (orphaned), shutting down...`);
234
- // FIX: Await cleanup to prevent browser/context leaks
235
- if (cleanupBrowser) {
236
- await cleanupBrowser().catch(err => {
237
- logToDebugFile('WARN', `[Worker-${workerId}] Browser cleanup failed during orphan exit:`, err);
238
- });
239
- }
240
- // Clear the orphan check timer
241
- cleanupOrphanProtection();
242
- process.env['PI_PROCESS_EXITING'] = '1';
243
- process.exit(1);
280
+ await exitAsOrphan();
244
281
  }
245
282
  };
246
283