@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.
@@ -22,6 +22,46 @@ import type { ISchedulerFactory, IScheduler } from '../../core/scheduler-factory
22
22
  let lastRestartTime = 0;
23
23
  const RESTART_COOLDOWN_MS = 10000;
24
24
 
25
+ /**
26
+ * Extra attempts reserved for a *leader handover* — the elected browser-pool leader
27
+ * exiting while this process still has work in flight ("Worker pool is shutting
28
+ * down" / "draining").
29
+ *
30
+ * This has its own budget because it is not a random socket blip: it is a known,
31
+ * self-healing window that lasts as long as a fresh election takes (measured at
32
+ * ~4 s on a loaded machine), and it hits *every* in-flight task of the affected
33
+ * run at once. With only the single generic retry, a whole 20-query search burst
34
+ * could be wiped out by one leader exiting — the run then failed outright with
35
+ * "all N queries encountered worker errors", seconds before the new leader came up.
36
+ * Spending these attempts is strictly cheaper than losing the run.
37
+ */
38
+ const LEADER_HANDOVER_RETRIES = 3;
39
+ /** Backoff between handover attempts — long enough for an election to complete. */
40
+ const HANDOVER_BACKOFF_MS = 1200;
41
+
42
+ /**
43
+ * Recover from a leader handover before retrying.
44
+ *
45
+ * Critically this DROPS the cached scheduler handle. A pool-shutdown error means
46
+ * the leader we are connected to is going away, so the cached client points at a
47
+ * corpse; simply waiting and retrying reconnects to the same dead port (observed
48
+ * as a retry logging "Connecting to existing scheduler" with the *same* version id
49
+ * and failing identically). Clearing it forces the next `getScheduler()` to
50
+ * re-resolve — joining the new leader, or winning the election itself.
51
+ *
52
+ * `forceSchedulerRestart(false)` keeps its own liveness probe and in-progress
53
+ * guard, so concurrent callers coalesce instead of stampeding.
54
+ */
55
+ async function recoverFromLeaderHandover(container: ServiceContainer): Promise<void> {
56
+ logger.warn('[BrowserManager] Leader handover detected (pool draining) — dropping stale scheduler handle and re-resolving...');
57
+ await forceSchedulerRestart(false, container).catch((err) =>
58
+ logger.debug('[BrowserManager] Scheduler re-resolve during handover failed (non-fatal):', err),
59
+ );
60
+ await waitForBrowserPoolIdle(15000).catch((err) =>
61
+ logger.debug('Wait for browser idle timed out or failed:', err),
62
+ );
63
+ }
64
+
25
65
  /**
26
66
  * Get the scheduler from the factory, optionally using a specific container.
27
67
  */
@@ -56,7 +96,8 @@ export async function runBrowserTask<T>(
56
96
  config?: Config,
57
97
  signal?: AbortSignal,
58
98
  retries = 1,
59
- container: ServiceContainer = getServiceContainer()
99
+ container: ServiceContainer = getServiceContainer(),
100
+ handoverRetries = LEADER_HANDOVER_RETRIES,
60
101
  ): Promise<T> {
61
102
  if (signal?.aborted) throw new Error('Aborted');
62
103
 
@@ -84,6 +125,23 @@ export async function runBrowserTask<T>(
84
125
  } catch (error: unknown) {
85
126
  if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
86
127
 
128
+ // A leader handover gets its own budget: it is a recoverable, seconds-long
129
+ // window that strikes every in-flight task at once, so it must not be
130
+ // charged against (or limited by) the single generic transient retry.
131
+ const isHandover = isPoolShutdownError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error);
132
+ if (isHandover && handoverRetries > 0) {
133
+ errorTracker.trackError(error, {
134
+ component: 'browser-manager',
135
+ operation: type,
136
+ taskType: type,
137
+ errorType: 'leader_handover',
138
+ });
139
+ logger.warn(`[BrowserManager] Leader handover during ${type} task (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
140
+ await recoverFromLeaderHandover(container);
141
+ await new Promise((resolve) => setTimeout(resolve, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
142
+ return runBrowserTask<T>(taskOrUrl, type, config, signal, retries, container, handoverRetries - 1);
143
+ }
144
+
87
145
  if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
88
146
  errorTracker.trackError(error, {
89
147
  component: 'browser-manager',
@@ -92,9 +150,9 @@ export async function runBrowserTask<T>(
92
150
  errorType: 'transient_socket_error',
93
151
  });
94
152
  logger.warn(`[BrowserManager] Transient socket error during ${type} task (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
95
-
153
+
96
154
  if (isPoolShutdownError(error)) {
97
- // Pool is temporarily draining — wait for the drain to finish.
155
+ // Handover budget already spent — fall back to a plain drain wait.
98
156
  logger.warn(`[BrowserManager] Pool is draining — waiting for pool idle before retry...`);
99
157
  await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
100
158
  } else {
@@ -120,7 +178,7 @@ export async function runBrowserTask<T>(
120
178
  // Retry with jitter (100-500ms) to prevent thundering herd
121
179
  const jitter = 100 + Math.floor(Math.random() * 400);
122
180
  await new Promise(resolve => setTimeout(resolve, jitter));
123
- return runBrowserTask<T>(taskOrUrl, type, config, signal, retries - 1, container);
181
+ return runBrowserTask<T>(taskOrUrl, type, config, signal, retries - 1, container, handoverRetries);
124
182
  }
125
183
  throw error;
126
184
  }
@@ -143,9 +201,12 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
143
201
  errorType: 'transient_socket_error',
144
202
  });
145
203
  logger.warn(`[BrowserManager] Transient socket error during healthcheck (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
146
-
204
+
147
205
  if (isPoolShutdownError(error)) {
148
- await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
206
+ // Same stale-handle trap as the task paths: waiting alone leaves the
207
+ // cached client aimed at the departing leader, so the retry re-probes
208
+ // a corpse and reports the pool unhealthy during a routine handover.
209
+ await recoverFromLeaderHandover(container);
149
210
  } else {
150
211
  // See runBrowserTask: don't force-clear remote leader state on a bare
151
212
  // transient socket error — let the liveness probe inside
@@ -172,7 +233,7 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
172
233
  /**
173
234
  * Run a worker search query with retry logic.
174
235
  */
175
- export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer()): Promise<SearchResult[]> {
236
+ export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES): Promise<SearchResult[]> {
176
237
  if (signal?.aborted) throw new Error('Aborted');
177
238
 
178
239
  const breaker = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
@@ -186,6 +247,22 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
186
247
  } catch (error: unknown) {
187
248
  if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
188
249
 
250
+ // See runBrowserTask: a leader handover wipes out an entire search burst at
251
+ // once, so it gets its own attempts rather than the single generic retry.
252
+ const isHandover = isPoolShutdownError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error);
253
+ if (isHandover && handoverRetries > 0) {
254
+ errorTracker.trackError(error, {
255
+ component: 'browser-manager',
256
+ operation: 'search',
257
+ query,
258
+ errorType: 'leader_handover',
259
+ });
260
+ logger.warn(`[BrowserManager] Leader handover during search (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
261
+ await recoverFromLeaderHandover(container);
262
+ await new Promise((resolve) => setTimeout(resolve, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
263
+ return runWorkerSearch(query, config, signal, retries, sessionId, container, handoverRetries - 1);
264
+ }
265
+
189
266
  if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
190
267
  errorTracker.trackError(error, {
191
268
  component: 'browser-manager',
@@ -194,7 +271,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
194
271
  errorType: 'transient_socket_error',
195
272
  });
196
273
  logger.warn(`[BrowserManager] Transient socket error during search (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
197
-
274
+
198
275
  if (isPoolShutdownError(error)) {
199
276
  await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
200
277
  } else {
@@ -214,7 +291,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
214
291
 
215
292
  const jitter = 100 + Math.floor(Math.random() * 400);
216
293
  await new Promise(resolve => setTimeout(resolve, jitter));
217
- return runWorkerSearch(query, config, signal, retries - 1, sessionId, container);
294
+ return runWorkerSearch(query, config, signal, retries - 1, sessionId, container, handoverRetries);
218
295
  }
219
296
  throw error;
220
297
  }
@@ -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, then rejects with
25
- * {@link ResearchRunCapacityError} (fail-fast with a clear message rather than
26
- * a 60-error degraded run).
24
+ * `pollDelayMs` and retries until `maxWaitMs` elapses (by default
25
+ * {@link DEFAULT_ACQUIRE_TIMEOUT_MS} — extra runs **queue** behind the in-flight
26
+ * ones instead of failing), then rejects with {@link ResearchRunCapacityError}
27
+ * rather than degrading into a 60-error run.
27
28
  * - **Release** unlinks the slot file via the captured slot service.
28
29
  * - **Crash recovery** is automatic and PID-reuse-safe: a slot held by a process
29
30
  * that has died (killed, crashed, SIGKILLed by earlyoom) is reclaimed
@@ -69,9 +70,21 @@ import type { IService } from '../core/service-registry.ts';
69
70
  import type { IProcessLifecycle } from '../core/interfaces/process-interfaces.ts';
70
71
 
71
72
  /** Default maximum number of concurrent research runs across all processes. */
72
- export const DEFAULT_MAX_CONCURRENT_RUNS = 2;
73
- /** Default per-acquire wait before failing fast when all slots are busy (0 = fail-fast). */
74
- export const DEFAULT_ACQUIRE_TIMEOUT_MS = 0;
73
+ export const DEFAULT_MAX_CONCURRENT_RUNS = 3;
74
+ /**
75
+ * Default per-acquire wait before giving up when all slots are held by live runs.
76
+ *
77
+ * This is deliberately a *queue*, not a fail-fast. The cap exists to stop N runs
78
+ * saturating the shared browser/embedding pool — but an extra run is a legitimate
79
+ * request to do work, not an error, so the correct response to contention is to
80
+ * serialize it behind the in-flight runs rather than to fail it. With a depth-1 run
81
+ * measured at ~2-3 minutes, 10 minutes absorbs a backlog of roughly three further
82
+ * runs per slot while still resolving well inside the agent-skill's documented
83
+ * command timeouts (so a queued run never looks like a hang to the caller).
84
+ *
85
+ * Set `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS=0` to restore strict fail-fast.
86
+ */
87
+ export const DEFAULT_ACQUIRE_TIMEOUT_MS = 600_000;
75
88
  /** Poll interval between full slot sweeps while waiting for a slot. */
76
89
  const POLL_DELAY_MS = 250;
77
90
 
@@ -84,9 +97,10 @@ export class ResearchRunCapacityError extends Error {
84
97
  readonly slots: number;
85
98
  constructor(slots: number, maxWaitMs: number) {
86
99
  super(
87
- `Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` after waiting ${maxWaitMs}ms` : ''}; ` +
88
- `wait for an in-progress run to finish and retry. ` +
89
- `(Override with PI_RESEARCH_MAX_CONCURRENT_RUNS.)`,
100
+ `Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` and still busy after queueing for ${Math.round(maxWaitMs / 1000)}s` : ''}; ` +
101
+ `another run is still in progress. This is temporary — retry shortly. ` +
102
+ `(Raise the cap with PI_RESEARCH_MAX_CONCURRENT_RUNS, or the queue wait with ` +
103
+ `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS.)`,
90
104
  );
91
105
  this.name = 'ResearchRunCapacityError';
92
106
  this.slots = slots;
@@ -209,10 +223,17 @@ export class ResearchRunSemaphore implements IService {
209
223
  * @param signal aborts the wait. Without it a run started under a long
210
224
  * `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` would ignore Ctrl-C until the deadline
211
225
  * passed, because the wait happens before any orchestrator exists to cancel.
226
+ * @param onQueued invoked at most once, when the first full sweep finds every
227
+ * slot busy and the call is about to wait. Lets a front-end tell the user the
228
+ * run is queued rather than hung. Never allowed to break the acquire.
212
229
  * @throws {ResearchRunCapacityError} if no slot frees within `maxWaitMs`.
213
230
  * @throws {Error} named `AbortError` if `signal` aborts while waiting.
214
231
  */
215
- async acquire(maxWaitMs: number = this.defaultMaxWaitMs, signal?: AbortSignal): Promise<RunSlotAcquisition> {
232
+ async acquire(
233
+ maxWaitMs: number = this.defaultMaxWaitMs,
234
+ signal?: AbortSignal,
235
+ onQueued?: (slots: number, maxWaitMs: number) => void,
236
+ ): Promise<RunSlotAcquisition> {
216
237
  if (!this._initialized) await this.initialize();
217
238
  if (signal?.aborted) {
218
239
  metrics.increment('research_run_acquire_total', 1, { status: 'aborted' });
@@ -220,6 +241,7 @@ export class ResearchRunSemaphore implements IService {
220
241
  }
221
242
 
222
243
  const deadline = Date.now() + Math.max(0, maxWaitMs);
244
+ let announcedQueued = false;
223
245
  for (;;) {
224
246
  // Round-robin from a rotating start so contending processes don't synchronize on slot 0.
225
247
  const start = this.nextStartIndex;
@@ -238,6 +260,17 @@ export class ResearchRunSemaphore implements IService {
238
260
  metrics.increment('research_run_acquire_total', 1, { status: 'exhausted' });
239
261
  throw new ResearchRunCapacityError(this.maxSlots, maxWaitMs);
240
262
  }
263
+ // Announce the queue exactly once, on the first sweep that found nothing free.
264
+ // A throwing front-end callback must never fail the acquire.
265
+ if (!announcedQueued) {
266
+ announcedQueued = true;
267
+ logger.info(`[ResearchRunSemaphore] All ${this.maxSlots} run slot(s) busy — queueing for up to ${maxWaitMs}ms.`);
268
+ try {
269
+ onQueued?.(this.maxSlots, maxWaitMs);
270
+ } catch (err) {
271
+ logger.debug(`[ResearchRunSemaphore] onQueued callback threw: ${err instanceof Error ? err.message : String(err)}`);
272
+ }
273
+ }
241
274
  // Count the aborted outcome too, so acquired + exhausted + aborted accounts for
242
275
  // every acquire attempt. A cancel that vanished from the counter would make the
243
276
  // capacity metrics quietly under-report demand.
@@ -95,6 +95,10 @@ export class HeadlessObserver implements ResearchObserver {
95
95
  this.emit('start', { query, complexity });
96
96
  }
97
97
 
98
+ onRunQueued(slots: number, maxWaitMs: number): void {
99
+ this.emit('run_queued', { slots, maxWaitMs });
100
+ }
101
+
98
102
  onPlanningStart(attempt: number): void {
99
103
  this.emit('planning_start', { attempt });
100
104
  }
@@ -108,17 +108,27 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
108
108
 
109
109
  const researchStart = Date.now();
110
110
 
111
- // Cross-process run-cap (Phase 1): acquire one of N slots so concurrent
112
- // research runs can't saturate the shared leader-elected browser/embedding
113
- // pool and degrade into ~60-error failures. Fail-OPEN on internal/IO errors
114
- // (a semaphore bug must never break research); only a genuine
115
- // ResearchRunCapacityError (all slots held by live processes) propagates,
116
- // failing the run fast with a clear message. The slot is released in finally.
111
+ // Cross-process run-cap: acquire one of N slots so concurrent research runs
112
+ // can't saturate the shared leader-elected browser/embedding pool and degrade
113
+ // into ~60-error failures. Over-cap runs QUEUE for a slot (announced once via
114
+ // the observer) rather than failing, because an extra run is a legitimate
115
+ // request to do work, not an error. Fail-OPEN on internal/IO errors (a
116
+ // semaphore bug must never break research); only a genuine
117
+ // ResearchRunCapacityError — nothing freed within the whole queue window —
118
+ // propagates. The slot is released in finally.
117
119
  let runSlot: { slotIndex: number; release(): Promise<void> } | null = null;
118
120
  try {
119
121
  const container = tryGetServiceContainerFromCtx(ctx);
120
122
  const semaphore = await getService<ResearchRunSemaphore>(ServiceNames.RESEARCH_RUN_SEMAPHORE, ctx, container);
121
- runSlot = await semaphore.acquire(undefined, signal);
123
+ runSlot = await semaphore.acquire(undefined, signal, (slots, maxWaitMs) => {
124
+ // The queue notice fires BEFORE any orchestrator exists, so `observer` is
125
+ // still exactly what the caller passed: either a ResearchObserver, or the
126
+ // HeadlessObserverOptions bag (only `onProgress`) that the CLI supplies and
127
+ // the orchestrator would normally wrap later. Dispatch to whichever shape
128
+ // is present — handling only the former silently drops the notice on the
129
+ // CLI, which is the very front-end that most needs it.
130
+ notifyRunQueued(observer, slots, maxWaitMs);
131
+ });
122
132
  logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
123
133
  } catch (err) {
124
134
  if (err instanceof ResearchRunCapacityError) throw err; // capacity exhausted → fail fast
@@ -583,3 +593,28 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
583
593
  }
584
594
  }
585
595
  }
596
+
597
+ /**
598
+ * Deliver the "your run is queued behind other runs" notice to whichever observer
599
+ * shape the caller supplied.
600
+ *
601
+ * This fires before the orchestrator normalizes the observer, so both forms are
602
+ * live here: a {@link ResearchObserver} instance (SDK / pi extension) or the
603
+ * {@link HeadlessObserverOptions} bag carrying only `onProgress` (the CLI and
604
+ * agent skill). A queued run is otherwise completely silent for the whole acquire
605
+ * wait, which is indistinguishable from a hang.
606
+ *
607
+ * Never throws: a front-end callback must not be able to fail the run it is
608
+ * merely reporting on.
609
+ */
610
+ function notifyRunQueued(observer: unknown, slots: number, maxWaitMs: number): void {
611
+ try {
612
+ const obs = observer as
613
+ | { onRunQueued?: (s: number, w: number) => void; onProgress?: (e: string, d?: unknown) => void }
614
+ | undefined;
615
+ if (typeof obs?.onRunQueued === 'function') obs.onRunQueued(slots, maxWaitMs);
616
+ else if (typeof obs?.onProgress === 'function') obs.onProgress('run_queued', { slots, maxWaitMs });
617
+ } catch (err) {
618
+ logger.debug(`[ResearchOrchestrationService] run_queued notification threw: ${err instanceof Error ? err.message : String(err)}`);
619
+ }
620
+ }
@@ -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
- await abortableDelay(delay, signal);
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
- await abortableDelay(delay, signal);
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
- // Replace [N] with [GlobalID]
71
- // Also handle [N][M] combinations
72
- content = content.replace(/\[(\d+)\]/g, (match, p1) => {
73
- const localId = parseInt(p1, 10);
74
- const globalId = localToGlobal.get(localId);
75
- return globalId !== undefined ? `[${globalId}]` : match;
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
+ }