@lincoln504/pi-research 1.0.10 → 1.1.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 +20 -8
- package/dist/cli.mjs +538 -66
- package/dist/thread-worker.mjs +39 -1
- package/docs/ARCHITECTURE.md +24 -0
- package/docs/CHANGELOG.md +40 -0
- package/docs/CONFIGURATION.md +2 -0
- package/docs/SDK.md +9 -0
- package/package.json +5 -2
- package/skills/pi-research/SKILL.md +11 -2
- package/src/cli.ts +40 -2
- package/src/core/interfaces/observer-interfaces.ts +9 -0
- package/src/core/interfaces/service-names.ts +2 -0
- package/src/infrastructure/browser/browser-client.ts +19 -1
- package/src/infrastructure/browser/browser-error-utils.ts +36 -4
- package/src/infrastructure/browser/browser-server.ts +70 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +19 -3
- package/src/infrastructure/browser/scheduler-factory.ts +1 -1
- package/src/infrastructure/browser/task-execution-service.ts +86 -9
- package/src/infrastructure/browser/thread-worker.mjs +39 -1
- package/src/infrastructure/process-lifecycle-service.ts +21 -3
- package/src/infrastructure/research-run-semaphore.ts +323 -0
- package/src/infrastructure/service-initialization.ts +20 -0
- package/src/orchestration/deep-research-orchestrator.ts +11 -0
- package/src/orchestration/headless-observer.ts +4 -0
- package/src/orchestration/research-orchestration-service.ts +70 -0
- package/src/orchestration/research-synthesis-service.ts +171 -15
- package/src/web-research/retry-utils.ts +9 -3
- package/src/web-research/scraper-utils.ts +43 -0
- package/src/web-research/web-scraper.ts +67 -40
package/dist/cli.mjs
CHANGED
|
@@ -1394,7 +1394,9 @@ var init_service_names = __esm({
|
|
|
1394
1394
|
WORKER_POOL_MANAGER: "worker-pool-manager",
|
|
1395
1395
|
// Research session services
|
|
1396
1396
|
RESEARCH_SESSION_SERVICE: "research-session-service",
|
|
1397
|
-
RESEARCH_SYNTHESIS_SERVICE: "research-synthesis-service"
|
|
1397
|
+
RESEARCH_SYNTHESIS_SERVICE: "research-synthesis-service",
|
|
1398
|
+
// Cross-process cap on concurrent research runs (Phase 1 run-cap)
|
|
1399
|
+
RESEARCH_RUN_SEMAPHORE: "research-run-semaphore"
|
|
1398
1400
|
};
|
|
1399
1401
|
}
|
|
1400
1402
|
});
|
|
@@ -3370,7 +3372,7 @@ var init_model_registry_factory = __esm({
|
|
|
3370
3372
|
});
|
|
3371
3373
|
|
|
3372
3374
|
// src/web-research/retry-utils.ts
|
|
3373
|
-
function abortableDelay(ms, signal) {
|
|
3375
|
+
function abortableDelay(ms, signal, keepAlive = false) {
|
|
3374
3376
|
return new Promise((resolve8, reject) => {
|
|
3375
3377
|
if (signal?.aborted) {
|
|
3376
3378
|
reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
|
|
@@ -3380,7 +3382,7 @@ function abortableDelay(ms, signal) {
|
|
|
3380
3382
|
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
3381
3383
|
resolve8();
|
|
3382
3384
|
}, ms);
|
|
3383
|
-
timeoutId.unref?.();
|
|
3385
|
+
if (!keepAlive) timeoutId.unref?.();
|
|
3384
3386
|
const onAbort = signal ? () => {
|
|
3385
3387
|
clearTimeout(timeoutId);
|
|
3386
3388
|
reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
|
|
@@ -10668,7 +10670,18 @@ var BrowserClient = class {
|
|
|
10668
10670
|
const duration = Date.now() - start;
|
|
10669
10671
|
try {
|
|
10670
10672
|
const parsed = JSON.parse(body);
|
|
10671
|
-
if (res.statusCode
|
|
10673
|
+
if (res.statusCode === 503 && parsed?.error === "draining") {
|
|
10674
|
+
const error = new Error(
|
|
10675
|
+
`Browser pool leader is draining (${parsed.reason || "leadership-lost"}) \u2014 re-elect and retry`
|
|
10676
|
+
);
|
|
10677
|
+
logger.debug(`[BrowserClient] Leader draining on ${path19}; re-election required.`);
|
|
10678
|
+
errorTracker.trackError(error, {
|
|
10679
|
+
component: "browser-manager",
|
|
10680
|
+
operation,
|
|
10681
|
+
errorType: "draining"
|
|
10682
|
+
});
|
|
10683
|
+
reject(error);
|
|
10684
|
+
} else if (res.statusCode !== 200) {
|
|
10672
10685
|
const error = new Error(parsed.error || `HTTP ${res.statusCode}`);
|
|
10673
10686
|
errorTracker.trackError(error, {
|
|
10674
10687
|
component: "browser-manager",
|
|
@@ -10777,7 +10790,10 @@ function isTransientSocketError(error) {
|
|
|
10777
10790
|
err.message.includes("Worker pool is shutting down") || // Poolifier throws this when pool.execute() is called while the pool is being
|
|
10778
10791
|
// destroyed (e.g., during scheduler restart). Treat as transient — the pool will
|
|
10779
10792
|
// be ready for new tasks once destruction completes and a new pool is initialized.
|
|
10780
|
-
err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool")
|
|
10793
|
+
err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // The leader answered 503 because it is shutting down after losing
|
|
10794
|
+
// leadership. Retrying re-elects and reaches the new leader, so this is
|
|
10795
|
+
// transient in exactly the same sense as a pool drain.
|
|
10796
|
+
err.message.includes("Browser pool leader is draining"));
|
|
10781
10797
|
}
|
|
10782
10798
|
function isCloudflareBlockError(error) {
|
|
10783
10799
|
if (!error || typeof error !== "object") return false;
|
|
@@ -10789,6 +10805,11 @@ function isTaskTimeoutError(error) {
|
|
|
10789
10805
|
const err = error;
|
|
10790
10806
|
return typeof err.message === "string" && (err.message.includes("timed out after") || err.message.includes("Search task timed out") || err.message.includes("Scrape task timed out") || err.message.includes("Health check timed out") || err.message.includes("[BrowserClient] Request to"));
|
|
10791
10807
|
}
|
|
10808
|
+
function isServerDrainingError(error) {
|
|
10809
|
+
if (!error || typeof error !== "object") return false;
|
|
10810
|
+
const err = error;
|
|
10811
|
+
return typeof err.message === "string" && err.message.includes("Browser pool leader is draining");
|
|
10812
|
+
}
|
|
10792
10813
|
function isPoolShutdownError(error) {
|
|
10793
10814
|
if (!error || typeof error !== "object") return false;
|
|
10794
10815
|
const err = error;
|
|
@@ -10807,6 +10828,7 @@ var DEFAULT_BREAKER_CONFIG = {
|
|
|
10807
10828
|
name: "BrowserPool",
|
|
10808
10829
|
isTransientError: (error) => {
|
|
10809
10830
|
if (isPoolShutdownError(error)) return false;
|
|
10831
|
+
if (isServerDrainingError(error)) return false;
|
|
10810
10832
|
if (isCloudflareBlockError(error)) return false;
|
|
10811
10833
|
if (isTaskTimeoutError(error)) return false;
|
|
10812
10834
|
return isTransientSocketError(error);
|
|
@@ -10847,6 +10869,50 @@ var BrowserServer = class {
|
|
|
10847
10869
|
options;
|
|
10848
10870
|
server = null;
|
|
10849
10871
|
port = 0;
|
|
10872
|
+
/**
|
|
10873
|
+
* Live client sockets. Tracked so teardown can tell whether anyone is still
|
|
10874
|
+
* attached (`hasActiveClients`) rather than guessing, and so drain has a
|
|
10875
|
+
* meaningful count to report.
|
|
10876
|
+
*/
|
|
10877
|
+
connections = /* @__PURE__ */ new Set();
|
|
10878
|
+
draining = false;
|
|
10879
|
+
/**
|
|
10880
|
+
* Why this server stopped accepting work. Reported to the client so a log line
|
|
10881
|
+
* says which teardown it hit — a lost election, an idle timeout, or process
|
|
10882
|
+
* shutdown all reach the same code path but are very different incidents.
|
|
10883
|
+
*/
|
|
10884
|
+
drainReason = "shutting-down";
|
|
10885
|
+
/**
|
|
10886
|
+
* Enter draining mode: every *subsequent* request is answered `503` instead of
|
|
10887
|
+
* being served or left to hit a closed socket. Requests already past this
|
|
10888
|
+
* check run to completion, so drain never truncates in-flight work.
|
|
10889
|
+
*
|
|
10890
|
+
* This exists for leadership-loss teardown. Without it a client that is
|
|
10891
|
+
* mid-conversation with a leader that just lost the election waits out a
|
|
10892
|
+
* connect timeout or gets a bare ECONNREFUSED/RST once the listener closes,
|
|
10893
|
+
* and only then re-elects. An explicit 503 turns that silent stall into an
|
|
10894
|
+
* immediate, unambiguous "this leader is gone — re-elect now".
|
|
10895
|
+
*
|
|
10896
|
+
* Idempotent: calling it while already draining is a no-op.
|
|
10897
|
+
*/
|
|
10898
|
+
beginDrain(reason = "shutting-down") {
|
|
10899
|
+
if (this.draining) return;
|
|
10900
|
+
this.draining = true;
|
|
10901
|
+
this.drainReason = reason;
|
|
10902
|
+
logger.log(`[BrowserServer] Draining (${reason}) \u2014 refusing new requests with 503 (${this.connections.size} client connection(s) attached).`);
|
|
10903
|
+
}
|
|
10904
|
+
/** True once {@link beginDrain} has been called. */
|
|
10905
|
+
isDraining() {
|
|
10906
|
+
return this.draining;
|
|
10907
|
+
}
|
|
10908
|
+
/** Whether any client socket is currently attached. */
|
|
10909
|
+
hasActiveClients() {
|
|
10910
|
+
return this.connections.size > 0;
|
|
10911
|
+
}
|
|
10912
|
+
/** Number of attached client sockets (observability / teardown decisions). */
|
|
10913
|
+
getConnectionCount() {
|
|
10914
|
+
return this.connections.size;
|
|
10915
|
+
}
|
|
10850
10916
|
async start() {
|
|
10851
10917
|
return new Promise((resolve8, reject) => {
|
|
10852
10918
|
this.server = http3.createServer(async (req, res) => {
|
|
@@ -10859,6 +10925,14 @@ var BrowserServer = class {
|
|
|
10859
10925
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
10860
10926
|
return;
|
|
10861
10927
|
}
|
|
10928
|
+
if (this.draining) {
|
|
10929
|
+
res.writeHead(503, {
|
|
10930
|
+
"Content-Type": "application/json",
|
|
10931
|
+
"Connection": "close"
|
|
10932
|
+
});
|
|
10933
|
+
res.end(JSON.stringify({ error: "draining", reason: this.drainReason }));
|
|
10934
|
+
return;
|
|
10935
|
+
}
|
|
10862
10936
|
if (req.method !== "POST") {
|
|
10863
10937
|
res.writeHead(405);
|
|
10864
10938
|
res.end("Method Not Allowed");
|
|
@@ -10928,6 +11002,12 @@ var BrowserServer = class {
|
|
|
10928
11002
|
}
|
|
10929
11003
|
});
|
|
10930
11004
|
});
|
|
11005
|
+
this.server.on("connection", (socket) => {
|
|
11006
|
+
this.connections.add(socket);
|
|
11007
|
+
socket.on("close", () => {
|
|
11008
|
+
this.connections.delete(socket);
|
|
11009
|
+
});
|
|
11010
|
+
});
|
|
10931
11011
|
this.server.listen(0, "127.0.0.1", () => {
|
|
10932
11012
|
const addr = this.server?.address();
|
|
10933
11013
|
if (addr && typeof addr === "object") {
|
|
@@ -10950,6 +11030,7 @@ var BrowserServer = class {
|
|
|
10950
11030
|
return new Promise((resolve8) => {
|
|
10951
11031
|
this.server?.close(() => {
|
|
10952
11032
|
this.server = null;
|
|
11033
|
+
this.connections.clear();
|
|
10953
11034
|
resolve8();
|
|
10954
11035
|
});
|
|
10955
11036
|
});
|
|
@@ -11394,7 +11475,7 @@ var BrowserTaskScheduler = class {
|
|
|
11394
11475
|
if (this.consecutiveLeadershipMisses >= this.LEADERSHIP_MISS_THRESHOLD) {
|
|
11395
11476
|
metrics.increment("browser_leadership_lost_total", 1);
|
|
11396
11477
|
logger.error(`[Scheduler] Leadership threshold exceeded (${this.consecutiveLeadershipMisses} misses), shutting down pool...`);
|
|
11397
|
-
await this.shutdown();
|
|
11478
|
+
await this.shutdown("leadership-lost");
|
|
11398
11479
|
return;
|
|
11399
11480
|
}
|
|
11400
11481
|
} else {
|
|
@@ -11422,7 +11503,7 @@ var BrowserTaskScheduler = class {
|
|
|
11422
11503
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
11423
11504
|
this.idleTimer = setTimeout(() => {
|
|
11424
11505
|
logger.log("[Scheduler] Browser pool idle timeout reached, shutting down...");
|
|
11425
|
-
this.shutdown().catch((err) => logger.warn("[Scheduler] Idle-timeout shutdown failed (non-fatal):", err));
|
|
11506
|
+
this.shutdown("idle-timeout").catch((err) => logger.warn("[Scheduler] Idle-timeout shutdown failed (non-fatal):", err));
|
|
11426
11507
|
}, this.IDLE_TIMEOUT_MS);
|
|
11427
11508
|
if (this.idleTimer.unref) this.idleTimer.unref();
|
|
11428
11509
|
}
|
|
@@ -11615,9 +11696,18 @@ var BrowserTaskScheduler = class {
|
|
|
11615
11696
|
}
|
|
11616
11697
|
return result;
|
|
11617
11698
|
}
|
|
11618
|
-
|
|
11699
|
+
/**
|
|
11700
|
+
* @param reason why the pool is going away. Reported verbatim to clients in the
|
|
11701
|
+
* drain 503, so a lost election, an idle timeout and a process shutdown are
|
|
11702
|
+
* distinguishable in a client-side log rather than all reading as one.
|
|
11703
|
+
*/
|
|
11704
|
+
async shutdown(reason = "shutting-down") {
|
|
11619
11705
|
if (this.isShuttingDown) return;
|
|
11620
11706
|
this.isShuttingDown = true;
|
|
11707
|
+
if (this.server?.hasActiveClients()) {
|
|
11708
|
+
logger.log(`[Scheduler] Draining ${this.server.getConnectionCount()} client connection(s) before shutdown (${reason}).`);
|
|
11709
|
+
}
|
|
11710
|
+
this.server?.beginDrain(reason);
|
|
11621
11711
|
if (this.idleTimer) {
|
|
11622
11712
|
clearTimeout(this.idleTimer);
|
|
11623
11713
|
this.idleTimer = null;
|
|
@@ -11771,7 +11861,7 @@ async function forceSchedulerRestart(forceClearRemoteState = false, container =
|
|
|
11771
11861
|
}
|
|
11772
11862
|
if (oldScheduler && "schedulerId" in oldScheduler && oldScheduler.schedulerId) {
|
|
11773
11863
|
if (oldScheduler instanceof BrowserTaskScheduler) {
|
|
11774
|
-
const shutdownPromise = oldScheduler.shutdown().catch((err) => {
|
|
11864
|
+
const shutdownPromise = oldScheduler.shutdown("leadership-lost").catch((err) => {
|
|
11775
11865
|
logger.warn("[Scheduler] Error during old scheduler shutdown after restart:", err);
|
|
11776
11866
|
});
|
|
11777
11867
|
schedulerService.setPendingShutdownPromise(shutdownPromise);
|
|
@@ -12962,7 +13052,8 @@ var ProcessLifecycleService = class {
|
|
|
12962
13052
|
if (expectedStartTime !== void 0 && expectedStartTime !== null) {
|
|
12963
13053
|
const actualStartTime = await this.getProcessStartTime(pid);
|
|
12964
13054
|
if (actualStartTime === null) {
|
|
12965
|
-
|
|
13055
|
+
process.kill(pid, 0);
|
|
13056
|
+
return true;
|
|
12966
13057
|
}
|
|
12967
13058
|
return Math.abs(actualStartTime - expectedStartTime) <= 1;
|
|
12968
13059
|
}
|
|
@@ -14130,6 +14221,193 @@ var StatePathConfiguration = class {
|
|
|
14130
14221
|
// src/infrastructure/service-initialization.ts
|
|
14131
14222
|
init_file_lock_service();
|
|
14132
14223
|
|
|
14224
|
+
// src/infrastructure/research-run-semaphore.ts
|
|
14225
|
+
init_logger();
|
|
14226
|
+
init_metrics();
|
|
14227
|
+
init_file_lock_service();
|
|
14228
|
+
init_service_registry();
|
|
14229
|
+
init_service_names();
|
|
14230
|
+
import * as pathmod2 from "node:path";
|
|
14231
|
+
var DEFAULT_MAX_CONCURRENT_RUNS = 3;
|
|
14232
|
+
var DEFAULT_ACQUIRE_TIMEOUT_MS = 6e5;
|
|
14233
|
+
var POLL_DELAY_MS = 250;
|
|
14234
|
+
var ResearchRunCapacityError = class extends Error {
|
|
14235
|
+
slots;
|
|
14236
|
+
constructor(slots, maxWaitMs) {
|
|
14237
|
+
super(
|
|
14238
|
+
`Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` and still busy after queueing for ${Math.round(maxWaitMs / 1e3)}s` : ""}; another run is still in progress. This is temporary \u2014 retry shortly. (Raise the cap with PI_RESEARCH_MAX_CONCURRENT_RUNS, or the queue wait with PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS.)`
|
|
14239
|
+
);
|
|
14240
|
+
this.name = "ResearchRunCapacityError";
|
|
14241
|
+
this.slots = slots;
|
|
14242
|
+
}
|
|
14243
|
+
};
|
|
14244
|
+
var ResearchRunSemaphore = class {
|
|
14245
|
+
name = ServiceNames.RESEARCH_RUN_SEMAPHORE;
|
|
14246
|
+
lifecycle = "uninitialized" /* UNINITIALIZED */;
|
|
14247
|
+
slotDir;
|
|
14248
|
+
processLifecycle;
|
|
14249
|
+
maxSlots;
|
|
14250
|
+
defaultMaxWaitMs;
|
|
14251
|
+
/** Rotating start index so contending processes don't all hammer slot 0 first. */
|
|
14252
|
+
nextStartIndex = 0;
|
|
14253
|
+
_initialized = false;
|
|
14254
|
+
/**
|
|
14255
|
+
* @param slotDir Directory holding the `research-run-slot-<i>.lock` files.
|
|
14256
|
+
* @param processLifecycle PID+startTime liveness source (PID-reuse-safe reclamation).
|
|
14257
|
+
* @param maxSlots N — maximum concurrent runs. Defaults from env then {@link DEFAULT_MAX_CONCURRENT_RUNS}.
|
|
14258
|
+
* @param defaultMaxWaitMs Default acquire wait. Defaults from env then {@link DEFAULT_ACQUIRE_TIMEOUT_MS}.
|
|
14259
|
+
*/
|
|
14260
|
+
constructor(slotDir, processLifecycle, maxSlots, defaultMaxWaitMs) {
|
|
14261
|
+
this.slotDir = slotDir;
|
|
14262
|
+
this.processLifecycle = processLifecycle;
|
|
14263
|
+
this.maxSlots = Math.max(1, Math.floor(
|
|
14264
|
+
maxSlots ?? numberFromEnv("PI_RESEARCH_MAX_CONCURRENT_RUNS") ?? DEFAULT_MAX_CONCURRENT_RUNS
|
|
14265
|
+
));
|
|
14266
|
+
this.defaultMaxWaitMs = Math.max(0, Math.floor(
|
|
14267
|
+
defaultMaxWaitMs ?? numberFromEnv("PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS") ?? DEFAULT_ACQUIRE_TIMEOUT_MS
|
|
14268
|
+
));
|
|
14269
|
+
}
|
|
14270
|
+
/** Number of slots (max concurrent runs). */
|
|
14271
|
+
getMaxSlots() {
|
|
14272
|
+
return this.maxSlots;
|
|
14273
|
+
}
|
|
14274
|
+
async initialize() {
|
|
14275
|
+
if (this._initialized) return;
|
|
14276
|
+
this.lifecycle = "initializing" /* INITIALIZING */;
|
|
14277
|
+
try {
|
|
14278
|
+
await import("node:fs/promises").then((fs17) => fs17.mkdir(this.slotDir, { recursive: true, mode: 448 }));
|
|
14279
|
+
} catch (err) {
|
|
14280
|
+
logger.warn(`[ResearchRunSemaphore] Could not create slot dir ${this.slotDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
14281
|
+
}
|
|
14282
|
+
this._initialized = true;
|
|
14283
|
+
this.lifecycle = "initialized" /* INITIALIZED */;
|
|
14284
|
+
logger.log(`[ResearchRunSemaphore] Initialized with ${this.maxSlots} run slot(s) (acquire timeout ${this.defaultMaxWaitMs}ms).`);
|
|
14285
|
+
}
|
|
14286
|
+
async dispose() {
|
|
14287
|
+
this.lifecycle = "disposed" /* DISPOSED */;
|
|
14288
|
+
}
|
|
14289
|
+
/**
|
|
14290
|
+
* Try to win one slot NON-BLOCKING. Builds a fresh {@link FileLockService}
|
|
14291
|
+
* configured as a try-take (see class doc: "fresh instance per attempt").
|
|
14292
|
+
* Returns an acquisition on success, or null if the slot is busy / lost a race.
|
|
14293
|
+
*/
|
|
14294
|
+
async trySlot(slotIndex) {
|
|
14295
|
+
const slot = new FileLockService({
|
|
14296
|
+
lockFilePath: pathmod2.join(this.slotDir, `research-run-slot-${slotIndex}.lock`),
|
|
14297
|
+
lockTimeout: 0,
|
|
14298
|
+
// fail immediately on a live-contended slot (try-take)
|
|
14299
|
+
lockRetries: 24,
|
|
14300
|
+
// enough iterations to complete a dead-owner reclaim + reacquire
|
|
14301
|
+
lockRetryDelay: 50,
|
|
14302
|
+
lockStaleThreshold: 15e3,
|
|
14303
|
+
// legacy (no-pid) locks: reclaim once 15s old
|
|
14304
|
+
// A live process holding a run slot for the entire (minutes-long) run is the
|
|
14305
|
+
// NORMAL case, not a stall. Never steal from a live owner — doing so would
|
|
14306
|
+
// admit an (N+1)th concurrent run, the exact condition this guard prevents.
|
|
14307
|
+
// Dead owners are reclaimed immediately regardless of age.
|
|
14308
|
+
liveOwnerStaleThreshold: Number.MAX_SAFE_INTEGER,
|
|
14309
|
+
processLifecycle: this.processLifecycle ?? void 0
|
|
14310
|
+
});
|
|
14311
|
+
try {
|
|
14312
|
+
await slot.initialize();
|
|
14313
|
+
await slot.acquireLock();
|
|
14314
|
+
} catch {
|
|
14315
|
+
return null;
|
|
14316
|
+
}
|
|
14317
|
+
return {
|
|
14318
|
+
slotIndex,
|
|
14319
|
+
release: async () => {
|
|
14320
|
+
try {
|
|
14321
|
+
await slot.releaseLock();
|
|
14322
|
+
} catch (err) {
|
|
14323
|
+
logger.warn(`[ResearchRunSemaphore] Failed to release slot ${slotIndex}: ${err instanceof Error ? err.message : String(err)}`);
|
|
14324
|
+
} finally {
|
|
14325
|
+
metrics.setGauge("research_run_slots_held", 0, { slot: String(slotIndex) });
|
|
14326
|
+
}
|
|
14327
|
+
}
|
|
14328
|
+
};
|
|
14329
|
+
}
|
|
14330
|
+
/**
|
|
14331
|
+
* Acquire one run slot, waiting up to `maxWaitMs` (defaults to the constructed
|
|
14332
|
+
* value) when all slots are held by live processes. Resolves with a handle that
|
|
14333
|
+
* must be released when the run ends.
|
|
14334
|
+
*
|
|
14335
|
+
* @param signal aborts the wait. Without it a run started under a long
|
|
14336
|
+
* `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` would ignore Ctrl-C until the deadline
|
|
14337
|
+
* passed, because the wait happens before any orchestrator exists to cancel.
|
|
14338
|
+
* @param onQueued invoked at most once, when the first full sweep finds every
|
|
14339
|
+
* slot busy and the call is about to wait. Lets a front-end tell the user the
|
|
14340
|
+
* run is queued rather than hung. Never allowed to break the acquire.
|
|
14341
|
+
* @throws {ResearchRunCapacityError} if no slot frees within `maxWaitMs`.
|
|
14342
|
+
* @throws {Error} named `AbortError` if `signal` aborts while waiting.
|
|
14343
|
+
*/
|
|
14344
|
+
async acquire(maxWaitMs = this.defaultMaxWaitMs, signal, onQueued) {
|
|
14345
|
+
if (!this._initialized) await this.initialize();
|
|
14346
|
+
if (signal?.aborted) {
|
|
14347
|
+
metrics.increment("research_run_acquire_total", 1, { status: "aborted" });
|
|
14348
|
+
throwIfAborted(signal);
|
|
14349
|
+
}
|
|
14350
|
+
const deadline = Date.now() + Math.max(0, maxWaitMs);
|
|
14351
|
+
let announcedQueued = false;
|
|
14352
|
+
for (; ; ) {
|
|
14353
|
+
const start = this.nextStartIndex;
|
|
14354
|
+
this.nextStartIndex = (this.nextStartIndex + 1) % this.maxSlots;
|
|
14355
|
+
for (let offset = 0; offset < this.maxSlots; offset++) {
|
|
14356
|
+
const i = (start + offset) % this.maxSlots;
|
|
14357
|
+
const got = await this.trySlot(i);
|
|
14358
|
+
if (got) {
|
|
14359
|
+
metrics.setGauge("research_run_slots_held", 1, { slot: String(i) });
|
|
14360
|
+
metrics.increment("research_run_acquire_total", 1, { status: "acquired" });
|
|
14361
|
+
return got;
|
|
14362
|
+
}
|
|
14363
|
+
}
|
|
14364
|
+
if (Date.now() >= deadline) {
|
|
14365
|
+
metrics.increment("research_run_acquire_total", 1, { status: "exhausted" });
|
|
14366
|
+
throw new ResearchRunCapacityError(this.maxSlots, maxWaitMs);
|
|
14367
|
+
}
|
|
14368
|
+
if (!announcedQueued) {
|
|
14369
|
+
announcedQueued = true;
|
|
14370
|
+
logger.info(`[ResearchRunSemaphore] All ${this.maxSlots} run slot(s) busy \u2014 queueing for up to ${maxWaitMs}ms.`);
|
|
14371
|
+
try {
|
|
14372
|
+
onQueued?.(this.maxSlots, maxWaitMs);
|
|
14373
|
+
} catch (err) {
|
|
14374
|
+
logger.debug(`[ResearchRunSemaphore] onQueued callback threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
14375
|
+
}
|
|
14376
|
+
}
|
|
14377
|
+
try {
|
|
14378
|
+
await sleep(POLL_DELAY_MS, signal);
|
|
14379
|
+
} catch (err) {
|
|
14380
|
+
metrics.increment("research_run_acquire_total", 1, { status: "aborted" });
|
|
14381
|
+
throw err;
|
|
14382
|
+
}
|
|
14383
|
+
}
|
|
14384
|
+
}
|
|
14385
|
+
};
|
|
14386
|
+
function throwIfAborted(signal) {
|
|
14387
|
+
if (signal?.aborted) {
|
|
14388
|
+
throw Object.assign(new Error("Research run slot acquisition aborted"), { name: "AbortError" });
|
|
14389
|
+
}
|
|
14390
|
+
}
|
|
14391
|
+
function numberFromEnv(key) {
|
|
14392
|
+
const raw = process.env[key];
|
|
14393
|
+
if (raw === void 0 || raw === "") return void 0;
|
|
14394
|
+
const n = Number(raw);
|
|
14395
|
+
return Number.isFinite(n) ? n : void 0;
|
|
14396
|
+
}
|
|
14397
|
+
function sleep(ms, signal) {
|
|
14398
|
+
return new Promise((resolve8, reject) => {
|
|
14399
|
+
const timer = setTimeout(() => {
|
|
14400
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
14401
|
+
resolve8();
|
|
14402
|
+
}, ms);
|
|
14403
|
+
const onAbort = signal ? () => {
|
|
14404
|
+
clearTimeout(timer);
|
|
14405
|
+
reject(Object.assign(new Error("Research run slot acquisition aborted"), { name: "AbortError" }));
|
|
14406
|
+
} : void 0;
|
|
14407
|
+
if (onAbort) signal?.addEventListener("abort", onAbort, { once: true });
|
|
14408
|
+
});
|
|
14409
|
+
}
|
|
14410
|
+
|
|
14133
14411
|
// src/infrastructure/state/state-backup-manager.ts
|
|
14134
14412
|
init_logger();
|
|
14135
14413
|
init_service_registry();
|
|
@@ -14389,6 +14667,19 @@ function registerInfrastructureServices(container = getServiceContainer()) {
|
|
|
14389
14667
|
},
|
|
14390
14668
|
container
|
|
14391
14669
|
);
|
|
14670
|
+
registerService(
|
|
14671
|
+
ServiceNames.RESEARCH_RUN_SEMAPHORE,
|
|
14672
|
+
async () => {
|
|
14673
|
+
const pathConfig = await getService(ServiceNames.STATE_PATH_CONFIGURATION, void 0, container);
|
|
14674
|
+
const processLifecycle = await getService(ServiceNames.PROCESS_LIFECYCLE, void 0, container);
|
|
14675
|
+
return new ResearchRunSemaphore(pathConfig.getStateDir(), processLifecycle);
|
|
14676
|
+
},
|
|
14677
|
+
{
|
|
14678
|
+
allowOverwrite: false,
|
|
14679
|
+
enableLogging: true
|
|
14680
|
+
},
|
|
14681
|
+
container
|
|
14682
|
+
);
|
|
14392
14683
|
registerService(
|
|
14393
14684
|
ServiceNames.STATE_BACKUP_MANAGER,
|
|
14394
14685
|
async () => {
|
|
@@ -14608,6 +14899,17 @@ init_service_registry();
|
|
|
14608
14899
|
init_service_interfaces();
|
|
14609
14900
|
var lastRestartTime = 0;
|
|
14610
14901
|
var RESTART_COOLDOWN_MS = 1e4;
|
|
14902
|
+
var LEADER_HANDOVER_RETRIES = 3;
|
|
14903
|
+
var HANDOVER_BACKOFF_MS = 1200;
|
|
14904
|
+
async function recoverFromLeaderHandover(container) {
|
|
14905
|
+
logger.warn("[BrowserManager] Leader handover detected (pool draining) \u2014 dropping stale scheduler handle and re-resolving...");
|
|
14906
|
+
await forceSchedulerRestart2(false, container).catch(
|
|
14907
|
+
(err) => logger.debug("[BrowserManager] Scheduler re-resolve during handover failed (non-fatal):", err)
|
|
14908
|
+
);
|
|
14909
|
+
await waitForBrowserPoolIdle(15e3).catch(
|
|
14910
|
+
(err) => logger.debug("Wait for browser idle timed out or failed:", err)
|
|
14911
|
+
);
|
|
14912
|
+
}
|
|
14611
14913
|
async function getScheduler2(config, container = getServiceContainer()) {
|
|
14612
14914
|
try {
|
|
14613
14915
|
const factory = await getService(ServiceNames.SCHEDULER_FACTORY, void 0, container);
|
|
@@ -14624,7 +14926,7 @@ async function forceSchedulerRestart2(forceClearRemoteState = false, container =
|
|
|
14624
14926
|
return await forceSchedulerRestart(forceClearRemoteState, container);
|
|
14625
14927
|
}
|
|
14626
14928
|
}
|
|
14627
|
-
async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retries = 1, container = getServiceContainer()) {
|
|
14929
|
+
async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retries = 1, container = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES) {
|
|
14628
14930
|
if (signal?.aborted) throw new Error("Aborted");
|
|
14629
14931
|
const sessionId = typeof taskOrUrl === "object" ? taskOrUrl.sessionId : void 0;
|
|
14630
14932
|
const breaker2 = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
|
|
@@ -14645,6 +14947,19 @@ async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retrie
|
|
|
14645
14947
|
});
|
|
14646
14948
|
} catch (error) {
|
|
14647
14949
|
if (signal?.aborted || error instanceof Error && error.message === "Aborted") throw new Error("Aborted", { cause: error });
|
|
14950
|
+
const isHandover = isPoolShutdownError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error);
|
|
14951
|
+
if (isHandover && handoverRetries > 0) {
|
|
14952
|
+
errorTracker.trackError(error, {
|
|
14953
|
+
component: "browser-manager",
|
|
14954
|
+
operation: type,
|
|
14955
|
+
taskType: type,
|
|
14956
|
+
errorType: "leader_handover"
|
|
14957
|
+
});
|
|
14958
|
+
logger.warn(`[BrowserManager] Leader handover during ${type} task (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
14959
|
+
await recoverFromLeaderHandover(container);
|
|
14960
|
+
await new Promise((resolve8) => setTimeout(resolve8, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
|
|
14961
|
+
return runBrowserTask(taskOrUrl, type, config, signal, retries, container, handoverRetries - 1);
|
|
14962
|
+
}
|
|
14648
14963
|
if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
|
|
14649
14964
|
errorTracker.trackError(error, {
|
|
14650
14965
|
component: "browser-manager",
|
|
@@ -14670,7 +14985,7 @@ async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retrie
|
|
|
14670
14985
|
}
|
|
14671
14986
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
14672
14987
|
await new Promise((resolve8) => setTimeout(resolve8, jitter));
|
|
14673
|
-
return runBrowserTask(taskOrUrl, type, config, signal, retries - 1, container);
|
|
14988
|
+
return runBrowserTask(taskOrUrl, type, config, signal, retries - 1, container, handoverRetries);
|
|
14674
14989
|
}
|
|
14675
14990
|
throw error;
|
|
14676
14991
|
}
|
|
@@ -14690,7 +15005,7 @@ async function runBrowserHealthCheck(config, retries = 1, signal, container = ge
|
|
|
14690
15005
|
});
|
|
14691
15006
|
logger.warn(`[BrowserManager] Transient socket error during healthcheck (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
14692
15007
|
if (isPoolShutdownError(error)) {
|
|
14693
|
-
await
|
|
15008
|
+
await recoverFromLeaderHandover(container);
|
|
14694
15009
|
} else {
|
|
14695
15010
|
const now = Date.now();
|
|
14696
15011
|
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
@@ -14709,7 +15024,7 @@ async function runBrowserHealthCheck(config, retries = 1, signal, container = ge
|
|
|
14709
15024
|
throw error;
|
|
14710
15025
|
}
|
|
14711
15026
|
}
|
|
14712
|
-
async function runWorkerSearch(query, config, signal, retries = 1, sessionId, container = getServiceContainer()) {
|
|
15027
|
+
async function runWorkerSearch(query, config, signal, retries = 1, sessionId, container = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES) {
|
|
14713
15028
|
if (signal?.aborted) throw new Error("Aborted");
|
|
14714
15029
|
const breaker2 = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
|
|
14715
15030
|
try {
|
|
@@ -14720,6 +15035,19 @@ async function runWorkerSearch(query, config, signal, retries = 1, sessionId, co
|
|
|
14720
15035
|
});
|
|
14721
15036
|
} catch (error) {
|
|
14722
15037
|
if (signal?.aborted || error instanceof Error && error.message === "Aborted") throw new Error("Aborted", { cause: error });
|
|
15038
|
+
const isHandover = isPoolShutdownError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error);
|
|
15039
|
+
if (isHandover && handoverRetries > 0) {
|
|
15040
|
+
errorTracker.trackError(error, {
|
|
15041
|
+
component: "browser-manager",
|
|
15042
|
+
operation: "search",
|
|
15043
|
+
query,
|
|
15044
|
+
errorType: "leader_handover"
|
|
15045
|
+
});
|
|
15046
|
+
logger.warn(`[BrowserManager] Leader handover during search (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
15047
|
+
await recoverFromLeaderHandover(container);
|
|
15048
|
+
await new Promise((resolve8) => setTimeout(resolve8, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
|
|
15049
|
+
return runWorkerSearch(query, config, signal, retries, sessionId, container, handoverRetries - 1);
|
|
15050
|
+
}
|
|
14723
15051
|
if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
|
|
14724
15052
|
errorTracker.trackError(error, {
|
|
14725
15053
|
component: "browser-manager",
|
|
@@ -14743,7 +15071,7 @@ async function runWorkerSearch(query, config, signal, retries = 1, sessionId, co
|
|
|
14743
15071
|
}
|
|
14744
15072
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
14745
15073
|
await new Promise((resolve8) => setTimeout(resolve8, jitter));
|
|
14746
|
-
return runWorkerSearch(query, config, signal, retries - 1, sessionId, container);
|
|
15074
|
+
return runWorkerSearch(query, config, signal, retries - 1, sessionId, container, handoverRetries);
|
|
14747
15075
|
}
|
|
14748
15076
|
throw error;
|
|
14749
15077
|
}
|
|
@@ -15538,6 +15866,34 @@ async function getSsrfSafeDispatcher() {
|
|
|
15538
15866
|
}
|
|
15539
15867
|
return cachedSsrfDispatcher;
|
|
15540
15868
|
}
|
|
15869
|
+
function formatErrorWithCause(err) {
|
|
15870
|
+
try {
|
|
15871
|
+
const parts = [];
|
|
15872
|
+
let current = err;
|
|
15873
|
+
for (let depth = 0; current !== null && current !== void 0 && depth < 5; depth++) {
|
|
15874
|
+
let segment;
|
|
15875
|
+
if (current instanceof Error) {
|
|
15876
|
+
segment = current.message || current.name;
|
|
15877
|
+
const code = current.code;
|
|
15878
|
+
if (typeof code === "string" && code && !segment.includes(code)) {
|
|
15879
|
+
segment = `${segment} [${code}]`;
|
|
15880
|
+
}
|
|
15881
|
+
} else if (typeof current === "string") {
|
|
15882
|
+
segment = current;
|
|
15883
|
+
} else {
|
|
15884
|
+
segment = String(current);
|
|
15885
|
+
}
|
|
15886
|
+
if (segment && !parts.includes(segment)) parts.push(segment);
|
|
15887
|
+
const next = current.cause;
|
|
15888
|
+
if (next === current) break;
|
|
15889
|
+
current = next;
|
|
15890
|
+
}
|
|
15891
|
+
if (parts.length === 0) return String(err);
|
|
15892
|
+
return parts.length === 1 ? parts[0] : parts.join(" \u2190 ");
|
|
15893
|
+
} catch {
|
|
15894
|
+
return String(err);
|
|
15895
|
+
}
|
|
15896
|
+
}
|
|
15541
15897
|
function isPrivateIp(ip) {
|
|
15542
15898
|
const parts = ip.split(".").map(Number);
|
|
15543
15899
|
if (parts.length !== 4 || parts.some((p) => isNaN(p))) return false;
|
|
@@ -15641,8 +15997,10 @@ function validateContent(html, markdown, url) {
|
|
|
15641
15997
|
}
|
|
15642
15998
|
|
|
15643
15999
|
// src/web-research/web-scraper.ts
|
|
16000
|
+
init_retry_utils();
|
|
15644
16001
|
init_safe_unref();
|
|
15645
16002
|
init_service_registry();
|
|
16003
|
+
var FETCH_RETRY_DELAY_MS = 250;
|
|
15646
16004
|
var playwrightAvailable = false;
|
|
15647
16005
|
var markdownConverterPromise = null;
|
|
15648
16006
|
function initScraperDependencies() {
|
|
@@ -15779,6 +16137,17 @@ async function readBodyCapped(response, maxBytes) {
|
|
|
15779
16137
|
}
|
|
15780
16138
|
return out;
|
|
15781
16139
|
}
|
|
16140
|
+
function trackFetchLayerFailure(url, error) {
|
|
16141
|
+
const isSsrf = error instanceof Error && error.message.includes("not allowed");
|
|
16142
|
+
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
16143
|
+
component: "scrapers",
|
|
16144
|
+
operation: "fetch",
|
|
16145
|
+
url,
|
|
16146
|
+
domain: extractDomain(url),
|
|
16147
|
+
layer: "fetch",
|
|
16148
|
+
...isSsrf ? { errorType: "ssrf_blocked" } : {}
|
|
16149
|
+
});
|
|
16150
|
+
}
|
|
15782
16151
|
async function scrapeWithFetch(url, signal) {
|
|
15783
16152
|
await validateUrlForSSRF(url);
|
|
15784
16153
|
const dispatcher = await getSsrfSafeDispatcher();
|
|
@@ -15885,14 +16254,6 @@ async function scrapeWithFetch(url, signal) {
|
|
|
15885
16254
|
const sizeMB = Math.round(e.bytesRead / 1024 / 1024);
|
|
15886
16255
|
logger.warn(`[Scrapers] HTML response too large (streamed ${sizeMB}MB, max 25MB), skipping`);
|
|
15887
16256
|
metrics.increment("scrape_errors_total", 1, { error_type: "size_exceeded", content_type: "html" });
|
|
15888
|
-
errorTracker.trackError(new Error(`HTML response too large (${sizeMB}MB, max 25MB)`), {
|
|
15889
|
-
component: "scrapers",
|
|
15890
|
-
operation: "fetch",
|
|
15891
|
-
url,
|
|
15892
|
-
domain: extractDomain(url),
|
|
15893
|
-
layer: "fetch",
|
|
15894
|
-
contentType: "html"
|
|
15895
|
-
});
|
|
15896
16257
|
throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`, { cause: e });
|
|
15897
16258
|
}
|
|
15898
16259
|
throw e;
|
|
@@ -15901,14 +16262,6 @@ async function scrapeWithFetch(url, signal) {
|
|
|
15901
16262
|
const sizeMB = Math.round(bodyBytes.length / 1024 / 1024);
|
|
15902
16263
|
logger.warn(`[Scrapers] HTML response too large (actual: ${sizeMB}MB, max 25MB), skipping`);
|
|
15903
16264
|
metrics.increment("scrape_errors_total", 1, { error_type: "size_exceeded", content_type: "html" });
|
|
15904
|
-
errorTracker.trackError(new Error(`HTML response too large (${sizeMB}MB, max 25MB)`), {
|
|
15905
|
-
component: "scrapers",
|
|
15906
|
-
operation: "fetch",
|
|
15907
|
-
url,
|
|
15908
|
-
domain: extractDomain(url),
|
|
15909
|
-
layer: "fetch",
|
|
15910
|
-
contentType: "html"
|
|
15911
|
-
});
|
|
15912
16265
|
throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`);
|
|
15913
16266
|
}
|
|
15914
16267
|
const html = decodeHtmlBody(bodyBytes, contentType);
|
|
@@ -15927,24 +16280,9 @@ async function scrapeWithFetch(url, signal) {
|
|
|
15927
16280
|
if (error instanceof Error && error.message.includes("not allowed")) {
|
|
15928
16281
|
metrics.increment("scrape_operations_total", 1, { layer: "fetch", status: "ssrf_blocked" });
|
|
15929
16282
|
metrics.observe("scrape_latency_ms", Date.now() - fetchStart, { layer: "fetch", status: "ssrf_blocked" });
|
|
15930
|
-
errorTracker.trackError(error, {
|
|
15931
|
-
component: "scrapers",
|
|
15932
|
-
operation: "fetch",
|
|
15933
|
-
url,
|
|
15934
|
-
domain: extractDomain(url),
|
|
15935
|
-
layer: "fetch",
|
|
15936
|
-
errorType: "ssrf_blocked"
|
|
15937
|
-
});
|
|
15938
16283
|
throw error;
|
|
15939
16284
|
}
|
|
15940
16285
|
metrics.increment("scrape_operations_total", 1, { layer: "fetch", status: "error" });
|
|
15941
|
-
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
15942
|
-
component: "scrapers",
|
|
15943
|
-
operation: "fetch",
|
|
15944
|
-
url,
|
|
15945
|
-
domain: extractDomain(url),
|
|
15946
|
-
layer: "fetch"
|
|
15947
|
-
});
|
|
15948
16286
|
throw error;
|
|
15949
16287
|
} finally {
|
|
15950
16288
|
clearTimeout(timeoutId);
|
|
@@ -16017,7 +16355,21 @@ async function scrapeSingle(url, signal, config, sessionId, container = getServi
|
|
|
16017
16355
|
}
|
|
16018
16356
|
const start = Date.now();
|
|
16019
16357
|
try {
|
|
16020
|
-
const res = await
|
|
16358
|
+
const res = await (async () => {
|
|
16359
|
+
try {
|
|
16360
|
+
return await scrapeWithFetch(url, signal);
|
|
16361
|
+
} catch (firstErr) {
|
|
16362
|
+
if (!isTransientError(firstErr) || signal?.aborted) throw firstErr;
|
|
16363
|
+
logger.debug(`[Scrapers] fetch transient failure for ${url}, retrying once: ${formatErrorWithCause(firstErr)}`);
|
|
16364
|
+
metrics.increment("scrape_fetch_retries_total", 1, { layer: "fetch" });
|
|
16365
|
+
try {
|
|
16366
|
+
await abortableDelay(FETCH_RETRY_DELAY_MS, signal, true);
|
|
16367
|
+
} catch {
|
|
16368
|
+
throw firstErr;
|
|
16369
|
+
}
|
|
16370
|
+
return await scrapeWithFetch(url, signal);
|
|
16371
|
+
}
|
|
16372
|
+
})();
|
|
16021
16373
|
const duration = Date.now() - start;
|
|
16022
16374
|
logger.log(`[Scrapers] fetch success for ${url} in ${duration}ms`);
|
|
16023
16375
|
metrics.increment("scrape_results_total", 1, { outcome: "fetch_success" });
|
|
@@ -16027,7 +16379,8 @@ async function scrapeSingle(url, signal, config, sessionId, container = getServi
|
|
|
16027
16379
|
return { ...res, url, success: true };
|
|
16028
16380
|
} catch (e1) {
|
|
16029
16381
|
const fetchDuration = Date.now() - start;
|
|
16030
|
-
logger.debug(`[Scrapers] fetch failed for ${url} in ${fetchDuration}ms: ${
|
|
16382
|
+
logger.debug(`[Scrapers] fetch failed for ${url} in ${fetchDuration}ms: ${formatErrorWithCause(e1)}`);
|
|
16383
|
+
trackFetchLayerFailure(url, e1);
|
|
16031
16384
|
if (playwrightAvailable) {
|
|
16032
16385
|
try {
|
|
16033
16386
|
const browserStart = Date.now();
|
|
@@ -16044,18 +16397,18 @@ async function scrapeSingle(url, signal, config, sessionId, container = getServi
|
|
|
16044
16397
|
} catch (e2) {
|
|
16045
16398
|
const totalDuration = Date.now() - start;
|
|
16046
16399
|
if (isBenignScrapeFailure(e2)) {
|
|
16047
|
-
logger.debug(`[Scrapers] Browser fallback did not yield content for ${url} in ${totalDuration}ms: ${
|
|
16400
|
+
logger.debug(`[Scrapers] Browser fallback did not yield content for ${url} in ${totalDuration}ms: ${formatErrorWithCause(e2)}`);
|
|
16048
16401
|
} else {
|
|
16049
16402
|
logger.error(`[Scrapers] Browser fallback failed for ${url} in ${totalDuration}ms:`, e2);
|
|
16050
16403
|
}
|
|
16051
16404
|
metrics.increment("scrape_errors_total", 1, { error_type: "fallback_failed", layer: "playwright" });
|
|
16052
16405
|
metrics.increment("scrape_results_total", 1, { outcome: "total_failure" });
|
|
16053
|
-
return { url, success: false, error:
|
|
16406
|
+
return { url, success: false, error: formatErrorWithCause(e2), markdown: "" };
|
|
16054
16407
|
}
|
|
16055
16408
|
}
|
|
16056
16409
|
metrics.increment("scrape_errors_total", 1, { error_type: "no_fallback_available", layer: "fetch" });
|
|
16057
16410
|
metrics.increment("scrape_results_total", 1, { outcome: "total_failure" });
|
|
16058
|
-
return { url, success: false, error:
|
|
16411
|
+
return { url, success: false, error: formatErrorWithCause(e1), markdown: "" };
|
|
16059
16412
|
}
|
|
16060
16413
|
}
|
|
16061
16414
|
async function scrape(urls, maxConcurrency = 5, signal, config, sessionId, onUrlComplete, container = getServiceContainer()) {
|
|
@@ -20510,6 +20863,9 @@ var HeadlessObserver = class {
|
|
|
20510
20863
|
onStart(query, complexity) {
|
|
20511
20864
|
this.emit("start", { query, complexity });
|
|
20512
20865
|
}
|
|
20866
|
+
onRunQueued(slots, maxWaitMs) {
|
|
20867
|
+
this.emit("run_queued", { slots, maxWaitMs });
|
|
20868
|
+
}
|
|
20513
20869
|
onPlanningStart(attempt) {
|
|
20514
20870
|
this.emit("planning_start", { attempt });
|
|
20515
20871
|
}
|
|
@@ -21211,6 +21567,8 @@ var DeepResearchOrchestrator = class {
|
|
|
21211
21567
|
} catch (obsErr) {
|
|
21212
21568
|
logger.debug("[DeepOrchestrator] onComplete observer threw:", obsErr);
|
|
21213
21569
|
}
|
|
21570
|
+
logger.debug(`[DeepOrchestrator] Final synthesis for ${researchId} (${result.length} chars):
|
|
21571
|
+
${result}`);
|
|
21214
21572
|
return result;
|
|
21215
21573
|
} catch (error) {
|
|
21216
21574
|
const sessionDuration = Date.now() - this.sessionStart;
|
|
@@ -21234,6 +21592,8 @@ var DeepResearchOrchestrator = class {
|
|
|
21234
21592
|
} catch (obsErr) {
|
|
21235
21593
|
logger.debug("[DeepOrchestrator] fallback onComplete observer threw:", obsErr);
|
|
21236
21594
|
}
|
|
21595
|
+
logger.debug(`[DeepOrchestrator] Fallback synthesis for ${researchId} (${result.length} chars):
|
|
21596
|
+
${result}`);
|
|
21237
21597
|
return result;
|
|
21238
21598
|
}
|
|
21239
21599
|
} catch (fallbackErr) {
|
|
@@ -21300,6 +21660,19 @@ var ResearchOrchestrationService = class {
|
|
|
21300
21660
|
const selectedModel = await this.resolveResearchModel(options);
|
|
21301
21661
|
logger.info(`[ResearchOrchestrationService] Using model: ${selectedModel.provider}/${selectedModel.id}`);
|
|
21302
21662
|
const researchStart = Date.now();
|
|
21663
|
+
let runSlot = null;
|
|
21664
|
+
try {
|
|
21665
|
+
const container = tryGetServiceContainerFromCtx(ctx);
|
|
21666
|
+
const semaphore = await getService(ServiceNames.RESEARCH_RUN_SEMAPHORE, ctx, container);
|
|
21667
|
+
runSlot = await semaphore.acquire(void 0, signal, (slots, maxWaitMs) => {
|
|
21668
|
+
notifyRunQueued(observer, slots, maxWaitMs);
|
|
21669
|
+
});
|
|
21670
|
+
logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
|
|
21671
|
+
} catch (err) {
|
|
21672
|
+
if (err instanceof ResearchRunCapacityError) throw err;
|
|
21673
|
+
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
21674
|
+
logger.warn(`[ResearchOrchestrationService] Run-cap unavailable, proceeding without it: ${err instanceof Error ? err.message : String(err)}`);
|
|
21675
|
+
}
|
|
21303
21676
|
let result;
|
|
21304
21677
|
try {
|
|
21305
21678
|
if (depth === 0) {
|
|
@@ -21342,6 +21715,14 @@ var ResearchOrchestrationService = class {
|
|
|
21342
21715
|
metrics.observe("research_manager_latency_ms", researchDuration, { depth: String(depth), status: "error", source: "extension" });
|
|
21343
21716
|
metrics.increment("research_manager_requests_total", 1, { depth: String(depth), status: "error", source: "extension" });
|
|
21344
21717
|
throw error;
|
|
21718
|
+
} finally {
|
|
21719
|
+
if (runSlot) {
|
|
21720
|
+
await runSlot.release().catch((relErr) => {
|
|
21721
|
+
logger.warn(
|
|
21722
|
+
`[ResearchOrchestrationService] Failed to release run slot ${runSlot?.slotIndex}; capacity is reduced until this process exits: ${relErr instanceof Error ? relErr.message : String(relErr)}`
|
|
21723
|
+
);
|
|
21724
|
+
});
|
|
21725
|
+
}
|
|
21345
21726
|
}
|
|
21346
21727
|
}
|
|
21347
21728
|
/**
|
|
@@ -21639,6 +22020,15 @@ var ResearchOrchestrationService = class {
|
|
|
21639
22020
|
}
|
|
21640
22021
|
}
|
|
21641
22022
|
};
|
|
22023
|
+
function notifyRunQueued(observer, slots, maxWaitMs) {
|
|
22024
|
+
try {
|
|
22025
|
+
const obs = observer;
|
|
22026
|
+
if (typeof obs?.onRunQueued === "function") obs.onRunQueued(slots, maxWaitMs);
|
|
22027
|
+
else if (typeof obs?.onProgress === "function") obs.onProgress("run_queued", { slots, maxWaitMs });
|
|
22028
|
+
} catch (err) {
|
|
22029
|
+
logger.debug(`[ResearchOrchestrationService] run_queued notification threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
22030
|
+
}
|
|
22031
|
+
}
|
|
21642
22032
|
|
|
21643
22033
|
// src/orchestration/research-session-service.ts
|
|
21644
22034
|
init_logger();
|
|
@@ -21782,10 +22172,63 @@ var ResearchSessionService = class {
|
|
|
21782
22172
|
// src/orchestration/research-synthesis-service.ts
|
|
21783
22173
|
init_text_utils();
|
|
21784
22174
|
init_text_utils();
|
|
22175
|
+
init_url_utils();
|
|
21785
22176
|
init_logger();
|
|
21786
22177
|
init_service_registry();
|
|
21787
22178
|
init_service_names();
|
|
21788
22179
|
var NO_SOURCES_NOTE = "CITED LINKS\n_No web sources were successfully retrieved for this report \u2014 searches or page fetches may have been blocked (for example by site bot-protection) or returned no usable results. Treat the findings above as unverified._";
|
|
22180
|
+
var UNVERIFIED_LINKS_NOTE = "_None of the links above could be verified: no page fetch succeeded this session and no citation could be matched to retrieved content. They may be inaccurate or invented \u2014 check each one before relying on it._";
|
|
22181
|
+
var MAX_PLAUSIBLE_CITATION_MARKER = 200;
|
|
22182
|
+
function rewriteCitationMarkers(text, remap, isDangling) {
|
|
22183
|
+
const inFence = fencedCodeMask(text);
|
|
22184
|
+
return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad, digits, offset) => {
|
|
22185
|
+
const n = parseInt(digits, 10);
|
|
22186
|
+
const mapped = remap.get(n);
|
|
22187
|
+
if (mapped !== void 0) return `${pad}[${mapped}]`;
|
|
22188
|
+
if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
|
|
22189
|
+
if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
|
|
22190
|
+
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
22191
|
+
return text.slice(lineStart, offset).trim() === "" ? pad : "";
|
|
22192
|
+
});
|
|
22193
|
+
}
|
|
22194
|
+
function fencedCodeMask(text) {
|
|
22195
|
+
const ranges = [];
|
|
22196
|
+
let open4 = -1;
|
|
22197
|
+
let pos = 0;
|
|
22198
|
+
for (const line of text.split("\n")) {
|
|
22199
|
+
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
22200
|
+
if (open4 < 0) open4 = pos;
|
|
22201
|
+
else {
|
|
22202
|
+
ranges.push([open4, pos + line.length]);
|
|
22203
|
+
open4 = -1;
|
|
22204
|
+
}
|
|
22205
|
+
}
|
|
22206
|
+
pos += line.length + 1;
|
|
22207
|
+
}
|
|
22208
|
+
if (open4 >= 0) ranges.push([open4, text.length]);
|
|
22209
|
+
return (offset) => ranges.some(([start, end]) => offset >= start && offset < end);
|
|
22210
|
+
}
|
|
22211
|
+
function inInlineCodeSpan(text, offset) {
|
|
22212
|
+
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
22213
|
+
let ticks = 0;
|
|
22214
|
+
for (let i = lineStart; i < offset; i++) if (text[i] === "`") ticks++;
|
|
22215
|
+
return ticks % 2 === 1;
|
|
22216
|
+
}
|
|
22217
|
+
function redactUnverifiedProseUrls(text, trusted) {
|
|
22218
|
+
if (trusted.size === 0) return text;
|
|
22219
|
+
let removed = 0;
|
|
22220
|
+
const result = text.replace(/https?:\/\/[^\s<>"']+/gi, (token) => {
|
|
22221
|
+
const core = stripTrailingLlmPunctuation(token);
|
|
22222
|
+
const tail = token.slice(core.length);
|
|
22223
|
+
if (!core || trusted.has(normalizeUrl(core))) return token;
|
|
22224
|
+
removed++;
|
|
22225
|
+
return `[link removed: not found in retrieved sources]${tail}`;
|
|
22226
|
+
});
|
|
22227
|
+
if (removed > 0) {
|
|
22228
|
+
logger.warn(`[ResearchSynthesisService] Redacted ${removed} unverified inline URL(s) from synthesis prose (not present in retrieved sources).`);
|
|
22229
|
+
}
|
|
22230
|
+
return result;
|
|
22231
|
+
}
|
|
21789
22232
|
var ResearchSynthesisService = class _ResearchSynthesisService {
|
|
21790
22233
|
name = ServiceNames.RESEARCH_SYNTHESIS_SERVICE;
|
|
21791
22234
|
lifecycle = "uninitialized" /* UNINITIALIZED */;
|
|
@@ -21939,35 +22382,42 @@ Research performed using ${modelId}`;
|
|
|
21939
22382
|
}
|
|
21940
22383
|
if (globalCitations.length > 0) {
|
|
21941
22384
|
const verifiedLinksSection = formatCitedLinks(globalCitations);
|
|
22385
|
+
const trustedUrls = new Set(globalCitations.map((c) => normalizeUrl(c.url)));
|
|
21942
22386
|
const citedHeaderIdx = lastCitedLinksHeaderIndex(synthesis);
|
|
21943
22387
|
if (citedHeaderIdx >= 0) {
|
|
21944
22388
|
logger.debug("[ResearchSynthesisService] Replacing existing CITED LINKS with verified version");
|
|
21945
22389
|
const ownCitations = parseCitations(synthesis);
|
|
21946
22390
|
const urlToGlobalId = new Map(globalCitations.map((c) => [normalizeUrl(c.url), c.id]));
|
|
21947
22391
|
const localToGlobal = /* @__PURE__ */ new Map();
|
|
22392
|
+
const droppedNumbers = /* @__PURE__ */ new Set();
|
|
21948
22393
|
ownCitations.forEach((cit, index) => {
|
|
22394
|
+
const localKey = cit.number ?? index + 1;
|
|
21949
22395
|
const globalId = urlToGlobalId.get(normalizeUrl(cit.url));
|
|
21950
|
-
if (globalId !== void 0) localToGlobal.set(
|
|
22396
|
+
if (globalId !== void 0) localToGlobal.set(localKey, globalId);
|
|
22397
|
+
else droppedNumbers.add(localKey);
|
|
21951
22398
|
});
|
|
21952
22399
|
let body = synthesis.slice(0, citedHeaderIdx).trimEnd();
|
|
21953
|
-
|
|
21954
|
-
|
|
21955
|
-
body = body.replace(/\[(\d+)\]/g, (match, p1) => {
|
|
21956
|
-
const globalId = localToGlobal.get(parseInt(p1, 10));
|
|
21957
|
-
return globalId !== void 0 ? `[${globalId}]` : match;
|
|
21958
|
-
});
|
|
22400
|
+
if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
|
|
22401
|
+
body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
|
|
21959
22402
|
}
|
|
22403
|
+
body = redactUnverifiedProseUrls(body, trustedUrls);
|
|
21960
22404
|
return `${body}
|
|
21961
22405
|
|
|
21962
22406
|
${verifiedLinksSection}`;
|
|
21963
22407
|
}
|
|
21964
22408
|
logger.warn("[ResearchSynthesisService] Synthesis missing CITED LINKS - appending verified version");
|
|
21965
|
-
|
|
22409
|
+
const validIds = new Set(globalCitations.map((c) => c.id));
|
|
22410
|
+
let prose = rewriteCitationMarkers(synthesis.trim(), /* @__PURE__ */ new Map(), (n) => !validIds.has(n));
|
|
22411
|
+
prose = redactUnverifiedProseUrls(prose, trustedUrls);
|
|
22412
|
+
return `${prose}
|
|
21966
22413
|
|
|
21967
22414
|
${verifiedLinksSection}`;
|
|
21968
22415
|
}
|
|
21969
22416
|
if (/CITED LINKS/i.test(synthesis)) {
|
|
21970
|
-
|
|
22417
|
+
logger.warn("[ResearchSynthesisService] Preserving an unparseable CITED LINKS block from a run that retrieved no sources \u2014 flagging it as unverified.");
|
|
22418
|
+
return `${synthesis.trimEnd()}
|
|
22419
|
+
|
|
22420
|
+
${UNVERIFIED_LINKS_NOTE}`;
|
|
21971
22421
|
}
|
|
21972
22422
|
logger.warn("[ResearchSynthesisService] No citations and no scrape provenance \u2014 appending explicit no-sources note.");
|
|
21973
22423
|
return `${synthesis.trim()}
|
|
@@ -22669,7 +23119,7 @@ init_logger();
|
|
|
22669
23119
|
init_constants();
|
|
22670
23120
|
import { promises as fs14 } from "node:fs";
|
|
22671
23121
|
import { homedir as homedir4, tmpdir as tmpdir4, platform as platform3 } from "node:os";
|
|
22672
|
-
import { join as
|
|
23122
|
+
import { join as join18, resolve as resolve3, normalize, sep as sep2 } from "node:path";
|
|
22673
23123
|
import { randomBytes as randomBytes5 } from "node:crypto";
|
|
22674
23124
|
var PREFERRED_SUBDIRS = [
|
|
22675
23125
|
"research",
|
|
@@ -22740,7 +23190,7 @@ async function findPreferredSubdir(cwd) {
|
|
|
22740
23190
|
for (const target of PREFERRED_SUBDIRS) {
|
|
22741
23191
|
const match = entries.find((e) => e.toLowerCase() === target);
|
|
22742
23192
|
if (match === void 0) continue;
|
|
22743
|
-
const candidate =
|
|
23193
|
+
const candidate = join18(cwd, match);
|
|
22744
23194
|
try {
|
|
22745
23195
|
const stat5 = await fs14.stat(candidate);
|
|
22746
23196
|
if (stat5.isDirectory()) return candidate;
|
|
@@ -22785,7 +23235,7 @@ async function exportResearchReport(query, result, _mode, cwd, explicitDir) {
|
|
|
22785
23235
|
for (let attempt = 0; attempt < MAX_EXPORT_RETRIES; attempt++) {
|
|
22786
23236
|
const hash = generateHash();
|
|
22787
23237
|
const filename = `${baseFilename}-${hash}.md`;
|
|
22788
|
-
const filepath =
|
|
23238
|
+
const filepath = join18(targetDir, filename);
|
|
22789
23239
|
try {
|
|
22790
23240
|
await fs14.writeFile(filepath, result, { flag: "wx" });
|
|
22791
23241
|
logger.log(`[export] Research report saved to: ${filepath}`);
|
|
@@ -22835,7 +23285,15 @@ var EXIT = {
|
|
|
22835
23285
|
/** Missing setup: package, model, or API key not configured. */
|
|
22836
23286
|
CONFIG: 78,
|
|
22837
23287
|
/** Something went wrong at runtime (network, provider, internal). */
|
|
22838
|
-
SOFTWARE: 70
|
|
23288
|
+
SOFTWARE: 70,
|
|
23289
|
+
/**
|
|
23290
|
+
* Temporary failure — the request was well-formed and the install is healthy,
|
|
23291
|
+
* but the machine is at capacity right now (all research run slots held by
|
|
23292
|
+
* live runs). Distinct from SOFTWARE so a calling agent can tell "busy, try
|
|
23293
|
+
* again shortly" apart from "this run crashed" and back off instead of
|
|
23294
|
+
* re-running a broken command.
|
|
23295
|
+
*/
|
|
23296
|
+
TEMPFAIL: 75
|
|
22839
23297
|
};
|
|
22840
23298
|
function toStderr(s) {
|
|
22841
23299
|
process.stderr.write(s);
|
|
@@ -22855,6 +23313,12 @@ function makeProgressObserver() {
|
|
|
22855
23313
|
toStderr(` \u2022 plan: ${data?.plan?.researchers?.length ?? "?"} researcher(s)
|
|
22856
23314
|
`);
|
|
22857
23315
|
break;
|
|
23316
|
+
case "run_queued":
|
|
23317
|
+
toStderr(
|
|
23318
|
+
` \u2022 queued: ${data?.slots ?? "?"} concurrent run(s) already active \u2014 waiting up to ${Math.round((data?.maxWaitMs ?? 0) / 1e3)}s for a slot\u2026
|
|
23319
|
+
`
|
|
23320
|
+
);
|
|
23321
|
+
break;
|
|
22858
23322
|
case "round_start":
|
|
22859
23323
|
toStderr(` \u2022 round ${data?.round}
|
|
22860
23324
|
`);
|
|
@@ -23399,17 +23863,25 @@ async function reportError(err, what, json) {
|
|
|
23399
23863
|
const msg2 = err instanceof Error ? err.message : String(err);
|
|
23400
23864
|
const lower = msg2.toLowerCase();
|
|
23401
23865
|
const isTaggedResearchStop = err?.code === RESEARCH_STOPPED_ERROR_CODE;
|
|
23402
|
-
const
|
|
23866
|
+
const isCapacity = err instanceof Error && err.name === "ResearchRunCapacityError";
|
|
23867
|
+
const isConfigError = !isTaggedResearchStop && !isCapacity && (lower.includes("no llm model available") || lower.includes("not found in pi") || lower.includes("no api key") || lower.includes("provider must be specified") || lower.includes("invalid model string") || lower.includes("authentication") || lower.includes("unauthorized") || lower.includes("api key"));
|
|
23403
23868
|
const isRateLimit = lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests");
|
|
23404
|
-
const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
|
|
23869
|
+
const exitCode = isCapacity ? EXIT.TEMPFAIL : isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
|
|
23405
23870
|
if (json) {
|
|
23406
23871
|
const payload = { ok: false, error: msg2, exitCode };
|
|
23872
|
+
if (isCapacity || isRateLimit) payload["retryable"] = true;
|
|
23407
23873
|
if (process.env["PI_RESEARCH_DEBUG"] === "true" && err instanceof Error && err.stack) {
|
|
23408
23874
|
payload["stack"] = err.stack;
|
|
23409
23875
|
}
|
|
23410
23876
|
toStdout(pretty(payload));
|
|
23411
23877
|
return exitCode;
|
|
23412
23878
|
}
|
|
23879
|
+
if (isCapacity) {
|
|
23880
|
+
toStderr(`
|
|
23881
|
+
Error: pi-research ${what} did not start: ${msg2}
|
|
23882
|
+
`);
|
|
23883
|
+
return exitCode;
|
|
23884
|
+
}
|
|
23413
23885
|
if (isRateLimit) {
|
|
23414
23886
|
toStderr(`
|
|
23415
23887
|
Error: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.
|