@lincoln504/pi-research 1.0.12 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -8
- package/dist/cli.mjs +99 -13
- package/docs/ARCHITECTURE.md +24 -0
- package/docs/CHANGELOG.md +14 -0
- package/docs/CONFIGURATION.md +2 -0
- package/docs/SDK.md +9 -0
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +11 -2
- package/src/cli.ts +40 -2
- package/src/core/interfaces/observer-interfaces.ts +9 -0
- package/src/infrastructure/browser/browser-error-utils.ts +12 -3
- package/src/infrastructure/browser/task-execution-service.ts +86 -9
- 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/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<strong>
|
|
6
|
+
<strong>Local web research and knowledge store</strong>
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
@@ -24,9 +24,20 @@
|
|
|
24
24
|
|
|
25
25
|
<a href="https://github.com/Lincoln504/pi-research/actions/workflows/ci.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/Lincoln504/pi-research/ci.yml?style=flat-square&branch=main" /></a> <a href="https://www.npmjs.com/package/@lincoln504/pi-research"><img alt="npm version" src="https://img.shields.io/npm/v/@lincoln504/pi-research.svg?style=flat-square" /></a>
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
**Free, unlimited web search & scraping — no monthly quota, no subscription.**
|
|
28
|
+
You pay only for the LLM tokens you use, and it builds your own private, searchable
|
|
29
|
+
knowledge store on your machine.
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
### How it works
|
|
32
|
+
|
|
33
|
+
A research run is a loop of agent teams: a coordinator plans the work and runs an
|
|
34
|
+
opening search, parallel researcher agents scrape and read sources through a fixed,
|
|
35
|
+
sandboxed toolkit (no filesystem or shell access), and an evaluator decides whether
|
|
36
|
+
to run another round or synthesize — returning one cited Markdown report while its
|
|
37
|
+
sources are saved to the knowledge store in the background.
|
|
38
|
+
|
|
39
|
+
Three depth levels — normal, deep, ultra — set the team size and number of rounds;
|
|
40
|
+
ask in natural language and the tool picks the right one:
|
|
30
41
|
|
|
31
42
|

|
|
32
43
|
|
|
@@ -39,12 +50,13 @@ Ask in natural language — the tool understands the depth needed:
|
|
|
39
50
|
- Using the pi-research agent skill as OpenClaw's web access.
|
|
40
51
|
- Building agent systems that identify and examine web sources.
|
|
41
52
|
|
|
42
|
-
###
|
|
53
|
+
### Why pi-research
|
|
43
54
|
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
55
|
+
- **Free, unlimited search & scrape — no quota, no monthly fee.** Most AI-search providers cap free searches per month and then charge; results run on *their* index on *their* servers. pi-research's search and scraping layer (DuckDuckGo via a stealth browser) is free and unlimited — you pay only for the LLM tokens you use, never per search or per month.
|
|
56
|
+
- **You own your index.** Every finding is written into a local [LanceDB](https://lancedb.com) knowledge store on *your* machine — a persistent, searchable index you control and keep, which also seeds future runs so repeat questions get faster, cheaper answers. No borrowed, server-side index you can't keep or query. Findings can also be kept **globally**, scoped to a **single project**, or **not kept at all** — set per directory, changed whenever you like. The optionality is the point: the store is yours to build, partition, or switch off.
|
|
57
|
+
- **Cited reports, not raw dumps.** It returns one synthesized, sourced Markdown report to the chat instead of dumping raw web content into the conversation — context-efficient and easy to verify.
|
|
58
|
+
- **Sandboxed by design.** Web access runs inside a specialized, limited research agent with no filesystem or shell access — safe to point at anything.
|
|
59
|
+
- **Search a little or a lot** — three depth levels in the pi tool (four via the SDK and standalone CLI, which add the quick depth-0 path). Levels 1 and 2 are recommended for everyday workflow use; level 3 is for larger-scale investigations.
|
|
48
60
|
|
|
49
61
|
### Requirements / limitations
|
|
50
62
|
|
package/dist/cli.mjs
CHANGED
|
@@ -14228,14 +14228,14 @@ init_file_lock_service();
|
|
|
14228
14228
|
init_service_registry();
|
|
14229
14229
|
init_service_names();
|
|
14230
14230
|
import * as pathmod2 from "node:path";
|
|
14231
|
-
var DEFAULT_MAX_CONCURRENT_RUNS =
|
|
14232
|
-
var DEFAULT_ACQUIRE_TIMEOUT_MS =
|
|
14231
|
+
var DEFAULT_MAX_CONCURRENT_RUNS = 3;
|
|
14232
|
+
var DEFAULT_ACQUIRE_TIMEOUT_MS = 6e5;
|
|
14233
14233
|
var POLL_DELAY_MS = 250;
|
|
14234
14234
|
var ResearchRunCapacityError = class extends Error {
|
|
14235
14235
|
slots;
|
|
14236
14236
|
constructor(slots, maxWaitMs) {
|
|
14237
14237
|
super(
|
|
14238
|
-
`Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` after
|
|
14238
|
+
`Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` and still busy after queueing for ${Math.round(maxWaitMs / 1e3)}s` : ""}; another run is still in progress. This is temporary \u2014 retry shortly. (Raise the cap with PI_RESEARCH_MAX_CONCURRENT_RUNS, or the queue wait with PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS.)`
|
|
14239
14239
|
);
|
|
14240
14240
|
this.name = "ResearchRunCapacityError";
|
|
14241
14241
|
this.slots = slots;
|
|
@@ -14335,16 +14335,20 @@ var ResearchRunSemaphore = class {
|
|
|
14335
14335
|
* @param signal aborts the wait. Without it a run started under a long
|
|
14336
14336
|
* `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` would ignore Ctrl-C until the deadline
|
|
14337
14337
|
* passed, because the wait happens before any orchestrator exists to cancel.
|
|
14338
|
+
* @param onQueued invoked at most once, when the first full sweep finds every
|
|
14339
|
+
* slot busy and the call is about to wait. Lets a front-end tell the user the
|
|
14340
|
+
* run is queued rather than hung. Never allowed to break the acquire.
|
|
14338
14341
|
* @throws {ResearchRunCapacityError} if no slot frees within `maxWaitMs`.
|
|
14339
14342
|
* @throws {Error} named `AbortError` if `signal` aborts while waiting.
|
|
14340
14343
|
*/
|
|
14341
|
-
async acquire(maxWaitMs = this.defaultMaxWaitMs, signal) {
|
|
14344
|
+
async acquire(maxWaitMs = this.defaultMaxWaitMs, signal, onQueued) {
|
|
14342
14345
|
if (!this._initialized) await this.initialize();
|
|
14343
14346
|
if (signal?.aborted) {
|
|
14344
14347
|
metrics.increment("research_run_acquire_total", 1, { status: "aborted" });
|
|
14345
14348
|
throwIfAborted(signal);
|
|
14346
14349
|
}
|
|
14347
14350
|
const deadline = Date.now() + Math.max(0, maxWaitMs);
|
|
14351
|
+
let announcedQueued = false;
|
|
14348
14352
|
for (; ; ) {
|
|
14349
14353
|
const start = this.nextStartIndex;
|
|
14350
14354
|
this.nextStartIndex = (this.nextStartIndex + 1) % this.maxSlots;
|
|
@@ -14361,6 +14365,15 @@ var ResearchRunSemaphore = class {
|
|
|
14361
14365
|
metrics.increment("research_run_acquire_total", 1, { status: "exhausted" });
|
|
14362
14366
|
throw new ResearchRunCapacityError(this.maxSlots, maxWaitMs);
|
|
14363
14367
|
}
|
|
14368
|
+
if (!announcedQueued) {
|
|
14369
|
+
announcedQueued = true;
|
|
14370
|
+
logger.info(`[ResearchRunSemaphore] All ${this.maxSlots} run slot(s) busy \u2014 queueing for up to ${maxWaitMs}ms.`);
|
|
14371
|
+
try {
|
|
14372
|
+
onQueued?.(this.maxSlots, maxWaitMs);
|
|
14373
|
+
} catch (err) {
|
|
14374
|
+
logger.debug(`[ResearchRunSemaphore] onQueued callback threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
14375
|
+
}
|
|
14376
|
+
}
|
|
14364
14377
|
try {
|
|
14365
14378
|
await sleep(POLL_DELAY_MS, signal);
|
|
14366
14379
|
} catch (err) {
|
|
@@ -14886,6 +14899,17 @@ init_service_registry();
|
|
|
14886
14899
|
init_service_interfaces();
|
|
14887
14900
|
var lastRestartTime = 0;
|
|
14888
14901
|
var RESTART_COOLDOWN_MS = 1e4;
|
|
14902
|
+
var LEADER_HANDOVER_RETRIES = 3;
|
|
14903
|
+
var HANDOVER_BACKOFF_MS = 1200;
|
|
14904
|
+
async function recoverFromLeaderHandover(container) {
|
|
14905
|
+
logger.warn("[BrowserManager] Leader handover detected (pool draining) \u2014 dropping stale scheduler handle and re-resolving...");
|
|
14906
|
+
await forceSchedulerRestart2(false, container).catch(
|
|
14907
|
+
(err) => logger.debug("[BrowserManager] Scheduler re-resolve during handover failed (non-fatal):", err)
|
|
14908
|
+
);
|
|
14909
|
+
await waitForBrowserPoolIdle(15e3).catch(
|
|
14910
|
+
(err) => logger.debug("Wait for browser idle timed out or failed:", err)
|
|
14911
|
+
);
|
|
14912
|
+
}
|
|
14889
14913
|
async function getScheduler2(config, container = getServiceContainer()) {
|
|
14890
14914
|
try {
|
|
14891
14915
|
const factory = await getService(ServiceNames.SCHEDULER_FACTORY, void 0, container);
|
|
@@ -14902,7 +14926,7 @@ async function forceSchedulerRestart2(forceClearRemoteState = false, container =
|
|
|
14902
14926
|
return await forceSchedulerRestart(forceClearRemoteState, container);
|
|
14903
14927
|
}
|
|
14904
14928
|
}
|
|
14905
|
-
async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retries = 1, container = getServiceContainer()) {
|
|
14929
|
+
async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retries = 1, container = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES) {
|
|
14906
14930
|
if (signal?.aborted) throw new Error("Aborted");
|
|
14907
14931
|
const sessionId = typeof taskOrUrl === "object" ? taskOrUrl.sessionId : void 0;
|
|
14908
14932
|
const breaker2 = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
|
|
@@ -14923,6 +14947,19 @@ async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retrie
|
|
|
14923
14947
|
});
|
|
14924
14948
|
} catch (error) {
|
|
14925
14949
|
if (signal?.aborted || error instanceof Error && error.message === "Aborted") throw new Error("Aborted", { cause: error });
|
|
14950
|
+
const isHandover = isPoolShutdownError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error);
|
|
14951
|
+
if (isHandover && handoverRetries > 0) {
|
|
14952
|
+
errorTracker.trackError(error, {
|
|
14953
|
+
component: "browser-manager",
|
|
14954
|
+
operation: type,
|
|
14955
|
+
taskType: type,
|
|
14956
|
+
errorType: "leader_handover"
|
|
14957
|
+
});
|
|
14958
|
+
logger.warn(`[BrowserManager] Leader handover during ${type} task (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
14959
|
+
await recoverFromLeaderHandover(container);
|
|
14960
|
+
await new Promise((resolve8) => setTimeout(resolve8, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
|
|
14961
|
+
return runBrowserTask(taskOrUrl, type, config, signal, retries, container, handoverRetries - 1);
|
|
14962
|
+
}
|
|
14926
14963
|
if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
|
|
14927
14964
|
errorTracker.trackError(error, {
|
|
14928
14965
|
component: "browser-manager",
|
|
@@ -14948,7 +14985,7 @@ async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retrie
|
|
|
14948
14985
|
}
|
|
14949
14986
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
14950
14987
|
await new Promise((resolve8) => setTimeout(resolve8, jitter));
|
|
14951
|
-
return runBrowserTask(taskOrUrl, type, config, signal, retries - 1, container);
|
|
14988
|
+
return runBrowserTask(taskOrUrl, type, config, signal, retries - 1, container, handoverRetries);
|
|
14952
14989
|
}
|
|
14953
14990
|
throw error;
|
|
14954
14991
|
}
|
|
@@ -14968,7 +15005,7 @@ async function runBrowserHealthCheck(config, retries = 1, signal, container = ge
|
|
|
14968
15005
|
});
|
|
14969
15006
|
logger.warn(`[BrowserManager] Transient socket error during healthcheck (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
14970
15007
|
if (isPoolShutdownError(error)) {
|
|
14971
|
-
await
|
|
15008
|
+
await recoverFromLeaderHandover(container);
|
|
14972
15009
|
} else {
|
|
14973
15010
|
const now = Date.now();
|
|
14974
15011
|
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
@@ -14987,7 +15024,7 @@ async function runBrowserHealthCheck(config, retries = 1, signal, container = ge
|
|
|
14987
15024
|
throw error;
|
|
14988
15025
|
}
|
|
14989
15026
|
}
|
|
14990
|
-
async function runWorkerSearch(query, config, signal, retries = 1, sessionId, container = getServiceContainer()) {
|
|
15027
|
+
async function runWorkerSearch(query, config, signal, retries = 1, sessionId, container = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES) {
|
|
14991
15028
|
if (signal?.aborted) throw new Error("Aborted");
|
|
14992
15029
|
const breaker2 = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
|
|
14993
15030
|
try {
|
|
@@ -14998,6 +15035,19 @@ async function runWorkerSearch(query, config, signal, retries = 1, sessionId, co
|
|
|
14998
15035
|
});
|
|
14999
15036
|
} catch (error) {
|
|
15000
15037
|
if (signal?.aborted || error instanceof Error && error.message === "Aborted") throw new Error("Aborted", { cause: error });
|
|
15038
|
+
const isHandover = isPoolShutdownError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error);
|
|
15039
|
+
if (isHandover && handoverRetries > 0) {
|
|
15040
|
+
errorTracker.trackError(error, {
|
|
15041
|
+
component: "browser-manager",
|
|
15042
|
+
operation: "search",
|
|
15043
|
+
query,
|
|
15044
|
+
errorType: "leader_handover"
|
|
15045
|
+
});
|
|
15046
|
+
logger.warn(`[BrowserManager] Leader handover during search (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
15047
|
+
await recoverFromLeaderHandover(container);
|
|
15048
|
+
await new Promise((resolve8) => setTimeout(resolve8, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
|
|
15049
|
+
return runWorkerSearch(query, config, signal, retries, sessionId, container, handoverRetries - 1);
|
|
15050
|
+
}
|
|
15001
15051
|
if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
|
|
15002
15052
|
errorTracker.trackError(error, {
|
|
15003
15053
|
component: "browser-manager",
|
|
@@ -15021,7 +15071,7 @@ async function runWorkerSearch(query, config, signal, retries = 1, sessionId, co
|
|
|
15021
15071
|
}
|
|
15022
15072
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
15023
15073
|
await new Promise((resolve8) => setTimeout(resolve8, jitter));
|
|
15024
|
-
return runWorkerSearch(query, config, signal, retries - 1, sessionId, container);
|
|
15074
|
+
return runWorkerSearch(query, config, signal, retries - 1, sessionId, container, handoverRetries);
|
|
15025
15075
|
}
|
|
15026
15076
|
throw error;
|
|
15027
15077
|
}
|
|
@@ -20813,6 +20863,9 @@ var HeadlessObserver = class {
|
|
|
20813
20863
|
onStart(query, complexity) {
|
|
20814
20864
|
this.emit("start", { query, complexity });
|
|
20815
20865
|
}
|
|
20866
|
+
onRunQueued(slots, maxWaitMs) {
|
|
20867
|
+
this.emit("run_queued", { slots, maxWaitMs });
|
|
20868
|
+
}
|
|
20816
20869
|
onPlanningStart(attempt) {
|
|
20817
20870
|
this.emit("planning_start", { attempt });
|
|
20818
20871
|
}
|
|
@@ -21611,7 +21664,9 @@ var ResearchOrchestrationService = class {
|
|
|
21611
21664
|
try {
|
|
21612
21665
|
const container = tryGetServiceContainerFromCtx(ctx);
|
|
21613
21666
|
const semaphore = await getService(ServiceNames.RESEARCH_RUN_SEMAPHORE, ctx, container);
|
|
21614
|
-
runSlot = await semaphore.acquire(void 0, signal)
|
|
21667
|
+
runSlot = await semaphore.acquire(void 0, signal, (slots, maxWaitMs) => {
|
|
21668
|
+
notifyRunQueued(observer, slots, maxWaitMs);
|
|
21669
|
+
});
|
|
21615
21670
|
logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
|
|
21616
21671
|
} catch (err) {
|
|
21617
21672
|
if (err instanceof ResearchRunCapacityError) throw err;
|
|
@@ -21965,6 +22020,15 @@ var ResearchOrchestrationService = class {
|
|
|
21965
22020
|
}
|
|
21966
22021
|
}
|
|
21967
22022
|
};
|
|
22023
|
+
function notifyRunQueued(observer, slots, maxWaitMs) {
|
|
22024
|
+
try {
|
|
22025
|
+
const obs = observer;
|
|
22026
|
+
if (typeof obs?.onRunQueued === "function") obs.onRunQueued(slots, maxWaitMs);
|
|
22027
|
+
else if (typeof obs?.onProgress === "function") obs.onProgress("run_queued", { slots, maxWaitMs });
|
|
22028
|
+
} catch (err) {
|
|
22029
|
+
logger.debug(`[ResearchOrchestrationService] run_queued notification threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
22030
|
+
}
|
|
22031
|
+
}
|
|
21968
22032
|
|
|
21969
22033
|
// src/orchestration/research-session-service.ts
|
|
21970
22034
|
init_logger();
|
|
@@ -23221,7 +23285,15 @@ var EXIT = {
|
|
|
23221
23285
|
/** Missing setup: package, model, or API key not configured. */
|
|
23222
23286
|
CONFIG: 78,
|
|
23223
23287
|
/** Something went wrong at runtime (network, provider, internal). */
|
|
23224
|
-
SOFTWARE: 70
|
|
23288
|
+
SOFTWARE: 70,
|
|
23289
|
+
/**
|
|
23290
|
+
* Temporary failure — the request was well-formed and the install is healthy,
|
|
23291
|
+
* but the machine is at capacity right now (all research run slots held by
|
|
23292
|
+
* live runs). Distinct from SOFTWARE so a calling agent can tell "busy, try
|
|
23293
|
+
* again shortly" apart from "this run crashed" and back off instead of
|
|
23294
|
+
* re-running a broken command.
|
|
23295
|
+
*/
|
|
23296
|
+
TEMPFAIL: 75
|
|
23225
23297
|
};
|
|
23226
23298
|
function toStderr(s) {
|
|
23227
23299
|
process.stderr.write(s);
|
|
@@ -23241,6 +23313,12 @@ function makeProgressObserver() {
|
|
|
23241
23313
|
toStderr(` \u2022 plan: ${data?.plan?.researchers?.length ?? "?"} researcher(s)
|
|
23242
23314
|
`);
|
|
23243
23315
|
break;
|
|
23316
|
+
case "run_queued":
|
|
23317
|
+
toStderr(
|
|
23318
|
+
` \u2022 queued: ${data?.slots ?? "?"} concurrent run(s) already active \u2014 waiting up to ${Math.round((data?.maxWaitMs ?? 0) / 1e3)}s for a slot\u2026
|
|
23319
|
+
`
|
|
23320
|
+
);
|
|
23321
|
+
break;
|
|
23244
23322
|
case "round_start":
|
|
23245
23323
|
toStderr(` \u2022 round ${data?.round}
|
|
23246
23324
|
`);
|
|
@@ -23785,17 +23863,25 @@ async function reportError(err, what, json) {
|
|
|
23785
23863
|
const msg2 = err instanceof Error ? err.message : String(err);
|
|
23786
23864
|
const lower = msg2.toLowerCase();
|
|
23787
23865
|
const isTaggedResearchStop = err?.code === RESEARCH_STOPPED_ERROR_CODE;
|
|
23788
|
-
const
|
|
23866
|
+
const isCapacity = err instanceof Error && err.name === "ResearchRunCapacityError";
|
|
23867
|
+
const isConfigError = !isTaggedResearchStop && !isCapacity && (lower.includes("no llm model available") || lower.includes("not found in pi") || lower.includes("no api key") || lower.includes("provider must be specified") || lower.includes("invalid model string") || lower.includes("authentication") || lower.includes("unauthorized") || lower.includes("api key"));
|
|
23789
23868
|
const isRateLimit = lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests");
|
|
23790
|
-
const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
|
|
23869
|
+
const exitCode = isCapacity ? EXIT.TEMPFAIL : isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
|
|
23791
23870
|
if (json) {
|
|
23792
23871
|
const payload = { ok: false, error: msg2, exitCode };
|
|
23872
|
+
if (isCapacity || isRateLimit) payload["retryable"] = true;
|
|
23793
23873
|
if (process.env["PI_RESEARCH_DEBUG"] === "true" && err instanceof Error && err.stack) {
|
|
23794
23874
|
payload["stack"] = err.stack;
|
|
23795
23875
|
}
|
|
23796
23876
|
toStdout(pretty(payload));
|
|
23797
23877
|
return exitCode;
|
|
23798
23878
|
}
|
|
23879
|
+
if (isCapacity) {
|
|
23880
|
+
toStderr(`
|
|
23881
|
+
Error: pi-research ${what} did not start: ${msg2}
|
|
23882
|
+
`);
|
|
23883
|
+
return exitCode;
|
|
23884
|
+
}
|
|
23799
23885
|
if (isRateLimit) {
|
|
23800
23886
|
toStderr(`
|
|
23801
23887
|
Error: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.
|
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,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.0] - 2026-08-04
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **Concurrent runs over the cap now QUEUE instead of failing — the run-cap default is a queue, not a fail-fast.** v1.0.11 added the machine-wide run cap that stopped parallel runs saturating the shared browser/embedding pool, but it shipped with `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` defaulting to `0`. That made contention *fatal*: the cap was enforced by rejecting the run outright, in about two seconds, rather than by serializing it. Verified end-to-end before the change — four simultaneous skill runs against the default cap produced two clean reports and two hard failures, with the two rejected queries simply lost. That is the wrong trade for a semaphore guarding a shared resource: an extra run is a legitimate request to do work, not an error. The default acquire wait is now **600000 ms (10 minutes)**, so over-cap runs wait for a slot and then execute normally; a depth-1 run measured at ~2–3 minutes, so this absorbs a backlog of roughly three further runs per slot. `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS=0` restores the old strict fail-fast. The cap itself is raised from **2 to 3** concurrent runs. The agent skill's recommended command timeouts are raised to match (depth 1 `1500000` ms, depth 2 `1800000`, depth 3 `2400000`), since a queued run legitimately takes longer than an unqueued one and the old budgets no longer bounded the worst case.
|
|
12
|
+
- **A queued run now says so** (`• queued: N concurrent run(s) already active — waiting up to Ns for a slot…`) instead of sitting silent for the whole acquire wait. Silence for up to ten minutes is indistinguishable from a hang, which would have made the queueing change above *look* like a regression. The notice is emitted exactly once per run, via a new optional `ResearchObserver.onRunQueued(slots, maxWaitMs)`; because the queue happens before any orchestrator exists, the notification is dispatched to whichever observer shape the caller supplied — a `ResearchObserver` instance (SDK / pi extension) or the `HeadlessObserverOptions` bag carrying only `onProgress` (CLI / agent skill). A throwing front-end callback can never fail the run it is reporting on.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Distinct exit code `75` (`EX_TEMPFAIL`) for run-cap exhaustion.** Capacity exhaustion previously exited `70` — the same code as a genuine crash — so a calling agent could not tell "this machine is busy with other healthy runs, try again shortly" from "this run is broken", and the agent skill's own exit-code table told it to treat `70` as a transient error worth one immediate retry (which, under a 0 ms acquire, failed again instantly). It now exits `75`, documented in `SKILL.md` with explicit guidance not to reconfigure anything and not to retry in a tight loop, and `--json` output carries `retryable: true`. The classification is matched on the error name so it survives bundling into `dist/cli.mjs`, and is checked ahead of the credentials heuristic — the capacity message names `PI_RESEARCH_MAX_CONCURRENT_RUNS`, and the substring heuristic would otherwise have been at risk of reading a busy machine as a misconfigured one. Capacity exhaustion is also reported without a stack trace, since a routine "busy" state printed as a stack reads as a crash.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **A browser-pool leader exiting could destroy a concurrent run outright — the retry reconnected to the dead leader instead of re-electing.** The browser pool is hosted by one elected leader process shared by every pi-research run on the machine, so when that leader finishes its own run and tears down its workers, every other run with work in flight takes `Worker pool is shutting down` on *all* outstanding tasks at once. The recovery path handled this incorrectly: on a pool-shutdown error it only waited for the pool to go idle and **never dropped the cached scheduler handle**, so the single retry called `getScheduler()`, got the same cached client still pointing at the dead leader's port (visible in logs as a retry "Connecting to existing scheduler" with an *identical* version id), and failed the same way. The generic transient-socket branch did the right thing and forced a re-resolve; the pool-shutdown branch — the case where the leader is provably going away — did not. Reproduced in a real six-way concurrent run: a run acquired its slot in the same second the leader shut down, all 20 queries of its search burst failed, and the run aborted with `Search completely failed: all 20 queries encountered worker errors` and the tell-tale `60 tracked error(s) … "Worker pool is shutting down" ×60` — roughly two seconds *before* a fresh leader finished being elected and served everyone else normally. A handover now drops the stale handle and forces re-resolution (joining the new leader, or winning the election itself), and gets its own retry budget of 3 attempts with a ~1.2 s backoff, sized to a real election rather than to the 100–500 ms jitter meant for a socket blip. The budget is tracked separately from the generic retry so a handover cannot be starved by, or consume, the single transient-socket attempt; genuinely unrecoverable pool loss still gives up rather than retrying forever. This failure mode became far more likely with over-cap queueing, since a queued run is admitted at exactly the moment another run finishes — which is exactly when the leader may be tearing down.
|
|
19
|
+
- **The run-cap's two environment variables were entirely undocumented.** `PI_RESEARCH_MAX_CONCURRENT_RUNS` and `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` shipped in v1.0.11 and appeared in neither `docs/CONFIGURATION.md` nor `.env.example` — the latter described in the docs as "the canonical, exhaustive list" — even though the capacity error message explicitly instructed the user to override the first of them. Both are now documented in both places, and the run cap has an `ARCHITECTURE.md` section covering slot ownership, crash reclamation, queueing and fail-open behaviour.
|
|
20
|
+
- **`docs/SDK.md` recommended a workaround that the run cap had since made unreliable** — it told callers to "use a separate process per concurrent run" with no mention that separate processes are themselves bounded machine-wide, so following the documented advice past the cap hit a hard failure. It now documents the cap, the queue window, `ResearchRunCapacityError`, exit code `75`, and `onRunQueued`.
|
|
21
|
+
|
|
8
22
|
## [1.0.12] - 2026-08-03
|
|
9
23
|
|
|
10
24
|
### Fixed
|
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. |
|
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
|
@@ -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
|
|
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`
|
|
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 =
|
|
873
|
+
const exitCode = isCapacity
|
|
874
|
+
? EXIT.TEMPFAIL
|
|
875
|
+
: isConfigError && !isRateLimit
|
|
876
|
+
? EXIT.CONFIG
|
|
877
|
+
: EXIT.SOFTWARE;
|
|
850
878
|
|
|
851
879
|
// --json: emit a structured error on stdout so a machine consumer never has to
|
|
852
880
|
// special-case plain-text stderr. Mirrors the { ok: true, ... } success shape.
|
|
853
881
|
if (json) {
|
|
854
882
|
const payload: Record<string, unknown> = { ok: false, error: msg, exitCode };
|
|
883
|
+
// Machine-readable "this will succeed later" flag, so a consumer can back off
|
|
884
|
+
// and retry without string-matching the human message.
|
|
885
|
+
if (isCapacity || isRateLimit) payload['retryable'] = true;
|
|
855
886
|
if (process.env['PI_RESEARCH_DEBUG'] === 'true' && err instanceof Error && err.stack) {
|
|
856
887
|
payload['stack'] = err.stack;
|
|
857
888
|
}
|
|
@@ -859,6 +890,13 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
|
|
|
859
890
|
return exitCode;
|
|
860
891
|
}
|
|
861
892
|
|
|
893
|
+
// Capacity exhaustion is a healthy-machine "busy" state — report it as such and
|
|
894
|
+
// never with a stack trace, which would read as a crash for a routine condition.
|
|
895
|
+
if (isCapacity) {
|
|
896
|
+
toStderr(`\nError: pi-research ${what} did not start: ${msg}\n`);
|
|
897
|
+
return exitCode;
|
|
898
|
+
}
|
|
899
|
+
|
|
862
900
|
// Rate limits are operational, not fatal setup problems — checked FIRST.
|
|
863
901
|
if (isRateLimit) {
|
|
864
902
|
toStderr(`\nError: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.\n ${msg}\n`);
|
|
@@ -18,6 +18,15 @@ export interface HeadlessObserverOptions {
|
|
|
18
18
|
export interface ResearchObserver {
|
|
19
19
|
onStart?(query: string, complexity: number): void;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Fired once when the run cannot start immediately because every concurrent-run
|
|
23
|
+
* slot is held, and it is about to queue. Without this a queued run is silent
|
|
24
|
+
* for as long as the acquire wait allows, which is indistinguishable from a hang.
|
|
25
|
+
* @param slots the machine-wide concurrent-run cap
|
|
26
|
+
* @param maxWaitMs how long the run will queue before giving up
|
|
27
|
+
*/
|
|
28
|
+
onRunQueued?(slots: number, maxWaitMs: number): void;
|
|
29
|
+
|
|
21
30
|
// Coordinator/Planning phase
|
|
22
31
|
onPlanningStart?(attempt: number): void;
|
|
23
32
|
onPlanningProgress?(status: string): void;
|
|
@@ -90,9 +90,18 @@ export function isServerDrainingError(error: unknown): boolean {
|
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
92
|
* Check if an error is specifically a pool-shutdown / pool-drain error.
|
|
93
|
-
*
|
|
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;
|
|
@@ -22,6 +22,46 @@ import type { ISchedulerFactory, IScheduler } from '../../core/scheduler-factory
|
|
|
22
22
|
let lastRestartTime = 0;
|
|
23
23
|
const RESTART_COOLDOWN_MS = 10000;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Extra attempts reserved for a *leader handover* — the elected browser-pool leader
|
|
27
|
+
* exiting while this process still has work in flight ("Worker pool is shutting
|
|
28
|
+
* down" / "draining").
|
|
29
|
+
*
|
|
30
|
+
* This has its own budget because it is not a random socket blip: it is a known,
|
|
31
|
+
* self-healing window that lasts as long as a fresh election takes (measured at
|
|
32
|
+
* ~4 s on a loaded machine), and it hits *every* in-flight task of the affected
|
|
33
|
+
* run at once. With only the single generic retry, a whole 20-query search burst
|
|
34
|
+
* could be wiped out by one leader exiting — the run then failed outright with
|
|
35
|
+
* "all N queries encountered worker errors", seconds before the new leader came up.
|
|
36
|
+
* Spending these attempts is strictly cheaper than losing the run.
|
|
37
|
+
*/
|
|
38
|
+
const LEADER_HANDOVER_RETRIES = 3;
|
|
39
|
+
/** Backoff between handover attempts — long enough for an election to complete. */
|
|
40
|
+
const HANDOVER_BACKOFF_MS = 1200;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Recover from a leader handover before retrying.
|
|
44
|
+
*
|
|
45
|
+
* Critically this DROPS the cached scheduler handle. A pool-shutdown error means
|
|
46
|
+
* the leader we are connected to is going away, so the cached client points at a
|
|
47
|
+
* corpse; simply waiting and retrying reconnects to the same dead port (observed
|
|
48
|
+
* as a retry logging "Connecting to existing scheduler" with the *same* version id
|
|
49
|
+
* and failing identically). Clearing it forces the next `getScheduler()` to
|
|
50
|
+
* re-resolve — joining the new leader, or winning the election itself.
|
|
51
|
+
*
|
|
52
|
+
* `forceSchedulerRestart(false)` keeps its own liveness probe and in-progress
|
|
53
|
+
* guard, so concurrent callers coalesce instead of stampeding.
|
|
54
|
+
*/
|
|
55
|
+
async function recoverFromLeaderHandover(container: ServiceContainer): Promise<void> {
|
|
56
|
+
logger.warn('[BrowserManager] Leader handover detected (pool draining) — dropping stale scheduler handle and re-resolving...');
|
|
57
|
+
await forceSchedulerRestart(false, container).catch((err) =>
|
|
58
|
+
logger.debug('[BrowserManager] Scheduler re-resolve during handover failed (non-fatal):', err),
|
|
59
|
+
);
|
|
60
|
+
await waitForBrowserPoolIdle(15000).catch((err) =>
|
|
61
|
+
logger.debug('Wait for browser idle timed out or failed:', err),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
25
65
|
/**
|
|
26
66
|
* Get the scheduler from the factory, optionally using a specific container.
|
|
27
67
|
*/
|
|
@@ -56,7 +96,8 @@ export async function runBrowserTask<T>(
|
|
|
56
96
|
config?: Config,
|
|
57
97
|
signal?: AbortSignal,
|
|
58
98
|
retries = 1,
|
|
59
|
-
container: ServiceContainer = getServiceContainer()
|
|
99
|
+
container: ServiceContainer = getServiceContainer(),
|
|
100
|
+
handoverRetries = LEADER_HANDOVER_RETRIES,
|
|
60
101
|
): Promise<T> {
|
|
61
102
|
if (signal?.aborted) throw new Error('Aborted');
|
|
62
103
|
|
|
@@ -84,6 +125,23 @@ export async function runBrowserTask<T>(
|
|
|
84
125
|
} catch (error: unknown) {
|
|
85
126
|
if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
|
|
86
127
|
|
|
128
|
+
// A leader handover gets its own budget: it is a recoverable, seconds-long
|
|
129
|
+
// window that strikes every in-flight task at once, so it must not be
|
|
130
|
+
// charged against (or limited by) the single generic transient retry.
|
|
131
|
+
const isHandover = isPoolShutdownError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error);
|
|
132
|
+
if (isHandover && handoverRetries > 0) {
|
|
133
|
+
errorTracker.trackError(error, {
|
|
134
|
+
component: 'browser-manager',
|
|
135
|
+
operation: type,
|
|
136
|
+
taskType: type,
|
|
137
|
+
errorType: 'leader_handover',
|
|
138
|
+
});
|
|
139
|
+
logger.warn(`[BrowserManager] Leader handover during ${type} task (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
140
|
+
await recoverFromLeaderHandover(container);
|
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
|
|
142
|
+
return runBrowserTask<T>(taskOrUrl, type, config, signal, retries, container, handoverRetries - 1);
|
|
143
|
+
}
|
|
144
|
+
|
|
87
145
|
if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
|
|
88
146
|
errorTracker.trackError(error, {
|
|
89
147
|
component: 'browser-manager',
|
|
@@ -92,9 +150,9 @@ export async function runBrowserTask<T>(
|
|
|
92
150
|
errorType: 'transient_socket_error',
|
|
93
151
|
});
|
|
94
152
|
logger.warn(`[BrowserManager] Transient socket error during ${type} task (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
95
|
-
|
|
153
|
+
|
|
96
154
|
if (isPoolShutdownError(error)) {
|
|
97
|
-
//
|
|
155
|
+
// Handover budget already spent — fall back to a plain drain wait.
|
|
98
156
|
logger.warn(`[BrowserManager] Pool is draining — waiting for pool idle before retry...`);
|
|
99
157
|
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
100
158
|
} else {
|
|
@@ -120,7 +178,7 @@ export async function runBrowserTask<T>(
|
|
|
120
178
|
// Retry with jitter (100-500ms) to prevent thundering herd
|
|
121
179
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
122
180
|
await new Promise(resolve => setTimeout(resolve, jitter));
|
|
123
|
-
return runBrowserTask<T>(taskOrUrl, type, config, signal, retries - 1, container);
|
|
181
|
+
return runBrowserTask<T>(taskOrUrl, type, config, signal, retries - 1, container, handoverRetries);
|
|
124
182
|
}
|
|
125
183
|
throw error;
|
|
126
184
|
}
|
|
@@ -143,9 +201,12 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
|
|
|
143
201
|
errorType: 'transient_socket_error',
|
|
144
202
|
});
|
|
145
203
|
logger.warn(`[BrowserManager] Transient socket error during healthcheck (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
146
|
-
|
|
204
|
+
|
|
147
205
|
if (isPoolShutdownError(error)) {
|
|
148
|
-
|
|
206
|
+
// Same stale-handle trap as the task paths: waiting alone leaves the
|
|
207
|
+
// cached client aimed at the departing leader, so the retry re-probes
|
|
208
|
+
// a corpse and reports the pool unhealthy during a routine handover.
|
|
209
|
+
await recoverFromLeaderHandover(container);
|
|
149
210
|
} else {
|
|
150
211
|
// See runBrowserTask: don't force-clear remote leader state on a bare
|
|
151
212
|
// transient socket error — let the liveness probe inside
|
|
@@ -172,7 +233,7 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
|
|
|
172
233
|
/**
|
|
173
234
|
* Run a worker search query with retry logic.
|
|
174
235
|
*/
|
|
175
|
-
export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer()): Promise<SearchResult[]> {
|
|
236
|
+
export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES): Promise<SearchResult[]> {
|
|
176
237
|
if (signal?.aborted) throw new Error('Aborted');
|
|
177
238
|
|
|
178
239
|
const breaker = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
|
|
@@ -186,6 +247,22 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
186
247
|
} catch (error: unknown) {
|
|
187
248
|
if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
|
|
188
249
|
|
|
250
|
+
// See runBrowserTask: a leader handover wipes out an entire search burst at
|
|
251
|
+
// once, so it gets its own attempts rather than the single generic retry.
|
|
252
|
+
const isHandover = isPoolShutdownError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error);
|
|
253
|
+
if (isHandover && handoverRetries > 0) {
|
|
254
|
+
errorTracker.trackError(error, {
|
|
255
|
+
component: 'browser-manager',
|
|
256
|
+
operation: 'search',
|
|
257
|
+
query,
|
|
258
|
+
errorType: 'leader_handover',
|
|
259
|
+
});
|
|
260
|
+
logger.warn(`[BrowserManager] Leader handover during search (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
261
|
+
await recoverFromLeaderHandover(container);
|
|
262
|
+
await new Promise((resolve) => setTimeout(resolve, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
|
|
263
|
+
return runWorkerSearch(query, config, signal, retries, sessionId, container, handoverRetries - 1);
|
|
264
|
+
}
|
|
265
|
+
|
|
189
266
|
if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
|
|
190
267
|
errorTracker.trackError(error, {
|
|
191
268
|
component: 'browser-manager',
|
|
@@ -194,7 +271,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
194
271
|
errorType: 'transient_socket_error',
|
|
195
272
|
});
|
|
196
273
|
logger.warn(`[BrowserManager] Transient socket error during search (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
197
|
-
|
|
274
|
+
|
|
198
275
|
if (isPoolShutdownError(error)) {
|
|
199
276
|
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
200
277
|
} else {
|
|
@@ -214,7 +291,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
214
291
|
|
|
215
292
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
216
293
|
await new Promise(resolve => setTimeout(resolve, jitter));
|
|
217
|
-
return runWorkerSearch(query, config, signal, retries - 1, sessionId, container);
|
|
294
|
+
return runWorkerSearch(query, config, signal, retries - 1, sessionId, container, handoverRetries);
|
|
218
295
|
}
|
|
219
296
|
throw error;
|
|
220
297
|
}
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
* configured as a *try-take* (`lockTimeout: 0`): it either wins the slot (free,
|
|
22
22
|
* or reclaimed from a *dead* owner) immediately or throws as soon as it sees a
|
|
23
23
|
* *live* owner. If every slot is held by a live process, the loop waits
|
|
24
|
-
* `pollDelayMs` and retries until `maxWaitMs` elapses
|
|
25
|
-
* {@link
|
|
26
|
-
*
|
|
24
|
+
* `pollDelayMs` and retries until `maxWaitMs` elapses (by default
|
|
25
|
+
* {@link DEFAULT_ACQUIRE_TIMEOUT_MS} — extra runs **queue** behind the in-flight
|
|
26
|
+
* ones instead of failing), then rejects with {@link ResearchRunCapacityError}
|
|
27
|
+
* rather than degrading into a 60-error run.
|
|
27
28
|
* - **Release** unlinks the slot file via the captured slot service.
|
|
28
29
|
* - **Crash recovery** is automatic and PID-reuse-safe: a slot held by a process
|
|
29
30
|
* that has died (killed, crashed, SIGKILLed by earlyoom) is reclaimed
|
|
@@ -69,9 +70,21 @@ import type { IService } from '../core/service-registry.ts';
|
|
|
69
70
|
import type { IProcessLifecycle } from '../core/interfaces/process-interfaces.ts';
|
|
70
71
|
|
|
71
72
|
/** Default maximum number of concurrent research runs across all processes. */
|
|
72
|
-
export const DEFAULT_MAX_CONCURRENT_RUNS =
|
|
73
|
-
/**
|
|
74
|
-
|
|
73
|
+
export const DEFAULT_MAX_CONCURRENT_RUNS = 3;
|
|
74
|
+
/**
|
|
75
|
+
* Default per-acquire wait before giving up when all slots are held by live runs.
|
|
76
|
+
*
|
|
77
|
+
* This is deliberately a *queue*, not a fail-fast. The cap exists to stop N runs
|
|
78
|
+
* saturating the shared browser/embedding pool — but an extra run is a legitimate
|
|
79
|
+
* request to do work, not an error, so the correct response to contention is to
|
|
80
|
+
* serialize it behind the in-flight runs rather than to fail it. With a depth-1 run
|
|
81
|
+
* measured at ~2-3 minutes, 10 minutes absorbs a backlog of roughly three further
|
|
82
|
+
* runs per slot while still resolving well inside the agent-skill's documented
|
|
83
|
+
* command timeouts (so a queued run never looks like a hang to the caller).
|
|
84
|
+
*
|
|
85
|
+
* Set `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS=0` to restore strict fail-fast.
|
|
86
|
+
*/
|
|
87
|
+
export const DEFAULT_ACQUIRE_TIMEOUT_MS = 600_000;
|
|
75
88
|
/** Poll interval between full slot sweeps while waiting for a slot. */
|
|
76
89
|
const POLL_DELAY_MS = 250;
|
|
77
90
|
|
|
@@ -84,9 +97,10 @@ export class ResearchRunCapacityError extends Error {
|
|
|
84
97
|
readonly slots: number;
|
|
85
98
|
constructor(slots: number, maxWaitMs: number) {
|
|
86
99
|
super(
|
|
87
|
-
`Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` after
|
|
88
|
-
`
|
|
89
|
-
`(
|
|
100
|
+
`Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` and still busy after queueing for ${Math.round(maxWaitMs / 1000)}s` : ''}; ` +
|
|
101
|
+
`another run is still in progress. This is temporary — retry shortly. ` +
|
|
102
|
+
`(Raise the cap with PI_RESEARCH_MAX_CONCURRENT_RUNS, or the queue wait with ` +
|
|
103
|
+
`PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS.)`,
|
|
90
104
|
);
|
|
91
105
|
this.name = 'ResearchRunCapacityError';
|
|
92
106
|
this.slots = slots;
|
|
@@ -209,10 +223,17 @@ export class ResearchRunSemaphore implements IService {
|
|
|
209
223
|
* @param signal aborts the wait. Without it a run started under a long
|
|
210
224
|
* `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` would ignore Ctrl-C until the deadline
|
|
211
225
|
* passed, because the wait happens before any orchestrator exists to cancel.
|
|
226
|
+
* @param onQueued invoked at most once, when the first full sweep finds every
|
|
227
|
+
* slot busy and the call is about to wait. Lets a front-end tell the user the
|
|
228
|
+
* run is queued rather than hung. Never allowed to break the acquire.
|
|
212
229
|
* @throws {ResearchRunCapacityError} if no slot frees within `maxWaitMs`.
|
|
213
230
|
* @throws {Error} named `AbortError` if `signal` aborts while waiting.
|
|
214
231
|
*/
|
|
215
|
-
async acquire(
|
|
232
|
+
async acquire(
|
|
233
|
+
maxWaitMs: number = this.defaultMaxWaitMs,
|
|
234
|
+
signal?: AbortSignal,
|
|
235
|
+
onQueued?: (slots: number, maxWaitMs: number) => void,
|
|
236
|
+
): Promise<RunSlotAcquisition> {
|
|
216
237
|
if (!this._initialized) await this.initialize();
|
|
217
238
|
if (signal?.aborted) {
|
|
218
239
|
metrics.increment('research_run_acquire_total', 1, { status: 'aborted' });
|
|
@@ -220,6 +241,7 @@ export class ResearchRunSemaphore implements IService {
|
|
|
220
241
|
}
|
|
221
242
|
|
|
222
243
|
const deadline = Date.now() + Math.max(0, maxWaitMs);
|
|
244
|
+
let announcedQueued = false;
|
|
223
245
|
for (;;) {
|
|
224
246
|
// Round-robin from a rotating start so contending processes don't synchronize on slot 0.
|
|
225
247
|
const start = this.nextStartIndex;
|
|
@@ -238,6 +260,17 @@ export class ResearchRunSemaphore implements IService {
|
|
|
238
260
|
metrics.increment('research_run_acquire_total', 1, { status: 'exhausted' });
|
|
239
261
|
throw new ResearchRunCapacityError(this.maxSlots, maxWaitMs);
|
|
240
262
|
}
|
|
263
|
+
// Announce the queue exactly once, on the first sweep that found nothing free.
|
|
264
|
+
// A throwing front-end callback must never fail the acquire.
|
|
265
|
+
if (!announcedQueued) {
|
|
266
|
+
announcedQueued = true;
|
|
267
|
+
logger.info(`[ResearchRunSemaphore] All ${this.maxSlots} run slot(s) busy — queueing for up to ${maxWaitMs}ms.`);
|
|
268
|
+
try {
|
|
269
|
+
onQueued?.(this.maxSlots, maxWaitMs);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
logger.debug(`[ResearchRunSemaphore] onQueued callback threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
241
274
|
// Count the aborted outcome too, so acquired + exhausted + aborted accounts for
|
|
242
275
|
// every acquire attempt. A cancel that vanished from the counter would make the
|
|
243
276
|
// capacity metrics quietly under-report demand.
|
|
@@ -95,6 +95,10 @@ export class HeadlessObserver implements ResearchObserver {
|
|
|
95
95
|
this.emit('start', { query, complexity });
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
onRunQueued(slots: number, maxWaitMs: number): void {
|
|
99
|
+
this.emit('run_queued', { slots, maxWaitMs });
|
|
100
|
+
}
|
|
101
|
+
|
|
98
102
|
onPlanningStart(attempt: number): void {
|
|
99
103
|
this.emit('planning_start', { attempt });
|
|
100
104
|
}
|
|
@@ -108,17 +108,27 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
108
108
|
|
|
109
109
|
const researchStart = Date.now();
|
|
110
110
|
|
|
111
|
-
// Cross-process run-cap
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
111
|
+
// Cross-process run-cap: acquire one of N slots so concurrent research runs
|
|
112
|
+
// can't saturate the shared leader-elected browser/embedding pool and degrade
|
|
113
|
+
// into ~60-error failures. Over-cap runs QUEUE for a slot (announced once via
|
|
114
|
+
// the observer) rather than failing, because an extra run is a legitimate
|
|
115
|
+
// request to do work, not an error. Fail-OPEN on internal/IO errors (a
|
|
116
|
+
// semaphore bug must never break research); only a genuine
|
|
117
|
+
// ResearchRunCapacityError — nothing freed within the whole queue window —
|
|
118
|
+
// propagates. The slot is released in finally.
|
|
117
119
|
let runSlot: { slotIndex: number; release(): Promise<void> } | null = null;
|
|
118
120
|
try {
|
|
119
121
|
const container = tryGetServiceContainerFromCtx(ctx);
|
|
120
122
|
const semaphore = await getService<ResearchRunSemaphore>(ServiceNames.RESEARCH_RUN_SEMAPHORE, ctx, container);
|
|
121
|
-
runSlot = await semaphore.acquire(undefined, signal)
|
|
123
|
+
runSlot = await semaphore.acquire(undefined, signal, (slots, maxWaitMs) => {
|
|
124
|
+
// The queue notice fires BEFORE any orchestrator exists, so `observer` is
|
|
125
|
+
// still exactly what the caller passed: either a ResearchObserver, or the
|
|
126
|
+
// HeadlessObserverOptions bag (only `onProgress`) that the CLI supplies and
|
|
127
|
+
// the orchestrator would normally wrap later. Dispatch to whichever shape
|
|
128
|
+
// is present — handling only the former silently drops the notice on the
|
|
129
|
+
// CLI, which is the very front-end that most needs it.
|
|
130
|
+
notifyRunQueued(observer, slots, maxWaitMs);
|
|
131
|
+
});
|
|
122
132
|
logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
|
|
123
133
|
} catch (err) {
|
|
124
134
|
if (err instanceof ResearchRunCapacityError) throw err; // capacity exhausted → fail fast
|
|
@@ -583,3 +593,28 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
583
593
|
}
|
|
584
594
|
}
|
|
585
595
|
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Deliver the "your run is queued behind other runs" notice to whichever observer
|
|
599
|
+
* shape the caller supplied.
|
|
600
|
+
*
|
|
601
|
+
* This fires before the orchestrator normalizes the observer, so both forms are
|
|
602
|
+
* live here: a {@link ResearchObserver} instance (SDK / pi extension) or the
|
|
603
|
+
* {@link HeadlessObserverOptions} bag carrying only `onProgress` (the CLI and
|
|
604
|
+
* agent skill). A queued run is otherwise completely silent for the whole acquire
|
|
605
|
+
* wait, which is indistinguishable from a hang.
|
|
606
|
+
*
|
|
607
|
+
* Never throws: a front-end callback must not be able to fail the run it is
|
|
608
|
+
* merely reporting on.
|
|
609
|
+
*/
|
|
610
|
+
function notifyRunQueued(observer: unknown, slots: number, maxWaitMs: number): void {
|
|
611
|
+
try {
|
|
612
|
+
const obs = observer as
|
|
613
|
+
| { onRunQueued?: (s: number, w: number) => void; onProgress?: (e: string, d?: unknown) => void }
|
|
614
|
+
| undefined;
|
|
615
|
+
if (typeof obs?.onRunQueued === 'function') obs.onRunQueued(slots, maxWaitMs);
|
|
616
|
+
else if (typeof obs?.onProgress === 'function') obs.onProgress('run_queued', { slots, maxWaitMs });
|
|
617
|
+
} catch (err) {
|
|
618
|
+
logger.debug(`[ResearchOrchestrationService] run_queued notification threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
619
|
+
}
|
|
620
|
+
}
|