@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
|
@@ -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
|
}
|
|
@@ -1284,6 +1284,11 @@ var init_circuit_breaker = __esm({
|
|
|
1284
1284
|
metrics.observe("circuit_breaker_call_duration_ms", duration, { breaker: this.options.name, status: "error" });
|
|
1285
1285
|
if (this.options.isTransientError(error)) {
|
|
1286
1286
|
this.onFailure(error);
|
|
1287
|
+
} else if (admittedAsProbe && this.state === "HALF_OPEN") {
|
|
1288
|
+
logger.warn(
|
|
1289
|
+
`[CircuitBreaker] '${this.options.name}' HALF_OPEN probe failed with a non-counted error; treating as inconclusive and re-opening: ${error instanceof Error ? error.message : String(error)}`
|
|
1290
|
+
);
|
|
1291
|
+
this.transitionTo("OPEN");
|
|
1287
1292
|
}
|
|
1288
1293
|
throw error;
|
|
1289
1294
|
} finally {
|
|
@@ -1379,7 +1384,12 @@ function isServerDrainingError(error) {
|
|
|
1379
1384
|
function isPoolShutdownError(error) {
|
|
1380
1385
|
if (!error || typeof error !== "object") return false;
|
|
1381
1386
|
const err = error;
|
|
1382
|
-
return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool")
|
|
1387
|
+
return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // worker-pool-manager throws this when auto-recovery is still swapping the
|
|
1388
|
+
// pool. Its own text says "please retry", but until it was listed here no
|
|
1389
|
+
// classifier matched it, so the first attempt rethrew instead of retrying —
|
|
1390
|
+
// and auto-recovery fires only after 3 consecutive worker failures, i.e.
|
|
1391
|
+
// exactly when a burst of tasks is in flight and would all die together.
|
|
1392
|
+
err.message.includes("Worker pool is being reset"));
|
|
1383
1393
|
}
|
|
1384
1394
|
var DEFAULT_BREAKER_CONFIG, browserCircuitBreaker;
|
|
1385
1395
|
var init_browser_error_utils = __esm({
|
|
@@ -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
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { parseCitations } from '../utils/text-utils.ts';
|
|
13
|
-
import { normalizeCitations, formatCitedLinks, type GlobalCitation } from '../utils/citation-utils.ts';
|
|
13
|
+
import { normalizeCitations, formatCitedLinks, rewriteCitationMarkers, type GlobalCitation } from '../utils/citation-utils.ts';
|
|
14
14
|
import { lastCitedLinksHeaderIndex } from '../utils/text-utils.ts';
|
|
15
15
|
import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
|
|
16
16
|
import { stripTrailingLlmPunctuation } from '../utils/url-utils.ts';
|
|
@@ -33,86 +33,6 @@ const NO_SOURCES_NOTE =
|
|
|
33
33
|
const UNVERIFIED_LINKS_NOTE =
|
|
34
34
|
'_None of the links above could be verified: no page fetch succeeded this session and no citation could be matched to retrieved content. They may be inaccurate or invented — check each one before relying on it._';
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
* Largest `[N]` treated as a citation marker. Inline markers index a source list
|
|
38
|
-
* that is realistically well under this; the bound exists so an ordinary bracketed
|
|
39
|
-
* number in prose — a year (`[2023]`), an issue id, an array index — is left alone
|
|
40
|
-
* instead of being silently deleted as a "dangling citation".
|
|
41
|
-
*/
|
|
42
|
-
const MAX_PLAUSIBLE_CITATION_MARKER = 200;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Rewrite inline `[N]` citation markers in a prose body: renumber the ones we can
|
|
46
|
-
* map, delete the ones known to dangle, leave everything else untouched.
|
|
47
|
-
*
|
|
48
|
-
* Deletion is whitespace-aware rather than followed by a global space collapse.
|
|
49
|
-
* A blanket `text.replace(/ +/g, ' ')` also eats *leading* indentation, which
|
|
50
|
-
* silently flattens nested list levels into one and turns 4-space indented code
|
|
51
|
-
* blocks into paragraphs — corrupting the structure of every report that contains
|
|
52
|
-
* them. So the pad preceding a deleted marker is consumed only when the marker is
|
|
53
|
-
* mid-line (i.e. real prose spacing); line-leading indentation is preserved.
|
|
54
|
-
*
|
|
55
|
-
* Code is skipped entirely. A report may quote code containing bracketed integers
|
|
56
|
-
* (`items[12]`), and on the no-CITED-LINKS path *every* out-of-range marker is treated
|
|
57
|
-
* as dangling — so without this guard a quoted array index would be silently deleted
|
|
58
|
-
* from the user's code sample. Fenced blocks are tracked across lines; single-backtick
|
|
59
|
-
* spans are detected by an odd backtick count earlier on the same line.
|
|
60
|
-
*
|
|
61
|
-
* @param remap local marker number → verified global id
|
|
62
|
-
* @param isDangling whether a marker not present in `remap` should be deleted
|
|
63
|
-
*/
|
|
64
|
-
function rewriteCitationMarkers(
|
|
65
|
-
text: string,
|
|
66
|
-
remap: Map<number, number>,
|
|
67
|
-
isDangling: (n: number) => boolean,
|
|
68
|
-
): string {
|
|
69
|
-
const inFence = fencedCodeMask(text);
|
|
70
|
-
return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad: string, digits: string, offset: number) => {
|
|
71
|
-
const n = parseInt(digits, 10);
|
|
72
|
-
const mapped = remap.get(n);
|
|
73
|
-
if (mapped !== undefined) return `${pad}[${mapped}]`;
|
|
74
|
-
// Citation numbering is 1-based; `[0]` is always an index or a literal, never a marker.
|
|
75
|
-
if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
|
|
76
|
-
if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
|
|
77
|
-
// Preserve indentation when nothing but whitespace precedes on this line;
|
|
78
|
-
// otherwise drop the pad too so no double space is left behind.
|
|
79
|
-
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
80
|
-
return text.slice(lineStart, offset).trim() === '' ? pad : '';
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Build an `offset → is inside a ``` fenced block` predicate for `text`.
|
|
86
|
-
*
|
|
87
|
-
* Computed once per rewrite (a single line scan) rather than per marker, so marker
|
|
88
|
-
* rewriting stays linear in the body length.
|
|
89
|
-
*/
|
|
90
|
-
function fencedCodeMask(text: string): (offset: number) => boolean {
|
|
91
|
-
const ranges: Array<[number, number]> = [];
|
|
92
|
-
let open = -1;
|
|
93
|
-
let pos = 0;
|
|
94
|
-
for (const line of text.split('\n')) {
|
|
95
|
-
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
96
|
-
if (open < 0) open = pos;
|
|
97
|
-
else {
|
|
98
|
-
ranges.push([open, pos + line.length]);
|
|
99
|
-
open = -1;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
pos += line.length + 1; // +1 for the '\n' consumed by split
|
|
103
|
-
}
|
|
104
|
-
// An unterminated fence runs to the end of the body — treat the remainder as code.
|
|
105
|
-
if (open >= 0) ranges.push([open, text.length]);
|
|
106
|
-
return (offset: number) => ranges.some(([start, end]) => offset >= start && offset < end);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Whether `offset` sits inside a single-backtick code span on its own line. */
|
|
110
|
-
function inInlineCodeSpan(text: string, offset: number): boolean {
|
|
111
|
-
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
112
|
-
let ticks = 0;
|
|
113
|
-
for (let i = lineStart; i < offset; i++) if (text[i] === '`') ticks++;
|
|
114
|
-
return ticks % 2 === 1;
|
|
115
|
-
}
|
|
116
36
|
|
|
117
37
|
/**
|
|
118
38
|
* Redact inline `https?://` tokens in synthesis prose whose URL was NOT retrieved
|
|
@@ -481,7 +481,10 @@ export async function runBackgroundExtraction(
|
|
|
481
481
|
const delay = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
|
|
482
482
|
metrics.increment('research_knowledge_search_synthesis_retries_total', 1);
|
|
483
483
|
logger.warn(`[research-knowledge-search] Synthesis attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg}); retrying in ${delay}ms`);
|
|
484
|
-
|
|
484
|
+
// keepAlive=true: the retry is already committed and the next attempt must
|
|
485
|
+
// run, so this sleep is foreground work. An unref'd timer here can be the
|
|
486
|
+
// only pending handle and let the process exit mid-retry (see abortableDelay).
|
|
487
|
+
await abortableDelay(delay, signal, true);
|
|
485
488
|
}
|
|
486
489
|
}
|
|
487
490
|
|
|
@@ -625,7 +628,10 @@ export async function triageRelevantUrls(
|
|
|
625
628
|
const delay = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
|
|
626
629
|
metrics.increment('research_knowledge_search_triage_retries_total', 1);
|
|
627
630
|
logger.warn(`[research-knowledge-search] Triage attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg}); retrying in ${delay}ms`);
|
|
628
|
-
|
|
631
|
+
// keepAlive=true: the retry is already committed and the next attempt must
|
|
632
|
+
// run, so this sleep is foreground work. An unref'd timer here can be the
|
|
633
|
+
// only pending handle and let the process exit mid-retry (see abortableDelay).
|
|
634
|
+
await abortableDelay(delay, signal, true);
|
|
629
635
|
}
|
|
630
636
|
}
|
|
631
637
|
|
|
@@ -78,6 +78,26 @@ export class CircuitBreaker {
|
|
|
78
78
|
metrics.observe('circuit_breaker_call_duration_ms', duration, { breaker: this.options.name, status: 'error' });
|
|
79
79
|
if (this.options.isTransientError(error)) {
|
|
80
80
|
this.onFailure(error);
|
|
81
|
+
} else if (admittedAsProbe && this.state === 'HALF_OPEN') {
|
|
82
|
+
// The trial probe failed for a reason this breaker does not count. That is
|
|
83
|
+
// INCONCLUSIVE, not healthy — so re-open and wait out another reset window
|
|
84
|
+
// rather than staying HALF_OPEN.
|
|
85
|
+
//
|
|
86
|
+
// Without this the breaker pins permanently: onFailure is the only path to
|
|
87
|
+
// OPEN, and it is skipped for non-counted errors, while transitionTo(HALF_OPEN)
|
|
88
|
+
// never re-arms nextAttemptTime. The admission gate above then fast-fails every
|
|
89
|
+
// caller beyond halfOpenMaxCalls (default 1) forever. This is not a corner case
|
|
90
|
+
// for the browser pool: DEFAULT_BREAKER_CONFIG.isTransientError deliberately
|
|
91
|
+
// excludes pool-shutdown, draining, Cloudflare and task-timeout errors — exactly
|
|
92
|
+
// what a recovering or blocked pool returns — so the probe most likely to run is
|
|
93
|
+
// also the one most likely to pin the breaker. Observed effect: 19 of a 20-query
|
|
94
|
+
// parallel burst fast-failing instantly, surfacing to the researcher as
|
|
95
|
+
// "your query may be too narrow".
|
|
96
|
+
logger.warn(
|
|
97
|
+
`[CircuitBreaker] '${this.options.name}' HALF_OPEN probe failed with a non-counted error; ` +
|
|
98
|
+
`treating as inconclusive and re-opening: ${error instanceof Error ? error.message : String(error)}`
|
|
99
|
+
);
|
|
100
|
+
this.transitionTo('OPEN');
|
|
81
101
|
}
|
|
82
102
|
throw error;
|
|
83
103
|
} finally {
|
|
@@ -67,13 +67,28 @@ export function normalizeCitations(reports: Map<string, string>): {
|
|
|
67
67
|
const headerIdx = lastCitedLinksHeaderIndex(report);
|
|
68
68
|
let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
|
|
69
69
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
// Renumber local [N] markers to their global ids, and DELETE the ones that could
|
|
71
|
+
// not be mapped.
|
|
72
|
+
//
|
|
73
|
+
// Leaving an unmapped marker in place (the previous `return match`) was a silent
|
|
74
|
+
// misattribution bug, not a harmless no-op: global ids are assigned densely from 1,
|
|
75
|
+
// so a surviving `[1]` no longer means "this report's first source" — it resolves
|
|
76
|
+
// against the renumbered global list and points at a completely different document.
|
|
77
|
+
// Markers go unmapped whenever parseCitations dropped the entry (e.g. a URL whose
|
|
78
|
+
// hostname has no dot: `localhost`, a bare internal host, a malformed line). The
|
|
79
|
+
// result is fed straight into the evaluator/synthesis prompt, which is explicitly
|
|
80
|
+
// told the numbering is already normalized — so the model faithfully reproduces the
|
|
81
|
+
// wrong attribution, and ensureCitedLinks then regenerates a CITED LINKS list that
|
|
82
|
+
// looks perfectly consistent with it.
|
|
83
|
+
//
|
|
84
|
+
// rewriteCitationMarkers is the same routine the synthesis path already uses: it
|
|
85
|
+
// skips fenced blocks and inline code spans (so a quoted `items[12]` survives),
|
|
86
|
+
// bounds what counts as a marker, and preserves line-leading indentation.
|
|
87
|
+
content = rewriteCitationMarkers(
|
|
88
|
+
content,
|
|
89
|
+
localToGlobal,
|
|
90
|
+
(n) => !localToGlobal.has(n),
|
|
91
|
+
);
|
|
77
92
|
|
|
78
93
|
normalizedReports.set(id, content.trim());
|
|
79
94
|
}
|
|
@@ -95,3 +110,84 @@ export function formatCitedLinks(citations: GlobalCitation[]): string {
|
|
|
95
110
|
|
|
96
111
|
return `CITED LINKS\n${links.join('\n')}`;
|
|
97
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Largest `[N]` treated as a citation marker. Inline markers index a source list
|
|
116
|
+
* that is realistically well under this; the bound exists so an ordinary bracketed
|
|
117
|
+
* number in prose — a year (`[2023]`), an issue id, an array index — is left alone
|
|
118
|
+
* instead of being silently deleted as a "dangling citation".
|
|
119
|
+
*/
|
|
120
|
+
export const MAX_PLAUSIBLE_CITATION_MARKER = 200;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Rewrite inline `[N]` citation markers in a prose body: renumber the ones we can
|
|
124
|
+
* map, delete the ones known to dangle, leave everything else untouched.
|
|
125
|
+
*
|
|
126
|
+
* Deletion is whitespace-aware rather than followed by a global space collapse.
|
|
127
|
+
* A blanket `text.replace(/ +/g, ' ')` also eats *leading* indentation, which
|
|
128
|
+
* silently flattens nested list levels into one and turns 4-space indented code
|
|
129
|
+
* blocks into paragraphs — corrupting the structure of every report that contains
|
|
130
|
+
* them. So the pad preceding a deleted marker is consumed only when the marker is
|
|
131
|
+
* mid-line (i.e. real prose spacing); line-leading indentation is preserved.
|
|
132
|
+
*
|
|
133
|
+
* Code is skipped entirely. A report may quote code containing bracketed integers
|
|
134
|
+
* (`items[12]`), and on the no-CITED-LINKS path *every* out-of-range marker is treated
|
|
135
|
+
* as dangling — so without this guard a quoted array index would be silently deleted
|
|
136
|
+
* from the user's code sample. Fenced blocks are tracked across lines; single-backtick
|
|
137
|
+
* spans are detected by an odd backtick count earlier on the same line.
|
|
138
|
+
*
|
|
139
|
+
* @param remap local marker number → verified global id
|
|
140
|
+
* @param isDangling whether a marker not present in `remap` should be deleted
|
|
141
|
+
*/
|
|
142
|
+
export function rewriteCitationMarkers(
|
|
143
|
+
text: string,
|
|
144
|
+
remap: Map<number, number>,
|
|
145
|
+
isDangling: (n: number) => boolean,
|
|
146
|
+
): string {
|
|
147
|
+
const inFence = fencedCodeMask(text);
|
|
148
|
+
return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad: string, digits: string, offset: number) => {
|
|
149
|
+
const n = parseInt(digits, 10);
|
|
150
|
+
const mapped = remap.get(n);
|
|
151
|
+
if (mapped !== undefined) return `${pad}[${mapped}]`;
|
|
152
|
+
// Citation numbering is 1-based; `[0]` is always an index or a literal, never a marker.
|
|
153
|
+
if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
|
|
154
|
+
if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
|
|
155
|
+
// Preserve indentation when nothing but whitespace precedes on this line;
|
|
156
|
+
// otherwise drop the pad too so no double space is left behind.
|
|
157
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
158
|
+
return text.slice(lineStart, offset).trim() === '' ? pad : '';
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build an `offset → is inside a ``` fenced block` predicate for `text`.
|
|
164
|
+
*
|
|
165
|
+
* Computed once per rewrite (a single line scan) rather than per marker, so marker
|
|
166
|
+
* rewriting stays linear in the body length.
|
|
167
|
+
*/
|
|
168
|
+
function fencedCodeMask(text: string): (offset: number) => boolean {
|
|
169
|
+
const ranges: Array<[number, number]> = [];
|
|
170
|
+
let open = -1;
|
|
171
|
+
let pos = 0;
|
|
172
|
+
for (const line of text.split('\n')) {
|
|
173
|
+
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
174
|
+
if (open < 0) open = pos;
|
|
175
|
+
else {
|
|
176
|
+
ranges.push([open, pos + line.length]);
|
|
177
|
+
open = -1;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
pos += line.length + 1; // +1 for the '\n' consumed by split
|
|
181
|
+
}
|
|
182
|
+
// An unterminated fence runs to the end of the body — treat the remainder as code.
|
|
183
|
+
if (open >= 0) ranges.push([open, text.length]);
|
|
184
|
+
return (offset: number) => ranges.some(([start, end]) => offset >= start && offset < end);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Whether `offset` sits inside a single-backtick code span on its own line. */
|
|
188
|
+
function inInlineCodeSpan(text: string, offset: number): boolean {
|
|
189
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
190
|
+
let ticks = 0;
|
|
191
|
+
for (let i = lineStart; i < offset; i++) if (text[i] === '`') ticks++;
|
|
192
|
+
return ticks % 2 === 1;
|
|
193
|
+
}
|