@lincoln504/pi-research 1.1.2 → 1.3.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/README.md +3 -3
- package/dist/cli.mjs +2599 -1268
- package/dist/thread-worker.mjs +224 -147
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +20 -10
- package/docs/CHANGELOG.md +195 -17
- package/docs/CONFIGURATION.md +9 -7
- package/docs/KNOWLEDGE-STORE.md +4 -3
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +55 -0
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/cleanup.cjs +114 -12
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +6 -3
- package/skills/pi-research/references/configuration.md +6 -2
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cleanup/research-cleanup.ts +12 -4
- package/src/cli.ts +372 -86
- package/src/config.ts +134 -17
- package/src/constants.ts +31 -0
- package/src/core/interfaces/health-check-interfaces.ts +3 -39
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/planning-interfaces.ts +8 -0
- package/src/core/interfaces/service-names.ts +0 -1
- package/src/core/interfaces/state-manager-interfaces.ts +11 -3
- package/src/core/llm/agentic-repair.ts +14 -0
- package/src/core/pi-version.ts +136 -0
- package/src/core/planning-service.ts +43 -1
- package/src/core/service-initialization.ts +6 -14
- package/src/core/service-interfaces.ts +1 -1
- package/src/core/service-registry.ts +117 -8
- package/src/healthcheck/index.ts +7 -2
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +80 -13
- package/src/infrastructure/browser/browser-error-utils.ts +6 -1
- package/src/infrastructure/browser/browser-server.ts +41 -12
- package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
- package/src/infrastructure/browser/cleanup-utils.ts +95 -9
- package/src/infrastructure/browser/client-agent.ts +6 -5
- package/src/infrastructure/browser/config.ts +14 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +44 -4
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
- package/src/infrastructure/browser/thread-worker-types.ts +3 -1
- package/src/infrastructure/browser/thread-worker.mjs +224 -147
- package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +61 -15
- package/src/infrastructure/embedding/embedding-server.ts +105 -11
- package/src/infrastructure/file-lock-service.ts +295 -28
- package/src/infrastructure/knowledge-store-service.ts +48 -3
- package/src/infrastructure/process-lifecycle-service.ts +134 -34
- package/src/infrastructure/research-run-semaphore.ts +37 -3
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-browser-api.ts +5 -2
- package/src/infrastructure/state/state-browser-manager.ts +15 -1
- package/src/infrastructure/state/state-manager-service.ts +5 -5
- package/src/infrastructure/state/state-manager.ts +34 -23
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/chunker.ts +88 -1
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +188 -46
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +101 -21
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/logger.ts +5 -1
- package/src/observers/research-observer-impl.ts +31 -0
- package/src/orchestration/abort-utils.ts +60 -0
- package/src/orchestration/deep-research-orchestrator.ts +25 -4
- package/src/orchestration/headless-observer.ts +8 -0
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +65 -15
- package/src/orchestration/research-session-service.ts +37 -11
- package/src/orchestration/research-synthesis-service.ts +33 -11
- package/src/orchestration/researcher-executor.ts +100 -20
- package/src/orchestration/researcher.ts +39 -20
- package/src/orchestration/session-state.ts +11 -0
- package/src/sdk.ts +125 -19
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/index.ts +51 -30
- package/src/security/nvd-client.ts +24 -8
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +153 -11
- package/src/stackexchange/index.ts +20 -5
- package/src/stackexchange/output/table.ts +7 -2
- package/src/stackexchange/rest-client.ts +41 -3
- package/src/tools/research-knowledge-search.ts +15 -0
- package/src/tools/research-tool-definition.ts +8 -2
- package/src/tools/scrape.ts +48 -25
- package/src/tools/search.ts +16 -2
- package/src/tools/security.ts +68 -7
- package/src/tui/research-health.ts +18 -2
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +9 -2
- package/src/utils/citation-utils.ts +3 -1
- package/src/utils/disk-space-checker.ts +11 -6
- package/src/utils/http-body.ts +176 -0
- package/src/utils/json-utils.ts +116 -76
- package/src/utils/llm-usage.ts +17 -1
- package/src/utils/log-rotation.ts +42 -10
- package/src/utils/log-utils.ts +40 -0
- package/src/utils/metrics-summary.ts +27 -9
- package/src/utils/stdio-capture.ts +63 -22
- package/src/utils/text-utils.ts +8 -1
- package/src/utils/tool-usage-tracker.ts +9 -2
- package/src/web-research/browser-search.ts +70 -5
- package/src/web-research/scraper-utils.ts +13 -0
- package/src/web-research/search.ts +25 -6
- package/src/web-research/types.ts +19 -4
- package/src/web-research/web-scraper.ts +72 -54
- package/src/youtube/transcript-client.ts +5 -1
- package/src/core/health-check-service.ts +0 -265
|
@@ -7,9 +7,20 @@ import type { StackExchangeWrapper } from './types.ts';
|
|
|
7
7
|
import { logger } from '../logger.ts';
|
|
8
8
|
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
|
|
9
9
|
import { metrics } from '../utils/metrics.ts';
|
|
10
|
+
import { readJsonCapped, BodyTooLargeError } from '../utils/http-body.ts';
|
|
10
11
|
|
|
11
12
|
const API_BASE = 'https://api.stackexchange.com/2.3';
|
|
12
13
|
|
|
14
|
+
// How long quota-exhaustion evidence keeps the request gate armed. quotaRemaining
|
|
15
|
+
// is only refreshed from a successful response, and the tool-level gate
|
|
16
|
+
// (stackexchange/index.ts) blocks every request while isQuotaExhausted() — so a
|
|
17
|
+
// plain `quotaRemaining <= 0` check could never observe SE's real daily reset and
|
|
18
|
+
// wedged the process-wide client until restart or an API-key change. After the
|
|
19
|
+
// cooldown one probe request is allowed: it either refreshes the quota (clearing
|
|
20
|
+
// the stamp) or shows 0 / throttles again, re-arming the gate — at most one
|
|
21
|
+
// wasted request per cooldown.
|
|
22
|
+
const QUOTA_PROBE_COOLDOWN_MS = 10 * 60 * 1000;
|
|
23
|
+
|
|
13
24
|
export interface RequestOptions {
|
|
14
25
|
method: string;
|
|
15
26
|
endpoint: string;
|
|
@@ -21,6 +32,9 @@ export class StackExchangeClient {
|
|
|
21
32
|
private readonly _timeout: number;
|
|
22
33
|
private quotaRemaining = 300;
|
|
23
34
|
private quotaMax = 300;
|
|
35
|
+
// Timestamp of the last quota-exhaustion evidence (a response reporting
|
|
36
|
+
// quota_remaining <= 0, or a throttle_violation API error). null = no evidence.
|
|
37
|
+
private quotaExhaustedAt: number | null = null;
|
|
24
38
|
private requestCount = 0;
|
|
25
39
|
private lastBackoff: number | null = null;
|
|
26
40
|
private circuitBreaker: CircuitBreaker;
|
|
@@ -99,7 +113,7 @@ export class StackExchangeClient {
|
|
|
99
113
|
|
|
100
114
|
// NB: the timeout is cleared in `finally`, not here. Clearing it now —
|
|
101
115
|
// when only the headers have arrived — would leave the body read
|
|
102
|
-
// (
|
|
116
|
+
// (readJsonCapped below) with no timeout, so a stalled body could hang
|
|
103
117
|
// for the whole process lifetime.
|
|
104
118
|
|
|
105
119
|
// The SE API returns a structured JSON wrapper (with error_id) even for
|
|
@@ -109,8 +123,11 @@ export class StackExchangeClient {
|
|
|
109
123
|
// "Unexpected token <" SyntaxError that hides the real cause.
|
|
110
124
|
let data: StackExchangeWrapper<T>;
|
|
111
125
|
try {
|
|
112
|
-
data = await
|
|
126
|
+
data = await readJsonCapped<StackExchangeWrapper<T>>(response);
|
|
113
127
|
} catch (parseErr) {
|
|
128
|
+
// An over-cap body is a size failure, not a malformed-JSON one: rethrow
|
|
129
|
+
// it unwrapped so it can't be relabelled as an HTTP-status error below.
|
|
130
|
+
if (parseErr instanceof BodyTooLargeError) throw parseErr;
|
|
114
131
|
if (!response.ok) throw new Error(`HTTP ${response.status} from Stack Exchange API`, { cause: parseErr });
|
|
115
132
|
throw parseErr;
|
|
116
133
|
}
|
|
@@ -118,6 +135,12 @@ export class StackExchangeClient {
|
|
|
118
135
|
// Handle API errors
|
|
119
136
|
if (data.error_id) {
|
|
120
137
|
const errorName = data.error_name ?? 'unknown';
|
|
138
|
+
// A throttle_violation is quota-exhaustion evidence even though no
|
|
139
|
+
// quota_remaining arrives with it (this throw happens before the quota
|
|
140
|
+
// update below) — stamp it so the gate arms/re-arms.
|
|
141
|
+
if (errorName === 'throttle_violation') {
|
|
142
|
+
this.quotaExhaustedAt = Date.now();
|
|
143
|
+
}
|
|
121
144
|
metrics.increment('stackexchange_errors_total', 1, {
|
|
122
145
|
endpoint: options.endpoint,
|
|
123
146
|
error_id: data.error_id.toString(),
|
|
@@ -132,6 +155,15 @@ export class StackExchangeClient {
|
|
|
132
155
|
this.quotaRemaining = data.quota_remaining;
|
|
133
156
|
this.quotaMax = data.quota_max;
|
|
134
157
|
this.requestCount++;
|
|
158
|
+
// Stamp (or clear) exhaustion evidence: isQuotaExhausted() reads the
|
|
159
|
+
// stamp, not quotaRemaining — which can never refresh while the gate
|
|
160
|
+
// blocks requests — so a probe after the cooldown self-corrects once
|
|
161
|
+
// SE's daily reset has happened.
|
|
162
|
+
if (data.quota_remaining <= 0) {
|
|
163
|
+
this.quotaExhaustedAt = Date.now();
|
|
164
|
+
} else {
|
|
165
|
+
this.quotaExhaustedAt = null;
|
|
166
|
+
}
|
|
135
167
|
|
|
136
168
|
// Track quota metrics
|
|
137
169
|
metrics.setGauge('stackexchange_quota_remaining', this.quotaRemaining);
|
|
@@ -193,7 +225,13 @@ export class StackExchangeClient {
|
|
|
193
225
|
}
|
|
194
226
|
|
|
195
227
|
isQuotaExhausted(): boolean {
|
|
196
|
-
|
|
228
|
+
// True only within the probe cooldown of the last exhaustion evidence. After
|
|
229
|
+
// it elapses, one probe request is allowed through the tool-level gate; the
|
|
230
|
+
// probe's outcome refreshes or clears the stamp (see request()).
|
|
231
|
+
return (
|
|
232
|
+
this.quotaExhaustedAt !== null &&
|
|
233
|
+
Date.now() - this.quotaExhaustedAt < QUOTA_PROBE_COOLDOWN_MS
|
|
234
|
+
);
|
|
197
235
|
}
|
|
198
236
|
|
|
199
237
|
isQuotaLow(): boolean {
|
|
@@ -962,6 +962,21 @@ export function createResearchKnowledgeSearchTool(iface?: ConfigInterface): Tool
|
|
|
962
962
|
return buildSteeringResult(result, urls);
|
|
963
963
|
} catch (error) {
|
|
964
964
|
const durationMs = Date.now() - startTime;
|
|
965
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
966
|
+
|
|
967
|
+
// A CANCELLED search is not an empty store. Reporting the miss string here
|
|
968
|
+
// told the calling agent, definitively, that the knowledge store has
|
|
969
|
+
// nothing on this topic — so it would go straight to full live research,
|
|
970
|
+
// the very work the user just interrupted. Propagate the abort instead;
|
|
971
|
+
// the run is ending either way. (Same misclassification as the search
|
|
972
|
+
// layer's "browser workers may be unavailable" on Ctrl-C.)
|
|
973
|
+
if (signal?.aborted || (error instanceof Error && error.name === 'AbortError') || /^(aborted|research (aborted|cancelled))$/i.test(msg)) {
|
|
974
|
+
metrics.observe('research_knowledge_search_duration_ms', durationMs, { status: 'cancelled' });
|
|
975
|
+
metrics.increment('research_knowledge_search_total', 1, { status: 'cancelled' });
|
|
976
|
+
logger.debug('[research-knowledge-search] Cancelled mid-search — propagating rather than reporting an empty store.');
|
|
977
|
+
throw error;
|
|
978
|
+
}
|
|
979
|
+
|
|
965
980
|
metrics.observe('research_knowledge_search_duration_ms', durationMs, { status: 'error' });
|
|
966
981
|
metrics.increment('research_knowledge_search_total', 1, { status: 'error' });
|
|
967
982
|
|
|
@@ -466,8 +466,14 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
|
|
|
466
466
|
|
|
467
467
|
const errMsg = String(error).toLowerCase();
|
|
468
468
|
|
|
469
|
-
// Handle rate limits gracefully
|
|
470
|
-
|
|
469
|
+
// Handle rate limits gracefully. "429" is matched on a word boundary
|
|
470
|
+
// (mirroring messageIsTransient in web-research/retry-utils.ts): a substring
|
|
471
|
+
// test also matched digits embedded in a larger number — e.g. a context-
|
|
472
|
+
// overflow error quoting "you requested 142935 tokens" — misreporting the
|
|
473
|
+
// run as rate-limited. 'quota' stays unanchored, same trade-off as
|
|
474
|
+
// retry-utils: provider quota errors ("insufficient_quota") carry no
|
|
475
|
+
// rate/limit wording to anchor on.
|
|
476
|
+
if (/\b429\b/.test(errMsg) || errMsg.includes('rate limit') || errMsg.includes('too many requests') || errMsg.includes('quota')) {
|
|
471
477
|
logger.warn('[research] Run halted gracefully due to rate limit:', error);
|
|
472
478
|
if (ctx.hasUI) {
|
|
473
479
|
ctx.ui.notify('Research halted: API rate limit reached', 'warning');
|
package/src/tools/scrape.ts
CHANGED
|
@@ -139,11 +139,38 @@ export function createScrapeTool(options: {
|
|
|
139
139
|
// almost nothing and must not be blocked as if every URL were a full scrape.
|
|
140
140
|
const { kept: dedupedUrls, duplicates } = deduplicateUrls(rawUrls, getGlobalState().researchId);
|
|
141
141
|
|
|
142
|
+
// Partition duplicates by whether their content still survives in the session
|
|
143
|
+
// cache. The content cache is FIFO-capped, so a pool member's content may have
|
|
144
|
+
// been evicted; those URLs re-enter the fetch list — otherwise they would be
|
|
145
|
+
// reported as "retrieved from session memory" while appearing nowhere in the
|
|
146
|
+
// output, and a re-request would just dedup them away again.
|
|
147
|
+
const duplicateResults: { url: string; markdown: string; success: true }[] = [];
|
|
148
|
+
const evictedDuplicates: string[] = [];
|
|
149
|
+
for (const url of duplicates) {
|
|
150
|
+
const cachedContent = getCachedScrapedContent(getGlobalState().researchId, url);
|
|
151
|
+
if (cachedContent) {
|
|
152
|
+
duplicateResults.push({ url, markdown: cachedContent, success: true });
|
|
153
|
+
} else {
|
|
154
|
+
evictedDuplicates.push(url);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Only the first MAX_SCRAPE_URLS candidates are ever fetched. Slice BEFORE the
|
|
159
|
+
// context gate so the projection bills what will actually be scraped — billing
|
|
160
|
+
// the full list blocked an 8-URL first batch on a 128k model at zero tokens
|
|
161
|
+
// used, while the URLs actually fetchable would have passed. Over-cap URLs are
|
|
162
|
+
// reported explicitly below instead of silently vanishing (they appear neither
|
|
163
|
+
// in results nor in Failed-to-Scrape, and the session-pool footer excludes the
|
|
164
|
+
// current batch).
|
|
165
|
+
const fetchCandidates = [...dedupedUrls, ...evictedDuplicates];
|
|
166
|
+
const finalUrls = fetchCandidates.slice(0, MAX_SCRAPE_URLS);
|
|
167
|
+
const skippedUrls = fetchCandidates.slice(MAX_SCRAPE_URLS);
|
|
168
|
+
|
|
142
169
|
// Context gate: block if projected token usage would exceed threshold
|
|
143
170
|
if (options.getTokensUsed) {
|
|
144
171
|
const ctxWindow = options.contextWindowSize ?? DEFAULT_MODEL_CONTEXT_WINDOW;
|
|
145
172
|
const tokensUsed = options.getTokensUsed();
|
|
146
|
-
const projected = (tokensUsed +
|
|
173
|
+
const projected = (tokensUsed + finalUrls.length * config.AVG_TOKENS_PER_SCRAPE) / ctxWindow;
|
|
147
174
|
if (projected >= config.MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING) {
|
|
148
175
|
const pct = Math.round(config.MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING * 100);
|
|
149
176
|
metrics.increment('tool_scrape_calls_total', 1, { status: 'context_limit' });
|
|
@@ -160,36 +187,22 @@ export function createScrapeTool(options: {
|
|
|
160
187
|
}
|
|
161
188
|
|
|
162
189
|
const callCount = options.tracker?.getToolCallCount('scrape') ?? 0;
|
|
163
|
-
const batchLabel = `Batch ${callCount + 1}`;
|
|
164
190
|
options.tracker?.recordCall('scrape');
|
|
165
191
|
const scrapeStartTime = Date.now();
|
|
166
|
-
|
|
167
|
-
|
|
192
|
+
// Count only content actually re-served. Evicted pool members are in the fetch
|
|
193
|
+
// list instead, so they surface as fresh results (or in the not-fetched note),
|
|
194
|
+
// never as a "retrieved" claim with no content attached.
|
|
195
|
+
let dedupNote = duplicateResults.length > 0
|
|
196
|
+
? `**Global Cache Hit**: ${duplicateResults.length} URL(s) retrieved from session memory (already scraped).\n\n`
|
|
168
197
|
: '';
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// Even for duplicates, we want to return the content if we have it in the session cache
|
|
172
|
-
const duplicateResults: { url: string; markdown: string; success: true }[] = [];
|
|
173
|
-
for (const url of duplicates) {
|
|
174
|
-
const cachedContent = getCachedScrapedContent(getGlobalState().researchId, url);
|
|
175
|
-
if (cachedContent) {
|
|
176
|
-
duplicateResults.push({ url, markdown: cachedContent, success: true });
|
|
177
|
-
options.onUrlScrapeResult?.(url, true);
|
|
178
|
-
}
|
|
198
|
+
if (evictedDuplicates.length > 0) {
|
|
199
|
+
dedupNote += `**Session Cache Evicted**: ${evictedDuplicates.length} previously-scraped URL(s) no longer in session memory — queued for a fresh fetch:\n${evictedDuplicates.map(u => `- ${u}`).join('\n')}\n\n`;
|
|
179
200
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
// Add footer with alternative URLs for discovery
|
|
184
|
-
const earlyFooter = buildSessionPoolFooter(getGlobalState().researchId, rawUrls, options.researcherId);
|
|
185
|
-
return {
|
|
186
|
-
content: [{ type: 'text', text: `# ${batchLabel} Skipped\n\nAll URLs were already in the global pool, but no cached content was available.${earlyFooter}` }],
|
|
187
|
-
details: { all_duplicates: true },
|
|
188
|
-
};
|
|
201
|
+
metrics.increment('tool_scrape_duplicates_total', duplicates.length);
|
|
202
|
+
for (const r of duplicateResults) {
|
|
203
|
+
options.onUrlScrapeResult?.(r.url, true);
|
|
189
204
|
}
|
|
190
205
|
|
|
191
|
-
const finalUrls = dedupedUrls.slice(0, MAX_SCRAPE_URLS);
|
|
192
|
-
|
|
193
206
|
// Batch 1 uses the configured scrape concurrency; batch 2+ may go wider
|
|
194
207
|
// once the context budget allows — but never wider than the real browser
|
|
195
208
|
// pool (WORKER_THREADS × WORKER_CONCURRENCY), or the extra requests just
|
|
@@ -331,6 +344,15 @@ export function createScrapeTool(options: {
|
|
|
331
344
|
markdown += '\n';
|
|
332
345
|
}
|
|
333
346
|
|
|
347
|
+
if (skippedUrls.length > 0) {
|
|
348
|
+
markdown += `## Not Fetched — Over Batch Cap (${skippedUrls.length})\n\n`;
|
|
349
|
+
markdown += `Only the first ${MAX_SCRAPE_URLS} URLs of a batch are fetched. The following URLs were NOT fetched — no content for them appears above. Request them in a later scrape batch if still needed:\n`;
|
|
350
|
+
for (const url of skippedUrls) {
|
|
351
|
+
markdown += `- ${url}\n`;
|
|
352
|
+
}
|
|
353
|
+
markdown += '\n';
|
|
354
|
+
}
|
|
355
|
+
|
|
334
356
|
// Append Session URL Pool footer
|
|
335
357
|
markdown += buildSessionPoolFooter(getGlobalState().researchId, rawUrls, options.researcherId);
|
|
336
358
|
|
|
@@ -342,6 +364,7 @@ export function createScrapeTool(options: {
|
|
|
342
364
|
failed: failedFresh.length,
|
|
343
365
|
cached: cachedResults.length,
|
|
344
366
|
fresh: successfulFresh.length,
|
|
367
|
+
skipped: skippedUrls.length,
|
|
345
368
|
},
|
|
346
369
|
};
|
|
347
370
|
},
|
package/src/tools/search.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { Type, type Static } from 'typebox';
|
|
|
9
9
|
import { Value } from 'typebox/value';
|
|
10
10
|
import { search } from '../web-research/search.ts';
|
|
11
11
|
import type { ToolUsageTracker } from '../utils/tool-usage-tracker.ts';
|
|
12
|
+
import type { SystemResearchState } from '../orchestration/deep-research-types.ts';
|
|
12
13
|
import { logger } from '../logger.ts';
|
|
13
14
|
import { type Config, getConfig } from '../config.ts';
|
|
14
15
|
import { metrics } from '../utils/metrics.ts';
|
|
@@ -18,6 +19,8 @@ export function createSearchTool(options: {
|
|
|
18
19
|
ctx: ExtensionContext;
|
|
19
20
|
tracker: ToolUsageTracker;
|
|
20
21
|
onProgress?: (links: number) => void;
|
|
22
|
+
/** Research state accessor; its researchId keys the per-session circuit breaker (mirrors scrape). */
|
|
23
|
+
getGlobalState?: () => SystemResearchState;
|
|
21
24
|
config?: Config;
|
|
22
25
|
}): ToolDefinition {
|
|
23
26
|
const youtubeEveryN = (options.config ?? getConfig(options.ctx.cwd)).YOUTUBE_QUERY_EVERY_N;
|
|
@@ -84,9 +87,15 @@ export function createSearchTool(options: {
|
|
|
84
87
|
|
|
85
88
|
try {
|
|
86
89
|
const container = tryGetServiceContainerFromCtx(ctx);
|
|
90
|
+
// Per-session circuit-breaker scoping: thread the researchId exactly as
|
|
91
|
+
// the scrape path does (tools/scrape.ts passes getGlobalState().researchId
|
|
92
|
+
// into scrape() → runBrowserTask). Search previously hard-coded
|
|
93
|
+
// sessionId=undefined at the runWorkerSearch hop, so its failures always
|
|
94
|
+
// hit the GLOBAL breaker while scrape's hit the per-run one.
|
|
95
|
+
const sessionId = options.getGlobalState?.().researchId;
|
|
87
96
|
const results = await search(queries, options.config, signal, (links) => {
|
|
88
97
|
if (options.onProgress) options.onProgress(links);
|
|
89
|
-
}, container);
|
|
98
|
+
}, container, sessionId);
|
|
90
99
|
const elapsed = Date.now() - startTime;
|
|
91
100
|
|
|
92
101
|
const totalResults = results.reduce((sum, r) => sum + r.results.length, 0);
|
|
@@ -106,7 +115,12 @@ export function createSearchTool(options: {
|
|
|
106
115
|
results.forEach((r, i) => {
|
|
107
116
|
markdown += `## Query ${i + 1}: ${r.query}\n`;
|
|
108
117
|
if (r.results.length === 0) {
|
|
109
|
-
|
|
118
|
+
// Render the attributed failure, not a bare "no results": a timeout or
|
|
119
|
+
// dead worker says nothing about the query, and presenting it as an
|
|
120
|
+
// empty result sent the model off rewriting perfectly good queries —
|
|
121
|
+
// the misdirection QueryFailure exists to prevent. This is the only
|
|
122
|
+
// surface the researcher agent actually reads.
|
|
123
|
+
markdown += r.error ? `*${r.error.message}*\n\n` : `*No results found.*\n\n`;
|
|
110
124
|
} else {
|
|
111
125
|
r.results.forEach((item, j) => {
|
|
112
126
|
markdown += `[${j + 1}] **${item.title}**\n${item.url}\n${item.content}\n\n`;
|
package/src/tools/security.ts
CHANGED
|
@@ -97,9 +97,48 @@ export function createSecuritySearchTool(options: {
|
|
|
97
97
|
throw new Error('At least one search term is required');
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// Normalize database names (schema accepts free text): "NVD" must reach the
|
|
101
|
+
// dispatcher as "nvd". Unknown names are still passed through — the searcher
|
|
102
|
+
// reports each one in `errors`, which the note below surfaces — but when EVERY
|
|
103
|
+
// requested name is unknown the call must fail loudly here: running zero
|
|
104
|
+
// databases and reporting "0 vulnerabilities found" would read as an
|
|
105
|
+
// authoritative all-clear.
|
|
106
|
+
const VALID_DATABASES = ['nvd', 'cisa_kev', 'github', 'osv'] as const;
|
|
100
107
|
const databases: readonly ('nvd' | 'cisa_kev' | 'github' | 'osv')[] = p.databases !== undefined && p.databases.length > 0
|
|
101
|
-
? p.databases as readonly ('nvd' | 'cisa_kev' | 'github' | 'osv')[]
|
|
102
|
-
:
|
|
108
|
+
? p.databases.map(db => db.trim().toLowerCase()) as readonly ('nvd' | 'cisa_kev' | 'github' | 'osv')[]
|
|
109
|
+
: VALID_DATABASES;
|
|
110
|
+
if (!databases.some(db => (VALID_DATABASES as readonly string[]).includes(db))) {
|
|
111
|
+
metrics.increment('tool_security_search_calls_total', 1, { status: 'unknown_databases' });
|
|
112
|
+
return {
|
|
113
|
+
content: [{
|
|
114
|
+
type: 'text',
|
|
115
|
+
text: `# Security Vulnerability Search Failed\n\n**Error:** No recognized databases in request — nothing was searched.\n\n**Requested:** ${p.databases!.join(', ')}\n\n**Valid databases (case-insensitive):** ${VALID_DATABASES.join(', ')}`,
|
|
116
|
+
}],
|
|
117
|
+
details: { error: 'unknown_databases', requested: p.databases, validDatabases: VALID_DATABASES },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// Normalize the severity filter at the single dispatch chokepoint (schema
|
|
121
|
+
// accepts free text). Only NVD's path normalized (security/index.ts
|
|
122
|
+
// getSeverityParam): GitHub/OSV received the raw string, so "med" or
|
|
123
|
+
// "HIGH,CRITICAL" ran NVD unfiltered while their strict filters silently
|
|
124
|
+
// returned zero. An unrecognized value must fail loudly BEFORE any database
|
|
125
|
+
// is queried rather than let the databases silently diverge.
|
|
126
|
+
const VALID_SEVERITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] as const;
|
|
127
|
+
let severity: string | undefined;
|
|
128
|
+
if (p.severity !== undefined) {
|
|
129
|
+
const upper = p.severity.trim().toUpperCase();
|
|
130
|
+
severity = upper === 'MODERATE' ? 'MEDIUM' : upper;
|
|
131
|
+
if (!(VALID_SEVERITIES as readonly string[]).includes(severity)) {
|
|
132
|
+
metrics.increment('tool_security_search_calls_total', 1, { status: 'invalid_severity' });
|
|
133
|
+
return {
|
|
134
|
+
content: [{
|
|
135
|
+
type: 'text',
|
|
136
|
+
text: `[Error] Unknown severity "${p.severity}" — valid: LOW, MEDIUM, HIGH, CRITICAL (MODERATE accepted as MEDIUM). No databases were queried.`,
|
|
137
|
+
}],
|
|
138
|
+
details: { error: 'invalid_severity', requested: p.severity, validSeverities: VALID_SEVERITIES },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
103
142
|
const maxResults = p.maxResults ?? 20;
|
|
104
143
|
|
|
105
144
|
metrics.increment('tool_security_search_terms_total', terms.length);
|
|
@@ -110,7 +149,7 @@ export function createSecuritySearchTool(options: {
|
|
|
110
149
|
const searchParams: SecuritySearchParams = {
|
|
111
150
|
terms,
|
|
112
151
|
databases,
|
|
113
|
-
severity
|
|
152
|
+
severity,
|
|
114
153
|
maxResults,
|
|
115
154
|
includeExploited: p.includeExploited ?? false,
|
|
116
155
|
ecosystem: p.ecosystem,
|
|
@@ -172,7 +211,13 @@ export function createSecuritySearchTool(options: {
|
|
|
172
211
|
markdown += '## NIST NVD\n\n';
|
|
173
212
|
if (results.results.nvd.error !== undefined) {
|
|
174
213
|
markdown += `[Error] ${results.results.nvd.error}\n\n`;
|
|
175
|
-
}
|
|
214
|
+
}
|
|
215
|
+
// Partial failures: the clients deliberately return whatever they fetched
|
|
216
|
+
// PLUS an error annotation (e.g. "NVD lookup failed for 1/2 term(s)"), so
|
|
217
|
+
// the data must render alongside the [Error] line — the header's totals
|
|
218
|
+
// already count it. Only a total failure (error with zero rows) stays
|
|
219
|
+
// error-only, so it can't read as an authoritative "found nothing".
|
|
220
|
+
if (results.results.nvd.error === undefined || results.results.nvd.vulnerabilities.length > 0) {
|
|
176
221
|
markdown += `Found: ${results.results.nvd.count} vulnerabilities\n\n`;
|
|
177
222
|
for (const vuln of results.results.nvd.vulnerabilities.slice(0, 20)) {
|
|
178
223
|
markdown += `### ${vuln.id}\n`;
|
|
@@ -212,7 +257,9 @@ export function createSecuritySearchTool(options: {
|
|
|
212
257
|
markdown += '## CISA Known Exploited Vulnerabilities\n\n';
|
|
213
258
|
if (results.results.cisa_kev.error !== undefined) {
|
|
214
259
|
markdown += `[Error] ${results.results.cisa_kev.error}\n\n`;
|
|
215
|
-
}
|
|
260
|
+
}
|
|
261
|
+
// Partial failure: render the data alongside the error (see NVD section).
|
|
262
|
+
if (results.results.cisa_kev.error === undefined || results.results.cisa_kev.vulnerabilities.length > 0) {
|
|
216
263
|
markdown += `Found: ${results.results.cisa_kev.count} actively exploited vulnerabilities\n\n`;
|
|
217
264
|
for (const vuln of results.results.cisa_kev.vulnerabilities.slice(0, 20)) {
|
|
218
265
|
markdown += `### ${vuln.id}\n`;
|
|
@@ -245,8 +292,16 @@ export function createSecuritySearchTool(options: {
|
|
|
245
292
|
markdown += '## GitHub Security Advisories\n\n';
|
|
246
293
|
if (results.results.github.error !== undefined) {
|
|
247
294
|
markdown += `[Error] ${results.results.github.error}\n\n`;
|
|
248
|
-
}
|
|
295
|
+
}
|
|
296
|
+
// Partial failure: render the data alongside the error (see NVD section).
|
|
297
|
+
if (results.results.github.error === undefined || results.results.github.advisories.length > 0) {
|
|
249
298
|
markdown += `Found: ${results.results.github.count} advisories\n\n`;
|
|
299
|
+
if (p.includeExploited === true) {
|
|
300
|
+
// includeExploited reaches only NVD (hasKev) and CISA KEV (inherently
|
|
301
|
+
// exploited-only); GitHub carries no exploitation signal, so these
|
|
302
|
+
// results are NOT filtered by it — say so rather than imply they were.
|
|
303
|
+
markdown += '*Note: GitHub Advisories are not filtered by known-exploitation — that signal exists only in NVD and CISA KEV.*\n\n';
|
|
304
|
+
}
|
|
250
305
|
for (const adv of results.results.github.advisories.slice(0, 20)) {
|
|
251
306
|
markdown += `### ${adv.id}\n`;
|
|
252
307
|
markdown += `- **Severity:** ${adv.severity}\n`;
|
|
@@ -277,8 +332,14 @@ export function createSecuritySearchTool(options: {
|
|
|
277
332
|
markdown += '## Open Source Vulnerabilities (OSV)\n\n';
|
|
278
333
|
if (results.results.osv.error !== undefined) {
|
|
279
334
|
markdown += `[Error] ${results.results.osv.error}\n\n`;
|
|
280
|
-
}
|
|
335
|
+
}
|
|
336
|
+
// Partial failure: render the data alongside the error (see NVD section).
|
|
337
|
+
if (results.results.osv.error === undefined || results.results.osv.vulnerabilities.length > 0) {
|
|
281
338
|
markdown += `Found: ${results.results.osv.count} vulnerabilities\n\n`;
|
|
339
|
+
if (p.includeExploited === true) {
|
|
340
|
+
// Same caveat as GitHub: OSV carries no exploitation signal.
|
|
341
|
+
markdown += '*Note: OSV results are not filtered by known-exploitation — that signal exists only in NVD and CISA KEV.*\n\n';
|
|
342
|
+
}
|
|
282
343
|
for (const vuln of results.results.osv.vulnerabilities.slice(0, 20)) {
|
|
283
344
|
markdown += `### ${vuln.id}\n`;
|
|
284
345
|
markdown += `- **Severity:** ${vuln.severity}\n`;
|
|
@@ -95,17 +95,33 @@ export interface HealthMonitor {
|
|
|
95
95
|
|
|
96
96
|
export function createHealthMonitor(): HealthMonitor {
|
|
97
97
|
let timer: NodeJS.Timeout | null = null;
|
|
98
|
+
// Critical components already warned this run, so a persistently failing check
|
|
99
|
+
// logs once rather than on every 30s tick.
|
|
100
|
+
const warnedCritical = new Set<string>();
|
|
98
101
|
|
|
99
102
|
const start = () => {
|
|
100
103
|
if (timer) return;
|
|
101
|
-
|
|
104
|
+
warnedCritical.clear();
|
|
105
|
+
|
|
106
|
+
// Note: the registry API (runAll) only supports { force } — there is no surface
|
|
107
|
+
// for selecting/skipping individual checks, so each tick runs the full set
|
|
108
|
+
// (including the real BrowserRuntime page-load probe when the pool is active).
|
|
102
109
|
timer = setInterval(async () => {
|
|
103
110
|
try {
|
|
104
111
|
const health = await healthRegistry.runAll();
|
|
105
|
-
const
|
|
112
|
+
const failed = health.components.filter(c => !c.healthy);
|
|
113
|
+
const failedNonCritical = failed.filter(c => !healthRegistry.isCritical(c.component));
|
|
106
114
|
if (failedNonCritical.length > 0) {
|
|
107
115
|
logger.warn(`[research] Periodic health check: non-critical components degraded: ${failedNonCritical.map(c => c.component).join(', ')}`);
|
|
108
116
|
}
|
|
117
|
+
// Critical failures are worth surfacing too — the run itself keeps going
|
|
118
|
+
// (per-task timeouts bound the real work), but silently burning probes while
|
|
119
|
+
// a critical component is down left the monitor with no output at all.
|
|
120
|
+
for (const c of failed.filter(f => healthRegistry.isCritical(f.component))) {
|
|
121
|
+
if (warnedCritical.has(c.component)) continue;
|
|
122
|
+
warnedCritical.add(c.component);
|
|
123
|
+
logger.warn(`[research] Periodic health check: critical component unhealthy mid-run: ${c.component}${c.error ? ` (${c.error})` : ''}`);
|
|
124
|
+
}
|
|
109
125
|
} catch (error) {
|
|
110
126
|
logger.debug('[research] Periodic health check failed (non-blocking):', error);
|
|
111
127
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file replacement, with the Windows caveat handled honestly.
|
|
3
|
+
*
|
|
4
|
+
* The temp-file + fsync + rename dance exists so a reader never observes a partial
|
|
5
|
+
* file and a crash never leaves one: rename is atomic, so the destination is either
|
|
6
|
+
* entirely the old content or entirely the new.
|
|
7
|
+
*
|
|
8
|
+
* On Windows that guarantee is easy to lose. The long-standing comment at these call
|
|
9
|
+
* sites — "fs.rename fails on Windows if target exists (NTFS)" — is not true: Node
|
|
10
|
+
* calls `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`, so replacing an existing file
|
|
11
|
+
* is fine. What actually fails is a rename whose TARGET is held open by another
|
|
12
|
+
* process without delete sharing (an AV scanner mid-scan, a backup agent), which
|
|
13
|
+
* surfaces as EPERM/EBUSY/EACCES.
|
|
14
|
+
*
|
|
15
|
+
* The old remedy — fall straight to `copyFile` — traded a transient, retryable error
|
|
16
|
+
* for a permanent hazard: `copyFile` truncates the destination and rewrites it in
|
|
17
|
+
* place, so a concurrent reader or a crash mid-copy sees a torn or zero-length file.
|
|
18
|
+
* For `state.json` (leader election, server auth secrets) and `config.env` (API keys)
|
|
19
|
+
* that is a much worse outcome than the error it was avoiding.
|
|
20
|
+
*
|
|
21
|
+
* So: retry the rename first. An AV lock clears in tens of milliseconds, and a retried
|
|
22
|
+
* rename stays atomic. Only a lock that outlives every retry falls back to copying,
|
|
23
|
+
* which is reported to the caller so it can be logged as the degraded path it is.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import * as fs from 'node:fs/promises';
|
|
27
|
+
import * as fsSync from 'node:fs';
|
|
28
|
+
|
|
29
|
+
/** Transient Windows sharing/locking failures — worth retrying, unlike ENOENT/ENOSPC. */
|
|
30
|
+
const RETRYABLE_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
31
|
+
|
|
32
|
+
const MAX_RENAME_ATTEMPTS = 5;
|
|
33
|
+
const RENAME_RETRY_DELAY_MS = 25;
|
|
34
|
+
|
|
35
|
+
export type ReplaceOutcome = 'renamed' | 'copied';
|
|
36
|
+
|
|
37
|
+
function isRetryable(err: unknown): boolean {
|
|
38
|
+
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
|
39
|
+
return code !== undefined && RETRYABLE_CODES.has(code);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Replace `destPath` with `tmpPath` atomically where the platform allows it.
|
|
44
|
+
*
|
|
45
|
+
* Returns `'renamed'` for the atomic path and `'copied'` for the degraded Windows
|
|
46
|
+
* fallback. Rethrows on every non-Windows failure, and on Windows failures that are
|
|
47
|
+
* not lock-related.
|
|
48
|
+
*/
|
|
49
|
+
export async function replaceFile(tmpPath: string, destPath: string): Promise<ReplaceOutcome> {
|
|
50
|
+
let lastError: unknown;
|
|
51
|
+
for (let attempt = 0; attempt < MAX_RENAME_ATTEMPTS; attempt++) {
|
|
52
|
+
try {
|
|
53
|
+
await fs.rename(tmpPath, destPath);
|
|
54
|
+
return 'renamed';
|
|
55
|
+
} catch (err) {
|
|
56
|
+
lastError = err;
|
|
57
|
+
// Only a Windows sharing conflict is worth another try; anything else (and any
|
|
58
|
+
// failure on a POSIX platform, where rename-over-existing always works) is real.
|
|
59
|
+
if (process.platform !== 'win32' || !isRetryable(err)) throw err;
|
|
60
|
+
if (attempt < MAX_RENAME_ATTEMPTS - 1) {
|
|
61
|
+
await new Promise((r) => setTimeout(r, RENAME_RETRY_DELAY_MS * (attempt + 1)));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Every retry lost to the same lock. Copying is not atomic — a reader can catch the
|
|
67
|
+
// destination mid-write — but it is the only remaining way to persist the data.
|
|
68
|
+
try {
|
|
69
|
+
await fs.copyFile(tmpPath, destPath);
|
|
70
|
+
return 'copied';
|
|
71
|
+
} catch {
|
|
72
|
+
throw lastError;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Synchronous {@link replaceFile}, for the config writer, which is sync throughout. */
|
|
77
|
+
export function replaceFileSync(tmpPath: string, destPath: string): ReplaceOutcome {
|
|
78
|
+
let lastError: unknown;
|
|
79
|
+
for (let attempt = 0; attempt < MAX_RENAME_ATTEMPTS; attempt++) {
|
|
80
|
+
try {
|
|
81
|
+
fsSync.renameSync(tmpPath, destPath);
|
|
82
|
+
return 'renamed';
|
|
83
|
+
} catch (err) {
|
|
84
|
+
lastError = err;
|
|
85
|
+
if (process.platform !== 'win32' || !isRetryable(err)) throw err;
|
|
86
|
+
if (attempt < MAX_RENAME_ATTEMPTS - 1) {
|
|
87
|
+
// Sync busy-wait: this path is rare, bounded (~250ms worst case) and the
|
|
88
|
+
// caller is already blocking on a synchronous write.
|
|
89
|
+
const until = Date.now() + RENAME_RETRY_DELAY_MS * (attempt + 1);
|
|
90
|
+
while (Date.now() < until) { /* spin */ }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
fsSync.copyFileSync(tmpPath, destPath);
|
|
97
|
+
return 'copied';
|
|
98
|
+
} catch {
|
|
99
|
+
throw lastError;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -71,7 +71,7 @@ export class CircuitBreaker {
|
|
|
71
71
|
const result = await action();
|
|
72
72
|
const duration = Date.now() - startTime;
|
|
73
73
|
metrics.observe('circuit_breaker_call_duration_ms', duration, { breaker: this.options.name, status: 'success' });
|
|
74
|
-
this.onSuccess();
|
|
74
|
+
this.onSuccess(admittedAsProbe);
|
|
75
75
|
return result;
|
|
76
76
|
} catch (error) {
|
|
77
77
|
const duration = Date.now() - startTime;
|
|
@@ -105,9 +105,16 @@ export class CircuitBreaker {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
private onSuccess() {
|
|
108
|
+
private onSuccess(admittedAsProbe: boolean) {
|
|
109
109
|
metrics.increment('circuit_breaker_success_total', 1, { breaker: this.options.name, state: this.state });
|
|
110
110
|
if (this.state === 'HALF_OPEN') {
|
|
111
|
+
// Only a call admitted through the HALF_OPEN gate is evidence of recovery.
|
|
112
|
+
// A call admitted while CLOSED that merely settles after the breaker tripped
|
|
113
|
+
// and re-half-opened carries PRE-outage evidence — counting it would close
|
|
114
|
+
// the circuit while the real probe is still in flight against a dependency
|
|
115
|
+
// that may still be down. Its success is simply not counted; the in-flight
|
|
116
|
+
// probe's own outcome decides the transition.
|
|
117
|
+
if (!admittedAsProbe) return;
|
|
111
118
|
this.successCount++;
|
|
112
119
|
if (this.successCount >= this.options.halfOpenMaxCalls) {
|
|
113
120
|
this.transitionTo('CLOSED');
|
|
@@ -16,7 +16,9 @@ export interface GlobalCitation extends Citation {
|
|
|
16
16
|
* @param reports - Map of researcher ID to report content
|
|
17
17
|
* @returns Object containing normalized reports and the global citation list
|
|
18
18
|
*/
|
|
19
|
-
|
|
19
|
+
// ReadonlyMap: this only reads `reports` (it returns a NEW normalizedReports map),
|
|
20
|
+
// and callers now pass a read-only view to avoid create-on-read side effects.
|
|
21
|
+
export function normalizeCitations(reports: ReadonlyMap<string, string>): {
|
|
20
22
|
normalizedReports: Map<string, string>;
|
|
21
23
|
globalCitations: GlobalCitation[];
|
|
22
24
|
} {
|
|
@@ -36,12 +36,17 @@ export class DiskSpaceChecker {
|
|
|
36
36
|
if (availableBytes < this.MIN_DISK_SPACE_BYTES) {
|
|
37
37
|
this.hasDiskSpace = false;
|
|
38
38
|
// Write directly to stderr — this check runs inside the logger itself,
|
|
39
|
-
// so using the logger here would create a circular dependency.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
// so using the logger here would create a circular dependency. Guarded:
|
|
40
|
+
// when the parent has already closed the pipe, a bare write throws
|
|
41
|
+
// EPIPE/ERR_STREAM_DESTROYED and (with no global uncaughtException
|
|
42
|
+
// handler in the main process) would crash the run over a diagnostic.
|
|
43
|
+
try {
|
|
44
|
+
process.stderr.write(
|
|
45
|
+
`[pi-research] Insufficient disk space for logging: ` +
|
|
46
|
+
`${Math.round(availableBytes / 1024 / 1024)}MB available, ` +
|
|
47
|
+
`minimum ${this.MIN_DISK_SPACE_BYTES / 1024 / 1024}MB required\n`
|
|
48
|
+
);
|
|
49
|
+
} catch { /* stderr gone — the flag above already halts log writes */ }
|
|
45
50
|
} else {
|
|
46
51
|
this.hasDiskSpace = true;
|
|
47
52
|
}
|