@lincoln504/pi-research 1.0.10 → 1.1.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 +20 -8
- package/dist/cli.mjs +538 -66
- package/dist/thread-worker.mjs +39 -1
- package/docs/ARCHITECTURE.md +24 -0
- package/docs/CHANGELOG.md +40 -0
- package/docs/CONFIGURATION.md +2 -0
- package/docs/SDK.md +9 -0
- package/package.json +5 -2
- package/skills/pi-research/SKILL.md +11 -2
- package/src/cli.ts +40 -2
- package/src/core/interfaces/observer-interfaces.ts +9 -0
- 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 +36 -4
- 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/task-execution-service.ts +86 -9
- 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 +323 -0
- package/src/infrastructure/service-initialization.ts +20 -0
- package/src/orchestration/deep-research-orchestrator.ts +11 -0
- package/src/orchestration/headless-observer.ts +4 -0
- package/src/orchestration/research-orchestration-service.ts +70 -0
- package/src/orchestration/research-synthesis-service.ts +171 -15
- 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
|
@@ -22,6 +22,46 @@ import type { ISchedulerFactory, IScheduler } from '../../core/scheduler-factory
|
|
|
22
22
|
let lastRestartTime = 0;
|
|
23
23
|
const RESTART_COOLDOWN_MS = 10000;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Extra attempts reserved for a *leader handover* — the elected browser-pool leader
|
|
27
|
+
* exiting while this process still has work in flight ("Worker pool is shutting
|
|
28
|
+
* down" / "draining").
|
|
29
|
+
*
|
|
30
|
+
* This has its own budget because it is not a random socket blip: it is a known,
|
|
31
|
+
* self-healing window that lasts as long as a fresh election takes (measured at
|
|
32
|
+
* ~4 s on a loaded machine), and it hits *every* in-flight task of the affected
|
|
33
|
+
* run at once. With only the single generic retry, a whole 20-query search burst
|
|
34
|
+
* could be wiped out by one leader exiting — the run then failed outright with
|
|
35
|
+
* "all N queries encountered worker errors", seconds before the new leader came up.
|
|
36
|
+
* Spending these attempts is strictly cheaper than losing the run.
|
|
37
|
+
*/
|
|
38
|
+
const LEADER_HANDOVER_RETRIES = 3;
|
|
39
|
+
/** Backoff between handover attempts — long enough for an election to complete. */
|
|
40
|
+
const HANDOVER_BACKOFF_MS = 1200;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Recover from a leader handover before retrying.
|
|
44
|
+
*
|
|
45
|
+
* Critically this DROPS the cached scheduler handle. A pool-shutdown error means
|
|
46
|
+
* the leader we are connected to is going away, so the cached client points at a
|
|
47
|
+
* corpse; simply waiting and retrying reconnects to the same dead port (observed
|
|
48
|
+
* as a retry logging "Connecting to existing scheduler" with the *same* version id
|
|
49
|
+
* and failing identically). Clearing it forces the next `getScheduler()` to
|
|
50
|
+
* re-resolve — joining the new leader, or winning the election itself.
|
|
51
|
+
*
|
|
52
|
+
* `forceSchedulerRestart(false)` keeps its own liveness probe and in-progress
|
|
53
|
+
* guard, so concurrent callers coalesce instead of stampeding.
|
|
54
|
+
*/
|
|
55
|
+
async function recoverFromLeaderHandover(container: ServiceContainer): Promise<void> {
|
|
56
|
+
logger.warn('[BrowserManager] Leader handover detected (pool draining) — dropping stale scheduler handle and re-resolving...');
|
|
57
|
+
await forceSchedulerRestart(false, container).catch((err) =>
|
|
58
|
+
logger.debug('[BrowserManager] Scheduler re-resolve during handover failed (non-fatal):', err),
|
|
59
|
+
);
|
|
60
|
+
await waitForBrowserPoolIdle(15000).catch((err) =>
|
|
61
|
+
logger.debug('Wait for browser idle timed out or failed:', err),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
25
65
|
/**
|
|
26
66
|
* Get the scheduler from the factory, optionally using a specific container.
|
|
27
67
|
*/
|
|
@@ -56,7 +96,8 @@ export async function runBrowserTask<T>(
|
|
|
56
96
|
config?: Config,
|
|
57
97
|
signal?: AbortSignal,
|
|
58
98
|
retries = 1,
|
|
59
|
-
container: ServiceContainer = getServiceContainer()
|
|
99
|
+
container: ServiceContainer = getServiceContainer(),
|
|
100
|
+
handoverRetries = LEADER_HANDOVER_RETRIES,
|
|
60
101
|
): Promise<T> {
|
|
61
102
|
if (signal?.aborted) throw new Error('Aborted');
|
|
62
103
|
|
|
@@ -84,6 +125,23 @@ export async function runBrowserTask<T>(
|
|
|
84
125
|
} catch (error: unknown) {
|
|
85
126
|
if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
|
|
86
127
|
|
|
128
|
+
// A leader handover gets its own budget: it is a recoverable, seconds-long
|
|
129
|
+
// window that strikes every in-flight task at once, so it must not be
|
|
130
|
+
// charged against (or limited by) the single generic transient retry.
|
|
131
|
+
const isHandover = isPoolShutdownError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error);
|
|
132
|
+
if (isHandover && handoverRetries > 0) {
|
|
133
|
+
errorTracker.trackError(error, {
|
|
134
|
+
component: 'browser-manager',
|
|
135
|
+
operation: type,
|
|
136
|
+
taskType: type,
|
|
137
|
+
errorType: 'leader_handover',
|
|
138
|
+
});
|
|
139
|
+
logger.warn(`[BrowserManager] Leader handover during ${type} task (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
140
|
+
await recoverFromLeaderHandover(container);
|
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
|
|
142
|
+
return runBrowserTask<T>(taskOrUrl, type, config, signal, retries, container, handoverRetries - 1);
|
|
143
|
+
}
|
|
144
|
+
|
|
87
145
|
if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
|
|
88
146
|
errorTracker.trackError(error, {
|
|
89
147
|
component: 'browser-manager',
|
|
@@ -92,9 +150,9 @@ export async function runBrowserTask<T>(
|
|
|
92
150
|
errorType: 'transient_socket_error',
|
|
93
151
|
});
|
|
94
152
|
logger.warn(`[BrowserManager] Transient socket error during ${type} task (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
95
|
-
|
|
153
|
+
|
|
96
154
|
if (isPoolShutdownError(error)) {
|
|
97
|
-
//
|
|
155
|
+
// Handover budget already spent — fall back to a plain drain wait.
|
|
98
156
|
logger.warn(`[BrowserManager] Pool is draining — waiting for pool idle before retry...`);
|
|
99
157
|
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
100
158
|
} else {
|
|
@@ -120,7 +178,7 @@ export async function runBrowserTask<T>(
|
|
|
120
178
|
// Retry with jitter (100-500ms) to prevent thundering herd
|
|
121
179
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
122
180
|
await new Promise(resolve => setTimeout(resolve, jitter));
|
|
123
|
-
return runBrowserTask<T>(taskOrUrl, type, config, signal, retries - 1, container);
|
|
181
|
+
return runBrowserTask<T>(taskOrUrl, type, config, signal, retries - 1, container, handoverRetries);
|
|
124
182
|
}
|
|
125
183
|
throw error;
|
|
126
184
|
}
|
|
@@ -143,9 +201,12 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
|
|
|
143
201
|
errorType: 'transient_socket_error',
|
|
144
202
|
});
|
|
145
203
|
logger.warn(`[BrowserManager] Transient socket error during healthcheck (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
146
|
-
|
|
204
|
+
|
|
147
205
|
if (isPoolShutdownError(error)) {
|
|
148
|
-
|
|
206
|
+
// Same stale-handle trap as the task paths: waiting alone leaves the
|
|
207
|
+
// cached client aimed at the departing leader, so the retry re-probes
|
|
208
|
+
// a corpse and reports the pool unhealthy during a routine handover.
|
|
209
|
+
await recoverFromLeaderHandover(container);
|
|
149
210
|
} else {
|
|
150
211
|
// See runBrowserTask: don't force-clear remote leader state on a bare
|
|
151
212
|
// transient socket error — let the liveness probe inside
|
|
@@ -172,7 +233,7 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
|
|
|
172
233
|
/**
|
|
173
234
|
* Run a worker search query with retry logic.
|
|
174
235
|
*/
|
|
175
|
-
export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer()): Promise<SearchResult[]> {
|
|
236
|
+
export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES): Promise<SearchResult[]> {
|
|
176
237
|
if (signal?.aborted) throw new Error('Aborted');
|
|
177
238
|
|
|
178
239
|
const breaker = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
|
|
@@ -186,6 +247,22 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
186
247
|
} catch (error: unknown) {
|
|
187
248
|
if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
|
|
188
249
|
|
|
250
|
+
// See runBrowserTask: a leader handover wipes out an entire search burst at
|
|
251
|
+
// once, so it gets its own attempts rather than the single generic retry.
|
|
252
|
+
const isHandover = isPoolShutdownError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error);
|
|
253
|
+
if (isHandover && handoverRetries > 0) {
|
|
254
|
+
errorTracker.trackError(error, {
|
|
255
|
+
component: 'browser-manager',
|
|
256
|
+
operation: 'search',
|
|
257
|
+
query,
|
|
258
|
+
errorType: 'leader_handover',
|
|
259
|
+
});
|
|
260
|
+
logger.warn(`[BrowserManager] Leader handover during search (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
261
|
+
await recoverFromLeaderHandover(container);
|
|
262
|
+
await new Promise((resolve) => setTimeout(resolve, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
|
|
263
|
+
return runWorkerSearch(query, config, signal, retries, sessionId, container, handoverRetries - 1);
|
|
264
|
+
}
|
|
265
|
+
|
|
189
266
|
if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
|
|
190
267
|
errorTracker.trackError(error, {
|
|
191
268
|
component: 'browser-manager',
|
|
@@ -194,7 +271,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
194
271
|
errorType: 'transient_socket_error',
|
|
195
272
|
});
|
|
196
273
|
logger.warn(`[BrowserManager] Transient socket error during search (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
197
|
-
|
|
274
|
+
|
|
198
275
|
if (isPoolShutdownError(error)) {
|
|
199
276
|
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
200
277
|
} else {
|
|
@@ -214,7 +291,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
214
291
|
|
|
215
292
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
216
293
|
await new Promise(resolve => setTimeout(resolve, jitter));
|
|
217
|
-
return runWorkerSearch(query, config, signal, retries - 1, sessionId, container);
|
|
294
|
+
return runWorkerSearch(query, config, signal, retries - 1, sessionId, container, handoverRetries);
|
|
218
295
|
}
|
|
219
296
|
throw error;
|
|
220
297
|
}
|
|
@@ -1356,7 +1356,10 @@ function isTransientSocketError(error) {
|
|
|
1356
1356
|
err.message.includes("Worker pool is shutting down") || // Poolifier throws this when pool.execute() is called while the pool is being
|
|
1357
1357
|
// destroyed (e.g., during scheduler restart). Treat as transient — the pool will
|
|
1358
1358
|
// be ready for new tasks once destruction completes and a new pool is initialized.
|
|
1359
|
-
err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool")
|
|
1359
|
+
err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // The leader answered 503 because it is shutting down after losing
|
|
1360
|
+
// leadership. Retrying re-elects and reaches the new leader, so this is
|
|
1361
|
+
// transient in exactly the same sense as a pool drain.
|
|
1362
|
+
err.message.includes("Browser pool leader is draining"));
|
|
1360
1363
|
}
|
|
1361
1364
|
function isCloudflareBlockError(error) {
|
|
1362
1365
|
if (!error || typeof error !== "object") return false;
|
|
@@ -1368,6 +1371,11 @@ function isTaskTimeoutError(error) {
|
|
|
1368
1371
|
const err = error;
|
|
1369
1372
|
return typeof err.message === "string" && (err.message.includes("timed out after") || err.message.includes("Search task timed out") || err.message.includes("Scrape task timed out") || err.message.includes("Health check timed out") || err.message.includes("[BrowserClient] Request to"));
|
|
1370
1373
|
}
|
|
1374
|
+
function isServerDrainingError(error) {
|
|
1375
|
+
if (!error || typeof error !== "object") return false;
|
|
1376
|
+
const err = error;
|
|
1377
|
+
return typeof err.message === "string" && err.message.includes("Browser pool leader is draining");
|
|
1378
|
+
}
|
|
1371
1379
|
function isPoolShutdownError(error) {
|
|
1372
1380
|
if (!error || typeof error !== "object") return false;
|
|
1373
1381
|
const err = error;
|
|
@@ -1389,6 +1397,7 @@ var init_browser_error_utils = __esm({
|
|
|
1389
1397
|
name: "BrowserPool",
|
|
1390
1398
|
isTransientError: (error) => {
|
|
1391
1399
|
if (isPoolShutdownError(error)) return false;
|
|
1400
|
+
if (isServerDrainingError(error)) return false;
|
|
1392
1401
|
if (isCloudflareBlockError(error)) return false;
|
|
1393
1402
|
if (isTaskTimeoutError(error)) return false;
|
|
1394
1403
|
return isTransientSocketError(error);
|
|
@@ -1404,6 +1413,7 @@ __export(scraper_utils_exports, {
|
|
|
1404
1413
|
createJsMarkdownConverter: () => createJsMarkdownConverter,
|
|
1405
1414
|
createNativeMarkdownConverter: () => createNativeMarkdownConverter,
|
|
1406
1415
|
extractDomain: () => extractDomain,
|
|
1416
|
+
formatErrorWithCause: () => formatErrorWithCause,
|
|
1407
1417
|
getRandomUserAgent: () => getRandomUserAgent,
|
|
1408
1418
|
getSsrfSafeDispatcher: () => getSsrfSafeDispatcher,
|
|
1409
1419
|
isBenignScrapeFailure: () => isBenignScrapeFailure,
|
|
@@ -1535,6 +1545,34 @@ async function getSsrfSafeDispatcher() {
|
|
|
1535
1545
|
}
|
|
1536
1546
|
return cachedSsrfDispatcher;
|
|
1537
1547
|
}
|
|
1548
|
+
function formatErrorWithCause(err) {
|
|
1549
|
+
try {
|
|
1550
|
+
const parts = [];
|
|
1551
|
+
let current = err;
|
|
1552
|
+
for (let depth = 0; current !== null && current !== void 0 && depth < 5; depth++) {
|
|
1553
|
+
let segment;
|
|
1554
|
+
if (current instanceof Error) {
|
|
1555
|
+
segment = current.message || current.name;
|
|
1556
|
+
const code = current.code;
|
|
1557
|
+
if (typeof code === "string" && code && !segment.includes(code)) {
|
|
1558
|
+
segment = `${segment} [${code}]`;
|
|
1559
|
+
}
|
|
1560
|
+
} else if (typeof current === "string") {
|
|
1561
|
+
segment = current;
|
|
1562
|
+
} else {
|
|
1563
|
+
segment = String(current);
|
|
1564
|
+
}
|
|
1565
|
+
if (segment && !parts.includes(segment)) parts.push(segment);
|
|
1566
|
+
const next = current.cause;
|
|
1567
|
+
if (next === current) break;
|
|
1568
|
+
current = next;
|
|
1569
|
+
}
|
|
1570
|
+
if (parts.length === 0) return String(err);
|
|
1571
|
+
return parts.length === 1 ? parts[0] : parts.join(" \u2190 ");
|
|
1572
|
+
} catch {
|
|
1573
|
+
return String(err);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1538
1576
|
function isPrivateIp(ip) {
|
|
1539
1577
|
const parts = ip.split(".").map(Number);
|
|
1540
1578
|
if (parts.length !== 4 || parts.some((p) => isNaN(p))) return false;
|
|
@@ -119,9 +119,27 @@ export class ProcessLifecycleService implements IProcessLifecycle {
|
|
|
119
119
|
if (expectedStartTime !== undefined && expectedStartTime !== null) {
|
|
120
120
|
const actualStartTime = await this.getProcessStartTime(pid);
|
|
121
121
|
if (actualStartTime === null) {
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
122
|
+
// signal(0) above confirmed a live process at this PID, but we couldn't
|
|
123
|
+
// resolve its start time to verify it's the SAME instance (the PID-reuse
|
|
124
|
+
// guard). The two failure modes collapse to the same null here but must
|
|
125
|
+
// NOT be treated identically:
|
|
126
|
+
// • Linux: usually the process exited in the microsecond gap between
|
|
127
|
+
// signal(0) and the /proc/{pid}/stat read → genuinely dead.
|
|
128
|
+
// • Windows/macOS: far more often the start-time lookup subprocess
|
|
129
|
+
// (powershell `Get-Process` / `ps`) simply failed or hit its timeout
|
|
130
|
+
// while the process is still very much alive → NOT dead.
|
|
131
|
+
// Reporting that confirmed-alive PID as DEAD is catastrophic for every
|
|
132
|
+
// caller — a live lock/leader/run-cap owner is reclaimed from under it
|
|
133
|
+
// → two writers collide (lost update) or the run-cap admits extra
|
|
134
|
+
// concurrent runs (the flaky duplicate-slot/cap-breach we saw on Windows
|
|
135
|
+
// CI). So re-confirm liveness instead of guessing: if signal(0) STILL
|
|
136
|
+
// succeeds the process is alive → return true; if it now throws it died
|
|
137
|
+
// between the two probes and the outer catch returns false. A genuine
|
|
138
|
+
// death is therefore still detected within one poll tick (~100ms); the
|
|
139
|
+
// only behavioural change is that a confirmed-alive PID is never again
|
|
140
|
+
// misreported as dead on a transient start-time lookup failure.
|
|
141
|
+
process.kill(pid, 0); // re-confirm; a throw (process died) → outer catch → false
|
|
142
|
+
return true;
|
|
125
143
|
}
|
|
126
144
|
// Allow ±1s slack. The macOS/BSD fallback derives start time as
|
|
127
145
|
// floor(Date.now()/1000) - `ps etimes` (elapsed whole seconds); both terms are
|
|
@@ -0,0 +1,323 @@
|
|
|
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 (by default
|
|
25
|
+
* {@link DEFAULT_ACQUIRE_TIMEOUT_MS} — extra runs **queue** behind the in-flight
|
|
26
|
+
* ones instead of failing), then rejects with {@link ResearchRunCapacityError}
|
|
27
|
+
* rather than degrading into a 60-error run.
|
|
28
|
+
* - **Release** unlinks the slot file via the captured slot service.
|
|
29
|
+
* - **Crash recovery** is automatic and PID-reuse-safe: a slot held by a process
|
|
30
|
+
* that has died (killed, crashed, SIGKILLed by earlyoom) is reclaimed
|
|
31
|
+
* immediately on the next acquire attempt, because `FileLockService` checks the
|
|
32
|
+
* owner's PID+startTime liveness before treating a lock as stale.
|
|
33
|
+
*
|
|
34
|
+
* ### Why a *fresh* FileLockService per attempt
|
|
35
|
+
* `FileLockService.acquireLock()` serializes callers on a per-instance FIFO queue
|
|
36
|
+
* and only releases that queue turn in `releaseLock()` — i.e. after the run ends.
|
|
37
|
+
* Reusing a persistent per-slot instance would therefore **deadlock concurrent
|
|
38
|
+
* research starts within one process**: the first holder keeps slot[i]'s queue
|
|
39
|
+
* turn for minutes, blocking a second acquire on that slot forever (it would
|
|
40
|
+
* never reach the other slots). A throwaway instance per attempt has an empty
|
|
41
|
+
* queue, so concurrent attempts never block on stale queue state — while still
|
|
42
|
+
* reusing all of FileLockService's verified atomic-create / liveness / reclaim
|
|
43
|
+
* logic. Two concurrent attempts on the *same* slot simply race on `fs.open(wx)`:
|
|
44
|
+
* one wins, the other reads a live owner and fails fast to the next slot.
|
|
45
|
+
*
|
|
46
|
+
* ### Why `liveOwnerStaleThreshold` is effectively infinite
|
|
47
|
+
* A research run legitimately holds its slot for *minutes*. `FileLockService`
|
|
48
|
+
* would normally steal a lock from a *live* owner once it ages past
|
|
49
|
+
* `liveOwnerStaleThreshold` (to recover a process stalled mid-critical-section).
|
|
50
|
+
* For a run-cap that is exactly wrong: stealing a live run's slot would silently
|
|
51
|
+
* admit an (N+1)th concurrent run — reintroducing the saturation this guard
|
|
52
|
+
* exists to prevent. So slots are configured to *never* steal from a live owner;
|
|
53
|
+
* only provably-dead owners are reclaimed (immediately, regardless of age).
|
|
54
|
+
*
|
|
55
|
+
* ### Concurrency within one process
|
|
56
|
+
* `acquire()` returns an independent {@link RunSlotAcquisition} handle capturing
|
|
57
|
+
* its own slot service; it stores *no* shared "held slot" state. Two concurrent
|
|
58
|
+
* `runResearch` calls on the same singleton service each acquire a distinct slot
|
|
59
|
+
* safely (see "fresh instance per attempt" above), with no risk of one release
|
|
60
|
+
* clobbering the other's tracking.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import * as pathmod from 'node:path';
|
|
64
|
+
import { logger } from '../logger.ts';
|
|
65
|
+
import { metrics } from '../utils/metrics.ts';
|
|
66
|
+
import { FileLockService } from './file-lock-service.ts';
|
|
67
|
+
import { ServiceLifecycle } from '../core/service-registry.ts';
|
|
68
|
+
import { ServiceNames } from '../core/interfaces/service-names.ts';
|
|
69
|
+
import type { IService } from '../core/service-registry.ts';
|
|
70
|
+
import type { IProcessLifecycle } from '../core/interfaces/process-interfaces.ts';
|
|
71
|
+
|
|
72
|
+
/** Default maximum number of concurrent research runs across all processes. */
|
|
73
|
+
export const DEFAULT_MAX_CONCURRENT_RUNS = 3;
|
|
74
|
+
/**
|
|
75
|
+
* Default per-acquire wait before giving up when all slots are held by live runs.
|
|
76
|
+
*
|
|
77
|
+
* This is deliberately a *queue*, not a fail-fast. The cap exists to stop N runs
|
|
78
|
+
* saturating the shared browser/embedding pool — but an extra run is a legitimate
|
|
79
|
+
* request to do work, not an error, so the correct response to contention is to
|
|
80
|
+
* serialize it behind the in-flight runs rather than to fail it. With a depth-1 run
|
|
81
|
+
* measured at ~2-3 minutes, 10 minutes absorbs a backlog of roughly three further
|
|
82
|
+
* runs per slot while still resolving well inside the agent-skill's documented
|
|
83
|
+
* command timeouts (so a queued run never looks like a hang to the caller).
|
|
84
|
+
*
|
|
85
|
+
* Set `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS=0` to restore strict fail-fast.
|
|
86
|
+
*/
|
|
87
|
+
export const DEFAULT_ACQUIRE_TIMEOUT_MS = 600_000;
|
|
88
|
+
/** Poll interval between full slot sweeps while waiting for a slot. */
|
|
89
|
+
const POLL_DELAY_MS = 250;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Thrown when every run slot is held by a live process and the acquire timeout
|
|
93
|
+
* has elapsed. Distinct from internal/IO errors so callers can treat
|
|
94
|
+
* "capacity exhausted" (retry later) differently from "the semaphore is broken".
|
|
95
|
+
*/
|
|
96
|
+
export class ResearchRunCapacityError extends Error {
|
|
97
|
+
readonly slots: number;
|
|
98
|
+
constructor(slots: number, maxWaitMs: number) {
|
|
99
|
+
super(
|
|
100
|
+
`Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` and still busy after queueing for ${Math.round(maxWaitMs / 1000)}s` : ''}; ` +
|
|
101
|
+
`another run is still in progress. This is temporary — retry shortly. ` +
|
|
102
|
+
`(Raise the cap with PI_RESEARCH_MAX_CONCURRENT_RUNS, or the queue wait with ` +
|
|
103
|
+
`PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS.)`,
|
|
104
|
+
);
|
|
105
|
+
this.name = 'ResearchRunCapacityError';
|
|
106
|
+
this.slots = slots;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A held run slot. Call {@link RunSlotAcquisition.release} exactly once when the run ends. */
|
|
111
|
+
export interface RunSlotAcquisition {
|
|
112
|
+
/** The 0-based slot index that was acquired (observability). */
|
|
113
|
+
readonly slotIndex: number;
|
|
114
|
+
/** Release the slot back to the pool. Safe to call once; subsequent calls are no-ops. */
|
|
115
|
+
release(): Promise<void>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Counting semaphore over N slot files coordinated with {@link FileLockService}.
|
|
120
|
+
*/
|
|
121
|
+
export class ResearchRunSemaphore implements IService {
|
|
122
|
+
readonly name = ServiceNames.RESEARCH_RUN_SEMAPHORE;
|
|
123
|
+
lifecycle = ServiceLifecycle.UNINITIALIZED;
|
|
124
|
+
|
|
125
|
+
private readonly slotDir: string;
|
|
126
|
+
private readonly processLifecycle: IProcessLifecycle | null;
|
|
127
|
+
private readonly maxSlots: number;
|
|
128
|
+
private readonly defaultMaxWaitMs: number;
|
|
129
|
+
/** Rotating start index so contending processes don't all hammer slot 0 first. */
|
|
130
|
+
private nextStartIndex = 0;
|
|
131
|
+
private _initialized = false;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param slotDir Directory holding the `research-run-slot-<i>.lock` files.
|
|
135
|
+
* @param processLifecycle PID+startTime liveness source (PID-reuse-safe reclamation).
|
|
136
|
+
* @param maxSlots N — maximum concurrent runs. Defaults from env then {@link DEFAULT_MAX_CONCURRENT_RUNS}.
|
|
137
|
+
* @param defaultMaxWaitMs Default acquire wait. Defaults from env then {@link DEFAULT_ACQUIRE_TIMEOUT_MS}.
|
|
138
|
+
*/
|
|
139
|
+
constructor(slotDir: string, processLifecycle: IProcessLifecycle | null, maxSlots?: number, defaultMaxWaitMs?: number) {
|
|
140
|
+
this.slotDir = slotDir;
|
|
141
|
+
this.processLifecycle = processLifecycle;
|
|
142
|
+
this.maxSlots = Math.max(1, Math.floor(
|
|
143
|
+
maxSlots ?? numberFromEnv('PI_RESEARCH_MAX_CONCURRENT_RUNS') ?? DEFAULT_MAX_CONCURRENT_RUNS,
|
|
144
|
+
));
|
|
145
|
+
this.defaultMaxWaitMs = Math.max(0, Math.floor(
|
|
146
|
+
defaultMaxWaitMs ?? numberFromEnv('PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS') ?? DEFAULT_ACQUIRE_TIMEOUT_MS,
|
|
147
|
+
));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Number of slots (max concurrent runs). */
|
|
151
|
+
getMaxSlots(): number {
|
|
152
|
+
return this.maxSlots;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async initialize(): Promise<void> {
|
|
156
|
+
if (this._initialized) return;
|
|
157
|
+
this.lifecycle = ServiceLifecycle.INITIALIZING;
|
|
158
|
+
// Ensure the slot directory exists; slot files are created lazily on acquire.
|
|
159
|
+
try {
|
|
160
|
+
await import('node:fs/promises').then((fs) => fs.mkdir(this.slotDir, { recursive: true, mode: 0o700 }));
|
|
161
|
+
} catch (err) {
|
|
162
|
+
logger.warn(`[ResearchRunSemaphore] Could not create slot dir ${this.slotDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
163
|
+
}
|
|
164
|
+
this._initialized = true;
|
|
165
|
+
this.lifecycle = ServiceLifecycle.INITIALIZED;
|
|
166
|
+
logger.log(`[ResearchRunSemaphore] Initialized with ${this.maxSlots} run slot(s) (acquire timeout ${this.defaultMaxWaitMs}ms).`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async dispose(): Promise<void> {
|
|
170
|
+
// Held slots are released by their RunSlotAcquisition handles (the runResearch
|
|
171
|
+
// finally block). A hard process exit leaves slot files behind, which the next
|
|
172
|
+
// acquire reclaims via PID+startTime liveness — so dispose has nothing to do.
|
|
173
|
+
this.lifecycle = ServiceLifecycle.DISPOSED;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Try to win one slot NON-BLOCKING. Builds a fresh {@link FileLockService}
|
|
178
|
+
* configured as a try-take (see class doc: "fresh instance per attempt").
|
|
179
|
+
* Returns an acquisition on success, or null if the slot is busy / lost a race.
|
|
180
|
+
*/
|
|
181
|
+
private async trySlot(slotIndex: number): Promise<RunSlotAcquisition | null> {
|
|
182
|
+
const slot = new FileLockService({
|
|
183
|
+
lockFilePath: pathmod.join(this.slotDir, `research-run-slot-${slotIndex}.lock`),
|
|
184
|
+
lockTimeout: 0, // fail immediately on a live-contended slot (try-take)
|
|
185
|
+
lockRetries: 24, // enough iterations to complete a dead-owner reclaim + reacquire
|
|
186
|
+
lockRetryDelay: 50,
|
|
187
|
+
lockStaleThreshold: 15000, // legacy (no-pid) locks: reclaim once 15s old
|
|
188
|
+
// A live process holding a run slot for the entire (minutes-long) run is the
|
|
189
|
+
// NORMAL case, not a stall. Never steal from a live owner — doing so would
|
|
190
|
+
// admit an (N+1)th concurrent run, the exact condition this guard prevents.
|
|
191
|
+
// Dead owners are reclaimed immediately regardless of age.
|
|
192
|
+
liveOwnerStaleThreshold: Number.MAX_SAFE_INTEGER,
|
|
193
|
+
processLifecycle: this.processLifecycle ?? undefined,
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
await slot.initialize(); // resolves own startTime (cached) + startup stale cleanup
|
|
197
|
+
await slot.acquireLock(); // try-take: wins, reclaims a dead owner, or throws fast
|
|
198
|
+
} catch {
|
|
199
|
+
return null; // busy (live owner) or lost a reclaim race — try the next slot
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
slotIndex,
|
|
203
|
+
release: async () => {
|
|
204
|
+
try {
|
|
205
|
+
await slot.releaseLock();
|
|
206
|
+
} catch (err) {
|
|
207
|
+
logger.warn(`[ResearchRunSemaphore] Failed to release slot ${slotIndex}: ${err instanceof Error ? err.message : String(err)}`);
|
|
208
|
+
} finally {
|
|
209
|
+
// Clear the gauge even if the unlink failed: the slot is no longer held
|
|
210
|
+
// by THIS process either way, and a gauge that only ever ratchets to 1
|
|
211
|
+
// reports permanent saturation for the life of the process.
|
|
212
|
+
metrics.setGauge('research_run_slots_held', 0, { slot: String(slotIndex) });
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Acquire one run slot, waiting up to `maxWaitMs` (defaults to the constructed
|
|
220
|
+
* value) when all slots are held by live processes. Resolves with a handle that
|
|
221
|
+
* must be released when the run ends.
|
|
222
|
+
*
|
|
223
|
+
* @param signal aborts the wait. Without it a run started under a long
|
|
224
|
+
* `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` would ignore Ctrl-C until the deadline
|
|
225
|
+
* passed, because the wait happens before any orchestrator exists to cancel.
|
|
226
|
+
* @param onQueued invoked at most once, when the first full sweep finds every
|
|
227
|
+
* slot busy and the call is about to wait. Lets a front-end tell the user the
|
|
228
|
+
* run is queued rather than hung. Never allowed to break the acquire.
|
|
229
|
+
* @throws {ResearchRunCapacityError} if no slot frees within `maxWaitMs`.
|
|
230
|
+
* @throws {Error} named `AbortError` if `signal` aborts while waiting.
|
|
231
|
+
*/
|
|
232
|
+
async acquire(
|
|
233
|
+
maxWaitMs: number = this.defaultMaxWaitMs,
|
|
234
|
+
signal?: AbortSignal,
|
|
235
|
+
onQueued?: (slots: number, maxWaitMs: number) => void,
|
|
236
|
+
): Promise<RunSlotAcquisition> {
|
|
237
|
+
if (!this._initialized) await this.initialize();
|
|
238
|
+
if (signal?.aborted) {
|
|
239
|
+
metrics.increment('research_run_acquire_total', 1, { status: 'aborted' });
|
|
240
|
+
throwIfAborted(signal);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const deadline = Date.now() + Math.max(0, maxWaitMs);
|
|
244
|
+
let announcedQueued = false;
|
|
245
|
+
for (;;) {
|
|
246
|
+
// Round-robin from a rotating start so contending processes don't synchronize on slot 0.
|
|
247
|
+
const start = this.nextStartIndex;
|
|
248
|
+
this.nextStartIndex = (this.nextStartIndex + 1) % this.maxSlots;
|
|
249
|
+
for (let offset = 0; offset < this.maxSlots; offset++) {
|
|
250
|
+
const i = (start + offset) % this.maxSlots;
|
|
251
|
+
const got = await this.trySlot(i);
|
|
252
|
+
if (got) {
|
|
253
|
+
metrics.setGauge('research_run_slots_held', 1, { slot: String(i) });
|
|
254
|
+
metrics.increment('research_run_acquire_total', 1, { status: 'acquired' });
|
|
255
|
+
return got;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Every slot is held by a live process.
|
|
259
|
+
if (Date.now() >= deadline) {
|
|
260
|
+
metrics.increment('research_run_acquire_total', 1, { status: 'exhausted' });
|
|
261
|
+
throw new ResearchRunCapacityError(this.maxSlots, maxWaitMs);
|
|
262
|
+
}
|
|
263
|
+
// Announce the queue exactly once, on the first sweep that found nothing free.
|
|
264
|
+
// A throwing front-end callback must never fail the acquire.
|
|
265
|
+
if (!announcedQueued) {
|
|
266
|
+
announcedQueued = true;
|
|
267
|
+
logger.info(`[ResearchRunSemaphore] All ${this.maxSlots} run slot(s) busy — queueing for up to ${maxWaitMs}ms.`);
|
|
268
|
+
try {
|
|
269
|
+
onQueued?.(this.maxSlots, maxWaitMs);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
logger.debug(`[ResearchRunSemaphore] onQueued callback threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Count the aborted outcome too, so acquired + exhausted + aborted accounts for
|
|
275
|
+
// every acquire attempt. A cancel that vanished from the counter would make the
|
|
276
|
+
// capacity metrics quietly under-report demand.
|
|
277
|
+
try {
|
|
278
|
+
await sleep(POLL_DELAY_MS, signal);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
metrics.increment('research_run_acquire_total', 1, { status: 'aborted' });
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
288
|
+
if (signal?.aborted) {
|
|
289
|
+
throw Object.assign(new Error('Research run slot acquisition aborted'), { name: 'AbortError' });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function numberFromEnv(key: string): number | undefined {
|
|
294
|
+
const raw = process.env[key];
|
|
295
|
+
if (raw === undefined || raw === '') return undefined;
|
|
296
|
+
const n = Number(raw);
|
|
297
|
+
return Number.isFinite(n) ? n : undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Poll delay that resolves early (rejecting) on abort.
|
|
302
|
+
*
|
|
303
|
+
* The timer is deliberately NOT `unref`'d. Waiting for a run slot is foreground work: a process
|
|
304
|
+
* whose only pending handle is this timer must stay alive until the slot frees, and an unref'd
|
|
305
|
+
* timer would let Node drain the loop and exit mid-wait (observed as an early exit in the
|
|
306
|
+
* multi-process harness). The timer is always settled — it either fires or is cleared on abort —
|
|
307
|
+
* so keeping it referenced cannot outlive the acquire.
|
|
308
|
+
*/
|
|
309
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
310
|
+
return new Promise((resolve, reject) => {
|
|
311
|
+
const timer = setTimeout(() => {
|
|
312
|
+
if (onAbort) signal?.removeEventListener('abort', onAbort);
|
|
313
|
+
resolve();
|
|
314
|
+
}, ms);
|
|
315
|
+
const onAbort = signal
|
|
316
|
+
? () => {
|
|
317
|
+
clearTimeout(timer);
|
|
318
|
+
reject(Object.assign(new Error('Research run slot acquisition aborted'), { name: 'AbortError' }));
|
|
319
|
+
}
|
|
320
|
+
: undefined;
|
|
321
|
+
if (onAbort) signal?.addEventListener('abort', onAbort, { once: true });
|
|
322
|
+
});
|
|
323
|
+
}
|
|
@@ -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) {
|
|
@@ -95,6 +95,10 @@ export class HeadlessObserver implements ResearchObserver {
|
|
|
95
95
|
this.emit('start', { query, complexity });
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
onRunQueued(slots: number, maxWaitMs: number): void {
|
|
99
|
+
this.emit('run_queued', { slots, maxWaitMs });
|
|
100
|
+
}
|
|
101
|
+
|
|
98
102
|
onPlanningStart(attempt: number): void {
|
|
99
103
|
this.emit('planning_start', { attempt });
|
|
100
104
|
}
|