@lincoln504/pi-research 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +2599 -1268
  3. package/dist/thread-worker.mjs +224 -147
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +20 -10
  6. package/docs/CHANGELOG.md +195 -17
  7. package/docs/CONFIGURATION.md +9 -7
  8. package/docs/KNOWLEDGE-STORE.md +4 -3
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +55 -0
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/cleanup.cjs +114 -12
  14. package/scripts/prepare.cjs +15 -5
  15. package/scripts/setup.cjs +24 -4
  16. package/skills/pi-research/README.md +9 -2
  17. package/skills/pi-research/SKILL.md +6 -3
  18. package/skills/pi-research/references/configuration.md +6 -2
  19. package/skills/pi-research/scripts/run.mjs +24 -2
  20. package/src/cleanup/research-cleanup.ts +12 -4
  21. package/src/cli.ts +372 -86
  22. package/src/config.ts +134 -17
  23. package/src/constants.ts +31 -0
  24. package/src/core/interfaces/health-check-interfaces.ts +3 -39
  25. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  26. package/src/core/interfaces/planning-interfaces.ts +8 -0
  27. package/src/core/interfaces/service-names.ts +0 -1
  28. package/src/core/interfaces/state-manager-interfaces.ts +11 -3
  29. package/src/core/llm/agentic-repair.ts +14 -0
  30. package/src/core/pi-version.ts +136 -0
  31. package/src/core/planning-service.ts +43 -1
  32. package/src/core/service-initialization.ts +6 -14
  33. package/src/core/service-interfaces.ts +1 -1
  34. package/src/core/service-registry.ts +117 -8
  35. package/src/healthcheck/index.ts +7 -2
  36. package/src/index.ts +11 -18
  37. package/src/infrastructure/browser/browser-client.ts +80 -13
  38. package/src/infrastructure/browser/browser-error-utils.ts +6 -1
  39. package/src/infrastructure/browser/browser-server.ts +41 -12
  40. package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
  41. package/src/infrastructure/browser/cleanup-utils.ts +95 -9
  42. package/src/infrastructure/browser/client-agent.ts +6 -5
  43. package/src/infrastructure/browser/config.ts +14 -3
  44. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  45. package/src/infrastructure/browser/scheduler-factory.ts +44 -4
  46. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  47. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  48. package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
  49. package/src/infrastructure/browser/thread-worker-types.ts +3 -1
  50. package/src/infrastructure/browser/thread-worker.mjs +224 -147
  51. package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
  52. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  53. package/src/infrastructure/embedding/embedding-factory.ts +61 -15
  54. package/src/infrastructure/embedding/embedding-server.ts +105 -11
  55. package/src/infrastructure/file-lock-service.ts +295 -28
  56. package/src/infrastructure/knowledge-store-service.ts +48 -3
  57. package/src/infrastructure/process-lifecycle-service.ts +134 -34
  58. package/src/infrastructure/research-run-semaphore.ts +37 -3
  59. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  60. package/src/infrastructure/state/state-browser-api.ts +5 -2
  61. package/src/infrastructure/state/state-browser-manager.ts +15 -1
  62. package/src/infrastructure/state/state-manager-service.ts +5 -5
  63. package/src/infrastructure/state/state-manager.ts +34 -23
  64. package/src/infrastructure/types/state-types.ts +9 -0
  65. package/src/knowledge/chunker.ts +88 -1
  66. package/src/knowledge/embedder-init.ts +1 -1
  67. package/src/knowledge/embedder.ts +188 -46
  68. package/src/knowledge/index.ts +1 -1
  69. package/src/knowledge/store.ts +101 -21
  70. package/src/knowledge/webgpu-viability.ts +9 -4
  71. package/src/knowledge/writer-queue.ts +88 -46
  72. package/src/logger.ts +5 -1
  73. package/src/observers/research-observer-impl.ts +31 -0
  74. package/src/orchestration/abort-utils.ts +60 -0
  75. package/src/orchestration/deep-research-orchestrator.ts +25 -4
  76. package/src/orchestration/headless-observer.ts +8 -0
  77. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  78. package/src/orchestration/research-orchestration-service.ts +65 -15
  79. package/src/orchestration/research-session-service.ts +37 -11
  80. package/src/orchestration/research-synthesis-service.ts +33 -11
  81. package/src/orchestration/researcher-executor.ts +100 -20
  82. package/src/orchestration/researcher.ts +39 -20
  83. package/src/orchestration/session-state.ts +11 -0
  84. package/src/sdk.ts +125 -19
  85. package/src/security/cisa-kev.ts +10 -3
  86. package/src/security/github-advisories.ts +18 -7
  87. package/src/security/index.ts +51 -30
  88. package/src/security/nvd-client.ts +24 -8
  89. package/src/security/osv-client.ts +14 -4
  90. package/src/skill-install/skill-installer.ts +153 -11
  91. package/src/stackexchange/index.ts +20 -5
  92. package/src/stackexchange/output/table.ts +7 -2
  93. package/src/stackexchange/rest-client.ts +41 -3
  94. package/src/tools/research-knowledge-search.ts +15 -0
  95. package/src/tools/research-tool-definition.ts +8 -2
  96. package/src/tools/scrape.ts +48 -25
  97. package/src/tools/search.ts +16 -2
  98. package/src/tools/security.ts +68 -7
  99. package/src/tui/research-health.ts +18 -2
  100. package/src/utils/atomic-replace.ts +101 -0
  101. package/src/utils/circuit-breaker.ts +9 -2
  102. package/src/utils/citation-utils.ts +3 -1
  103. package/src/utils/disk-space-checker.ts +11 -6
  104. package/src/utils/http-body.ts +176 -0
  105. package/src/utils/json-utils.ts +116 -76
  106. package/src/utils/llm-usage.ts +17 -1
  107. package/src/utils/log-rotation.ts +42 -10
  108. package/src/utils/log-utils.ts +40 -0
  109. package/src/utils/metrics-summary.ts +27 -9
  110. package/src/utils/stdio-capture.ts +63 -22
  111. package/src/utils/text-utils.ts +8 -1
  112. package/src/utils/tool-usage-tracker.ts +9 -2
  113. package/src/web-research/browser-search.ts +70 -5
  114. package/src/web-research/scraper-utils.ts +13 -0
  115. package/src/web-research/search.ts +25 -6
  116. package/src/web-research/types.ts +19 -4
  117. package/src/web-research/web-scraper.ts +72 -54
  118. package/src/youtube/transcript-client.ts +5 -1
  119. package/src/core/health-check-service.ts +0 -265
@@ -16,7 +16,7 @@ interface WriterQueueOptions {
16
16
  store: {
17
17
  isStoreClosed?: () => boolean;
18
18
  findByUrl: (url: string) => Promise<StoreDocument[]>;
19
- deleteByUrlAndType: (url: string, type: string) => Promise<void>;
19
+ deleteByUrlAndType: (url: string, type: string, olderThan?: number) => Promise<void>;
20
20
  addDocuments: (docs: StoreDocument[]) => Promise<void>;
21
21
  };
22
22
  chunker: Chunker | null;
@@ -73,50 +73,58 @@ export class WriterQueue implements IWriterQueue {
73
73
  if (this.processing) return;
74
74
  this.processing = true;
75
75
 
76
- while (this.queue.length > 0) {
77
- const item = this.queue.shift()!;
78
- try {
79
- // FIX (#2): Chain per-URL to serialize concurrent ingests of the same URL.
80
- // process() already runs sequentially, so we chain only for the race case
81
- // where two enqueue() calls trigger concurrent process() calls.
82
- const urlKey = normalizeUrl(item.url);
83
- const prev = this.inflightByUrl.get(urlKey) ?? Promise.resolve();
84
- const inflight = prev.then(() => this._ingestInner(item));
85
- this.inflightByUrl.set(urlKey, inflight);
76
+ // try/finally around the whole drain: the latch and the drain notification used
77
+ // to sit on the normal exit path only. Anything escaping the loop — including
78
+ // from the catch block below, which itself awaits and logs — left `processing`
79
+ // stuck true forever: no further item would ever be ingested, drain() would never
80
+ // resolve, and dispose() would burn its full timeout on every later shutdown.
81
+ // enqueue() swallows this promise's rejection, so the wedge was silent.
82
+ try {
83
+ while (this.queue.length > 0) {
84
+ const item = this.queue.shift()!;
86
85
  try {
87
- await inflight;
88
- } finally {
89
- if (this.inflightByUrl.get(urlKey) === inflight) {
90
- this.inflightByUrl.delete(urlKey);
91
- }
92
- }
93
- } catch (err) {
94
- if (isEmbedderUnreachable(err)) {
95
- logger.warn(`[writer-queue] Embedder unreachable for ${item.url}, retrying once after 2s...`);
96
- await new Promise(r => setTimeout(r, 2000));
86
+ // FIX (#2): Chain per-URL to serialize concurrent ingests of the same URL.
87
+ // process() already runs sequentially, so we chain only for the race case
88
+ // where two enqueue() calls trigger concurrent process() calls.
89
+ const urlKey = normalizeUrl(item.url);
90
+ const prev = this.inflightByUrl.get(urlKey) ?? Promise.resolve();
91
+ const inflight = prev.then(() => this._ingestInner(item));
92
+ this.inflightByUrl.set(urlKey, inflight);
97
93
  try {
98
- await this.ingest(item);
99
- } catch (retryErr) {
100
- logger.error(`[writer-queue] Retry failed for ${item.url}, dropping:`, retryErr);
101
- metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'embedder_unreachable' });
94
+ await inflight;
95
+ } finally {
96
+ if (this.inflightByUrl.get(urlKey) === inflight) {
97
+ this.inflightByUrl.delete(urlKey);
98
+ }
99
+ }
100
+ } catch (err) {
101
+ if (isEmbedderUnreachable(err)) {
102
+ logger.warn(`[writer-queue] Embedder unreachable for ${item.url}, retrying once after 2s...`);
103
+ await new Promise(r => setTimeout(r, 2000));
104
+ try {
105
+ await this.ingest(item);
106
+ } catch (retryErr) {
107
+ logger.error(`[writer-queue] Retry failed for ${item.url}, dropping:`, retryErr);
108
+ metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'embedder_unreachable' });
109
+ }
110
+ } else if (isNoSpace(err)) {
111
+ logger.error(`[writer-queue] ENOSPC: disk full — dropping ${item.url}. Free up disk space to resume knowledge ingestion.`);
112
+ metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'enospc' });
113
+ } else {
114
+ logger.error(`[writer-queue] Failed to ingest ${item.url}:`, err);
115
+ metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'ingest_error' });
102
116
  }
103
- } else if (isNoSpace(err)) {
104
- logger.error(`[writer-queue] ENOSPC: disk full — dropping ${item.url}. Free up disk space to resume knowledge ingestion.`);
105
- metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'enospc' });
106
- } else {
107
- logger.error(`[writer-queue] Failed to ingest ${item.url}:`, err);
108
- metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'ingest_error' });
109
117
  }
110
118
  }
111
- }
119
+ } finally {
120
+ this.processing = false;
112
121
 
113
- this.processing = false;
114
-
115
- // Notify all drain callers
116
- const resolvers = [...this.drainResolvers];
117
- this.drainResolvers = [];
118
- for (const resolve of resolvers) {
119
- resolve();
122
+ // Notify all drain callers
123
+ const resolvers = [...this.drainResolvers];
124
+ this.drainResolvers = [];
125
+ for (const resolve of resolvers) {
126
+ resolve();
127
+ }
120
128
  }
121
129
  }
122
130
 
@@ -172,15 +180,28 @@ export class WriterQueue implements IWriterQueue {
172
180
  // Dedup: check if we already have this exact URL+type with identical content.
173
181
  // findByUrl applies scope filtering, so this only dedupes within the
174
182
  // current project's visible scope (local workspace + global entries).
183
+ // Compared against the NEWEST generation, not row order: after a failed prune
184
+ // two generations coexist and findByUrl's order is arbitrary — matching the
185
+ // OLD generation's hash would skip the refresh with the duplicates in place.
175
186
  const existing = await this.options.store.findByUrl(normalizedUrl);
176
187
  const sameType = existing.filter(c => c.metadata['ingestionType'] === incomingType);
177
- if (sameType.length > 0 && sameType[0]!.metadata['contentHash'] === hash) {
178
- logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) — content unchanged.`);
179
- return;
180
- }
181
-
182
188
  if (sameType.length > 0) {
183
- await this.options.store.deleteByUrlAndType(normalizedUrl, incomingType);
189
+ const newestTs = Math.max(...sameType.map(d => Number(d.timestamp) || 0));
190
+ const newest = sameType.find(d => (Number(d.timestamp) || 0) === newestTs)!;
191
+ if (newest.metadata['contentHash'] === hash) {
192
+ // Content unchanged vs the current generation — but a prior failed prune
193
+ // may still have left older generations behind, and with stable content
194
+ // this skip is the only code that ever looks at this URL again. Collect
195
+ // them now instead of preserving the duplicates forever.
196
+ if (sameType.some(d => (Number(d.timestamp) || 0) < newestTs)) {
197
+ await this.options.store.deleteByUrlAndType(normalizedUrl, incomingType, newestTs).catch((err) => {
198
+ logger.warn(`[writer-queue] Sweeping superseded duplicates for ${normalizedUrl} (${incomingType}) failed:`, err);
199
+ metrics.increment('knowledge_ingest_stale_prune_failed_total', 1);
200
+ });
201
+ }
202
+ logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) — content unchanged.`);
203
+ return;
204
+ }
184
205
  }
185
206
 
186
207
  const rawChunks = this.options.chunker
@@ -189,6 +210,10 @@ export class WriterQueue implements IWriterQueue {
189
210
 
190
211
  if (rawChunks.length === 0) return;
191
212
 
213
+ // Single timestamp shared by every chunk of this write: it is both the row's
214
+ // recency value and the boundary used to prune the rows this write replaces.
215
+ const insertedAt = Date.now();
216
+
192
217
  const docs: StoreDocument[] = rawChunks.map((chunk, i) => ({
193
218
  url: normalizedUrl,
194
219
  text: chunk.text,
@@ -203,10 +228,27 @@ export class WriterQueue implements IWriterQueue {
203
228
  chunkIndex: i,
204
229
  totalChunks: rawChunks.length,
205
230
  },
206
- timestamp: Date.now(),
231
+ timestamp: insertedAt,
207
232
  }));
208
233
 
234
+ // Write the replacement BEFORE removing what it replaces. Deleting first made
235
+ // the refresh destructive: addDocuments can fail (embedder unreachable, ENOSPC,
236
+ // store closing mid-write) and the perfectly good previous entry was already
237
+ // gone, so a failed refresh left the URL with no entry at all rather than a
238
+ // stale one. In this order a failure leaves the old entry intact and the retry
239
+ // simply tries again.
209
240
  await this.options.store.addDocuments(docs);
241
+
242
+ if (sameType.length > 0) {
243
+ // Prune only rows older than this write, so the delete cannot take the rows
244
+ // just inserted. Failing here leaves duplicate chunks — visible but harmless,
245
+ // and cleaned up by the next successful ingest of this URL — which is a far
246
+ // better outcome than throwing away a write that already succeeded.
247
+ await this.options.store.deleteByUrlAndType(normalizedUrl, incomingType, insertedAt).catch((err) => {
248
+ logger.warn(`[writer-queue] Replacement for ${normalizedUrl} (${incomingType}) is stored, but pruning the superseded chunks failed; duplicates remain until the next refresh:`, err);
249
+ metrics.increment('knowledge_ingest_stale_prune_failed_total', 1);
250
+ });
251
+ }
210
252
  }
211
253
 
212
254
  async drain(timeoutMs?: number): Promise<void> {
package/src/logger.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  safeJsonStringify,
36
36
  formatArg,
37
37
  redactSecrets,
38
+ stripTerminalEscapes,
38
39
  type LogContext,
39
40
  } from './utils/log-utils.ts';
40
41
  import { LogRotation } from './utils/log-rotation.ts';
@@ -286,7 +287,10 @@ export class Logger implements ILogger {
286
287
  // escapes control chars on its own. CodeQL (js/log-injection) StringReplaceSanitizer
287
288
  // requires the replacement to be "" (empty string) — not ' ' — per its QL definition:
288
289
  // replaces(s, "") and s.regexpMatch("\\n")
289
- const msg = message.replace(/[\r\n]/g, '');
290
+ // redactSecrets removed only the colour/style CSI subset, so private-mode
291
+ // CSI (\x1b[?25l), OSC retitling, and DCS would still reach the terminal
292
+ // here — stripTerminalEscapes drops those plus residual control bytes.
293
+ const msg = stripTerminalEscapes(message.replace(/[\r\n]/g, ''));
290
294
  const rawSid = this.sessionId ?? '';
291
295
  const sid = rawSid.replace(/[\r\n]/g, '');
292
296
  const prefix = sid ? `[${sid}] ` : '';
@@ -26,6 +26,14 @@ import {
26
26
  clearCompletedResearchers,
27
27
  } from '../tui/research-panel.ts';
28
28
 
29
+ /**
30
+ * Slice id for the run-queued placeholder box. Shown by onRunQueued while the
31
+ * run waits for a machine-wide run-cap slot (before ANY other lifecycle event)
32
+ * and removed by onStart once a slot is acquired — both quick and deep fire
33
+ * onStart before doing real work. Stable-id keyed like 'coord'/'eval'.
34
+ */
35
+ const RUN_QUEUED_SLICE_ID = 'run-queued';
36
+
29
37
  export interface ObserverContext {
30
38
  panelState: ResearchPanelState;
31
39
  debouncedRefresh: () => void;
@@ -71,6 +79,9 @@ export function createResearchObserver(
71
79
 
72
80
  return {
73
81
  onStart: (query, complexity) => {
82
+ // A run-cap slot was acquired and the run is actually starting — drop the
83
+ // queued-run placeholder (no-op when the run never queued).
84
+ removeSlice(panelState, RUN_QUEUED_SLICE_ID);
74
85
  if (complexity === 0) {
75
86
  // Truncate at a CODEPOINT boundary (Array.from iterates codepoints), not a
76
87
  // code-unit index: query.slice(0, 20) can split a surrogate pair (emoji,
@@ -91,6 +102,26 @@ export function createResearchObserver(
91
102
  debouncedRefresh();
92
103
  },
93
104
 
105
+ onRunQueued: (slots, maxWaitMs) => {
106
+ // Fired before any other lifecycle event when every machine-wide run slot
107
+ // is held and this run is about to queue (for up to maxWaitMs). Without a
108
+ // visible notice the whole wait renders as an empty panel — the exact
109
+ // "looks like a hang" condition the callback exists to prevent. Surface it
110
+ // through the same slice/status channel every other phase uses; the
111
+ // non-numeric label routes updateSliceStatus to a persistent status (no
112
+ // timed pop), and onStart removes the placeholder once the run begins.
113
+ const waitMins = Math.max(1, Math.ceil(maxWaitMs / 60_000));
114
+ addSlice(panelState, RUN_QUEUED_SLICE_ID, 'queued', false);
115
+ activateSlice(panelState, RUN_QUEUED_SLICE_ID);
116
+ updateSliceStatus(
117
+ panelState,
118
+ RUN_QUEUED_SLICE_ID,
119
+ `waiting for a free run slot (all ${slots} in use, up to ${waitMins}m)`,
120
+ debouncedRefresh,
121
+ );
122
+ debouncedRefresh();
123
+ },
124
+
94
125
  onPlanningStart: (attempt) => {
95
126
  if (attempt === 1) {
96
127
  addSlice(panelState, 'coord', `coordinator`, false);
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Abort/Teardown Utilities
3
+ *
4
+ * Shared by the orchestration layer for two teardown concerns:
5
+ * - Classifying the "Aborted" sentinel consistently everywhere it is consumed.
6
+ * - Bounding waits on session aborts, which may never settle.
7
+ */
8
+
9
+ /**
10
+ * Upper bound for awaiting a session abort during cleanup/teardown.
11
+ * session.abort() awaits the very in-flight request it is cancelling, which can
12
+ * be wedged (e.g. a provider retry loop against an exhausted quota) and never
13
+ * settle — so no teardown path may await it unbounded.
14
+ */
15
+ export const SESSION_ABORT_SETTLE_TIMEOUT_MS = 10_000;
16
+
17
+ /**
18
+ * True when an error message is the abort sentinel. Two forms exist and must
19
+ * classify identically:
20
+ * - bare 'Aborted' — thrown by the orchestrators' local abort races, and
21
+ * - '<researcher-id>: Aborted' — thrown by ensureAssistantResponse
22
+ * (text-utils.ts) when an externally-aborted pi session resolves prompt()
23
+ * normally with no salvageable text (the fast-stop abortAllSessions path).
24
+ * An exact-equality check misses the prefixed form, which made the executor
25
+ * RETRY a researcher the fast-stop path had just aborted: fresh session, fresh
26
+ * search/scrape burst, sessions re-registered after cleanup already ran.
27
+ */
28
+ export function isAbortSentinel(message: string): boolean {
29
+ return /(^|: )Aborted$/.test(message);
30
+ }
31
+
32
+ /**
33
+ * Await an abort (which must never reject — callers attach their own .catch)
34
+ * for at most `timeoutMs`, then proceed with teardown. When the bound fires,
35
+ * `onTimeout` runs (callers log there); the abandoned abort keeps draining in
36
+ * the background and dies with the run's teardown. The timer never keeps the
37
+ * process alive, and is cleared when the abort settles first so it cannot fire
38
+ * a stale onTimeout later.
39
+ */
40
+ export async function boundSessionAbort(
41
+ abort: Promise<void>,
42
+ onTimeout: () => void,
43
+ timeoutMs: number = SESSION_ABORT_SETTLE_TIMEOUT_MS,
44
+ ): Promise<void> {
45
+ let timer: NodeJS.Timeout | undefined;
46
+ try {
47
+ await Promise.race([
48
+ abort,
49
+ new Promise<void>((resolve) => {
50
+ timer = setTimeout(() => {
51
+ onTimeout();
52
+ resolve();
53
+ }, timeoutMs);
54
+ timer.unref?.();
55
+ }),
56
+ ]);
57
+ } finally {
58
+ if (timer) clearTimeout(timer);
59
+ }
60
+ }
@@ -7,10 +7,9 @@
7
7
  import type { ExtensionContext, AgentToolResult } from '@earendil-works/pi-coding-agent';
8
8
  import { type Model } from '@earendil-works/pi-ai';
9
9
  import { logger } from '../logger.ts';
10
- import { safeUnref } from '../utils/safe-unref.ts';
11
10
  import { metrics } from '../utils/metrics.ts';
12
11
  import { getSteeringMessages, consumeQueuedMessages, getActiveSteeringMessages, getFailedResearchers, getResearcherFailureReasons } from './session-state.ts';
13
- import { MAX_EXTRA_ROUNDS_WITH_STEERING } from '../constants.ts';
12
+ import { MAX_EXTRA_ROUNDS_WITH_STEERING, resolveExcludedTools } from '../constants.ts';
14
13
  import {
15
14
  getMaxRounds,
16
15
  } from '../core/planning-utils.ts';
@@ -218,6 +217,17 @@ export class DeepResearchOrchestrator {
218
217
  excludeTools: this.options.excludeTools,
219
218
  steeringMessages: steeringTexts,
220
219
  });
220
+ } else if (this.currentRound >= maxRounds) {
221
+ // Last iteration: the `plan.action === 'synthesize' || currentRound >=
222
+ // maxRounds` break below fires no matter what the evaluator decides, and
223
+ // the forced-synthesis path after the loop runs updatePlanForRound again
224
+ // with mustSynthesize. So on every capped run whose evaluator would have
225
+ // said `delegate` — the common case, since it is being cut off mid-plan —
226
+ // this call is a second full-context coordinator request whose result is
227
+ // discarded. Break straight to the forced synthesis: exactly one
228
+ // coordinator call either way, and the same outcome.
229
+ logger.log(`[DeepOrchestrator] Round cap (${maxRounds}) reached — skipping the evaluator and going straight to final synthesis ${this.elapsed()}`);
230
+ break;
221
231
  } else {
222
232
  const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
223
233
  observer?.onEvaluationStart?.(this.currentRound);
@@ -234,6 +244,11 @@ export class DeepResearchOrchestrator {
234
244
  reports: synthesisService.getAllReports(researchId),
235
245
  previousPlan: planningService.getCurrentPlan(researchId),
236
246
  totalResearchersPlanned: planningService.getTotalResearchersPlanned(researchId),
247
+ // Live, steering-extended budget — see maxRounds comment above. Without
248
+ // this, updatePlanForRound recomputes the BASE complexity-table value
249
+ // internally and understates the denominator once steering has extended
250
+ // the round budget, prematurely skewing its round-phase guidance to LATE.
251
+ maxRounds,
237
252
  signal,
238
253
  observer,
239
254
  excludeTools: this.options.excludeTools,
@@ -283,11 +298,15 @@ export class DeepResearchOrchestrator {
283
298
  clearTimeout(timeout);
284
299
  reject(new Error('Research cancelled'));
285
300
  };
301
+ // Deliberately REFERENCED: this sleep IS the run's sole pending
302
+ // operation at this moment (nothing else is in flight during a
303
+ // 'wait'), so an unref'd timer would let the process drain its
304
+ // event loop and exit 0 silently mid-run. Abortability, not
305
+ // unref, is what keeps cancellation responsive here.
286
306
  const timeout = setTimeout(() => {
287
307
  signal?.removeEventListener('abort', onAbort);
288
308
  resolve();
289
309
  }, 5000);
290
- safeUnref(timeout);
291
310
  signal?.addEventListener('abort', onAbort, { once: true });
292
311
  });
293
312
 
@@ -382,7 +401,7 @@ export class DeepResearchOrchestrator {
382
401
  options: {
383
402
  ...this.options,
384
403
  config: this.config,
385
- excludeTools: this.options.excludeTools || ['grep'],
404
+ excludeTools: resolveExcludedTools(this.options.excludeTools),
386
405
  observer: this.observer,
387
406
  },
388
407
  currentRound: this.currentRound,
@@ -463,6 +482,8 @@ export class DeepResearchOrchestrator {
463
482
  previousPlan: planningService.getCurrentPlan(researchId),
464
483
  totalResearchersPlanned: planningService.getTotalResearchersPlanned(researchId),
465
484
  mustSynthesize: true,
485
+ // Live, steering-extended budget — see the in-loop call above.
486
+ maxRounds,
466
487
  signal,
467
488
  observer,
468
489
  excludeTools: this.options.excludeTools,
@@ -139,6 +139,10 @@ export class HeadlessObserver implements ResearchObserver {
139
139
  this.emit('researcher_progress', { id, status, tokens, cost });
140
140
  }
141
141
 
142
+ onResearcherTokensHint(id: string, inputTokens: number): void {
143
+ this.emit('researcher_tokens_hint', { id, inputTokens });
144
+ }
145
+
142
146
  onResearcherComplete(id: string, report: string): void {
143
147
  this.emit('researcher_complete', { id, report });
144
148
  }
@@ -167,6 +171,10 @@ export class HeadlessObserver implements ResearchObserver {
167
171
  this.emit('evaluation_decision', { action, plan, round });
168
172
  }
169
173
 
174
+ onSynthesisStart(): void {
175
+ this.emit('synthesis_start');
176
+ }
177
+
170
178
  onComplete(result: string): void {
171
179
  this.emit('complete', { result });
172
180
  }
@@ -18,7 +18,7 @@ import { logger } from '../logger.ts';
18
18
  import { getConfig, type Config } from '../config.ts';
19
19
  import { createResearcherSession } from './researcher.ts';
20
20
  import { ensureAssistantResponse, parseCitations } from '../utils/text-utils.ts';
21
- import { getMaxScrapeBatches } from '../constants.ts';
21
+ import { getMaxScrapeBatches, resolveExcludedTools } from '../constants.ts';
22
22
  import type { ResearchObserver } from '../core/interfaces/observer-interfaces.ts';
23
23
  import { HeadlessObserver, makeSafeObserver, type HeadlessObserverOptions } from './headless-observer.ts';
24
24
  import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
@@ -180,7 +180,7 @@ export class QuickResearchOrchestrator {
180
180
  modelRegistry: ctx.modelRegistry,
181
181
  systemPrompt: prompt,
182
182
  extensionCtx: ctx,
183
- excludeTools: this.options.excludeTools || ['grep'],
183
+ excludeTools: resolveExcludedTools(this.options.excludeTools),
184
184
  config: this.config,
185
185
  getGlobalState: (): SystemResearchState => ({
186
186
  version: 1,
@@ -14,7 +14,7 @@ import type { Model } from '@earendil-works/pi-ai';
14
14
  import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
15
15
  import type { QueryResultWithError } from '../web-research/types.ts';
16
16
  import type { RunResearchersOptions } from './orchestration-types.ts';
17
- import { RESEARCHER_LAUNCH_DELAY_MS } from '../constants.ts';
17
+ import { RESEARCHER_LAUNCH_DELAY_MS, resolveExcludedTools } from '../constants.ts';
18
18
  import { search } from '../web-research/search.ts';
19
19
  import { parseCitations } from '../utils/text-utils.ts';
20
20
  import { logger, resetLogger } from '../logger.ts';
@@ -39,6 +39,7 @@ import { getCachedScrapedContent, normalizeUrl, cleanupSharedLinks } from '../ut
39
39
  import { runResearcher } from './researcher-executor.ts';
40
40
  import { ResearchRunSemaphore, ResearchRunCapacityError } from '../infrastructure/research-run-semaphore.ts';
41
41
  import { recordResearcherFailure, shouldStopResearch, createResearchStopError } from './session-state.ts';
42
+ import { isAbortSentinel, boundSessionAbort } from './abort-utils.ts';
42
43
  import type { ResearchSessionService } from './research-session-service.ts';
43
44
  import { QuickResearchOrchestrator } from './quick-research-orchestrator.ts';
44
45
  import { DeepResearchOrchestrator } from './deep-research-orchestrator.ts';
@@ -98,9 +99,7 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
98
99
  const configDisabledTools = researchConfig.DISABLED_TOOLS
99
100
  ? researchConfig.DISABLED_TOOLS.split(',').map((s) => s.trim()).filter(Boolean)
100
101
  : [];
101
- const mergedExcludeTools = configDisabledTools.length > 0
102
- ? [...new Set([...(excludeTools ?? []), ...configDisabledTools])]
103
- : excludeTools;
102
+ const mergedExcludeTools = resolveExcludedTools(excludeTools, configDisabledTools);
104
103
 
105
104
  // Resolve model using centralized priority logic
106
105
  const selectedModel = await this.resolveResearchModel(options);
@@ -346,8 +345,34 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
346
345
  // without this an early check, up to maxConcurrent-1 extra researchers could
347
346
  // start past the stop threshold and waste a browser slot + an LLM call.
348
347
  if (shouldStopResearch(sessionId, researchId, maxFailedResearchers)) {
349
- const sessionService = await getService<ResearchSessionService>(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
350
- await sessionService.abortAllSessions(researchId);
348
+ // A SIGTERM mid-run disposes the container, and getService then throws
349
+ // "Cannot get service … during container disposal" — which would REPLACE
350
+ // the already-decided stop error with a raw service error. Teardown is
351
+ // happening either way, so a failure here must not gate the stop throw.
352
+ try {
353
+ const sessionService = await getService<ResearchSessionService>(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
354
+ // Bounded: abortAllSessions awaits each session's abort, and session.abort()
355
+ // can wedge on the very in-flight call it is cancelling — an unbounded await
356
+ // here would hang the fast-stop throw for as long as the wedged call takes.
357
+ // Proceed after the bound; the abandoned aborts drain in the background.
358
+ await boundSessionAbort(
359
+ sessionService.abortAllSessions(researchId),
360
+ () => logger.debug('[ResearchOrchestrationService] Fast-stop abortAllSessions did not settle within bound; proceeding with stop'),
361
+ );
362
+ } catch (abortErr) {
363
+ logger.debug(`[ResearchOrchestrationService] Fast-stop session abort unavailable (container disposing?): ${abortErr instanceof Error ? abortErr.message : String(abortErr)}`);
364
+ }
365
+ // abortAllSessions only reaches REGISTERED sessions; a researcher still
366
+ // building a session, in a retry backoff, or in its initial-links search
367
+ // loop self-stops via shouldStopResearch (researcher-executor.ts). Bound-
368
+ // await the active set so those self-stops settle BEFORE the stop error
369
+ // returns the run and releases its run-cap slot — bounded for the same
370
+ // reason as boundSessionAbort: one wedged researcher must not hang the
371
+ // already-decided stop.
372
+ await boundSessionAbort(
373
+ Promise.allSettled(active).then(() => undefined),
374
+ () => logger.debug('[ResearchOrchestrationService] Fast-stop: active researchers did not settle within bound; proceeding with stop'),
375
+ );
351
376
  throw createResearchStopError(sessionId, researchId);
352
377
  }
353
378
 
@@ -367,7 +392,7 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
367
392
  }) : null;
368
393
  try {
369
394
  await Promise.race([...active, ...(abortPromise ? [abortPromise] : [])])
370
- .catch(err => { if (err.message !== 'Aborted') throw err; });
395
+ .catch(err => { if (!isAbortSentinel(err.message)) throw err; });
371
396
  } finally {
372
397
  if (signal && onAbort) signal.removeEventListener('abort', onAbort);
373
398
  }
@@ -396,11 +421,18 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
396
421
  });
397
422
  } catch (err) {
398
423
  const errMsg = err instanceof Error ? err.message : String(err);
399
- // A user cancellation (quit mid-run) surfaces here as an 'Aborted' throw.
400
- // That is a clean stop, not a researcher failure: don't log it at ERROR,
401
- // don't count it toward the fast-stop threshold, and don't paint the TUI
402
- // researcher slice red. Mirrors the guard in researcher-executor.ts.
403
- if (signal?.aborted || errMsg === 'Aborted') {
424
+ // A user cancellation (quit mid-run) surfaces here as an abort-sentinel
425
+ // throw — either the race's bare 'Aborted' or the '<id>: Aborted' that
426
+ // ensureAssistantResponse raises for an externally-aborted session (the
427
+ // fast-stop path). Container disposal (SIGTERM mid-run) is the same
428
+ // clean-teardown class: the executor stops retrying on it, and counting
429
+ // its "during container disposal" throw as a researcher failure both
430
+ // paints a red slice with a bogus root cause and can trip
431
+ // shouldStopResearch during shutdown. None of these are researcher
432
+ // failures: don't log at ERROR, don't count toward the fast-stop
433
+ // threshold, don't paint the TUI slice red. Mirrors the guard in
434
+ // researcher-executor.ts.
435
+ if (signal?.aborted || isAbortSentinel(errMsg) || container?.isDisposing || errMsg.includes('during container disposal')) {
404
436
  logger.debug(`[ResearchOrchestrationService] Researcher ${id} cancelled (aborted).`);
405
437
  } else {
406
438
  logger.error(`[ResearchOrchestrationService] Researcher ${id} failed: ${errMsg}`);
@@ -438,9 +470,27 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
438
470
  }
439
471
 
440
472
  if (shouldStopResearch(sessionId, researchId, maxFailedResearchers)) {
441
- const sessionService = await getService<ResearchSessionService>(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
442
- // Abort sessions specifically for this researchId, not the whole piSessionId
443
- await sessionService.abortAllSessions(researchId);
473
+ // getService can throw during container disposal — same guard as the
474
+ // pre-launch fast-stop above: the stop error must win, not a raw
475
+ // "Cannot get service … during container disposal".
476
+ try {
477
+ const sessionService = await getService<ResearchSessionService>(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
478
+ // Abort sessions specifically for this researchId, not the whole piSessionId.
479
+ // Bounded for the same reason as the pre-launch fast-stop above: a wedged
480
+ // session.abort() must not gate the stop error that is already decided.
481
+ await boundSessionAbort(
482
+ sessionService.abortAllSessions(researchId),
483
+ () => logger.debug('[ResearchOrchestrationService] Fast-stop abortAllSessions did not settle within bound; proceeding with stop'),
484
+ );
485
+ } catch (abortErr) {
486
+ logger.debug(`[ResearchOrchestrationService] Fast-stop session abort unavailable (container disposing?): ${abortErr instanceof Error ? abortErr.message : String(abortErr)}`);
487
+ }
488
+ // Bound-await the active set so unregistered researchers self-stop before
489
+ // the stop error releases the run-cap slot (see the pre-launch site).
490
+ await boundSessionAbort(
491
+ Promise.allSettled(active).then(() => undefined),
492
+ () => logger.debug('[ResearchOrchestrationService] Fast-stop: active researchers did not settle within bound; proceeding with stop'),
493
+ );
444
494
 
445
495
  throw createResearchStopError(sessionId, researchId);
446
496
  }
@@ -12,6 +12,7 @@ import type { AgentSession } from '@earendil-works/pi-coding-agent';
12
12
  import { logger } from '../logger.ts';
13
13
  import { ServiceLifecycle, type IService } from '../core/service-registry.ts';
14
14
  import { ServiceNames } from '../core/interfaces/service-names.ts';
15
+ import { boundSessionAbort } from './abort-utils.ts';
15
16
 
16
17
  /**
17
18
  * Session entry with abort capability
@@ -99,26 +100,51 @@ export class ResearchSessionService implements IService {
99
100
  }
100
101
 
101
102
  /**
102
- * Abort all active sessions for a specific sessionId, or all sessions if sessionId is omitted
103
+ * Abort all active sessions for a specific sessionId, or all sessions if sessionId is omitted.
104
+ *
105
+ * Each abort is bounded (boundSessionAbort): entry.abort() awaits the very
106
+ * in-flight call it is cancelling, which may never settle — and this method
107
+ * gates both the fast-stop throw (runResearchers) and run()'s finally via
108
+ * cleanup(), so one wedged session would otherwise hang the whole run's
109
+ * return. When the bound fires, teardown proceeds and the abandoned abort
110
+ * keeps draining in the background.
103
111
  */
104
112
  async abortAllSessions(sessionId?: string): Promise<void> {
105
113
  if (sessionId) {
106
- const sessionMap = this.getSessionMap(sessionId);
107
- const aborts = Array.from(sessionMap.values()).map((entry, index) =>
108
- entry.abort().catch((err) => {
109
- logger.warn(`[ResearchSessionService] Failed to abort session ${index}:`, err);
110
- })
114
+ // Read path: peek, never create — getSessionMap would resurrect an empty
115
+ // bucket for an already-completed researchId and leak it forever.
116
+ const sessionMap = this.peekSessionMap(sessionId);
117
+ if (!sessionMap) return;
118
+ // Snapshot BEFORE awaiting: only the entries aborted below may be removed.
119
+ // A session registered (or re-registered under the same id) DURING the
120
+ // bounded awaits — e.g. a researcher retry building a fresh session — was
121
+ // never aborted here; a blanket clear() would silently orphan it live.
122
+ const snapshot = Array.from(sessionMap.entries());
123
+ const aborts = snapshot.map(([, entry], index) =>
124
+ boundSessionAbort(
125
+ entry.abort().catch((err) => {
126
+ logger.warn(`[ResearchSessionService] Failed to abort session ${index}:`, err);
127
+ }),
128
+ () => logger.debug(`[ResearchSessionService] Session ${index} abort did not settle within bound; continuing teardown`),
129
+ )
111
130
  );
112
131
  await Promise.all(aborts);
113
- sessionMap.clear();
114
- this.sessions.delete(sessionId);
132
+ for (const [id, entry] of snapshot) {
133
+ // Identity check: delete only the exact entry we aborted, not a
134
+ // same-id replacement registered while we awaited.
135
+ if (sessionMap.get(id) === entry) sessionMap.delete(id);
136
+ }
137
+ if (sessionMap.size === 0) this.sessions.delete(sessionId);
115
138
  } else {
116
139
  const aborts: Promise<void>[] = [];
117
140
  for (const sessionMap of this.sessions.values()) {
118
141
  aborts.push(...Array.from(sessionMap.values()).map((entry, index) =>
119
- entry.abort().catch((err) => {
120
- logger.warn(`[ResearchSessionService] Failed to abort session ${index}:`, err);
121
- })
142
+ boundSessionAbort(
143
+ entry.abort().catch((err) => {
144
+ logger.warn(`[ResearchSessionService] Failed to abort session ${index}:`, err);
145
+ }),
146
+ () => logger.debug(`[ResearchSessionService] Session ${index} abort did not settle within bound; continuing teardown`),
147
+ )
122
148
  ));
123
149
  }
124
150
  await Promise.all(aborts);