@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.
@@ -1356,7 +1356,10 @@ function isTransientSocketError(error) {
1356
1356
  err.message.includes("Worker pool is shutting down") || // Poolifier throws this when pool.execute() is called while the pool is being
1357
1357
  // destroyed (e.g., during scheduler restart). Treat as transient — the pool will
1358
1358
  // be ready for new tasks once destruction completes and a new pool is initialized.
1359
- err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool"));
1359
+ 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
1360
+ // leadership. Retrying re-elects and reaches the new leader, so this is
1361
+ // transient in exactly the same sense as a pool drain.
1362
+ err.message.includes("Browser pool leader is draining"));
1360
1363
  }
1361
1364
  function isCloudflareBlockError(error) {
1362
1365
  if (!error || typeof error !== "object") return false;
@@ -1368,6 +1371,11 @@ function isTaskTimeoutError(error) {
1368
1371
  const err = error;
1369
1372
  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"));
1370
1373
  }
1374
+ function isServerDrainingError(error) {
1375
+ if (!error || typeof error !== "object") return false;
1376
+ const err = error;
1377
+ return typeof err.message === "string" && err.message.includes("Browser pool leader is draining");
1378
+ }
1371
1379
  function isPoolShutdownError(error) {
1372
1380
  if (!error || typeof error !== "object") return false;
1373
1381
  const err = error;
@@ -1389,6 +1397,7 @@ var init_browser_error_utils = __esm({
1389
1397
  name: "BrowserPool",
1390
1398
  isTransientError: (error) => {
1391
1399
  if (isPoolShutdownError(error)) return false;
1400
+ if (isServerDrainingError(error)) return false;
1392
1401
  if (isCloudflareBlockError(error)) return false;
1393
1402
  if (isTaskTimeoutError(error)) return false;
1394
1403
  return isTransientSocketError(error);
@@ -1404,6 +1413,7 @@ __export(scraper_utils_exports, {
1404
1413
  createJsMarkdownConverter: () => createJsMarkdownConverter,
1405
1414
  createNativeMarkdownConverter: () => createNativeMarkdownConverter,
1406
1415
  extractDomain: () => extractDomain,
1416
+ formatErrorWithCause: () => formatErrorWithCause,
1407
1417
  getRandomUserAgent: () => getRandomUserAgent,
1408
1418
  getSsrfSafeDispatcher: () => getSsrfSafeDispatcher,
1409
1419
  isBenignScrapeFailure: () => isBenignScrapeFailure,
@@ -1535,6 +1545,34 @@ async function getSsrfSafeDispatcher() {
1535
1545
  }
1536
1546
  return cachedSsrfDispatcher;
1537
1547
  }
1548
+ function formatErrorWithCause(err) {
1549
+ try {
1550
+ const parts = [];
1551
+ let current = err;
1552
+ for (let depth = 0; current !== null && current !== void 0 && depth < 5; depth++) {
1553
+ let segment;
1554
+ if (current instanceof Error) {
1555
+ segment = current.message || current.name;
1556
+ const code = current.code;
1557
+ if (typeof code === "string" && code && !segment.includes(code)) {
1558
+ segment = `${segment} [${code}]`;
1559
+ }
1560
+ } else if (typeof current === "string") {
1561
+ segment = current;
1562
+ } else {
1563
+ segment = String(current);
1564
+ }
1565
+ if (segment && !parts.includes(segment)) parts.push(segment);
1566
+ const next = current.cause;
1567
+ if (next === current) break;
1568
+ current = next;
1569
+ }
1570
+ if (parts.length === 0) return String(err);
1571
+ return parts.length === 1 ? parts[0] : parts.join(" \u2190 ");
1572
+ } catch {
1573
+ return String(err);
1574
+ }
1575
+ }
1538
1576
  function isPrivateIp(ip) {
1539
1577
  const parts = ip.split(".").map(Number);
1540
1578
  if (parts.length !== 4 || parts.some((p) => isNaN(p))) return false;
@@ -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,46 @@ 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
+
22
+ ## [1.0.12] - 2026-08-03
23
+
24
+ ### Fixed
25
+ - **Run-cap / file-lock / leader-election could misreport a live process as dead (Windows/macOS) — the root cause of the flaky `run-semaphore-multiprocess` integration failures on Windows CI.** `ProcessLifecycleService.isProcessAlive(pid, expectedStartTime)` returned `false` ("dead") whenever `process.kill(pid, 0)` confirmed the process was alive but the follow-up **start-time lookup failed**. On Linux that null reliably means the process exited in the microsecond gap between the `signal(0)` and the `/proc/{pid}/stat` read, so `false` was correct — but on Windows/macOS the start time comes from a subprocess (`powershell Get-Process` / `ps`) that can fail or time out under load while the process is still very much alive. Reporting a confirmed-alive PID as dead is catastrophic for *every* caller: a live lock / leader / run-cap owner is reclaimed from under it, so two writers collide (lost update) or the run-cap admits extra concurrent runs. The v1.0.11 Windows CI failure manifested both ways depending on timing — `expected 3 to be 4` (two holders collided on one slot) and `expected +0 to be 3` (a 5th process stole a live holder's slot and was admitted past the cap). The fix re-confirms liveness instead of guessing: when the start-time lookup returns null it issues a second `signal(0)`; if the process is still alive it reports `true`, and only if that probe throws (the process genuinely died in the interim) does the outer handler return `false`. A real death is therefore still detected within one poll tick (~100 ms); the only behavioural change is that a confirmed-alive process is never again reclaimed on a transient start-time lookup failure. PID-reuse protection is unchanged when the start time is resolvable (the `±1s` slack comparison still applies). This is a strict safety improvement across all consumers — file locks, the run-cap semaphore, GPU locks, the embedding leader, browser scheduler and state sessions — none of which ever wanted a live owner treated as dead.
26
+ - **Stack Exchange integration tests no longer fail when the upstream API returns empty results** — the Stack Exchange `search` endpoints intermittently answer `200 OK` (quota consumed, no error) with a zero-item `items:[]` for queries that normally return thousands of results (verified 2026-08-03: `q=javascript` on Stack Overflow returned `[]` from a residential IP). This is an upstream API degradation / contract drift, not a pi-research code fault and not a datacenter-IP block, yet it hard-failed the serial integration suite on all three CI OSes and thereby blocked every release whose code was otherwise identical to a previously-green build. The integration-test environment-skip helper (`isNetworkUnavailable`) now also recognizes a successful-but-empty Stack Exchange response (anchored on the tool-specific `**API Quota:**` footer plus the zero-item body) and skips the affected tests visibly via `ctx.skip()` — the same treatment already given to transport errors and `429`/`5xx` throttling. The anchor is emitted only by the Stack Exchange tool, so it cannot mask a regression in any other tool, and a populated result still exercises the assertions in full.
27
+
28
+ ## [1.0.11] - 2026-08-02
29
+
30
+ ### Added
31
+ - **Cross-process research run-cap** — a machine-wide counting semaphore (`ResearchRunSemaphore`) now gates every `runResearch()` entry on one of N file-lock slots. This is the root-cause fix for the parallel-run instability (60+ error storms and, on constrained hosts, process aborts) that occurred when several research runs saturated the single shared leader-elected browser/embedding pool at once. Holders are reclaimed immediately when their PID exits (PID + startTime guards against PID reuse); *live* holders are never stolen, because a run legitimately holds a slot for minutes. The cap **fails open** on any internal/IO error (a semaphore bug can never break research) and **fails fast** with a distinct `ResearchRunCapacityError` only on genuine capacity exhaustion. (Phase 1 of the multi-run hardening plan.)
32
+ - **Browser leader `drain` / 503 handover** — when the elected leader loses leadership (election loss, idle timeout, or shutdown), it now enters a draining state that answers new requests with an explicit `503 { error: "draining" }` *before* closing the listener, instead of letting connected clients stall on a connect timeout or bare reset. The client classifies this as a transient "re-elect and retry" signal (logged at DEBUG, recorded under its own `draining` errorType for forensics) and it is deliberately excluded from the circuit breaker so a routine leadership handover never reads as an incident.
33
+
34
+ ### Fixed
35
+ - **Citation rewriting regression — `[removed]` tokens, list/code corruption, `[0]` deletion** — the prior placeholder-citation defense rewrote synthesis bodies with a global space collapse that flattened nested lists and 4-space code blocks, deleted bracketed integers *inside* quoted code (`items[12]`), treated `[0]` as a citation, and emitted broken `[removed]` tokens for dropped citations. All replaced by a structure-preserving rewrite: fenced blocks and inline-code spans are skipped entirely, line-leading indentation is preserved, dangling markers are removed cleanly (never as a visible token), and `[N]` outside a plausible citation range (`1..200`) and `[0]` are left untouched.
36
+ - **Provenance gate mass-dropping legitimate citations** — the gate that previously DROPPED citations tagged Scrape/Transcript when their URL was absent from the successful-scrape pool conflated a *blocked* scrape (real URL, content unretrieved) with fabrication: under the common high-scrape-failure rate (bot blocks on GitHub / Microsoft / etc.) it mass-deleted real citations. The gate is removed; every parsed citation is now kept (deduped by URL). Fabrication defense rests on URL dedup plus a new prose-URL sweep instead of punishing failed scrapes.
37
+ - **Fabricated inline URLs in synthesis prose** — inline `https?://` tokens in the report body whose URL was not retrieved this session are now redacted to `[link removed: not found in retrieved sources]` (verified inline references are kept). A matching caveat block is appended when a model-written `CITED LINKS` section survives a run that retrieved nothing, so no report ships fully-sourced-looking with no warning on a zero-retrieval run.
38
+ - **Fetch-layer scrape had no retry** — the fetch layer was the only scrape layer with no transient retry, so a single socket/DNS blip (ECONNRESET, ENOTFOUND, `UND_ERR_*`, a bare `fetch failed`) cost a full 10–30× slower browser render. It now retries once (250 ms, gated on `isTransientError`) before falling back to the stealth browser; SSRF rejections, size caps and 4xx rethrow immediately. Error tracking is recorded exactly once per URL (moved out of the retried layer) rather than once per attempt.
39
+ - **Uninformative `fetch failed` scrape logs** — Node/undici wraps the real transport reason under `error.cause`; logging only `String(err)` recorded the uninformative top-level message and discarded it. A new `formatErrorWithCause` walks the cause chain (bounded, `.code`-folding, defensive) so every fetch-failure line is self-diagnosing; it is now used consistently across the scrape path.
40
+ - **Final synthesis body never logged** — every other layer (researcher prompts/responses, searches, scrapes) was logged at DEBUG, but the synthesis itself — the actual bytes returned to the caller — was the one conspicuous exception, making post-hoc diagnosis of citation/output reports impossible. The full final and fallback synthesis bodies are now logged at DEBUG.
41
+ - **`abortableDelay` could exit the process mid-operation** — the timer was unconditionally `unref`'d, so a foreground backoff that was the sole pending handle let Node drain the loop and silently truncate work. A `keepAlive` option (used by the new foreground fetch retry) holds the loop until the operation completes; background callers are unaffected.
42
+
43
+ ## [1.0.10] - 2026-07-30
44
+
45
+ ### Fixed
46
+ - **pi 0.83.0 compatibility — `ResourceLoader` contract** — pi 0.83.0 extended the `ResourceLoader` contract with `getSystemPromptSource()` and `getAppendSystemPromptSources()` (consumed by interactive mode's startup context listing for file-backed `SYSTEM.md`/`APPEND_SYSTEM.md`). The in-memory sub-session loader now implements both, returning "no backing file" — accurate for prompts passed as text. The extra methods are harmless on pre-0.83 hosts, so the supported range stays `>=0.80.8 <1`. Audited the 0.82.1→0.83.0 dist diffs: the only other contract change (`ExtensionContext.scopedModels`) is provided by pi's own session code, and the mock runtime path has been immune since v1.0.9 via `createExtensionRuntime()`.
47
+
8
48
  ## [1.0.9] - 2026-07-28
9
49
 
10
50
  ### 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.10",
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,
@@ -142,7 +142,10 @@
142
142
  "ua-parser-js": "^1.0.41",
143
143
  "protobufjs": "7.6.5",
144
144
  "vite": "^8.0.16",
145
- "adm-zip": "^0.6.0"
145
+ "adm-zip": "^0.6.0",
146
+ "jsdom": {
147
+ "undici": "^7.29.0"
148
+ }
146
149
  },
147
150
  "devDependencies": {
148
151
  "@eslint/js": "^10.0.1",
@@ -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.10", "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;
@@ -31,6 +31,8 @@ export const ServiceNames = {
31
31
  // Research session services
32
32
  RESEARCH_SESSION_SERVICE: 'research-session-service',
33
33
  RESEARCH_SYNTHESIS_SERVICE: 'research-synthesis-service',
34
+ // Cross-process cap on concurrent research runs (Phase 1 run-cap)
35
+ RESEARCH_RUN_SEMAPHORE: 'research-run-semaphore',
34
36
  } as const;
35
37
 
36
38
  /**
@@ -103,7 +103,25 @@ export class BrowserClient implements IScheduler {
103
103
  const duration = Date.now() - start;
104
104
  try {
105
105
  const parsed = JSON.parse(body);
106
- if (res.statusCode !== 200) {
106
+ if (res.statusCode === 503 && parsed?.error === 'draining') {
107
+ // Planned leadership handover, not a fault. Distinct message so
108
+ // isServerDrainingError can classify it: retry paths treat it as
109
+ // transient and the circuit breaker ignores it. Logged at DEBUG,
110
+ // not ERROR, so a routine re-election doesn't read as an incident.
111
+ // It is still recorded in the error tracker (this file records every
112
+ // non-200 uniformly) under its own `draining` errorType, which is
113
+ // what keeps handovers distinguishable from real 503s in forensics.
114
+ const error = new Error(
115
+ `Browser pool leader is draining (${parsed.reason || 'leadership-lost'}) — re-elect and retry`,
116
+ );
117
+ logger.debug(`[BrowserClient] Leader draining on ${path}; re-election required.`);
118
+ errorTracker.trackError(error, {
119
+ component: 'browser-manager',
120
+ operation,
121
+ errorType: 'draining',
122
+ });
123
+ reject(error);
124
+ } else if (res.statusCode !== 200) {
107
125
  const error = new Error(parsed.error || `HTTP ${res.statusCode}`);
108
126
  errorTracker.trackError(error, {
109
127
  component: 'browser-manager',
@@ -31,7 +31,11 @@ export function isTransientSocketError(error: unknown): boolean {
31
31
  // destroyed (e.g., during scheduler restart). Treat as transient — the pool will
32
32
  // be ready for new tasks once destruction completes and a new pool is initialized.
33
33
  err.message.includes('Cannot execute a task on destroying pool') ||
34
- err.message.includes('destroying pool')
34
+ err.message.includes('destroying pool') ||
35
+ // The leader answered 503 because it is shutting down after losing
36
+ // leadership. Retrying re-elects and reaches the new leader, so this is
37
+ // transient in exactly the same sense as a pool drain.
38
+ err.message.includes('Browser pool leader is draining')
35
39
  );
36
40
  }
37
41
 
@@ -68,11 +72,36 @@ export function isTaskTimeoutError(error: unknown): boolean {
68
72
  );
69
73
  }
70
74
 
75
+ /**
76
+ * Check if an error is a "leader is draining" rejection (HTTP 503 from
77
+ * {@link BrowserServer.beginDrain}).
78
+ *
79
+ * The leader answers 503 while tearing down after losing leadership, so this is
80
+ * an expected transition signal rather than a fault: the correct response is to
81
+ * re-elect and retry against the new leader. It must not count toward the
82
+ * circuit breaker — a leadership handover would otherwise look like a burst of
83
+ * infrastructure failures and open the breaker for every subsequent request.
84
+ */
85
+ export function isServerDrainingError(error: unknown): boolean {
86
+ if (!error || typeof error !== 'object') return false;
87
+ const err = error as NodeError;
88
+ return typeof err.message === 'string' && err.message.includes('Browser pool leader is draining');
89
+ }
90
+
71
91
  /**
72
92
  * Check if an error is specifically a pool-shutdown / pool-drain error.
73
- * For these errors the scheduler itself is fine — only the WorkerPool is temporarily
74
- * draining. The correct recovery is to wait for the drain to finish and retry,
75
- * 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.
76
105
  */
77
106
  export function isPoolShutdownError(error: unknown): boolean {
78
107
  if (!error || typeof error !== 'object') return false;
@@ -107,6 +136,9 @@ const DEFAULT_BREAKER_CONFIG: any = {
107
136
  isTransientError: (error: unknown) => {
108
137
  // Pool-drain transients: pool recovers on its own — don't count.
109
138
  if (isPoolShutdownError(error)) return false;
139
+ // Leadership handover (503 draining) is a planned transition, not a
140
+ // failure — counting it would open the breaker on every re-election.
141
+ if (isServerDrainingError(error)) return false;
110
142
  // Cloudflare blocks are content-layer rejections, not infra failures — don't count.
111
143
  if (isCloudflareBlockError(error)) return false;
112
144
  // Task-level timeouts (slow/unresponsive sites) are expected during parallel
@@ -32,9 +32,57 @@ export function getBrowserServerAuthSecret(): string {
32
32
  export class BrowserServer {
33
33
  private server: http.Server | null = null;
34
34
  private port: number = 0;
35
+ /**
36
+ * Live client sockets. Tracked so teardown can tell whether anyone is still
37
+ * attached (`hasActiveClients`) rather than guessing, and so drain has a
38
+ * meaningful count to report.
39
+ */
40
+ private connections: Set<import('node:net').Socket> = new Set();
41
+ private draining = false;
42
+ /**
43
+ * Why this server stopped accepting work. Reported to the client so a log line
44
+ * says which teardown it hit — a lost election, an idle timeout, or process
45
+ * shutdown all reach the same code path but are very different incidents.
46
+ */
47
+ private drainReason = 'shutting-down';
35
48
 
36
49
  constructor(private options: BrowserServerOptions) {}
37
50
 
51
+ /**
52
+ * Enter draining mode: every *subsequent* request is answered `503` instead of
53
+ * being served or left to hit a closed socket. Requests already past this
54
+ * check run to completion, so drain never truncates in-flight work.
55
+ *
56
+ * This exists for leadership-loss teardown. Without it a client that is
57
+ * mid-conversation with a leader that just lost the election waits out a
58
+ * connect timeout or gets a bare ECONNREFUSED/RST once the listener closes,
59
+ * and only then re-elects. An explicit 503 turns that silent stall into an
60
+ * immediate, unambiguous "this leader is gone — re-elect now".
61
+ *
62
+ * Idempotent: calling it while already draining is a no-op.
63
+ */
64
+ beginDrain(reason = 'shutting-down'): void {
65
+ if (this.draining) return;
66
+ this.draining = true;
67
+ this.drainReason = reason;
68
+ logger.log(`[BrowserServer] Draining (${reason}) — refusing new requests with 503 (${this.connections.size} client connection(s) attached).`);
69
+ }
70
+
71
+ /** True once {@link beginDrain} has been called. */
72
+ isDraining(): boolean {
73
+ return this.draining;
74
+ }
75
+
76
+ /** Whether any client socket is currently attached. */
77
+ hasActiveClients(): boolean {
78
+ return this.connections.size > 0;
79
+ }
80
+
81
+ /** Number of attached client sockets (observability / teardown decisions). */
82
+ getConnectionCount(): number {
83
+ return this.connections.size;
84
+ }
85
+
38
86
  async start(): Promise<number> {
39
87
  return new Promise((resolve, reject) => {
40
88
  this.server = http.createServer(async (req, res) => {
@@ -49,6 +97,20 @@ export class BrowserServer {
49
97
  return;
50
98
  }
51
99
 
100
+ // Checked after auth so an unauthenticated caller cannot probe
101
+ // leadership state, and before routing so no new work is started
102
+ // against a pool that is being torn down. `Connection: close`
103
+ // stops the client reusing this socket for a retry that would
104
+ // only 503 again.
105
+ if (this.draining) {
106
+ res.writeHead(503, {
107
+ 'Content-Type': 'application/json',
108
+ 'Connection': 'close',
109
+ });
110
+ res.end(JSON.stringify({ error: 'draining', reason: this.drainReason }));
111
+ return;
112
+ }
113
+
52
114
  if (req.method !== 'POST') {
53
115
  res.writeHead(405);
54
116
  res.end('Method Not Allowed');
@@ -138,6 +200,13 @@ export class BrowserServer {
138
200
  });
139
201
  });
140
202
 
203
+ this.server.on('connection', (socket) => {
204
+ this.connections.add(socket);
205
+ socket.on('close', () => {
206
+ this.connections.delete(socket);
207
+ });
208
+ });
209
+
141
210
  this.server.listen(0, '127.0.0.1', () => {
142
211
  const addr = this.server?.address();
143
212
  if (addr && typeof addr === 'object') {
@@ -162,6 +231,7 @@ export class BrowserServer {
162
231
  return new Promise((resolve) => {
163
232
  this.server?.close(() => {
164
233
  this.server = null;
234
+ this.connections.clear();
165
235
  resolve();
166
236
  });
167
237
  });
@@ -108,7 +108,7 @@ export class BrowserTaskScheduler implements IScheduler {
108
108
  if (this.consecutiveLeadershipMisses >= this.LEADERSHIP_MISS_THRESHOLD) {
109
109
  metrics.increment('browser_leadership_lost_total', 1);
110
110
  logger.error(`[Scheduler] Leadership threshold exceeded (${this.consecutiveLeadershipMisses} misses), shutting down pool...`);
111
- await this.shutdown();
111
+ await this.shutdown('leadership-lost');
112
112
  return;
113
113
  }
114
114
  } else {
@@ -145,7 +145,7 @@ export class BrowserTaskScheduler implements IScheduler {
145
145
  // getService throwing during a concurrent container disposal) would otherwise surface
146
146
  // as an unhandledRejection from this fire-and-forget timer. Swallow it — an idle
147
147
  // teardown that fails is non-fatal; the next run gets a fresh scheduler regardless.
148
- this.shutdown().catch(err => logger.warn('[Scheduler] Idle-timeout shutdown failed (non-fatal):', err));
148
+ this.shutdown('idle-timeout').catch(err => logger.warn('[Scheduler] Idle-timeout shutdown failed (non-fatal):', err));
149
149
  }, this.IDLE_TIMEOUT_MS);
150
150
  if (this.idleTimer.unref) this.idleTimer.unref();
151
151
  }
@@ -374,10 +374,26 @@ export class BrowserTaskScheduler implements IScheduler {
374
374
  return result;
375
375
  }
376
376
 
377
- async shutdown() {
377
+ /**
378
+ * @param reason why the pool is going away. Reported verbatim to clients in the
379
+ * drain 503, so a lost election, an idle timeout and a process shutdown are
380
+ * distinguishable in a client-side log rather than all reading as one.
381
+ */
382
+ async shutdown(reason = 'shutting-down') {
378
383
  if (this.isShuttingDown) return;
379
384
  this.isShuttingDown = true;
380
385
 
386
+ // Start draining before any of the teardown below. Clearing state and
387
+ // stopping the pool involves awaits, and until the listener actually
388
+ // closes a connected client would otherwise keep issuing requests against
389
+ // a pool that is already going away — the leadership-loss case this
390
+ // guards. Draining first turns those into an immediate 503 so the client
391
+ // re-elects at once instead of waiting for a reset.
392
+ if (this.server?.hasActiveClients()) {
393
+ logger.log(`[Scheduler] Draining ${this.server.getConnectionCount()} client connection(s) before shutdown (${reason}).`);
394
+ }
395
+ this.server?.beginDrain(reason);
396
+
381
397
  if (this.idleTimer) {
382
398
  clearTimeout(this.idleTimer);
383
399
  this.idleTimer = null;
@@ -139,7 +139,7 @@ export async function forceSchedulerRestart(forceClearRemoteState: boolean = fal
139
139
 
140
140
  if (oldScheduler && 'schedulerId' in oldScheduler && oldScheduler.schedulerId) {
141
141
  if (oldScheduler instanceof BrowserTaskScheduler) {
142
- const shutdownPromise: Promise<void> = oldScheduler.shutdown().catch((err: unknown) => {
142
+ const shutdownPromise: Promise<void> = oldScheduler.shutdown('leadership-lost').catch((err: unknown) => {
143
143
  logger.warn('[Scheduler] Error during old scheduler shutdown after restart:', err);
144
144
  });
145
145
  schedulerService.setPendingShutdownPromise(shutdownPromise);