@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.
- package/README.md +3 -3
- package/dist/cli.mjs +2599 -1268
- package/dist/thread-worker.mjs +224 -147
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +20 -10
- package/docs/CHANGELOG.md +195 -17
- package/docs/CONFIGURATION.md +9 -7
- package/docs/KNOWLEDGE-STORE.md +4 -3
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +55 -0
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/cleanup.cjs +114 -12
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +6 -3
- package/skills/pi-research/references/configuration.md +6 -2
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cleanup/research-cleanup.ts +12 -4
- package/src/cli.ts +372 -86
- package/src/config.ts +134 -17
- package/src/constants.ts +31 -0
- package/src/core/interfaces/health-check-interfaces.ts +3 -39
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/planning-interfaces.ts +8 -0
- package/src/core/interfaces/service-names.ts +0 -1
- package/src/core/interfaces/state-manager-interfaces.ts +11 -3
- package/src/core/llm/agentic-repair.ts +14 -0
- package/src/core/pi-version.ts +136 -0
- package/src/core/planning-service.ts +43 -1
- package/src/core/service-initialization.ts +6 -14
- package/src/core/service-interfaces.ts +1 -1
- package/src/core/service-registry.ts +117 -8
- package/src/healthcheck/index.ts +7 -2
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +80 -13
- package/src/infrastructure/browser/browser-error-utils.ts +6 -1
- package/src/infrastructure/browser/browser-server.ts +41 -12
- package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
- package/src/infrastructure/browser/cleanup-utils.ts +95 -9
- package/src/infrastructure/browser/client-agent.ts +6 -5
- package/src/infrastructure/browser/config.ts +14 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +44 -4
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
- package/src/infrastructure/browser/thread-worker-types.ts +3 -1
- package/src/infrastructure/browser/thread-worker.mjs +224 -147
- package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +61 -15
- package/src/infrastructure/embedding/embedding-server.ts +105 -11
- package/src/infrastructure/file-lock-service.ts +295 -28
- package/src/infrastructure/knowledge-store-service.ts +48 -3
- package/src/infrastructure/process-lifecycle-service.ts +134 -34
- package/src/infrastructure/research-run-semaphore.ts +37 -3
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-browser-api.ts +5 -2
- package/src/infrastructure/state/state-browser-manager.ts +15 -1
- package/src/infrastructure/state/state-manager-service.ts +5 -5
- package/src/infrastructure/state/state-manager.ts +34 -23
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/chunker.ts +88 -1
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +188 -46
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +101 -21
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/logger.ts +5 -1
- package/src/observers/research-observer-impl.ts +31 -0
- package/src/orchestration/abort-utils.ts +60 -0
- package/src/orchestration/deep-research-orchestrator.ts +25 -4
- package/src/orchestration/headless-observer.ts +8 -0
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +65 -15
- package/src/orchestration/research-session-service.ts +37 -11
- package/src/orchestration/research-synthesis-service.ts +33 -11
- package/src/orchestration/researcher-executor.ts +100 -20
- package/src/orchestration/researcher.ts +39 -20
- package/src/orchestration/session-state.ts +11 -0
- package/src/sdk.ts +125 -19
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/index.ts +51 -30
- package/src/security/nvd-client.ts +24 -8
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +153 -11
- package/src/stackexchange/index.ts +20 -5
- package/src/stackexchange/output/table.ts +7 -2
- package/src/stackexchange/rest-client.ts +41 -3
- package/src/tools/research-knowledge-search.ts +15 -0
- package/src/tools/research-tool-definition.ts +8 -2
- package/src/tools/scrape.ts +48 -25
- package/src/tools/search.ts +16 -2
- package/src/tools/security.ts +68 -7
- package/src/tui/research-health.ts +18 -2
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +9 -2
- package/src/utils/citation-utils.ts +3 -1
- package/src/utils/disk-space-checker.ts +11 -6
- package/src/utils/http-body.ts +176 -0
- package/src/utils/json-utils.ts +116 -76
- package/src/utils/llm-usage.ts +17 -1
- package/src/utils/log-rotation.ts +42 -10
- package/src/utils/log-utils.ts +40 -0
- package/src/utils/metrics-summary.ts +27 -9
- package/src/utils/stdio-capture.ts +63 -22
- package/src/utils/text-utils.ts +8 -1
- package/src/utils/tool-usage-tracker.ts +9 -2
- package/src/web-research/browser-search.ts +70 -5
- package/src/web-research/scraper-utils.ts +13 -0
- package/src/web-research/search.ts +25 -6
- package/src/web-research/types.ts +19 -4
- package/src/web-research/web-scraper.ts +72 -54
- package/src/youtube/transcript-client.ts +5 -1
- 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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
350
|
-
|
|
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
|
|
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
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
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
|
-
|
|
442
|
-
//
|
|
443
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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);
|