@lincoln504/pi-research 0.1.12 → 1.0.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.
- package/LICENSE +1 -1
- package/README.md +64 -115
- package/dist/cli.mjs +23211 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +102 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +213 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +113 -0
- package/docs/THIRD-PARTY-NOTICES.md +64 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1044 -0
- package/src/config.ts +1036 -147
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +264 -7
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -174
- package/src/infrastructure/searxng-lifecycle.ts +0 -577
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- package/tsconfig.json +0 -40
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding Server
|
|
3
|
+
*
|
|
4
|
+
* Wraps a single Embedder instance behind an HTTP server so all other
|
|
5
|
+
* processes can share one GPU context. Mirrors BrowserTaskScheduler.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as http from 'node:http';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { logger } from '../../logger.ts';
|
|
11
|
+
import { captureStdio } from '../../utils/stdio-capture.ts';
|
|
12
|
+
import { buildDefaultDebugLogPath } from '../../utils/log-utils.ts';
|
|
13
|
+
import { DiskSpaceChecker } from '../../utils/disk-space-checker.ts';
|
|
14
|
+
import type { IEmbedder } from '../../core/interfaces/knowledge-interfaces.ts';
|
|
15
|
+
import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
|
|
16
|
+
import type { Embedder } from '../../knowledge/embedder.ts';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// SerialQueue — ensures embed/embedMany never run concurrently on the GPU
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
class SerialQueue {
|
|
23
|
+
private running = false;
|
|
24
|
+
private readonly tasks: Array<{ run: () => Promise<void>; fail: (e: Error) => void }> = [];
|
|
25
|
+
private readonly maxDepth: number;
|
|
26
|
+
private readonly timeoutMs: number;
|
|
27
|
+
private readonly hardDeadlineMs: number;
|
|
28
|
+
private readonly onPoison?: (err: Error) => void;
|
|
29
|
+
private poisoned: Error | null = null;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
maxDepth = 200,
|
|
33
|
+
timeoutMs = 120000,
|
|
34
|
+
// A single embed/embedMany that has still not settled after this long is
|
|
35
|
+
// genuinely hung (e.g. a WebGPU device-lost stall), not merely slow. Kept well
|
|
36
|
+
// above the soft caller timeout so a legitimately slow CPU batch is never
|
|
37
|
+
// false-poisoned. On expiry the queue is POISONED rather than the slot freed:
|
|
38
|
+
// freeing the slot would start a second inference concurrently with the stuck
|
|
39
|
+
// one on the re-entrant-locked GPU context (the native-segfault class this
|
|
40
|
+
// queue exists to prevent), and the native call cannot be cancelled in-process.
|
|
41
|
+
hardDeadlineMs = 600000,
|
|
42
|
+
onPoison?: (err: Error) => void,
|
|
43
|
+
) {
|
|
44
|
+
this.maxDepth = maxDepth;
|
|
45
|
+
this.timeoutMs = timeoutMs;
|
|
46
|
+
this.hardDeadlineMs = hardDeadlineMs;
|
|
47
|
+
this.onPoison = onPoison;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
isPoisoned(): boolean {
|
|
51
|
+
return this.poisoned !== null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
|
55
|
+
// Once poisoned (a prior inference hung permanently) the queue can never run
|
|
56
|
+
// another task without risking concurrent GPU inference on the stuck context,
|
|
57
|
+
// so fail every caller fast instead of letting them hang indefinitely.
|
|
58
|
+
if (this.poisoned) return Promise.reject(this.poisoned);
|
|
59
|
+
if (this.tasks.length >= this.maxDepth) {
|
|
60
|
+
return Promise.reject(new Error(`SerialQueue at capacity (${this.maxDepth}). Embed request dropped.`));
|
|
61
|
+
}
|
|
62
|
+
return new Promise<T>((resolve, reject) => {
|
|
63
|
+
const run = async () => {
|
|
64
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
65
|
+
const timeoutPromise = new Promise<never>((_, rej) => {
|
|
66
|
+
timer = setTimeout(() => rej(new Error(`SerialQueue: embed request timed out after ${this.timeoutMs}ms`)), this.timeoutMs);
|
|
67
|
+
});
|
|
68
|
+
// Start the real GPU work once and keep a handle to it. On timeout we
|
|
69
|
+
// report to the caller promptly, but must NOT let pump() advance to the
|
|
70
|
+
// next task while this inference is still executing — Promise.race does
|
|
71
|
+
// not cancel the loser, and the same-PID GPU lock is re-entrant, so this
|
|
72
|
+
// queue is the only guard against concurrent GPU inference (the class of
|
|
73
|
+
// bug that previously caused native segfaults). fn() is invoked inside the
|
|
74
|
+
// try so a synchronous throw rejects the caller cleanly (run() itself
|
|
75
|
+
// never rejects, so pump()'s `await run()` can't produce an unhandled
|
|
76
|
+
// rejection).
|
|
77
|
+
let work: Promise<T> | undefined;
|
|
78
|
+
let settled = false;
|
|
79
|
+
let hardTimer: ReturnType<typeof setTimeout> | undefined;
|
|
80
|
+
try {
|
|
81
|
+
work = fn();
|
|
82
|
+
// Arm the hard-deadline watchdog from the moment the real GPU work starts, so
|
|
83
|
+
// the cap is an honest wall-clock deadline measured from work-start rather than
|
|
84
|
+
// (soft-timeout + hardDeadlineMs). A single inference still unsettled after
|
|
85
|
+
// hardDeadlineMs is genuinely hung (e.g. a WebGPU device-lost stall); on expiry
|
|
86
|
+
// the queue is POISONED rather than the slot freed — freeing it would start a
|
|
87
|
+
// second inference concurrently with the stuck one on the re-entrant GPU lock.
|
|
88
|
+
hardTimer = setTimeout(() => {
|
|
89
|
+
if (settled) return;
|
|
90
|
+
this.poison(new Error(
|
|
91
|
+
`SerialQueue: embed permanently hung (exceeded hard deadline ${this.hardDeadlineMs}ms) — embedder poisoned; leader stepping down`,
|
|
92
|
+
));
|
|
93
|
+
}, this.hardDeadlineMs);
|
|
94
|
+
hardTimer.unref?.();
|
|
95
|
+
resolve(await Promise.race([work, timeoutPromise]));
|
|
96
|
+
} catch (e) {
|
|
97
|
+
reject(e);
|
|
98
|
+
} finally {
|
|
99
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
// Hold the queue slot until the actual work settles, even past a reported (soft)
|
|
102
|
+
// timeout, to preserve the GPU-serialization invariant. The hard-deadline watchdog
|
|
103
|
+
// armed above is the backstop: a genuinely hung inference never settles and would
|
|
104
|
+
// otherwise wedge the whole queue (and, via the leader's HTTP endpoint, every other
|
|
105
|
+
// process) forever, so it poisons the queue once the work outlives hardDeadlineMs.
|
|
106
|
+
if (work) {
|
|
107
|
+
try {
|
|
108
|
+
await work.catch(() => {});
|
|
109
|
+
} finally {
|
|
110
|
+
settled = true;
|
|
111
|
+
if (hardTimer !== undefined) clearTimeout(hardTimer);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
this.tasks.push({ run, fail: reject });
|
|
116
|
+
if (!this.running) void this.pump();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private poison(err: Error): void {
|
|
121
|
+
if (this.poisoned) return;
|
|
122
|
+
this.poisoned = err;
|
|
123
|
+
logger.error(`[SerialQueue] ${err.message}`);
|
|
124
|
+
// Fast-fail everything already queued behind the hung task — their per-task
|
|
125
|
+
// timeout timers never armed (the timer is created only once a task starts
|
|
126
|
+
// running), so without this they would hang with no timeout at all.
|
|
127
|
+
const queued = this.tasks.splice(0, this.tasks.length);
|
|
128
|
+
for (const entry of queued) {
|
|
129
|
+
try { entry.fail(err); } catch { /* caller already settled */ }
|
|
130
|
+
}
|
|
131
|
+
try { this.onPoison?.(err); } catch (e) { logger.warn('[SerialQueue] onPoison hook failed:', e); }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async pump(): Promise<void> {
|
|
135
|
+
// FIX (#19): Guard against empty array (edge case from concurrent enqueue + pump)
|
|
136
|
+
if (this.running) return;
|
|
137
|
+
this.running = true;
|
|
138
|
+
try {
|
|
139
|
+
while (this.tasks.length > 0) {
|
|
140
|
+
const entry = this.tasks.shift();
|
|
141
|
+
if (entry) {
|
|
142
|
+
await entry.run();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} finally {
|
|
146
|
+
this.running = false;
|
|
147
|
+
// If tasks were enqueued while we were finishing, pump again
|
|
148
|
+
if (this.tasks.length > 0) {
|
|
149
|
+
void this.pump();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Exported under a _-prefixed alias for focused unit testing of the
|
|
156
|
+
// serialization invariant; the class itself stays internal.
|
|
157
|
+
export { SerialQueue as _SerialQueue };
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// EmbeddingServer
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export class EmbeddingServer implements IEmbedder {
|
|
164
|
+
private server: http.Server | null = null;
|
|
165
|
+
private leadershipTimer: ReturnType<typeof setTimeout> | null = null;
|
|
166
|
+
private consecutiveLeadershipMisses = 0;
|
|
167
|
+
private readonly LEADERSHIP_MISS_THRESHOLD = 3;
|
|
168
|
+
private isShuttingDown = false;
|
|
169
|
+
private readonly queue: SerialQueue;
|
|
170
|
+
|
|
171
|
+
// Dimension field — set by store.ts from table schema before embedder is warmed up,
|
|
172
|
+
// and updated after embedder gets its own dimension at initialization.
|
|
173
|
+
dimension: number | null = null;
|
|
174
|
+
|
|
175
|
+
constructor(
|
|
176
|
+
private readonly embedder: Embedder,
|
|
177
|
+
private readonly stateManager: IStateManager,
|
|
178
|
+
public readonly serverId: string,
|
|
179
|
+
// Invoked when the embedding queue poisons itself after a permanently-hung
|
|
180
|
+
// inference — lets the factory drop this now-leaderless singleton so the next
|
|
181
|
+
// getEmbedder() re-elects a fresh leader in a clean process.
|
|
182
|
+
private readonly externalOnPoison?: (err: Error) => void,
|
|
183
|
+
) {
|
|
184
|
+
// Wire the queue's poison hook to step this leader down (without disposing the
|
|
185
|
+
// stuck embedder) so a fresh process can re-elect with a clean GPU context.
|
|
186
|
+
this.queue = new SerialQueue(200, 120000, 600000, (err) => this.handleQueuePoisoned(err));
|
|
187
|
+
// Leadership check is started externally (by embedding-factory) after the
|
|
188
|
+
// election is won. Starting it here would cause false misses because model
|
|
189
|
+
// init (inside startServer) can take longer than the 30s check interval.
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- IEmbedder ----
|
|
193
|
+
|
|
194
|
+
getDevice(): string {
|
|
195
|
+
return this.embedder.isInitialized() ? this.embedder.getDevice() : 'unknown';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
getOriginalDevice(): string {
|
|
199
|
+
return this.embedder.isInitialized() ? this.embedder.getOriginalDevice() : 'unknown';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
getDimension(): number | null {
|
|
203
|
+
if (this.dimension !== null) return this.dimension;
|
|
204
|
+
return this.embedder.isInitialized() ? this.embedder.getDimension() : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
setDimension(dim: number): void {
|
|
208
|
+
this.dimension = dim;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
isInitialized(): boolean {
|
|
212
|
+
return this.embedder.isInitialized();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async embed(text: string): Promise<Float32Array> {
|
|
216
|
+
this.assertServing();
|
|
217
|
+
return this.queue.enqueue(() => this.embedder.embed(text));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async embedMany(texts: string[]): Promise<(Float32Array | number[])[]> {
|
|
221
|
+
this.assertServing();
|
|
222
|
+
return this.queue.enqueue(() => this.embedder.embedMany(texts));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Refuse to serve once this server has stepped down (leadership lost or queue
|
|
226
|
+
// poisoned). Throwing an ECONNREFUSED-bearing error — the same signal a dead
|
|
227
|
+
// remote leader produces — drives the store's withEmbedderReconnect to re-resolve
|
|
228
|
+
// through the factory and become a client of the NEW leader, instead of silently
|
|
229
|
+
// re-initializing this disposed embedder and re-acquiring the GPU in a process
|
|
230
|
+
// that is no longer the leader (which would break the single-GPU-context invariant).
|
|
231
|
+
private assertServing(): void {
|
|
232
|
+
if (this.isShuttingDown) {
|
|
233
|
+
throw new Error('[EmbeddingServer] embedding endpoint gone (ECONNREFUSED): leader stepped down — reconnect required');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private handleQueuePoisoned(err: Error): void {
|
|
238
|
+
logger.error(
|
|
239
|
+
'[EmbeddingServer] Embedding queue poisoned by a permanently hung inference. ' +
|
|
240
|
+
'Stepping down as leader so a fresh process can re-elect with a clean GPU context. ' +
|
|
241
|
+
err.message,
|
|
242
|
+
);
|
|
243
|
+
// Step down WITHOUT disposing the embedder: pipeline.dispose() would join the
|
|
244
|
+
// stuck native thread (itself hanging) or touch a device-lost context. Clearing
|
|
245
|
+
// the state registration + closing the HTTP server is enough for re-election;
|
|
246
|
+
// the wedged native resources are abandoned to eventual process exit.
|
|
247
|
+
void this.shutdown(false);
|
|
248
|
+
this.externalOnPoison?.(err);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async dispose(): Promise<void> {
|
|
252
|
+
return this.shutdown();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---- Server lifecycle ----
|
|
256
|
+
|
|
257
|
+
async startServer(): Promise<number> {
|
|
258
|
+
// Wrap embedder initialization in a dedicated captureStdio scope keyed by
|
|
259
|
+
// serverId. This ensures native C++ output (Dawn/ONNX limit-clamping warnings
|
|
260
|
+
// written directly to FD 2) is redirected to the log file regardless of whether
|
|
261
|
+
// a research session's outer captureStdio is already active — the serverId key
|
|
262
|
+
// bypasses the global isAnyLoggerCapturingOutput guard.
|
|
263
|
+
const diskChecker = new DiskSpaceChecker();
|
|
264
|
+
// Consolidated log: the embedding server writes into the same file as the
|
|
265
|
+
// main process (path defaults to tmpdir, overridable via PI_RESEARCH_LOG_PATH).
|
|
266
|
+
const logFile = buildDefaultDebugLogPath();
|
|
267
|
+
await captureStdio(
|
|
268
|
+
logFile,
|
|
269
|
+
() => diskChecker.checkDiskSpace(path.dirname(logFile)),
|
|
270
|
+
() => this.embedder.initialize(),
|
|
271
|
+
this.serverId,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// Capture dimension after initialization
|
|
275
|
+
this.dimension = this.embedder.getDimension();
|
|
276
|
+
|
|
277
|
+
return new Promise((resolve, reject) => {
|
|
278
|
+
this.server = http.createServer((req, res) => {
|
|
279
|
+
void this.handleRequest(req, res);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
this.server.listen(0, '127.0.0.1', () => {
|
|
283
|
+
const addr = this.server?.address();
|
|
284
|
+
if (addr && typeof addr === 'object') {
|
|
285
|
+
logger.info(`[EmbeddingServer] Listening on http://127.0.0.1:${addr.port} (serverId: ${this.serverId})`);
|
|
286
|
+
resolve(addr.port);
|
|
287
|
+
} else {
|
|
288
|
+
reject(new Error('[EmbeddingServer] Failed to get server port'));
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
this.server.on('error', (err) => {
|
|
293
|
+
logger.error('[EmbeddingServer] Server error:', err);
|
|
294
|
+
reject(err);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async shutdown(disposeEmbedder = true): Promise<void> {
|
|
300
|
+
if (this.isShuttingDown) return;
|
|
301
|
+
this.isShuttingDown = true;
|
|
302
|
+
|
|
303
|
+
if (this.leadershipTimer) {
|
|
304
|
+
clearTimeout(this.leadershipTimer);
|
|
305
|
+
this.leadershipTimer = null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Clear our registration if we still own it
|
|
309
|
+
try {
|
|
310
|
+
const serverInfo = await this.stateManager.getEmbeddingServer();
|
|
311
|
+
if (serverInfo?.serverId === this.serverId) {
|
|
312
|
+
await this.stateManager.clearEmbeddingServer().catch((err) => {
|
|
313
|
+
logger.warn('[EmbeddingServer] Failed to clear embedding server state:', err);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
} catch (err) {
|
|
317
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
318
|
+
// State manager is torn down before the embedding server during process exit —
|
|
319
|
+
// this is expected and harmless; log at debug to avoid noise.
|
|
320
|
+
if (msg.includes('not initialized') || msg.includes('State manager')) {
|
|
321
|
+
logger.debug('[EmbeddingServer] State manager already disposed during shutdown, skipping registration cleanup.');
|
|
322
|
+
} else {
|
|
323
|
+
logger.warn('[EmbeddingServer] Could not read embedding server state during shutdown:', err);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (this.server) {
|
|
328
|
+
this.server.closeAllConnections?.();
|
|
329
|
+
const srv = this.server;
|
|
330
|
+
// Bound server.close(): a lingering keep-alive client could otherwise hang
|
|
331
|
+
// the callback forever and block process shutdown. Force teardown after 5s.
|
|
332
|
+
await new Promise<void>((resolve) => {
|
|
333
|
+
let settled = false;
|
|
334
|
+
const done = () => { if (!settled) { settled = true; resolve(); } };
|
|
335
|
+
const timer = setTimeout(() => {
|
|
336
|
+
logger.warn('[EmbeddingServer] server.close() timed out after 5s; forcing teardown.');
|
|
337
|
+
done();
|
|
338
|
+
}, 5000);
|
|
339
|
+
timer.unref?.();
|
|
340
|
+
try {
|
|
341
|
+
srv.close(() => { clearTimeout(timer); done(); });
|
|
342
|
+
} catch {
|
|
343
|
+
clearTimeout(timer); done();
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
this.server = null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Skip embedder disposal on the poison path (disposeEmbedder=false): the native
|
|
350
|
+
// inference is wedged, so pipeline.dispose()/ReleaseSession would join the stuck
|
|
351
|
+
// thread and hang the whole teardown. Registration is already cleared above, which
|
|
352
|
+
// is enough for another process to re-elect; the leaked native resources die with
|
|
353
|
+
// eventual process exit.
|
|
354
|
+
if (disposeEmbedder) {
|
|
355
|
+
await this.embedder.dispose().catch((err) => {
|
|
356
|
+
if (this.isShuttingDown) {
|
|
357
|
+
logger.debug('[EmbeddingServer] Error disposing embedder during shutdown (expected):', err);
|
|
358
|
+
} else {
|
|
359
|
+
logger.warn('[EmbeddingServer] Error disposing embedder:', err);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private async getEmbeddingServerWithRetry(retries = 2, delay = 300): Promise<{ port: number; pid: number; serverId: string } | null> {
|
|
366
|
+
for (let i = 0; i <= retries; i++) {
|
|
367
|
+
const serverInfo = await this.stateManager.getEmbeddingServer();
|
|
368
|
+
if (serverInfo) return serverInfo;
|
|
369
|
+
if (i < retries) {
|
|
370
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---- Leadership check ----
|
|
377
|
+
|
|
378
|
+
startLeadershipCheck(): void {
|
|
379
|
+
const check = async () => {
|
|
380
|
+
if (this.isShuttingDown) return;
|
|
381
|
+
try {
|
|
382
|
+
const serverInfo = await this.getEmbeddingServerWithRetry();
|
|
383
|
+
|
|
384
|
+
// Resilience: Skip check if server info is transiently unavailable after retries.
|
|
385
|
+
// This is a benign, explicitly-handled condition (the state entry can be briefly
|
|
386
|
+
// absent while a concurrent run rewrites shared state), so log at DEBUG — at WARN
|
|
387
|
+
// it spams the log every 30s for the life of the process.
|
|
388
|
+
if (!serverInfo) {
|
|
389
|
+
logger.debug('[EmbeddingServer] Leadership check: no embedding server found in state after retries. Skipping check.');
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (serverInfo.serverId !== this.serverId) {
|
|
394
|
+
this.consecutiveLeadershipMisses++;
|
|
395
|
+
logger.warn(
|
|
396
|
+
`[EmbeddingServer] Leadership check failed (${this.consecutiveLeadershipMisses}/${this.LEADERSHIP_MISS_THRESHOLD}) — ` +
|
|
397
|
+
`expected ${this.serverId}, found ${serverInfo?.serverId ?? 'none'}`,
|
|
398
|
+
);
|
|
399
|
+
if (this.consecutiveLeadershipMisses >= this.LEADERSHIP_MISS_THRESHOLD) {
|
|
400
|
+
// Another process won leadership; step down. This is an orderly, expected
|
|
401
|
+
// handoff (not a fault), so it logs at WARN to match the surrounding
|
|
402
|
+
// leadership-check severity (DEBUG/WARN above) rather than polluting the
|
|
403
|
+
// ERROR log — which also re-tracks into the diagnostic error count.
|
|
404
|
+
logger.warn('[EmbeddingServer] Leadership lost, shutting down...');
|
|
405
|
+
void this.shutdown();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
if (this.consecutiveLeadershipMisses > 0) {
|
|
410
|
+
logger.log(`[EmbeddingServer] Leadership confirmed, resetting miss counter from ${this.consecutiveLeadershipMisses}`);
|
|
411
|
+
this.consecutiveLeadershipMisses = 0;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} catch (err) {
|
|
415
|
+
logger.warn('[EmbeddingServer] Leadership check error:', err);
|
|
416
|
+
} finally {
|
|
417
|
+
if (!this.isShuttingDown) {
|
|
418
|
+
this.leadershipTimer = setTimeout(check, 30_000);
|
|
419
|
+
if (this.leadershipTimer.unref) this.leadershipTimer.unref();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
this.leadershipTimer = setTimeout(check, 30_000);
|
|
425
|
+
if (this.leadershipTimer.unref) this.leadershipTimer.unref();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---- HTTP request handler ----
|
|
429
|
+
|
|
430
|
+
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
431
|
+
const MAX_BODY_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
432
|
+
|
|
433
|
+
let body = '';
|
|
434
|
+
let oversized = false;
|
|
435
|
+
req.on('data', (chunk: Buffer) => {
|
|
436
|
+
body += chunk;
|
|
437
|
+
if (body.length > MAX_BODY_SIZE) {
|
|
438
|
+
oversized = true;
|
|
439
|
+
req.destroy(new Error('Payload too large'));
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
await new Promise<void>((resolve) => {
|
|
444
|
+
let settled = false;
|
|
445
|
+
const finish = () => { if (!settled) { settled = true; resolve(); } };
|
|
446
|
+
|
|
447
|
+
// req.destroy() on an oversized body (and any client-side stream fault)
|
|
448
|
+
// emits 'error' but NOT 'end'. Without this the 'end'-only awaiter below
|
|
449
|
+
// would never resolve — the handler would dangle and the client would
|
|
450
|
+
// block until its own timeout. Respond (best-effort) and unblock.
|
|
451
|
+
req.on('error', (err) => {
|
|
452
|
+
logger.error('[EmbeddingServer] Request stream error:', err);
|
|
453
|
+
if (!res.headersSent) {
|
|
454
|
+
try {
|
|
455
|
+
res.writeHead(oversized ? 413 : 400, { 'Content-Type': 'application/json' });
|
|
456
|
+
res.end(JSON.stringify({ error: oversized ? 'Payload too large' : 'Request stream error' }));
|
|
457
|
+
} catch { /* socket may already be torn down */ }
|
|
458
|
+
}
|
|
459
|
+
finish();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
req.on('end', async () => {
|
|
463
|
+
try {
|
|
464
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
465
|
+
const payload = {
|
|
466
|
+
status: 'ok',
|
|
467
|
+
device: this.getDevice(),
|
|
468
|
+
dimension: this.getDimension(),
|
|
469
|
+
};
|
|
470
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
471
|
+
res.end(JSON.stringify(payload));
|
|
472
|
+
finish();
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (req.method !== 'POST') {
|
|
477
|
+
res.writeHead(405);
|
|
478
|
+
res.end('Method Not Allowed');
|
|
479
|
+
finish();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const data = JSON.parse(body);
|
|
484
|
+
|
|
485
|
+
switch (req.url) {
|
|
486
|
+
case '/embed': {
|
|
487
|
+
// Refuse once stepped down (leader lost / queue poisoned) — the in-process
|
|
488
|
+
// embed()/embedMany() already guard, but the HTTP path (the actual REMOTE
|
|
489
|
+
// client route) bypassed it and would re-acquire the GPU in a non-leader
|
|
490
|
+
// process, breaking the single-GPU-context invariant. The ECONNREFUSED
|
|
491
|
+
// message drives the client to reconnect to the new leader.
|
|
492
|
+
this.assertServing();
|
|
493
|
+
const result = await this.queue.enqueue(() => this.embedder.embed(data.text as string));
|
|
494
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
495
|
+
res.end(JSON.stringify({ data: Array.from(result) }));
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
case '/embedMany': {
|
|
499
|
+
this.assertServing();
|
|
500
|
+
const results = await this.queue.enqueue(() => this.embedder.embedMany(data.texts as string[]));
|
|
501
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
502
|
+
res.end(JSON.stringify({ results: results.map(r => Array.from(r)) }));
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
default:
|
|
506
|
+
res.writeHead(404);
|
|
507
|
+
res.end('Not Found');
|
|
508
|
+
}
|
|
509
|
+
} catch (error) {
|
|
510
|
+
// Log full error internally for debugging
|
|
511
|
+
logger.error('[EmbeddingServer] Error handling request:', error);
|
|
512
|
+
|
|
513
|
+
// Extract safe error message - first line only to prevent stack trace exposure
|
|
514
|
+
let errorMessage = 'Unknown error';
|
|
515
|
+
if (error instanceof Error && error.message) {
|
|
516
|
+
errorMessage = (error.message.split('\n')[0] || '').trim();
|
|
517
|
+
} else if (typeof error === 'string') {
|
|
518
|
+
errorMessage = (error.split('\n')[0] || '').trim();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Headers may already be out (e.g. res.end() threw mid-response) —
|
|
522
|
+
// writeHead() would then throw ERR_HTTP_HEADERS_SENT inside this async
|
|
523
|
+
// handler and surface as an unhandledRejection in the leader process.
|
|
524
|
+
if (!res.headersSent) {
|
|
525
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
526
|
+
res.end(JSON.stringify({ error: errorMessage }));
|
|
527
|
+
} else {
|
|
528
|
+
res.destroy();
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
finish();
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|