@cortexkit/aft-bridge 0.18.5 → 0.19.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/dist/active-logger.js +44 -0
- package/dist/active-logger.js.map +1 -0
- package/dist/bridge.js +653 -0
- package/dist/bridge.js.map +1 -0
- package/dist/downloader.js +193 -0
- package/dist/downloader.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -1387
- package/dist/index.js.map +1 -0
- package/dist/logger.js +2 -0
- package/dist/logger.js.map +1 -0
- package/dist/onnx-runtime.js +631 -0
- package/dist/onnx-runtime.js.map +1 -0
- package/dist/platform.js +31 -0
- package/dist/platform.js.map +1 -0
- package/dist/pool.js +159 -0
- package/dist/pool.js.map +1 -0
- package/dist/protocol.js +10 -0
- package/dist/protocol.js.map +1 -0
- package/dist/resolver.js +186 -0
- package/dist/resolver.js.map +1 -0
- package/dist/url-fetch.d.ts +33 -0
- package/dist/url-fetch.d.ts.map +1 -0
- package/dist/url-fetch.js +387 -0
- package/dist/url-fetch.js.map +1 -0
- package/package.json +6 -3
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { error, getLogFilePath, log, sessionWarn, warn } from "./active-logger.js";
|
|
5
|
+
const DEFAULT_BRIDGE_TIMEOUT_MS = 30_000;
|
|
6
|
+
const SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS = 5_000;
|
|
7
|
+
const MAX_STDOUT_BUFFER = 64 * 1024 * 1024; // 64MB
|
|
8
|
+
// ## Note on TypeScript `as` type assertions
|
|
9
|
+
//
|
|
10
|
+
// Bridge responses use `as string`, `as string[]` etc. in several places.
|
|
11
|
+
// This is intentional: all 16 tool handlers already guard against error
|
|
12
|
+
// responses with `if (response.success === false) throw ...` before accessing
|
|
13
|
+
// typed fields. The remaining `as` casts are on fields from known-success
|
|
14
|
+
// Rust responses where the shape is guaranteed by the protocol contract.
|
|
15
|
+
// Adding Zod runtime validation for every bridge response would add ~2ms
|
|
16
|
+
// per call with no practical safety benefit given the error guards.
|
|
17
|
+
/**
|
|
18
|
+
* Compare two semver version strings (major.minor.patch plus pre-release).
|
|
19
|
+
* Returns: negative if a < b, 0 if equal, positive if a > b.
|
|
20
|
+
*/
|
|
21
|
+
export function compareSemver(a, b) {
|
|
22
|
+
const [aMain, aPre] = a.split("-", 2);
|
|
23
|
+
const [bMain, bPre] = b.split("-", 2);
|
|
24
|
+
const aParts = aMain.split(".").map(Number);
|
|
25
|
+
const bParts = bMain.split(".").map(Number);
|
|
26
|
+
for (let i = 0; i < 3; i++) {
|
|
27
|
+
if (aParts[i] !== bParts[i])
|
|
28
|
+
return (aParts[i] ?? 0) - (bParts[i] ?? 0);
|
|
29
|
+
}
|
|
30
|
+
if (!aPre && !bPre)
|
|
31
|
+
return 0;
|
|
32
|
+
if (!aPre)
|
|
33
|
+
return 1;
|
|
34
|
+
if (!bPre)
|
|
35
|
+
return -1;
|
|
36
|
+
const aIds = aPre.split(".");
|
|
37
|
+
const bIds = bPre.split(".");
|
|
38
|
+
for (let i = 0; i < Math.max(aIds.length, bIds.length); i++) {
|
|
39
|
+
const ai = aIds[i];
|
|
40
|
+
const bi = bIds[i];
|
|
41
|
+
if (ai === undefined)
|
|
42
|
+
return -1;
|
|
43
|
+
if (bi === undefined)
|
|
44
|
+
return 1;
|
|
45
|
+
const aNum = /^\d+$/.test(ai);
|
|
46
|
+
const bNum = /^\d+$/.test(bi);
|
|
47
|
+
if (aNum && bNum) {
|
|
48
|
+
const diff = Number.parseInt(ai, 10) - Number.parseInt(bi, 10);
|
|
49
|
+
if (diff !== 0)
|
|
50
|
+
return diff;
|
|
51
|
+
}
|
|
52
|
+
else if (aNum) {
|
|
53
|
+
return -1;
|
|
54
|
+
}
|
|
55
|
+
else if (bNum) {
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const cmp = ai.localeCompare(bi);
|
|
60
|
+
if (cmp !== 0)
|
|
61
|
+
return cmp;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
function clampSemanticTimeout(configOverrides, bridgeTimeoutMs) {
|
|
67
|
+
const semantic = configOverrides.semantic;
|
|
68
|
+
if (!semantic || typeof semantic !== "object" || Array.isArray(semantic)) {
|
|
69
|
+
return configOverrides;
|
|
70
|
+
}
|
|
71
|
+
const timeoutMs = semantic.timeout_ms;
|
|
72
|
+
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
|
|
73
|
+
return configOverrides;
|
|
74
|
+
}
|
|
75
|
+
const maxSemanticTimeoutMs = bridgeTimeoutMs > SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS
|
|
76
|
+
? bridgeTimeoutMs - SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS
|
|
77
|
+
: Math.max(1, bridgeTimeoutMs - 1);
|
|
78
|
+
if (timeoutMs <= maxSemanticTimeoutMs) {
|
|
79
|
+
return configOverrides;
|
|
80
|
+
}
|
|
81
|
+
warn(`semantic.timeout_ms=${timeoutMs} exceeds bridge timeout budget; clamping to ${maxSemanticTimeoutMs}ms (bridge timeout: ${bridgeTimeoutMs}ms)`);
|
|
82
|
+
return {
|
|
83
|
+
...configOverrides,
|
|
84
|
+
semantic: {
|
|
85
|
+
...semantic,
|
|
86
|
+
timeout_ms: maxSemanticTimeoutMs,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Manages a persistent `aft` child process, communicating via NDJSON over
|
|
92
|
+
* stdin/stdout. Lazy-spawns on first `send()` call. Handles crash detection
|
|
93
|
+
* with exponential backoff auto-restart.
|
|
94
|
+
*/
|
|
95
|
+
export class BinaryBridge {
|
|
96
|
+
static RESTART_RESET_MS = 5 * 60 * 1000;
|
|
97
|
+
/** How many recent stderr lines to keep for crash diagnostics. */
|
|
98
|
+
static STDERR_TAIL_MAX = 20;
|
|
99
|
+
binaryPath;
|
|
100
|
+
cwd;
|
|
101
|
+
process = null;
|
|
102
|
+
pending = new Map();
|
|
103
|
+
nextId = 1;
|
|
104
|
+
stdoutBuffer = "";
|
|
105
|
+
/** Ring buffer of the last N stderr lines, cleared on every spawn. */
|
|
106
|
+
stderrTail = [];
|
|
107
|
+
_restartCount = 0;
|
|
108
|
+
_shuttingDown = false;
|
|
109
|
+
timeoutMs;
|
|
110
|
+
maxRestarts;
|
|
111
|
+
configured = false;
|
|
112
|
+
_configurePromise = null;
|
|
113
|
+
configOverrides;
|
|
114
|
+
minVersion;
|
|
115
|
+
onVersionMismatch;
|
|
116
|
+
onConfigureWarnings;
|
|
117
|
+
onBashCompletion;
|
|
118
|
+
/** Notification clients keyed by session_id for async configure warning pushes. */
|
|
119
|
+
configureWarningClients = new Map();
|
|
120
|
+
restartResetTimer = null;
|
|
121
|
+
errorPrefix;
|
|
122
|
+
constructor(binaryPath, cwd, options, configOverrides) {
|
|
123
|
+
this.binaryPath = binaryPath;
|
|
124
|
+
this.cwd = cwd;
|
|
125
|
+
this.timeoutMs = options?.timeoutMs ?? DEFAULT_BRIDGE_TIMEOUT_MS;
|
|
126
|
+
this.maxRestarts = options?.maxRestarts ?? 3;
|
|
127
|
+
this.configOverrides = clampSemanticTimeout(configOverrides ?? {}, this.timeoutMs);
|
|
128
|
+
this.minVersion = options?.minVersion;
|
|
129
|
+
this.onVersionMismatch = options?.onVersionMismatch;
|
|
130
|
+
this.onConfigureWarnings = options?.onConfigureWarnings;
|
|
131
|
+
this.onBashCompletion = options?.onBashCompletion;
|
|
132
|
+
this.errorPrefix = options?.errorPrefix ?? "[aft-bridge]";
|
|
133
|
+
}
|
|
134
|
+
/** Number of times the binary has been restarted after a crash. */
|
|
135
|
+
get restartCount() {
|
|
136
|
+
return this._restartCount;
|
|
137
|
+
}
|
|
138
|
+
/** Whether the child process is currently alive. */
|
|
139
|
+
isAlive() {
|
|
140
|
+
return this.process !== null && this.process.exitCode === null && !this.process.killed;
|
|
141
|
+
}
|
|
142
|
+
hasPendingRequests() {
|
|
143
|
+
return this.pending.size > 0;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Send a command to the binary and return the parsed response.
|
|
147
|
+
* Lazy-spawns the binary on first call.
|
|
148
|
+
*/
|
|
149
|
+
async send(command, params = {}, options) {
|
|
150
|
+
if (this._shuttingDown) {
|
|
151
|
+
throw new Error(`${this.errorPrefix} Bridge is shutting down, cannot send "${command}"`);
|
|
152
|
+
}
|
|
153
|
+
if (Object.hasOwn(params, "id")) {
|
|
154
|
+
throw new Error("params cannot contain reserved key 'id'");
|
|
155
|
+
}
|
|
156
|
+
this.ensureSpawned();
|
|
157
|
+
// Capture session_id early so auto-configure can reuse the initiating
|
|
158
|
+
// session's notification client when the deferred configure warning frame
|
|
159
|
+
// arrives later. One project bridge can serve many sessions, so keep this
|
|
160
|
+
// per-session instead of one bridge-wide "last client".
|
|
161
|
+
const requestSessionId = typeof params.session_id === "string" && params.session_id.length > 0
|
|
162
|
+
? params.session_id
|
|
163
|
+
: undefined;
|
|
164
|
+
if (requestSessionId && options?.configureWarningClient !== undefined) {
|
|
165
|
+
this.configureWarningClients.set(requestSessionId, options.configureWarningClient);
|
|
166
|
+
}
|
|
167
|
+
// Auto-configure project root + plugin config on first command, then check version.
|
|
168
|
+
// configured is set AFTER success to prevent skipping configuration on failure (#18).
|
|
169
|
+
// When multiple parallel calls arrive before configure completes, they all await
|
|
170
|
+
// the same promise instead of each independently trying to configure.
|
|
171
|
+
if (!this.configured) {
|
|
172
|
+
if (command !== "configure" && command !== "version") {
|
|
173
|
+
if (!this._configurePromise) {
|
|
174
|
+
// First caller — create the configure promise.
|
|
175
|
+
// All parallel callers await this same promise.
|
|
176
|
+
//
|
|
177
|
+
// Forward the triggering call's session_id into configure so
|
|
178
|
+
// Rust's thread-local session context propagates through to
|
|
179
|
+
// background tasks spawned by configure (search-index pre-warm,
|
|
180
|
+
// semantic-index build). Without this, background log lines
|
|
181
|
+
// emitted by configure threads appear with no session prefix.
|
|
182
|
+
const sessionIdForConfigure = typeof params.session_id === "string" ? params.session_id : undefined;
|
|
183
|
+
this._configurePromise = (async () => {
|
|
184
|
+
try {
|
|
185
|
+
const configResult = await this.send("configure", {
|
|
186
|
+
project_root: this.cwd,
|
|
187
|
+
...this.configOverrides,
|
|
188
|
+
...(sessionIdForConfigure ? { session_id: sessionIdForConfigure } : {}),
|
|
189
|
+
});
|
|
190
|
+
if (configResult.success === false) {
|
|
191
|
+
throw new Error(`${this.errorPrefix} Configure failed: ${configResult.message ?? "unknown error"}`);
|
|
192
|
+
}
|
|
193
|
+
// Large-repo warning is emitted by the Rust side via log::warn!
|
|
194
|
+
// and relayed through stderr → plugin log. No need to re-log here
|
|
195
|
+
// (doing so would just duplicate the same line in aft-plugin.log).
|
|
196
|
+
await this.deliverConfigureWarnings(configResult, params, options);
|
|
197
|
+
await this.checkVersion();
|
|
198
|
+
// Re-check liveness after version check — checkVersion() swallows
|
|
199
|
+
// errors as best-effort, so the bridge may have died without throwing.
|
|
200
|
+
if (!this.isAlive()) {
|
|
201
|
+
throw new Error(`${this.errorPrefix} Bridge died during version check. Check logs: ${getLogFilePath()}`);
|
|
202
|
+
}
|
|
203
|
+
this.configured = true;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
this._configurePromise = null;
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
}
|
|
210
|
+
// All callers (including the first) await the shared promise
|
|
211
|
+
await this._configurePromise;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const id = String(this.nextId++);
|
|
215
|
+
// Wire format: when params contains a key that collides with the protocol
|
|
216
|
+
// envelope (`command`/`method`), nest params under a `params` key so the
|
|
217
|
+
// outer dispatch dispatches on `command: "<bridge command>"` rather than
|
|
218
|
+
// the user's payload key. Reserved envelope fields (`session_id`,
|
|
219
|
+
// `lsp_hints`) must STILL be promoted to the top level so RawRequest's
|
|
220
|
+
// dedicated fields deserialize correctly. Without this promotion, e.g.
|
|
221
|
+
// `bash` (whose params include `command: "<shell command>"`) silently
|
|
222
|
+
// loses `session_id` because it stays nested inside `params`.
|
|
223
|
+
let request;
|
|
224
|
+
if (Object.hasOwn(params, "command") || Object.hasOwn(params, "method")) {
|
|
225
|
+
const nested = { ...params };
|
|
226
|
+
const reserved = {};
|
|
227
|
+
for (const key of ["session_id", "lsp_hints"]) {
|
|
228
|
+
if (Object.hasOwn(nested, key)) {
|
|
229
|
+
reserved[key] = nested[key];
|
|
230
|
+
delete nested[key];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
request = { id, command, ...reserved, params: nested };
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
request = { id, command, ...params };
|
|
237
|
+
}
|
|
238
|
+
const line = `${JSON.stringify(request)}\n`;
|
|
239
|
+
// Per-op timeout override: tool wrappers can pass longer budgets for
|
|
240
|
+
// commands that legitimately need them (callers, trace_to, grep on big
|
|
241
|
+
// repos). Defaults to the bridge-wide timeout otherwise.
|
|
242
|
+
const effectiveTimeoutMs = options?.transportTimeoutMs ?? options?.timeoutMs ?? this.timeoutMs;
|
|
243
|
+
const keepBridgeOnTimeout = options?.keepBridgeOnTimeout === true;
|
|
244
|
+
return new Promise((resolve, reject) => {
|
|
245
|
+
const timer = setTimeout(() => {
|
|
246
|
+
this.pending.delete(id);
|
|
247
|
+
const restartSuffix = keepBridgeOnTimeout ? "" : " — restarting bridge";
|
|
248
|
+
const timeoutMsg = `Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms${restartSuffix}`;
|
|
249
|
+
if (requestSessionId) {
|
|
250
|
+
sessionWarn(requestSessionId, timeoutMsg);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
warn(timeoutMsg);
|
|
254
|
+
}
|
|
255
|
+
reject(new Error(`${this.errorPrefix} Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`));
|
|
256
|
+
// Kill the hung process so the next request gets a fresh bridge —
|
|
257
|
+
// unless the caller explicitly opted out (e.g. bash, which enforces
|
|
258
|
+
// its own timeout on the Rust side and shouldn't lose warm bridge
|
|
259
|
+
// state when its response is merely late).
|
|
260
|
+
if (!keepBridgeOnTimeout) {
|
|
261
|
+
this.handleTimeout();
|
|
262
|
+
}
|
|
263
|
+
}, effectiveTimeoutMs);
|
|
264
|
+
this.pending.set(id, { resolve, reject, timer, onProgress: options?.onProgress });
|
|
265
|
+
if (!this.process?.stdin?.writable) {
|
|
266
|
+
this.pending.delete(id);
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
reject(new Error(`${this.errorPrefix} stdin not writable for command "${command}"`));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
this.process.stdin.write(line, (err) => {
|
|
272
|
+
if (err) {
|
|
273
|
+
const entry = this.pending.get(id);
|
|
274
|
+
if (entry) {
|
|
275
|
+
this.pending.delete(id);
|
|
276
|
+
clearTimeout(entry.timer);
|
|
277
|
+
entry.reject(new Error(`${this.errorPrefix} Failed to write to stdin: ${err.message}`));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
async deliverConfigureWarnings(configResult, params, options) {
|
|
284
|
+
if (!this.onConfigureWarnings || !Array.isArray(configResult.warnings))
|
|
285
|
+
return;
|
|
286
|
+
if (configResult.warnings.length === 0)
|
|
287
|
+
return;
|
|
288
|
+
try {
|
|
289
|
+
const sessionId = typeof params.session_id === "string" ? params.session_id : undefined;
|
|
290
|
+
await this.onConfigureWarnings({
|
|
291
|
+
projectRoot: this.cwd,
|
|
292
|
+
sessionId,
|
|
293
|
+
client: options?.configureWarningClient ??
|
|
294
|
+
(sessionId ? this.configureWarningClients.get(sessionId) : undefined),
|
|
295
|
+
warnings: configResult.warnings,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
warn(`configure warning delivery failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Handle the `configure_warnings` push frame the Rust binary emits after
|
|
304
|
+
* configure has returned. The frame carries the warnings produced by the
|
|
305
|
+
* deferred file walk + missing-binary detection. Forwards to the same
|
|
306
|
+
* `onConfigureWarnings` handler used for synchronous warnings so plugins
|
|
307
|
+
* don't need to know about the async path.
|
|
308
|
+
*/
|
|
309
|
+
async handleConfigureWarningsFrame(frame) {
|
|
310
|
+
if (!this.onConfigureWarnings)
|
|
311
|
+
return;
|
|
312
|
+
const warnings = frame.warnings;
|
|
313
|
+
if (!Array.isArray(warnings) || warnings.length === 0)
|
|
314
|
+
return;
|
|
315
|
+
const projectRoot = typeof frame.project_root === "string" ? frame.project_root : this.cwd;
|
|
316
|
+
const rawSessionId = frame.session_id;
|
|
317
|
+
const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : null;
|
|
318
|
+
await this.onConfigureWarnings({
|
|
319
|
+
projectRoot,
|
|
320
|
+
sessionId,
|
|
321
|
+
client: sessionId ? this.configureWarningClients.get(sessionId) : undefined,
|
|
322
|
+
warnings: warnings,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
/** Kill the child process and reject all pending requests. */
|
|
326
|
+
async shutdown() {
|
|
327
|
+
this._shuttingDown = true;
|
|
328
|
+
this.clearRestartResetTimer();
|
|
329
|
+
this.rejectAllPending(new Error(`${this.errorPrefix} Bridge shutting down`));
|
|
330
|
+
if (this.process) {
|
|
331
|
+
const proc = this.process;
|
|
332
|
+
this.process = null;
|
|
333
|
+
return new Promise((resolve) => {
|
|
334
|
+
const forceKillTimer = setTimeout(() => {
|
|
335
|
+
proc.kill("SIGKILL");
|
|
336
|
+
resolve();
|
|
337
|
+
}, 5_000);
|
|
338
|
+
proc.once("exit", () => {
|
|
339
|
+
clearTimeout(forceKillTimer);
|
|
340
|
+
log("Process exited during shutdown");
|
|
341
|
+
resolve();
|
|
342
|
+
});
|
|
343
|
+
proc.kill("SIGTERM");
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// ---- Internal ----
|
|
348
|
+
/** Query binary version and compare against minVersion. Calls onVersionMismatch if outdated. */
|
|
349
|
+
async checkVersion() {
|
|
350
|
+
if (!this.minVersion)
|
|
351
|
+
return;
|
|
352
|
+
try {
|
|
353
|
+
const resp = await this.send("version");
|
|
354
|
+
const binaryVersion = resp.version;
|
|
355
|
+
if (!binaryVersion) {
|
|
356
|
+
log("Binary did not report a version — skipping version check");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
log(`Binary version: ${binaryVersion}`);
|
|
360
|
+
if (compareSemver(binaryVersion, this.minVersion) < 0) {
|
|
361
|
+
warn(`Binary version ${binaryVersion} is older than required ${this.minVersion}`);
|
|
362
|
+
this.onVersionMismatch?.(binaryVersion, this.minVersion);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
// Version check is best-effort — don't block tool usage if it fails
|
|
367
|
+
warn(`Version check failed: ${err.message}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
ensureSpawned() {
|
|
371
|
+
if (this.isAlive())
|
|
372
|
+
return;
|
|
373
|
+
this.spawnProcess();
|
|
374
|
+
}
|
|
375
|
+
spawnProcess() {
|
|
376
|
+
log(`Spawning binary: ${this.binaryPath} (cwd: ${this.cwd})`);
|
|
377
|
+
const semantic = this.configOverrides.semantic;
|
|
378
|
+
const semanticBackend = (() => {
|
|
379
|
+
if (semantic && typeof semantic === "object" && !Array.isArray(semantic)) {
|
|
380
|
+
const candidate = semantic.backend;
|
|
381
|
+
return typeof candidate === "string" ? candidate : undefined;
|
|
382
|
+
}
|
|
383
|
+
return undefined;
|
|
384
|
+
})();
|
|
385
|
+
const useFastembedBackend = semanticBackend === undefined || semanticBackend === "fastembed" || semanticBackend === "";
|
|
386
|
+
const ortDir = typeof this.configOverrides._ort_dylib_dir === "string" && useFastembedBackend
|
|
387
|
+
? this.configOverrides._ort_dylib_dir
|
|
388
|
+
: null;
|
|
389
|
+
const ortLibraryPath = ortDir == null
|
|
390
|
+
? null
|
|
391
|
+
: join(ortDir, process.platform === "win32"
|
|
392
|
+
? "onnxruntime.dll"
|
|
393
|
+
: process.platform === "darwin"
|
|
394
|
+
? "libonnxruntime.dylib"
|
|
395
|
+
: "libonnxruntime.so");
|
|
396
|
+
const envPath = process.platform === "win32" && ortDir
|
|
397
|
+
? `${ortDir};${process.env.PATH ?? ""}`
|
|
398
|
+
: process.env.PATH;
|
|
399
|
+
const env = {
|
|
400
|
+
...process.env,
|
|
401
|
+
...(envPath ? { PATH: envPath } : {}),
|
|
402
|
+
};
|
|
403
|
+
if (useFastembedBackend) {
|
|
404
|
+
// Store fastembed model files alongside the semantic index, not the project cwd.
|
|
405
|
+
// This is only relevant when the fastembed backend is selected.
|
|
406
|
+
env.FASTEMBED_CACHE_DIR =
|
|
407
|
+
process.env.FASTEMBED_CACHE_DIR ||
|
|
408
|
+
(typeof this.configOverrides.storage_dir === "string"
|
|
409
|
+
? join(this.configOverrides.storage_dir, "semantic", "models")
|
|
410
|
+
: join(homedir() || "", ".cache", "fastembed"));
|
|
411
|
+
// Point ort to the auto-downloaded or system ONNX Runtime library.
|
|
412
|
+
if (ortLibraryPath) {
|
|
413
|
+
env.ORT_DYLIB_PATH = ortLibraryPath;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const child = spawn(this.binaryPath, [], {
|
|
417
|
+
cwd: this.cwd,
|
|
418
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
419
|
+
env,
|
|
420
|
+
});
|
|
421
|
+
const currentChild = child;
|
|
422
|
+
child.stdout?.on("data", (chunk) => {
|
|
423
|
+
this.onStdoutData(chunk.toString("utf-8"));
|
|
424
|
+
});
|
|
425
|
+
child.stderr?.on("data", (chunk) => {
|
|
426
|
+
const lines = chunk.toString("utf-8").trimEnd().split("\n");
|
|
427
|
+
for (const line of lines) {
|
|
428
|
+
if (!line)
|
|
429
|
+
continue;
|
|
430
|
+
// Strip Rust env_logger prefix and re-tag under [aft]
|
|
431
|
+
const stripped = line.replace(/^\[aft\]\s*/, "");
|
|
432
|
+
log(`[aft] ${stripped}`);
|
|
433
|
+
this.pushStderrLine(stripped);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
child.on("error", (err) => {
|
|
437
|
+
if (this.process !== currentChild)
|
|
438
|
+
return;
|
|
439
|
+
error(`Process error: ${err.message}${this.formatStderrTail()}`);
|
|
440
|
+
this.handleCrash();
|
|
441
|
+
});
|
|
442
|
+
child.on("exit", (code, signal) => {
|
|
443
|
+
if (this.process !== currentChild)
|
|
444
|
+
return;
|
|
445
|
+
if (this._shuttingDown)
|
|
446
|
+
return;
|
|
447
|
+
log(`Process exited: code=${code}, signal=${signal}`);
|
|
448
|
+
// External termination signals (SIGTERM/SIGKILL/SIGHUP/SIGINT) are almost
|
|
449
|
+
// always intentional kills — from our own shutdown path, OpenCode tearing
|
|
450
|
+
// down, OS shutdown, or the user killing the host. Auto-restarting here
|
|
451
|
+
// produces process avalanches (issue #14): N bridges all receive SIGTERM
|
|
452
|
+
// simultaneously, each "auto-restarts", spawning N fresh processes that
|
|
453
|
+
// reload ONNX + semantic + trigram indexes. Real Rust panics/crashes exit
|
|
454
|
+
// with a non-null `code` and `signal === null`; those still restart.
|
|
455
|
+
if (signal === "SIGTERM" ||
|
|
456
|
+
signal === "SIGKILL" ||
|
|
457
|
+
signal === "SIGHUP" ||
|
|
458
|
+
signal === "SIGINT") {
|
|
459
|
+
this.process = null;
|
|
460
|
+
this.configured = false;
|
|
461
|
+
this.clearRestartResetTimer();
|
|
462
|
+
this.rejectAllPending(new Error(`${this.errorPrefix} Binary killed by ${signal}`));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
this.handleCrash();
|
|
466
|
+
});
|
|
467
|
+
this.process = child;
|
|
468
|
+
this.stdoutBuffer = "";
|
|
469
|
+
// Fresh spawn — clear the stderr ring so crash diagnostics only reflect
|
|
470
|
+
// the current child's output, not output from prior restart cycles.
|
|
471
|
+
this.stderrTail = [];
|
|
472
|
+
}
|
|
473
|
+
pushStderrLine(line) {
|
|
474
|
+
this.stderrTail.push(line);
|
|
475
|
+
if (this.stderrTail.length > BinaryBridge.STDERR_TAIL_MAX) {
|
|
476
|
+
this.stderrTail.shift();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Format the current stderr tail for inclusion in error messages. Returns
|
|
481
|
+
* empty string when nothing has been captured (e.g., silent SIGKILL from
|
|
482
|
+
* macOS amfid) so the caller can safely concatenate unconditionally.
|
|
483
|
+
*/
|
|
484
|
+
formatStderrTail() {
|
|
485
|
+
if (this.stderrTail.length === 0)
|
|
486
|
+
return "";
|
|
487
|
+
const tail = this.stderrTail.join("\n ");
|
|
488
|
+
return `\n --- last ${this.stderrTail.length} stderr lines ---\n ${tail}`;
|
|
489
|
+
}
|
|
490
|
+
onStdoutData(data) {
|
|
491
|
+
this.stdoutBuffer += data;
|
|
492
|
+
if (this.stdoutBuffer.length > MAX_STDOUT_BUFFER) {
|
|
493
|
+
this.handleCrash(new Error(`aft bridge stdout buffer exceeded ${MAX_STDOUT_BUFFER} bytes — killing bridge`));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// Process complete lines
|
|
497
|
+
let newlineIdx;
|
|
498
|
+
while ((newlineIdx = this.stdoutBuffer.indexOf("\n")) !== -1) {
|
|
499
|
+
const line = this.stdoutBuffer.slice(0, newlineIdx).trim();
|
|
500
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIdx + 1);
|
|
501
|
+
if (!line)
|
|
502
|
+
continue;
|
|
503
|
+
try {
|
|
504
|
+
const response = JSON.parse(line);
|
|
505
|
+
if (response.type === "progress") {
|
|
506
|
+
const requestId = response.request_id;
|
|
507
|
+
const entry = requestId ? this.pending.get(requestId) : undefined;
|
|
508
|
+
const kind = response.kind === "stderr" ? "stderr" : "stdout";
|
|
509
|
+
const text = typeof response.chunk === "string" ? response.chunk : "";
|
|
510
|
+
entry?.onProgress?.({ kind, text });
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (response.type === "permission_ask") {
|
|
514
|
+
const requestId = response.request_id;
|
|
515
|
+
const entry = requestId ? this.pending.get(requestId) : undefined;
|
|
516
|
+
if (requestId && entry) {
|
|
517
|
+
this.pending.delete(requestId);
|
|
518
|
+
clearTimeout(entry.timer);
|
|
519
|
+
entry.resolve({
|
|
520
|
+
success: false,
|
|
521
|
+
code: "permission_required",
|
|
522
|
+
message: "bash command requires permission",
|
|
523
|
+
asks: response.asks,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (response.type === "bash_completed") {
|
|
529
|
+
this.onBashCompletion?.(response, this);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (response.type === "configure_warnings") {
|
|
533
|
+
this.handleConfigureWarningsFrame(response).catch((err) => {
|
|
534
|
+
warn(`configure warning delivery failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
535
|
+
});
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const id = response.id;
|
|
539
|
+
if (id && this.pending.has(id)) {
|
|
540
|
+
const entry = this.pending.get(id);
|
|
541
|
+
if (!entry)
|
|
542
|
+
continue;
|
|
543
|
+
this.pending.delete(id);
|
|
544
|
+
clearTimeout(entry.timer);
|
|
545
|
+
this.scheduleRestartCountReset();
|
|
546
|
+
entry.resolve(response);
|
|
547
|
+
}
|
|
548
|
+
else if (typeof response.type === "string") {
|
|
549
|
+
log(`Ignoring unknown stdout push frame type: ${response.type}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
catch (_err) {
|
|
553
|
+
warn(`Failed to parse stdout line: ${line}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
handleTimeout() {
|
|
558
|
+
// A single request timed out. Kill the hung process so the bridge can
|
|
559
|
+
// respawn on the next call — but do NOT reject other pending requests
|
|
560
|
+
// here (#21). Each pending request has its own timer and will reject
|
|
561
|
+
// itself if it also times out. Proactively rejecting peers destroys work
|
|
562
|
+
// that may have been perfectly healthy (e.g. a `read` call waiting behind
|
|
563
|
+
// a slow `bash` command).
|
|
564
|
+
//
|
|
565
|
+
// When the process dies, its stdout closes and the crash handler fires,
|
|
566
|
+
// which will reject any remaining pending requests through the normal path.
|
|
567
|
+
if (this.process) {
|
|
568
|
+
this.process.kill("SIGKILL");
|
|
569
|
+
this.process = null;
|
|
570
|
+
}
|
|
571
|
+
this.clearRestartResetTimer();
|
|
572
|
+
this.configured = false;
|
|
573
|
+
// Capture the stderr tail for diagnostics. The tail goes to the plugin
|
|
574
|
+
// log only — it's operator-facing noise (loaded N backups, invalidated K
|
|
575
|
+
// files, etc.) that the agent can't act on, so we don't put it in the
|
|
576
|
+
// rejection error. Clear the ring so the next spawn doesn't inherit it.
|
|
577
|
+
const tail = this.formatStderrTail();
|
|
578
|
+
this.stderrTail = [];
|
|
579
|
+
if (tail) {
|
|
580
|
+
error(`Bridge killed after timeout.${tail}`);
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
warn(`Bridge killed after timeout (see ${getLogFilePath()})`);
|
|
584
|
+
}
|
|
585
|
+
// Peer requests are NOT rejected here. They will either:
|
|
586
|
+
// 1. Resolve if the binary somehow still delivers their response (unlikely
|
|
587
|
+
// after SIGKILL, but harmless to leave pending briefly), or
|
|
588
|
+
// 2. Reject through their own timers when they individually expire, or
|
|
589
|
+
// 3. Reject immediately through handleCrash() when the stdout pipe closes.
|
|
590
|
+
}
|
|
591
|
+
handleCrash(cause) {
|
|
592
|
+
const proc = this.process;
|
|
593
|
+
this.process = null;
|
|
594
|
+
if (proc && proc.exitCode === null && !proc.killed) {
|
|
595
|
+
proc.kill("SIGKILL");
|
|
596
|
+
}
|
|
597
|
+
this.clearRestartResetTimer();
|
|
598
|
+
this.configured = false; // Force reconfigure on next command after restart
|
|
599
|
+
// Capture the tail BEFORE spawning the replacement, because the next spawn
|
|
600
|
+
// clears the ring. The tail goes to the plugin log only — it's operator
|
|
601
|
+
// diagnostic output that the agent can't act on. The pending-request
|
|
602
|
+
// rejection only carries a pointer to the log.
|
|
603
|
+
const tail = this.formatStderrTail();
|
|
604
|
+
if (tail) {
|
|
605
|
+
error(`Binary crashed (restarts: ${this._restartCount})${cause ? `: ${cause.message}` : ""}.${tail}`);
|
|
606
|
+
}
|
|
607
|
+
this.rejectAllPending(new Error(`${this.errorPrefix} Binary crashed (restarts: ${this._restartCount})${cause ? `: ${cause.message}` : ""} (see ${getLogFilePath()})`));
|
|
608
|
+
// Auto-restart with exponential backoff
|
|
609
|
+
if (this._restartCount < this.maxRestarts) {
|
|
610
|
+
const delay = 100 * 2 ** this._restartCount; // 100ms, 200ms, 400ms
|
|
611
|
+
this._restartCount++;
|
|
612
|
+
log(`Auto-restart #${this._restartCount} in ${delay}ms`);
|
|
613
|
+
setTimeout(() => {
|
|
614
|
+
if (!this._shuttingDown && !this.isAlive()) {
|
|
615
|
+
try {
|
|
616
|
+
this.spawnProcess();
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
error(`Failed to restart: ${err.message}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}, delay);
|
|
623
|
+
// Also decay the counter over time so repeated crashes without any
|
|
624
|
+
// successful response don't permanently wedge the bridge.
|
|
625
|
+
this.scheduleRestartCountReset();
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
error(`Max restarts (${this.maxRestarts}) reached, giving up. Logs: ${getLogFilePath()}${tail}`);
|
|
629
|
+
this.scheduleRestartCountReset();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
rejectAllPending(error) {
|
|
633
|
+
for (const [_id, entry] of this.pending) {
|
|
634
|
+
clearTimeout(entry.timer);
|
|
635
|
+
entry.reject(error);
|
|
636
|
+
}
|
|
637
|
+
this.pending.clear();
|
|
638
|
+
}
|
|
639
|
+
scheduleRestartCountReset() {
|
|
640
|
+
this.clearRestartResetTimer();
|
|
641
|
+
this.restartResetTimer = setTimeout(() => {
|
|
642
|
+
this._restartCount = 0;
|
|
643
|
+
this.restartResetTimer = null;
|
|
644
|
+
}, BinaryBridge.RESTART_RESET_MS);
|
|
645
|
+
}
|
|
646
|
+
clearRestartResetTimer() {
|
|
647
|
+
if (this.restartResetTimer) {
|
|
648
|
+
clearTimeout(this.restartResetTimer);
|
|
649
|
+
this.restartResetTimer = null;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
//# sourceMappingURL=bridge.js.map
|