@lincoln504/pi-research 1.0.12 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -8
- package/dist/cli.mjs +210 -67
- package/dist/thread-worker.mjs +11 -1
- package/docs/ARCHITECTURE.md +24 -0
- package/docs/CHANGELOG.md +40 -0
- package/docs/CONFIGURATION.md +3 -1
- package/docs/KNOWLEDGE-STORE.md +1 -1
- package/docs/SDK.md +17 -5
- package/package.json +1 -1
- package/scripts/build.cjs +23 -5
- package/skills/pi-research/SKILL.md +13 -4
- package/skills/pi-research/references/configuration.md +1 -1
- package/src/cli.ts +54 -4
- package/src/core/interfaces/observer-interfaces.ts +9 -0
- package/src/core/llm/model-registry-factory.ts +40 -3
- package/src/core/llm/research-model-resolver.ts +2 -2
- package/src/infrastructure/browser/browser-error-utils.ts +19 -4
- package/src/infrastructure/browser/task-execution-service.ts +86 -9
- package/src/infrastructure/browser/thread-worker.mjs +11 -1
- package/src/infrastructure/research-run-semaphore.ts +43 -10
- package/src/orchestration/headless-observer.ts +4 -0
- package/src/orchestration/research-orchestration-service.ts +42 -7
- package/src/orchestration/research-synthesis-service.ts +1 -81
- package/src/tools/research-knowledge-search.ts +8 -2
- package/src/utils/circuit-breaker.ts +20 -0
- package/src/utils/citation-utils.ts +103 -7
- package/src/utils/llm-usage.ts +37 -0
- package/src/utils/metrics-summary.ts +25 -2
- package/src/utils/research-export.ts +9 -2
- package/src/web-research/retry-utils.ts +13 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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.2] - 2026-08-04
|
|
9
|
+
|
|
10
|
+
> Supersedes `v1.1.1`, which was tagged but never published: its release run was
|
|
11
|
+
> correctly blocked by a red CI, and the tag is retained only as a marker of that
|
|
12
|
+
> attempt. Everything below is the full set of changes since 1.1.0.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **The multi-process run-cap test counted cumulative slot acquisitions as concurrent ones.** The "thundering herd" test asserts the *concurrent* cap but tallied *cumulative* `acquired` events, and let its winners hold for a finite 4 s. On a slow runner the six contenders start staggered, so a late one can attempt after an early winner's hold has already elapsed, legitimately win the freed slot, and push the cumulative tally past the cap while at most `MAX` ever held at once — a false failure that blocked this release on Windows CI. Confirmed by direct reproduction: with the stagger forced, four acquisitions were recorded against a maximum of two concurrent holders, the late pair's acquire timestamps falling strictly after the early pair's releases on reused slots. Winners now hold until killed, so there is no free slot to win and the count measures genuine simultaneity — a strictly stronger assertion. Test-only; the semaphore itself was never at fault and is unchanged. (The sibling "distinct slot assignments" test was fixed the same way in 1.1.0; that change cited this test's own stated principle but left this test on the racy pattern.)
|
|
16
|
+
- **Citation markers could be silently retargeted at the wrong source.** When merging researcher reports, `normalizeCitations` renumbers each report's local `[N]` markers to global ids — but a marker it could not map was left *verbatim* instead of being removed. Global ids are assigned densely from 1, so a surviving `[1]` no longer meant "this report's first source": it resolved against the renumbered global list and pointed at an unrelated document. Markers go unmapped whenever `parseCitations` legitimately drops an entry, e.g. a URL whose hostname has no dot (`localhost`, a bare internal host, a malformed line). This was not cosmetic — the normalized body is fed straight into the evaluator/synthesis prompt, which is explicitly told the numbering is already global, so the model faithfully reproduced the wrong attribution, and `ensureCitedLinks` then regenerated a `CITED LINKS` list perfectly consistent with it. A correct structure-preserving rewriter already existed on the synthesis path (added in 1.0.11); it is now shared, so dangling markers are removed cleanly while fenced blocks and inline-code spans — a quoted `items[12]` — stay untouched. The prior test could never have caught this: its assertion was structurally guaranteed by the CITED-LINKS slice regardless of marker handling.
|
|
17
|
+
- **A retry could silently abandon the operation instead of retrying it.** `retryWithBackoff` awaited its inter-attempt sleep on an `unref`'d timer. The sleep *is* the operation — the retry has already been decided and the next attempt must run — and `abortableDelay`'s own docstring says exactly that (`web-scraper.ts` passes `keepAlive` with a comment explaining why); the backoff simply omitted it. The failure compounds itself: the transient network errors that trigger a retry are precisely the ones that leave no referenced socket handle, so the backoff is frequently the *only* pending handle, and Node drains the event loop and exits mid-retry. The next attempt never runs and nothing is reported — in the CLI neither branch of the top-level handler fires, so the user gets a bare exit with no report and no error. Affected every `retryWithBackoff` caller (LLM planning, all four security clients, YouTube transcripts) plus both knowledge-search retry loops.
|
|
18
|
+
- **The circuit breaker could pin permanently in `HALF_OPEN`, collapsing parallel search bursts.** `onFailure` is the only path back to `OPEN` and is skipped for errors the breaker does not count, while `transitionTo('HALF_OPEN')` never re-arms `nextAttemptTime` — so a trial probe failing with a non-counted error left the breaker stuck with no time-based escape, fast-failing every caller beyond `halfOpenMaxCalls` (default 1) indefinitely. This was not a corner case for the browser pool: `DEFAULT_BREAKER_CONFIG.isTransientError` deliberately excludes pool-shutdown, draining, Cloudflare and task-timeout errors — exactly what a recovering or blocked pool returns — so the probe most likely to run was also the one most likely to pin it. A non-counted probe failure is now treated as *inconclusive* and re-opens with a fresh reset window.
|
|
19
|
+
- **`npm install <dir>`, `file:`, `npm link` and `pi install <dir>` aborted during install.** The published package ships the built `skills/pi-research/scripts/run.mjs` and deliberately excludes its `run.ts` source, yet also ships `prepare.cjs` + `build.cjs` — and `prepare` runs `build.cjs all`, whose `skill` target hard-requires `run.ts` as its esbuild entry point. Running the shipped build against the shipped tree failed with `Could not resolve run.ts` and exit 1. Registry `.tgz` and `npx` installs were unaffected only because `prepare` does not run there. The target now no-ops when `run.ts` is absent and `run.mjs` is already built, and still errors when neither exists.
|
|
20
|
+
- **A pool-reset error told callers to retry, but nothing recognised it.** `worker-pool-manager` throws `Worker pool is being reset, please retry` while auto-recovery swaps the pool, and no error classifier matched that string — so the first attempt rethrew instead of retrying. Auto-recovery fires only after 3 consecutive worker failures, i.e. exactly when a burst of tasks is in flight and would all fail together: the same user-visible shape as the leader-handover loss fixed in 1.1.0, entering through a different door. Now classified as a transient pool-unavailable condition.
|
|
21
|
+
- **Cancelling a run at the wrong moment could kill the process.** `withTimeout`'s early-abort path returned before attaching to the caller-supplied promise, orphaning that promise's eventual rejection. Node 22+ defaults to `--unhandled-rejections=throw`, and the main process registers no `unhandledRejection` handler, so an Esc timed between two awaits (reachable via the YouTube transcript client, which threads the same signal into both) crashed the host instead of cancelling cleanly. The in-flight rejection is now adopted before bailing out.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- **Model-resolution errors now lead with the credential path instead of pointing at `models.json`.** For a provider pi ships built in (openrouter, anthropic, openai, …), the fix for an unresolved model is a credential — after which pi fetches that provider's catalog, with real pricing and context limits, on its own. Both error messages instead named `~/.pi/agent/models.json` as the remedy, which is wrong for exactly that case and demonstrably led users to hand-write `models[]` entries. Because such an entry **replaces** pi's catalog entry wholesale rather than merging into it (`modelOverrides` is the merging mechanism), doing so silently zeroed out the model's pricing and capped its context and output windows. The messages now name `/login`, `auth.json` and the provider env var first, scope `models.json` to genuinely custom or self-hosted providers, and warn about the replace-vs-merge distinction.
|
|
25
|
+
- **A cost of exactly `$0.00` is now shown rather than omitted.** Cost is computed locally as tokens × the model's price table, never read off the wire, so a model whose table is all zeros bills tokens and reports zero indefinitely. Every display site gated on `cost > 0`, which made a misconfigured price table indistinguishable from "nothing reported yet" — and on the CLI progress line a falsy cost suppressed the **token count** as well, so the run appeared to consume nothing. Zero is also the honest figure for flat-rate plans and local models. `recordLlmUsage` additionally warns once per model when tokens are billed against an all-zero table, naming the usual cause.
|
|
26
|
+
- **Session Metrics now actually reports cost.** The view has always advertised "API cost estimates", but `SessionStats` carried no cost field to show; it now aggregates and displays one.
|
|
27
|
+
|
|
28
|
+
### Documentation
|
|
29
|
+
- **`README.md` claimed the research agent has "no filesystem or shell access" and is "safe to point at anything".** The shell half is accurate; the filesystem half was not — `read` and `grep` are registered unconditionally and the researcher exclusion list (`bash`, `write`, `edit`, `repl`, `git`, `terminal`) never excluded them. `ARCHITECTURE.md` documented this correctly, so the README was the outlier — and it is the sentence a user relies on when deciding whether to run this against a sensitive directory. Corrected to what the code actually enforces: no shell, no writes, but reads and searches within the working directory.
|
|
30
|
+
- **The `knowledge-config` example was an unquoted shell pipe.** `... knowledge-config set none|project|global` sat inside a block `SKILL.md` explicitly instructs the agent to run on the user's behalf; pasted literally, bash executes `set none` and pipes into two "command not found" lines — so a user asking to *enable* the store would have it silently *disabled*. Now a `<none|project|global>` placeholder, fixed in all four copies.
|
|
31
|
+
- **Exit-code documentation gaps.** `75` (at capacity) — 1.1.0's headline exit code — was missing from the CLI's own `--help`. And `78` from `knowledge` covers two very different conditions (store deliberately disabled vs. missing credentials) while `SKILL.md` documented only the first, telling agents to stay silent and continue on what is actually a hard configuration failure. Both surfaces now distinguish them.
|
|
32
|
+
- **`docs/SDK.md` recommended a loader recipe that cannot work.** It suggested `node --experimental-transform-types your-script.ts` for consuming the TypeScript entrypoint; Node refuses to strip *or* transform TypeScript under `node_modules`, failing with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. A loader (`tsx`/`ts-node`) or the pi host is mandatory, and the limitation is now stated.
|
|
33
|
+
|
|
34
|
+
## [1.1.0] - 2026-08-04
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- **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.
|
|
38
|
+
- **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.
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
- **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.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
- **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.
|
|
45
|
+
- **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.
|
|
46
|
+
- **`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`.
|
|
47
|
+
|
|
8
48
|
## [1.0.12] - 2026-08-03
|
|
9
49
|
|
|
10
50
|
### Fixed
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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. |
|
|
@@ -216,7 +218,7 @@ set the knowledge-store mode per directory directly:
|
|
|
216
218
|
|
|
217
219
|
```sh
|
|
218
220
|
pi-research knowledge-config # show the mode here and where it comes from
|
|
219
|
-
pi-research knowledge-config set none|project|global
|
|
221
|
+
pi-research knowledge-config set <none|project|global> # pick ONE value
|
|
220
222
|
```
|
|
221
223
|
|
|
222
224
|
Under the agent skill you don't have to run this yourself — you ask the agent (e.g. "disable the
|
package/docs/KNOWLEDGE-STORE.md
CHANGED
|
@@ -44,7 +44,7 @@ a project-scoped setting you can change per directory:
|
|
|
44
44
|
| `none` | The store is disabled — nothing is read or written, the `research_knowledge_search` tool is not advertised to the agent, and `/knowledge-store` is unavailable. Re-enabling needs no restart (see [PI-EXTENSION.md](PI-EXTENSION.md) for the registration mechanics). |
|
|
45
45
|
|
|
46
46
|
Change the mode for the current directory with the `/research-config` TUI (Knowledge Mode)
|
|
47
|
-
in the pi extension, or with `pi-research knowledge-config set none|project|global
|
|
47
|
+
in the pi extension, or with `pi-research knowledge-config set <none|project|global>` (pick one value) on the
|
|
48
48
|
standalone CLI. The setting persists to the per-directory project registry — see
|
|
49
49
|
[CONFIGURATION.md](CONFIGURATION.md) for the full precedence chain. The change applies on
|
|
50
50
|
the next run — no restart.
|
package/docs/SDK.md
CHANGED
|
@@ -33,13 +33,16 @@ global file entirely and run purely from defaults + `process.env` + `options.con
|
|
|
33
33
|
> `enum` and constructor parameter properties, which Node's strip-only mode
|
|
34
34
|
> (`--experimental-strip-types`, the default since Node 23.6) rejects with
|
|
35
35
|
> `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. Use one of:
|
|
36
|
-
> - the pi host, which loads it natively;
|
|
37
|
-
> - **`node --experimental-transform-types your-script.ts`** — must be passed
|
|
38
|
-
> explicitly; full transform is not the default on any current Node release;
|
|
36
|
+
> - the pi host, which loads it natively (via `jiti`);
|
|
39
37
|
> - a loader such as `tsx` or `ts-node`.
|
|
40
38
|
>
|
|
41
|
-
>
|
|
42
|
-
>
|
|
39
|
+
> **Bare Node cannot load it at all, with any flag.** Node refuses to strip or
|
|
40
|
+
> transform TypeScript that lives under `node_modules` — as an installed
|
|
41
|
+
> dependency's source does — failing with
|
|
42
|
+
> `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. That applies to
|
|
43
|
+
> `--experimental-transform-types` exactly as it does to
|
|
44
|
+
> `--experimental-strip-types`, so neither flag helps here; a loader (or the pi
|
|
45
|
+
> host) is mandatory. (`engines.node` is `>=22.19.0`.)
|
|
43
46
|
|
|
44
47
|
```typescript
|
|
45
48
|
import {
|
|
@@ -85,6 +88,15 @@ report caused by a sparse topic apart from one where most researchers failed). B
|
|
|
85
88
|
> Concurrency: a single initialized SDK instance runs one research call at a time.
|
|
86
89
|
> Overlapping `runDeepResearch`/`runQuickResearch` calls on the same instance throw
|
|
87
90
|
> — run them sequentially, or use a separate process per concurrent run.
|
|
91
|
+
>
|
|
92
|
+
> Separate processes are additionally bounded by a **machine-wide run cap**
|
|
93
|
+
> (`PI_RESEARCH_MAX_CONCURRENT_RUNS`, default 3) covering every pi-research process
|
|
94
|
+
> on the host, because they all share one leader-elected browser/embedding pool. A
|
|
95
|
+
> run over the cap queues for up to `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` (default
|
|
96
|
+
> 10 min) and only then rejects with `ResearchRunCapacityError` — a temporary
|
|
97
|
+
> "try again shortly" condition, surfaced by the CLI as exit code `75`. Supply an
|
|
98
|
+
> observer with `onRunQueued(slots, maxWaitMs)` to tell a waiting user the run is
|
|
99
|
+
> queued rather than hung.
|
|
88
100
|
|
|
89
101
|
The SDK does not write report files. Report export is a front-end concern — the pi
|
|
90
102
|
extension and the CLI / agent skill do it when `PI_RESEARCH_REPORT_EXPORT_ENABLED=true`.
|
package/package.json
CHANGED
package/scripts/build.cjs
CHANGED
|
@@ -106,13 +106,31 @@ const TARGETS = {
|
|
|
106
106
|
copyWebGpuProbe();
|
|
107
107
|
copyPrompts();
|
|
108
108
|
},
|
|
109
|
-
skill: () =>
|
|
110
|
-
|
|
109
|
+
skill: () => {
|
|
110
|
+
const entry = p('skills', 'pi-research', 'scripts', 'run.ts');
|
|
111
|
+
const outfile = p('skills', 'pi-research', 'scripts', 'run.mjs');
|
|
112
|
+
// The published package ships the BUILT run.mjs and deliberately excludes its
|
|
113
|
+
// run.ts source (package.json "files": "!skills/pi-research/scripts/run.ts"),
|
|
114
|
+
// yet it also ships prepare.cjs + build.cjs — and `prepare` runs `build.cjs all`.
|
|
115
|
+
// On any install that executes prepare against the shipped tree (npm install
|
|
116
|
+
// <dir>, file:, npm link, pi install <dir>) this target had no entry point and
|
|
117
|
+
// aborted the whole install with "Could not resolve run.ts". Registry .tgz and
|
|
118
|
+
// npx installs were unaffected only because prepare does not run there.
|
|
119
|
+
// Nothing to do when the artifact is already present and its source is not.
|
|
120
|
+
if (!fs.existsSync(entry)) {
|
|
121
|
+
if (fs.existsSync(outfile)) {
|
|
122
|
+
console.log('[build] skill: run.ts absent and run.mjs already built (published tree) — skipping');
|
|
123
|
+
return Promise.resolve();
|
|
124
|
+
}
|
|
125
|
+
throw new Error(`[build] skill: neither ${entry} nor a prebuilt ${outfile} exists`);
|
|
126
|
+
}
|
|
127
|
+
return esbuild.build({
|
|
111
128
|
...COMMON,
|
|
112
|
-
entryPoints: [
|
|
113
|
-
outfile
|
|
129
|
+
entryPoints: [entry],
|
|
130
|
+
outfile,
|
|
114
131
|
banner: { js: SHEBANG },
|
|
115
|
-
})
|
|
132
|
+
});
|
|
133
|
+
},
|
|
116
134
|
};
|
|
117
135
|
|
|
118
136
|
// The cli target's copyThreadWorker step depends on the worker output existing,
|
|
@@ -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.
|
|
5
|
+
metadata: { "author": "Lincoln Deen", "version": "1.1.2", "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`
|
|
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
|
|
|
@@ -55,8 +63,9 @@ node "<SKILL_DIR>/scripts/run.mjs" status [--json]
|
|
|
55
63
|
| Exit | Meaning | Action |
|
|
56
64
|
|------|---------|--------|
|
|
57
65
|
| `0` | Success | Read stdout; cite sources; relay any "saved to: <path>" line. |
|
|
58
|
-
| `78` on `knowledge` |
|
|
66
|
+
| `78` on `knowledge` | Two cases — **read stderr to tell them apart.** | Says the store is **disabled**: silent; proceed to `research`. Mentions **credentials/model/API key**: this is the same hard config failure as `78` on `research` — **stop and relay full stderr**; don't silently continue, `research` will fail the same way. |
|
|
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
|
|
|
@@ -72,7 +81,7 @@ the new setting. Check the current one first if useful:
|
|
|
72
81
|
|
|
73
82
|
```
|
|
74
83
|
node "<SKILL_DIR>/scripts/run.mjs" knowledge-config # current mode + where it's set
|
|
75
|
-
node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set none|project|global
|
|
84
|
+
node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set <none|project|global> # pick ONE value
|
|
76
85
|
```
|
|
77
86
|
|
|
78
87
|
The change applies to the current directory on the next run. Never change it unprompted.
|
|
@@ -52,7 +52,7 @@ shared store — the default), `project` (scoped to the current directory), or `
|
|
|
52
52
|
|
|
53
53
|
```sh
|
|
54
54
|
node "<SKILL_DIR>/scripts/run.mjs" knowledge-config # current mode + its source
|
|
55
|
-
node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set none|project|global
|
|
55
|
+
node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set <none|project|global> # pick ONE value
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
`set` persists to the per-directory project registry (`~/.pi/research/state/
|
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;
|
|
@@ -123,9 +137,19 @@ function makeProgressObserver(): HeadlessObserverOptions {
|
|
|
123
137
|
case 'evaluation_decision':
|
|
124
138
|
toStderr(` • evaluator: ${data?.action}\n`);
|
|
125
139
|
break;
|
|
126
|
-
case 'tokens_consumed':
|
|
127
|
-
|
|
140
|
+
case 'tokens_consumed': {
|
|
141
|
+
// A truthiness check on cost used to gate this whole line, so a model with
|
|
142
|
+
// an all-zero price table suppressed the TOKEN count as well — the run
|
|
143
|
+
// looked like it consumed nothing. Tokens stand on their own; cost is
|
|
144
|
+
// appended whenever it is a number, including 0.
|
|
145
|
+
const consumed = Number(data?.tokens ?? 0);
|
|
146
|
+
const costVal = typeof data?.cost === 'number' ? data.cost : undefined;
|
|
147
|
+
if (consumed > 0 || costVal !== undefined) {
|
|
148
|
+
const costPart = costVal === undefined ? '' : ` ($${costVal.toFixed(4)})`;
|
|
149
|
+
toStderr(` • tokens: ${consumed}${costPart}\n`);
|
|
150
|
+
}
|
|
128
151
|
break;
|
|
152
|
+
}
|
|
129
153
|
case 'complete':
|
|
130
154
|
toStderr(` • complete (${data?.result?.length ?? 0} chars)\n`);
|
|
131
155
|
break;
|
|
@@ -829,9 +853,19 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
|
|
|
829
853
|
// research-stop — regardless of real cause — as a config error (exit 78).
|
|
830
854
|
const isTaggedResearchStop = (err as { code?: unknown } | undefined)?.code === RESEARCH_STOPPED_ERROR_CODE;
|
|
831
855
|
|
|
856
|
+
// Run-cap exhaustion is an operational state, not a fault: every slot is held by
|
|
857
|
+
// another *healthy* live run. Matched on the error name rather than `instanceof`
|
|
858
|
+
// so it survives bundling (dist/cli.mjs) and any duplicate module instance.
|
|
859
|
+
// Checked ahead of the substring heuristics below rather than relying on the
|
|
860
|
+
// current wording of the message: capacity text is config-shaped (it names two
|
|
861
|
+
// PI_RESEARCH_* env vars), so an exact classification must not depend on that
|
|
862
|
+
// message never drifting into one of the credential keywords. Misreading a busy
|
|
863
|
+
// machine as a setup problem would send the user to reconfigure a working install.
|
|
864
|
+
const isCapacity = err instanceof Error && err.name === 'ResearchRunCapacityError';
|
|
865
|
+
|
|
832
866
|
// Auth / model / config-shaped errors → CONFIG exit + config block.
|
|
833
867
|
const isConfigError =
|
|
834
|
-
!isTaggedResearchStop && (
|
|
868
|
+
!isTaggedResearchStop && !isCapacity && (
|
|
835
869
|
lower.includes('no llm model available') ||
|
|
836
870
|
lower.includes('not found in pi') ||
|
|
837
871
|
lower.includes('no api key') ||
|
|
@@ -846,12 +880,19 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
|
|
|
846
880
|
// classifying that as a setup problem sends the user to reconfigure a
|
|
847
881
|
// perfectly working key instead of waiting out the limit.
|
|
848
882
|
const isRateLimit = lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests');
|
|
849
|
-
const exitCode =
|
|
883
|
+
const exitCode = isCapacity
|
|
884
|
+
? EXIT.TEMPFAIL
|
|
885
|
+
: isConfigError && !isRateLimit
|
|
886
|
+
? EXIT.CONFIG
|
|
887
|
+
: EXIT.SOFTWARE;
|
|
850
888
|
|
|
851
889
|
// --json: emit a structured error on stdout so a machine consumer never has to
|
|
852
890
|
// special-case plain-text stderr. Mirrors the { ok: true, ... } success shape.
|
|
853
891
|
if (json) {
|
|
854
892
|
const payload: Record<string, unknown> = { ok: false, error: msg, exitCode };
|
|
893
|
+
// Machine-readable "this will succeed later" flag, so a consumer can back off
|
|
894
|
+
// and retry without string-matching the human message.
|
|
895
|
+
if (isCapacity || isRateLimit) payload['retryable'] = true;
|
|
855
896
|
if (process.env['PI_RESEARCH_DEBUG'] === 'true' && err instanceof Error && err.stack) {
|
|
856
897
|
payload['stack'] = err.stack;
|
|
857
898
|
}
|
|
@@ -859,6 +900,13 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
|
|
|
859
900
|
return exitCode;
|
|
860
901
|
}
|
|
861
902
|
|
|
903
|
+
// Capacity exhaustion is a healthy-machine "busy" state — report it as such and
|
|
904
|
+
// never with a stack trace, which would read as a crash for a routine condition.
|
|
905
|
+
if (isCapacity) {
|
|
906
|
+
toStderr(`\nError: pi-research ${what} did not start: ${msg}\n`);
|
|
907
|
+
return exitCode;
|
|
908
|
+
}
|
|
909
|
+
|
|
862
910
|
// Rate limits are operational, not fatal setup problems — checked FIRST.
|
|
863
911
|
if (isRateLimit) {
|
|
864
912
|
toStderr(`\nError: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.\n ${msg}\n`);
|
|
@@ -978,7 +1026,9 @@ CONFIGURE
|
|
|
978
1026
|
EXIT CODES
|
|
979
1027
|
0 success
|
|
980
1028
|
64 bad arguments
|
|
1029
|
+
75 at capacity — every concurrent run slot is busy; nothing is broken, retry later
|
|
981
1030
|
78 not configured — message prints the exact locations to fix
|
|
1031
|
+
(on \`knowledge\`, also returned when the store is deliberately disabled)
|
|
982
1032
|
70 runtime error (network, provider, internal)
|
|
983
1033
|
`;
|
|
984
1034
|
}
|
|
@@ -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;
|
|
@@ -219,6 +219,13 @@ export function constructMinimalModel(provider: string, modelId: string, _apiKey
|
|
|
219
219
|
baseUrl: '', // Provider-specific base URLs are handled by pi-ai internal registry
|
|
220
220
|
reasoning: false,
|
|
221
221
|
input: ['text'],
|
|
222
|
+
// No catalog is available on this path, so there is no price data and no real
|
|
223
|
+
// context/output ceiling to read. The zeros below are "unknown", NOT "free":
|
|
224
|
+
// cost reporting for this model will read $0.00 (see warnIfUnpriced in
|
|
225
|
+
// utils/llm-usage.ts), and the window figures are conservative guesses, not the
|
|
226
|
+
// model's true limits — so docs/CONFIGURATION.md's "clamped to the model's real
|
|
227
|
+
// ceiling" guarantee does not hold here. Anyone relying on accurate cost or the
|
|
228
|
+
// full context window should give pi a credential so it can fetch the real catalog.
|
|
222
229
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
223
230
|
contextWindow: 128_000,
|
|
224
231
|
maxTokens: 32_768,
|
|
@@ -253,7 +260,7 @@ export function resolveModel(registry: ModelRegistry, modelSpec?: string, provid
|
|
|
253
260
|
return constructMinimalModel(prov, modelId, apiKey);
|
|
254
261
|
}
|
|
255
262
|
|
|
256
|
-
throw new Error(
|
|
263
|
+
throw new Error(buildModelNotFoundMessage(modelSpec));
|
|
257
264
|
}
|
|
258
265
|
|
|
259
266
|
// Validate format: must contain a slash OR be found as a bare model ID.
|
|
@@ -306,11 +313,41 @@ export function resolveModel(registry: ModelRegistry, modelSpec?: string, provid
|
|
|
306
313
|
return constructMinimalModel(provider, modelSpec, apiKey);
|
|
307
314
|
}
|
|
308
315
|
|
|
309
|
-
throw new Error(
|
|
310
|
-
|
|
316
|
+
throw new Error(buildNoModelAvailableMessage());
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Credential-first guidance for model-resolution failures.
|
|
321
|
+
*
|
|
322
|
+
* These messages used to say only "Check ~/.pi/agent/models.json", which is the
|
|
323
|
+
* WRONG remedy for a provider pi ships built in: the fix there is a credential,
|
|
324
|
+
* after which pi fetches the provider's catalog (with real pricing and context
|
|
325
|
+
* limits) on its own. Following the old advice, users hand-wrote `models[]`
|
|
326
|
+
* entries — and because such an entry REPLACES pi's catalog entry wholesale
|
|
327
|
+
* rather than merging into it, that silently zeroed out pricing and capped
|
|
328
|
+
* context/output windows. Lead with the credential path; scope models.json to
|
|
329
|
+
* genuinely custom providers; name `modelOverrides` for single-field tweaks.
|
|
330
|
+
*/
|
|
331
|
+
function credentialGuidance(): string {
|
|
332
|
+
const agentDir = getAgentDir();
|
|
333
|
+
return (
|
|
334
|
+
` • Provider pi supports built-in (openrouter, anthropic, openai, …): add a credential — run \`pi\` and use /login, ` +
|
|
335
|
+
`set the provider's API-key environment variable, or add it to ${path.join(agentDir, 'auth.json')}. ` +
|
|
336
|
+
`pi then fetches that provider's model catalog automatically.\n` +
|
|
337
|
+
` • Custom or self-hosted provider (Ollama, vLLM, a proxy): declare it in ${path.join(agentDir, 'models.json')}.\n` +
|
|
338
|
+
` Note: a models.json entry REPLACES pi's catalog entry for the same model id, discarding its pricing and\n` +
|
|
339
|
+
` context/output limits. To adjust a single field of a built-in model, use "modelOverrides" instead — it merges.`
|
|
311
340
|
);
|
|
312
341
|
}
|
|
313
342
|
|
|
343
|
+
export function buildModelNotFoundMessage(modelSpec: string): string {
|
|
344
|
+
return `Model "${modelSpec}" not found in pi's model registry.\n${credentialGuidance()}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function buildNoModelAvailableMessage(): string {
|
|
348
|
+
return `No LLM model available.\n${credentialGuidance()}\n • Or pass an explicit apiKey.`;
|
|
349
|
+
}
|
|
350
|
+
|
|
314
351
|
/**
|
|
315
352
|
* Read the user's provider order from ~/.pi/agent/models.json.
|
|
316
353
|
* Returns provider keys in document (insertion) order, or [] when the file is
|
|
@@ -9,7 +9,7 @@ import { type Model } from '@earendil-works/pi-ai';
|
|
|
9
9
|
import { type ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
10
10
|
import { getConfig } from '../../config.ts';
|
|
11
11
|
import { logger } from '../../logger.ts';
|
|
12
|
-
import { pickPreferredAvailable, readModelsJsonProviderOrder, safeGetAll, safeGetAvailable } from './model-registry-factory.ts';
|
|
12
|
+
import { buildNoModelAvailableMessage, pickPreferredAvailable, readModelsJsonProviderOrder, safeGetAll, safeGetAvailable } from './model-registry-factory.ts';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Resolve the research model with standardized priority:
|
|
@@ -87,5 +87,5 @@ export function resolveResearchModel(options: {
|
|
|
87
87
|
return pickPreferredAvailable(anyModel, readModelsJsonProviderOrder()) as Model<any>;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
throw new Error(
|
|
90
|
+
throw new Error(buildNoModelAvailableMessage());
|
|
91
91
|
}
|
|
@@ -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
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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;
|
|
@@ -100,7 +109,13 @@ export function isPoolShutdownError(error: unknown): boolean {
|
|
|
100
109
|
return typeof err.message === 'string' && (
|
|
101
110
|
err.message.includes('Worker pool is shutting down') ||
|
|
102
111
|
err.message.includes('Cannot execute a task on destroying pool') ||
|
|
103
|
-
err.message.includes('destroying pool')
|
|
112
|
+
err.message.includes('destroying pool') ||
|
|
113
|
+
// worker-pool-manager throws this when auto-recovery is still swapping the
|
|
114
|
+
// pool. Its own text says "please retry", but until it was listed here no
|
|
115
|
+
// classifier matched it, so the first attempt rethrew instead of retrying —
|
|
116
|
+
// and auto-recovery fires only after 3 consecutive worker failures, i.e.
|
|
117
|
+
// exactly when a burst of tasks is in flight and would all die together.
|
|
118
|
+
err.message.includes('Worker pool is being reset')
|
|
104
119
|
);
|
|
105
120
|
}
|
|
106
121
|
|