@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/subc-transport.js
CHANGED
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
* published plugin dist; it is never a published runtime dependency.
|
|
18
18
|
*/
|
|
19
19
|
import { statSync } from "node:fs";
|
|
20
|
-
import { AuthError, connectionFileExists, isConsumerReconnectTransient, StaleRouteHandleError, SubcCallError, SubcClient, SubcError, } from "@cortexkit/subc-client";
|
|
20
|
+
import { AuthError, connectionFileExists, isConsumerReconnectTransient, routeCloseReason, SocketTimeoutError, StaleRouteHandleError, SubcCallError, SubcClient, SubcError, } from "@cortexkit/subc-client";
|
|
21
21
|
import { log, warn } from "./active-logger.js";
|
|
22
|
+
import { isTerminalStatus } from "./bash-format.js";
|
|
22
23
|
import { isRouteOpenReloadWindowError, isRouteRequestReloadRefusal } from "./error-contract.js";
|
|
23
24
|
import { asCanonicalRootPath, asRootGeneration, LifecycleRegistry, } from "./lifecycle-registry.js";
|
|
24
25
|
import { canonicalizeProjectRoot } from "./project-identity.js";
|
|
25
|
-
import { callPresetFor, PRESET_FIELD, WORKER_SESSION_FIELD } from "./transport.js";
|
|
26
|
+
import { callPresetFor, HOST_TOOL_NAMES_FIELD, PRESET_FIELD, WORKER_SESSION_FIELD, } from "./transport.js";
|
|
26
27
|
/** The subc pool is closing and cannot carry another request. */
|
|
27
28
|
export class SubcTransportShuttingDownError extends SubcCallError {
|
|
28
29
|
constructor() {
|
|
@@ -49,6 +50,61 @@ export class SubcRouteClosedMidCallError extends SubcCallError {
|
|
|
49
50
|
this.reason = reason;
|
|
50
51
|
}
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* A data-plane request was queued and sent, but reading the daemon's response
|
|
55
|
+
* frame timed out waiting for body bytes after the header was received.
|
|
56
|
+
*
|
|
57
|
+
* subc-client raises SocketTimeoutError from readExact when the body does not
|
|
58
|
+
* arrive within BODY_READ_TIMEOUT_MS after the header. Because the request was
|
|
59
|
+
* already sent to the daemon, it may have executed: the outcome is unknown.
|
|
60
|
+
* The call must not be blind-retried, and bash must not re-run it on the host.
|
|
61
|
+
*/
|
|
62
|
+
export class SubcFrameReadTimeoutError extends SubcCallError {
|
|
63
|
+
constructor(cause) {
|
|
64
|
+
super("outcome_unknown", "the command may have run; timed out waiting for frame body bytes from the AFT daemon", "frame_read_timeout", cause);
|
|
65
|
+
this.name = "SubcFrameReadTimeoutError";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Matches the frame-body read timeout raised by subc-client (socket.ts:185/190):
|
|
70
|
+
* "timed out waiting for <N> bytes".
|
|
71
|
+
*
|
|
72
|
+
* Excludes connect timeouts ("timed out connecting to ...") and write timeouts
|
|
73
|
+
* ("timed out writing to subc"): connect timeouts are provably pre-send, while
|
|
74
|
+
* write timeouts keep their existing transport classification.
|
|
75
|
+
*/
|
|
76
|
+
function isFrameBodyReadTimeoutMessage(message) {
|
|
77
|
+
return (typeof message === "string" &&
|
|
78
|
+
(/timed out waiting for \d+ bytes/i.test(message) ||
|
|
79
|
+
message.includes("timed out waiting for frame body bytes")));
|
|
80
|
+
}
|
|
81
|
+
export function isSubcFrameReadTimeoutError(error) {
|
|
82
|
+
if (error instanceof SubcFrameReadTimeoutError)
|
|
83
|
+
return true;
|
|
84
|
+
if (error instanceof SocketTimeoutError && isFrameBodyReadTimeoutMessage(error.message)) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (error instanceof SubcError && isFrameBodyReadTimeoutMessage(error.message)) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
if (error instanceof SubcCallError) {
|
|
91
|
+
if (error.code === "frame_read_timeout")
|
|
92
|
+
return true;
|
|
93
|
+
if (error.cause !== undefined &&
|
|
94
|
+
error.cause !== error &&
|
|
95
|
+
isSubcFrameReadTimeoutError(error.cause)) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (isFrameBodyReadTimeoutMessage(error.message))
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
export function frameReadTimeoutError(error) {
|
|
104
|
+
if (!isSubcFrameReadTimeoutError(error))
|
|
105
|
+
return error;
|
|
106
|
+
return error instanceof SubcFrameReadTimeoutError ? error : new SubcFrameReadTimeoutError(error);
|
|
107
|
+
}
|
|
52
108
|
/**
|
|
53
109
|
* subc-client's rejection of a request that was pending when the client itself
|
|
54
110
|
* closed the route: `closeRoute` tearing that route down, or the client closing
|
|
@@ -101,6 +157,8 @@ export function isSubcClientClosedError(error) {
|
|
|
101
157
|
* the connection has already been torn down locally.
|
|
102
158
|
*/
|
|
103
159
|
export function isSocketDeathError(error, depth = 0) {
|
|
160
|
+
if (isSubcFrameReadTimeoutError(error))
|
|
161
|
+
return true;
|
|
104
162
|
// Raw data-plane requests wrap socket death in a SubcError; its typed
|
|
105
163
|
// connection-loss reason survives even when the underlying errno does not.
|
|
106
164
|
if (error instanceof SubcError && error.closeReason === "connection_lost")
|
|
@@ -164,6 +222,14 @@ export class SubcLivenessProbeTimeoutError extends Error {
|
|
|
164
222
|
}
|
|
165
223
|
/** The subc module id AFT registers under (matches the daemon manifest). */
|
|
166
224
|
const AFT_MODULE_ID = "aft";
|
|
225
|
+
const HOST_SCOPE_LOOKUP_TIMEOUT_MS = 2_000;
|
|
226
|
+
const HOST_SCOPE_RETRY_FLOOR_MS = 5_000;
|
|
227
|
+
const HOST_SCOPE_RETRY_CAP_MS = 300_000;
|
|
228
|
+
function isHostScopeSyncRefusal(code) {
|
|
229
|
+
// crates/aft/src/gh_shim_relay/mod.rs lists scope_not_synced and scope_changed as retryable.
|
|
230
|
+
// A live scope freshly reported by core can also be scope_not_live until the daemon applies it.
|
|
231
|
+
return code === "scope_not_live" || code === "scope_not_synced" || code === "scope_changed";
|
|
232
|
+
}
|
|
167
233
|
/**
|
|
168
234
|
* A run of consecutive per-call failures that got no answer (timeout / route
|
|
169
235
|
* GOODBYE / refusal) on the SAME client raises the suspicion of a dead half-open
|
|
@@ -305,7 +371,7 @@ class BgSubscription {
|
|
|
305
371
|
identity;
|
|
306
372
|
acquireClient;
|
|
307
373
|
dropClient;
|
|
308
|
-
|
|
374
|
+
openRoute;
|
|
309
375
|
onNudge;
|
|
310
376
|
sleep;
|
|
311
377
|
canAttach;
|
|
@@ -316,23 +382,25 @@ class BgSubscription {
|
|
|
316
382
|
isCurrent;
|
|
317
383
|
noteClientAlive;
|
|
318
384
|
noteUnansweredCall;
|
|
385
|
+
onRouteAttached;
|
|
319
386
|
stopped = false;
|
|
320
387
|
/** The live subscription handle, read by stop() to wake the loop's `await closed`. */
|
|
321
388
|
current = null;
|
|
322
389
|
/** Client carrying the live subscription route, counted when that client is dropped. */
|
|
323
390
|
attached = null;
|
|
391
|
+
route = null;
|
|
324
392
|
loop;
|
|
325
393
|
lifecycleLogState = new Map();
|
|
326
394
|
nudgeReceiptLogState = null;
|
|
327
|
-
constructor(identity, acquireClient, dropClient,
|
|
395
|
+
constructor(identity, acquireClient, dropClient, openRoute, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, dispatchProbeIntervalMs, nudgeRef, isCurrent = () => true,
|
|
328
396
|
/** Reports a reply seen on the client, which proves its socket still delivers. */
|
|
329
397
|
noteClientAlive = () => undefined,
|
|
330
398
|
/** Reports a call on the client that got no answer; enough in a row start a liveness probe. */
|
|
331
|
-
noteUnansweredCall = () => undefined) {
|
|
399
|
+
noteUnansweredCall = () => undefined, onRouteAttached = () => undefined) {
|
|
332
400
|
this.identity = identity;
|
|
333
401
|
this.acquireClient = acquireClient;
|
|
334
402
|
this.dropClient = dropClient;
|
|
335
|
-
this.
|
|
403
|
+
this.openRoute = openRoute;
|
|
336
404
|
this.onNudge = onNudge;
|
|
337
405
|
this.sleep = sleep;
|
|
338
406
|
this.canAttach = canAttach;
|
|
@@ -343,14 +411,20 @@ class BgSubscription {
|
|
|
343
411
|
this.isCurrent = isCurrent;
|
|
344
412
|
this.noteClientAlive = noteClientAlive;
|
|
345
413
|
this.noteUnansweredCall = noteUnansweredCall;
|
|
414
|
+
this.onRouteAttached = onRouteAttached;
|
|
346
415
|
this.loop = this.run();
|
|
347
416
|
}
|
|
348
417
|
/** The client whose route currently carries this subscription, if any. */
|
|
349
418
|
get attachedClient() {
|
|
350
419
|
return this.attached;
|
|
351
420
|
}
|
|
421
|
+
get attachedRoute() {
|
|
422
|
+
return this.route;
|
|
423
|
+
}
|
|
424
|
+
cancelBackoff = null;
|
|
352
425
|
async stop() {
|
|
353
426
|
this.stopped = true;
|
|
427
|
+
this.cancelBackoff?.();
|
|
354
428
|
// Wake a live `await sub.closed`; the loop's `finally` remains the sole
|
|
355
429
|
// owner of closeRouteChannel, avoiding a double close from stop() + run().
|
|
356
430
|
const sub = this.current;
|
|
@@ -477,7 +551,7 @@ class BgSubscription {
|
|
|
477
551
|
try {
|
|
478
552
|
// A dedicated route keeps bg_events independent of the tool route's
|
|
479
553
|
// credit window while preserving the client's connection-bound handle.
|
|
480
|
-
route = await
|
|
554
|
+
route = await this.openRoute(client);
|
|
481
555
|
}
|
|
482
556
|
catch (err) {
|
|
483
557
|
if (this.isCurrent() && this.onRootAttachFailure(err)) {
|
|
@@ -536,6 +610,8 @@ class BgSubscription {
|
|
|
536
610
|
});
|
|
537
611
|
this.current = sub;
|
|
538
612
|
this.attached = client;
|
|
613
|
+
this.route = route;
|
|
614
|
+
this.onRouteAttached();
|
|
539
615
|
stopDispatchProbe = this.startDispatchProbe(client, routeId);
|
|
540
616
|
this.info("subscription-open", `subscription open channel=${routeId}`);
|
|
541
617
|
if (reconnecting) {
|
|
@@ -586,6 +662,7 @@ class BgSubscription {
|
|
|
586
662
|
stopDispatchProbe();
|
|
587
663
|
this.current = null;
|
|
588
664
|
this.attached = null;
|
|
665
|
+
this.route = null;
|
|
589
666
|
safeCloseRoute(client, route);
|
|
590
667
|
}
|
|
591
668
|
await this.backoff(backoffAttempt++);
|
|
@@ -594,14 +671,47 @@ class BgSubscription {
|
|
|
594
671
|
giveUp("stopped");
|
|
595
672
|
}
|
|
596
673
|
async backoff(attempt) {
|
|
597
|
-
|
|
674
|
+
if (this.stopped)
|
|
675
|
+
return;
|
|
676
|
+
await new Promise((resolve, reject) => {
|
|
677
|
+
let timer;
|
|
678
|
+
let finished = false;
|
|
679
|
+
const settle = (error) => {
|
|
680
|
+
if (finished)
|
|
681
|
+
return;
|
|
682
|
+
finished = true;
|
|
683
|
+
if (timer)
|
|
684
|
+
clearTimeout(timer);
|
|
685
|
+
this.cancelBackoff = null;
|
|
686
|
+
if (error !== undefined)
|
|
687
|
+
reject(error);
|
|
688
|
+
else
|
|
689
|
+
resolve();
|
|
690
|
+
};
|
|
691
|
+
this.cancelBackoff = () => settle();
|
|
692
|
+
const delay = reconnectBackoffMs(attempt);
|
|
693
|
+
if (this.sleep) {
|
|
694
|
+
// An injected sleeper may not be cancellable. Observe its eventual
|
|
695
|
+
// rejection, but let stop release the subscription without awaiting it.
|
|
696
|
+
try {
|
|
697
|
+
void this.sleep(delay).then(() => settle(), settle);
|
|
698
|
+
}
|
|
699
|
+
catch (error) {
|
|
700
|
+
settle(error);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
timer = setTimeout(() => settle(), delay);
|
|
705
|
+
}
|
|
706
|
+
});
|
|
598
707
|
}
|
|
599
708
|
}
|
|
600
709
|
function isRecord(value) {
|
|
601
710
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
602
711
|
}
|
|
603
712
|
function isRouteProvenAbsentError(err) {
|
|
604
|
-
return ((err instanceof SubcError &&
|
|
713
|
+
return ((err instanceof SubcError &&
|
|
714
|
+
(err.code === "unknown_channel" || err.code === "stale_route_epoch")) ||
|
|
605
715
|
err instanceof StaleRouteHandleError);
|
|
606
716
|
}
|
|
607
717
|
/**
|
|
@@ -636,17 +746,25 @@ function callDeadlinePassedError(lastError, callDeadlineMs) {
|
|
|
636
746
|
return withDeadlineSuffix(lastError, suffix);
|
|
637
747
|
return new SubcError(`route to module '${AFT_MODULE_ID}' was not ready before the call ran out of time.${suffix}`, REQUEST_DEADLINE_CODE);
|
|
638
748
|
}
|
|
749
|
+
export const __withDeadlineSuffixForTests = withDeadlineSuffix;
|
|
639
750
|
function withDeadlineSuffix(error, suffix) {
|
|
640
|
-
if (error instanceof Error)
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
}
|
|
649
|
-
|
|
751
|
+
if (!(error instanceof Error))
|
|
752
|
+
return new Error(`${String(error)}${suffix}`, { cause: error });
|
|
753
|
+
const message = `${error.message}${suffix}`;
|
|
754
|
+
if (error instanceof SubcCallError) {
|
|
755
|
+
const wrapped = new SubcCallError(error.kind, message, error.code, error);
|
|
756
|
+
// SubcCallError's detail getter only unwraps a direct SubcError cause.
|
|
757
|
+
Object.defineProperty(wrapped, "detail", { value: error.detail });
|
|
758
|
+
return wrapped;
|
|
759
|
+
}
|
|
760
|
+
if (error instanceof SubcError) {
|
|
761
|
+
const wrapped = new SubcError(message, error.code, error.detail, error.closeReason);
|
|
762
|
+
Object.defineProperty(wrapped, "cause", { value: error });
|
|
763
|
+
return wrapped;
|
|
764
|
+
}
|
|
765
|
+
const wrapped = new Error(message, { cause: error });
|
|
766
|
+
wrapped.name = error.name;
|
|
767
|
+
return wrapped;
|
|
650
768
|
}
|
|
651
769
|
/**
|
|
652
770
|
* Fire-and-forget route close that can never throw — neither synchronously (a
|
|
@@ -799,6 +917,8 @@ class SubcTransport {
|
|
|
799
917
|
body.preview = true;
|
|
800
918
|
if (options?.workerSession === true)
|
|
801
919
|
body[WORKER_SESSION_FIELD] = true;
|
|
920
|
+
if (options?.hostToolNames)
|
|
921
|
+
body[HOST_TOOL_NAMES_FIELD] = options.hostToolNames;
|
|
802
922
|
body[PRESET_FIELD] = callPresetFor(options?.workerSession === true);
|
|
803
923
|
const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress, this.generation, abortSignal);
|
|
804
924
|
return reliftReply(reply);
|
|
@@ -823,10 +943,14 @@ class SubcTransport {
|
|
|
823
943
|
// The caller's role travels beside the call, like the session (which the
|
|
824
944
|
// route bind carries), not inside the command's arguments: the module
|
|
825
945
|
// reads it from the call body.
|
|
826
|
-
|
|
946
|
+
// The caller's tool names travel the same way: RouteBind has no field for
|
|
947
|
+
// them, and one route's host must not decide another route's wording.
|
|
948
|
+
const { [WORKER_SESSION_FIELD]: workerSession, [HOST_TOOL_NAMES_FIELD]: hostToolNames, ...args } = params;
|
|
827
949
|
const body = { name: command, arguments: args };
|
|
828
950
|
if (workerSession === true)
|
|
829
951
|
body[WORKER_SESSION_FIELD] = true;
|
|
952
|
+
if (hostToolNames !== undefined)
|
|
953
|
+
body[HOST_TOOL_NAMES_FIELD] = hostToolNames;
|
|
830
954
|
body[PRESET_FIELD] = callPresetFor(workerSession === true);
|
|
831
955
|
const editSlotSurvives = this.pool.getEditSlotSurvives();
|
|
832
956
|
if (editSlotSurvives !== undefined)
|
|
@@ -857,6 +981,11 @@ export class SubcTransportPool {
|
|
|
857
981
|
connectionFile;
|
|
858
982
|
handshakeTimeoutMs;
|
|
859
983
|
consumerIdentity;
|
|
984
|
+
hostScopes;
|
|
985
|
+
/** Scope identity is host-session-wide, even when the session uses multiple project roots. */
|
|
986
|
+
hostScopeCache = new Map();
|
|
987
|
+
hostScopeRefusals = new Set();
|
|
988
|
+
hostScopeOutcomes = new Map();
|
|
860
989
|
connectFn;
|
|
861
990
|
onBgEventsNudge;
|
|
862
991
|
onBgEventsNudgeRef;
|
|
@@ -883,6 +1012,8 @@ export class SubcTransportPool {
|
|
|
883
1012
|
routeReopenRetryMs = null;
|
|
884
1013
|
/** Per-session records keyed by the opaque identity key. */
|
|
885
1014
|
sessions = new Map();
|
|
1015
|
+
/** Keep global insertion order so a session using several roots selects the same first live route. */
|
|
1016
|
+
sessionIndex = new Map();
|
|
886
1017
|
// Pending host creation events are bounded. Forgetting one only makes that
|
|
887
1018
|
// session unknown (retained), never falsely eligible for eviction.
|
|
888
1019
|
observedStarts = new Set();
|
|
@@ -916,11 +1047,11 @@ export class SubcTransportPool {
|
|
|
916
1047
|
this.harness = options.harness;
|
|
917
1048
|
this.handshakeTimeoutMs = options.handshakeTimeoutMs;
|
|
918
1049
|
this.consumerIdentity = options.consumerIdentity;
|
|
1050
|
+
this.hostScopes = options.hostScopes ?? false;
|
|
919
1051
|
this.connectFn = options.connect ?? ((opts) => SubcClient.connect(opts));
|
|
920
1052
|
this.onBgEventsNudge = options.onBgEventsNudge;
|
|
921
1053
|
this.onBgEventsNudgeRef = options.onBgEventsNudgeRef;
|
|
922
|
-
this.bgBackoffSleep =
|
|
923
|
-
options.bgBackoffSleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1054
|
+
this.bgBackoffSleep = options.bgBackoffSleep;
|
|
924
1055
|
this.routeRetrySleep =
|
|
925
1056
|
options.routeRetrySleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
926
1057
|
this.bgDispatchProbeIntervalMs =
|
|
@@ -1174,7 +1305,9 @@ export class SubcTransportPool {
|
|
|
1174
1305
|
* always has a registered root and captured generation.
|
|
1175
1306
|
*/
|
|
1176
1307
|
getBridge(projectRoot) {
|
|
1177
|
-
|
|
1308
|
+
return this.getBridgeForCanonicalRoot(this.canonicalRoot(projectRoot));
|
|
1309
|
+
}
|
|
1310
|
+
getBridgeForCanonicalRoot(root) {
|
|
1178
1311
|
if (this.shuttingDown && this.lifecycleEnabled()) {
|
|
1179
1312
|
throw new SubcTransportShuttingDownError();
|
|
1180
1313
|
}
|
|
@@ -1279,6 +1412,12 @@ export class SubcTransportPool {
|
|
|
1279
1412
|
pendingDetach: false,
|
|
1280
1413
|
};
|
|
1281
1414
|
this.sessions.set(key, record);
|
|
1415
|
+
let records = this.sessionIndex.get(identity.session);
|
|
1416
|
+
if (!records) {
|
|
1417
|
+
records = new Set();
|
|
1418
|
+
this.sessionIndex.set(identity.session, records);
|
|
1419
|
+
}
|
|
1420
|
+
records.add(record);
|
|
1282
1421
|
let keys = this.rootIndex.get(root);
|
|
1283
1422
|
if (!keys) {
|
|
1284
1423
|
keys = new Set();
|
|
@@ -1298,8 +1437,12 @@ export class SubcTransportPool {
|
|
|
1298
1437
|
* always nudges a drain, replaying completions queued while no carrier existed.
|
|
1299
1438
|
*/
|
|
1300
1439
|
async reapIdleSessions(now = Date.now()) {
|
|
1301
|
-
for (const [key,
|
|
1302
|
-
|
|
1440
|
+
for (const [key, bucket] of this.nudgeDeliveryLogState) {
|
|
1441
|
+
for (const [kind, state] of bucket) {
|
|
1442
|
+
if (now - state.lastEmittedAt >= 15 * 60_000)
|
|
1443
|
+
bucket.delete(kind);
|
|
1444
|
+
}
|
|
1445
|
+
if (bucket.size === 0)
|
|
1303
1446
|
this.nudgeDeliveryLogState.delete(key);
|
|
1304
1447
|
}
|
|
1305
1448
|
const detached = [];
|
|
@@ -1387,7 +1530,7 @@ export class SubcTransportPool {
|
|
|
1387
1530
|
}
|
|
1388
1531
|
if (typeof data.task_id === "string") {
|
|
1389
1532
|
// Terminal tasks remain pinned until the daemon explicitly ACKs them.
|
|
1390
|
-
const terminal =
|
|
1533
|
+
const terminal = isTerminalStatus(data.status);
|
|
1391
1534
|
const running = ["starting", "running", "killing"].includes(String(data.status));
|
|
1392
1535
|
record.backgroundTasks.set(data.task_id, terminal ? "terminal" : running ? "running" : "unknown");
|
|
1393
1536
|
if (name === "bash" || name === "powershell")
|
|
@@ -1472,19 +1615,27 @@ export class SubcTransportPool {
|
|
|
1472
1615
|
};
|
|
1473
1616
|
}
|
|
1474
1617
|
logNudgeDelivery(kind, record, message) {
|
|
1475
|
-
|
|
1618
|
+
let bucket = this.nudgeDeliveryLogState.get(record.identityKey);
|
|
1619
|
+
if (!bucket) {
|
|
1620
|
+
bucket = new Map();
|
|
1621
|
+
this.nudgeDeliveryLogState.set(record.identityKey, bucket);
|
|
1622
|
+
}
|
|
1476
1623
|
const now = Date.now();
|
|
1477
|
-
const state =
|
|
1624
|
+
const state = bucket.get(kind);
|
|
1478
1625
|
if (state && now - state.lastEmittedAt < BG_LIFECYCLE_LOG_INTERVAL_MS) {
|
|
1479
1626
|
state.suppressed += 1;
|
|
1480
1627
|
return;
|
|
1481
1628
|
}
|
|
1482
1629
|
const suppressed = state?.suppressed ?? 0;
|
|
1483
|
-
|
|
1630
|
+
bucket.set(kind, { lastEmittedAt: now, suppressed: 0 });
|
|
1484
1631
|
const suffix = suppressed > 0 ? ` suppressed=${suppressed}` : "";
|
|
1485
1632
|
log(`subc bg_events: ${message}${suffix}`, { sessionId: record.identity.session });
|
|
1486
1633
|
}
|
|
1487
1634
|
removeIndexMembership(record) {
|
|
1635
|
+
const records = this.sessionIndex.get(record.identity.session);
|
|
1636
|
+
records?.delete(record);
|
|
1637
|
+
if (records?.size === 0)
|
|
1638
|
+
this.sessionIndex.delete(record.identity.session);
|
|
1488
1639
|
const keys = this.rootIndex.get(record.canonicalRoot);
|
|
1489
1640
|
if (!keys)
|
|
1490
1641
|
return;
|
|
@@ -1507,6 +1658,7 @@ export class SubcTransportPool {
|
|
|
1507
1658
|
this.sessions.delete(record.identityKey);
|
|
1508
1659
|
this.removeIndexMembership(record);
|
|
1509
1660
|
}
|
|
1661
|
+
this.stopUnusedHostScopeRetry(record.identity.session);
|
|
1510
1662
|
}
|
|
1511
1663
|
/**
|
|
1512
1664
|
* Atomically detaches one opaque identity. Nothing in this method awaits: it
|
|
@@ -1520,16 +1672,14 @@ export class SubcTransportPool {
|
|
|
1520
1672
|
record.teardownReason = reason;
|
|
1521
1673
|
this.sessions.delete(key);
|
|
1522
1674
|
this.removeIndexMembership(record);
|
|
1523
|
-
|
|
1524
|
-
if (logKey.endsWith(`\u0000${key}`))
|
|
1525
|
-
this.nudgeDeliveryLogState.delete(logKey);
|
|
1526
|
-
}
|
|
1675
|
+
this.nudgeDeliveryLogState.delete(key);
|
|
1527
1676
|
const bgSub = record.bgSub;
|
|
1528
1677
|
record.bgSub = null;
|
|
1529
1678
|
const routeEntry = record.routeEntry;
|
|
1530
1679
|
record.routeEntry = null;
|
|
1531
1680
|
if (routeEntry)
|
|
1532
1681
|
routeEntry.closed = true;
|
|
1682
|
+
this.stopUnusedHostScopeRetry(record.identity.session);
|
|
1533
1683
|
return { record, bgSub, routeEntry };
|
|
1534
1684
|
}
|
|
1535
1685
|
async cleanupDetached(detached) {
|
|
@@ -1589,6 +1739,17 @@ export class SubcTransportPool {
|
|
|
1589
1739
|
entry.inflight -= 1;
|
|
1590
1740
|
this.closeRetiredRoute(record, entry);
|
|
1591
1741
|
}
|
|
1742
|
+
retireHostToolRoute(record) {
|
|
1743
|
+
const entry = record.routeEntry;
|
|
1744
|
+
if (!entry)
|
|
1745
|
+
return;
|
|
1746
|
+
entry.closed = true;
|
|
1747
|
+
entry.closedBy ??= "route replaced";
|
|
1748
|
+
record.routeEntry = null;
|
|
1749
|
+
record.retiredRoutes.add(entry);
|
|
1750
|
+
this.closeRetiredRoute(record, entry);
|
|
1751
|
+
this.stopUnusedHostScopeRetry(record.identity.session);
|
|
1752
|
+
}
|
|
1592
1753
|
/** Open or reuse a route while guarding every lifecycle boundary. */
|
|
1593
1754
|
async routeRequest(identity, body, timeoutMs, onProgress, expectedGeneration, abortSignal) {
|
|
1594
1755
|
const callStartedAt = performance.now();
|
|
@@ -1760,17 +1921,16 @@ export class SubcTransportPool {
|
|
|
1760
1921
|
const clearRouteEntry = (entry) => {
|
|
1761
1922
|
if (record.routeEntry !== entry)
|
|
1762
1923
|
return;
|
|
1763
|
-
|
|
1764
|
-
entry.closedBy ??= "route replaced";
|
|
1765
|
-
record.routeEntry = null;
|
|
1766
|
-
record.retiredRoutes.add(entry);
|
|
1767
|
-
this.closeRetiredRoute(record, entry);
|
|
1924
|
+
this.retireHostToolRoute(record);
|
|
1768
1925
|
};
|
|
1769
1926
|
const handleRequestFailure = (error, entry) => {
|
|
1770
1927
|
const aborted = error instanceof Error && error.name === "AbortError";
|
|
1771
1928
|
const ownsClient = this.isCurrentSession(key, record) &&
|
|
1772
1929
|
this.client === client &&
|
|
1773
1930
|
!this.isReapInduced(record);
|
|
1931
|
+
if (ownsClient && error instanceof SubcError && error.closeReason?.startsWith("scope_")) {
|
|
1932
|
+
this.invalidateHostScope(identity.session);
|
|
1933
|
+
}
|
|
1774
1934
|
if (!aborted && ownsClient && isSocketDeathError(error)) {
|
|
1775
1935
|
// Dropped before the route is cleared so the drop log counts it.
|
|
1776
1936
|
this.dropClient(client, {
|
|
@@ -1785,7 +1945,8 @@ export class SubcTransportPool {
|
|
|
1785
1945
|
// route remains reusable, and no replacement may cut its other calls.
|
|
1786
1946
|
if (aborted ||
|
|
1787
1947
|
isRouteProvenAbsentError(error) ||
|
|
1788
|
-
(error instanceof SubcError && error.closeReason !== undefined)
|
|
1948
|
+
(error instanceof SubcError && error.closeReason !== undefined) ||
|
|
1949
|
+
isSubcFrameReadTimeoutError(error)) {
|
|
1789
1950
|
clearRouteEntry(entry);
|
|
1790
1951
|
}
|
|
1791
1952
|
if (aborted || !ownsClient || this.client !== client)
|
|
@@ -1877,10 +2038,15 @@ export class SubcTransportPool {
|
|
|
1877
2038
|
return reply;
|
|
1878
2039
|
}
|
|
1879
2040
|
catch (requestError) {
|
|
1880
|
-
const error = routeClosedMidCallError(requestError, routeAndEntry.entry, record);
|
|
2041
|
+
const error = frameReadTimeoutError(routeClosedMidCallError(requestError, routeAndEntry.entry, record));
|
|
1881
2042
|
if (this.isReapInduced(record))
|
|
1882
2043
|
throw this.annotateReapError(error, record);
|
|
1883
2044
|
const ownsRoute = this.isCurrentSession(key, record) && this.client === client;
|
|
2045
|
+
if (ownsRoute &&
|
|
2046
|
+
((error instanceof SubcError && error.code === "stale_route_epoch") ||
|
|
2047
|
+
error instanceof StaleRouteHandleError)) {
|
|
2048
|
+
this.invalidateHostScope(identity.session);
|
|
2049
|
+
}
|
|
1884
2050
|
// A route that was bound before a module reload started gets its
|
|
1885
2051
|
// requests refused by the daemon for as long as the drain lasts. The
|
|
1886
2052
|
// refusal happens before the request is forwarded, so it is handled
|
|
@@ -1978,9 +2144,14 @@ export class SubcTransportPool {
|
|
|
1978
2144
|
return this.client;
|
|
1979
2145
|
if (this.connecting)
|
|
1980
2146
|
return this.connecting;
|
|
2147
|
+
let owner;
|
|
1981
2148
|
this.connecting = this.connectFn({
|
|
1982
2149
|
connectionFile: this.connectionFile,
|
|
1983
2150
|
handshakeTimeoutMs: this.handshakeTimeoutMs,
|
|
2151
|
+
onControlPush: (push) => {
|
|
2152
|
+
if (owner && this.client === owner)
|
|
2153
|
+
this.onScopeRouteClose(push);
|
|
2154
|
+
},
|
|
1984
2155
|
})
|
|
1985
2156
|
.then((client) => {
|
|
1986
2157
|
this.connecting = null;
|
|
@@ -1994,6 +2165,7 @@ export class SubcTransportPool {
|
|
|
1994
2165
|
throw new SubcTransportShuttingDownError();
|
|
1995
2166
|
}
|
|
1996
2167
|
this.client = client;
|
|
2168
|
+
owner = client;
|
|
1997
2169
|
this.hadWorkingClient = true;
|
|
1998
2170
|
this.transportFailures = 0;
|
|
1999
2171
|
return client;
|
|
@@ -2027,10 +2199,7 @@ export class SubcTransportPool {
|
|
|
2027
2199
|
closedBy: null,
|
|
2028
2200
|
};
|
|
2029
2201
|
this.assertRootCanAttach(record.canonicalRoot);
|
|
2030
|
-
const opening = client
|
|
2031
|
-
.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
|
|
2032
|
-
consumerIdentity: this.consumerIdentity,
|
|
2033
|
-
})
|
|
2202
|
+
const opening = this.openHostToolRoute(client, identity, record, entry)
|
|
2034
2203
|
.then((route) => {
|
|
2035
2204
|
if (!this.isCurrentSession(record.identityKey, record) ||
|
|
2036
2205
|
record.routeEntry !== entry ||
|
|
@@ -2046,6 +2215,15 @@ export class SubcTransportPool {
|
|
|
2046
2215
|
entry.handle = route;
|
|
2047
2216
|
entry.opening = null;
|
|
2048
2217
|
this.noteClientAlive(client);
|
|
2218
|
+
const state = this.hostScopeCache.get(identity.session);
|
|
2219
|
+
if (state)
|
|
2220
|
+
this.scheduleHostScopeRetry(identity.session, state);
|
|
2221
|
+
if (state?.answer?.result === "scope" &&
|
|
2222
|
+
!state.scopeBindPending &&
|
|
2223
|
+
!entry.hostScope &&
|
|
2224
|
+
!this.hostScopeRefusals.has(identity.session)) {
|
|
2225
|
+
this.promoteHostScope(identity.session);
|
|
2226
|
+
}
|
|
2049
2227
|
return route;
|
|
2050
2228
|
})
|
|
2051
2229
|
.catch((error) => {
|
|
@@ -2054,6 +2232,7 @@ export class SubcTransportPool {
|
|
|
2054
2232
|
entry.closed = true;
|
|
2055
2233
|
record.routeEntry = null;
|
|
2056
2234
|
}
|
|
2235
|
+
this.stopUnusedHostScopeRetry(identity.session);
|
|
2057
2236
|
if (!this.isCurrentSession(record.identityKey, record) &&
|
|
2058
2237
|
!(error instanceof RouteTornDownError)) {
|
|
2059
2238
|
throw new RouteTornDownError("subc route opened after session closed");
|
|
@@ -2067,6 +2246,393 @@ export class SubcTransportPool {
|
|
|
2067
2246
|
record.routeEntry = entry;
|
|
2068
2247
|
return { route: await opening, entry };
|
|
2069
2248
|
}
|
|
2249
|
+
hostScope(client, identity) {
|
|
2250
|
+
if (this.hostScopeRefusals.has(identity.session))
|
|
2251
|
+
return Promise.resolve(undefined);
|
|
2252
|
+
let state = this.hostScopeCache.get(identity.session);
|
|
2253
|
+
if (!state) {
|
|
2254
|
+
state = { retryAt: 0, retryMs: HOST_SCOPE_RETRY_FLOOR_MS };
|
|
2255
|
+
this.hostScopeCache.set(identity.session, state);
|
|
2256
|
+
}
|
|
2257
|
+
if (state.answer) {
|
|
2258
|
+
return Promise.resolve(state.answer.result === "scope" && !state.scopeBindPending ? state.answer.scope : undefined);
|
|
2259
|
+
}
|
|
2260
|
+
if (state.pending)
|
|
2261
|
+
return state.pending;
|
|
2262
|
+
// Backoff skips the lookup, not the bind, so an unavailable core cannot delay every root.
|
|
2263
|
+
if (Date.now() < state.retryAt)
|
|
2264
|
+
return Promise.resolve(undefined);
|
|
2265
|
+
const current = state;
|
|
2266
|
+
clearTimeout(current.retryTimer);
|
|
2267
|
+
current.retryTimer = undefined;
|
|
2268
|
+
const abort = new AbortController();
|
|
2269
|
+
current.lookupAbort = abort;
|
|
2270
|
+
const lookup = this.readHostScope(client, identity, abort.signal).then((outcome) => {
|
|
2271
|
+
// A scope-close push may invalidate the session while the management request is in flight.
|
|
2272
|
+
if (this.hostScopeCache.get(identity.session) !== current)
|
|
2273
|
+
return undefined;
|
|
2274
|
+
current.pending = undefined;
|
|
2275
|
+
current.lookupAbort = undefined;
|
|
2276
|
+
if (abort.signal.aborted)
|
|
2277
|
+
return undefined;
|
|
2278
|
+
this.logHostScopeOutcome(identity.session, outcome);
|
|
2279
|
+
if (outcome.result === "scope" || outcome.result === "no_scope") {
|
|
2280
|
+
current.answer = outcome;
|
|
2281
|
+
current.retryAt = 0;
|
|
2282
|
+
current.retryMs = HOST_SCOPE_RETRY_FLOOR_MS;
|
|
2283
|
+
if (outcome.result === "scope")
|
|
2284
|
+
this.promoteHostScope(identity.session);
|
|
2285
|
+
}
|
|
2286
|
+
else {
|
|
2287
|
+
current.retryAt = Date.now() + current.retryMs;
|
|
2288
|
+
current.retryMs = Math.min(current.retryMs * 2, HOST_SCOPE_RETRY_CAP_MS);
|
|
2289
|
+
this.scheduleHostScopeRetry(identity.session, current);
|
|
2290
|
+
}
|
|
2291
|
+
return outcome.result === "scope" ? outcome.scope : undefined;
|
|
2292
|
+
});
|
|
2293
|
+
current.pending = lookup;
|
|
2294
|
+
return lookup;
|
|
2295
|
+
}
|
|
2296
|
+
unscopedHostRecord(session) {
|
|
2297
|
+
for (const record of this.sessionIndex.get(session) ?? []) {
|
|
2298
|
+
const entry = record.routeEntry;
|
|
2299
|
+
if (record.identity.session === session &&
|
|
2300
|
+
!record.closed &&
|
|
2301
|
+
((entry &&
|
|
2302
|
+
!entry.closed &&
|
|
2303
|
+
!entry.hostScope &&
|
|
2304
|
+
entry.client === this.client &&
|
|
2305
|
+
(entry.handle || entry.opening)) ||
|
|
2306
|
+
(record.bgSub?.attachedClient === this.client && record.bgSub?.attachedRoute)))
|
|
2307
|
+
return record;
|
|
2308
|
+
}
|
|
2309
|
+
return undefined;
|
|
2310
|
+
}
|
|
2311
|
+
cancelHostScopeRetry(state) {
|
|
2312
|
+
clearTimeout(state.retryTimer);
|
|
2313
|
+
state.retryTimer = undefined;
|
|
2314
|
+
state.lookupAbort?.abort();
|
|
2315
|
+
}
|
|
2316
|
+
stopUnusedHostScopeRetry(session) {
|
|
2317
|
+
const state = this.hostScopeCache.get(session);
|
|
2318
|
+
if (state && !this.unscopedHostRecord(session)) {
|
|
2319
|
+
if (state.retryTimer || (state.scopeBindPending && state.bindPending))
|
|
2320
|
+
log(`subc head scope: session=${session} retry stopped reason=route_closed`);
|
|
2321
|
+
this.cancelHostScopeRetry(state);
|
|
2322
|
+
state.scopeBindPending = false;
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
scheduleHostScopeRetry(session, state) {
|
|
2326
|
+
if (this.shuttingDown ||
|
|
2327
|
+
this.hostScopeCache.get(session) !== state ||
|
|
2328
|
+
(state.answer && !state.scopeBindPending) ||
|
|
2329
|
+
state.pending ||
|
|
2330
|
+
state.bindPending ||
|
|
2331
|
+
state.retryTimer ||
|
|
2332
|
+
this.hostScopeRefusals.has(session) ||
|
|
2333
|
+
!this.unscopedHostRecord(session))
|
|
2334
|
+
return;
|
|
2335
|
+
// A head can keep its tool route for its whole life, so recovery cannot rely on another bind.
|
|
2336
|
+
state.retryTimer = setTimeout(() => {
|
|
2337
|
+
state.retryTimer = undefined;
|
|
2338
|
+
const record = this.unscopedHostRecord(session);
|
|
2339
|
+
const client = record?.routeEntry?.client ?? record?.bgSub?.attachedClient;
|
|
2340
|
+
if (this.shuttingDown || this.hostScopeCache.get(session) !== state || !record || !client)
|
|
2341
|
+
return;
|
|
2342
|
+
let retry;
|
|
2343
|
+
if (state.scopeBindPending && state.answer?.result === "scope") {
|
|
2344
|
+
state.bindPending = this.retryHostScopeBind(client, record.identity, state, state.answer.scope).finally(() => {
|
|
2345
|
+
state.bindPending = undefined;
|
|
2346
|
+
this.scheduleHostScopeRetry(session, state);
|
|
2347
|
+
});
|
|
2348
|
+
retry = state.bindPending;
|
|
2349
|
+
}
|
|
2350
|
+
else {
|
|
2351
|
+
retry = this.hostScope(client, record.identity);
|
|
2352
|
+
}
|
|
2353
|
+
void retry.catch((error) => {
|
|
2354
|
+
warn(`subc head scope: background retry failed code=${error instanceof Error ? error.name : "Error"}`);
|
|
2355
|
+
});
|
|
2356
|
+
}, Math.max(0, state.retryAt - Date.now()));
|
|
2357
|
+
state.retryTimer.unref();
|
|
2358
|
+
}
|
|
2359
|
+
deferHostScopeBind(session, state, code) {
|
|
2360
|
+
state.scopeBindPending = true;
|
|
2361
|
+
if (Date.now() >= state.retryAt) {
|
|
2362
|
+
state.retryAt = Date.now() + state.retryMs;
|
|
2363
|
+
state.retryMs = Math.min(state.retryMs * 2, HOST_SCOPE_RETRY_CAP_MS);
|
|
2364
|
+
}
|
|
2365
|
+
warn(`subc head scope: session=${session} scoped bind refused code=${code}; binding unscoped; retry in=${Math.max(0, state.retryAt - Date.now())}ms`);
|
|
2366
|
+
this.scheduleHostScopeRetry(session, state);
|
|
2367
|
+
}
|
|
2368
|
+
refuseHostScopeBind(session, state, code) {
|
|
2369
|
+
if (!this.hostScopeRefusals.has(session)) {
|
|
2370
|
+
this.hostScopeRefusals.add(session);
|
|
2371
|
+
warn(`subc head scope: session=${session} scoped bind refused code=${code}; binding unscoped; retry stopped`);
|
|
2372
|
+
}
|
|
2373
|
+
if (state) {
|
|
2374
|
+
state.scopeBindPending = false;
|
|
2375
|
+
this.cancelHostScopeRetry(state);
|
|
2376
|
+
}
|
|
2377
|
+
// Remember the downgrade without misreporting it as core's no_scope answer.
|
|
2378
|
+
}
|
|
2379
|
+
async retryHostScopeBind(client, identity, state, scope) {
|
|
2380
|
+
try {
|
|
2381
|
+
// Validate daemon acceptance before retiring a working fallback route or its held requests.
|
|
2382
|
+
const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, { consumerIdentity: this.consumerIdentity, scope });
|
|
2383
|
+
safeCloseRoute(client, route);
|
|
2384
|
+
if (this.hostScopeCache.get(identity.session) !== state ||
|
|
2385
|
+
!state.scopeBindPending ||
|
|
2386
|
+
this.shuttingDown ||
|
|
2387
|
+
client !== this.client ||
|
|
2388
|
+
!this.unscopedHostRecord(identity.session))
|
|
2389
|
+
return;
|
|
2390
|
+
state.scopeBindPending = false;
|
|
2391
|
+
state.retryAt = 0;
|
|
2392
|
+
state.retryMs = HOST_SCOPE_RETRY_FLOOR_MS;
|
|
2393
|
+
log(`subc head scope: session=${identity.session} scoped bind recovered ref=${scope.ref.slice(0, 8)} epoch=${scope.scopeEpoch}`);
|
|
2394
|
+
this.promoteHostScope(identity.session);
|
|
2395
|
+
}
|
|
2396
|
+
catch (error) {
|
|
2397
|
+
if (this.hostScopeCache.get(identity.session) !== state ||
|
|
2398
|
+
!state.scopeBindPending ||
|
|
2399
|
+
this.shuttingDown ||
|
|
2400
|
+
!this.unscopedHostRecord(identity.session))
|
|
2401
|
+
return;
|
|
2402
|
+
if (error instanceof SubcError &&
|
|
2403
|
+
error.code &&
|
|
2404
|
+
!isSocketDeathError(error) &&
|
|
2405
|
+
!isRouteOpenReloadWindowError(error) &&
|
|
2406
|
+
!isHostScopeSyncRefusal(error.code)) {
|
|
2407
|
+
this.refuseHostScopeBind(identity.session, state, error.code);
|
|
2408
|
+
}
|
|
2409
|
+
else {
|
|
2410
|
+
this.deferHostScopeBind(identity.session, state, error instanceof SubcError ? (error.code ?? error.name) : "Error");
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
promoteHostScope(session) {
|
|
2415
|
+
for (const record of this.sessions.values()) {
|
|
2416
|
+
const entry = record.routeEntry;
|
|
2417
|
+
if (record.identity.session !== session)
|
|
2418
|
+
continue;
|
|
2419
|
+
// Retirement stops new dispatches; the existing request counter defers closure of held waits.
|
|
2420
|
+
if (entry?.handle && !entry.hostScope)
|
|
2421
|
+
this.retireHostToolRoute(record);
|
|
2422
|
+
const sub = record.bgSub;
|
|
2423
|
+
if (!sub?.attachedRoute)
|
|
2424
|
+
continue;
|
|
2425
|
+
// The old subscription must finish closing before its normal attach path opens a scoped one.
|
|
2426
|
+
void sub.stop().then(() => {
|
|
2427
|
+
if (record.bgSub !== sub)
|
|
2428
|
+
return;
|
|
2429
|
+
record.bgSub = null;
|
|
2430
|
+
this.ensureBgSubscription(record.identity, record);
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
logHostScopeOutcome(session, outcome) {
|
|
2435
|
+
let key;
|
|
2436
|
+
let detail;
|
|
2437
|
+
switch (outcome.result) {
|
|
2438
|
+
case "scope":
|
|
2439
|
+
key = JSON.stringify([outcome.result, outcome.scope.ref, outcome.scope.scopeEpoch]);
|
|
2440
|
+
detail = `scope ref=${outcome.scope.ref.slice(0, 8)} epoch=${outcome.scope.scopeEpoch}`;
|
|
2441
|
+
break;
|
|
2442
|
+
case "error":
|
|
2443
|
+
key = JSON.stringify([outcome.result, outcome.code]);
|
|
2444
|
+
detail = `error code=${outcome.code} after=${outcome.afterMs}`;
|
|
2445
|
+
break;
|
|
2446
|
+
case "timeout":
|
|
2447
|
+
key = outcome.result;
|
|
2448
|
+
detail = `timeout after=${outcome.afterMs}`;
|
|
2449
|
+
break;
|
|
2450
|
+
default:
|
|
2451
|
+
key = outcome.result;
|
|
2452
|
+
detail = outcome.result;
|
|
2453
|
+
}
|
|
2454
|
+
// Elapsed time varies on every retry; only a changed answer or error code merits another line.
|
|
2455
|
+
if (this.hostScopeOutcomes.get(session) === key)
|
|
2456
|
+
return;
|
|
2457
|
+
this.hostScopeOutcomes.set(session, key);
|
|
2458
|
+
log(`subc head scope: session=${session} result=${detail}`);
|
|
2459
|
+
}
|
|
2460
|
+
async readHostScope(client, identity, signal) {
|
|
2461
|
+
const startedAt = Date.now();
|
|
2462
|
+
const timeout = () => ({ result: "timeout", afterMs: Date.now() - startedAt });
|
|
2463
|
+
let expired = false;
|
|
2464
|
+
let timer;
|
|
2465
|
+
const abort = new AbortController();
|
|
2466
|
+
let cancel;
|
|
2467
|
+
const cancelled = new Promise((resolve) => {
|
|
2468
|
+
cancel = () => {
|
|
2469
|
+
expired = true;
|
|
2470
|
+
abort.abort();
|
|
2471
|
+
resolve({ result: "error", code: "AbortError", afterMs: Date.now() - startedAt });
|
|
2472
|
+
};
|
|
2473
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
2474
|
+
});
|
|
2475
|
+
const read = async () => {
|
|
2476
|
+
let route;
|
|
2477
|
+
try {
|
|
2478
|
+
route = await client.routeOpen({ kind: "management_surface", module_id: "prefrontal-core" }, identity, { consumerIdentity: this.consumerIdentity });
|
|
2479
|
+
if (expired)
|
|
2480
|
+
return timeout();
|
|
2481
|
+
const envelope = await client.request(route, {
|
|
2482
|
+
// Core decodes every management request as {method, params}.
|
|
2483
|
+
method: "session.scope_identity",
|
|
2484
|
+
params: { harness: identity.harness, session: identity.session },
|
|
2485
|
+
}, { timeoutMs: HOST_SCOPE_LOOKUP_TIMEOUT_MS, signal: abort.signal });
|
|
2486
|
+
// Core answers a management request as {result: <answer>}.
|
|
2487
|
+
const reply = isRecord(envelope) ? envelope.result : undefined;
|
|
2488
|
+
if (isRecord(reply) && reply.answer === "no_scope")
|
|
2489
|
+
return { result: "no_scope" };
|
|
2490
|
+
if (!isRecord(reply) ||
|
|
2491
|
+
!isRecord(reply.owner) ||
|
|
2492
|
+
reply.owner.kind !== "reserved" ||
|
|
2493
|
+
reply.owner.module_id !== "prefrontal-core" ||
|
|
2494
|
+
typeof reply.ref !== "string" ||
|
|
2495
|
+
reply.ref.length === 0 ||
|
|
2496
|
+
!Number.isSafeInteger(reply.scope_epoch) ||
|
|
2497
|
+
reply.scope_epoch < 0)
|
|
2498
|
+
return { result: "malformed" };
|
|
2499
|
+
return {
|
|
2500
|
+
result: "scope",
|
|
2501
|
+
scope: {
|
|
2502
|
+
owner: { kind: "reserved", module_id: "prefrontal-core" },
|
|
2503
|
+
ref: reply.ref,
|
|
2504
|
+
scopeEpoch: reply.scope_epoch,
|
|
2505
|
+
},
|
|
2506
|
+
};
|
|
2507
|
+
}
|
|
2508
|
+
catch (error) {
|
|
2509
|
+
// A missing or unavailable scope service must not disable the host's tools.
|
|
2510
|
+
if (expired)
|
|
2511
|
+
return timeout();
|
|
2512
|
+
return {
|
|
2513
|
+
result: "error",
|
|
2514
|
+
code: error instanceof SubcError && error.code
|
|
2515
|
+
? error.code
|
|
2516
|
+
: error instanceof Error
|
|
2517
|
+
? error.name
|
|
2518
|
+
: "Error",
|
|
2519
|
+
afterMs: Date.now() - startedAt,
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
finally {
|
|
2523
|
+
if (route)
|
|
2524
|
+
safeCloseRoute(client, route);
|
|
2525
|
+
}
|
|
2526
|
+
};
|
|
2527
|
+
try {
|
|
2528
|
+
// Keep the two-second bind budget during core startup; fallback routes retry in the background.
|
|
2529
|
+
return await Promise.race([
|
|
2530
|
+
read(),
|
|
2531
|
+
cancelled,
|
|
2532
|
+
new Promise((resolve) => {
|
|
2533
|
+
timer = setTimeout(() => {
|
|
2534
|
+
expired = true;
|
|
2535
|
+
abort.abort();
|
|
2536
|
+
resolve(timeout());
|
|
2537
|
+
}, HOST_SCOPE_LOOKUP_TIMEOUT_MS);
|
|
2538
|
+
}),
|
|
2539
|
+
]);
|
|
2540
|
+
}
|
|
2541
|
+
finally {
|
|
2542
|
+
clearTimeout(timer);
|
|
2543
|
+
signal.removeEventListener("abort", cancel);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
openHostToolRoute(client, identity, record, entry) {
|
|
2547
|
+
if (!this.hostScopes ||
|
|
2548
|
+
!["opencode", "pi"].includes(identity.harness) ||
|
|
2549
|
+
identity.session === DEFAULT_SESSION_ID) {
|
|
2550
|
+
return client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
|
|
2551
|
+
consumerIdentity: this.consumerIdentity,
|
|
2552
|
+
});
|
|
2553
|
+
}
|
|
2554
|
+
return this.openScopedHostToolRoute(client, identity, record, entry);
|
|
2555
|
+
}
|
|
2556
|
+
async openScopedHostToolRoute(client, identity, record, entry) {
|
|
2557
|
+
const scope = await this.hostScope(client, identity);
|
|
2558
|
+
this.assertRecordLive(record);
|
|
2559
|
+
if (scope) {
|
|
2560
|
+
try {
|
|
2561
|
+
const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, { consumerIdentity: this.consumerIdentity, scope });
|
|
2562
|
+
if (entry)
|
|
2563
|
+
entry.hostScope = scope;
|
|
2564
|
+
return route;
|
|
2565
|
+
}
|
|
2566
|
+
catch (error) {
|
|
2567
|
+
if (!(error instanceof SubcError) || !error.code || isSocketDeathError(error))
|
|
2568
|
+
throw error;
|
|
2569
|
+
// A restarting provider has not rejected the scope. Keep it for the normal reopen retry.
|
|
2570
|
+
if (isRouteOpenReloadWindowError(error))
|
|
2571
|
+
throw error;
|
|
2572
|
+
this.assertRecordLive(record);
|
|
2573
|
+
const state = this.hostScopeCache.get(identity.session);
|
|
2574
|
+
if (state && isHostScopeSyncRefusal(error.code)) {
|
|
2575
|
+
this.deferHostScopeBind(identity.session, state, error.code);
|
|
2576
|
+
}
|
|
2577
|
+
else {
|
|
2578
|
+
this.refuseHostScopeBind(identity.session, state, error.code);
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
this.assertRecordLive(record);
|
|
2583
|
+
const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
|
|
2584
|
+
consumerIdentity: this.consumerIdentity,
|
|
2585
|
+
});
|
|
2586
|
+
// Scope recovery can finish while a fallback bind waits for the provider, including bg_events.
|
|
2587
|
+
if (this.hostScopeCache.get(identity.session)?.answer?.result === "scope" &&
|
|
2588
|
+
!this.hostScopeCache.get(identity.session)?.scopeBindPending &&
|
|
2589
|
+
!this.hostScopeRefusals.has(identity.session)) {
|
|
2590
|
+
safeCloseRoute(client, route);
|
|
2591
|
+
return this.openScopedHostToolRoute(client, identity, record, entry);
|
|
2592
|
+
}
|
|
2593
|
+
return route;
|
|
2594
|
+
}
|
|
2595
|
+
invalidateHostScope(session) {
|
|
2596
|
+
const state = this.hostScopeCache.get(session);
|
|
2597
|
+
if (state) {
|
|
2598
|
+
if (state.scopeBindPending)
|
|
2599
|
+
log(`subc head scope: session=${session} retry stopped reason=scope_route_closed`);
|
|
2600
|
+
this.cancelHostScopeRetry(state);
|
|
2601
|
+
}
|
|
2602
|
+
this.hostScopeCache.delete(session);
|
|
2603
|
+
this.hostScopeRefusals.delete(session);
|
|
2604
|
+
for (const record of this.sessions.values()) {
|
|
2605
|
+
if (record.identity.session !== session || !record.routeEntry)
|
|
2606
|
+
continue;
|
|
2607
|
+
this.retireHostToolRoute(record);
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
onScopeRouteClose(push) {
|
|
2611
|
+
if (push.op !== "route.closed")
|
|
2612
|
+
return;
|
|
2613
|
+
const reason = routeCloseReason(push);
|
|
2614
|
+
if (!reason?.startsWith("scope_")) {
|
|
2615
|
+
for (const record of this.sessions.values()) {
|
|
2616
|
+
const route = record.routeEntry?.handle;
|
|
2617
|
+
if (!route)
|
|
2618
|
+
continue;
|
|
2619
|
+
if ((Array.isArray(push.body.channels) && push.body.channels.includes(route.channel)) ||
|
|
2620
|
+
(!Array.isArray(push.body.channels) && push.body.module_id === AFT_MODULE_ID))
|
|
2621
|
+
this.retireHostToolRoute(record);
|
|
2622
|
+
}
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2625
|
+
const sessions = new Set();
|
|
2626
|
+
for (const record of this.sessions.values()) {
|
|
2627
|
+
const routes = [record.routeEntry?.handle, record.bgSub?.attachedRoute].filter((route) => route != null);
|
|
2628
|
+
if ((Array.isArray(push.body.channels) &&
|
|
2629
|
+
routes.some((route) => push.body.channels.includes(route.channel))) ||
|
|
2630
|
+
(!Array.isArray(push.body.channels) && push.body.module_id === AFT_MODULE_ID))
|
|
2631
|
+
sessions.add(record.identity.session);
|
|
2632
|
+
}
|
|
2633
|
+
for (const session of sessions)
|
|
2634
|
+
this.invalidateHostScope(session);
|
|
2635
|
+
}
|
|
2070
2636
|
ensureBgSubscription(identity, record) {
|
|
2071
2637
|
if (this.shuttingDown || (!this.onBgEventsNudge && !this.onBgEventsNudgeRef))
|
|
2072
2638
|
return;
|
|
@@ -2077,6 +2643,15 @@ export class SubcTransportPool {
|
|
|
2077
2643
|
if (!this.rootCanAttach(record.canonicalRoot))
|
|
2078
2644
|
return;
|
|
2079
2645
|
const nudgeRef = this.nudgeRefFor(record);
|
|
2646
|
+
const dispatch = (callback) => {
|
|
2647
|
+
const failed = (error) => warn(`subc bg nudge delivery failed: ${describeFailure(error)}`);
|
|
2648
|
+
try {
|
|
2649
|
+
void Promise.resolve(callback()).catch(failed);
|
|
2650
|
+
}
|
|
2651
|
+
catch (error) {
|
|
2652
|
+
failed(error);
|
|
2653
|
+
}
|
|
2654
|
+
};
|
|
2080
2655
|
const onNudge = (observedEvent) => {
|
|
2081
2656
|
const currentRecord = this.currentSessionForNudge(identity);
|
|
2082
2657
|
if (!currentRecord) {
|
|
@@ -2092,32 +2667,46 @@ export class SubcTransportPool {
|
|
|
2092
2667
|
const currentRef = this.nudgeRefFor(currentRecord);
|
|
2093
2668
|
let delivered = false;
|
|
2094
2669
|
if (currentRef && this.onBgEventsNudgeRef) {
|
|
2095
|
-
this.onBgEventsNudgeRef(currentRef);
|
|
2670
|
+
dispatch(() => this.onBgEventsNudgeRef?.(currentRef));
|
|
2096
2671
|
delivered = true;
|
|
2097
2672
|
}
|
|
2098
2673
|
if (this.onBgEventsNudge) {
|
|
2099
2674
|
if (!currentRef && this.onBgEventsNudgeRef) {
|
|
2100
2675
|
this.logNudgeDelivery("fallback-missing-generation", currentRecord, `nudge dispatch fallback=root-session-handler cause=generation-provenance-unavailable root=${currentRecord.canonicalRoot}`);
|
|
2101
2676
|
}
|
|
2102
|
-
this.onBgEventsNudge(currentRecord.identity.project_root, currentRecord.identity.session);
|
|
2677
|
+
dispatch(() => this.onBgEventsNudge?.(currentRecord.identity.project_root, currentRecord.identity.session));
|
|
2103
2678
|
delivered = true;
|
|
2104
2679
|
}
|
|
2105
2680
|
if (delivered)
|
|
2106
2681
|
return;
|
|
2107
2682
|
this.logNudgeDelivery("drop-no-compatible-handler", currentRecord, `nudge dropped cause=generation-provenance-unavailable-and-root-session-handler-unwired root=${currentRecord.canonicalRoot}`);
|
|
2108
2683
|
};
|
|
2684
|
+
const binding = {};
|
|
2109
2685
|
let sub = null;
|
|
2110
2686
|
const clearDormantSubscription = () => {
|
|
2111
2687
|
if (sub && record.bgSub === sub)
|
|
2112
2688
|
record.bgSub = null;
|
|
2113
2689
|
};
|
|
2114
|
-
sub = new BgSubscription(identity, () => this.ensureClient(), (client, cause) => this.dropClient(client, cause),
|
|
2690
|
+
sub = new BgSubscription(identity, () => this.ensureClient(), (client, cause) => this.dropClient(client, cause), (client) => {
|
|
2691
|
+
binding.hostScope = undefined;
|
|
2692
|
+
return this.openHostToolRoute(client, identity, record, binding);
|
|
2693
|
+
}, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
|
|
2115
2694
|
if (!isAbsentRootRouteError(error))
|
|
2116
2695
|
return false;
|
|
2117
2696
|
this.markRootDormant(record.canonicalRoot, error);
|
|
2118
2697
|
return true;
|
|
2119
2698
|
}, clearDormantSubscription, this.bgDispatchProbeIntervalMs, nudgeRef, () => this.isCurrentSession(record.identityKey, record) &&
|
|
2120
|
-
this.isCurrentLiveGeneration(record.canonicalRoot, record.generation), (client) => this.noteClientAlive(client), (client, cause) => this.noteUnansweredCall(client, cause))
|
|
2699
|
+
this.isCurrentLiveGeneration(record.canonicalRoot, record.generation), (client) => this.noteClientAlive(client), (client, cause) => this.noteUnansweredCall(client, cause), () => {
|
|
2700
|
+
const state = this.hostScopeCache.get(identity.session);
|
|
2701
|
+
if (state)
|
|
2702
|
+
this.scheduleHostScopeRetry(identity.session, state);
|
|
2703
|
+
if (state?.answer?.result === "scope" &&
|
|
2704
|
+
!state.scopeBindPending &&
|
|
2705
|
+
!binding.hostScope &&
|
|
2706
|
+
!this.hostScopeRefusals.has(identity.session)) {
|
|
2707
|
+
this.promoteHostScope(identity.session);
|
|
2708
|
+
}
|
|
2709
|
+
});
|
|
2121
2710
|
record.bgSub = sub;
|
|
2122
2711
|
if (!this.rootCanAttach(record.canonicalRoot)) {
|
|
2123
2712
|
record.bgSub = null;
|
|
@@ -2166,6 +2755,8 @@ export class SubcTransportPool {
|
|
|
2166
2755
|
record.routeEntry = null;
|
|
2167
2756
|
this.deleteSessionIfEmpty(record.identityKey, record);
|
|
2168
2757
|
}
|
|
2758
|
+
for (const session of this.hostScopeCache.keys())
|
|
2759
|
+
this.stopUnusedHostScopeRetry(session);
|
|
2169
2760
|
this.transportFailures = 0;
|
|
2170
2761
|
try {
|
|
2171
2762
|
client.close();
|
|
@@ -2337,6 +2928,13 @@ export class SubcTransportPool {
|
|
|
2337
2928
|
this.generationRejections.clear();
|
|
2338
2929
|
this.observedStarts.clear();
|
|
2339
2930
|
this.dormantRoots.clear();
|
|
2931
|
+
const scopeLookups = [...this.hostScopeCache.values()].flatMap((state) => {
|
|
2932
|
+
this.cancelHostScopeRetry(state);
|
|
2933
|
+
return state.pending ? [state.pending] : [];
|
|
2934
|
+
});
|
|
2935
|
+
this.hostScopeCache.clear();
|
|
2936
|
+
this.hostScopeRefusals.clear();
|
|
2937
|
+
this.hostScopeOutcomes.clear();
|
|
2340
2938
|
// Deregistration synchronously tombstones all registered roots and invokes
|
|
2341
2939
|
// closeProjectRoot before this method reaches its first await.
|
|
2342
2940
|
const registration = this.lifecycleRegistration;
|
|
@@ -2349,11 +2947,16 @@ export class SubcTransportPool {
|
|
|
2349
2947
|
detached.push(owner);
|
|
2350
2948
|
}
|
|
2351
2949
|
this.rootIndex.clear();
|
|
2950
|
+
this.sessionIndex.clear();
|
|
2352
2951
|
this.transports.clear();
|
|
2353
2952
|
const client = this.client;
|
|
2354
2953
|
this.client = null;
|
|
2355
2954
|
const pending = [...this.pendingRootCleanups];
|
|
2356
|
-
await Promise.allSettled([
|
|
2955
|
+
await Promise.allSettled([
|
|
2956
|
+
...detached.map((owner) => this.cleanupDetached(owner)),
|
|
2957
|
+
...pending,
|
|
2958
|
+
...scopeLookups,
|
|
2959
|
+
]);
|
|
2357
2960
|
if (client) {
|
|
2358
2961
|
try {
|
|
2359
2962
|
client.close();
|
|
@@ -2370,6 +2973,13 @@ export class SubcTransportPool {
|
|
|
2370
2973
|
session: session && session.length > 0 ? session : DEFAULT_SESSION_ID,
|
|
2371
2974
|
};
|
|
2372
2975
|
const owner = this.detachSession(identityKey(identity), "session_closed");
|
|
2976
|
+
if (!this.sessionIndex.has(identity.session)) {
|
|
2977
|
+
const state = this.hostScopeCache.get(identity.session);
|
|
2978
|
+
if (state)
|
|
2979
|
+
this.cancelHostScopeRetry(state);
|
|
2980
|
+
this.hostScopeCache.delete(identity.session);
|
|
2981
|
+
this.hostScopeRefusals.delete(identity.session);
|
|
2982
|
+
}
|
|
2373
2983
|
if (owner)
|
|
2374
2984
|
await this.cleanupDetached(owner);
|
|
2375
2985
|
}
|