@lincoln504/pi-research 1.7.10 → 1.7.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/agent-skill/pi-research/SKILL.md +1 -1
- package/dist/cli.mjs +67 -6
- package/dist/thread-worker.mjs +12 -1
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/CHANGELOG.md +24 -0
- package/package.json +2 -2
- package/src/core/pi-version.ts +18 -1
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +76 -2
- package/src/infrastructure/browser/thread-worker.mjs +12 -1
- package/src/infrastructure/browser/thread-worker.ts +6 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +64 -4
- package/src/sdk.ts +8 -2
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: pi-research
|
|
3
3
|
description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
|
|
4
4
|
license: MIT
|
|
5
|
-
metadata: { "author": "Lincoln Deen", "version": "1.7.
|
|
5
|
+
metadata: { "author": "Lincoln Deen", "version": "1.7.12", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
|
|
6
6
|
allowed-tools: Bash(node:*)
|
|
7
7
|
---
|
|
8
8
|
|
package/dist/cli.mjs
CHANGED
|
@@ -16617,6 +16617,7 @@ init_metrics();
|
|
|
16617
16617
|
init_config();
|
|
16618
16618
|
import { dirname as dirname10, join as join14 } from "node:path";
|
|
16619
16619
|
import { existsSync as existsSync9 } from "node:fs";
|
|
16620
|
+
import { execFileSync } from "node:child_process";
|
|
16620
16621
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
16621
16622
|
import { FixedClusterPool, WorkerChoiceStrategies } from "poolifier";
|
|
16622
16623
|
|
|
@@ -16922,6 +16923,30 @@ function setupMasterIpcErrorHandler() {
|
|
|
16922
16923
|
const w = cluster.workers?.[id];
|
|
16923
16924
|
if (w) guardClusterWorker(w);
|
|
16924
16925
|
}
|
|
16926
|
+
guardWorkerEmitReemit();
|
|
16927
|
+
}
|
|
16928
|
+
var workerEmitGuardPatched = false;
|
|
16929
|
+
var workerEmitOriginal = null;
|
|
16930
|
+
function guardWorkerEmitReemit() {
|
|
16931
|
+
if (workerEmitGuardPatched || cluster.isWorker) return;
|
|
16932
|
+
const Worker2 = cluster.Worker;
|
|
16933
|
+
if (!Worker2 || !Worker2.prototype) return;
|
|
16934
|
+
workerEmitGuardPatched = true;
|
|
16935
|
+
const origEmit = Worker2.prototype.emit;
|
|
16936
|
+
if (typeof origEmit !== "function") return;
|
|
16937
|
+
workerEmitOriginal = origEmit;
|
|
16938
|
+
Worker2.prototype.emit = function(event, ...args) {
|
|
16939
|
+
if (event === "error" && typeof this.listenerCount === "function" && this.listenerCount("error") === 0) {
|
|
16940
|
+
this.on("error", (err) => {
|
|
16941
|
+
if (isBenignClusterIpcError(err)) {
|
|
16942
|
+
logToDebugFile("DEBUG", `[Master] Last-resort guard swallowed listener-less IPC error: ${err?.code || err?.message}`);
|
|
16943
|
+
return;
|
|
16944
|
+
}
|
|
16945
|
+
logToDebugFile("WARN", `[Master] Last-resort guard handled listener-less worker error: ${err?.stack || err?.message || err}`);
|
|
16946
|
+
});
|
|
16947
|
+
}
|
|
16948
|
+
return origEmit.apply(this, [event, ...args]);
|
|
16949
|
+
};
|
|
16925
16950
|
}
|
|
16926
16951
|
function guardClusterWorker(worker) {
|
|
16927
16952
|
if (!worker || worker.__piIpcGuarded) return;
|
|
@@ -17020,6 +17045,34 @@ var WorkerPoolManager = class {
|
|
|
17020
17045
|
// Handle for the schedulePoolReset() destroy timer, so shutdown() can cancel a pending
|
|
17021
17046
|
// reset instead of letting it fire after teardown and clobber a freshly-rebuilt pool.
|
|
17022
17047
|
_resetTimer = null;
|
|
17048
|
+
// One in-flight destroy promise per pool instance. Both the auto-recovery timer
|
|
17049
|
+
// and shutdown() can try to destroy the same pool (observed 2026-10-08 during a
|
|
17050
|
+
// leader-handover storm: "Cannot destroy an already destroying pool"). A second
|
|
17051
|
+
// destroy() racing the first wipes worker listeners (terminate →
|
|
17052
|
+
// removeAllListeners) while the first is still sending kill messages — the fatal
|
|
17053
|
+
// IPC re-emit window. Sharing one promise per pool makes that race impossible.
|
|
17054
|
+
_destroyInFlight = /* @__PURE__ */ new Map();
|
|
17055
|
+
/**
|
|
17056
|
+
* Serialize pool destruction: the first caller wins; later callers await the
|
|
17057
|
+
* same promise instead of racing a second destroy(). Rejections that merely
|
|
17058
|
+
* report a concurrent/finished destroy are treated as success — the pool is
|
|
17059
|
+
* gone either way, and re-throwing them would turn a benign race into a
|
|
17060
|
+
* logged (and potentially escalated) failure.
|
|
17061
|
+
*/
|
|
17062
|
+
destroyPoolOnce(deadPool) {
|
|
17063
|
+
let p = this._destroyInFlight.get(deadPool);
|
|
17064
|
+
if (!p) {
|
|
17065
|
+
p = deadPool.destroy().catch((e) => {
|
|
17066
|
+
if (e instanceof Error && /already (destroying|destroyed|starting)/i.test(e.message)) {
|
|
17067
|
+
return;
|
|
17068
|
+
}
|
|
17069
|
+
throw new Error(`Worker pool destroy failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
17070
|
+
});
|
|
17071
|
+
void p.then(() => this._destroyInFlight.delete(deadPool));
|
|
17072
|
+
this._destroyInFlight.set(deadPool, p);
|
|
17073
|
+
}
|
|
17074
|
+
return p;
|
|
17075
|
+
}
|
|
17023
17076
|
/**
|
|
17024
17077
|
* Ensure the pool is initialized with the current config.
|
|
17025
17078
|
* Recreates the pool if the worker count has changed.
|
|
@@ -17054,7 +17107,7 @@ var WorkerPoolManager = class {
|
|
|
17054
17107
|
if (this.pool && (this.currentWorkerCount !== maxWorkers || this.currentWorkerConcurrency !== workerConcurrency)) {
|
|
17055
17108
|
logger.log(`[WorkerPoolManager] Pool shape changed (workers ${this.currentWorkerCount} -> ${maxWorkers}, concurrency ${this.currentWorkerConcurrency} -> ${workerConcurrency}), recreating pool...`);
|
|
17056
17109
|
this.poolEpoch++;
|
|
17057
|
-
await this.pool
|
|
17110
|
+
await this.destroyPoolOnce(this.pool);
|
|
17058
17111
|
this.pool = null;
|
|
17059
17112
|
}
|
|
17060
17113
|
this.currentWorkerCount = maxWorkers;
|
|
@@ -17074,6 +17127,14 @@ var WorkerPoolManager = class {
|
|
|
17074
17127
|
`Browser worker bundle is missing at ${workerPath}. It is produced by the build step; reinstall the extension (e.g. \`pi update --extensions\`) or run \`npm install\` in the extension directory so the prepare/build script regenerates it.`
|
|
17075
17128
|
);
|
|
17076
17129
|
}
|
|
17130
|
+
try {
|
|
17131
|
+
execFileSync(process.execPath, ["--check", workerPath], { stdio: "pipe" });
|
|
17132
|
+
} catch (e) {
|
|
17133
|
+
throw new Error(
|
|
17134
|
+
`Browser worker bundle at ${workerPath} does not parse (${e instanceof Error ? e.message : String(e)}). Reinstall the extension (e.g. \`pi update --extensions\`) so the build step regenerates it.`,
|
|
17135
|
+
{ cause: e }
|
|
17136
|
+
);
|
|
17137
|
+
}
|
|
17077
17138
|
const myEpoch = ++this.poolEpoch;
|
|
17078
17139
|
this.pool = new FixedClusterPool(maxWorkers, workerPath, {
|
|
17079
17140
|
env: browserEnv,
|
|
@@ -17103,7 +17164,7 @@ var WorkerPoolManager = class {
|
|
|
17103
17164
|
}
|
|
17104
17165
|
},
|
|
17105
17166
|
exitHandler: (code) => {
|
|
17106
|
-
if (code !== 0 && !this.isShuttingDown && this.poolEpoch === myEpoch) {
|
|
17167
|
+
if (code !== 0 && !this.isShuttingDown && !process.env["PI_PROCESS_EXITING"] && this.poolEpoch === myEpoch) {
|
|
17107
17168
|
logger.error(`[WorkerPoolManager] Worker exited with code ${code}`);
|
|
17108
17169
|
this.consecutiveErrors++;
|
|
17109
17170
|
if (this.consecutiveErrors >= 3) {
|
|
@@ -17235,7 +17296,7 @@ var WorkerPoolManager = class {
|
|
|
17235
17296
|
try {
|
|
17236
17297
|
if (this.isShuttingDown || this.generation !== myGen) return;
|
|
17237
17298
|
this.poolEpoch++;
|
|
17238
|
-
if (deadPool) await
|
|
17299
|
+
if (deadPool) await this.destroyPoolOnce(deadPool);
|
|
17239
17300
|
logger.info("[WorkerPoolManager] Auto-recovery: old pool destroyed.");
|
|
17240
17301
|
} catch (err) {
|
|
17241
17302
|
logger.warn("[WorkerPoolManager] Auto-recovery: error destroying old pool:", err);
|
|
@@ -17276,7 +17337,7 @@ var WorkerPoolManager = class {
|
|
|
17276
17337
|
if (this.pool) {
|
|
17277
17338
|
try {
|
|
17278
17339
|
this.poolEpoch++;
|
|
17279
|
-
const destroyPromise = this.pool
|
|
17340
|
+
const destroyPromise = this.destroyPoolOnce(this.pool);
|
|
17280
17341
|
destroyPromise.catch((err) => logger.debug(`[WorkerPoolManager] Background pool destroy rejection: ${err.message}`));
|
|
17281
17342
|
await raceWithDeadline(destroyPromise, 5e3);
|
|
17282
17343
|
} catch (e) {
|
|
@@ -18510,7 +18571,7 @@ init_logger();
|
|
|
18510
18571
|
|
|
18511
18572
|
// src/healthcheck/index.ts
|
|
18512
18573
|
init_config();
|
|
18513
|
-
import { execFileSync } from "node:child_process";
|
|
18574
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
18514
18575
|
init_service_registry();
|
|
18515
18576
|
init_service_interfaces();
|
|
18516
18577
|
|
|
@@ -18602,7 +18663,7 @@ async function checkBrowserCapability() {
|
|
|
18602
18663
|
if (isBrowserAvailable() || mockMode) {
|
|
18603
18664
|
if (!mockMode && resolveHeadlessMode() === "virtual") {
|
|
18604
18665
|
try {
|
|
18605
|
-
|
|
18666
|
+
execFileSync2("which", ["Xvfb"], { stdio: "ignore" });
|
|
18606
18667
|
} catch {
|
|
18607
18668
|
return {
|
|
18608
18669
|
healthy: false,
|
package/dist/thread-worker.mjs
CHANGED
|
@@ -1958,12 +1958,22 @@ function setupIpcErrorHandler() {
|
|
|
1958
1958
|
return;
|
|
1959
1959
|
}
|
|
1960
1960
|
cluster.worker.on("error", (err) => {
|
|
1961
|
-
if (err
|
|
1961
|
+
if (isBenignClusterIpcError(err)) {
|
|
1962
1962
|
return;
|
|
1963
1963
|
}
|
|
1964
1964
|
throw err;
|
|
1965
1965
|
});
|
|
1966
1966
|
}
|
|
1967
|
+
var BENIGN_IPC_CODES = /* @__PURE__ */ new Set(["EPIPE", "ERR_IPC_CHANNEL_CLOSED", "ECONNRESET"]);
|
|
1968
|
+
function isBenignClusterIpcError(err) {
|
|
1969
|
+
if (!err) return false;
|
|
1970
|
+
if (typeof err.code === "string" && BENIGN_IPC_CODES.has(err.code)) return true;
|
|
1971
|
+
const msg = String(err.message || err);
|
|
1972
|
+
return /\b(EPIPE|ECONNRESET)\b/.test(msg) || /channel closed/i.test(msg);
|
|
1973
|
+
}
|
|
1974
|
+
function markWorkerBootstrap(id) {
|
|
1975
|
+
logToDebugFile("WARN", `[Worker-${id}] Bootstrap complete (pid ${process2.pid})`);
|
|
1976
|
+
}
|
|
1967
1977
|
function setupUncaughtExceptionHandler() {
|
|
1968
1978
|
process2.on("uncaughtException", (err) => {
|
|
1969
1979
|
if (err && err.message && err.message.includes("reading 'url'") && err.stack && err.stack.includes("coreBundle.js")) {
|
|
@@ -3232,6 +3242,7 @@ setBrowserCleanup(cleanupBrowser2);
|
|
|
3232
3242
|
if (process3.env["GITHUB_ACTIONS"] !== "true") {
|
|
3233
3243
|
setupOrphanProtection();
|
|
3234
3244
|
}
|
|
3245
|
+
markWorkerBootstrap(workerId4);
|
|
3235
3246
|
var FULL_MOCK_MODE = process3.env["PI_RESEARCH_MOCK_SEARCH"] === "true" && process3.env["PI_RESEARCH_MOCK_SCRAPE"] === "true";
|
|
3236
3247
|
async function runTask(data) {
|
|
3237
3248
|
if (!data) {
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -387,8 +387,8 @@ warns on every startup when a host-provided package (`typebox`, `@sinclair/typeb
|
|
|
387
387
|
loader and create a second live module instance (`resource-loader.js`,
|
|
388
388
|
`collectExtensionPackageWarnings`; pi `docs/packages.md`). Under pi the host's own copy is used,
|
|
389
389
|
so the two agree on `Value.Check`/`Convert` by construction. `@earendil-works/pi-ai` and
|
|
390
|
-
`@earendil-works/pi-coding-agent` 1.0.0 through 1.
|
|
391
|
-
the installed packages' `package.json
|
|
390
|
+
`@earendil-works/pi-coding-agent` 1.0.0 through 1.3.0 all pin `1.3.27` (re-checked 2026-10-17 in
|
|
391
|
+
the installed 1.3.0 packages' `package.json`, previously 2026-10-08 in 1.1.0), which is why the devDependency, and so every test run and
|
|
392
392
|
the type-check, stays on exactly that version: bump it in lockstep with the pi host, not
|
|
393
393
|
independently. A standalone CLI/SDK install resolves the peer automatically on npm >= 7; the
|
|
394
394
|
`pi-research` bin preflights it with the pi packages and exits 78 with the remedy when a
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.7.12] - 2026-10-17
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **The pi host dev dependencies move 1.1.0 → 1.3.0** (`@earendil-works/pi-ai`, `pi-coding-agent`, `pi-tui`, and the `pi-agent-core`/`pi-codemode`/`pi-mcp`/`pi-telemetry` they pull in; `@earendil-works/chord` drops out of the tree; the peer range stays `>=0.99.0 <2`). The 1.1.0 → 1.3.0 declaration diff (installed trees, 2026-10-17): `pi-coding-agent`'s extension surface file (`dist/core/extensions/types.d.ts`) changes in exactly four places — `BeforeAgentStartEvent` becomes a `messageType`-discriminated union (`BeforeAgentStartUserEvent` / `BeforeAgentStartCustomEvent`) because `before_agent_start` now also fires for idle `pi.sendMessage(..., { triggerTurn: true })` runs, `ToolNamespace` gains `aliases?`, `ToolDefinition` gains `reachesTools?`, and `ToolInfo` picks up `reachesTools`. `pi-ai` removes the `assistant-message-frame` module (`AssistantMessageFrame*`, `reduceAssistantMessageFrames`) — the only breaking removal across the two releases, and one this package never imports — and adds the OpenRouter routing schemas and `splitErrorToolResultImages`; its thinking-level types move to `providers/model-schema.d.ts` but are re-exported from the root unchanged. `pi-tui` adds the fake-cursor helpers. Nothing this package consumes is removed or re-signed: the four type-checks (TypeScript 6 and 7, src and tests) pass against 1.3.0, and `typebox` still pins `1.3.27` in `pi-ai` and `pi-coding-agent`, so the lockstep rule and the 0.99.0 floor are unchanged.
|
|
13
|
+
- **`PI_TESTED_MAX_VERSION` moves to 1.3.0** (`src/core/pi-version.ts`), so a pi 1.2.x or 1.3.x host runs without the "newer than the tested ceiling" notice. pi-research never triggers the new firing path — all its `pi.sendMessage` calls are `customType` deliveries without `triggerTurn`, and both `pi.sendUserMessage` calls already pass `deliverAs` — and its `before_agent_start` handler reads only `systemPrompt`/`systemPromptOptions`, present on both union members.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Worker exits during a process-group teardown no longer trip the pool health counter.** `isShuttingDown` only covers the pool's own `shutdown()`, but a process-group signal (a cron wrapper's `timeout N`) SIGTERMs the workers in the same instant as the master, BEFORE the pool's shutdown begins, so those exits landed as unexpected deaths and could log the pool unhealthy on a process that was exiting anyway. The exit handler now also ignores non-zero exits while `PI_PROCESS_EXITING` is set — the flag the SDK signal handler sets synchronously as its first action. The `sdk.ts` teardown comment now says which deaths each teardown path covers. New unit tests pin both sides: flag set → three exits, counter 0, no auto-recovery destroy; flag unset → counted.
|
|
18
|
+
- In-range lockfile refresh from the 1.3.0 install: nothing else moved.
|
|
19
|
+
|
|
20
|
+
## [1.7.11] - 2026-10-10
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **The browser worker pool crash class observed on 2026-10-08 is closed at four layers.** On a pi host, an `ERR_IPC_CHANNEL_CLOSED` raised by a write to a worker's closed IPC channel reached a `cluster.Worker` with no `error` listener and threw inside `EventEmitter` → `uncaughtException` → the pi process exited mid-research. The full chain was reconstructed from the host's crash log: a storm of worker exits with code 1 (16+ in under a second, **zero worker-side log lines**) fed the 3-consecutive-error threshold, the auto-recovery 1 s timer fired into a leader-handover storm that tore down and rebuilt the pool three times in ten seconds with an explicit `Cannot destroy an already destroying pool` double-destroy, and the fatal `destroy()` → `sendKillMessageToWorker` → `Worker.send` hit the closed channel at 22:00:30.12. Fixes, in `src/infrastructure/browser/thread-worker-lifecycle.ts` and `worker-pool-manager.ts`:
|
|
25
|
+
- **The worker-side IPC handler now swallows the same benign set as the master side** (`EPIPE`, `ECONNRESET`, `ERR_IPC_CHANNEL_CLOSED`, `channel closed`, via the shared `isBenignClusterIpcError`). The old narrow check (`ERR_IPC_CHANNEL_CLOSED` only) re-threw `EPIPE`/`ECONNRESET` during teardown churn as an uncaught worker-side exception — a code-1 exit that fed the consecutive-error counter driving the storm. Non-benign errors still re-throw (the worker's uncaught handler logs and exits, by design).
|
|
26
|
+
- **Pool destruction is serialized.** The auto-recovery timer, `shutdown()`, and the shape-change re-init all destroy through one in-flight promise per pool (`destroyPoolOnce`), so a second `destroy()` can no longer race the first — the double-destroy that wiped worker listeners (`terminate` → `removeAllListeners`) while the first destroy was still sending kill messages, which opened the fatal re-emit window. Rejections that merely report a concurrent/finished destroy are swallowed as success.
|
|
27
|
+
- **A `cluster.Worker` prototype last-resort guard.** `setupMasterIpcErrorHandler` also patches `Worker.prototype.emit` so no Worker can ever emit `error` without a listener, whatever wipe path, timing, or pool generation is involved. The per-worker re-attach patch already survives poolifier's `removeAllListeners`; this makes the crash class unreachable rather than merely guarded. The listener it attaches can only be the first on a listener-less Worker (the exact state where `EventEmitter` throws), so it never shadows poolifier's own error accounting.
|
|
28
|
+
- **A corrupted worker bundle now fails loudly at pool init.** A bundle that exists but is truncated or overwritten fails with a bare `SyntaxError` in every forked worker — code-1 exits with no worker-side log, the same silent-churn signature the missing-bundle guard could not catch. `ensurePool` now runs `node --check` on `thread-worker.mjs` (~40 ms) and throws an actionable reinstall error on a parse failure. (Observed 2026-10-09 on a consumer git checkout that had also lost `src/cleanup/research-cleanup.ts`, breaking the research tool with `Cannot find module` — a partial-install corruption class, now loud for the bundle and diagnosable for the rest.)
|
|
29
|
+
- **Each worker writes a startup marker** (`[Worker-<id>] Bootstrap complete (pid …)`, WARN, always recorded). A non-zero exit with no marker means the worker died during top-level module evaluation, before its handlers were installed — the exact signature of the 2026-10-08 silent churn storm, now visible in post-mortems.
|
|
30
|
+
- Tests: new `test/unit/infrastructure/browser/thread-worker-lifecycle.test.ts` (widened swallow, re-attach patch, prototype last-resort guard plus the no-patch control that throws, bootstrap marker) and destroy-serialization cases in `worker-pool-manager.test.ts` (auto-recovery timer vs concurrent `shutdown()` sharing one `destroy()` call; "already destroying" rejection swallowed; parse-guard failure). Full unit suite 3318, integration 103+57, lint, type-check (TypeScript 6 and 7), depcruise and `npm audit` (0) pass.
|
|
31
|
+
|
|
8
32
|
## [1.7.10] - 2026-10-08
|
|
9
33
|
|
|
10
34
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincoln504/pi-research",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.12",
|
|
4
4
|
"description": "Web research for your agents with smart and safe tooling + knowledge store",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -119,11 +119,11 @@
|
|
|
119
119
|
"image": "https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/hero.png"
|
|
120
120
|
},
|
|
121
121
|
"dependencies": {
|
|
122
|
+
"@camoufox/camoufox": "^0.5.7",
|
|
122
123
|
"@kreuzberg/html-to-markdown-node": "^3.7.2",
|
|
123
124
|
"@lancedb/lancedb": "0.39.0",
|
|
124
125
|
"apache-arrow": "21.1.0",
|
|
125
126
|
"bgutils-js": "^4.0.3",
|
|
126
|
-
"@camoufox/camoufox": "^0.5.7",
|
|
127
127
|
"esbuild": "^0.28.2",
|
|
128
128
|
"impit": "0.14.5",
|
|
129
129
|
"jsdom": "^30.1.0",
|
package/src/core/pi-version.ts
CHANGED
|
@@ -130,8 +130,25 @@ export const PI_MIN_VERSION: SemverParts = { major: 0, minor: 99, patch: 0 };
|
|
|
130
130
|
* and an optional `AuthProvider.agentName`; `@earendil-works/pi-tui` adds the OSC 7501
|
|
131
131
|
* program-status helpers and `Text.setPaddingX`. `docs/extensions.md` is unchanged,
|
|
132
132
|
* and nothing was removed or re-signed, so the 0.99.0 floor is unchanged too.
|
|
133
|
+
*
|
|
134
|
+
* 1.2.0 and 1.3.0 (2026-10-12 / 2026-10-15) were verified on 2026-10-17 by diffing
|
|
135
|
+
* the installed declaration trees of all three packages, 1.1.0 against 1.3.0:
|
|
136
|
+
* `@earendil-works/pi-coding-agent`'s extension surface file
|
|
137
|
+
* (`dist/core/extensions/types.d.ts`) changes in exactly four places: `BeforeAgentStartEvent`
|
|
138
|
+
* becomes a `messageType`-discriminated union (`BeforeAgentStartUserEvent` / `BeforeAgentStartCustomEvent`)
|
|
139
|
+
* because `before_agent_start` now also fires for idle `pi.sendMessage(..., { triggerTurn: true })`
|
|
140
|
+
* runs, `ToolNamespace` gains `aliases?`, `ToolDefinition` gains `reachesTools?`, and `ToolInfo`
|
|
141
|
+
* picks up `reachesTools`. `@earendil-works/pi-ai` removes the `assistant-message-frame`
|
|
142
|
+
* module (`AssistantMessageFrame*`, `reduceAssistantMessageFrames`) — a breaking removal this
|
|
143
|
+
* package never imports — and adds the OpenRouter routing schemas and
|
|
144
|
+
* `splitErrorToolResultImages`; its thinking-level types move to `providers/model-schema.d.ts`
|
|
145
|
+
* but are re-exported from the root unchanged. `@earendil-works/pi-tui` adds the fake-cursor
|
|
146
|
+
* helpers (`renderFakeCursor`, `FAKE_CURSOR_START/END`) and is otherwise unchanged.
|
|
147
|
+
* Nothing this package consumes is removed or re-signed; the four type-checks pass against
|
|
148
|
+
* 1.3.0 (TypeScript 6 and 7, src and tests), and `typebox` still pins 1.3.27 in `pi-ai`
|
|
149
|
+
* and `pi-coding-agent`, so the lockstep rule and the 0.99.0 floor are unchanged.
|
|
133
150
|
*/
|
|
134
|
-
export const PI_TESTED_MAX_VERSION: SemverParts = { major: 1, minor:
|
|
151
|
+
export const PI_TESTED_MAX_VERSION: SemverParts = { major: 1, minor: 3, patch: 0 };
|
|
135
152
|
|
|
136
153
|
export type PiCompatibilityLevel = 'ok' | 'unparseable' | 'too-old' | 'untested';
|
|
137
154
|
|
|
@@ -20,14 +20,23 @@ export function setWorkerId(id: string): void {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Handle
|
|
23
|
+
* Handle benign closed-channel IPC errors when poolifier tears down a worker's
|
|
24
|
+
* channel. Symmetric with the master-side classification (isBenignClusterIpcError):
|
|
25
|
+
* the write failed because the pipe is already gone, which is not a health signal.
|
|
26
|
+
*
|
|
27
|
+
* The old narrow check (ERR_IPC_CHANNEL_CLOSED only) re-threw EPIPE/ECONNRESET as an
|
|
28
|
+
* uncaught worker-side exception → exit code 1. A worker that dies that way during
|
|
29
|
+
* teardown churn feeds the master's consecutive-error counter and re-spawns — the
|
|
30
|
+
* self-sustaining worker storm behind the 2026-10-08 crash chain. Swallowing the
|
|
31
|
+
* same benign set here stops that feedback; genuinely non-benign errors still
|
|
32
|
+
* re-throw (the worker's uncaught handler logs and exits, as designed).
|
|
24
33
|
*/
|
|
25
34
|
export function setupIpcErrorHandler(): void {
|
|
26
35
|
if (!cluster.isWorker || !cluster.worker) {
|
|
27
36
|
return;
|
|
28
37
|
}
|
|
29
38
|
cluster.worker.on('error', (err: any) => {
|
|
30
|
-
if (err
|
|
39
|
+
if (isBenignClusterIpcError(err)) {
|
|
31
40
|
return;
|
|
32
41
|
}
|
|
33
42
|
throw err;
|
|
@@ -92,6 +101,10 @@ export function setupMasterIpcErrorHandler(): void {
|
|
|
92
101
|
const w = (cluster.workers as any)?.[id];
|
|
93
102
|
if (w) guardClusterWorker(w);
|
|
94
103
|
}
|
|
104
|
+
|
|
105
|
+
// Prototype-level backstop: no cluster.Worker can ever emit 'error' without
|
|
106
|
+
// a listener, whatever path wiped its per-instance listeners.
|
|
107
|
+
guardWorkerEmitReemit();
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
/** Reset for tests — re-arms the one-time install guard. */
|
|
@@ -99,6 +112,55 @@ export function __resetMasterIpcGuardForTests(): void {
|
|
|
99
112
|
masterIpcGuardInstalled = false;
|
|
100
113
|
}
|
|
101
114
|
|
|
115
|
+
let workerEmitGuardPatched = false;
|
|
116
|
+
let workerEmitOriginal: unknown = null;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Last-resort defense against the crash class observed on 2026-10-08: an 'error'
|
|
120
|
+
* event emitted on a cluster.Worker with NO listeners throws inside EventEmitter
|
|
121
|
+
* (uncaughtException → the pi host exits). The per-worker guard above is re-armed
|
|
122
|
+
* after poolifier's removeAllListeners() by the re-attach patch in
|
|
123
|
+
* guardClusterWorker, but this patches the Worker PROTOTYPE so that no Worker can
|
|
124
|
+
* ever emit 'error' without a listener — whatever wipe path, re-emit timing, or
|
|
125
|
+
* pool generation is involved.
|
|
126
|
+
*
|
|
127
|
+
* The listener attached here can only be the first listener on a listener-less
|
|
128
|
+
* Worker (that is the exact state where EventEmitter would throw), so it can never
|
|
129
|
+
* shadow poolifier's own error accounting, which is always attached at fork time.
|
|
130
|
+
* Idempotent; master process only. Installed once by setupMasterIpcErrorHandler().
|
|
131
|
+
*/
|
|
132
|
+
export function guardWorkerEmitReemit(): void {
|
|
133
|
+
if (workerEmitGuardPatched || cluster.isWorker) return;
|
|
134
|
+
const Worker = (cluster as unknown as { Worker?: { prototype: { emit?: unknown } } }).Worker;
|
|
135
|
+
if (!Worker || !Worker.prototype) return;
|
|
136
|
+
workerEmitGuardPatched = true;
|
|
137
|
+
const origEmit = Worker.prototype.emit;
|
|
138
|
+
if (typeof origEmit !== 'function') return;
|
|
139
|
+
workerEmitOriginal = origEmit;
|
|
140
|
+
Worker.prototype.emit = function (this: any, event: string | symbol, ...args: unknown[]) {
|
|
141
|
+
if (event === 'error' && typeof this.listenerCount === 'function' && this.listenerCount('error') === 0) {
|
|
142
|
+
this.on('error', (err: any) => {
|
|
143
|
+
if (isBenignClusterIpcError(err)) {
|
|
144
|
+
logToDebugFile('DEBUG', `[Master] Last-resort guard swallowed listener-less IPC error: ${err?.code || err?.message}`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
logToDebugFile('WARN', `[Master] Last-resort guard handled listener-less worker error: ${err?.stack || err?.message || err}`);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return origEmit.apply(this, [event, ...args]);
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Reset for tests — restores the original prototype emit and re-arms the flag. */
|
|
155
|
+
export function __resetWorkerEmitGuardForTests(): void {
|
|
156
|
+
if (workerEmitGuardPatched && workerEmitOriginal) {
|
|
157
|
+
const Worker = (cluster as unknown as { Worker?: { prototype: { emit?: unknown } } }).Worker;
|
|
158
|
+
if (Worker && Worker.prototype) Worker.prototype.emit = workerEmitOriginal;
|
|
159
|
+
workerEmitOriginal = null;
|
|
160
|
+
}
|
|
161
|
+
workerEmitGuardPatched = false;
|
|
162
|
+
}
|
|
163
|
+
|
|
102
164
|
function guardClusterWorker(worker: any): void {
|
|
103
165
|
if (!worker || worker.__piIpcGuarded) return;
|
|
104
166
|
worker.__piIpcGuarded = true;
|
|
@@ -137,6 +199,18 @@ function guardClusterWorker(worker: any): void {
|
|
|
137
199
|
}
|
|
138
200
|
}
|
|
139
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Worker bootstrap marker. WARN (always recorded; INFO/DEBUG are gated behind
|
|
204
|
+
* PI_RESEARCH_DEBUG). If a worker later exits non-zero WITHOUT this line in the
|
|
205
|
+
* log, it died during top-level module evaluation — before its error handlers
|
|
206
|
+
* were installed — and left no diagnostic behind. The 2026-10-08 churn storm
|
|
207
|
+
* had exactly that signature (a burst of code-1 exits with zero worker-side log
|
|
208
|
+
* lines); this marker makes that class visible in post-mortems.
|
|
209
|
+
*/
|
|
210
|
+
export function markWorkerBootstrap(id: string): void {
|
|
211
|
+
logToDebugFile('WARN', `[Worker-${id}] Bootstrap complete (pid ${process.pid})`);
|
|
212
|
+
}
|
|
213
|
+
|
|
140
214
|
/**
|
|
141
215
|
* Handle Uncaught Exceptions in Worker
|
|
142
216
|
*/
|
|
@@ -1958,12 +1958,22 @@ function setupIpcErrorHandler() {
|
|
|
1958
1958
|
return;
|
|
1959
1959
|
}
|
|
1960
1960
|
cluster.worker.on("error", (err) => {
|
|
1961
|
-
if (err
|
|
1961
|
+
if (isBenignClusterIpcError(err)) {
|
|
1962
1962
|
return;
|
|
1963
1963
|
}
|
|
1964
1964
|
throw err;
|
|
1965
1965
|
});
|
|
1966
1966
|
}
|
|
1967
|
+
var BENIGN_IPC_CODES = /* @__PURE__ */ new Set(["EPIPE", "ERR_IPC_CHANNEL_CLOSED", "ECONNRESET"]);
|
|
1968
|
+
function isBenignClusterIpcError(err) {
|
|
1969
|
+
if (!err) return false;
|
|
1970
|
+
if (typeof err.code === "string" && BENIGN_IPC_CODES.has(err.code)) return true;
|
|
1971
|
+
const msg = String(err.message || err);
|
|
1972
|
+
return /\b(EPIPE|ECONNRESET)\b/.test(msg) || /channel closed/i.test(msg);
|
|
1973
|
+
}
|
|
1974
|
+
function markWorkerBootstrap(id) {
|
|
1975
|
+
logToDebugFile("WARN", `[Worker-${id}] Bootstrap complete (pid ${process2.pid})`);
|
|
1976
|
+
}
|
|
1967
1977
|
function setupUncaughtExceptionHandler() {
|
|
1968
1978
|
process2.on("uncaughtException", (err) => {
|
|
1969
1979
|
if (err && err.message && err.message.includes("reading 'url'") && err.stack && err.stack.includes("coreBundle.js")) {
|
|
@@ -3232,6 +3242,7 @@ setBrowserCleanup(cleanupBrowser2);
|
|
|
3232
3242
|
if (process3.env["GITHUB_ACTIONS"] !== "true") {
|
|
3233
3243
|
setupOrphanProtection();
|
|
3234
3244
|
}
|
|
3245
|
+
markWorkerBootstrap(workerId4);
|
|
3235
3246
|
var FULL_MOCK_MODE = process3.env["PI_RESEARCH_MOCK_SEARCH"] === "true" && process3.env["PI_RESEARCH_MOCK_SCRAPE"] === "true";
|
|
3236
3247
|
async function runTask(data) {
|
|
3237
3248
|
if (!data) {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
setupOrphanProtection,
|
|
20
20
|
createKillHandler,
|
|
21
21
|
setBrowserCleanup,
|
|
22
|
+
markWorkerBootstrap,
|
|
22
23
|
} from './thread-worker-lifecycle.ts';
|
|
23
24
|
import {
|
|
24
25
|
setWorkerId,
|
|
@@ -60,6 +61,11 @@ if (process.env['GITHUB_ACTIONS'] !== 'true') {
|
|
|
60
61
|
setupOrphanProtection();
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
// Startup marker (always recorded). A non-zero exit with no marker in the log
|
|
65
|
+
// means the worker died during top-level module evaluation, before the handlers
|
|
66
|
+
// above were installed — the silent churn signature of the 2026-10-08 crash chain.
|
|
67
|
+
markWorkerBootstrap(workerId);
|
|
68
|
+
|
|
63
69
|
// When both search and scrape are mocked, skip Firefox entirely in task handlers
|
|
64
70
|
// and in the eager warm-up below. Firefox startup in cluster workers takes 60-90s
|
|
65
71
|
// on constrained CI runners; mocked tasks don't need a real browser at all.
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
10
11
|
import { fileURLToPath } from 'node:url';
|
|
11
12
|
import { FixedClusterPool, WorkerChoiceStrategies } from 'poolifier';
|
|
12
13
|
import { logger } from '../../logger.ts';
|
|
@@ -110,6 +111,35 @@ export class WorkerPoolManager implements IService {
|
|
|
110
111
|
// Handle for the schedulePoolReset() destroy timer, so shutdown() can cancel a pending
|
|
111
112
|
// reset instead of letting it fire after teardown and clobber a freshly-rebuilt pool.
|
|
112
113
|
private _resetTimer: ReturnType<typeof setTimeout> | null = null;
|
|
114
|
+
// One in-flight destroy promise per pool instance. Both the auto-recovery timer
|
|
115
|
+
// and shutdown() can try to destroy the same pool (observed 2026-10-08 during a
|
|
116
|
+
// leader-handover storm: "Cannot destroy an already destroying pool"). A second
|
|
117
|
+
// destroy() racing the first wipes worker listeners (terminate →
|
|
118
|
+
// removeAllListeners) while the first is still sending kill messages — the fatal
|
|
119
|
+
// IPC re-emit window. Sharing one promise per pool makes that race impossible.
|
|
120
|
+
private _destroyInFlight: Map<object, Promise<void>> = new Map();
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Serialize pool destruction: the first caller wins; later callers await the
|
|
124
|
+
* same promise instead of racing a second destroy(). Rejections that merely
|
|
125
|
+
* report a concurrent/finished destroy are treated as success — the pool is
|
|
126
|
+
* gone either way, and re-throwing them would turn a benign race into a
|
|
127
|
+
* logged (and potentially escalated) failure.
|
|
128
|
+
*/
|
|
129
|
+
private destroyPoolOnce(deadPool: object): Promise<void> {
|
|
130
|
+
let p = this._destroyInFlight.get(deadPool);
|
|
131
|
+
if (!p) {
|
|
132
|
+
p = (deadPool as { destroy: () => Promise<void> }).destroy().catch((e: unknown) => {
|
|
133
|
+
if (e instanceof Error && /already (destroying|destroyed|starting)/i.test(e.message)) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`Worker pool destroy failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
137
|
+
});
|
|
138
|
+
void p.then(() => this._destroyInFlight.delete(deadPool));
|
|
139
|
+
this._destroyInFlight.set(deadPool, p);
|
|
140
|
+
}
|
|
141
|
+
return p;
|
|
142
|
+
}
|
|
113
143
|
|
|
114
144
|
constructor(
|
|
115
145
|
private readonly onPoolError?: (error: Error, consecutiveErrors: number) => void
|
|
@@ -169,7 +199,7 @@ export class WorkerPoolManager implements IService {
|
|
|
169
199
|
if (this.pool && (this.currentWorkerCount !== maxWorkers || this.currentWorkerConcurrency !== workerConcurrency)) {
|
|
170
200
|
logger.log(`[WorkerPoolManager] Pool shape changed (workers ${this.currentWorkerCount} -> ${maxWorkers}, concurrency ${this.currentWorkerConcurrency} -> ${workerConcurrency}), recreating pool...`);
|
|
171
201
|
this.poolEpoch++; // intentional destroy: silence the dying pool's exit events
|
|
172
|
-
await this.pool
|
|
202
|
+
await this.destroyPoolOnce(this.pool);
|
|
173
203
|
this.pool = null;
|
|
174
204
|
}
|
|
175
205
|
|
|
@@ -237,6 +267,23 @@ export class WorkerPoolManager implements IService {
|
|
|
237
267
|
);
|
|
238
268
|
}
|
|
239
269
|
|
|
270
|
+
// A bundle that EXISTS but is corrupted (truncated or partially overwritten
|
|
271
|
+
// file — observed 2026-10-08: a git checkout missing files) fails with a bare
|
|
272
|
+
// SyntaxError in every forked worker — code-1 exits with no worker-side log,
|
|
273
|
+
// the same silent churn signature as a missing bundle. Parse (not execute)
|
|
274
|
+
// the source here so a corrupted bundle fails loudly at pool init instead of
|
|
275
|
+
// masking as worker churn. `node --check` parses .mjs as ESM (~40 ms).
|
|
276
|
+
try {
|
|
277
|
+
execFileSync(process.execPath, ['--check', workerPath], { stdio: 'pipe' });
|
|
278
|
+
} catch (e) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`Browser worker bundle at ${workerPath} does not parse ` +
|
|
281
|
+
`(${e instanceof Error ? e.message : String(e)}). ` +
|
|
282
|
+
`Reinstall the extension (e.g. \`pi update --extensions\`) so the build step regenerates it.`,
|
|
283
|
+
{ cause: e },
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
240
287
|
// Identity token for THIS pool instance. The handlers below compare it against
|
|
241
288
|
// the live `this.poolEpoch` so a superseded pool's trailing events are ignored.
|
|
242
289
|
const myEpoch = ++this.poolEpoch;
|
|
@@ -293,7 +340,17 @@ export class WorkerPoolManager implements IService {
|
|
|
293
340
|
// the 2026-09-01/02 logs coincided with SDK SIGTERM shutdown, tripping
|
|
294
341
|
// "3 consecutive exits → pool unhealthy" once per dying session. Not a
|
|
295
342
|
// queue-capacity or OOM problem: zero exits occurred during live research.
|
|
296
|
-
|
|
343
|
+
//
|
|
344
|
+
// The mid-shutdown flag alone does NOT cover process-GROUP signals: a cron
|
|
345
|
+
// wrapper like `timeout N` SIGTERMs the whole process group, so the
|
|
346
|
+
// workers die in the same instant as the master — BEFORE the pool's own
|
|
347
|
+
// shutdown() runs and sets isShuttingDown (the scheduler reaches
|
|
348
|
+
// workerPoolManager.shutdown() only after service lookups, a state read
|
|
349
|
+
// and server.stop()). The SDK signal handler sets PI_PROCESS_EXITING
|
|
350
|
+
// synchronously as its first action (sdk.ts _onSignal), before any
|
|
351
|
+
// teardown await, so any exit arriving after that flag is teardown
|
|
352
|
+
// noise on a dying process, not a health fault.
|
|
353
|
+
if (code !== 0 && !this.isShuttingDown && !process.env['PI_PROCESS_EXITING'] && this.poolEpoch === myEpoch) {
|
|
297
354
|
logger.error(`[WorkerPoolManager] Worker exited with code ${code}`);
|
|
298
355
|
this.consecutiveErrors++;
|
|
299
356
|
if (this.consecutiveErrors >= 3) {
|
|
@@ -472,7 +529,7 @@ export class WorkerPoolManager implements IService {
|
|
|
472
529
|
// and nulling shared state would clobber a pool rebuilt after the shutdown.
|
|
473
530
|
if (this.isShuttingDown || this.generation !== myGen) return;
|
|
474
531
|
this.poolEpoch++; // intentional destroy: silence the dying pool's exit events
|
|
475
|
-
if (deadPool) await
|
|
532
|
+
if (deadPool) await this.destroyPoolOnce(deadPool);
|
|
476
533
|
logger.info('[WorkerPoolManager] Auto-recovery: old pool destroyed.');
|
|
477
534
|
} catch (err) {
|
|
478
535
|
logger.warn('[WorkerPoolManager] Auto-recovery: error destroying old pool:', err);
|
|
@@ -535,7 +592,10 @@ export class WorkerPoolManager implements IService {
|
|
|
535
592
|
// in their killHandler (context.close / browser.close via Playwright), so
|
|
536
593
|
// allow enough time for those to complete before the IPC channel closes.
|
|
537
594
|
this.poolEpoch++; // intentional destroy: silence the dying pool's exit events
|
|
538
|
-
|
|
595
|
+
// Shared promise: if the auto-recovery timer is already destroying this pool
|
|
596
|
+
// (it fired before shutdown cleared the handle), await that destroy instead of
|
|
597
|
+
// racing a second one — the double-destroy race of the 2026-10-08 crash chain.
|
|
598
|
+
const destroyPromise = this.destroyPoolOnce(this.pool);
|
|
539
599
|
destroyPromise.catch((err: Error) => logger.debug(`[WorkerPoolManager] Background pool destroy rejection: ${err.message}`));
|
|
540
600
|
await raceWithDeadline(destroyPromise, 5000);
|
|
541
601
|
} catch (e) {
|
package/src/sdk.ts
CHANGED
|
@@ -365,8 +365,14 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
|
|
|
365
365
|
// sdk_shutdown cleanup list is empty ("No cleanup tasks registered"), and the scheduler's cluster workers died mid-DAG-disposal —
|
|
366
366
|
// with code null — while the worker pool was still live, which the pool's
|
|
367
367
|
// exitHandler counted as unexpected deaths ("3 consecutive exits → pool
|
|
368
|
-
// unhealthy") on a process that was exiting anyway.
|
|
369
|
-
//
|
|
368
|
+
// unhealthy") on a process that was exiting anyway. For a signal received
|
|
369
|
+
// by the master only, shutting down here puts those deaths inside the
|
|
370
|
+
// pool's intentional-destroy window. A process-GROUP signal (e.g. `timeout
|
|
371
|
+
// N` from a cron wrapper) SIGTERMs the workers in the same instant the
|
|
372
|
+
// master runs its handler, so those deaths land BEFORE this task starts —
|
|
373
|
+
// they are silenced instead by the PI_PROCESS_EXITING flag the handler
|
|
374
|
+
// sets synchronously as its first action (see the exitHandler in
|
|
375
|
+
// worker-pool-manager.ts). Both
|
|
370
376
|
// teardown paths are idempotent (the later DAG dispose no-ops), and the task
|
|
371
377
|
// is bounded well inside the 15s signal force-exit deadline. Registered per
|
|
372
378
|
// init: runCleanup detaches the task set after running, so re-init gets a
|