@cortexkit/aft-bridge 0.59.0 → 0.61.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/bash-format.d.ts +5 -0
- package/dist/bash-format.d.ts.map +1 -1
- package/dist/bash-format.js +12 -5
- package/dist/bash-format.js.map +1 -1
- package/dist/bash-hints.d.ts +3 -3
- package/dist/bash-hints.d.ts.map +1 -1
- package/dist/bash-hints.js +14 -13
- package/dist/bash-hints.js.map +1 -1
- package/dist/bash-host-fallback.d.ts +2 -2
- package/dist/bash-host-fallback.d.ts.map +1 -1
- package/dist/bash-host-fallback.js +2 -2
- package/dist/bash-host-fallback.js.map +1 -1
- package/dist/bridge.d.ts +87 -9
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +289 -108
- package/dist/bridge.js.map +1 -1
- package/dist/config-watch.d.ts +8 -6
- package/dist/config-watch.d.ts.map +1 -1
- package/dist/config-watch.js +17 -12
- package/dist/config-watch.js.map +1 -1
- package/dist/downloader.js +4 -3
- package/dist/downloader.js.map +1 -1
- package/dist/error-contract.d.ts +30 -2
- package/dist/error-contract.d.ts.map +1 -1
- package/dist/error-contract.js +69 -7
- package/dist/error-contract.js.map +1 -1
- package/dist/feature-config.d.ts +3 -0
- package/dist/feature-config.d.ts.map +1 -1
- package/dist/feature-config.js +124 -0
- package/dist/feature-config.js.map +1 -1
- package/dist/index.d.ts +11 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -6
- package/dist/index.js.map +1 -1
- package/dist/lsp-github-install.d.ts +104 -0
- package/dist/lsp-github-install.d.ts.map +1 -0
- package/dist/lsp-github-install.js +1455 -0
- package/dist/lsp-github-install.js.map +1 -0
- package/dist/lsp-github-probe.d.ts +81 -0
- package/dist/lsp-github-probe.d.ts.map +1 -0
- package/dist/lsp-github-probe.js +131 -0
- package/dist/lsp-github-probe.js.map +1 -0
- package/dist/lsp-github-table.d.ts +64 -0
- package/dist/lsp-github-table.d.ts.map +1 -0
- package/dist/lsp-github-table.js +174 -0
- package/dist/lsp-github-table.js.map +1 -0
- package/dist/migration.d.ts +10 -3
- package/dist/migration.d.ts.map +1 -1
- package/dist/migration.js +19 -14
- package/dist/migration.js.map +1 -1
- package/dist/onnx-runtime.d.ts +2 -0
- package/dist/onnx-runtime.d.ts.map +1 -1
- package/dist/onnx-runtime.js +12 -7
- package/dist/onnx-runtime.js.map +1 -1
- package/dist/paths.d.ts +3 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +41 -6
- package/dist/paths.js.map +1 -1
- package/dist/pool.d.ts +8 -0
- package/dist/pool.d.ts.map +1 -1
- package/dist/pool.js +31 -8
- package/dist/pool.js.map +1 -1
- package/dist/project-identity.d.ts +2 -0
- package/dist/project-identity.d.ts.map +1 -1
- package/dist/project-identity.js +8 -2
- package/dist/project-identity.js.map +1 -1
- package/dist/protocol.d.ts +3 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +14 -1
- package/dist/protocol.js.map +1 -1
- package/dist/resolver.d.ts.map +1 -1
- package/dist/resolver.js +5 -2
- package/dist/resolver.js.map +1 -1
- package/dist/revivable-transport.d.ts +16 -1
- package/dist/revivable-transport.d.ts.map +1 -1
- package/dist/revivable-transport.js +83 -11
- package/dist/revivable-transport.js.map +1 -1
- package/dist/subc-transport.d.ts +45 -4
- package/dist/subc-transport.d.ts.map +1 -1
- package/dist/subc-transport.js +659 -49
- package/dist/subc-transport.js.map +1 -1
- package/dist/tar-executable.d.ts +3 -1
- package/dist/tar-executable.d.ts.map +1 -1
- package/dist/tar-executable.js +35 -15
- package/dist/tar-executable.js.map +1 -1
- package/dist/transport-factory.d.ts.map +1 -1
- package/dist/transport-factory.js +8 -32
- package/dist/transport-factory.js.map +1 -1
- package/dist/transport.d.ts +27 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +9 -0
- package/dist/transport.js.map +1 -1
- package/package.json +2 -2
package/dist/bridge.js
CHANGED
|
@@ -6,7 +6,8 @@ import { binaryContentHash, binaryStampKey, peekBinaryContentHash } from "./bina
|
|
|
6
6
|
import { spawn } from "./child-process.js";
|
|
7
7
|
import { isPassiveCommand, PASSIVE_COMMAND_TIMEOUT_MS } from "./command-timeouts.js";
|
|
8
8
|
import { withPathPrepended } from "./path-env.js";
|
|
9
|
-
import {
|
|
9
|
+
import { isTerminalBashStatus } from "./protocol.js";
|
|
10
|
+
import { HOST_TOOL_NAMES_FIELD, WORKER_SESSION_FIELD } from "./transport.js";
|
|
10
11
|
const DEFAULT_BRIDGE_TIMEOUT_MS = 30_000;
|
|
11
12
|
const BRIDGE_HANG_TIMEOUT_THRESHOLD = 2;
|
|
12
13
|
const MAX_STDOUT_BUFFER = 64 * 1024 * 1024; // 64MB
|
|
@@ -14,19 +15,6 @@ const MAX_STDOUT_BUFFER = 64 * 1024 * 1024; // 64MB
|
|
|
14
15
|
const MALFORMED_STDOUT_LOG_CHARS = 512;
|
|
15
16
|
const HASHLINE_REGISTRATION_LOG_INTERVAL_MS = 60_000;
|
|
16
17
|
const HASHLINE_REGISTRATION_LOG_STATE_LIMIT = 256;
|
|
17
|
-
const TERMINAL_BASH_STATUSES = new Set([
|
|
18
|
-
"completed",
|
|
19
|
-
"failed",
|
|
20
|
-
"killed",
|
|
21
|
-
"timed_out",
|
|
22
|
-
"fate_unknown",
|
|
23
|
-
// Historical/defensive aliases seen in plugin-side compatibility code.
|
|
24
|
-
"cancelled",
|
|
25
|
-
"timeout",
|
|
26
|
-
]);
|
|
27
|
-
function isTerminalBashStatus(status) {
|
|
28
|
-
return typeof status === "string" && TERMINAL_BASH_STATUSES.has(status);
|
|
29
|
-
}
|
|
30
18
|
function bashTaskIdFrom(response) {
|
|
31
19
|
const snakeCase = response.task_id;
|
|
32
20
|
if (typeof snakeCase === "string" && snakeCase.length > 0)
|
|
@@ -115,47 +103,49 @@ export function shouldSurfaceStderrLine(line) {
|
|
|
115
103
|
normalized === BENIGN_CPUINFO_PROC_CPUINFO_PARSE_FAILURE);
|
|
116
104
|
}
|
|
117
105
|
export function compareSemver(a, b) {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
106
|
+
const parse = (version) => {
|
|
107
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version);
|
|
108
|
+
const pre = match?.[4]?.split(".") ?? [];
|
|
109
|
+
if (!match || pre.some((id) => /^\d+$/.test(id) && id.length > 1 && id[0] === "0")) {
|
|
110
|
+
throw new Error(`Invalid semantic version: ${version}`);
|
|
111
|
+
}
|
|
112
|
+
return { core: match.slice(1, 4), pre };
|
|
113
|
+
};
|
|
114
|
+
// Compare numeric identifiers as strings so even large valid identifiers
|
|
115
|
+
// retain their order without Number rounding or overflow.
|
|
116
|
+
const numeric = (a, b) => a.length !== b.length ? a.length - b.length : a === b ? 0 : a < b ? -1 : 1;
|
|
117
|
+
const left = parse(a);
|
|
118
|
+
const right = parse(b);
|
|
122
119
|
for (let i = 0; i < 3; i++) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const bIds = bPre.split(".");
|
|
134
|
-
for (let i = 0; i < Math.max(aIds.length, bIds.length); i++) {
|
|
135
|
-
const ai = aIds[i];
|
|
136
|
-
const bi = bIds[i];
|
|
120
|
+
const diff = numeric(left.core[i], right.core[i]);
|
|
121
|
+
if (diff !== 0)
|
|
122
|
+
return diff;
|
|
123
|
+
}
|
|
124
|
+
if (left.pre.length === 0 || right.pre.length === 0) {
|
|
125
|
+
return left.pre.length === right.pre.length ? 0 : left.pre.length === 0 ? 1 : -1;
|
|
126
|
+
}
|
|
127
|
+
for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
|
|
128
|
+
const ai = left.pre[i];
|
|
129
|
+
const bi = right.pre[i];
|
|
137
130
|
if (ai === undefined)
|
|
138
131
|
return -1;
|
|
139
132
|
if (bi === undefined)
|
|
140
133
|
return 1;
|
|
141
134
|
const aNum = /^\d+$/.test(ai);
|
|
142
135
|
const bNum = /^\d+$/.test(bi);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (cmp !== 0)
|
|
157
|
-
return cmp;
|
|
158
|
-
}
|
|
136
|
+
const diff = aNum && bNum
|
|
137
|
+
? numeric(ai, bi)
|
|
138
|
+
: aNum !== bNum
|
|
139
|
+
? aNum
|
|
140
|
+
? -1
|
|
141
|
+
: 1
|
|
142
|
+
: ai === bi
|
|
143
|
+
? 0
|
|
144
|
+
: ai < bi
|
|
145
|
+
? -1
|
|
146
|
+
: 1;
|
|
147
|
+
if (diff !== 0)
|
|
148
|
+
return diff;
|
|
159
149
|
}
|
|
160
150
|
return 0;
|
|
161
151
|
}
|
|
@@ -195,12 +185,44 @@ export class BridgeTransportTimeoutError extends Error {
|
|
|
195
185
|
command;
|
|
196
186
|
timeoutMs;
|
|
197
187
|
code = "transport_timeout";
|
|
198
|
-
|
|
188
|
+
/**
|
|
189
|
+
* For a `bash`/`powershell` call, what the bridge learned about the command
|
|
190
|
+
* while it waited a short grace period after the deadline. Undefined for
|
|
191
|
+
* other commands.
|
|
192
|
+
*/
|
|
193
|
+
shellOutcome;
|
|
194
|
+
constructor(command, timeoutMs, message, shellOutcome) {
|
|
199
195
|
super(message);
|
|
200
196
|
this.command = command;
|
|
201
197
|
this.timeoutMs = timeoutMs;
|
|
202
198
|
this.name = "BridgeTransportTimeoutError";
|
|
199
|
+
this.shellOutcome = shellOutcome;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Commands that start a shell task in AFT and answer with its task id. */
|
|
203
|
+
const SHELL_TASK_COMMANDS = new Set(["bash", "powershell"]);
|
|
204
|
+
/**
|
|
205
|
+
* How long a timed-out shell call keeps listening for AFT's late reply. AFT
|
|
206
|
+
* can start the command and answer just after the bridge's deadline (for
|
|
207
|
+
* example while it is busy with other work); that reply carries the task id,
|
|
208
|
+
* which is the only way the agent can find the running command. Kept short
|
|
209
|
+
* because the agent waits this much longer for its error.
|
|
210
|
+
*/
|
|
211
|
+
const DEFAULT_LATE_SHELL_REPLY_GRACE_MS = 10_000;
|
|
212
|
+
function shellOutcomeFromLateReply(response) {
|
|
213
|
+
const taskId = bashTaskIdFrom(response);
|
|
214
|
+
if (taskId)
|
|
215
|
+
return { kind: "task_started", taskId };
|
|
216
|
+
if (response.success === false) {
|
|
217
|
+
return {
|
|
218
|
+
kind: "not_started",
|
|
219
|
+
...(typeof response.code === "string" ? { code: response.code } : {}),
|
|
220
|
+
...(typeof response.message === "string" ? { message: response.message } : {}),
|
|
221
|
+
};
|
|
203
222
|
}
|
|
223
|
+
// A successful reply without a task id is not a shape AFT sends for a shell
|
|
224
|
+
// command, so it proves nothing either way.
|
|
225
|
+
return { kind: "unknown" };
|
|
204
226
|
}
|
|
205
227
|
/** A standalone bridge cannot carry a request because its process transport died. */
|
|
206
228
|
export class BridgeTransportUnavailableError extends Error {
|
|
@@ -240,6 +262,22 @@ export class BinaryBridge {
|
|
|
240
262
|
/** Once true, this bridge must not accept new requests and should be drained. */
|
|
241
263
|
_retiringDueToBinaryChange = false;
|
|
242
264
|
pending = new Map();
|
|
265
|
+
/**
|
|
266
|
+
* Public `send()` calls that have not settled yet, including the configure
|
|
267
|
+
* and version requests a first call runs before its own request. While this
|
|
268
|
+
* is above zero (or a background bash task is outstanding) the child process
|
|
269
|
+
* and its pipes keep the host's event loop alive; otherwise they are
|
|
270
|
+
* unreferenced, so an idle bridge never stops a one-shot host command from
|
|
271
|
+
* exiting. See `syncEventLoopHold`.
|
|
272
|
+
*/
|
|
273
|
+
activeSendCalls = 0;
|
|
274
|
+
/**
|
|
275
|
+
* Whether the current child's handles are referenced. A fresh spawn starts
|
|
276
|
+
* referenced, as Node and Bun create them. Tracked so `ref`/`unref` are
|
|
277
|
+
* only called on a change: Bun counts them on its child pipes, so a
|
|
278
|
+
* repeated `ref()` would need as many `unref()` calls to release the loop.
|
|
279
|
+
*/
|
|
280
|
+
processLoopReferenced = true;
|
|
243
281
|
outstandingBackgroundTaskIds = new Set();
|
|
244
282
|
nextId = 1;
|
|
245
283
|
processGeneration = 0;
|
|
@@ -263,6 +301,7 @@ export class BinaryBridge {
|
|
|
263
301
|
_shuttingDown = false;
|
|
264
302
|
timeoutMs;
|
|
265
303
|
hangThreshold;
|
|
304
|
+
lateShellReplyGraceMs;
|
|
266
305
|
maxRestarts;
|
|
267
306
|
configured = false;
|
|
268
307
|
_configurePromise = null;
|
|
@@ -280,6 +319,7 @@ export class BinaryBridge {
|
|
|
280
319
|
statusListeners = new Set();
|
|
281
320
|
/** Notification clients keyed by session_id for async configure warning pushes. */
|
|
282
321
|
configureWarningClients = new Map();
|
|
322
|
+
restartTimer = null;
|
|
283
323
|
restartResetTimer = null;
|
|
284
324
|
/** Updated after every successfully parsed stdout frame from the child. */
|
|
285
325
|
lastChildActivityAt = 0;
|
|
@@ -298,6 +338,8 @@ export class BinaryBridge {
|
|
|
298
338
|
this.cwd = cwd;
|
|
299
339
|
this.timeoutMs = options?.timeoutMs ?? DEFAULT_BRIDGE_TIMEOUT_MS;
|
|
300
340
|
this.hangThreshold = options?.hangThreshold ?? BRIDGE_HANG_TIMEOUT_THRESHOLD;
|
|
341
|
+
this.lateShellReplyGraceMs =
|
|
342
|
+
options?.lateShellReplyGraceMs ?? DEFAULT_LATE_SHELL_REPLY_GRACE_MS;
|
|
301
343
|
this.maxRestarts = options?.maxRestarts ?? 3;
|
|
302
344
|
this.errorPrefix = options?.errorPrefix ?? "[aft-bridge]";
|
|
303
345
|
// P1 config relocation: semantic config now arrives as raw `config` tiers and
|
|
@@ -532,6 +574,50 @@ export class BinaryBridge {
|
|
|
532
574
|
* Lazy-spawns the binary on first call.
|
|
533
575
|
*/
|
|
534
576
|
async send(command, params = {}, options) {
|
|
577
|
+
this.activeSendCalls += 1;
|
|
578
|
+
this.syncEventLoopHold();
|
|
579
|
+
try {
|
|
580
|
+
return await this.dispatchSend(command, params, options);
|
|
581
|
+
}
|
|
582
|
+
finally {
|
|
583
|
+
this.activeSendCalls -= 1;
|
|
584
|
+
this.syncEventLoopHold();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Reference the child process and its stdio pipes while the bridge has work
|
|
589
|
+
* in flight, and unreference them while it is idle.
|
|
590
|
+
*
|
|
591
|
+
* A host that loads the plugin and then has nothing left to do (a one-shot
|
|
592
|
+
* CLI command, or a headless run after its last turn) must be able to exit
|
|
593
|
+
* even though a warm `aft` child is still running; the child sees its stdin
|
|
594
|
+
* close and exits on its own. While a request is in flight the handles stay
|
|
595
|
+
* referenced, so the host cannot exit mid-call and lose the response. A
|
|
596
|
+
* background bash task the child is still running counts as work in flight
|
|
597
|
+
* too: its completion frame can wake the session, so the host stays up for
|
|
598
|
+
* it, as it always did before idle bridges were released.
|
|
599
|
+
* `ref`/`unref` are optional because not every runtime exposes them on every
|
|
600
|
+
* pipe (Bun's child stdin has neither and does not hold the loop).
|
|
601
|
+
*/
|
|
602
|
+
syncEventLoopHold() {
|
|
603
|
+
const child = this.process;
|
|
604
|
+
if (!child)
|
|
605
|
+
return;
|
|
606
|
+
const hold = this.activeSendCalls > 0 || this.outstandingBackgroundTaskIds.size > 0;
|
|
607
|
+
if (hold === this.processLoopReferenced)
|
|
608
|
+
return;
|
|
609
|
+
this.processLoopReferenced = hold;
|
|
610
|
+
// The stdio pipes are typed as plain streams, but at runtime they are
|
|
611
|
+
// sockets (Node) or native readables (Bun) that carry ref/unref.
|
|
612
|
+
const handles = [child, child.stdin, child.stdout, child.stderr];
|
|
613
|
+
for (const handle of handles) {
|
|
614
|
+
if (hold)
|
|
615
|
+
handle?.ref?.();
|
|
616
|
+
else
|
|
617
|
+
handle?.unref?.();
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
async dispatchSend(command, params, options) {
|
|
535
621
|
let dispatchParams = params;
|
|
536
622
|
if (command === "configure") {
|
|
537
623
|
dispatchParams = { ...params };
|
|
@@ -578,11 +664,13 @@ export class BinaryBridge {
|
|
|
578
664
|
params.edit_slot_survives = editSlotSurvives;
|
|
579
665
|
this.logHashlineRegistrationCarrier("tool_call", sessionId, editSlotSurvives);
|
|
580
666
|
}
|
|
581
|
-
const { preview, workerSession, ...sendOptions } = options ?? {};
|
|
667
|
+
const { preview, workerSession, hostToolNames, ...sendOptions } = options ?? {};
|
|
582
668
|
if (preview === true)
|
|
583
669
|
params.preview = true;
|
|
584
670
|
if (workerSession === true)
|
|
585
671
|
params[WORKER_SESSION_FIELD] = true;
|
|
672
|
+
if (hostToolNames)
|
|
673
|
+
params[HOST_TOOL_NAMES_FIELD] = hostToolNames;
|
|
586
674
|
return (await this.send("tool_call", params, Object.keys(sendOptions).length > 0 ? sendOptions : undefined));
|
|
587
675
|
}
|
|
588
676
|
async sendWithVersionMismatchRetry(command, params, options, canRetryAfterVersionSwap) {
|
|
@@ -604,13 +692,6 @@ export class BinaryBridge {
|
|
|
604
692
|
? params.session_id
|
|
605
693
|
: undefined;
|
|
606
694
|
this.ensureSpawned(requestSessionId);
|
|
607
|
-
// Auto-configure can reuse the initiating session's notification client
|
|
608
|
-
// when the deferred configure warning frame arrives later. One project
|
|
609
|
-
// bridge can serve many sessions, so keep this per-session instead of one
|
|
610
|
-
// bridge-wide "last client".
|
|
611
|
-
if (requestSessionId && options?.configureWarningClient !== undefined) {
|
|
612
|
-
this.configureWarningClients.set(requestSessionId, options.configureWarningClient);
|
|
613
|
-
}
|
|
614
695
|
// Per-op timeout override: tool wrappers can pass longer budgets for
|
|
615
696
|
// commands that legitimately need them (callers, trace_to, grep on big
|
|
616
697
|
// repos). Defaults to the bridge-wide timeout otherwise.
|
|
@@ -635,6 +716,13 @@ export class BinaryBridge {
|
|
|
635
716
|
if (!this.configured) {
|
|
636
717
|
if (command !== "configure" && command !== "version") {
|
|
637
718
|
if (!this._configurePromise) {
|
|
719
|
+
// Only the initiating session owns configure's deferred delivery.
|
|
720
|
+
// Later requests must not retain unrelated notification clients.
|
|
721
|
+
if (this.onConfigureWarnings &&
|
|
722
|
+
requestSessionId &&
|
|
723
|
+
options?.configureWarningClient !== undefined) {
|
|
724
|
+
this.configureWarningClients.set(requestSessionId, options.configureWarningClient);
|
|
725
|
+
}
|
|
638
726
|
const configuringChild = this.process;
|
|
639
727
|
const requireConfiguringChild = () => {
|
|
640
728
|
if (this.process !== configuringChild) {
|
|
@@ -675,6 +763,11 @@ export class BinaryBridge {
|
|
|
675
763
|
}
|
|
676
764
|
this.configured = true;
|
|
677
765
|
}
|
|
766
|
+
catch (error) {
|
|
767
|
+
if (sessionIdForConfigure)
|
|
768
|
+
this.configureWarningClients.delete(sessionIdForConfigure);
|
|
769
|
+
throw error;
|
|
770
|
+
}
|
|
678
771
|
finally {
|
|
679
772
|
this._configurePromise = null;
|
|
680
773
|
}
|
|
@@ -700,7 +793,12 @@ export class BinaryBridge {
|
|
|
700
793
|
Object.hasOwn(params, "method")) {
|
|
701
794
|
const nested = { ...params };
|
|
702
795
|
const reserved = {};
|
|
703
|
-
for (const key of [
|
|
796
|
+
for (const key of [
|
|
797
|
+
"session_id",
|
|
798
|
+
"lsp_hints",
|
|
799
|
+
WORKER_SESSION_FIELD,
|
|
800
|
+
HOST_TOOL_NAMES_FIELD,
|
|
801
|
+
]) {
|
|
704
802
|
if (Object.hasOwn(nested, key)) {
|
|
705
803
|
reserved[key] = nested[key];
|
|
706
804
|
delete nested[key];
|
|
@@ -725,6 +823,10 @@ export class BinaryBridge {
|
|
|
725
823
|
const entry = this.pending.get(id);
|
|
726
824
|
if (!entry)
|
|
727
825
|
return;
|
|
826
|
+
if (keepBridgeOnTimeout &&
|
|
827
|
+
this.holdForLateShellReply(id, entry, effectiveTimeoutMs, requestSessionId)) {
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
728
830
|
this.pending.delete(id);
|
|
729
831
|
clearTimeout(entry.timer);
|
|
730
832
|
entry.onSettled?.();
|
|
@@ -832,6 +934,13 @@ export class BinaryBridge {
|
|
|
832
934
|
}
|
|
833
935
|
}
|
|
834
936
|
async deliverConfigureWarnings(configResult, params, options) {
|
|
937
|
+
const sessionId = typeof params.session_id === "string" ? params.session_id : undefined;
|
|
938
|
+
const client = options?.configureWarningClient ??
|
|
939
|
+
(sessionId ? this.configureWarningClients.get(sessionId) : undefined);
|
|
940
|
+
// Capture ownership before dispatch. A hung host callback must neither hold
|
|
941
|
+
// configure readiness nor delay releasing the terminal delivery's client.
|
|
942
|
+
if (sessionId && configResult.warnings_pending !== true)
|
|
943
|
+
this.configureWarningClients.delete(sessionId);
|
|
835
944
|
if (!this.onConfigureWarnings)
|
|
836
945
|
return;
|
|
837
946
|
const warnings = Array.isArray(configResult.warnings)
|
|
@@ -840,57 +949,40 @@ export class BinaryBridge {
|
|
|
840
949
|
const configDroppedKeys = coerceConfigureDroppedKeys(configResult.config_dropped_keys);
|
|
841
950
|
if (warnings.length === 0 && configDroppedKeys.length === 0)
|
|
842
951
|
return;
|
|
843
|
-
const sessionId = typeof params.session_id === "string" ? params.session_id : undefined;
|
|
844
952
|
const context = {
|
|
845
953
|
projectRoot: this.cwd,
|
|
846
954
|
sessionId,
|
|
847
|
-
client
|
|
848
|
-
(sessionId ? this.configureWarningClients.get(sessionId) : undefined),
|
|
955
|
+
client,
|
|
849
956
|
warnings,
|
|
850
957
|
};
|
|
851
|
-
if (configDroppedKeys.length > 0)
|
|
958
|
+
if (configDroppedKeys.length > 0)
|
|
852
959
|
context.configDroppedKeys = configDroppedKeys;
|
|
853
|
-
|
|
854
|
-
try {
|
|
855
|
-
await this.onConfigureWarnings(context);
|
|
856
|
-
}
|
|
857
|
-
catch (err) {
|
|
858
|
-
this.warnVia(`configure warning delivery failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
859
|
-
}
|
|
860
|
-
finally {
|
|
861
|
-
if (sessionId) {
|
|
862
|
-
this.configureWarningClients.delete(sessionId);
|
|
863
|
-
}
|
|
864
|
-
}
|
|
960
|
+
this.dispatchHostCallback("configure warning", () => this.onConfigureWarnings?.(context));
|
|
865
961
|
}
|
|
866
|
-
/**
|
|
867
|
-
* Handle the `configure_warnings` push frame the Rust binary emits after
|
|
868
|
-
* configure has returned. The frame carries the warnings produced by the
|
|
869
|
-
* deferred file walk + missing-binary detection. Forwards to the same
|
|
870
|
-
* `onConfigureWarnings` handler used for synchronous warnings so plugins
|
|
871
|
-
* don't need to know about the async path.
|
|
872
|
-
*/
|
|
962
|
+
/** Release deferred-warning ownership even when the terminal frame has no warnings. */
|
|
873
963
|
async handleConfigureWarningsFrame(frame) {
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
964
|
+
const sessionId = typeof frame.session_id === "string" && frame.session_id.length > 0 ? frame.session_id : null;
|
|
965
|
+
const client = sessionId ? this.configureWarningClients.get(sessionId) : undefined;
|
|
966
|
+
if (sessionId)
|
|
967
|
+
this.configureWarningClients.delete(sessionId);
|
|
968
|
+
if (!this.onConfigureWarnings || !Array.isArray(frame.warnings) || frame.warnings.length === 0)
|
|
878
969
|
return;
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
970
|
+
this.dispatchHostCallback("configure warning", () => this.onConfigureWarnings?.({
|
|
971
|
+
projectRoot: typeof frame.project_root === "string" ? frame.project_root : this.cwd,
|
|
972
|
+
sessionId,
|
|
973
|
+
client,
|
|
974
|
+
warnings: frame.warnings,
|
|
975
|
+
}));
|
|
976
|
+
}
|
|
977
|
+
dispatchHostCallback(label, callback) {
|
|
978
|
+
const failed = (error) => {
|
|
979
|
+
this.warnVia(`${label} delivery failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
980
|
+
};
|
|
882
981
|
try {
|
|
883
|
-
|
|
884
|
-
projectRoot,
|
|
885
|
-
sessionId,
|
|
886
|
-
client: sessionId ? this.configureWarningClients.get(sessionId) : undefined,
|
|
887
|
-
warnings: warnings,
|
|
888
|
-
});
|
|
982
|
+
void Promise.resolve(callback()).catch(failed);
|
|
889
983
|
}
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
this.configureWarningClients.delete(sessionId);
|
|
893
|
-
}
|
|
984
|
+
catch (error) {
|
|
985
|
+
failed(error);
|
|
894
986
|
}
|
|
895
987
|
}
|
|
896
988
|
handleStatusChangedFrame(frame) {
|
|
@@ -918,6 +1010,7 @@ export class BinaryBridge {
|
|
|
918
1010
|
this._shuttingDown = true;
|
|
919
1011
|
this.spawnedBinaryFingerprint = null;
|
|
920
1012
|
this.clearRestartResetTimer();
|
|
1013
|
+
this.clearRestartTimer();
|
|
921
1014
|
this.configureWarningClients.clear();
|
|
922
1015
|
this.rejectAllPending(new BridgeTransportUnknownOutcomeError(`${this.errorPrefix} Bridge shutting down`));
|
|
923
1016
|
if (this.process) {
|
|
@@ -1006,6 +1099,7 @@ export class BinaryBridge {
|
|
|
1006
1099
|
this.spawnProcess(triggeringSessionId);
|
|
1007
1100
|
}
|
|
1008
1101
|
spawnProcess(triggeringSessionId) {
|
|
1102
|
+
this.clearRestartTimer();
|
|
1009
1103
|
this._retiringDueToBinaryChange = false;
|
|
1010
1104
|
if (triggeringSessionId) {
|
|
1011
1105
|
this.sessionLogVia(triggeringSessionId, `Spawning binary: ${this.binaryPath} (cwd: ${this.cwd})`);
|
|
@@ -1090,6 +1184,13 @@ export class BinaryBridge {
|
|
|
1090
1184
|
env: spawnEnv,
|
|
1091
1185
|
});
|
|
1092
1186
|
const currentChild = child;
|
|
1187
|
+
// Writable streams emit an error in addition to invoking write callbacks.
|
|
1188
|
+
// Keep retired children observed too, but only retire the current transport.
|
|
1189
|
+
child.stdin?.on("error", (cause) => {
|
|
1190
|
+
if (this.process !== currentChild)
|
|
1191
|
+
return;
|
|
1192
|
+
this.invalidateTransportProcess(new BridgeTransportUnknownOutcomeError(`${this.errorPrefix} Failed to write to stdin: ${cause.message}`, { cause }));
|
|
1193
|
+
});
|
|
1093
1194
|
const stdoutDecoder = new StringDecoder("utf8");
|
|
1094
1195
|
child.stdout?.on("data", (chunk) => {
|
|
1095
1196
|
if (this.process !== currentChild)
|
|
@@ -1162,6 +1263,10 @@ export class BinaryBridge {
|
|
|
1162
1263
|
// Fresh spawn — clear the stderr ring so crash diagnostics only reflect
|
|
1163
1264
|
// the current child's output, not output from prior restart cycles.
|
|
1164
1265
|
this.stderrTail = [];
|
|
1266
|
+
// A child spawned by a call is held until that call settles; one spawned
|
|
1267
|
+
// by an auto-restart with nothing in flight is released right away.
|
|
1268
|
+
this.processLoopReferenced = true;
|
|
1269
|
+
this.syncEventLoopHold();
|
|
1165
1270
|
}
|
|
1166
1271
|
pushStderrLine(line) {
|
|
1167
1272
|
this.stderrTail.push(line);
|
|
@@ -1217,11 +1322,12 @@ export class BinaryBridge {
|
|
|
1217
1322
|
return `\n --- last ${this.stderrTail.length} stderr lines ---\n ${tail}`;
|
|
1218
1323
|
}
|
|
1219
1324
|
onStdoutData(data) {
|
|
1220
|
-
|
|
1221
|
-
|
|
1325
|
+
const exceedsLimit = (fragment) => {
|
|
1326
|
+
if (this.stdoutPendingLength + Buffer.byteLength(fragment, "utf8") <= MAX_STDOUT_BUFFER)
|
|
1327
|
+
return false;
|
|
1222
1328
|
this.handleCrash(new Error(`aft bridge stdout buffer exceeded ${MAX_STDOUT_BUFFER} bytes — killing bridge`));
|
|
1223
|
-
return;
|
|
1224
|
-
}
|
|
1329
|
+
return true;
|
|
1330
|
+
};
|
|
1225
1331
|
// Only the new chunk is searched for newlines: the carried chunks are
|
|
1226
1332
|
// already known to contain none.
|
|
1227
1333
|
this.stdoutScannedChars += data.length;
|
|
@@ -1230,6 +1336,8 @@ export class BinaryBridge {
|
|
|
1230
1336
|
let newlineIdx = data.indexOf("\n");
|
|
1231
1337
|
while (newlineIdx !== -1) {
|
|
1232
1338
|
let line = data.slice(start, newlineIdx);
|
|
1339
|
+
if (exceedsLimit(line))
|
|
1340
|
+
return;
|
|
1233
1341
|
if (this.stdoutPending.length > 0) {
|
|
1234
1342
|
this.stdoutPending.push(line);
|
|
1235
1343
|
line = this.stdoutPending.join("");
|
|
@@ -1249,8 +1357,10 @@ export class BinaryBridge {
|
|
|
1249
1357
|
}
|
|
1250
1358
|
if (start < data.length) {
|
|
1251
1359
|
const rest = start === 0 ? data : data.slice(start);
|
|
1360
|
+
if (exceedsLimit(rest))
|
|
1361
|
+
return;
|
|
1252
1362
|
this.stdoutPending.push(rest);
|
|
1253
|
-
this.stdoutPendingLength += rest
|
|
1363
|
+
this.stdoutPendingLength += Buffer.byteLength(rest, "utf8");
|
|
1254
1364
|
}
|
|
1255
1365
|
}
|
|
1256
1366
|
flushStdoutBuffer() {
|
|
@@ -1293,15 +1403,16 @@ export class BinaryBridge {
|
|
|
1293
1403
|
const taskId = bashTaskIdFrom(response);
|
|
1294
1404
|
if (taskId)
|
|
1295
1405
|
this.outstandingBackgroundTaskIds.delete(taskId);
|
|
1296
|
-
this.
|
|
1406
|
+
this.syncEventLoopHold();
|
|
1407
|
+
this.dispatchHostCallback("bash completion", () => this.onBashCompletion?.(response, this));
|
|
1297
1408
|
return;
|
|
1298
1409
|
}
|
|
1299
1410
|
if (response.type === "bash_long_running") {
|
|
1300
|
-
this.onBashLongRunning?.(response, this);
|
|
1411
|
+
this.dispatchHostCallback("bash long-running", () => this.onBashLongRunning?.(response, this));
|
|
1301
1412
|
return;
|
|
1302
1413
|
}
|
|
1303
1414
|
if (response.type === "bash_pattern_match") {
|
|
1304
|
-
this.onBashPatternMatch?.(response, this);
|
|
1415
|
+
this.dispatchHostCallback("bash pattern-match", () => this.onBashPatternMatch?.(response, this));
|
|
1305
1416
|
return;
|
|
1306
1417
|
}
|
|
1307
1418
|
if (response.type === "configure_warnings") {
|
|
@@ -1339,6 +1450,18 @@ export class BinaryBridge {
|
|
|
1339
1450
|
this.accountForBashTaskResponse(entry.command, response);
|
|
1340
1451
|
entry.resolve(response);
|
|
1341
1452
|
}
|
|
1453
|
+
else if (id && typeof response.type !== "string") {
|
|
1454
|
+
// A reply for a request that already settled (its deadline and any
|
|
1455
|
+
// grace period passed). One that names a task is a warning: the task
|
|
1456
|
+
// may still be running, and this log line is the only record of its id.
|
|
1457
|
+
const taskId = bashTaskIdFrom(response);
|
|
1458
|
+
if (taskId) {
|
|
1459
|
+
this.warnVia(`Dropped late reply for settled request id=${id} task_id=${taskId}`);
|
|
1460
|
+
}
|
|
1461
|
+
else {
|
|
1462
|
+
this.logVia(`Dropped late reply for settled request id=${id}`);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1342
1465
|
else if (typeof response.type === "string") {
|
|
1343
1466
|
this.logVia(`Ignoring unknown stdout push frame type: ${response.type}`);
|
|
1344
1467
|
}
|
|
@@ -1351,11 +1474,54 @@ export class BinaryBridge {
|
|
|
1351
1474
|
this.warnVia(`Failed to parse stdout line: ${quoted}`);
|
|
1352
1475
|
}
|
|
1353
1476
|
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Keep a timed-out shell call listening for AFT's reply for a short grace
|
|
1479
|
+
* period instead of rejecting it at once. AFT may already have started the
|
|
1480
|
+
* command; its late reply carries the task id, and dropping it would leave the
|
|
1481
|
+
* agent with a running task it cannot find, which it may then start twice.
|
|
1482
|
+
*
|
|
1483
|
+
* The entry stays in `pending`, so the ordinary reply path still records the
|
|
1484
|
+
* task (see accountForBashTaskResponse) and clears the grace timer. The call
|
|
1485
|
+
* then rejects with a timeout error whose `shellOutcome` says what AFT
|
|
1486
|
+
* answered, or that nothing arrived. Returns false when the call is not a
|
|
1487
|
+
* shell command or the grace period is disabled; the caller then rejects as
|
|
1488
|
+
* before.
|
|
1489
|
+
*/
|
|
1490
|
+
holdForLateShellReply(id, entry, effectiveTimeoutMs, requestSessionId) {
|
|
1491
|
+
const graceMs = this.lateShellReplyGraceMs;
|
|
1492
|
+
if (!SHELL_TASK_COMMANDS.has(entry.command) || graceMs <= 0)
|
|
1493
|
+
return false;
|
|
1494
|
+
const timeoutMsg = `Request "${entry.command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms; waiting up to ${graceMs}ms for a late reply naming its task`;
|
|
1495
|
+
if (requestSessionId) {
|
|
1496
|
+
this.sessionWarnVia(requestSessionId, timeoutMsg);
|
|
1497
|
+
}
|
|
1498
|
+
else {
|
|
1499
|
+
this.warnVia(timeoutMsg);
|
|
1500
|
+
}
|
|
1501
|
+
const message = `${this.errorPrefix} Request "${entry.command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`;
|
|
1502
|
+
const rejectWith = entry.reject;
|
|
1503
|
+
const settle = (outcome) => {
|
|
1504
|
+
rejectWith(new BridgeTransportTimeoutError(entry.command, effectiveTimeoutMs, message, outcome));
|
|
1505
|
+
};
|
|
1506
|
+
// A reply now arrives after the caller's deadline, so it settles the call
|
|
1507
|
+
// as a timeout that reports what AFT answered.
|
|
1508
|
+
entry.resolve = (response) => settle(shellOutcomeFromLateReply(response));
|
|
1509
|
+
clearTimeout(entry.timer);
|
|
1510
|
+
entry.timer = setTimeout(() => {
|
|
1511
|
+
if (this.pending.get(id) !== entry)
|
|
1512
|
+
return;
|
|
1513
|
+
this.pending.delete(id);
|
|
1514
|
+
entry.onSettled?.();
|
|
1515
|
+
this.warnVia(`Request "${entry.command}" (id=${id}) got no reply within ${graceMs}ms after its deadline`);
|
|
1516
|
+
settle({ kind: "unknown" });
|
|
1517
|
+
}, graceMs);
|
|
1518
|
+
return true;
|
|
1519
|
+
}
|
|
1354
1520
|
accountForBashTaskResponse(command, response) {
|
|
1355
1521
|
const taskId = bashTaskIdFrom(response);
|
|
1356
1522
|
if (!taskId)
|
|
1357
1523
|
return;
|
|
1358
|
-
if (isTerminalBashStatus(response.status)) {
|
|
1524
|
+
if (isTerminalBashStatus(response.status, true)) {
|
|
1359
1525
|
this.outstandingBackgroundTaskIds.delete(taskId);
|
|
1360
1526
|
return;
|
|
1361
1527
|
}
|
|
@@ -1414,6 +1580,7 @@ export class BinaryBridge {
|
|
|
1414
1580
|
}
|
|
1415
1581
|
}
|
|
1416
1582
|
handleCrash(cause) {
|
|
1583
|
+
this.clearRestartTimer();
|
|
1417
1584
|
const proc = this.process;
|
|
1418
1585
|
this.process = null;
|
|
1419
1586
|
this.spawnedBinaryFingerprint = null;
|
|
@@ -1445,7 +1612,8 @@ export class BinaryBridge {
|
|
|
1445
1612
|
const delay = 100 * 2 ** this._restartCount; // 100ms, 200ms, 400ms
|
|
1446
1613
|
this._restartCount++;
|
|
1447
1614
|
this.logVia(`Auto-restart #${this._restartCount} in ${delay}ms`);
|
|
1448
|
-
setTimeout(() => {
|
|
1615
|
+
this.restartTimer = setTimeout(() => {
|
|
1616
|
+
this.restartTimer = null;
|
|
1449
1617
|
if (!this._shuttingDown && !this.isAlive()) {
|
|
1450
1618
|
try {
|
|
1451
1619
|
this.spawnProcess();
|
|
@@ -1455,6 +1623,9 @@ export class BinaryBridge {
|
|
|
1455
1623
|
}
|
|
1456
1624
|
}
|
|
1457
1625
|
}, delay);
|
|
1626
|
+
// The crash already rejected every pending request, so nothing waits on
|
|
1627
|
+
// this restart; it must not keep a host that is otherwise done alive.
|
|
1628
|
+
this.restartTimer.unref?.();
|
|
1458
1629
|
// Also decay the counter over time so repeated crashes without any
|
|
1459
1630
|
// successful response don't permanently wedge the bridge.
|
|
1460
1631
|
this.scheduleRestartCountReset();
|
|
@@ -1465,6 +1636,7 @@ export class BinaryBridge {
|
|
|
1465
1636
|
}
|
|
1466
1637
|
}
|
|
1467
1638
|
rejectAllPending(error) {
|
|
1639
|
+
this.configureWarningClients.clear();
|
|
1468
1640
|
for (const [_id, entry] of this.pending) {
|
|
1469
1641
|
clearTimeout(entry.timer);
|
|
1470
1642
|
entry.onSettled?.();
|
|
@@ -1473,11 +1645,20 @@ export class BinaryBridge {
|
|
|
1473
1645
|
this.pending.clear();
|
|
1474
1646
|
}
|
|
1475
1647
|
scheduleRestartCountReset() {
|
|
1648
|
+
if (this._restartCount === 0)
|
|
1649
|
+
return;
|
|
1476
1650
|
this.clearRestartResetTimer();
|
|
1477
1651
|
this.restartResetTimer = setTimeout(() => {
|
|
1478
1652
|
this._restartCount = 0;
|
|
1479
1653
|
this.restartResetTimer = null;
|
|
1480
1654
|
}, BinaryBridge.RESTART_RESET_MS);
|
|
1655
|
+
// Bookkeeping only: a five-minute counter reset must not hold the host open.
|
|
1656
|
+
this.restartResetTimer.unref?.();
|
|
1657
|
+
}
|
|
1658
|
+
clearRestartTimer() {
|
|
1659
|
+
if (this.restartTimer)
|
|
1660
|
+
clearTimeout(this.restartTimer);
|
|
1661
|
+
this.restartTimer = null;
|
|
1481
1662
|
}
|
|
1482
1663
|
clearRestartResetTimer() {
|
|
1483
1664
|
if (this.restartResetTimer) {
|