@lincoln504/pi-research 1.5.0 → 1.5.1

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.
Files changed (34) hide show
  1. package/dist/cli.mjs +210 -86
  2. package/dist/thread-worker.mjs +16 -3
  3. package/docs/CHANGELOG.md +87 -2
  4. package/docs/CONFIGURATION.md +1 -1
  5. package/package.json +3 -2
  6. package/skills/pi-research/SKILL.md +1 -1
  7. package/src/cli.ts +1 -1
  8. package/src/config.ts +5 -0
  9. package/src/core/interfaces/state-manager-interfaces.ts +8 -0
  10. package/src/core/planning-service.ts +27 -3
  11. package/src/infrastructure/browser/thread-worker-browser.ts +10 -0
  12. package/src/infrastructure/browser/thread-worker-messaging.ts +15 -2
  13. package/src/infrastructure/browser/thread-worker.mjs +16 -3
  14. package/src/infrastructure/embedding/embedding-factory.ts +34 -0
  15. package/src/infrastructure/embedding/embedding-server.ts +5 -0
  16. package/src/infrastructure/state/state-manager.ts +15 -0
  17. package/src/knowledge/embedder.ts +36 -7
  18. package/src/knowledge/store-operations.ts +47 -38
  19. package/src/knowledge/store.ts +26 -4
  20. package/src/knowledge/writer-queue.ts +4 -2
  21. package/src/orchestration/deep-research-orchestrator.ts +15 -3
  22. package/src/orchestration/quick-research-orchestrator.ts +8 -1
  23. package/src/orchestration/research-orchestration-service.ts +21 -2
  24. package/src/orchestration/research-synthesis-service.ts +8 -4
  25. package/src/orchestration/researcher-executor.ts +15 -1
  26. package/src/orchestration/session-state.ts +17 -0
  27. package/src/sdk.ts +59 -52
  28. package/src/stackexchange/index.ts +8 -0
  29. package/src/tools/health-tool-definition.ts +11 -1
  30. package/src/tools/research-knowledge-search.ts +23 -8
  31. package/src/tools/stackexchange.ts +14 -7
  32. package/src/utils/llm-usage.ts +4 -1
  33. package/src/web-research/scraper-utils.ts +6 -2
  34. package/src/youtube/potoken.ts +20 -2
@@ -1571,6 +1571,8 @@ function isBenignScrapeFailure(error) {
1571
1571
  msg.startsWith("Fetch returned stub:") || // nav-only / near-empty page
1572
1572
  msg.startsWith("Could not extract content from PDF") || // malformed/encrypted/scanned-only PDF
1573
1573
  msg.startsWith("PDF too large") || // exceeds the deliberate 100MB policy cap
1574
+ msg.startsWith("HTML response too large") || // fetch-layer 25MB policy cap — same class as the PDF cap
1575
+ msg.startsWith("Browser HTML too large") || // browser-layer 25MB policy cap
1574
1576
  /^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
1575
1577
  isCloudflareBlockError(error) || isTaskTimeoutError(error) || isPoolShutdownError(error);
1576
1578
  }
@@ -2190,7 +2192,7 @@ async function extractSearchResults(page) {
2190
2192
  try {
2191
2193
  const u = new URL(url);
2192
2194
  const uddg = u.searchParams.get("uddg");
2193
- if (uddg) url = decodeURIComponent(uddg || "");
2195
+ if (uddg) url = uddg;
2194
2196
  } catch {
2195
2197
  }
2196
2198
  let urlHostname = "";
@@ -2374,7 +2376,8 @@ async function executeScrapeTask(_context, url, signal) {
2374
2376
  }
2375
2377
  const contentType = await response?.headerValue("content-type") || "";
2376
2378
  const status = response?.status?.() ?? 0;
2377
- if (contentType.includes("application/pdf") || url.toLowerCase().endsWith(".pdf")) {
2379
+ const finalUrl = String(response?.url?.() ?? url);
2380
+ if (contentType.includes("application/pdf") || finalUrl.toLowerCase().endsWith(".pdf")) {
2378
2381
  if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
2379
2382
  if (status >= 400) throw new Error(`HTTP ${status}`);
2380
2383
  const declaredLength = parseInt(await response.headerValue("content-length").catch(() => null) || "", 10);
@@ -2805,7 +2808,17 @@ async function initBrowser() {
2805
2808
  // silent either, so the mute is applied unconditionally.
2806
2809
  firefox_user_prefs: {
2807
2810
  "media.volume_scale": "0.0",
2808
- "media.autoplay.default": 5
2811
+ "media.autoplay.default": 5,
2812
+ // SSRF: WebSocket connections bypass BOTH layers of the browser
2813
+ // scrape defense — page.route('**') intercepts HTTP(S) only, and
2814
+ // the serverAddr() rebinding backstop hangs off page.on('response'),
2815
+ // which a WS handshake never fires. A hostile scraped page could
2816
+ // therefore `new WebSocket('ws://10.x.x.x/...')` to probe or reach
2817
+ // internal hosts with no validation, and write any reply into the
2818
+ // DOM that page.content() returns. The scraper extracts static
2819
+ // content (domcontentloaded), so nothing it needs arrives over WS —
2820
+ // disable the channel outright at the engine level.
2821
+ "network.websocket.max-connections": 0
2809
2822
  }
2810
2823
  });
2811
2824
  launchPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser launch rejection: ${err.message}`));
@@ -162,6 +162,31 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
162
162
  // read → the entry omits it and liveness falls back to a bare signal-0 check.
163
163
  const myStartTime = await processLifecycle.getCurrentProcessStartTime();
164
164
 
165
+ // Read-only state (a NEWER build's state file is on disk — mixed-version
166
+ // window): every state write is suppressed, so the port=-1 candidacy claim
167
+ // below would never be visible to other processes, every process would
168
+ // "win" its own election, and the GPU lock would likewise always read as
169
+ // unowned — the exact concurrent-GPU-init segfault class election + lock
170
+ // exist to prevent. Degrade to a coordination-free mode instead: no
171
+ // election, local in-process embedder forced onto CPU (concurrent CPU
172
+ // inference across processes is safe; the exclusion protects GPU/WebGPU
173
+ // contexts, and a CPU device never touches the GPU lock). Slower embedding
174
+ // for the mixed-version window only; normal service resumes once every
175
+ // session runs the same build.
176
+ const localCpuForReadOnlyState = (): IEmbedder => {
177
+ logger.warn(
178
+ '[EmbeddingFactory] State file is read-only (written by a newer build): cross-process leader election and the GPU lock cannot coordinate in this mode. Skipping election and embedding locally on CPU until all sessions run the same build.',
179
+ );
180
+ const embedder = buildInProcessEmbedder({ ...cfg, EMBEDDING_DEVICE: 'cpu' }, stateManager);
181
+ _embeddingInstance = embedder;
182
+ _cachedModel = cfg.EMBEDDING_MODEL;
183
+ _cachedDevice = cfg.EMBEDDING_DEVICE;
184
+ return embedder;
185
+ };
186
+ if (stateManager.isReadOnly?.()) {
187
+ return localCpuForReadOnlyState();
188
+ }
189
+
165
190
  // ---- Phase 1: Atomically claim candidacy under the state lock ----
166
191
  // Writing port=-1 acts as a mutex: other processes that see it will wait
167
192
  // for the real port rather than starting their own model initialization.
@@ -200,6 +225,15 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
200
225
  return state;
201
226
  });
202
227
 
228
+ // Re-check AFTER the claim: the read-only flag is set lazily by the first
229
+ // read that encounters the newer file — which can be this very updateState.
230
+ // In that case iAmCandidate is true but the claim write was silently
231
+ // suppressed: no other process can see it, so proceeding as leader is the
232
+ // concurrent-election hazard the pre-check above exists to avoid.
233
+ if (iAmCandidate && stateManager.isReadOnly?.()) {
234
+ return localCpuForReadOnlyState();
235
+ }
236
+
203
237
  // A live leader serving a DIFFERENT model must not be adopted: two sessions
204
238
  // with different EMBEDDING_MODELs would cross-embed (same-dimension models
205
239
  // corrupt the vector space silently). Fall back to a local in-process
@@ -350,6 +350,11 @@ export class EmbeddingServer implements IEmbedder {
350
350
  // stuck native thread (itself hanging) or touch a device-lost context. Clearing
351
351
  // the state registration + closing the HTTP server is enough for re-election;
352
352
  // the wedged native resources are abandoned to eventual process exit.
353
+ // Latch the wedge on the Embedder itself too: it stays registered for
354
+ // process-exit cleanup (registerGlobalEmbedder), and without the latch that
355
+ // exit task's dispose() would call pipeline.dispose() on the wedged session
356
+ // anyway — re-creating at teardown the exact hang this path avoids.
357
+ this.embedder.markPipelineWedged();
353
358
  void this.shutdown(false);
354
359
  this.externalOnPoison?.(err);
355
360
  }
@@ -198,6 +198,21 @@ export class StateManager {
198
198
  };
199
199
  }
200
200
 
201
+ /**
202
+ * True once this process has seen a newer-build state file and entered
203
+ * read-only mode (writes suppressed, reads served from an in-memory default).
204
+ * In this mode NO cross-process coordination that lives in the state file
205
+ * works: a GPU-lock acquire always sees "no owner" and the embedding
206
+ * leader-election CAS always sees an empty slot, so every read-only process
207
+ * would self-elect — the concurrent-GPU-init class those mechanisms exist to
208
+ * prevent. Consumers that rely on that exclusion (the embedding factory) must
209
+ * check this and degrade to a coordination-free mode (local CPU embedding).
210
+ * The flag is set lazily by the first read that encounters the newer file.
211
+ */
212
+ isReadOnly(): boolean {
213
+ return this.stateTooNew;
214
+ }
215
+
201
216
  // ==================== Core State Operations ====================
202
217
 
203
218
  /**
@@ -50,6 +50,14 @@ export class Embedder {
50
50
  private initializingPromise: Promise<void> | null = null;
51
51
  private disposePromise: Promise<void> | null = null;
52
52
  private recoveryPromise: Promise<void> | null = null;
53
+ // Latched by the SerialQueue poison path (via EmbeddingServer): a native
54
+ // inference is permanently hung inside this pipeline. dispose() must then
55
+ // never call pipeline.dispose() — it would join the stuck native thread, the
56
+ // exact hazard the poison path's shutdown(false) avoided — yet this instance
57
+ // stays registered for process-exit cleanup (registerGlobalEmbedder), whose
58
+ // task calls dispose() unconditionally. Without the latch the teardown path
59
+ // re-created the hang the step-down deliberately abandoned.
60
+ private pipelineWedged = false;
53
61
 
54
62
  private model: string;
55
63
  private poolingMode: 'mean' | 'cls' | 'last_token';
@@ -656,6 +664,15 @@ export class Embedder {
656
664
  }
657
665
  }
658
666
 
667
+ /**
668
+ * Mark this embedder's pipeline as permanently wedged (a native inference hung
669
+ * past the SerialQueue hard deadline). From here on, dispose() abandons the
670
+ * pipeline instead of calling pipeline.dispose() — see pipelineWedged.
671
+ */
672
+ markPipelineWedged(): void {
673
+ this.pipelineWedged = true;
674
+ }
675
+
659
676
  /**
660
677
  * @param reason `'idle'` for the memory-reclaim pause driven by the idle timer —
661
678
  * a later embed is allowed to revive the embedder. Anything else is terminal
@@ -678,9 +695,13 @@ export class Embedder {
678
695
  this.disposeReason = reason;
679
696
  this.state = 'disposing';
680
697
  this.disposePromise = (async () => {
681
- // Wait for all active embeddings to complete
698
+ // Wait for all active embeddings to complete. Pointless when the pipeline
699
+ // is wedged: the hung call holds its activeEmbeddings slot forever, so the
700
+ // drain can only time out — skip straight to the (dispose-skipping)
701
+ // teardown below instead of burning the wait twice at process exit.
682
702
  const maxWaitMs = 5000;
683
703
  const drainActiveEmbeddings = async (): Promise<void> => {
704
+ if (this.pipelineWedged) return;
684
705
  const startTime = Date.now();
685
706
  while (this.activeEmbeddings > 0 && (Date.now() - startTime) < maxWaitMs) {
686
707
  await new Promise(resolve => setTimeout(resolve, 50));
@@ -709,14 +730,22 @@ export class Embedder {
709
730
  }
710
731
 
711
732
  if (this.pipeline) {
712
- try {
713
- if (typeof (this.pipeline as any).dispose === 'function') {
714
- await (this.pipeline as DisposablePipeline).dispose();
733
+ if (this.pipelineWedged) {
734
+ // See pipelineWedged's declaration: disposing a wedged ORT session
735
+ // joins the permanently hung native thread. Abandon it — the native
736
+ // resources die with process exit, which is where this path runs.
737
+ logger.warn('[embedder] Skipping pipeline.dispose(): pipeline wedged by a permanently hung inference; abandoning native resources to process exit.');
738
+ this.pipeline = null;
739
+ } else {
740
+ try {
741
+ if (typeof (this.pipeline as any).dispose === 'function') {
742
+ await (this.pipeline as DisposablePipeline).dispose();
743
+ }
744
+ } catch (err) {
745
+ logger.warn('[embedder] Error during pipeline dispose:', err);
715
746
  }
716
- } catch (err) {
717
- logger.warn('[embedder] Error during pipeline dispose:', err);
747
+ this.pipeline = null;
718
748
  }
719
- this.pipeline = null;
720
749
  }
721
750
 
722
751
  // Always release the GPU lock once on dispose (defensive: the gpuLockHeld flag
@@ -13,6 +13,52 @@ import { logger } from '../logger.ts';
13
13
  import { metrics } from '../utils/metrics.ts';
14
14
  import { StoreDocument } from '../core/interfaces/knowledge-interfaces.ts';
15
15
 
16
+ /**
17
+ * Guard against a broken embedder silently writing corrupt vectors: a
18
+ * wrong-count, empty, ragged, or NaN/Inf-poisoned result would otherwise land
19
+ * in the FixedSizeList column and surface only as an opaque Arrow error or,
20
+ * worse, as silently degraded search (NaN distances break ANN ordering). Fail
21
+ * loudly at the last gate before persistence instead. Shared by the normal
22
+ * addDocuments path and the re-embed migration (which writes vectors through
23
+ * its own table handle and previously had no guard at all).
24
+ */
25
+ export function assertValidEmbeddingVectors(
26
+ vectors: (Float32Array | number[])[],
27
+ expectedCount: number,
28
+ declaredDim: number | null | undefined,
29
+ ): void {
30
+ if (vectors.length !== expectedCount) {
31
+ throw new Error(
32
+ `[store] embedder returned ${vectors.length} vectors for ${expectedCount} documents`,
33
+ );
34
+ }
35
+ // Expected width: the embedder's declared dimension, falling back to the
36
+ // first vector's width. Every vector must match it exactly — a uniformly
37
+ // wrong width is just as corrupting as a ragged one.
38
+ const dim = declaredDim && declaredDim > 0 ? declaredDim : (vectors[0]?.length ?? 0);
39
+ if (dim === 0) {
40
+ throw new Error('[store] embedder returned empty/zero-width vectors');
41
+ }
42
+ for (let i = 0; i < vectors.length; i++) {
43
+ const v = vectors[i];
44
+ if (!v || v.length !== dim) {
45
+ throw new Error(
46
+ `[store] embedder returned a wrong-width vector at index ${i} (got ${v?.length ?? 0}, expected ${dim})`,
47
+ );
48
+ }
49
+ // A correct-width vector can still be poisoned: a WebGPU device-lost
50
+ // mid-inference, or a truncated server response coerced through
51
+ // Float32Array, yields NaN/Inf entries that pass every length check.
52
+ for (let j = 0; j < v.length; j++) {
53
+ if (!Number.isFinite(v[j]!)) {
54
+ throw new Error(
55
+ `[store] embedder returned a non-finite value (${v[j]}) at vector ${i}, position ${j}`,
56
+ );
57
+ }
58
+ }
59
+ }
60
+ }
61
+
16
62
  /**
17
63
  * Add documents to the store
18
64
  */
@@ -35,44 +81,7 @@ export async function addDocumentsToStore(
35
81
 
36
82
  try {
37
83
  const vectors = await embedder.embedMany(docs.map(d => d.text));
38
-
39
- // Guard against a broken embedder silently writing corrupt vectors: a
40
- // wrong-count, empty, or ragged result would otherwise land in the
41
- // FixedSizeList column and surface only as an opaque Arrow error or, worse,
42
- // as silently degraded search. Fail loudly here instead.
43
- if (vectors.length !== docs.length) {
44
- throw new Error(
45
- `[store] embedder returned ${vectors.length} vectors for ${docs.length} documents`,
46
- );
47
- }
48
- // Expected width: the embedder's declared dimension, falling back to the
49
- // first vector's width. Every vector must match it exactly — a uniformly
50
- // wrong width is just as corrupting as a ragged one.
51
- const declaredDim = embedder.getDimension?.();
52
- const dim = declaredDim && declaredDim > 0 ? declaredDim : (vectors[0]?.length ?? 0);
53
- if (dim === 0) {
54
- throw new Error('[store] embedder returned empty/zero-width vectors');
55
- }
56
- for (let i = 0; i < vectors.length; i++) {
57
- const v = vectors[i];
58
- if (!v || v.length !== dim) {
59
- throw new Error(
60
- `[store] embedder returned a wrong-width vector at index ${i} (got ${v?.length ?? 0}, expected ${dim})`,
61
- );
62
- }
63
- // A correct-width vector can still be poisoned: a WebGPU device-lost
64
- // mid-inference, or a truncated server response coerced through
65
- // Float32Array, yields NaN/Inf entries that pass every length check but
66
- // silently corrupt the ANN index (NaN distances break ordering). Scan
67
- // here — this is the last gate before the vector is persisted.
68
- for (let j = 0; j < v.length; j++) {
69
- if (!Number.isFinite(v[j]!)) {
70
- throw new Error(
71
- `[store] embedder returned a non-finite value (${v[j]}) at vector ${i}, position ${j}`,
72
- );
73
- }
74
- }
75
- }
84
+ assertValidEmbeddingVectors(vectors, docs.length, embedder.getDimension?.());
76
85
 
77
86
  const data = docs.map((doc, i) => ({
78
87
  vector: vectors[i]!,
@@ -22,7 +22,7 @@ import * as path from 'node:path';
22
22
  import { MigrationStrategy, MigrationResult } from './migration.ts';
23
23
  import { metrics } from '../utils/metrics.ts';
24
24
  import { createStoreTable, CURRENT_SCHEMA_VERSION } from './store-schema.ts';
25
- import { addDocumentsToStore, searchStore, findDocumentsByUrl, findRelevantUrls } from './store-operations.ts';
25
+ import { addDocumentsToStore, assertValidEmbeddingVectors, searchStore, findDocumentsByUrl, findRelevantUrls } from './store-operations.ts';
26
26
  import { isEmbedderUnreachable } from './embedder-utils.ts';
27
27
  import { ServiceLifecycle } from '../core/service-registry.ts';
28
28
  import { ServiceNames } from '../core/service-interfaces.ts';
@@ -448,7 +448,16 @@ export class KnowledgeStore implements IKnowledgeStore {
448
448
  });
449
449
 
450
450
  const texts = batchDocs.map(d => d.text);
451
- const vectors = await this.options.embedder.embedMany(texts);
451
+ // Reconnect-wrapped like the warm-up embed in openInternal: a re-embed
452
+ // migration is a LONG operation, exactly when a leader idle-dispose /
453
+ // step-down (ECONNREFUSED fast-fail) is most likely — and any throw
454
+ // here escalates through openInternal's fallback ladder to the 'backup'
455
+ // strategy, which replaces the fully-intact table with an EMPTY one.
456
+ // One transient blip must not convert "preserve my data" into that.
457
+ const vectors = await this.withEmbedderReconnect(embedder => embedder.embedMany(texts));
458
+ // Same last-gate integrity check as addDocumentsToStore: this path
459
+ // writes vectors through its own table handle and previously had none.
460
+ assertValidEmbeddingVectors(vectors, batchDocs.length, this.options.embedder.getDimension?.());
452
461
 
453
462
  const records = batchDocs.map((doc, idx) => ({
454
463
  vector: Array.from(vectors[idx]!),
@@ -1007,11 +1016,24 @@ export class KnowledgeStore implements IKnowledgeStore {
1007
1016
  // coexist, and LanceDB's fragment-scan order typically returns the OLDEST row
1008
1017
  // first — limit(1) would serve the superseded content (and feed its older
1009
1018
  // timestamp to the serve-age gate) until the next successful ingest.
1010
- const results = await table
1019
+ const whereClause = `url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`;
1020
+ let results = await table
1011
1021
  .query()
1012
- .where(`url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`)
1022
+ .where(whereClause)
1013
1023
  .limit(16)
1014
1024
  .toArray();
1025
+ if (results.length === 16) {
1026
+ // Cap hit: the OLD generation alone may have filled all 16 slots (a long
1027
+ // description chunks into many rows), leaving the newest generation
1028
+ // entirely outside the fragment-scan window — the sort below would then
1029
+ // pick "newest of the stale". Re-fetch with a bound two generations of
1030
+ // one URL cannot plausibly reach.
1031
+ results = await table
1032
+ .query()
1033
+ .where(whereClause)
1034
+ .limit(1024)
1035
+ .toArray();
1036
+ }
1015
1037
 
1016
1038
  const duration = Date.now() - startTime;
1017
1039
  metrics.observe('knowledge_store_query_duration_ms', duration, { operation: 'rebuild_document' });
@@ -181,8 +181,10 @@ export class WriterQueue implements IWriterQueue {
181
181
  };
182
182
 
183
183
  // Dedup: check if we already have this exact URL+type with identical content.
184
- // findByUrl applies scope filtering, so this only dedupes within the
185
- // current project's visible scope (local workspace + global entries).
184
+ // findByUrl applies scope filtering, so this only dedupes within the current
185
+ // scope's VISIBLE rows — in project mode that is the workspace's own rows only
186
+ // (getScopeFilter() in project mode excludes global rows), which is correct because
187
+ // project mode doesn't serve global rows either.
186
188
  // Compared against the NEWEST generation, not row order: after a failed prune
187
189
  // two generations coexist and findByUrl's order is arbitrary — matching the
188
190
  // OLD generation's hash would skip the refresh with the duplicates in place.
@@ -226,13 +226,18 @@ export class DeepResearchOrchestrator {
226
226
  // to run the SAME round again. Announcing it each time emitted `round_start`
227
227
  // 1, 2, 2, 3 — a duplicate rather than the gap the post-loop guard was added for,
228
228
  // but the same broken contract for a consumer counting rounds.
229
- if (this.currentRound > lastAnnouncedRound) {
229
+ const firstEntryThisRound = this.currentRound > lastAnnouncedRound;
230
+ if (firstEntryThisRound) {
230
231
  observer?.onRoundStart?.(this.currentRound);
231
232
  lastAnnouncedRound = this.currentRound;
232
233
  }
233
234
 
234
235
  // Check infrastructure health (advisory: logs status, never aborts the run).
235
- await orchestrationService.checkHealth(this.currentRound, researchId, ctx);
236
+ // Once per round, not per loop entry: a 'wait' retry re-enters the same
237
+ // round and must not re-probe the same infrastructure it just probed.
238
+ if (firstEntryThisRound) {
239
+ await orchestrationService.checkHealth(this.currentRound, researchId, ctx);
240
+ }
236
241
 
237
242
  // 1. Update/Generate Plan
238
243
  let plan: ResearchPlan;
@@ -254,7 +259,14 @@ export class DeepResearchOrchestrator {
254
259
  });
255
260
  } else {
256
261
  const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
257
- observer?.onEvaluationStart?.(this.currentRound);
262
+ // Same once-per-round contract as round_start above: a 'wait' retry
263
+ // re-enters this branch for the SAME round, and re-emitting
264
+ // evaluation_start per retry gave consumers counting evaluations
265
+ // duplicates (2, 2, 2 for one round-2 evaluation). The progress
266
+ // pulse below is a status update and stays per-entry.
267
+ if (firstEntryThisRound) {
268
+ observer?.onEvaluationStart?.(this.currentRound);
269
+ }
258
270
  observer?.onEvaluationProgress?.('evaluating');
259
271
  plan = await planningService.updatePlanForRound({
260
272
  sessionId: researchId,
@@ -356,6 +356,13 @@ export class QuickResearchOrchestrator {
356
356
 
357
357
  // Ensure CITED LINKS section is accurate and consistent
358
358
  result = synthesisService.ensureCitedLinks(this.options.researchId, result);
359
+ // Snapshot BEFORE steering guidance is appended: parseCitations absorbs
360
+ // any text trailing the last CITED LINKS entry into that entry's
361
+ // Description (the same mechanism that forced the weak-grounding notice
362
+ // to sit BEFORE the header), so knowledge-store descriptions must be
363
+ // parsed from the pre-steering text or a steered quick run stores the
364
+ // steering block inside the final source's description.
365
+ const citationSource = result;
359
366
 
360
367
  // Append steering guidance — only active (consumed by orchestrator) messages
361
368
  const finalSteeringMessages = getActiveSteeringMessages(this.options.sessionId);
@@ -386,7 +393,7 @@ export class QuickResearchOrchestrator {
386
393
  if (!writer) {
387
394
  logger.debug('[QuickOrchestrator] Writer queue unavailable, skipping link descriptions');
388
395
  } else {
389
- const citations = parseCitations(result);
396
+ const citations = parseCitations(citationSource);
390
397
  if (citations.length === 0) {
391
398
  logger.warn('[QuickOrchestrator] Researcher produced no parseable CITED LINKS — no descriptions stored for this session');
392
399
  }
@@ -209,8 +209,12 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
209
209
  return result;
210
210
  } catch (error) {
211
211
  const researchDuration = Date.now() - researchStart;
212
- metrics.observe('research_manager_latency_ms', researchDuration, { depth: String(depth), status: 'error', source: 'extension' });
213
- metrics.increment('research_manager_requests_total', 1, { depth: String(depth), status: 'error', source: 'extension' });
212
+ // Label a user cancel 'cancelled', not 'error' — the orchestrators' own
213
+ // research_session_duration_ms one layer down already does, and a metrics
214
+ // consumer diffing the two layers read every quick-mode cancel as an error.
215
+ const status = signal?.aborted ? 'cancelled' : 'error';
216
+ metrics.observe('research_manager_latency_ms', researchDuration, { depth: String(depth), status, source: 'extension' });
217
+ metrics.increment('research_manager_requests_total', 1, { depth: String(depth), status, source: 'extension' });
214
218
  throw error;
215
219
  } finally {
216
220
  // Always release the run-cap slot — on success, error, or abort (the
@@ -366,6 +370,15 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
366
370
 
367
371
  const researchers = plan.researchers || [];
368
372
  const active = new Set<Promise<void>>();
373
+ // Set just before each fast-stop throw below. A researcher wedged past the
374
+ // bounded abort/settle waits is ABANDONED by the stop — its promise settles
375
+ // later, after the run's single terminal callback has fired (and, on the
376
+ // tool path, after endResearchSession freed the session state). Its late
377
+ // failure must then be suppressed: recordResearcherFailure would re-create
378
+ // the just-freed session entry via getPiState's create-on-read (a
379
+ // process-lifetime leak on the SDK's random per-run sessionIds), and
380
+ // onResearcherFailure would violate the no-events-after-terminal contract.
381
+ let runTerminated = false;
369
382
  // Honour MAX_CONCURRENT_RESEARCHERS — prevents resource spikes when a plan has many researchers.
370
383
  const maxConcurrent: number = (orchestratorOptions.config as Config)?.MAX_CONCURRENT_RESEARCHERS ?? DEFAULTS.MAX_CONCURRENT_RESEARCHERS;
371
384
  const maxFailedResearchers: number = (orchestratorOptions.config as Config)?.MAX_FAILED_RESEARCHERS ?? DEFAULTS.MAX_FAILED_RESEARCHERS;
@@ -406,6 +419,7 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
406
419
  Promise.allSettled(active).then(() => undefined),
407
420
  () => logger.debug('[ResearchOrchestrationService] Fast-stop: active researchers did not settle within bound; proceeding with stop'),
408
421
  );
422
+ runTerminated = true;
409
423
  throw createResearchStopError(sessionId, researchId);
410
424
  }
411
425
 
@@ -467,6 +481,10 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
467
481
  // researcher-executor.ts.
468
482
  if (signal?.aborted || isAbortSentinel(errMsg) || container?.isDisposing || errMsg.includes('during container disposal')) {
469
483
  logger.debug(`[ResearchOrchestrationService] Researcher ${id} cancelled (aborted).`);
484
+ } else if (runTerminated) {
485
+ // Abandoned-by-fast-stop researcher settling AFTER the run's
486
+ // terminal error — see the runTerminated declaration above.
487
+ logger.debug(`[ResearchOrchestrationService] Researcher ${id} settled after the run's fast-stop terminal error; suppressing post-terminal failure recording (${errMsg}).`);
470
488
  } else {
471
489
  logger.error(`[ResearchOrchestrationService] Researcher ${id} failed: ${errMsg}`);
472
490
  // Record failure (with its root cause) for stopping logic and for the
@@ -525,6 +543,7 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
525
543
  () => logger.debug('[ResearchOrchestrationService] Fast-stop: active researchers did not settle within bound; proceeding with stop'),
526
544
  );
527
545
 
546
+ runTerminated = true;
528
547
  throw createResearchStopError(sessionId, researchId);
529
548
  }
530
549
  }
@@ -34,7 +34,7 @@ const NO_SOURCES_NOTE =
34
34
  const UNVERIFIED_LINKS_NOTE =
35
35
  '_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._';
36
36
 
37
- // Below this many verified sources, a completed report is WEAKLY grounded: it will
37
+ // Below this many cited sources, a completed report is WEAKLY grounded: it will
38
38
  // still read as a fully sourced document, but almost none of its claims can trace to
39
39
  // retrieved content — exactly the shape of a run where scrapes mostly failed (bot
40
40
  // blocks, resource starvation) or the topic had little real coverage, and the model
@@ -45,7 +45,11 @@ const UNVERIFIED_LINKS_NOTE =
45
45
  const WEAK_GROUNDING_MIN_SOURCES = 3;
46
46
 
47
47
  function weakGroundingNote(count: number): string {
48
- return `_Grounding notice: only ${count} source${count === 1 ? '' : 's'} could be verified for this report. ` +
48
+ // "cited", not "verified": since the provenance gate was removed (see the
49
+ // comment in extractGlobalCitations' caller), this count includes citations
50
+ // the model wrote whose pages were never successfully retrieved — claiming
51
+ // they were "verified" overstated what the pipeline checked.
52
+ return `_Grounding notice: only ${count} source${count === 1 ? ' is' : 's are'} cited in this report. ` +
49
53
  'Claims not attributable to the cited source' + (count === 1 ? '' : 's') +
50
54
  ' above should be treated as unverified — cross-check anything load-bearing before relying on it._';
51
55
  }
@@ -405,7 +409,7 @@ export class ResearchSynthesisService implements IService {
405
409
  // string to build knowledge-store entries — a notice placed after the
406
410
  // list polluted the stored description of the final source on precisely
407
411
  // the degraded runs the notice exists to flag.
408
- logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} verified source(s) — appending grounding notice.`);
412
+ logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} cited source(s) — appending grounding notice.`);
409
413
  return `${body}\n\n${weakGroundingNote(globalCitations.length)}\n\n${verifiedLinksSection}`;
410
414
  }
411
415
  return `${body}\n\n${verifiedLinksSection}`;
@@ -422,7 +426,7 @@ export class ResearchSynthesisService implements IService {
422
426
  if (globalCitations.length < WEAK_GROUNDING_MIN_SOURCES) {
423
427
  // Before the CITED LINKS header for the same parseCitations-absorption
424
428
  // reason as the replace path above.
425
- logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} verified source(s) — appending grounding notice.`);
429
+ logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} cited source(s) — appending grounding notice.`);
426
430
  return `${prose}\n\n${weakGroundingNote(globalCitations.length)}\n\n${verifiedLinksSection}`;
427
431
  }
428
432
  return `${prose}\n\n${verifiedLinksSection}`;
@@ -130,7 +130,21 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
130
130
  if (signal?.aborted || container?.isDisposing || stopRequested()) break;
131
131
  const delay = Math.min(1000 * Math.pow(2, linkAttempt - 2), config.RESEARCHER_MAX_RETRY_DELAY_MS);
132
132
  logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results; retry ${linkAttempt - 1}/${config.RESEARCHER_MAX_RETRIES} after ${delay}ms`);
133
- await new Promise(r => setTimeout(r, delay));
133
+ // Abortable, like every sibling sleep in this file (attempt backoff
134
+ // below, stagger in runResearchers): a plain setTimeout held a cancel
135
+ // hostage for up to RESEARCHER_MAX_RETRY_DELAY_MS. The loop-top
136
+ // abort/stop check right after this resolves handles the early wake.
137
+ await new Promise<void>((resolve) => {
138
+ const timer = setTimeout(() => {
139
+ if (signal) signal.removeEventListener('abort', onAbort);
140
+ resolve();
141
+ }, delay);
142
+ const onAbort = () => { clearTimeout(timer); resolve(); };
143
+ if (signal) {
144
+ if (signal.aborted) onAbort();
145
+ else signal.addEventListener('abort', onAbort, { once: true });
146
+ }
147
+ });
134
148
  }
135
149
  if (signal?.aborted || container?.isDisposing || stopRequested()) break;
136
150
  try {
@@ -130,6 +130,23 @@ export function addSteeringMessage(piSessionId: string | undefined, message: str
130
130
  state.steeringMessages.splice(evictIdx, 1);
131
131
  }
132
132
  }
133
+
134
+ // 'popped' entries are otherwise pruned only in endResearchSession's
135
+ // last-run-out branch, which a long-lived pi session that always has at
136
+ // least one active run never reaches — every Alt+P pop then accumulated one
137
+ // array entry forever. Bound them to the same cap here; nothing reads a
138
+ // popped message once it is popped (dedup and eviction above both skip them).
139
+ const poppedCount = state.steeringMessages.filter(m => m.status === 'popped').length;
140
+ if (poppedCount > MAX_STEERING_MESSAGES) {
141
+ let toDrop = poppedCount - MAX_STEERING_MESSAGES;
142
+ state.steeringMessages = state.steeringMessages.filter(m => {
143
+ if (m.status === 'popped' && toDrop > 0) {
144
+ toDrop--;
145
+ return false;
146
+ }
147
+ return true;
148
+ });
149
+ }
133
150
 
134
151
  const steeringMsg: SteeringMessage = {
135
152
  id: randomUUID(),