@lincoln504/pi-research 1.0.9 → 1.0.12
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/dist/cli.mjs +451 -58
- package/dist/thread-worker.mjs +39 -1
- package/docs/CHANGELOG.md +26 -0
- package/package.json +5 -2
- package/skills/pi-research/SKILL.md +1 -1
- package/src/core/interfaces/service-names.ts +2 -0
- package/src/infrastructure/browser/browser-client.ts +19 -1
- package/src/infrastructure/browser/browser-error-utils.ts +24 -1
- package/src/infrastructure/browser/browser-server.ts +70 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +19 -3
- package/src/infrastructure/browser/scheduler-factory.ts +1 -1
- package/src/infrastructure/browser/thread-worker.mjs +39 -1
- package/src/infrastructure/process-lifecycle-service.ts +21 -3
- package/src/infrastructure/research-run-semaphore.ts +290 -0
- package/src/infrastructure/service-initialization.ts +20 -0
- package/src/orchestration/deep-research-orchestrator.ts +11 -0
- package/src/orchestration/research-orchestration-service.ts +35 -0
- package/src/orchestration/research-synthesis-service.ts +171 -15
- package/src/utils/make-resource-loader.ts +7 -0
- package/src/web-research/retry-utils.ts +9 -3
- package/src/web-research/scraper-utils.ts +43 -0
- package/src/web-research/web-scraper.ts +67 -40
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Research Run Semaphore
|
|
3
|
+
*
|
|
4
|
+
* Cross-process cap on the number of concurrent research runs, built on the
|
|
5
|
+
* verified {@link FileLockService}. This is the Phase 1 "run-cap" mitigation for
|
|
6
|
+
* the parallel-runs degradation documented in
|
|
7
|
+
* INVESTIGATION-2026-08-02-parallel-runs-sigabrt.md: when more than N research
|
|
8
|
+
* runs pile onto a single leader-elected browser/embedding pool, the shared
|
|
9
|
+
* priority queue saturates, runs churn through leader elections, and research
|
|
10
|
+
* degrades into ~60-error failures. Capping concurrency at the orchestration
|
|
11
|
+
* entry stops that at the source.
|
|
12
|
+
*
|
|
13
|
+
* ## Design
|
|
14
|
+
*
|
|
15
|
+
* A counting semaphore of N slots is realized as N well-known slot files
|
|
16
|
+
* (`research-run-slot-<i>.lock`) in the shared state directory, coordinated with
|
|
17
|
+
* {@link FileLockService}.
|
|
18
|
+
*
|
|
19
|
+
* - **Acquire** tries each slot once (round-robin from a rotating start index to
|
|
20
|
+
* spread contention). Each per-slot attempt builds a **fresh** `FileLockService`
|
|
21
|
+
* configured as a *try-take* (`lockTimeout: 0`): it either wins the slot (free,
|
|
22
|
+
* or reclaimed from a *dead* owner) immediately or throws as soon as it sees a
|
|
23
|
+
* *live* owner. If every slot is held by a live process, the loop waits
|
|
24
|
+
* `pollDelayMs` and retries until `maxWaitMs` elapses, then rejects with
|
|
25
|
+
* {@link ResearchRunCapacityError} (fail-fast with a clear message rather than
|
|
26
|
+
* a 60-error degraded run).
|
|
27
|
+
* - **Release** unlinks the slot file via the captured slot service.
|
|
28
|
+
* - **Crash recovery** is automatic and PID-reuse-safe: a slot held by a process
|
|
29
|
+
* that has died (killed, crashed, SIGKILLed by earlyoom) is reclaimed
|
|
30
|
+
* immediately on the next acquire attempt, because `FileLockService` checks the
|
|
31
|
+
* owner's PID+startTime liveness before treating a lock as stale.
|
|
32
|
+
*
|
|
33
|
+
* ### Why a *fresh* FileLockService per attempt
|
|
34
|
+
* `FileLockService.acquireLock()` serializes callers on a per-instance FIFO queue
|
|
35
|
+
* and only releases that queue turn in `releaseLock()` — i.e. after the run ends.
|
|
36
|
+
* Reusing a persistent per-slot instance would therefore **deadlock concurrent
|
|
37
|
+
* research starts within one process**: the first holder keeps slot[i]'s queue
|
|
38
|
+
* turn for minutes, blocking a second acquire on that slot forever (it would
|
|
39
|
+
* never reach the other slots). A throwaway instance per attempt has an empty
|
|
40
|
+
* queue, so concurrent attempts never block on stale queue state — while still
|
|
41
|
+
* reusing all of FileLockService's verified atomic-create / liveness / reclaim
|
|
42
|
+
* logic. Two concurrent attempts on the *same* slot simply race on `fs.open(wx)`:
|
|
43
|
+
* one wins, the other reads a live owner and fails fast to the next slot.
|
|
44
|
+
*
|
|
45
|
+
* ### Why `liveOwnerStaleThreshold` is effectively infinite
|
|
46
|
+
* A research run legitimately holds its slot for *minutes*. `FileLockService`
|
|
47
|
+
* would normally steal a lock from a *live* owner once it ages past
|
|
48
|
+
* `liveOwnerStaleThreshold` (to recover a process stalled mid-critical-section).
|
|
49
|
+
* For a run-cap that is exactly wrong: stealing a live run's slot would silently
|
|
50
|
+
* admit an (N+1)th concurrent run — reintroducing the saturation this guard
|
|
51
|
+
* exists to prevent. So slots are configured to *never* steal from a live owner;
|
|
52
|
+
* only provably-dead owners are reclaimed (immediately, regardless of age).
|
|
53
|
+
*
|
|
54
|
+
* ### Concurrency within one process
|
|
55
|
+
* `acquire()` returns an independent {@link RunSlotAcquisition} handle capturing
|
|
56
|
+
* its own slot service; it stores *no* shared "held slot" state. Two concurrent
|
|
57
|
+
* `runResearch` calls on the same singleton service each acquire a distinct slot
|
|
58
|
+
* safely (see "fresh instance per attempt" above), with no risk of one release
|
|
59
|
+
* clobbering the other's tracking.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import * as pathmod from 'node:path';
|
|
63
|
+
import { logger } from '../logger.ts';
|
|
64
|
+
import { metrics } from '../utils/metrics.ts';
|
|
65
|
+
import { FileLockService } from './file-lock-service.ts';
|
|
66
|
+
import { ServiceLifecycle } from '../core/service-registry.ts';
|
|
67
|
+
import { ServiceNames } from '../core/interfaces/service-names.ts';
|
|
68
|
+
import type { IService } from '../core/service-registry.ts';
|
|
69
|
+
import type { IProcessLifecycle } from '../core/interfaces/process-interfaces.ts';
|
|
70
|
+
|
|
71
|
+
/** Default maximum number of concurrent research runs across all processes. */
|
|
72
|
+
export const DEFAULT_MAX_CONCURRENT_RUNS = 2;
|
|
73
|
+
/** Default per-acquire wait before failing fast when all slots are busy (0 = fail-fast). */
|
|
74
|
+
export const DEFAULT_ACQUIRE_TIMEOUT_MS = 0;
|
|
75
|
+
/** Poll interval between full slot sweeps while waiting for a slot. */
|
|
76
|
+
const POLL_DELAY_MS = 250;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Thrown when every run slot is held by a live process and the acquire timeout
|
|
80
|
+
* has elapsed. Distinct from internal/IO errors so callers can treat
|
|
81
|
+
* "capacity exhausted" (retry later) differently from "the semaphore is broken".
|
|
82
|
+
*/
|
|
83
|
+
export class ResearchRunCapacityError extends Error {
|
|
84
|
+
readonly slots: number;
|
|
85
|
+
constructor(slots: number, maxWaitMs: number) {
|
|
86
|
+
super(
|
|
87
|
+
`Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` after waiting ${maxWaitMs}ms` : ''}; ` +
|
|
88
|
+
`wait for an in-progress run to finish and retry. ` +
|
|
89
|
+
`(Override with PI_RESEARCH_MAX_CONCURRENT_RUNS.)`,
|
|
90
|
+
);
|
|
91
|
+
this.name = 'ResearchRunCapacityError';
|
|
92
|
+
this.slots = slots;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A held run slot. Call {@link RunSlotAcquisition.release} exactly once when the run ends. */
|
|
97
|
+
export interface RunSlotAcquisition {
|
|
98
|
+
/** The 0-based slot index that was acquired (observability). */
|
|
99
|
+
readonly slotIndex: number;
|
|
100
|
+
/** Release the slot back to the pool. Safe to call once; subsequent calls are no-ops. */
|
|
101
|
+
release(): Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Counting semaphore over N slot files coordinated with {@link FileLockService}.
|
|
106
|
+
*/
|
|
107
|
+
export class ResearchRunSemaphore implements IService {
|
|
108
|
+
readonly name = ServiceNames.RESEARCH_RUN_SEMAPHORE;
|
|
109
|
+
lifecycle = ServiceLifecycle.UNINITIALIZED;
|
|
110
|
+
|
|
111
|
+
private readonly slotDir: string;
|
|
112
|
+
private readonly processLifecycle: IProcessLifecycle | null;
|
|
113
|
+
private readonly maxSlots: number;
|
|
114
|
+
private readonly defaultMaxWaitMs: number;
|
|
115
|
+
/** Rotating start index so contending processes don't all hammer slot 0 first. */
|
|
116
|
+
private nextStartIndex = 0;
|
|
117
|
+
private _initialized = false;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param slotDir Directory holding the `research-run-slot-<i>.lock` files.
|
|
121
|
+
* @param processLifecycle PID+startTime liveness source (PID-reuse-safe reclamation).
|
|
122
|
+
* @param maxSlots N — maximum concurrent runs. Defaults from env then {@link DEFAULT_MAX_CONCURRENT_RUNS}.
|
|
123
|
+
* @param defaultMaxWaitMs Default acquire wait. Defaults from env then {@link DEFAULT_ACQUIRE_TIMEOUT_MS}.
|
|
124
|
+
*/
|
|
125
|
+
constructor(slotDir: string, processLifecycle: IProcessLifecycle | null, maxSlots?: number, defaultMaxWaitMs?: number) {
|
|
126
|
+
this.slotDir = slotDir;
|
|
127
|
+
this.processLifecycle = processLifecycle;
|
|
128
|
+
this.maxSlots = Math.max(1, Math.floor(
|
|
129
|
+
maxSlots ?? numberFromEnv('PI_RESEARCH_MAX_CONCURRENT_RUNS') ?? DEFAULT_MAX_CONCURRENT_RUNS,
|
|
130
|
+
));
|
|
131
|
+
this.defaultMaxWaitMs = Math.max(0, Math.floor(
|
|
132
|
+
defaultMaxWaitMs ?? numberFromEnv('PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS') ?? DEFAULT_ACQUIRE_TIMEOUT_MS,
|
|
133
|
+
));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Number of slots (max concurrent runs). */
|
|
137
|
+
getMaxSlots(): number {
|
|
138
|
+
return this.maxSlots;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async initialize(): Promise<void> {
|
|
142
|
+
if (this._initialized) return;
|
|
143
|
+
this.lifecycle = ServiceLifecycle.INITIALIZING;
|
|
144
|
+
// Ensure the slot directory exists; slot files are created lazily on acquire.
|
|
145
|
+
try {
|
|
146
|
+
await import('node:fs/promises').then((fs) => fs.mkdir(this.slotDir, { recursive: true, mode: 0o700 }));
|
|
147
|
+
} catch (err) {
|
|
148
|
+
logger.warn(`[ResearchRunSemaphore] Could not create slot dir ${this.slotDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
149
|
+
}
|
|
150
|
+
this._initialized = true;
|
|
151
|
+
this.lifecycle = ServiceLifecycle.INITIALIZED;
|
|
152
|
+
logger.log(`[ResearchRunSemaphore] Initialized with ${this.maxSlots} run slot(s) (acquire timeout ${this.defaultMaxWaitMs}ms).`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async dispose(): Promise<void> {
|
|
156
|
+
// Held slots are released by their RunSlotAcquisition handles (the runResearch
|
|
157
|
+
// finally block). A hard process exit leaves slot files behind, which the next
|
|
158
|
+
// acquire reclaims via PID+startTime liveness — so dispose has nothing to do.
|
|
159
|
+
this.lifecycle = ServiceLifecycle.DISPOSED;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Try to win one slot NON-BLOCKING. Builds a fresh {@link FileLockService}
|
|
164
|
+
* configured as a try-take (see class doc: "fresh instance per attempt").
|
|
165
|
+
* Returns an acquisition on success, or null if the slot is busy / lost a race.
|
|
166
|
+
*/
|
|
167
|
+
private async trySlot(slotIndex: number): Promise<RunSlotAcquisition | null> {
|
|
168
|
+
const slot = new FileLockService({
|
|
169
|
+
lockFilePath: pathmod.join(this.slotDir, `research-run-slot-${slotIndex}.lock`),
|
|
170
|
+
lockTimeout: 0, // fail immediately on a live-contended slot (try-take)
|
|
171
|
+
lockRetries: 24, // enough iterations to complete a dead-owner reclaim + reacquire
|
|
172
|
+
lockRetryDelay: 50,
|
|
173
|
+
lockStaleThreshold: 15000, // legacy (no-pid) locks: reclaim once 15s old
|
|
174
|
+
// A live process holding a run slot for the entire (minutes-long) run is the
|
|
175
|
+
// NORMAL case, not a stall. Never steal from a live owner — doing so would
|
|
176
|
+
// admit an (N+1)th concurrent run, the exact condition this guard prevents.
|
|
177
|
+
// Dead owners are reclaimed immediately regardless of age.
|
|
178
|
+
liveOwnerStaleThreshold: Number.MAX_SAFE_INTEGER,
|
|
179
|
+
processLifecycle: this.processLifecycle ?? undefined,
|
|
180
|
+
});
|
|
181
|
+
try {
|
|
182
|
+
await slot.initialize(); // resolves own startTime (cached) + startup stale cleanup
|
|
183
|
+
await slot.acquireLock(); // try-take: wins, reclaims a dead owner, or throws fast
|
|
184
|
+
} catch {
|
|
185
|
+
return null; // busy (live owner) or lost a reclaim race — try the next slot
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
slotIndex,
|
|
189
|
+
release: async () => {
|
|
190
|
+
try {
|
|
191
|
+
await slot.releaseLock();
|
|
192
|
+
} catch (err) {
|
|
193
|
+
logger.warn(`[ResearchRunSemaphore] Failed to release slot ${slotIndex}: ${err instanceof Error ? err.message : String(err)}`);
|
|
194
|
+
} finally {
|
|
195
|
+
// Clear the gauge even if the unlink failed: the slot is no longer held
|
|
196
|
+
// by THIS process either way, and a gauge that only ever ratchets to 1
|
|
197
|
+
// reports permanent saturation for the life of the process.
|
|
198
|
+
metrics.setGauge('research_run_slots_held', 0, { slot: String(slotIndex) });
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Acquire one run slot, waiting up to `maxWaitMs` (defaults to the constructed
|
|
206
|
+
* value) when all slots are held by live processes. Resolves with a handle that
|
|
207
|
+
* must be released when the run ends.
|
|
208
|
+
*
|
|
209
|
+
* @param signal aborts the wait. Without it a run started under a long
|
|
210
|
+
* `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` would ignore Ctrl-C until the deadline
|
|
211
|
+
* passed, because the wait happens before any orchestrator exists to cancel.
|
|
212
|
+
* @throws {ResearchRunCapacityError} if no slot frees within `maxWaitMs`.
|
|
213
|
+
* @throws {Error} named `AbortError` if `signal` aborts while waiting.
|
|
214
|
+
*/
|
|
215
|
+
async acquire(maxWaitMs: number = this.defaultMaxWaitMs, signal?: AbortSignal): Promise<RunSlotAcquisition> {
|
|
216
|
+
if (!this._initialized) await this.initialize();
|
|
217
|
+
if (signal?.aborted) {
|
|
218
|
+
metrics.increment('research_run_acquire_total', 1, { status: 'aborted' });
|
|
219
|
+
throwIfAborted(signal);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const deadline = Date.now() + Math.max(0, maxWaitMs);
|
|
223
|
+
for (;;) {
|
|
224
|
+
// Round-robin from a rotating start so contending processes don't synchronize on slot 0.
|
|
225
|
+
const start = this.nextStartIndex;
|
|
226
|
+
this.nextStartIndex = (this.nextStartIndex + 1) % this.maxSlots;
|
|
227
|
+
for (let offset = 0; offset < this.maxSlots; offset++) {
|
|
228
|
+
const i = (start + offset) % this.maxSlots;
|
|
229
|
+
const got = await this.trySlot(i);
|
|
230
|
+
if (got) {
|
|
231
|
+
metrics.setGauge('research_run_slots_held', 1, { slot: String(i) });
|
|
232
|
+
metrics.increment('research_run_acquire_total', 1, { status: 'acquired' });
|
|
233
|
+
return got;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Every slot is held by a live process.
|
|
237
|
+
if (Date.now() >= deadline) {
|
|
238
|
+
metrics.increment('research_run_acquire_total', 1, { status: 'exhausted' });
|
|
239
|
+
throw new ResearchRunCapacityError(this.maxSlots, maxWaitMs);
|
|
240
|
+
}
|
|
241
|
+
// Count the aborted outcome too, so acquired + exhausted + aborted accounts for
|
|
242
|
+
// every acquire attempt. A cancel that vanished from the counter would make the
|
|
243
|
+
// capacity metrics quietly under-report demand.
|
|
244
|
+
try {
|
|
245
|
+
await sleep(POLL_DELAY_MS, signal);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
metrics.increment('research_run_acquire_total', 1, { status: 'aborted' });
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
255
|
+
if (signal?.aborted) {
|
|
256
|
+
throw Object.assign(new Error('Research run slot acquisition aborted'), { name: 'AbortError' });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function numberFromEnv(key: string): number | undefined {
|
|
261
|
+
const raw = process.env[key];
|
|
262
|
+
if (raw === undefined || raw === '') return undefined;
|
|
263
|
+
const n = Number(raw);
|
|
264
|
+
return Number.isFinite(n) ? n : undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Poll delay that resolves early (rejecting) on abort.
|
|
269
|
+
*
|
|
270
|
+
* The timer is deliberately NOT `unref`'d. Waiting for a run slot is foreground work: a process
|
|
271
|
+
* whose only pending handle is this timer must stay alive until the slot frees, and an unref'd
|
|
272
|
+
* timer would let Node drain the loop and exit mid-wait (observed as an early exit in the
|
|
273
|
+
* multi-process harness). The timer is always settled — it either fires or is cleared on abort —
|
|
274
|
+
* so keeping it referenced cannot outlive the acquire.
|
|
275
|
+
*/
|
|
276
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
277
|
+
return new Promise((resolve, reject) => {
|
|
278
|
+
const timer = setTimeout(() => {
|
|
279
|
+
if (onAbort) signal?.removeEventListener('abort', onAbort);
|
|
280
|
+
resolve();
|
|
281
|
+
}, ms);
|
|
282
|
+
const onAbort = signal
|
|
283
|
+
? () => {
|
|
284
|
+
clearTimeout(timer);
|
|
285
|
+
reject(Object.assign(new Error('Research run slot acquisition aborted'), { name: 'AbortError' }));
|
|
286
|
+
}
|
|
287
|
+
: undefined;
|
|
288
|
+
if (onAbort) signal?.addEventListener('abort', onAbort, { once: true });
|
|
289
|
+
});
|
|
290
|
+
}
|
|
@@ -22,6 +22,7 @@ import { StateValidator } from './state/state-validator.ts';
|
|
|
22
22
|
import { WorkerPoolManager } from './browser/worker-pool-manager.ts';
|
|
23
23
|
import { StatePathConfiguration } from './state/state-path-configuration.ts';
|
|
24
24
|
import { FileLockService } from './file-lock-service.ts';
|
|
25
|
+
import { ResearchRunSemaphore } from './research-run-semaphore.ts';
|
|
25
26
|
import { StateBackupManager } from './state/state-backup-manager.ts';
|
|
26
27
|
import { logger } from '../logger.ts';
|
|
27
28
|
|
|
@@ -73,6 +74,25 @@ export function registerInfrastructureServices(container: ServiceContainer = get
|
|
|
73
74
|
container
|
|
74
75
|
);
|
|
75
76
|
|
|
77
|
+
// Register Research Run Semaphore (Phase 1 cross-process run-cap).
|
|
78
|
+
// Depends on StatePathConfiguration (slot dir) and ProcessLifecycle (PID-reuse-safe
|
|
79
|
+
// reclamation). Lazy: only built on first research run. Acquired at runResearch()
|
|
80
|
+
// entry; released in its finally. A bug here fails OPEN (research proceeds) so it
|
|
81
|
+
// can never break research; genuine capacity exhaustion fails fast with a clear error.
|
|
82
|
+
registerService(
|
|
83
|
+
ServiceNames.RESEARCH_RUN_SEMAPHORE,
|
|
84
|
+
async () => {
|
|
85
|
+
const pathConfig = await getService<StatePathConfiguration>(ServiceNames.STATE_PATH_CONFIGURATION, undefined, container);
|
|
86
|
+
const processLifecycle = await getService<ProcessLifecycleService>(ServiceNames.PROCESS_LIFECYCLE, undefined, container);
|
|
87
|
+
return new ResearchRunSemaphore(pathConfig.getStateDir(), processLifecycle);
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
allowOverwrite: false,
|
|
91
|
+
enableLogging: true,
|
|
92
|
+
},
|
|
93
|
+
container
|
|
94
|
+
);
|
|
95
|
+
|
|
76
96
|
// Register State Backup Manager Service
|
|
77
97
|
// Depends on StatePathConfiguration
|
|
78
98
|
registerService(
|
|
@@ -555,6 +555,16 @@ export class DeepResearchOrchestrator {
|
|
|
555
555
|
logger.debug('[DeepOrchestrator] onComplete observer threw:', obsErr);
|
|
556
556
|
}
|
|
557
557
|
|
|
558
|
+
// Observability: log the FULL final synthesis body. Every other layer —
|
|
559
|
+
// researcher prompts, researcher final responses, searches, scrapes — is logged
|
|
560
|
+
// at DEBUG; the synthesis itself (the actual bytes returned to the caller) was
|
|
561
|
+
// the one conspicuous exception, which made post-hoc diagnosis of citation /
|
|
562
|
+
// output issues impossible (a reported "the engine returned placeholder X"
|
|
563
|
+
// could not be confirmed or refuted from the logs). Mirrors the DEBUG logging
|
|
564
|
+
// of researcher final responses. Truncation is deliberately NOT applied: the
|
|
565
|
+
// raw bytes are exactly what's needed to diagnose grounding failures.
|
|
566
|
+
logger.debug(`[DeepOrchestrator] Final synthesis for ${researchId} (${result.length} chars):\n${result}`);
|
|
567
|
+
|
|
558
568
|
return result;
|
|
559
569
|
|
|
560
570
|
} catch (error) {
|
|
@@ -589,6 +599,7 @@ export class DeepResearchOrchestrator {
|
|
|
589
599
|
} catch (obsErr) {
|
|
590
600
|
logger.debug('[DeepOrchestrator] fallback onComplete observer threw:', obsErr);
|
|
591
601
|
}
|
|
602
|
+
logger.debug(`[DeepOrchestrator] Fallback synthesis for ${researchId} (${result.length} chars):\n${result}`);
|
|
592
603
|
return result;
|
|
593
604
|
}
|
|
594
605
|
} catch (fallbackErr) {
|
|
@@ -37,6 +37,7 @@ import type { Config } from '../config.ts';
|
|
|
37
37
|
import { getConfig, DEFAULTS } from '../config.ts';
|
|
38
38
|
import { getCachedScrapedContent, normalizeUrl, cleanupSharedLinks } from '../utils/shared-links.ts';
|
|
39
39
|
import { runResearcher } from './researcher-executor.ts';
|
|
40
|
+
import { ResearchRunSemaphore, ResearchRunCapacityError } from '../infrastructure/research-run-semaphore.ts';
|
|
40
41
|
import { recordResearcherFailure, shouldStopResearch, createResearchStopError } from './session-state.ts';
|
|
41
42
|
import type { ResearchSessionService } from './research-session-service.ts';
|
|
42
43
|
import { QuickResearchOrchestrator } from './quick-research-orchestrator.ts';
|
|
@@ -107,6 +108,26 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
107
108
|
|
|
108
109
|
const researchStart = Date.now();
|
|
109
110
|
|
|
111
|
+
// Cross-process run-cap (Phase 1): acquire one of N slots so concurrent
|
|
112
|
+
// research runs can't saturate the shared leader-elected browser/embedding
|
|
113
|
+
// pool and degrade into ~60-error failures. Fail-OPEN on internal/IO errors
|
|
114
|
+
// (a semaphore bug must never break research); only a genuine
|
|
115
|
+
// ResearchRunCapacityError (all slots held by live processes) propagates,
|
|
116
|
+
// failing the run fast with a clear message. The slot is released in finally.
|
|
117
|
+
let runSlot: { slotIndex: number; release(): Promise<void> } | null = null;
|
|
118
|
+
try {
|
|
119
|
+
const container = tryGetServiceContainerFromCtx(ctx);
|
|
120
|
+
const semaphore = await getService<ResearchRunSemaphore>(ServiceNames.RESEARCH_RUN_SEMAPHORE, ctx, container);
|
|
121
|
+
runSlot = await semaphore.acquire(undefined, signal);
|
|
122
|
+
logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err instanceof ResearchRunCapacityError) throw err; // capacity exhausted → fail fast
|
|
125
|
+
// A cancel while queueing for a slot must stay cancelled. Fail-open here would
|
|
126
|
+
// start the very run the user just aborted.
|
|
127
|
+
if (err instanceof Error && err.name === 'AbortError') throw err;
|
|
128
|
+
logger.warn(`[ResearchOrchestrationService] Run-cap unavailable, proceeding without it: ${err instanceof Error ? err.message : String(err)}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
110
131
|
let result: string;
|
|
111
132
|
try {
|
|
112
133
|
if (depth === 0) {
|
|
@@ -149,6 +170,20 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
149
170
|
metrics.observe('research_manager_latency_ms', researchDuration, { depth: String(depth), status: 'error', source: 'extension' });
|
|
150
171
|
metrics.increment('research_manager_requests_total', 1, { depth: String(depth), status: 'error', source: 'extension' });
|
|
151
172
|
throw error;
|
|
173
|
+
} finally {
|
|
174
|
+
// Always release the run-cap slot — on success, error, or abort (the
|
|
175
|
+
// orchestrator throws on AbortSignal, hitting this finally before rethrow).
|
|
176
|
+
if (runSlot) {
|
|
177
|
+
// Never let a release failure mask the run's own outcome — but do report it.
|
|
178
|
+
// A slot that fails to release stays held for the lifetime of THIS process
|
|
179
|
+
// (liveness reclaim only fires once the owner is gone), so it silently costs
|
|
180
|
+
// one unit of capacity for every subsequent run in the session.
|
|
181
|
+
await runSlot.release().catch((relErr) => {
|
|
182
|
+
logger.warn(
|
|
183
|
+
`[ResearchOrchestrationService] Failed to release run slot ${runSlot?.slotIndex}; capacity is reduced until this process exits: ${relErr instanceof Error ? relErr.message : String(relErr)}`,
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
152
187
|
}
|
|
153
188
|
}
|
|
154
189
|
|
|
@@ -13,6 +13,7 @@ import { parseCitations } from '../utils/text-utils.ts';
|
|
|
13
13
|
import { normalizeCitations, formatCitedLinks, type GlobalCitation } from '../utils/citation-utils.ts';
|
|
14
14
|
import { lastCitedLinksHeaderIndex } from '../utils/text-utils.ts';
|
|
15
15
|
import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
|
|
16
|
+
import { stripTrailingLlmPunctuation } from '../utils/url-utils.ts';
|
|
16
17
|
import { logger } from '../logger.ts';
|
|
17
18
|
import { ServiceLifecycle, type IService } from '../core/service-registry.ts';
|
|
18
19
|
import { ServiceNames } from '../core/interfaces/service-names.ts';
|
|
@@ -26,6 +27,125 @@ import type { SteeringMessage } from './session-state.ts';
|
|
|
26
27
|
const NO_SOURCES_NOTE =
|
|
27
28
|
'CITED LINKS\n_No web sources were successfully retrieved for this report — searches or page fetches may have been blocked (for example by site bot-protection) or returned no usable results. Treat the findings above as unverified._';
|
|
28
29
|
|
|
30
|
+
// Appended when the model wrote its own links but the run retrieved nothing we could
|
|
31
|
+
// verify them against. The block is kept (it may still be a useful lead) but must not
|
|
32
|
+
// read as though the engine checked it.
|
|
33
|
+
const UNVERIFIED_LINKS_NOTE =
|
|
34
|
+
'_None of the links above could be verified: no page fetch succeeded this session and no citation could be matched to retrieved content. They may be inaccurate or invented — check each one before relying on it._';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Largest `[N]` treated as a citation marker. Inline markers index a source list
|
|
38
|
+
* that is realistically well under this; the bound exists so an ordinary bracketed
|
|
39
|
+
* number in prose — a year (`[2023]`), an issue id, an array index — is left alone
|
|
40
|
+
* instead of being silently deleted as a "dangling citation".
|
|
41
|
+
*/
|
|
42
|
+
const MAX_PLAUSIBLE_CITATION_MARKER = 200;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Rewrite inline `[N]` citation markers in a prose body: renumber the ones we can
|
|
46
|
+
* map, delete the ones known to dangle, leave everything else untouched.
|
|
47
|
+
*
|
|
48
|
+
* Deletion is whitespace-aware rather than followed by a global space collapse.
|
|
49
|
+
* A blanket `text.replace(/ +/g, ' ')` also eats *leading* indentation, which
|
|
50
|
+
* silently flattens nested list levels into one and turns 4-space indented code
|
|
51
|
+
* blocks into paragraphs — corrupting the structure of every report that contains
|
|
52
|
+
* them. So the pad preceding a deleted marker is consumed only when the marker is
|
|
53
|
+
* mid-line (i.e. real prose spacing); line-leading indentation is preserved.
|
|
54
|
+
*
|
|
55
|
+
* Code is skipped entirely. A report may quote code containing bracketed integers
|
|
56
|
+
* (`items[12]`), and on the no-CITED-LINKS path *every* out-of-range marker is treated
|
|
57
|
+
* as dangling — so without this guard a quoted array index would be silently deleted
|
|
58
|
+
* from the user's code sample. Fenced blocks are tracked across lines; single-backtick
|
|
59
|
+
* spans are detected by an odd backtick count earlier on the same line.
|
|
60
|
+
*
|
|
61
|
+
* @param remap local marker number → verified global id
|
|
62
|
+
* @param isDangling whether a marker not present in `remap` should be deleted
|
|
63
|
+
*/
|
|
64
|
+
function rewriteCitationMarkers(
|
|
65
|
+
text: string,
|
|
66
|
+
remap: Map<number, number>,
|
|
67
|
+
isDangling: (n: number) => boolean,
|
|
68
|
+
): string {
|
|
69
|
+
const inFence = fencedCodeMask(text);
|
|
70
|
+
return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad: string, digits: string, offset: number) => {
|
|
71
|
+
const n = parseInt(digits, 10);
|
|
72
|
+
const mapped = remap.get(n);
|
|
73
|
+
if (mapped !== undefined) return `${pad}[${mapped}]`;
|
|
74
|
+
// Citation numbering is 1-based; `[0]` is always an index or a literal, never a marker.
|
|
75
|
+
if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
|
|
76
|
+
if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
|
|
77
|
+
// Preserve indentation when nothing but whitespace precedes on this line;
|
|
78
|
+
// otherwise drop the pad too so no double space is left behind.
|
|
79
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
80
|
+
return text.slice(lineStart, offset).trim() === '' ? pad : '';
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build an `offset → is inside a ``` fenced block` predicate for `text`.
|
|
86
|
+
*
|
|
87
|
+
* Computed once per rewrite (a single line scan) rather than per marker, so marker
|
|
88
|
+
* rewriting stays linear in the body length.
|
|
89
|
+
*/
|
|
90
|
+
function fencedCodeMask(text: string): (offset: number) => boolean {
|
|
91
|
+
const ranges: Array<[number, number]> = [];
|
|
92
|
+
let open = -1;
|
|
93
|
+
let pos = 0;
|
|
94
|
+
for (const line of text.split('\n')) {
|
|
95
|
+
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
96
|
+
if (open < 0) open = pos;
|
|
97
|
+
else {
|
|
98
|
+
ranges.push([open, pos + line.length]);
|
|
99
|
+
open = -1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
pos += line.length + 1; // +1 for the '\n' consumed by split
|
|
103
|
+
}
|
|
104
|
+
// An unterminated fence runs to the end of the body — treat the remainder as code.
|
|
105
|
+
if (open >= 0) ranges.push([open, text.length]);
|
|
106
|
+
return (offset: number) => ranges.some(([start, end]) => offset >= start && offset < end);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Whether `offset` sits inside a single-backtick code span on its own line. */
|
|
110
|
+
function inInlineCodeSpan(text: string, offset: number): boolean {
|
|
111
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
112
|
+
let ticks = 0;
|
|
113
|
+
for (let i = lineStart; i < offset; i++) if (text[i] === '`') ticks++;
|
|
114
|
+
return ticks % 2 === 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Redact inline `https?://` tokens in synthesis prose whose URL was NOT retrieved
|
|
119
|
+
* this session (absent from `trusted`, a set of normalized verified-source URLs).
|
|
120
|
+
*
|
|
121
|
+
* The synthesis body is otherwise passed through verbatim, so an unverified inline
|
|
122
|
+
* URL is the one channel by which a fabricated source can reach the user — and
|
|
123
|
+
* inline URLs violate the required format (links belong in CITED LINKS only).
|
|
124
|
+
* Legitimate inline references to real sources are preserved (they are present in
|
|
125
|
+
* `trusted`). Returns the (possibly) redacted text.
|
|
126
|
+
*/
|
|
127
|
+
function redactUnverifiedProseUrls(text: string, trusted: Set<string>): string {
|
|
128
|
+
if (trusted.size === 0) return text;
|
|
129
|
+
let removed = 0;
|
|
130
|
+
// Brackets are NOT excluded from the token: a bare `[^\s)\]>"']+` truncates
|
|
131
|
+
// `.../Python_(programming_language)` at the first `)`, so the truncated form
|
|
132
|
+
// never matches its own entry in `trusted` and a perfectly good Wikipedia link
|
|
133
|
+
// gets redacted. Instead take the greedy token and split off exactly the
|
|
134
|
+
// trailing punctuation `normalizeUrl` itself discards — so the compared string
|
|
135
|
+
// and the redacted span always agree — restoring that tail afterwards.
|
|
136
|
+
const result = text.replace(/https?:\/\/[^\s<>"']+/gi, (token) => {
|
|
137
|
+
const core = stripTrailingLlmPunctuation(token);
|
|
138
|
+
const tail = token.slice(core.length);
|
|
139
|
+
if (!core || trusted.has(normalizeUrl(core))) return token;
|
|
140
|
+
removed++;
|
|
141
|
+
return `[link removed: not found in retrieved sources]${tail}`;
|
|
142
|
+
});
|
|
143
|
+
if (removed > 0) {
|
|
144
|
+
logger.warn(`[ResearchSynthesisService] Redacted ${removed} unverified inline URL(s) from synthesis prose (not present in retrieved sources).`);
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
29
149
|
/**
|
|
30
150
|
* Research Synthesis Service
|
|
31
151
|
*
|
|
@@ -178,6 +298,18 @@ export class ResearchSynthesisService implements IService {
|
|
|
178
298
|
let globalCitations: GlobalCitation[] =
|
|
179
299
|
reports.size > 0 ? normalizeCitations(reports).globalCitations : [];
|
|
180
300
|
|
|
301
|
+
// NOTE: a "provenance gate" previously DROPPED citations tagged Scrape/Transcript
|
|
302
|
+
// whose URL was absent from the session's successful-scrape pool. It was REMOVED
|
|
303
|
+
// because it conflated a failed/blocked scrape (real URL, content unretrieved) with
|
|
304
|
+
// fabrication: under the common high scrape-failure rate (bot blocks on GitHub /
|
|
305
|
+
// Microsoft / etc.) it mass-dropped legitimate citations, and the remap below then
|
|
306
|
+
// neutralized their inline [N] markers into a broken '[removed]' token that reached
|
|
307
|
+
// users. We now KEEP every parsed citation (deduped by URL) and never emit
|
|
308
|
+
// '[removed]'; a dangling inline marker is removed cleanly instead. Fabrication
|
|
309
|
+
// defense rests on the URL dedup + the prose-URL sweep, which do not punish failed
|
|
310
|
+
// scrapes. (The successful-scrape pool is still reconciled below to FOLD IN any
|
|
311
|
+
// sources the researchers scraped but failed to cite.)
|
|
312
|
+
|
|
181
313
|
// Robustness fallback: reconcile against ground-truth provenance — the URLs
|
|
182
314
|
// ACTUALLY, successfully scraped this session (registerScrapedLinks only records
|
|
183
315
|
// HTTP-success fetches). This runs even when SOME citations parsed: a
|
|
@@ -216,6 +348,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
216
348
|
|
|
217
349
|
if (globalCitations.length > 0) {
|
|
218
350
|
const verifiedLinksSection = formatCitedLinks(globalCitations);
|
|
351
|
+
const trustedUrls = new Set(globalCitations.map((c) => normalizeUrl(c.url)));
|
|
219
352
|
// Replace an existing CITED LINKS section with the verified version — but only
|
|
220
353
|
// a line-leading, all-caps "CITED LINKS" header marks the section. An
|
|
221
354
|
// unanchored /CITED LINKS[\s\S]*$/i would also fire on an incidental
|
|
@@ -225,31 +358,48 @@ export class ResearchSynthesisService implements IService {
|
|
|
225
358
|
if (citedHeaderIdx >= 0) {
|
|
226
359
|
logger.debug('[ResearchSynthesisService] Replacing existing CITED LINKS with verified version');
|
|
227
360
|
// The verified list is renumbered (dedup by URL, implausible entries dropped),
|
|
228
|
-
// but the body's inline [N] markers were authored against the list being
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
361
|
+
// but the body's inline [N] markers were authored against the list being replaced.
|
|
362
|
+
// Remap them by URL identity — parse the synthesis's own list and map each
|
|
363
|
+
// written number to the global id of the same normalized URL. In deep mode
|
|
364
|
+
// the numbering already matches and this is a no-op; in quick mode (where the
|
|
365
|
+
// model's own list is what gets replaced) it prevents every marker after a
|
|
366
|
+
// dropped/deduped entry from pointing at the wrong source.
|
|
234
367
|
const ownCitations = parseCitations(synthesis);
|
|
235
368
|
const urlToGlobalId = new Map(globalCitations.map((c) => [normalizeUrl(c.url), c.id]));
|
|
236
369
|
const localToGlobal = new Map<number, number>();
|
|
370
|
+
// own-citation numbers whose URL is absent from the verified list. Their inline
|
|
371
|
+
// [N] markers are REMOVED cleanly (never '[removed]') so the prose reads
|
|
372
|
+
// naturally. A marker can dangle only if the lead cited a URL absent from the
|
|
373
|
+
// rebuilt list (e.g. it invented one not in any researcher report); removing it
|
|
374
|
+
// beats shipping a broken token or silently retargeting it at the wrong source.
|
|
375
|
+
const droppedNumbers = new Set<number>();
|
|
237
376
|
ownCitations.forEach((cit, index) => {
|
|
377
|
+
const localKey = cit.number ?? index + 1;
|
|
238
378
|
const globalId = urlToGlobalId.get(normalizeUrl(cit.url));
|
|
239
|
-
if (globalId !== undefined) localToGlobal.set(
|
|
379
|
+
if (globalId !== undefined) localToGlobal.set(localKey, globalId);
|
|
380
|
+
else droppedNumbers.add(localKey);
|
|
240
381
|
});
|
|
241
382
|
let body = synthesis.slice(0, citedHeaderIdx).trimEnd();
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
body = body.replace(/\[(\d+)\]/g, (match, p1) => {
|
|
245
|
-
const globalId = localToGlobal.get(parseInt(p1, 10));
|
|
246
|
-
return globalId !== undefined ? `[${globalId}]` : match;
|
|
247
|
-
});
|
|
383
|
+
if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
|
|
384
|
+
body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
|
|
248
385
|
}
|
|
386
|
+
// Defense-in-depth: redact unverified inline URLs in the prose body. The body
|
|
387
|
+
// is otherwise passed through verbatim, so this is the one channel by which a
|
|
388
|
+
// fabricated source can still reach the user; inline URLs also violate the
|
|
389
|
+
// required format. Verified inline references (in `trustedUrls`) are kept.
|
|
390
|
+
body = redactUnverifiedProseUrls(body, trustedUrls);
|
|
249
391
|
return `${body}\n\n${verifiedLinksSection}`;
|
|
250
392
|
}
|
|
251
393
|
logger.warn('[ResearchSynthesisService] Synthesis missing CITED LINKS - appending verified version');
|
|
252
|
-
|
|
394
|
+
// No existing CITED LINKS header: the synthesis is all prose. The model's
|
|
395
|
+
// inline [N] markers were authored without a list we could remap against, so
|
|
396
|
+
// REMOVE any that fall outside the verified range (1..K) — they point at sources
|
|
397
|
+
// absent from the rebuilt list — before sweeping for unverified inline URLs and
|
|
398
|
+
// appending the verified section. Removed cleanly (never '[removed]').
|
|
399
|
+
const validIds = new Set(globalCitations.map((c) => c.id));
|
|
400
|
+
let prose = rewriteCitationMarkers(synthesis.trim(), new Map(), (n) => !validIds.has(n));
|
|
401
|
+
prose = redactUnverifiedProseUrls(prose, trustedUrls);
|
|
402
|
+
return `${prose}\n\n${verifiedLinksSection}`;
|
|
253
403
|
}
|
|
254
404
|
|
|
255
405
|
// Genuinely no sources were retrieved (e.g. every search/scrape was blocked by
|
|
@@ -257,7 +407,13 @@ export class ResearchSynthesisService implements IService {
|
|
|
257
407
|
// uncited report that looks sourced — but also never destroy a CITED LINKS block
|
|
258
408
|
// the model may have written that we simply could not parse (preserve as-is).
|
|
259
409
|
if (/CITED LINKS/i.test(synthesis)) {
|
|
260
|
-
|
|
410
|
+
// Preserved, but NOT silently: reaching here means no citation parsed AND no
|
|
411
|
+
// scrape succeeded, so every link in that block is unverified by construction.
|
|
412
|
+
// Returning it untouched shipped a fully sourced-looking report with no caveat —
|
|
413
|
+
// the worst case for a reader, since an unretrievable session is precisely when
|
|
414
|
+
// a model's links are most likely to be invented.
|
|
415
|
+
logger.warn('[ResearchSynthesisService] Preserving an unparseable CITED LINKS block from a run that retrieved no sources — flagging it as unverified.');
|
|
416
|
+
return `${synthesis.trimEnd()}\n\n${UNVERIFIED_LINKS_NOTE}`;
|
|
261
417
|
}
|
|
262
418
|
logger.warn('[ResearchSynthesisService] No citations and no scrape provenance — appending explicit no-sources note.');
|
|
263
419
|
return `${synthesis.trim()}\n\n${NO_SOURCES_NOTE}`;
|
|
@@ -28,7 +28,14 @@ export function makeResourceLoader(systemPromptText: string): ResourceLoader {
|
|
|
28
28
|
getThemes: () => ({ themes: [], diagnostics: [] }),
|
|
29
29
|
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
30
30
|
getSystemPrompt: () => systemPromptText,
|
|
31
|
+
// pi 0.83.0 added these to the ResourceLoader contract (used by interactive
|
|
32
|
+
// mode's startup context listing to show file-backed SYSTEM.md /
|
|
33
|
+
// APPEND_SYSTEM.md paths). Our sub-session prompts are in-memory text with
|
|
34
|
+
// no backing file, so "no source" is the accurate answer. Harmless extras
|
|
35
|
+
// on pre-0.83 hosts.
|
|
36
|
+
getSystemPromptSource: () => undefined,
|
|
31
37
|
getAppendSystemPrompt: () => [],
|
|
38
|
+
getAppendSystemPromptSources: () => [],
|
|
32
39
|
extendResources: () => {},
|
|
33
40
|
reload: async () => {},
|
|
34
41
|
};
|