@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/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,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 waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
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 scrapeWithFetch(url, signal);
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: ${String(e1)}`);
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: ${String(e2)}`);
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: String(e2), markdown: "" };
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: String(e1), markdown: "" };
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(cit.number ?? index + 1, globalId);
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
- 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
- });
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
- return `${synthesis.trim()}
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
- return synthesis;
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 join17, resolve as resolve3, normalize, sep as sep2 } from "node:path";
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 = join17(cwd, match);
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 = join17(targetDir, filename);
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 isConfigError = !isTaggedResearchStop && (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"));
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.