@lincoln504/pi-research 1.0.10 → 1.0.12

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/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 !== 200) {
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
- async shutdown() {
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
- return false;
13055
+ process.kill(pid, 0);
13056
+ return true;
12966
13057
  }
12967
13058
  return Math.abs(actualStartTime - expectedStartTime) <= 1;
12968
13059
  }
@@ -14130,6 +14221,180 @@ 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 = 2;
14232
+ var DEFAULT_ACQUIRE_TIMEOUT_MS = 0;
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 ? ` after waiting ${maxWaitMs}ms` : ""}; wait for an in-progress run to finish and retry. (Override with PI_RESEARCH_MAX_CONCURRENT_RUNS.)`
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
+ * @throws {ResearchRunCapacityError} if no slot frees within `maxWaitMs`.
14339
+ * @throws {Error} named `AbortError` if `signal` aborts while waiting.
14340
+ */
14341
+ async acquire(maxWaitMs = this.defaultMaxWaitMs, signal) {
14342
+ if (!this._initialized) await this.initialize();
14343
+ if (signal?.aborted) {
14344
+ metrics.increment("research_run_acquire_total", 1, { status: "aborted" });
14345
+ throwIfAborted(signal);
14346
+ }
14347
+ const deadline = Date.now() + Math.max(0, maxWaitMs);
14348
+ for (; ; ) {
14349
+ const start = this.nextStartIndex;
14350
+ this.nextStartIndex = (this.nextStartIndex + 1) % this.maxSlots;
14351
+ for (let offset = 0; offset < this.maxSlots; offset++) {
14352
+ const i = (start + offset) % this.maxSlots;
14353
+ const got = await this.trySlot(i);
14354
+ if (got) {
14355
+ metrics.setGauge("research_run_slots_held", 1, { slot: String(i) });
14356
+ metrics.increment("research_run_acquire_total", 1, { status: "acquired" });
14357
+ return got;
14358
+ }
14359
+ }
14360
+ if (Date.now() >= deadline) {
14361
+ metrics.increment("research_run_acquire_total", 1, { status: "exhausted" });
14362
+ throw new ResearchRunCapacityError(this.maxSlots, maxWaitMs);
14363
+ }
14364
+ try {
14365
+ await sleep(POLL_DELAY_MS, signal);
14366
+ } catch (err) {
14367
+ metrics.increment("research_run_acquire_total", 1, { status: "aborted" });
14368
+ throw err;
14369
+ }
14370
+ }
14371
+ }
14372
+ };
14373
+ function throwIfAborted(signal) {
14374
+ if (signal?.aborted) {
14375
+ throw Object.assign(new Error("Research run slot acquisition aborted"), { name: "AbortError" });
14376
+ }
14377
+ }
14378
+ function numberFromEnv(key) {
14379
+ const raw = process.env[key];
14380
+ if (raw === void 0 || raw === "") return void 0;
14381
+ const n = Number(raw);
14382
+ return Number.isFinite(n) ? n : void 0;
14383
+ }
14384
+ function sleep(ms, signal) {
14385
+ return new Promise((resolve8, reject) => {
14386
+ const timer = setTimeout(() => {
14387
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
14388
+ resolve8();
14389
+ }, ms);
14390
+ const onAbort = signal ? () => {
14391
+ clearTimeout(timer);
14392
+ reject(Object.assign(new Error("Research run slot acquisition aborted"), { name: "AbortError" }));
14393
+ } : void 0;
14394
+ if (onAbort) signal?.addEventListener("abort", onAbort, { once: true });
14395
+ });
14396
+ }
14397
+
14133
14398
  // src/infrastructure/state/state-backup-manager.ts
14134
14399
  init_logger();
14135
14400
  init_service_registry();
@@ -14389,6 +14654,19 @@ function registerInfrastructureServices(container = getServiceContainer()) {
14389
14654
  },
14390
14655
  container
14391
14656
  );
14657
+ registerService(
14658
+ ServiceNames.RESEARCH_RUN_SEMAPHORE,
14659
+ async () => {
14660
+ const pathConfig = await getService(ServiceNames.STATE_PATH_CONFIGURATION, void 0, container);
14661
+ const processLifecycle = await getService(ServiceNames.PROCESS_LIFECYCLE, void 0, container);
14662
+ return new ResearchRunSemaphore(pathConfig.getStateDir(), processLifecycle);
14663
+ },
14664
+ {
14665
+ allowOverwrite: false,
14666
+ enableLogging: true
14667
+ },
14668
+ container
14669
+ );
14392
14670
  registerService(
14393
14671
  ServiceNames.STATE_BACKUP_MANAGER,
14394
14672
  async () => {
@@ -15538,6 +15816,34 @@ async function getSsrfSafeDispatcher() {
15538
15816
  }
15539
15817
  return cachedSsrfDispatcher;
15540
15818
  }
15819
+ function formatErrorWithCause(err) {
15820
+ try {
15821
+ const parts = [];
15822
+ let current = err;
15823
+ for (let depth = 0; current !== null && current !== void 0 && depth < 5; depth++) {
15824
+ let segment;
15825
+ if (current instanceof Error) {
15826
+ segment = current.message || current.name;
15827
+ const code = current.code;
15828
+ if (typeof code === "string" && code && !segment.includes(code)) {
15829
+ segment = `${segment} [${code}]`;
15830
+ }
15831
+ } else if (typeof current === "string") {
15832
+ segment = current;
15833
+ } else {
15834
+ segment = String(current);
15835
+ }
15836
+ if (segment && !parts.includes(segment)) parts.push(segment);
15837
+ const next = current.cause;
15838
+ if (next === current) break;
15839
+ current = next;
15840
+ }
15841
+ if (parts.length === 0) return String(err);
15842
+ return parts.length === 1 ? parts[0] : parts.join(" \u2190 ");
15843
+ } catch {
15844
+ return String(err);
15845
+ }
15846
+ }
15541
15847
  function isPrivateIp(ip) {
15542
15848
  const parts = ip.split(".").map(Number);
15543
15849
  if (parts.length !== 4 || parts.some((p) => isNaN(p))) return false;
@@ -15641,8 +15947,10 @@ function validateContent(html, markdown, url) {
15641
15947
  }
15642
15948
 
15643
15949
  // src/web-research/web-scraper.ts
15950
+ init_retry_utils();
15644
15951
  init_safe_unref();
15645
15952
  init_service_registry();
15953
+ var FETCH_RETRY_DELAY_MS = 250;
15646
15954
  var playwrightAvailable = false;
15647
15955
  var markdownConverterPromise = null;
15648
15956
  function initScraperDependencies() {
@@ -15779,6 +16087,17 @@ async function readBodyCapped(response, maxBytes) {
15779
16087
  }
15780
16088
  return out;
15781
16089
  }
16090
+ function trackFetchLayerFailure(url, error) {
16091
+ const isSsrf = error instanceof Error && error.message.includes("not allowed");
16092
+ errorTracker.trackError(error instanceof Error ? error : String(error), {
16093
+ component: "scrapers",
16094
+ operation: "fetch",
16095
+ url,
16096
+ domain: extractDomain(url),
16097
+ layer: "fetch",
16098
+ ...isSsrf ? { errorType: "ssrf_blocked" } : {}
16099
+ });
16100
+ }
15782
16101
  async function scrapeWithFetch(url, signal) {
15783
16102
  await validateUrlForSSRF(url);
15784
16103
  const dispatcher = await getSsrfSafeDispatcher();
@@ -15885,14 +16204,6 @@ async function scrapeWithFetch(url, signal) {
15885
16204
  const sizeMB = Math.round(e.bytesRead / 1024 / 1024);
15886
16205
  logger.warn(`[Scrapers] HTML response too large (streamed ${sizeMB}MB, max 25MB), skipping`);
15887
16206
  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
16207
  throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`, { cause: e });
15897
16208
  }
15898
16209
  throw e;
@@ -15901,14 +16212,6 @@ async function scrapeWithFetch(url, signal) {
15901
16212
  const sizeMB = Math.round(bodyBytes.length / 1024 / 1024);
15902
16213
  logger.warn(`[Scrapers] HTML response too large (actual: ${sizeMB}MB, max 25MB), skipping`);
15903
16214
  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
16215
  throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`);
15913
16216
  }
15914
16217
  const html = decodeHtmlBody(bodyBytes, contentType);
@@ -15927,24 +16230,9 @@ async function scrapeWithFetch(url, signal) {
15927
16230
  if (error instanceof Error && error.message.includes("not allowed")) {
15928
16231
  metrics.increment("scrape_operations_total", 1, { layer: "fetch", status: "ssrf_blocked" });
15929
16232
  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
16233
  throw error;
15939
16234
  }
15940
16235
  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
16236
  throw error;
15949
16237
  } finally {
15950
16238
  clearTimeout(timeoutId);
@@ -16017,7 +16305,21 @@ async function scrapeSingle(url, signal, config, sessionId, container = getServi
16017
16305
  }
16018
16306
  const start = Date.now();
16019
16307
  try {
16020
- const res = await scrapeWithFetch(url, signal);
16308
+ const res = await (async () => {
16309
+ try {
16310
+ return await scrapeWithFetch(url, signal);
16311
+ } catch (firstErr) {
16312
+ if (!isTransientError(firstErr) || signal?.aborted) throw firstErr;
16313
+ logger.debug(`[Scrapers] fetch transient failure for ${url}, retrying once: ${formatErrorWithCause(firstErr)}`);
16314
+ metrics.increment("scrape_fetch_retries_total", 1, { layer: "fetch" });
16315
+ try {
16316
+ await abortableDelay(FETCH_RETRY_DELAY_MS, signal, true);
16317
+ } catch {
16318
+ throw firstErr;
16319
+ }
16320
+ return await scrapeWithFetch(url, signal);
16321
+ }
16322
+ })();
16021
16323
  const duration = Date.now() - start;
16022
16324
  logger.log(`[Scrapers] fetch success for ${url} in ${duration}ms`);
16023
16325
  metrics.increment("scrape_results_total", 1, { outcome: "fetch_success" });
@@ -16027,7 +16329,8 @@ async function scrapeSingle(url, signal, config, sessionId, container = getServi
16027
16329
  return { ...res, url, success: true };
16028
16330
  } catch (e1) {
16029
16331
  const fetchDuration = Date.now() - start;
16030
- logger.debug(`[Scrapers] fetch failed for ${url} in ${fetchDuration}ms: ${String(e1)}`);
16332
+ logger.debug(`[Scrapers] fetch failed for ${url} in ${fetchDuration}ms: ${formatErrorWithCause(e1)}`);
16333
+ trackFetchLayerFailure(url, e1);
16031
16334
  if (playwrightAvailable) {
16032
16335
  try {
16033
16336
  const browserStart = Date.now();
@@ -16044,18 +16347,18 @@ async function scrapeSingle(url, signal, config, sessionId, container = getServi
16044
16347
  } catch (e2) {
16045
16348
  const totalDuration = Date.now() - start;
16046
16349
  if (isBenignScrapeFailure(e2)) {
16047
- logger.debug(`[Scrapers] Browser fallback did not yield content for ${url} in ${totalDuration}ms: ${String(e2)}`);
16350
+ logger.debug(`[Scrapers] Browser fallback did not yield content for ${url} in ${totalDuration}ms: ${formatErrorWithCause(e2)}`);
16048
16351
  } else {
16049
16352
  logger.error(`[Scrapers] Browser fallback failed for ${url} in ${totalDuration}ms:`, e2);
16050
16353
  }
16051
16354
  metrics.increment("scrape_errors_total", 1, { error_type: "fallback_failed", layer: "playwright" });
16052
16355
  metrics.increment("scrape_results_total", 1, { outcome: "total_failure" });
16053
- return { url, success: false, error: String(e2), markdown: "" };
16356
+ return { url, success: false, error: formatErrorWithCause(e2), markdown: "" };
16054
16357
  }
16055
16358
  }
16056
16359
  metrics.increment("scrape_errors_total", 1, { error_type: "no_fallback_available", layer: "fetch" });
16057
16360
  metrics.increment("scrape_results_total", 1, { outcome: "total_failure" });
16058
- return { url, success: false, error: String(e1), markdown: "" };
16361
+ return { url, success: false, error: formatErrorWithCause(e1), markdown: "" };
16059
16362
  }
16060
16363
  }
16061
16364
  async function scrape(urls, maxConcurrency = 5, signal, config, sessionId, onUrlComplete, container = getServiceContainer()) {
@@ -21211,6 +21514,8 @@ var DeepResearchOrchestrator = class {
21211
21514
  } catch (obsErr) {
21212
21515
  logger.debug("[DeepOrchestrator] onComplete observer threw:", obsErr);
21213
21516
  }
21517
+ logger.debug(`[DeepOrchestrator] Final synthesis for ${researchId} (${result.length} chars):
21518
+ ${result}`);
21214
21519
  return result;
21215
21520
  } catch (error) {
21216
21521
  const sessionDuration = Date.now() - this.sessionStart;
@@ -21234,6 +21539,8 @@ var DeepResearchOrchestrator = class {
21234
21539
  } catch (obsErr) {
21235
21540
  logger.debug("[DeepOrchestrator] fallback onComplete observer threw:", obsErr);
21236
21541
  }
21542
+ logger.debug(`[DeepOrchestrator] Fallback synthesis for ${researchId} (${result.length} chars):
21543
+ ${result}`);
21237
21544
  return result;
21238
21545
  }
21239
21546
  } catch (fallbackErr) {
@@ -21300,6 +21607,17 @@ var ResearchOrchestrationService = class {
21300
21607
  const selectedModel = await this.resolveResearchModel(options);
21301
21608
  logger.info(`[ResearchOrchestrationService] Using model: ${selectedModel.provider}/${selectedModel.id}`);
21302
21609
  const researchStart = Date.now();
21610
+ let runSlot = null;
21611
+ try {
21612
+ const container = tryGetServiceContainerFromCtx(ctx);
21613
+ const semaphore = await getService(ServiceNames.RESEARCH_RUN_SEMAPHORE, ctx, container);
21614
+ runSlot = await semaphore.acquire(void 0, signal);
21615
+ logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
21616
+ } catch (err) {
21617
+ if (err instanceof ResearchRunCapacityError) throw err;
21618
+ if (err instanceof Error && err.name === "AbortError") throw err;
21619
+ logger.warn(`[ResearchOrchestrationService] Run-cap unavailable, proceeding without it: ${err instanceof Error ? err.message : String(err)}`);
21620
+ }
21303
21621
  let result;
21304
21622
  try {
21305
21623
  if (depth === 0) {
@@ -21342,6 +21660,14 @@ var ResearchOrchestrationService = class {
21342
21660
  metrics.observe("research_manager_latency_ms", researchDuration, { depth: String(depth), status: "error", source: "extension" });
21343
21661
  metrics.increment("research_manager_requests_total", 1, { depth: String(depth), status: "error", source: "extension" });
21344
21662
  throw error;
21663
+ } finally {
21664
+ if (runSlot) {
21665
+ await runSlot.release().catch((relErr) => {
21666
+ logger.warn(
21667
+ `[ResearchOrchestrationService] Failed to release run slot ${runSlot?.slotIndex}; capacity is reduced until this process exits: ${relErr instanceof Error ? relErr.message : String(relErr)}`
21668
+ );
21669
+ });
21670
+ }
21345
21671
  }
21346
21672
  }
21347
21673
  /**
@@ -21782,10 +22108,63 @@ var ResearchSessionService = class {
21782
22108
  // src/orchestration/research-synthesis-service.ts
21783
22109
  init_text_utils();
21784
22110
  init_text_utils();
22111
+ init_url_utils();
21785
22112
  init_logger();
21786
22113
  init_service_registry();
21787
22114
  init_service_names();
21788
22115
  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._";
22116
+ 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._";
22117
+ var MAX_PLAUSIBLE_CITATION_MARKER = 200;
22118
+ function rewriteCitationMarkers(text, remap, isDangling) {
22119
+ const inFence = fencedCodeMask(text);
22120
+ return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad, digits, offset) => {
22121
+ const n = parseInt(digits, 10);
22122
+ const mapped = remap.get(n);
22123
+ if (mapped !== void 0) return `${pad}[${mapped}]`;
22124
+ if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
22125
+ if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
22126
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
22127
+ return text.slice(lineStart, offset).trim() === "" ? pad : "";
22128
+ });
22129
+ }
22130
+ function fencedCodeMask(text) {
22131
+ const ranges = [];
22132
+ let open4 = -1;
22133
+ let pos = 0;
22134
+ for (const line of text.split("\n")) {
22135
+ if (/^\s{0,3}(```|~~~)/.test(line)) {
22136
+ if (open4 < 0) open4 = pos;
22137
+ else {
22138
+ ranges.push([open4, pos + line.length]);
22139
+ open4 = -1;
22140
+ }
22141
+ }
22142
+ pos += line.length + 1;
22143
+ }
22144
+ if (open4 >= 0) ranges.push([open4, text.length]);
22145
+ return (offset) => ranges.some(([start, end]) => offset >= start && offset < end);
22146
+ }
22147
+ function inInlineCodeSpan(text, offset) {
22148
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
22149
+ let ticks = 0;
22150
+ for (let i = lineStart; i < offset; i++) if (text[i] === "`") ticks++;
22151
+ return ticks % 2 === 1;
22152
+ }
22153
+ function redactUnverifiedProseUrls(text, trusted) {
22154
+ if (trusted.size === 0) return text;
22155
+ let removed = 0;
22156
+ const result = text.replace(/https?:\/\/[^\s<>"']+/gi, (token) => {
22157
+ const core = stripTrailingLlmPunctuation(token);
22158
+ const tail = token.slice(core.length);
22159
+ if (!core || trusted.has(normalizeUrl(core))) return token;
22160
+ removed++;
22161
+ return `[link removed: not found in retrieved sources]${tail}`;
22162
+ });
22163
+ if (removed > 0) {
22164
+ logger.warn(`[ResearchSynthesisService] Redacted ${removed} unverified inline URL(s) from synthesis prose (not present in retrieved sources).`);
22165
+ }
22166
+ return result;
22167
+ }
21789
22168
  var ResearchSynthesisService = class _ResearchSynthesisService {
21790
22169
  name = ServiceNames.RESEARCH_SYNTHESIS_SERVICE;
21791
22170
  lifecycle = "uninitialized" /* UNINITIALIZED */;
@@ -21939,35 +22318,42 @@ Research performed using ${modelId}`;
21939
22318
  }
21940
22319
  if (globalCitations.length > 0) {
21941
22320
  const verifiedLinksSection = formatCitedLinks(globalCitations);
22321
+ const trustedUrls = new Set(globalCitations.map((c) => normalizeUrl(c.url)));
21942
22322
  const citedHeaderIdx = lastCitedLinksHeaderIndex(synthesis);
21943
22323
  if (citedHeaderIdx >= 0) {
21944
22324
  logger.debug("[ResearchSynthesisService] Replacing existing CITED LINKS with verified version");
21945
22325
  const ownCitations = parseCitations(synthesis);
21946
22326
  const urlToGlobalId = new Map(globalCitations.map((c) => [normalizeUrl(c.url), c.id]));
21947
22327
  const localToGlobal = /* @__PURE__ */ new Map();
22328
+ const droppedNumbers = /* @__PURE__ */ new Set();
21948
22329
  ownCitations.forEach((cit, index) => {
22330
+ const localKey = cit.number ?? index + 1;
21949
22331
  const globalId = urlToGlobalId.get(normalizeUrl(cit.url));
21950
- if (globalId !== void 0) localToGlobal.set(cit.number ?? index + 1, globalId);
22332
+ if (globalId !== void 0) localToGlobal.set(localKey, globalId);
22333
+ else droppedNumbers.add(localKey);
21951
22334
  });
21952
22335
  let body = synthesis.slice(0, citedHeaderIdx).trimEnd();
21953
- const needsRemap = [...localToGlobal].some(([local, global]) => local !== global);
21954
- if (needsRemap) {
21955
- body = body.replace(/\[(\d+)\]/g, (match, p1) => {
21956
- const globalId = localToGlobal.get(parseInt(p1, 10));
21957
- return globalId !== void 0 ? `[${globalId}]` : match;
21958
- });
22336
+ if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
22337
+ body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
21959
22338
  }
22339
+ body = redactUnverifiedProseUrls(body, trustedUrls);
21960
22340
  return `${body}
21961
22341
 
21962
22342
  ${verifiedLinksSection}`;
21963
22343
  }
21964
22344
  logger.warn("[ResearchSynthesisService] Synthesis missing CITED LINKS - appending verified version");
21965
- return `${synthesis.trim()}
22345
+ const validIds = new Set(globalCitations.map((c) => c.id));
22346
+ let prose = rewriteCitationMarkers(synthesis.trim(), /* @__PURE__ */ new Map(), (n) => !validIds.has(n));
22347
+ prose = redactUnverifiedProseUrls(prose, trustedUrls);
22348
+ return `${prose}
21966
22349
 
21967
22350
  ${verifiedLinksSection}`;
21968
22351
  }
21969
22352
  if (/CITED LINKS/i.test(synthesis)) {
21970
- return synthesis;
22353
+ logger.warn("[ResearchSynthesisService] Preserving an unparseable CITED LINKS block from a run that retrieved no sources \u2014 flagging it as unverified.");
22354
+ return `${synthesis.trimEnd()}
22355
+
22356
+ ${UNVERIFIED_LINKS_NOTE}`;
21971
22357
  }
21972
22358
  logger.warn("[ResearchSynthesisService] No citations and no scrape provenance \u2014 appending explicit no-sources note.");
21973
22359
  return `${synthesis.trim()}
@@ -22669,7 +23055,7 @@ init_logger();
22669
23055
  init_constants();
22670
23056
  import { promises as fs14 } from "node:fs";
22671
23057
  import { homedir as homedir4, tmpdir as tmpdir4, platform as platform3 } from "node:os";
22672
- import { join as join17, resolve as resolve3, normalize, sep as sep2 } from "node:path";
23058
+ import { join as join18, resolve as resolve3, normalize, sep as sep2 } from "node:path";
22673
23059
  import { randomBytes as randomBytes5 } from "node:crypto";
22674
23060
  var PREFERRED_SUBDIRS = [
22675
23061
  "research",
@@ -22740,7 +23126,7 @@ async function findPreferredSubdir(cwd) {
22740
23126
  for (const target of PREFERRED_SUBDIRS) {
22741
23127
  const match = entries.find((e) => e.toLowerCase() === target);
22742
23128
  if (match === void 0) continue;
22743
- const candidate = join17(cwd, match);
23129
+ const candidate = join18(cwd, match);
22744
23130
  try {
22745
23131
  const stat5 = await fs14.stat(candidate);
22746
23132
  if (stat5.isDirectory()) return candidate;
@@ -22785,7 +23171,7 @@ async function exportResearchReport(query, result, _mode, cwd, explicitDir) {
22785
23171
  for (let attempt = 0; attempt < MAX_EXPORT_RETRIES; attempt++) {
22786
23172
  const hash = generateHash();
22787
23173
  const filename = `${baseFilename}-${hash}.md`;
22788
- const filepath = join17(targetDir, filename);
23174
+ const filepath = join18(targetDir, filename);
22789
23175
  try {
22790
23176
  await fs14.writeFile(filepath, result, { flag: "wx" });
22791
23177
  logger.log(`[export] Research report saved to: ${filepath}`);