@lincoln504/pi-research 1.0.12 → 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 CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <strong>Limitless research and knowledge store — for pi, agent skill, or SDK.</strong>
6
+ <strong>Local web research and knowledge store</strong>
7
7
  </p>
8
8
 
9
9
  <p align="center">
@@ -24,9 +24,20 @@
24
24
 
25
25
  <a href="https://github.com/Lincoln504/pi-research/actions/workflows/ci.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/Lincoln504/pi-research/ci.yml?style=flat-square&branch=main" /></a> <a href="https://www.npmjs.com/package/@lincoln504/pi-research"><img alt="npm version" src="https://img.shields.io/npm/v/@lincoln504/pi-research.svg?style=flat-square" /></a>
26
26
 
27
- Research is broken into subtasks automatically, with each sub-researcher given a high volume of sources to investigate. Then, evaluator decides whether the answer is complete or an additional round of research is needed. The final result is a synthesized cited report.
27
+ **Free, unlimited web search &amp; scraping — no monthly quota, no subscription.**
28
+ You pay only for the LLM tokens you use, and it builds your own private, searchable
29
+ knowledge store on your machine.
28
30
 
29
- Ask in natural language — the tool understands the depth needed:
31
+ ### How it works
32
+
33
+ A research run is a loop of agent teams: a coordinator plans the work and runs an
34
+ opening search, parallel researcher agents scrape and read sources through a fixed,
35
+ sandboxed toolkit (no filesystem or shell access), and an evaluator decides whether
36
+ to run another round or synthesize — returning one cited Markdown report while its
37
+ sources are saved to the knowledge store in the background.
38
+
39
+ Three depth levels — normal, deep, ultra — set the team size and number of rounds;
40
+ ask in natural language and the tool picks the right one:
30
41
 
31
42
  ![Prompt-driven multi-round research in the pi TUI](docs/media/02-prompt-research.gif)
32
43
 
@@ -39,12 +50,13 @@ Ask in natural language — the tool understands the depth needed:
39
50
  - Using the pi-research agent skill as OpenClaw's web access.
40
51
  - Building agent systems that identify and examine web sources.
41
52
 
42
- ### Advantages
53
+ ### Why pi-research
43
54
 
44
- - Unlimited search and scrape, for free — you only pay for LLM tokens.
45
- - Context-efficient — it returns a synthesized, cited report to the chat instead of dumping raw web content into the conversation.
46
- - Safe by design — web access runs inside a specialized, limited research agent with no filesystem or shell access.
47
- - Search a little or a lot — three depth levels in the pi tool (four via the SDK and standalone CLI, which add the quick depth-0 path). Levels 1 and 2 are recommended for everyday workflow use; level 3 is for larger-scale investigations.
55
+ - **Free, unlimited search &amp; scrape — no quota, no monthly fee.** Most AI-search providers cap free searches per month and then charge; results run on *their* index on *their* servers. pi-research's search and scraping layer (DuckDuckGo via a stealth browser) is free and unlimited — you pay only for the LLM tokens you use, never per search or per month.
56
+ - **You own your index.** Every finding is written into a local [LanceDB](https://lancedb.com) knowledge store on *your* machine — a persistent, searchable index you control and keep, which also seeds future runs so repeat questions get faster, cheaper answers. No borrowed, server-side index you can't keep or query. Findings can also be kept **globally**, scoped to a **single project**, or **not kept at all** — set per directory, changed whenever you like. The optionality is the point: the store is yours to build, partition, or switch off.
57
+ - **Cited reports, not raw dumps.** It returns one synthesized, sourced Markdown report to the chat instead of dumping raw web content into the conversation — context-efficient and easy to verify.
58
+ - **Sandboxed by design.** Web access runs inside a specialized, limited research agent with no filesystem or shell access — safe to point at anything.
59
+ - **Search a little or a lot** — three depth levels in the pi tool (four via the SDK and standalone CLI, which add the quick depth-0 path). Levels 1 and 2 are recommended for everyday workflow use; level 3 is for larger-scale investigations.
48
60
 
49
61
  ### Requirements / limitations
50
62
 
package/dist/cli.mjs CHANGED
@@ -14228,14 +14228,14 @@ init_file_lock_service();
14228
14228
  init_service_registry();
14229
14229
  init_service_names();
14230
14230
  import * as pathmod2 from "node:path";
14231
- var DEFAULT_MAX_CONCURRENT_RUNS = 2;
14232
- var DEFAULT_ACQUIRE_TIMEOUT_MS = 0;
14231
+ var DEFAULT_MAX_CONCURRENT_RUNS = 3;
14232
+ var DEFAULT_ACQUIRE_TIMEOUT_MS = 6e5;
14233
14233
  var POLL_DELAY_MS = 250;
14234
14234
  var ResearchRunCapacityError = class extends Error {
14235
14235
  slots;
14236
14236
  constructor(slots, maxWaitMs) {
14237
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.)`
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
14239
  );
14240
14240
  this.name = "ResearchRunCapacityError";
14241
14241
  this.slots = slots;
@@ -14335,16 +14335,20 @@ var ResearchRunSemaphore = class {
14335
14335
  * @param signal aborts the wait. Without it a run started under a long
14336
14336
  * `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` would ignore Ctrl-C until the deadline
14337
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.
14338
14341
  * @throws {ResearchRunCapacityError} if no slot frees within `maxWaitMs`.
14339
14342
  * @throws {Error} named `AbortError` if `signal` aborts while waiting.
14340
14343
  */
14341
- async acquire(maxWaitMs = this.defaultMaxWaitMs, signal) {
14344
+ async acquire(maxWaitMs = this.defaultMaxWaitMs, signal, onQueued) {
14342
14345
  if (!this._initialized) await this.initialize();
14343
14346
  if (signal?.aborted) {
14344
14347
  metrics.increment("research_run_acquire_total", 1, { status: "aborted" });
14345
14348
  throwIfAborted(signal);
14346
14349
  }
14347
14350
  const deadline = Date.now() + Math.max(0, maxWaitMs);
14351
+ let announcedQueued = false;
14348
14352
  for (; ; ) {
14349
14353
  const start = this.nextStartIndex;
14350
14354
  this.nextStartIndex = (this.nextStartIndex + 1) % this.maxSlots;
@@ -14361,6 +14365,15 @@ var ResearchRunSemaphore = class {
14361
14365
  metrics.increment("research_run_acquire_total", 1, { status: "exhausted" });
14362
14366
  throw new ResearchRunCapacityError(this.maxSlots, maxWaitMs);
14363
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
+ }
14364
14377
  try {
14365
14378
  await sleep(POLL_DELAY_MS, signal);
14366
14379
  } catch (err) {
@@ -14886,6 +14899,17 @@ init_service_registry();
14886
14899
  init_service_interfaces();
14887
14900
  var lastRestartTime = 0;
14888
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
+ }
14889
14913
  async function getScheduler2(config, container = getServiceContainer()) {
14890
14914
  try {
14891
14915
  const factory = await getService(ServiceNames.SCHEDULER_FACTORY, void 0, container);
@@ -14902,7 +14926,7 @@ async function forceSchedulerRestart2(forceClearRemoteState = false, container =
14902
14926
  return await forceSchedulerRestart(forceClearRemoteState, container);
14903
14927
  }
14904
14928
  }
14905
- 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) {
14906
14930
  if (signal?.aborted) throw new Error("Aborted");
14907
14931
  const sessionId = typeof taskOrUrl === "object" ? taskOrUrl.sessionId : void 0;
14908
14932
  const breaker2 = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
@@ -14923,6 +14947,19 @@ async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retrie
14923
14947
  });
14924
14948
  } catch (error) {
14925
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
+ }
14926
14963
  if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
14927
14964
  errorTracker.trackError(error, {
14928
14965
  component: "browser-manager",
@@ -14948,7 +14985,7 @@ async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retrie
14948
14985
  }
14949
14986
  const jitter = 100 + Math.floor(Math.random() * 400);
14950
14987
  await new Promise((resolve8) => setTimeout(resolve8, jitter));
14951
- return runBrowserTask(taskOrUrl, type, config, signal, retries - 1, container);
14988
+ return runBrowserTask(taskOrUrl, type, config, signal, retries - 1, container, handoverRetries);
14952
14989
  }
14953
14990
  throw error;
14954
14991
  }
@@ -14968,7 +15005,7 @@ async function runBrowserHealthCheck(config, retries = 1, signal, container = ge
14968
15005
  });
14969
15006
  logger.warn(`[BrowserManager] Transient socket error during healthcheck (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
14970
15007
  if (isPoolShutdownError(error)) {
14971
- await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
15008
+ await recoverFromLeaderHandover(container);
14972
15009
  } else {
14973
15010
  const now = Date.now();
14974
15011
  if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
@@ -14987,7 +15024,7 @@ async function runBrowserHealthCheck(config, retries = 1, signal, container = ge
14987
15024
  throw error;
14988
15025
  }
14989
15026
  }
14990
- 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) {
14991
15028
  if (signal?.aborted) throw new Error("Aborted");
14992
15029
  const breaker2 = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
14993
15030
  try {
@@ -14998,6 +15035,19 @@ async function runWorkerSearch(query, config, signal, retries = 1, sessionId, co
14998
15035
  });
14999
15036
  } catch (error) {
15000
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
+ }
15001
15051
  if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
15002
15052
  errorTracker.trackError(error, {
15003
15053
  component: "browser-manager",
@@ -15021,7 +15071,7 @@ async function runWorkerSearch(query, config, signal, retries = 1, sessionId, co
15021
15071
  }
15022
15072
  const jitter = 100 + Math.floor(Math.random() * 400);
15023
15073
  await new Promise((resolve8) => setTimeout(resolve8, jitter));
15024
- return runWorkerSearch(query, config, signal, retries - 1, sessionId, container);
15074
+ return runWorkerSearch(query, config, signal, retries - 1, sessionId, container, handoverRetries);
15025
15075
  }
15026
15076
  throw error;
15027
15077
  }
@@ -20813,6 +20863,9 @@ var HeadlessObserver = class {
20813
20863
  onStart(query, complexity) {
20814
20864
  this.emit("start", { query, complexity });
20815
20865
  }
20866
+ onRunQueued(slots, maxWaitMs) {
20867
+ this.emit("run_queued", { slots, maxWaitMs });
20868
+ }
20816
20869
  onPlanningStart(attempt) {
20817
20870
  this.emit("planning_start", { attempt });
20818
20871
  }
@@ -21611,7 +21664,9 @@ var ResearchOrchestrationService = class {
21611
21664
  try {
21612
21665
  const container = tryGetServiceContainerFromCtx(ctx);
21613
21666
  const semaphore = await getService(ServiceNames.RESEARCH_RUN_SEMAPHORE, ctx, container);
21614
- runSlot = await semaphore.acquire(void 0, signal);
21667
+ runSlot = await semaphore.acquire(void 0, signal, (slots, maxWaitMs) => {
21668
+ notifyRunQueued(observer, slots, maxWaitMs);
21669
+ });
21615
21670
  logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
21616
21671
  } catch (err) {
21617
21672
  if (err instanceof ResearchRunCapacityError) throw err;
@@ -21965,6 +22020,15 @@ var ResearchOrchestrationService = class {
21965
22020
  }
21966
22021
  }
21967
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
+ }
21968
22032
 
21969
22033
  // src/orchestration/research-session-service.ts
21970
22034
  init_logger();
@@ -23221,7 +23285,15 @@ var EXIT = {
23221
23285
  /** Missing setup: package, model, or API key not configured. */
23222
23286
  CONFIG: 78,
23223
23287
  /** Something went wrong at runtime (network, provider, internal). */
23224
- 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
23225
23297
  };
23226
23298
  function toStderr(s) {
23227
23299
  process.stderr.write(s);
@@ -23241,6 +23313,12 @@ function makeProgressObserver() {
23241
23313
  toStderr(` \u2022 plan: ${data?.plan?.researchers?.length ?? "?"} researcher(s)
23242
23314
  `);
23243
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;
23244
23322
  case "round_start":
23245
23323
  toStderr(` \u2022 round ${data?.round}
23246
23324
  `);
@@ -23785,17 +23863,25 @@ async function reportError(err, what, json) {
23785
23863
  const msg2 = err instanceof Error ? err.message : String(err);
23786
23864
  const lower = msg2.toLowerCase();
23787
23865
  const isTaggedResearchStop = err?.code === RESEARCH_STOPPED_ERROR_CODE;
23788
- 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"));
23789
23868
  const isRateLimit = lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests");
23790
- const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
23869
+ const exitCode = isCapacity ? EXIT.TEMPFAIL : isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
23791
23870
  if (json) {
23792
23871
  const payload = { ok: false, error: msg2, exitCode };
23872
+ if (isCapacity || isRateLimit) payload["retryable"] = true;
23793
23873
  if (process.env["PI_RESEARCH_DEBUG"] === "true" && err instanceof Error && err.stack) {
23794
23874
  payload["stack"] = err.stack;
23795
23875
  }
23796
23876
  toStdout(pretty(payload));
23797
23877
  return exitCode;
23798
23878
  }
23879
+ if (isCapacity) {
23880
+ toStderr(`
23881
+ Error: pi-research ${what} did not start: ${msg2}
23882
+ `);
23883
+ return exitCode;
23884
+ }
23799
23885
  if (isRateLimit) {
23800
23886
  toStderr(`
23801
23887
  Error: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.
@@ -203,6 +203,30 @@ Cross-session, cross-process state (active sessions, browser status, metrics) li
203
203
  `StateManagerService` (`src/infrastructure/state/`), which serializes concurrent writes
204
204
  with file-based locking (`FileLockService`).
205
205
 
206
+ ### Concurrent runs (the run cap)
207
+
208
+ Every pi-research process on a machine — CLI, agent skill, pi extension, SDK — shares
209
+ one leader-elected browser pool and one embedding model. Letting an unbounded number of
210
+ research runs onto that shared pool does not slow runs down gracefully; it saturates the
211
+ priority queue and degrades *all* of them at once.
212
+
213
+ `ResearchRunSemaphore` (`src/infrastructure/research-run-semaphore.ts`) therefore gates
214
+ every `runResearch()` entry on one of N slots, realized as N well-known lock files in the
215
+ state directory and coordinated by the same `FileLockService`. Because slot ownership is
216
+ recorded as PID + process start time, a slot held by a crashed run is reclaimed
217
+ immediately by the next acquire, while a *live* holder is never stolen — a legitimate run
218
+ holds its slot for minutes, and stealing it would admit the (N+1)th run the cap exists to
219
+ prevent.
220
+
221
+ Runs beyond the cap **queue** rather than fail: the acquire polls until a slot frees,
222
+ announcing itself once through the observer (`onRunQueued`, surfaced by the CLI as
223
+ `• queued: …`) so a waiting run is never mistaken for a hung one. Only if nothing frees
224
+ within the whole queue window does it raise `ResearchRunCapacityError` — a temporary
225
+ condition the CLI reports as exit code `75`, distinct from a crash. The cap fails *open*
226
+ on any internal or IO error, so a fault in the semaphore itself can never prevent research
227
+ from running. Both the cap and the queue window are configurable
228
+ (`PI_RESEARCH_MAX_CONCURRENT_RUNS`, `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS`).
229
+
206
230
  ### TUI
207
231
 
208
232
  The live progress panel uses `@earendil-works/pi-tui`, which handles terminal state
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.0] - 2026-08-04
9
+
10
+ ### Changed
11
+ - **Concurrent runs over the cap now QUEUE instead of failing — the run-cap default is a queue, not a fail-fast.** v1.0.11 added the machine-wide run cap that stopped parallel runs saturating the shared browser/embedding pool, but it shipped with `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` defaulting to `0`. That made contention *fatal*: the cap was enforced by rejecting the run outright, in about two seconds, rather than by serializing it. Verified end-to-end before the change — four simultaneous skill runs against the default cap produced two clean reports and two hard failures, with the two rejected queries simply lost. That is the wrong trade for a semaphore guarding a shared resource: an extra run is a legitimate request to do work, not an error. The default acquire wait is now **600000 ms (10 minutes)**, so over-cap runs wait for a slot and then execute normally; a depth-1 run measured at ~2–3 minutes, so this absorbs a backlog of roughly three further runs per slot. `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS=0` restores the old strict fail-fast. The cap itself is raised from **2 to 3** concurrent runs. The agent skill's recommended command timeouts are raised to match (depth 1 `1500000` ms, depth 2 `1800000`, depth 3 `2400000`), since a queued run legitimately takes longer than an unqueued one and the old budgets no longer bounded the worst case.
12
+ - **A queued run now says so** (`• queued: N concurrent run(s) already active — waiting up to Ns for a slot…`) instead of sitting silent for the whole acquire wait. Silence for up to ten minutes is indistinguishable from a hang, which would have made the queueing change above *look* like a regression. The notice is emitted exactly once per run, via a new optional `ResearchObserver.onRunQueued(slots, maxWaitMs)`; because the queue happens before any orchestrator exists, the notification is dispatched to whichever observer shape the caller supplied — a `ResearchObserver` instance (SDK / pi extension) or the `HeadlessObserverOptions` bag carrying only `onProgress` (CLI / agent skill). A throwing front-end callback can never fail the run it is reporting on.
13
+
14
+ ### Added
15
+ - **Distinct exit code `75` (`EX_TEMPFAIL`) for run-cap exhaustion.** Capacity exhaustion previously exited `70` — the same code as a genuine crash — so a calling agent could not tell "this machine is busy with other healthy runs, try again shortly" from "this run is broken", and the agent skill's own exit-code table told it to treat `70` as a transient error worth one immediate retry (which, under a 0 ms acquire, failed again instantly). It now exits `75`, documented in `SKILL.md` with explicit guidance not to reconfigure anything and not to retry in a tight loop, and `--json` output carries `retryable: true`. The classification is matched on the error name so it survives bundling into `dist/cli.mjs`, and is checked ahead of the credentials heuristic — the capacity message names `PI_RESEARCH_MAX_CONCURRENT_RUNS`, and the substring heuristic would otherwise have been at risk of reading a busy machine as a misconfigured one. Capacity exhaustion is also reported without a stack trace, since a routine "busy" state printed as a stack reads as a crash.
16
+
17
+ ### Fixed
18
+ - **A browser-pool leader exiting could destroy a concurrent run outright — the retry reconnected to the dead leader instead of re-electing.** The browser pool is hosted by one elected leader process shared by every pi-research run on the machine, so when that leader finishes its own run and tears down its workers, every other run with work in flight takes `Worker pool is shutting down` on *all* outstanding tasks at once. The recovery path handled this incorrectly: on a pool-shutdown error it only waited for the pool to go idle and **never dropped the cached scheduler handle**, so the single retry called `getScheduler()`, got the same cached client still pointing at the dead leader's port (visible in logs as a retry "Connecting to existing scheduler" with an *identical* version id), and failed the same way. The generic transient-socket branch did the right thing and forced a re-resolve; the pool-shutdown branch — the case where the leader is provably going away — did not. Reproduced in a real six-way concurrent run: a run acquired its slot in the same second the leader shut down, all 20 queries of its search burst failed, and the run aborted with `Search completely failed: all 20 queries encountered worker errors` and the tell-tale `60 tracked error(s) … "Worker pool is shutting down" ×60` — roughly two seconds *before* a fresh leader finished being elected and served everyone else normally. A handover now drops the stale handle and forces re-resolution (joining the new leader, or winning the election itself), and gets its own retry budget of 3 attempts with a ~1.2 s backoff, sized to a real election rather than to the 100–500 ms jitter meant for a socket blip. The budget is tracked separately from the generic retry so a handover cannot be starved by, or consume, the single transient-socket attempt; genuinely unrecoverable pool loss still gives up rather than retrying forever. This failure mode became far more likely with over-cap queueing, since a queued run is admitted at exactly the moment another run finishes — which is exactly when the leader may be tearing down.
19
+ - **The run-cap's two environment variables were entirely undocumented.** `PI_RESEARCH_MAX_CONCURRENT_RUNS` and `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` shipped in v1.0.11 and appeared in neither `docs/CONFIGURATION.md` nor `.env.example` — the latter described in the docs as "the canonical, exhaustive list" — even though the capacity error message explicitly instructed the user to override the first of them. Both are now documented in both places, and the run cap has an `ARCHITECTURE.md` section covering slot ownership, crash reclamation, queueing and fail-open behaviour.
20
+ - **`docs/SDK.md` recommended a workaround that the run cap had since made unreliable** — it told callers to "use a separate process per concurrent run" with no mention that separate processes are themselves bounded machine-wide, so following the documented advice past the cap hit a hard failure. It now documents the cap, the queue window, `ResearchRunCapacityError`, exit code `75`, and `onRunQueued`.
21
+
8
22
  ## [1.0.12] - 2026-08-03
9
23
 
10
24
  ### Fixed
@@ -76,6 +76,8 @@ Research
76
76
  | `PI_RESEARCH_MAX_FAILED_RESEARCHERS` | `2` | 1–10 | Unique researcher failures that abort the whole run. Raise to let slower, still-in-flight researchers finish before giving up. |
77
77
  | `PI_RESEARCH_WORKER_THREADS` | `4` | 1–10 | Browser worker processes. Higher = more throughput, more CPU/RAM. |
78
78
  | `PI_RESEARCH_WORKER_CONCURRENCY` | `2` | 1–10 | Tasks per worker process. |
79
+ | `PI_RESEARCH_MAX_CONCURRENT_RUNS` | `3` | ≥1 | Machine-wide cap on research runs executing at the same time, across **every** process (CLI, agent skill, pi extension, SDK). Runs beyond the cap queue rather than fail. All concurrent runs share one leader-elected browser/embedding pool, so oversubscribing it degrades every run at once. |
80
+ | `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` | `600000` | ≥0 | How long a run queues for a free slot before failing with "maximum concurrent research runs reached" (CLI exit `75`). `0` = fail immediately instead of queueing. |
79
81
  | `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one (the CLI's per-run `--model` flag also satisfies this). On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis, while the coordinator and evaluator keep using the session model. Accepts `provider/id` or a bare model id. |
80
82
  | `PI_RESEARCH_DISABLED_TOOLS` | _(none)_ | — | Comma-separated research tools to disable for a run (`search`, `scrape`, `security_search`, `stackexchange`, `youtube_transcript`, `grep`). Removed from every researcher's toolset and named in the coordinator/evaluator prompt. |
81
83
  | `PI_RESEARCH_REPORT_EXPORT_ENABLED` (TUI) | `false` | — | Front-ends write a Markdown report to disk and surface its path. |
package/docs/SDK.md CHANGED
@@ -85,6 +85,15 @@ report caused by a sparse topic apart from one where most researchers failed). B
85
85
  > Concurrency: a single initialized SDK instance runs one research call at a time.
86
86
  > Overlapping `runDeepResearch`/`runQuickResearch` calls on the same instance throw
87
87
  > — run them sequentially, or use a separate process per concurrent run.
88
+ >
89
+ > Separate processes are additionally bounded by a **machine-wide run cap**
90
+ > (`PI_RESEARCH_MAX_CONCURRENT_RUNS`, default 3) covering every pi-research process
91
+ > on the host, because they all share one leader-elected browser/embedding pool. A
92
+ > run over the cap queues for up to `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` (default
93
+ > 10 min) and only then rejects with `ResearchRunCapacityError` — a temporary
94
+ > "try again shortly" condition, surfaced by the CLI as exit code `75`. Supply an
95
+ > observer with `onRunQueued(slots, maxWaitMs)` to tell a waiting user the run is
96
+ > queued rather than hung.
88
97
 
89
98
  The SDK does not write report files. Report export is a front-end concern — the pi
90
99
  extension and the CLI / agent skill do it when `PI_RESEARCH_REPORT_EXPORT_ENABLED=true`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.0.12",
3
+ "version": "1.1.0",
4
4
  "description": "A set of web research tools (search, scrape, and others) and research agent system that uses them safely and efficiently.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.0.12", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.1.0", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
@@ -22,8 +22,16 @@ node "<SKILL_DIR>/scripts/run.mjs" status [--json]
22
22
  - **stderr** = progress + errors — relay errors, suppress routine progress.
23
23
  - **Always set a generous command timeout** so a stalled run can't block forever, but
24
24
  leave plenty of headroom — a real run should finish well inside it:
25
- `knowledge` 600000ms · `research --depth 1` 900000ms · `--depth 2` 1200000ms · `--depth 3` 1800000ms.
25
+ `knowledge` 600000ms · `research --depth 1` 1500000ms · `--depth 2` 1800000ms · `--depth 3` 2400000ms.
26
+ These already include headroom for the run to sit **queued** behind other runs on a
27
+ busy machine (see below); don't shorten them on the assumption a slot is free.
26
28
  - **Background**: append `&` and keep the PID to work while a run completes; otherwise it runs foreground (blocks). Timeouts apply either way.
29
+ - **Several at once is fine.** Up to 3 runs execute concurrently machine-wide (shared
30
+ with any other tool or agent on this machine); further runs **queue** for a slot and
31
+ report `• queued: …` on stderr — that is normal, not a stall. Only if nothing frees
32
+ up within the queue window does a run exit `75` (see *Exit codes*). Prefer one
33
+ `research` call with a well-scoped query over splitting one topic into many parallel
34
+ calls — depth 1 already parallelises internally.
27
35
 
28
36
  ## Workflow
29
37
 
@@ -57,6 +65,7 @@ node "<SKILL_DIR>/scripts/run.mjs" status [--json]
57
65
  | `0` | Success | Read stdout; cite sources; relay any "saved to: <path>" line. |
58
66
  | `78` on `knowledge` | Store disabled/unconfigured | Silent; proceed to `research`. |
59
67
  | `78` on `research` | Engine missing or no model/key | **Stop. Relay full stderr** (it shows where to configure). Don't retry until fixed. |
68
+ | `75` | Machine at capacity — other research runs are already using every slot | **Nothing is broken.** Wait for an in-flight run to finish, then retry this query once. Don't reconfigure anything and don't retry in a tight loop. |
60
69
  | `70` | Runtime error | Relay stderr; suggest one retry for transient failures (network, rate limit). |
61
70
  | `64` | Bad arguments | Fix args; don't surface unless the query itself is malformed. |
62
71
 
package/src/cli.ts CHANGED
@@ -76,6 +76,14 @@ export const EXIT = {
76
76
  CONFIG: 78,
77
77
  /** Something went wrong at runtime (network, provider, internal). */
78
78
  SOFTWARE: 70,
79
+ /**
80
+ * Temporary failure — the request was well-formed and the install is healthy,
81
+ * but the machine is at capacity right now (all research run slots held by
82
+ * live runs). Distinct from SOFTWARE so a calling agent can tell "busy, try
83
+ * again shortly" apart from "this run crashed" and back off instead of
84
+ * re-running a broken command.
85
+ */
86
+ TEMPFAIL: 75,
79
87
  } as const;
80
88
 
81
89
  // ---------------------------------------------------------------------------
@@ -108,6 +116,12 @@ function makeProgressObserver(): HeadlessObserverOptions {
108
116
  case 'planning_success':
109
117
  toStderr(` • plan: ${data?.plan?.researchers?.length ?? '?'} researcher(s)\n`);
110
118
  break;
119
+ case 'run_queued':
120
+ toStderr(
121
+ ` • queued: ${data?.slots ?? '?'} concurrent run(s) already active — waiting up to ` +
122
+ `${Math.round((data?.maxWaitMs ?? 0) / 1000)}s for a slot…\n`,
123
+ );
124
+ break;
111
125
  case 'round_start':
112
126
  toStderr(` • round ${data?.round}\n`);
113
127
  break;
@@ -829,9 +843,19 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
829
843
  // research-stop — regardless of real cause — as a config error (exit 78).
830
844
  const isTaggedResearchStop = (err as { code?: unknown } | undefined)?.code === RESEARCH_STOPPED_ERROR_CODE;
831
845
 
846
+ // Run-cap exhaustion is an operational state, not a fault: every slot is held by
847
+ // another *healthy* live run. Matched on the error name rather than `instanceof`
848
+ // so it survives bundling (dist/cli.mjs) and any duplicate module instance.
849
+ // Checked ahead of the substring heuristics below rather than relying on the
850
+ // current wording of the message: capacity text is config-shaped (it names two
851
+ // PI_RESEARCH_* env vars), so an exact classification must not depend on that
852
+ // message never drifting into one of the credential keywords. Misreading a busy
853
+ // machine as a setup problem would send the user to reconfigure a working install.
854
+ const isCapacity = err instanceof Error && err.name === 'ResearchRunCapacityError';
855
+
832
856
  // Auth / model / config-shaped errors → CONFIG exit + config block.
833
857
  const isConfigError =
834
- !isTaggedResearchStop && (
858
+ !isTaggedResearchStop && !isCapacity && (
835
859
  lower.includes('no llm model available') ||
836
860
  lower.includes('not found in pi') ||
837
861
  lower.includes('no api key') ||
@@ -846,12 +870,19 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
846
870
  // classifying that as a setup problem sends the user to reconfigure a
847
871
  // perfectly working key instead of waiting out the limit.
848
872
  const isRateLimit = lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests');
849
- const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
873
+ const exitCode = isCapacity
874
+ ? EXIT.TEMPFAIL
875
+ : isConfigError && !isRateLimit
876
+ ? EXIT.CONFIG
877
+ : EXIT.SOFTWARE;
850
878
 
851
879
  // --json: emit a structured error on stdout so a machine consumer never has to
852
880
  // special-case plain-text stderr. Mirrors the { ok: true, ... } success shape.
853
881
  if (json) {
854
882
  const payload: Record<string, unknown> = { ok: false, error: msg, exitCode };
883
+ // Machine-readable "this will succeed later" flag, so a consumer can back off
884
+ // and retry without string-matching the human message.
885
+ if (isCapacity || isRateLimit) payload['retryable'] = true;
855
886
  if (process.env['PI_RESEARCH_DEBUG'] === 'true' && err instanceof Error && err.stack) {
856
887
  payload['stack'] = err.stack;
857
888
  }
@@ -859,6 +890,13 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
859
890
  return exitCode;
860
891
  }
861
892
 
893
+ // Capacity exhaustion is a healthy-machine "busy" state — report it as such and
894
+ // never with a stack trace, which would read as a crash for a routine condition.
895
+ if (isCapacity) {
896
+ toStderr(`\nError: pi-research ${what} did not start: ${msg}\n`);
897
+ return exitCode;
898
+ }
899
+
862
900
  // Rate limits are operational, not fatal setup problems — checked FIRST.
863
901
  if (isRateLimit) {
864
902
  toStderr(`\nError: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.\n ${msg}\n`);
@@ -18,6 +18,15 @@ export interface HeadlessObserverOptions {
18
18
  export interface ResearchObserver {
19
19
  onStart?(query: string, complexity: number): void;
20
20
 
21
+ /**
22
+ * Fired once when the run cannot start immediately because every concurrent-run
23
+ * slot is held, and it is about to queue. Without this a queued run is silent
24
+ * for as long as the acquire wait allows, which is indistinguishable from a hang.
25
+ * @param slots the machine-wide concurrent-run cap
26
+ * @param maxWaitMs how long the run will queue before giving up
27
+ */
28
+ onRunQueued?(slots: number, maxWaitMs: number): void;
29
+
21
30
  // Coordinator/Planning phase
22
31
  onPlanningStart?(attempt: number): void;
23
32
  onPlanningProgress?(status: string): void;
@@ -90,9 +90,18 @@ export function isServerDrainingError(error: unknown): boolean {
90
90
 
91
91
  /**
92
92
  * Check if an error is specifically a pool-shutdown / pool-drain error.
93
- * For these errors the scheduler itself is fine — only the WorkerPool is temporarily
94
- * draining. The correct recovery is to wait for the drain to finish and retry,
95
- * NOT to call forceSchedulerRestart (which would start another drain cycle).
93
+ *
94
+ * These are never counted against the circuit breaker: the pool recovers on its own.
95
+ *
96
+ * NOTE: this was once documented as "wait for the drain to finish and retry, NOT
97
+ * forceSchedulerRestart". That is right only when the draining pool is *local*. In the
98
+ * common case the pool belongs to another process — the elected leader — which is
99
+ * exiting, so waiting alone leaves the cached client pointed at a dead port and the
100
+ * retry fails identically (this cost whole search bursts; see the v1.1.0 changelog).
101
+ * Recovery must also drop the cached scheduler handle so the next call re-resolves the
102
+ * new leader; `recoverFromLeaderHandover` in task-execution-service.ts does exactly
103
+ * that, using `forceSchedulerRestart(false)` whose liveness probe and in-progress guard
104
+ * prevent the restart churn the old note was warning about.
96
105
  */
97
106
  export function isPoolShutdownError(error: unknown): boolean {
98
107
  if (!error || typeof error !== 'object') return false;
@@ -22,6 +22,46 @@ import type { ISchedulerFactory, IScheduler } from '../../core/scheduler-factory
22
22
  let lastRestartTime = 0;
23
23
  const RESTART_COOLDOWN_MS = 10000;
24
24
 
25
+ /**
26
+ * Extra attempts reserved for a *leader handover* — the elected browser-pool leader
27
+ * exiting while this process still has work in flight ("Worker pool is shutting
28
+ * down" / "draining").
29
+ *
30
+ * This has its own budget because it is not a random socket blip: it is a known,
31
+ * self-healing window that lasts as long as a fresh election takes (measured at
32
+ * ~4 s on a loaded machine), and it hits *every* in-flight task of the affected
33
+ * run at once. With only the single generic retry, a whole 20-query search burst
34
+ * could be wiped out by one leader exiting — the run then failed outright with
35
+ * "all N queries encountered worker errors", seconds before the new leader came up.
36
+ * Spending these attempts is strictly cheaper than losing the run.
37
+ */
38
+ const LEADER_HANDOVER_RETRIES = 3;
39
+ /** Backoff between handover attempts — long enough for an election to complete. */
40
+ const HANDOVER_BACKOFF_MS = 1200;
41
+
42
+ /**
43
+ * Recover from a leader handover before retrying.
44
+ *
45
+ * Critically this DROPS the cached scheduler handle. A pool-shutdown error means
46
+ * the leader we are connected to is going away, so the cached client points at a
47
+ * corpse; simply waiting and retrying reconnects to the same dead port (observed
48
+ * as a retry logging "Connecting to existing scheduler" with the *same* version id
49
+ * and failing identically). Clearing it forces the next `getScheduler()` to
50
+ * re-resolve — joining the new leader, or winning the election itself.
51
+ *
52
+ * `forceSchedulerRestart(false)` keeps its own liveness probe and in-progress
53
+ * guard, so concurrent callers coalesce instead of stampeding.
54
+ */
55
+ async function recoverFromLeaderHandover(container: ServiceContainer): Promise<void> {
56
+ logger.warn('[BrowserManager] Leader handover detected (pool draining) — dropping stale scheduler handle and re-resolving...');
57
+ await forceSchedulerRestart(false, container).catch((err) =>
58
+ logger.debug('[BrowserManager] Scheduler re-resolve during handover failed (non-fatal):', err),
59
+ );
60
+ await waitForBrowserPoolIdle(15000).catch((err) =>
61
+ logger.debug('Wait for browser idle timed out or failed:', err),
62
+ );
63
+ }
64
+
25
65
  /**
26
66
  * Get the scheduler from the factory, optionally using a specific container.
27
67
  */
@@ -56,7 +96,8 @@ export async function runBrowserTask<T>(
56
96
  config?: Config,
57
97
  signal?: AbortSignal,
58
98
  retries = 1,
59
- container: ServiceContainer = getServiceContainer()
99
+ container: ServiceContainer = getServiceContainer(),
100
+ handoverRetries = LEADER_HANDOVER_RETRIES,
60
101
  ): Promise<T> {
61
102
  if (signal?.aborted) throw new Error('Aborted');
62
103
 
@@ -84,6 +125,23 @@ export async function runBrowserTask<T>(
84
125
  } catch (error: unknown) {
85
126
  if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
86
127
 
128
+ // A leader handover gets its own budget: it is a recoverable, seconds-long
129
+ // window that strikes every in-flight task at once, so it must not be
130
+ // charged against (or limited by) the single generic transient retry.
131
+ const isHandover = isPoolShutdownError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error);
132
+ if (isHandover && handoverRetries > 0) {
133
+ errorTracker.trackError(error, {
134
+ component: 'browser-manager',
135
+ operation: type,
136
+ taskType: type,
137
+ errorType: 'leader_handover',
138
+ });
139
+ logger.warn(`[BrowserManager] Leader handover during ${type} task (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
140
+ await recoverFromLeaderHandover(container);
141
+ await new Promise((resolve) => setTimeout(resolve, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
142
+ return runBrowserTask<T>(taskOrUrl, type, config, signal, retries, container, handoverRetries - 1);
143
+ }
144
+
87
145
  if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
88
146
  errorTracker.trackError(error, {
89
147
  component: 'browser-manager',
@@ -92,9 +150,9 @@ export async function runBrowserTask<T>(
92
150
  errorType: 'transient_socket_error',
93
151
  });
94
152
  logger.warn(`[BrowserManager] Transient socket error during ${type} task (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
95
-
153
+
96
154
  if (isPoolShutdownError(error)) {
97
- // Pool is temporarily draining — wait for the drain to finish.
155
+ // Handover budget already spent — fall back to a plain drain wait.
98
156
  logger.warn(`[BrowserManager] Pool is draining — waiting for pool idle before retry...`);
99
157
  await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
100
158
  } else {
@@ -120,7 +178,7 @@ export async function runBrowserTask<T>(
120
178
  // Retry with jitter (100-500ms) to prevent thundering herd
121
179
  const jitter = 100 + Math.floor(Math.random() * 400);
122
180
  await new Promise(resolve => setTimeout(resolve, jitter));
123
- return runBrowserTask<T>(taskOrUrl, type, config, signal, retries - 1, container);
181
+ return runBrowserTask<T>(taskOrUrl, type, config, signal, retries - 1, container, handoverRetries);
124
182
  }
125
183
  throw error;
126
184
  }
@@ -143,9 +201,12 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
143
201
  errorType: 'transient_socket_error',
144
202
  });
145
203
  logger.warn(`[BrowserManager] Transient socket error during healthcheck (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
146
-
204
+
147
205
  if (isPoolShutdownError(error)) {
148
- await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
206
+ // Same stale-handle trap as the task paths: waiting alone leaves the
207
+ // cached client aimed at the departing leader, so the retry re-probes
208
+ // a corpse and reports the pool unhealthy during a routine handover.
209
+ await recoverFromLeaderHandover(container);
149
210
  } else {
150
211
  // See runBrowserTask: don't force-clear remote leader state on a bare
151
212
  // transient socket error — let the liveness probe inside
@@ -172,7 +233,7 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
172
233
  /**
173
234
  * Run a worker search query with retry logic.
174
235
  */
175
- export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer()): Promise<SearchResult[]> {
236
+ export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES): Promise<SearchResult[]> {
176
237
  if (signal?.aborted) throw new Error('Aborted');
177
238
 
178
239
  const breaker = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
@@ -186,6 +247,22 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
186
247
  } catch (error: unknown) {
187
248
  if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
188
249
 
250
+ // See runBrowserTask: a leader handover wipes out an entire search burst at
251
+ // once, so it gets its own attempts rather than the single generic retry.
252
+ const isHandover = isPoolShutdownError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error);
253
+ if (isHandover && handoverRetries > 0) {
254
+ errorTracker.trackError(error, {
255
+ component: 'browser-manager',
256
+ operation: 'search',
257
+ query,
258
+ errorType: 'leader_handover',
259
+ });
260
+ logger.warn(`[BrowserManager] Leader handover during search (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
261
+ await recoverFromLeaderHandover(container);
262
+ await new Promise((resolve) => setTimeout(resolve, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
263
+ return runWorkerSearch(query, config, signal, retries, sessionId, container, handoverRetries - 1);
264
+ }
265
+
189
266
  if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
190
267
  errorTracker.trackError(error, {
191
268
  component: 'browser-manager',
@@ -194,7 +271,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
194
271
  errorType: 'transient_socket_error',
195
272
  });
196
273
  logger.warn(`[BrowserManager] Transient socket error during search (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
197
-
274
+
198
275
  if (isPoolShutdownError(error)) {
199
276
  await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
200
277
  } else {
@@ -214,7 +291,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
214
291
 
215
292
  const jitter = 100 + Math.floor(Math.random() * 400);
216
293
  await new Promise(resolve => setTimeout(resolve, jitter));
217
- return runWorkerSearch(query, config, signal, retries - 1, sessionId, container);
294
+ return runWorkerSearch(query, config, signal, retries - 1, sessionId, container, handoverRetries);
218
295
  }
219
296
  throw error;
220
297
  }
@@ -21,9 +21,10 @@
21
21
  * configured as a *try-take* (`lockTimeout: 0`): it either wins the slot (free,
22
22
  * or reclaimed from a *dead* owner) immediately or throws as soon as it sees a
23
23
  * *live* owner. If every slot is held by a live process, the loop waits
24
- * `pollDelayMs` and retries until `maxWaitMs` elapses, then rejects with
25
- * {@link ResearchRunCapacityError} (fail-fast with a clear message rather than
26
- * a 60-error degraded run).
24
+ * `pollDelayMs` and retries until `maxWaitMs` elapses (by default
25
+ * {@link DEFAULT_ACQUIRE_TIMEOUT_MS} — extra runs **queue** behind the in-flight
26
+ * ones instead of failing), then rejects with {@link ResearchRunCapacityError}
27
+ * rather than degrading into a 60-error run.
27
28
  * - **Release** unlinks the slot file via the captured slot service.
28
29
  * - **Crash recovery** is automatic and PID-reuse-safe: a slot held by a process
29
30
  * that has died (killed, crashed, SIGKILLed by earlyoom) is reclaimed
@@ -69,9 +70,21 @@ import type { IService } from '../core/service-registry.ts';
69
70
  import type { IProcessLifecycle } from '../core/interfaces/process-interfaces.ts';
70
71
 
71
72
  /** Default maximum number of concurrent research runs across all processes. */
72
- export const DEFAULT_MAX_CONCURRENT_RUNS = 2;
73
- /** Default per-acquire wait before failing fast when all slots are busy (0 = fail-fast). */
74
- export const DEFAULT_ACQUIRE_TIMEOUT_MS = 0;
73
+ export const DEFAULT_MAX_CONCURRENT_RUNS = 3;
74
+ /**
75
+ * Default per-acquire wait before giving up when all slots are held by live runs.
76
+ *
77
+ * This is deliberately a *queue*, not a fail-fast. The cap exists to stop N runs
78
+ * saturating the shared browser/embedding pool — but an extra run is a legitimate
79
+ * request to do work, not an error, so the correct response to contention is to
80
+ * serialize it behind the in-flight runs rather than to fail it. With a depth-1 run
81
+ * measured at ~2-3 minutes, 10 minutes absorbs a backlog of roughly three further
82
+ * runs per slot while still resolving well inside the agent-skill's documented
83
+ * command timeouts (so a queued run never looks like a hang to the caller).
84
+ *
85
+ * Set `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS=0` to restore strict fail-fast.
86
+ */
87
+ export const DEFAULT_ACQUIRE_TIMEOUT_MS = 600_000;
75
88
  /** Poll interval between full slot sweeps while waiting for a slot. */
76
89
  const POLL_DELAY_MS = 250;
77
90
 
@@ -84,9 +97,10 @@ export class ResearchRunCapacityError extends Error {
84
97
  readonly slots: number;
85
98
  constructor(slots: number, maxWaitMs: number) {
86
99
  super(
87
- `Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` after waiting ${maxWaitMs}ms` : ''}; ` +
88
- `wait for an in-progress run to finish and retry. ` +
89
- `(Override with PI_RESEARCH_MAX_CONCURRENT_RUNS.)`,
100
+ `Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` and still busy after queueing for ${Math.round(maxWaitMs / 1000)}s` : ''}; ` +
101
+ `another run is still in progress. This is temporary — retry shortly. ` +
102
+ `(Raise the cap with PI_RESEARCH_MAX_CONCURRENT_RUNS, or the queue wait with ` +
103
+ `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS.)`,
90
104
  );
91
105
  this.name = 'ResearchRunCapacityError';
92
106
  this.slots = slots;
@@ -209,10 +223,17 @@ export class ResearchRunSemaphore implements IService {
209
223
  * @param signal aborts the wait. Without it a run started under a long
210
224
  * `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` would ignore Ctrl-C until the deadline
211
225
  * passed, because the wait happens before any orchestrator exists to cancel.
226
+ * @param onQueued invoked at most once, when the first full sweep finds every
227
+ * slot busy and the call is about to wait. Lets a front-end tell the user the
228
+ * run is queued rather than hung. Never allowed to break the acquire.
212
229
  * @throws {ResearchRunCapacityError} if no slot frees within `maxWaitMs`.
213
230
  * @throws {Error} named `AbortError` if `signal` aborts while waiting.
214
231
  */
215
- async acquire(maxWaitMs: number = this.defaultMaxWaitMs, signal?: AbortSignal): Promise<RunSlotAcquisition> {
232
+ async acquire(
233
+ maxWaitMs: number = this.defaultMaxWaitMs,
234
+ signal?: AbortSignal,
235
+ onQueued?: (slots: number, maxWaitMs: number) => void,
236
+ ): Promise<RunSlotAcquisition> {
216
237
  if (!this._initialized) await this.initialize();
217
238
  if (signal?.aborted) {
218
239
  metrics.increment('research_run_acquire_total', 1, { status: 'aborted' });
@@ -220,6 +241,7 @@ export class ResearchRunSemaphore implements IService {
220
241
  }
221
242
 
222
243
  const deadline = Date.now() + Math.max(0, maxWaitMs);
244
+ let announcedQueued = false;
223
245
  for (;;) {
224
246
  // Round-robin from a rotating start so contending processes don't synchronize on slot 0.
225
247
  const start = this.nextStartIndex;
@@ -238,6 +260,17 @@ export class ResearchRunSemaphore implements IService {
238
260
  metrics.increment('research_run_acquire_total', 1, { status: 'exhausted' });
239
261
  throw new ResearchRunCapacityError(this.maxSlots, maxWaitMs);
240
262
  }
263
+ // Announce the queue exactly once, on the first sweep that found nothing free.
264
+ // A throwing front-end callback must never fail the acquire.
265
+ if (!announcedQueued) {
266
+ announcedQueued = true;
267
+ logger.info(`[ResearchRunSemaphore] All ${this.maxSlots} run slot(s) busy — queueing for up to ${maxWaitMs}ms.`);
268
+ try {
269
+ onQueued?.(this.maxSlots, maxWaitMs);
270
+ } catch (err) {
271
+ logger.debug(`[ResearchRunSemaphore] onQueued callback threw: ${err instanceof Error ? err.message : String(err)}`);
272
+ }
273
+ }
241
274
  // Count the aborted outcome too, so acquired + exhausted + aborted accounts for
242
275
  // every acquire attempt. A cancel that vanished from the counter would make the
243
276
  // capacity metrics quietly under-report demand.
@@ -95,6 +95,10 @@ export class HeadlessObserver implements ResearchObserver {
95
95
  this.emit('start', { query, complexity });
96
96
  }
97
97
 
98
+ onRunQueued(slots: number, maxWaitMs: number): void {
99
+ this.emit('run_queued', { slots, maxWaitMs });
100
+ }
101
+
98
102
  onPlanningStart(attempt: number): void {
99
103
  this.emit('planning_start', { attempt });
100
104
  }
@@ -108,17 +108,27 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
108
108
 
109
109
  const researchStart = Date.now();
110
110
 
111
- // Cross-process run-cap (Phase 1): acquire one of N slots so concurrent
112
- // research runs can't saturate the shared leader-elected browser/embedding
113
- // pool and degrade into ~60-error failures. Fail-OPEN on internal/IO errors
114
- // (a semaphore bug must never break research); only a genuine
115
- // ResearchRunCapacityError (all slots held by live processes) propagates,
116
- // failing the run fast with a clear message. The slot is released in finally.
111
+ // Cross-process run-cap: acquire one of N slots so concurrent research runs
112
+ // can't saturate the shared leader-elected browser/embedding pool and degrade
113
+ // into ~60-error failures. Over-cap runs QUEUE for a slot (announced once via
114
+ // the observer) rather than failing, because an extra run is a legitimate
115
+ // request to do work, not an error. Fail-OPEN on internal/IO errors (a
116
+ // semaphore bug must never break research); only a genuine
117
+ // ResearchRunCapacityError — nothing freed within the whole queue window —
118
+ // propagates. The slot is released in finally.
117
119
  let runSlot: { slotIndex: number; release(): Promise<void> } | null = null;
118
120
  try {
119
121
  const container = tryGetServiceContainerFromCtx(ctx);
120
122
  const semaphore = await getService<ResearchRunSemaphore>(ServiceNames.RESEARCH_RUN_SEMAPHORE, ctx, container);
121
- runSlot = await semaphore.acquire(undefined, signal);
123
+ runSlot = await semaphore.acquire(undefined, signal, (slots, maxWaitMs) => {
124
+ // The queue notice fires BEFORE any orchestrator exists, so `observer` is
125
+ // still exactly what the caller passed: either a ResearchObserver, or the
126
+ // HeadlessObserverOptions bag (only `onProgress`) that the CLI supplies and
127
+ // the orchestrator would normally wrap later. Dispatch to whichever shape
128
+ // is present — handling only the former silently drops the notice on the
129
+ // CLI, which is the very front-end that most needs it.
130
+ notifyRunQueued(observer, slots, maxWaitMs);
131
+ });
122
132
  logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
123
133
  } catch (err) {
124
134
  if (err instanceof ResearchRunCapacityError) throw err; // capacity exhausted → fail fast
@@ -583,3 +593,28 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
583
593
  }
584
594
  }
585
595
  }
596
+
597
+ /**
598
+ * Deliver the "your run is queued behind other runs" notice to whichever observer
599
+ * shape the caller supplied.
600
+ *
601
+ * This fires before the orchestrator normalizes the observer, so both forms are
602
+ * live here: a {@link ResearchObserver} instance (SDK / pi extension) or the
603
+ * {@link HeadlessObserverOptions} bag carrying only `onProgress` (the CLI and
604
+ * agent skill). A queued run is otherwise completely silent for the whole acquire
605
+ * wait, which is indistinguishable from a hang.
606
+ *
607
+ * Never throws: a front-end callback must not be able to fail the run it is
608
+ * merely reporting on.
609
+ */
610
+ function notifyRunQueued(observer: unknown, slots: number, maxWaitMs: number): void {
611
+ try {
612
+ const obs = observer as
613
+ | { onRunQueued?: (s: number, w: number) => void; onProgress?: (e: string, d?: unknown) => void }
614
+ | undefined;
615
+ if (typeof obs?.onRunQueued === 'function') obs.onRunQueued(slots, maxWaitMs);
616
+ else if (typeof obs?.onProgress === 'function') obs.onProgress('run_queued', { slots, maxWaitMs });
617
+ } catch (err) {
618
+ logger.debug(`[ResearchOrchestrationService] run_queued notification threw: ${err instanceof Error ? err.message : String(err)}`);
619
+ }
620
+ }