@cortexkit/aft-bridge 0.59.0 → 0.60.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-hints.d.ts +3 -3
- package/dist/bash-hints.d.ts.map +1 -1
- package/dist/bash-hints.js +11 -4
- 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 +33 -0
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +81 -4
- 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 +11 -12
- package/dist/config-watch.js.map +1 -1
- package/dist/error-contract.d.ts +15 -0
- package/dist/error-contract.d.ts.map +1 -1
- package/dist/error-contract.js +27 -1
- package/dist/error-contract.js.map +1 -1
- package/dist/feature-config.d.ts.map +1 -1
- package/dist/feature-config.js +75 -0
- package/dist/feature-config.js.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -5
- package/dist/index.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 +2 -0
- package/dist/pool.d.ts.map +1 -1
- package/dist/pool.js +4 -1
- package/dist/pool.js.map +1 -1
- package/dist/revivable-transport.d.ts +4 -0
- package/dist/revivable-transport.d.ts.map +1 -1
- package/dist/revivable-transport.js +15 -4
- package/dist/revivable-transport.js.map +1 -1
- package/dist/subc-transport.d.ts +43 -4
- package/dist/subc-transport.d.ts.map +1 -1
- package/dist/subc-transport.js +578 -24
- package/dist/subc-transport.js.map +1 -1
- package/dist/transport-factory.d.ts.map +1 -1
- package/dist/transport-factory.js +5 -4
- package/dist/transport-factory.js.map +1 -1
- package/dist/transport.d.ts +25 -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,12 @@
|
|
|
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
22
|
import { isRouteOpenReloadWindowError, isRouteRequestReloadRefusal } from "./error-contract.js";
|
|
23
23
|
import { asCanonicalRootPath, asRootGeneration, LifecycleRegistry, } from "./lifecycle-registry.js";
|
|
24
24
|
import { canonicalizeProjectRoot } from "./project-identity.js";
|
|
25
|
-
import { callPresetFor, PRESET_FIELD, WORKER_SESSION_FIELD } from "./transport.js";
|
|
25
|
+
import { callPresetFor, HOST_TOOL_NAMES_FIELD, PRESET_FIELD, WORKER_SESSION_FIELD, } from "./transport.js";
|
|
26
26
|
/** The subc pool is closing and cannot carry another request. */
|
|
27
27
|
export class SubcTransportShuttingDownError extends SubcCallError {
|
|
28
28
|
constructor() {
|
|
@@ -49,6 +49,61 @@ export class SubcRouteClosedMidCallError extends SubcCallError {
|
|
|
49
49
|
this.reason = reason;
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* A data-plane request was queued and sent, but reading the daemon's response
|
|
54
|
+
* frame timed out waiting for body bytes after the header was received.
|
|
55
|
+
*
|
|
56
|
+
* subc-client raises SocketTimeoutError from readExact when the body does not
|
|
57
|
+
* arrive within BODY_READ_TIMEOUT_MS after the header. Because the request was
|
|
58
|
+
* already sent to the daemon, it may have executed: the outcome is unknown.
|
|
59
|
+
* The call must not be blind-retried, and bash must not re-run it on the host.
|
|
60
|
+
*/
|
|
61
|
+
export class SubcFrameReadTimeoutError extends SubcCallError {
|
|
62
|
+
constructor(cause) {
|
|
63
|
+
super("outcome_unknown", "the command may have run; timed out waiting for frame body bytes from the AFT daemon", "frame_read_timeout", cause);
|
|
64
|
+
this.name = "SubcFrameReadTimeoutError";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Matches the frame-body read timeout raised by subc-client (socket.ts:185/190):
|
|
69
|
+
* "timed out waiting for <N> bytes".
|
|
70
|
+
*
|
|
71
|
+
* Excludes connect timeouts ("timed out connecting to ...") and write timeouts
|
|
72
|
+
* ("timed out writing to subc"): connect timeouts are provably pre-send, while
|
|
73
|
+
* write timeouts keep their existing transport classification.
|
|
74
|
+
*/
|
|
75
|
+
function isFrameBodyReadTimeoutMessage(message) {
|
|
76
|
+
return (typeof message === "string" &&
|
|
77
|
+
(/timed out waiting for \d+ bytes/i.test(message) ||
|
|
78
|
+
message.includes("timed out waiting for frame body bytes")));
|
|
79
|
+
}
|
|
80
|
+
export function isSubcFrameReadTimeoutError(error) {
|
|
81
|
+
if (error instanceof SubcFrameReadTimeoutError)
|
|
82
|
+
return true;
|
|
83
|
+
if (error instanceof SocketTimeoutError && isFrameBodyReadTimeoutMessage(error.message)) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (error instanceof SubcError && isFrameBodyReadTimeoutMessage(error.message)) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
if (error instanceof SubcCallError) {
|
|
90
|
+
if (error.code === "frame_read_timeout")
|
|
91
|
+
return true;
|
|
92
|
+
if (error.cause !== undefined &&
|
|
93
|
+
error.cause !== error &&
|
|
94
|
+
isSubcFrameReadTimeoutError(error.cause)) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
if (isFrameBodyReadTimeoutMessage(error.message))
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
export function frameReadTimeoutError(error) {
|
|
103
|
+
if (!isSubcFrameReadTimeoutError(error))
|
|
104
|
+
return error;
|
|
105
|
+
return error instanceof SubcFrameReadTimeoutError ? error : new SubcFrameReadTimeoutError(error);
|
|
106
|
+
}
|
|
52
107
|
/**
|
|
53
108
|
* subc-client's rejection of a request that was pending when the client itself
|
|
54
109
|
* closed the route: `closeRoute` tearing that route down, or the client closing
|
|
@@ -101,6 +156,8 @@ export function isSubcClientClosedError(error) {
|
|
|
101
156
|
* the connection has already been torn down locally.
|
|
102
157
|
*/
|
|
103
158
|
export function isSocketDeathError(error, depth = 0) {
|
|
159
|
+
if (isSubcFrameReadTimeoutError(error))
|
|
160
|
+
return true;
|
|
104
161
|
// Raw data-plane requests wrap socket death in a SubcError; its typed
|
|
105
162
|
// connection-loss reason survives even when the underlying errno does not.
|
|
106
163
|
if (error instanceof SubcError && error.closeReason === "connection_lost")
|
|
@@ -164,6 +221,14 @@ export class SubcLivenessProbeTimeoutError extends Error {
|
|
|
164
221
|
}
|
|
165
222
|
/** The subc module id AFT registers under (matches the daemon manifest). */
|
|
166
223
|
const AFT_MODULE_ID = "aft";
|
|
224
|
+
const HOST_SCOPE_LOOKUP_TIMEOUT_MS = 2_000;
|
|
225
|
+
const HOST_SCOPE_RETRY_FLOOR_MS = 5_000;
|
|
226
|
+
const HOST_SCOPE_RETRY_CAP_MS = 300_000;
|
|
227
|
+
function isHostScopeSyncRefusal(code) {
|
|
228
|
+
// crates/aft/src/gh_shim_relay/mod.rs lists scope_not_synced and scope_changed as retryable.
|
|
229
|
+
// A live scope freshly reported by core can also be scope_not_live until the daemon applies it.
|
|
230
|
+
return code === "scope_not_live" || code === "scope_not_synced" || code === "scope_changed";
|
|
231
|
+
}
|
|
167
232
|
/**
|
|
168
233
|
* A run of consecutive per-call failures that got no answer (timeout / route
|
|
169
234
|
* GOODBYE / refusal) on the SAME client raises the suspicion of a dead half-open
|
|
@@ -305,7 +370,7 @@ class BgSubscription {
|
|
|
305
370
|
identity;
|
|
306
371
|
acquireClient;
|
|
307
372
|
dropClient;
|
|
308
|
-
|
|
373
|
+
openRoute;
|
|
309
374
|
onNudge;
|
|
310
375
|
sleep;
|
|
311
376
|
canAttach;
|
|
@@ -316,23 +381,25 @@ class BgSubscription {
|
|
|
316
381
|
isCurrent;
|
|
317
382
|
noteClientAlive;
|
|
318
383
|
noteUnansweredCall;
|
|
384
|
+
onRouteAttached;
|
|
319
385
|
stopped = false;
|
|
320
386
|
/** The live subscription handle, read by stop() to wake the loop's `await closed`. */
|
|
321
387
|
current = null;
|
|
322
388
|
/** Client carrying the live subscription route, counted when that client is dropped. */
|
|
323
389
|
attached = null;
|
|
390
|
+
route = null;
|
|
324
391
|
loop;
|
|
325
392
|
lifecycleLogState = new Map();
|
|
326
393
|
nudgeReceiptLogState = null;
|
|
327
|
-
constructor(identity, acquireClient, dropClient,
|
|
394
|
+
constructor(identity, acquireClient, dropClient, openRoute, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, dispatchProbeIntervalMs, nudgeRef, isCurrent = () => true,
|
|
328
395
|
/** Reports a reply seen on the client, which proves its socket still delivers. */
|
|
329
396
|
noteClientAlive = () => undefined,
|
|
330
397
|
/** Reports a call on the client that got no answer; enough in a row start a liveness probe. */
|
|
331
|
-
noteUnansweredCall = () => undefined) {
|
|
398
|
+
noteUnansweredCall = () => undefined, onRouteAttached = () => undefined) {
|
|
332
399
|
this.identity = identity;
|
|
333
400
|
this.acquireClient = acquireClient;
|
|
334
401
|
this.dropClient = dropClient;
|
|
335
|
-
this.
|
|
402
|
+
this.openRoute = openRoute;
|
|
336
403
|
this.onNudge = onNudge;
|
|
337
404
|
this.sleep = sleep;
|
|
338
405
|
this.canAttach = canAttach;
|
|
@@ -343,12 +410,16 @@ class BgSubscription {
|
|
|
343
410
|
this.isCurrent = isCurrent;
|
|
344
411
|
this.noteClientAlive = noteClientAlive;
|
|
345
412
|
this.noteUnansweredCall = noteUnansweredCall;
|
|
413
|
+
this.onRouteAttached = onRouteAttached;
|
|
346
414
|
this.loop = this.run();
|
|
347
415
|
}
|
|
348
416
|
/** The client whose route currently carries this subscription, if any. */
|
|
349
417
|
get attachedClient() {
|
|
350
418
|
return this.attached;
|
|
351
419
|
}
|
|
420
|
+
get attachedRoute() {
|
|
421
|
+
return this.route;
|
|
422
|
+
}
|
|
352
423
|
async stop() {
|
|
353
424
|
this.stopped = true;
|
|
354
425
|
// Wake a live `await sub.closed`; the loop's `finally` remains the sole
|
|
@@ -477,7 +548,7 @@ class BgSubscription {
|
|
|
477
548
|
try {
|
|
478
549
|
// A dedicated route keeps bg_events independent of the tool route's
|
|
479
550
|
// credit window while preserving the client's connection-bound handle.
|
|
480
|
-
route = await
|
|
551
|
+
route = await this.openRoute(client);
|
|
481
552
|
}
|
|
482
553
|
catch (err) {
|
|
483
554
|
if (this.isCurrent() && this.onRootAttachFailure(err)) {
|
|
@@ -536,6 +607,8 @@ class BgSubscription {
|
|
|
536
607
|
});
|
|
537
608
|
this.current = sub;
|
|
538
609
|
this.attached = client;
|
|
610
|
+
this.route = route;
|
|
611
|
+
this.onRouteAttached();
|
|
539
612
|
stopDispatchProbe = this.startDispatchProbe(client, routeId);
|
|
540
613
|
this.info("subscription-open", `subscription open channel=${routeId}`);
|
|
541
614
|
if (reconnecting) {
|
|
@@ -586,6 +659,7 @@ class BgSubscription {
|
|
|
586
659
|
stopDispatchProbe();
|
|
587
660
|
this.current = null;
|
|
588
661
|
this.attached = null;
|
|
662
|
+
this.route = null;
|
|
589
663
|
safeCloseRoute(client, route);
|
|
590
664
|
}
|
|
591
665
|
await this.backoff(backoffAttempt++);
|
|
@@ -601,7 +675,8 @@ function isRecord(value) {
|
|
|
601
675
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
602
676
|
}
|
|
603
677
|
function isRouteProvenAbsentError(err) {
|
|
604
|
-
return ((err instanceof SubcError &&
|
|
678
|
+
return ((err instanceof SubcError &&
|
|
679
|
+
(err.code === "unknown_channel" || err.code === "stale_route_epoch")) ||
|
|
605
680
|
err instanceof StaleRouteHandleError);
|
|
606
681
|
}
|
|
607
682
|
/**
|
|
@@ -799,6 +874,8 @@ class SubcTransport {
|
|
|
799
874
|
body.preview = true;
|
|
800
875
|
if (options?.workerSession === true)
|
|
801
876
|
body[WORKER_SESSION_FIELD] = true;
|
|
877
|
+
if (options?.hostToolNames)
|
|
878
|
+
body[HOST_TOOL_NAMES_FIELD] = options.hostToolNames;
|
|
802
879
|
body[PRESET_FIELD] = callPresetFor(options?.workerSession === true);
|
|
803
880
|
const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress, this.generation, abortSignal);
|
|
804
881
|
return reliftReply(reply);
|
|
@@ -823,10 +900,14 @@ class SubcTransport {
|
|
|
823
900
|
// The caller's role travels beside the call, like the session (which the
|
|
824
901
|
// route bind carries), not inside the command's arguments: the module
|
|
825
902
|
// reads it from the call body.
|
|
826
|
-
|
|
903
|
+
// The caller's tool names travel the same way: RouteBind has no field for
|
|
904
|
+
// them, and one route's host must not decide another route's wording.
|
|
905
|
+
const { [WORKER_SESSION_FIELD]: workerSession, [HOST_TOOL_NAMES_FIELD]: hostToolNames, ...args } = params;
|
|
827
906
|
const body = { name: command, arguments: args };
|
|
828
907
|
if (workerSession === true)
|
|
829
908
|
body[WORKER_SESSION_FIELD] = true;
|
|
909
|
+
if (hostToolNames !== undefined)
|
|
910
|
+
body[HOST_TOOL_NAMES_FIELD] = hostToolNames;
|
|
830
911
|
body[PRESET_FIELD] = callPresetFor(workerSession === true);
|
|
831
912
|
const editSlotSurvives = this.pool.getEditSlotSurvives();
|
|
832
913
|
if (editSlotSurvives !== undefined)
|
|
@@ -857,6 +938,11 @@ export class SubcTransportPool {
|
|
|
857
938
|
connectionFile;
|
|
858
939
|
handshakeTimeoutMs;
|
|
859
940
|
consumerIdentity;
|
|
941
|
+
hostScopes;
|
|
942
|
+
/** Scope identity is host-session-wide, even when the session uses multiple project roots. */
|
|
943
|
+
hostScopeCache = new Map();
|
|
944
|
+
hostScopeRefusals = new Set();
|
|
945
|
+
hostScopeOutcomes = new Map();
|
|
860
946
|
connectFn;
|
|
861
947
|
onBgEventsNudge;
|
|
862
948
|
onBgEventsNudgeRef;
|
|
@@ -883,6 +969,8 @@ export class SubcTransportPool {
|
|
|
883
969
|
routeReopenRetryMs = null;
|
|
884
970
|
/** Per-session records keyed by the opaque identity key. */
|
|
885
971
|
sessions = new Map();
|
|
972
|
+
/** Keep global insertion order so a session using several roots selects the same first live route. */
|
|
973
|
+
sessionIndex = new Map();
|
|
886
974
|
// Pending host creation events are bounded. Forgetting one only makes that
|
|
887
975
|
// session unknown (retained), never falsely eligible for eviction.
|
|
888
976
|
observedStarts = new Set();
|
|
@@ -916,6 +1004,7 @@ export class SubcTransportPool {
|
|
|
916
1004
|
this.harness = options.harness;
|
|
917
1005
|
this.handshakeTimeoutMs = options.handshakeTimeoutMs;
|
|
918
1006
|
this.consumerIdentity = options.consumerIdentity;
|
|
1007
|
+
this.hostScopes = options.hostScopes ?? false;
|
|
919
1008
|
this.connectFn = options.connect ?? ((opts) => SubcClient.connect(opts));
|
|
920
1009
|
this.onBgEventsNudge = options.onBgEventsNudge;
|
|
921
1010
|
this.onBgEventsNudgeRef = options.onBgEventsNudgeRef;
|
|
@@ -1174,7 +1263,9 @@ export class SubcTransportPool {
|
|
|
1174
1263
|
* always has a registered root and captured generation.
|
|
1175
1264
|
*/
|
|
1176
1265
|
getBridge(projectRoot) {
|
|
1177
|
-
|
|
1266
|
+
return this.getBridgeForCanonicalRoot(this.canonicalRoot(projectRoot));
|
|
1267
|
+
}
|
|
1268
|
+
getBridgeForCanonicalRoot(root) {
|
|
1178
1269
|
if (this.shuttingDown && this.lifecycleEnabled()) {
|
|
1179
1270
|
throw new SubcTransportShuttingDownError();
|
|
1180
1271
|
}
|
|
@@ -1279,6 +1370,12 @@ export class SubcTransportPool {
|
|
|
1279
1370
|
pendingDetach: false,
|
|
1280
1371
|
};
|
|
1281
1372
|
this.sessions.set(key, record);
|
|
1373
|
+
let records = this.sessionIndex.get(identity.session);
|
|
1374
|
+
if (!records) {
|
|
1375
|
+
records = new Set();
|
|
1376
|
+
this.sessionIndex.set(identity.session, records);
|
|
1377
|
+
}
|
|
1378
|
+
records.add(record);
|
|
1282
1379
|
let keys = this.rootIndex.get(root);
|
|
1283
1380
|
if (!keys) {
|
|
1284
1381
|
keys = new Set();
|
|
@@ -1485,6 +1582,10 @@ export class SubcTransportPool {
|
|
|
1485
1582
|
log(`subc bg_events: ${message}${suffix}`, { sessionId: record.identity.session });
|
|
1486
1583
|
}
|
|
1487
1584
|
removeIndexMembership(record) {
|
|
1585
|
+
const records = this.sessionIndex.get(record.identity.session);
|
|
1586
|
+
records?.delete(record);
|
|
1587
|
+
if (records?.size === 0)
|
|
1588
|
+
this.sessionIndex.delete(record.identity.session);
|
|
1488
1589
|
const keys = this.rootIndex.get(record.canonicalRoot);
|
|
1489
1590
|
if (!keys)
|
|
1490
1591
|
return;
|
|
@@ -1507,6 +1608,7 @@ export class SubcTransportPool {
|
|
|
1507
1608
|
this.sessions.delete(record.identityKey);
|
|
1508
1609
|
this.removeIndexMembership(record);
|
|
1509
1610
|
}
|
|
1611
|
+
this.stopUnusedHostScopeRetry(record.identity.session);
|
|
1510
1612
|
}
|
|
1511
1613
|
/**
|
|
1512
1614
|
* Atomically detaches one opaque identity. Nothing in this method awaits: it
|
|
@@ -1530,6 +1632,7 @@ export class SubcTransportPool {
|
|
|
1530
1632
|
record.routeEntry = null;
|
|
1531
1633
|
if (routeEntry)
|
|
1532
1634
|
routeEntry.closed = true;
|
|
1635
|
+
this.stopUnusedHostScopeRetry(record.identity.session);
|
|
1533
1636
|
return { record, bgSub, routeEntry };
|
|
1534
1637
|
}
|
|
1535
1638
|
async cleanupDetached(detached) {
|
|
@@ -1589,6 +1692,17 @@ export class SubcTransportPool {
|
|
|
1589
1692
|
entry.inflight -= 1;
|
|
1590
1693
|
this.closeRetiredRoute(record, entry);
|
|
1591
1694
|
}
|
|
1695
|
+
retireHostToolRoute(record) {
|
|
1696
|
+
const entry = record.routeEntry;
|
|
1697
|
+
if (!entry)
|
|
1698
|
+
return;
|
|
1699
|
+
entry.closed = true;
|
|
1700
|
+
entry.closedBy ??= "route replaced";
|
|
1701
|
+
record.routeEntry = null;
|
|
1702
|
+
record.retiredRoutes.add(entry);
|
|
1703
|
+
this.closeRetiredRoute(record, entry);
|
|
1704
|
+
this.stopUnusedHostScopeRetry(record.identity.session);
|
|
1705
|
+
}
|
|
1592
1706
|
/** Open or reuse a route while guarding every lifecycle boundary. */
|
|
1593
1707
|
async routeRequest(identity, body, timeoutMs, onProgress, expectedGeneration, abortSignal) {
|
|
1594
1708
|
const callStartedAt = performance.now();
|
|
@@ -1760,17 +1874,16 @@ export class SubcTransportPool {
|
|
|
1760
1874
|
const clearRouteEntry = (entry) => {
|
|
1761
1875
|
if (record.routeEntry !== entry)
|
|
1762
1876
|
return;
|
|
1763
|
-
|
|
1764
|
-
entry.closedBy ??= "route replaced";
|
|
1765
|
-
record.routeEntry = null;
|
|
1766
|
-
record.retiredRoutes.add(entry);
|
|
1767
|
-
this.closeRetiredRoute(record, entry);
|
|
1877
|
+
this.retireHostToolRoute(record);
|
|
1768
1878
|
};
|
|
1769
1879
|
const handleRequestFailure = (error, entry) => {
|
|
1770
1880
|
const aborted = error instanceof Error && error.name === "AbortError";
|
|
1771
1881
|
const ownsClient = this.isCurrentSession(key, record) &&
|
|
1772
1882
|
this.client === client &&
|
|
1773
1883
|
!this.isReapInduced(record);
|
|
1884
|
+
if (ownsClient && error instanceof SubcError && error.closeReason?.startsWith("scope_")) {
|
|
1885
|
+
this.invalidateHostScope(identity.session);
|
|
1886
|
+
}
|
|
1774
1887
|
if (!aborted && ownsClient && isSocketDeathError(error)) {
|
|
1775
1888
|
// Dropped before the route is cleared so the drop log counts it.
|
|
1776
1889
|
this.dropClient(client, {
|
|
@@ -1785,7 +1898,8 @@ export class SubcTransportPool {
|
|
|
1785
1898
|
// route remains reusable, and no replacement may cut its other calls.
|
|
1786
1899
|
if (aborted ||
|
|
1787
1900
|
isRouteProvenAbsentError(error) ||
|
|
1788
|
-
(error instanceof SubcError && error.closeReason !== undefined)
|
|
1901
|
+
(error instanceof SubcError && error.closeReason !== undefined) ||
|
|
1902
|
+
isSubcFrameReadTimeoutError(error)) {
|
|
1789
1903
|
clearRouteEntry(entry);
|
|
1790
1904
|
}
|
|
1791
1905
|
if (aborted || !ownsClient || this.client !== client)
|
|
@@ -1877,10 +1991,15 @@ export class SubcTransportPool {
|
|
|
1877
1991
|
return reply;
|
|
1878
1992
|
}
|
|
1879
1993
|
catch (requestError) {
|
|
1880
|
-
const error = routeClosedMidCallError(requestError, routeAndEntry.entry, record);
|
|
1994
|
+
const error = frameReadTimeoutError(routeClosedMidCallError(requestError, routeAndEntry.entry, record));
|
|
1881
1995
|
if (this.isReapInduced(record))
|
|
1882
1996
|
throw this.annotateReapError(error, record);
|
|
1883
1997
|
const ownsRoute = this.isCurrentSession(key, record) && this.client === client;
|
|
1998
|
+
if (ownsRoute &&
|
|
1999
|
+
((error instanceof SubcError && error.code === "stale_route_epoch") ||
|
|
2000
|
+
error instanceof StaleRouteHandleError)) {
|
|
2001
|
+
this.invalidateHostScope(identity.session);
|
|
2002
|
+
}
|
|
1884
2003
|
// A route that was bound before a module reload started gets its
|
|
1885
2004
|
// requests refused by the daemon for as long as the drain lasts. The
|
|
1886
2005
|
// refusal happens before the request is forwarded, so it is handled
|
|
@@ -1978,9 +2097,14 @@ export class SubcTransportPool {
|
|
|
1978
2097
|
return this.client;
|
|
1979
2098
|
if (this.connecting)
|
|
1980
2099
|
return this.connecting;
|
|
2100
|
+
let owner;
|
|
1981
2101
|
this.connecting = this.connectFn({
|
|
1982
2102
|
connectionFile: this.connectionFile,
|
|
1983
2103
|
handshakeTimeoutMs: this.handshakeTimeoutMs,
|
|
2104
|
+
onControlPush: (push) => {
|
|
2105
|
+
if (owner && this.client === owner)
|
|
2106
|
+
this.onScopeRouteClose(push);
|
|
2107
|
+
},
|
|
1984
2108
|
})
|
|
1985
2109
|
.then((client) => {
|
|
1986
2110
|
this.connecting = null;
|
|
@@ -1994,6 +2118,7 @@ export class SubcTransportPool {
|
|
|
1994
2118
|
throw new SubcTransportShuttingDownError();
|
|
1995
2119
|
}
|
|
1996
2120
|
this.client = client;
|
|
2121
|
+
owner = client;
|
|
1997
2122
|
this.hadWorkingClient = true;
|
|
1998
2123
|
this.transportFailures = 0;
|
|
1999
2124
|
return client;
|
|
@@ -2027,10 +2152,7 @@ export class SubcTransportPool {
|
|
|
2027
2152
|
closedBy: null,
|
|
2028
2153
|
};
|
|
2029
2154
|
this.assertRootCanAttach(record.canonicalRoot);
|
|
2030
|
-
const opening = client
|
|
2031
|
-
.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
|
|
2032
|
-
consumerIdentity: this.consumerIdentity,
|
|
2033
|
-
})
|
|
2155
|
+
const opening = this.openHostToolRoute(client, identity, record, entry)
|
|
2034
2156
|
.then((route) => {
|
|
2035
2157
|
if (!this.isCurrentSession(record.identityKey, record) ||
|
|
2036
2158
|
record.routeEntry !== entry ||
|
|
@@ -2046,6 +2168,15 @@ export class SubcTransportPool {
|
|
|
2046
2168
|
entry.handle = route;
|
|
2047
2169
|
entry.opening = null;
|
|
2048
2170
|
this.noteClientAlive(client);
|
|
2171
|
+
const state = this.hostScopeCache.get(identity.session);
|
|
2172
|
+
if (state)
|
|
2173
|
+
this.scheduleHostScopeRetry(identity.session, state);
|
|
2174
|
+
if (state?.answer?.result === "scope" &&
|
|
2175
|
+
!state.scopeBindPending &&
|
|
2176
|
+
!entry.hostScope &&
|
|
2177
|
+
!this.hostScopeRefusals.has(identity.session)) {
|
|
2178
|
+
this.promoteHostScope(identity.session);
|
|
2179
|
+
}
|
|
2049
2180
|
return route;
|
|
2050
2181
|
})
|
|
2051
2182
|
.catch((error) => {
|
|
@@ -2054,6 +2185,7 @@ export class SubcTransportPool {
|
|
|
2054
2185
|
entry.closed = true;
|
|
2055
2186
|
record.routeEntry = null;
|
|
2056
2187
|
}
|
|
2188
|
+
this.stopUnusedHostScopeRetry(identity.session);
|
|
2057
2189
|
if (!this.isCurrentSession(record.identityKey, record) &&
|
|
2058
2190
|
!(error instanceof RouteTornDownError)) {
|
|
2059
2191
|
throw new RouteTornDownError("subc route opened after session closed");
|
|
@@ -2067,6 +2199,393 @@ export class SubcTransportPool {
|
|
|
2067
2199
|
record.routeEntry = entry;
|
|
2068
2200
|
return { route: await opening, entry };
|
|
2069
2201
|
}
|
|
2202
|
+
hostScope(client, identity) {
|
|
2203
|
+
if (this.hostScopeRefusals.has(identity.session))
|
|
2204
|
+
return Promise.resolve(undefined);
|
|
2205
|
+
let state = this.hostScopeCache.get(identity.session);
|
|
2206
|
+
if (!state) {
|
|
2207
|
+
state = { retryAt: 0, retryMs: HOST_SCOPE_RETRY_FLOOR_MS };
|
|
2208
|
+
this.hostScopeCache.set(identity.session, state);
|
|
2209
|
+
}
|
|
2210
|
+
if (state.answer) {
|
|
2211
|
+
return Promise.resolve(state.answer.result === "scope" && !state.scopeBindPending ? state.answer.scope : undefined);
|
|
2212
|
+
}
|
|
2213
|
+
if (state.pending)
|
|
2214
|
+
return state.pending;
|
|
2215
|
+
// Backoff skips the lookup, not the bind, so an unavailable core cannot delay every root.
|
|
2216
|
+
if (Date.now() < state.retryAt)
|
|
2217
|
+
return Promise.resolve(undefined);
|
|
2218
|
+
const current = state;
|
|
2219
|
+
clearTimeout(current.retryTimer);
|
|
2220
|
+
current.retryTimer = undefined;
|
|
2221
|
+
const abort = new AbortController();
|
|
2222
|
+
current.lookupAbort = abort;
|
|
2223
|
+
const lookup = this.readHostScope(client, identity, abort.signal).then((outcome) => {
|
|
2224
|
+
// A scope-close push may invalidate the session while the management request is in flight.
|
|
2225
|
+
if (this.hostScopeCache.get(identity.session) !== current)
|
|
2226
|
+
return undefined;
|
|
2227
|
+
current.pending = undefined;
|
|
2228
|
+
current.lookupAbort = undefined;
|
|
2229
|
+
if (abort.signal.aborted)
|
|
2230
|
+
return undefined;
|
|
2231
|
+
this.logHostScopeOutcome(identity.session, outcome);
|
|
2232
|
+
if (outcome.result === "scope" || outcome.result === "no_scope") {
|
|
2233
|
+
current.answer = outcome;
|
|
2234
|
+
current.retryAt = 0;
|
|
2235
|
+
current.retryMs = HOST_SCOPE_RETRY_FLOOR_MS;
|
|
2236
|
+
if (outcome.result === "scope")
|
|
2237
|
+
this.promoteHostScope(identity.session);
|
|
2238
|
+
}
|
|
2239
|
+
else {
|
|
2240
|
+
current.retryAt = Date.now() + current.retryMs;
|
|
2241
|
+
current.retryMs = Math.min(current.retryMs * 2, HOST_SCOPE_RETRY_CAP_MS);
|
|
2242
|
+
this.scheduleHostScopeRetry(identity.session, current);
|
|
2243
|
+
}
|
|
2244
|
+
return outcome.result === "scope" ? outcome.scope : undefined;
|
|
2245
|
+
});
|
|
2246
|
+
current.pending = lookup;
|
|
2247
|
+
return lookup;
|
|
2248
|
+
}
|
|
2249
|
+
unscopedHostRecord(session) {
|
|
2250
|
+
for (const record of this.sessionIndex.get(session) ?? []) {
|
|
2251
|
+
const entry = record.routeEntry;
|
|
2252
|
+
if (record.identity.session === session &&
|
|
2253
|
+
!record.closed &&
|
|
2254
|
+
((entry &&
|
|
2255
|
+
!entry.closed &&
|
|
2256
|
+
!entry.hostScope &&
|
|
2257
|
+
entry.client === this.client &&
|
|
2258
|
+
(entry.handle || entry.opening)) ||
|
|
2259
|
+
(record.bgSub?.attachedClient === this.client && record.bgSub?.attachedRoute)))
|
|
2260
|
+
return record;
|
|
2261
|
+
}
|
|
2262
|
+
return undefined;
|
|
2263
|
+
}
|
|
2264
|
+
cancelHostScopeRetry(state) {
|
|
2265
|
+
clearTimeout(state.retryTimer);
|
|
2266
|
+
state.retryTimer = undefined;
|
|
2267
|
+
state.lookupAbort?.abort();
|
|
2268
|
+
}
|
|
2269
|
+
stopUnusedHostScopeRetry(session) {
|
|
2270
|
+
const state = this.hostScopeCache.get(session);
|
|
2271
|
+
if (state && !this.unscopedHostRecord(session)) {
|
|
2272
|
+
if (state.retryTimer || (state.scopeBindPending && state.bindPending))
|
|
2273
|
+
log(`subc head scope: session=${session} retry stopped reason=route_closed`);
|
|
2274
|
+
this.cancelHostScopeRetry(state);
|
|
2275
|
+
state.scopeBindPending = false;
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
scheduleHostScopeRetry(session, state) {
|
|
2279
|
+
if (this.shuttingDown ||
|
|
2280
|
+
this.hostScopeCache.get(session) !== state ||
|
|
2281
|
+
(state.answer && !state.scopeBindPending) ||
|
|
2282
|
+
state.pending ||
|
|
2283
|
+
state.bindPending ||
|
|
2284
|
+
state.retryTimer ||
|
|
2285
|
+
this.hostScopeRefusals.has(session) ||
|
|
2286
|
+
!this.unscopedHostRecord(session))
|
|
2287
|
+
return;
|
|
2288
|
+
// A head can keep its tool route for its whole life, so recovery cannot rely on another bind.
|
|
2289
|
+
state.retryTimer = setTimeout(() => {
|
|
2290
|
+
state.retryTimer = undefined;
|
|
2291
|
+
const record = this.unscopedHostRecord(session);
|
|
2292
|
+
const client = record?.routeEntry?.client ?? record?.bgSub?.attachedClient;
|
|
2293
|
+
if (this.shuttingDown || this.hostScopeCache.get(session) !== state || !record || !client)
|
|
2294
|
+
return;
|
|
2295
|
+
let retry;
|
|
2296
|
+
if (state.scopeBindPending && state.answer?.result === "scope") {
|
|
2297
|
+
state.bindPending = this.retryHostScopeBind(client, record.identity, state, state.answer.scope).finally(() => {
|
|
2298
|
+
state.bindPending = undefined;
|
|
2299
|
+
this.scheduleHostScopeRetry(session, state);
|
|
2300
|
+
});
|
|
2301
|
+
retry = state.bindPending;
|
|
2302
|
+
}
|
|
2303
|
+
else {
|
|
2304
|
+
retry = this.hostScope(client, record.identity);
|
|
2305
|
+
}
|
|
2306
|
+
void retry.catch((error) => {
|
|
2307
|
+
warn(`subc head scope: background retry failed code=${error instanceof Error ? error.name : "Error"}`);
|
|
2308
|
+
});
|
|
2309
|
+
}, Math.max(0, state.retryAt - Date.now()));
|
|
2310
|
+
state.retryTimer.unref();
|
|
2311
|
+
}
|
|
2312
|
+
deferHostScopeBind(session, state, code) {
|
|
2313
|
+
state.scopeBindPending = true;
|
|
2314
|
+
if (Date.now() >= state.retryAt) {
|
|
2315
|
+
state.retryAt = Date.now() + state.retryMs;
|
|
2316
|
+
state.retryMs = Math.min(state.retryMs * 2, HOST_SCOPE_RETRY_CAP_MS);
|
|
2317
|
+
}
|
|
2318
|
+
warn(`subc head scope: session=${session} scoped bind refused code=${code}; binding unscoped; retry in=${Math.max(0, state.retryAt - Date.now())}ms`);
|
|
2319
|
+
this.scheduleHostScopeRetry(session, state);
|
|
2320
|
+
}
|
|
2321
|
+
refuseHostScopeBind(session, state, code) {
|
|
2322
|
+
if (!this.hostScopeRefusals.has(session)) {
|
|
2323
|
+
this.hostScopeRefusals.add(session);
|
|
2324
|
+
warn(`subc head scope: session=${session} scoped bind refused code=${code}; binding unscoped; retry stopped`);
|
|
2325
|
+
}
|
|
2326
|
+
if (state) {
|
|
2327
|
+
state.scopeBindPending = false;
|
|
2328
|
+
this.cancelHostScopeRetry(state);
|
|
2329
|
+
}
|
|
2330
|
+
// Remember the downgrade without misreporting it as core's no_scope answer.
|
|
2331
|
+
}
|
|
2332
|
+
async retryHostScopeBind(client, identity, state, scope) {
|
|
2333
|
+
try {
|
|
2334
|
+
// Validate daemon acceptance before retiring a working fallback route or its held requests.
|
|
2335
|
+
const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, { consumerIdentity: this.consumerIdentity, scope });
|
|
2336
|
+
safeCloseRoute(client, route);
|
|
2337
|
+
if (this.hostScopeCache.get(identity.session) !== state ||
|
|
2338
|
+
!state.scopeBindPending ||
|
|
2339
|
+
this.shuttingDown ||
|
|
2340
|
+
client !== this.client ||
|
|
2341
|
+
!this.unscopedHostRecord(identity.session))
|
|
2342
|
+
return;
|
|
2343
|
+
state.scopeBindPending = false;
|
|
2344
|
+
state.retryAt = 0;
|
|
2345
|
+
state.retryMs = HOST_SCOPE_RETRY_FLOOR_MS;
|
|
2346
|
+
log(`subc head scope: session=${identity.session} scoped bind recovered ref=${scope.ref.slice(0, 8)} epoch=${scope.scopeEpoch}`);
|
|
2347
|
+
this.promoteHostScope(identity.session);
|
|
2348
|
+
}
|
|
2349
|
+
catch (error) {
|
|
2350
|
+
if (this.hostScopeCache.get(identity.session) !== state ||
|
|
2351
|
+
!state.scopeBindPending ||
|
|
2352
|
+
this.shuttingDown ||
|
|
2353
|
+
!this.unscopedHostRecord(identity.session))
|
|
2354
|
+
return;
|
|
2355
|
+
if (error instanceof SubcError &&
|
|
2356
|
+
error.code &&
|
|
2357
|
+
!isSocketDeathError(error) &&
|
|
2358
|
+
!isRouteOpenReloadWindowError(error) &&
|
|
2359
|
+
!isHostScopeSyncRefusal(error.code)) {
|
|
2360
|
+
this.refuseHostScopeBind(identity.session, state, error.code);
|
|
2361
|
+
}
|
|
2362
|
+
else {
|
|
2363
|
+
this.deferHostScopeBind(identity.session, state, error instanceof SubcError ? (error.code ?? error.name) : "Error");
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
promoteHostScope(session) {
|
|
2368
|
+
for (const record of this.sessions.values()) {
|
|
2369
|
+
const entry = record.routeEntry;
|
|
2370
|
+
if (record.identity.session !== session)
|
|
2371
|
+
continue;
|
|
2372
|
+
// Retirement stops new dispatches; the existing request counter defers closure of held waits.
|
|
2373
|
+
if (entry?.handle && !entry.hostScope)
|
|
2374
|
+
this.retireHostToolRoute(record);
|
|
2375
|
+
const sub = record.bgSub;
|
|
2376
|
+
if (!sub?.attachedRoute)
|
|
2377
|
+
continue;
|
|
2378
|
+
// The old subscription must finish closing before its normal attach path opens a scoped one.
|
|
2379
|
+
void sub.stop().then(() => {
|
|
2380
|
+
if (record.bgSub !== sub)
|
|
2381
|
+
return;
|
|
2382
|
+
record.bgSub = null;
|
|
2383
|
+
this.ensureBgSubscription(record.identity, record);
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
logHostScopeOutcome(session, outcome) {
|
|
2388
|
+
let key;
|
|
2389
|
+
let detail;
|
|
2390
|
+
switch (outcome.result) {
|
|
2391
|
+
case "scope":
|
|
2392
|
+
key = JSON.stringify([outcome.result, outcome.scope.ref, outcome.scope.scopeEpoch]);
|
|
2393
|
+
detail = `scope ref=${outcome.scope.ref.slice(0, 8)} epoch=${outcome.scope.scopeEpoch}`;
|
|
2394
|
+
break;
|
|
2395
|
+
case "error":
|
|
2396
|
+
key = JSON.stringify([outcome.result, outcome.code]);
|
|
2397
|
+
detail = `error code=${outcome.code} after=${outcome.afterMs}`;
|
|
2398
|
+
break;
|
|
2399
|
+
case "timeout":
|
|
2400
|
+
key = outcome.result;
|
|
2401
|
+
detail = `timeout after=${outcome.afterMs}`;
|
|
2402
|
+
break;
|
|
2403
|
+
default:
|
|
2404
|
+
key = outcome.result;
|
|
2405
|
+
detail = outcome.result;
|
|
2406
|
+
}
|
|
2407
|
+
// Elapsed time varies on every retry; only a changed answer or error code merits another line.
|
|
2408
|
+
if (this.hostScopeOutcomes.get(session) === key)
|
|
2409
|
+
return;
|
|
2410
|
+
this.hostScopeOutcomes.set(session, key);
|
|
2411
|
+
log(`subc head scope: session=${session} result=${detail}`);
|
|
2412
|
+
}
|
|
2413
|
+
async readHostScope(client, identity, signal) {
|
|
2414
|
+
const startedAt = Date.now();
|
|
2415
|
+
const timeout = () => ({ result: "timeout", afterMs: Date.now() - startedAt });
|
|
2416
|
+
let expired = false;
|
|
2417
|
+
let timer;
|
|
2418
|
+
const abort = new AbortController();
|
|
2419
|
+
let cancel;
|
|
2420
|
+
const cancelled = new Promise((resolve) => {
|
|
2421
|
+
cancel = () => {
|
|
2422
|
+
expired = true;
|
|
2423
|
+
abort.abort();
|
|
2424
|
+
resolve({ result: "error", code: "AbortError", afterMs: Date.now() - startedAt });
|
|
2425
|
+
};
|
|
2426
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
2427
|
+
});
|
|
2428
|
+
const read = async () => {
|
|
2429
|
+
let route;
|
|
2430
|
+
try {
|
|
2431
|
+
route = await client.routeOpen({ kind: "management_surface", module_id: "prefrontal-core" }, identity, { consumerIdentity: this.consumerIdentity });
|
|
2432
|
+
if (expired)
|
|
2433
|
+
return timeout();
|
|
2434
|
+
const envelope = await client.request(route, {
|
|
2435
|
+
// Core decodes every management request as {method, params}.
|
|
2436
|
+
method: "session.scope_identity",
|
|
2437
|
+
params: { harness: identity.harness, session: identity.session },
|
|
2438
|
+
}, { timeoutMs: HOST_SCOPE_LOOKUP_TIMEOUT_MS, signal: abort.signal });
|
|
2439
|
+
// Core answers a management request as {result: <answer>}.
|
|
2440
|
+
const reply = isRecord(envelope) ? envelope.result : undefined;
|
|
2441
|
+
if (isRecord(reply) && reply.answer === "no_scope")
|
|
2442
|
+
return { result: "no_scope" };
|
|
2443
|
+
if (!isRecord(reply) ||
|
|
2444
|
+
!isRecord(reply.owner) ||
|
|
2445
|
+
reply.owner.kind !== "reserved" ||
|
|
2446
|
+
reply.owner.module_id !== "prefrontal-core" ||
|
|
2447
|
+
typeof reply.ref !== "string" ||
|
|
2448
|
+
reply.ref.length === 0 ||
|
|
2449
|
+
!Number.isSafeInteger(reply.scope_epoch) ||
|
|
2450
|
+
reply.scope_epoch < 0)
|
|
2451
|
+
return { result: "malformed" };
|
|
2452
|
+
return {
|
|
2453
|
+
result: "scope",
|
|
2454
|
+
scope: {
|
|
2455
|
+
owner: { kind: "reserved", module_id: "prefrontal-core" },
|
|
2456
|
+
ref: reply.ref,
|
|
2457
|
+
scopeEpoch: reply.scope_epoch,
|
|
2458
|
+
},
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
catch (error) {
|
|
2462
|
+
// A missing or unavailable scope service must not disable the host's tools.
|
|
2463
|
+
if (expired)
|
|
2464
|
+
return timeout();
|
|
2465
|
+
return {
|
|
2466
|
+
result: "error",
|
|
2467
|
+
code: error instanceof SubcError && error.code
|
|
2468
|
+
? error.code
|
|
2469
|
+
: error instanceof Error
|
|
2470
|
+
? error.name
|
|
2471
|
+
: "Error",
|
|
2472
|
+
afterMs: Date.now() - startedAt,
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
finally {
|
|
2476
|
+
if (route)
|
|
2477
|
+
safeCloseRoute(client, route);
|
|
2478
|
+
}
|
|
2479
|
+
};
|
|
2480
|
+
try {
|
|
2481
|
+
// Keep the two-second bind budget during core startup; fallback routes retry in the background.
|
|
2482
|
+
return await Promise.race([
|
|
2483
|
+
read(),
|
|
2484
|
+
cancelled,
|
|
2485
|
+
new Promise((resolve) => {
|
|
2486
|
+
timer = setTimeout(() => {
|
|
2487
|
+
expired = true;
|
|
2488
|
+
abort.abort();
|
|
2489
|
+
resolve(timeout());
|
|
2490
|
+
}, HOST_SCOPE_LOOKUP_TIMEOUT_MS);
|
|
2491
|
+
}),
|
|
2492
|
+
]);
|
|
2493
|
+
}
|
|
2494
|
+
finally {
|
|
2495
|
+
clearTimeout(timer);
|
|
2496
|
+
signal.removeEventListener("abort", cancel);
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
openHostToolRoute(client, identity, record, entry) {
|
|
2500
|
+
if (!this.hostScopes ||
|
|
2501
|
+
!["opencode", "pi"].includes(identity.harness) ||
|
|
2502
|
+
identity.session === DEFAULT_SESSION_ID) {
|
|
2503
|
+
return client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
|
|
2504
|
+
consumerIdentity: this.consumerIdentity,
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
return this.openScopedHostToolRoute(client, identity, record, entry);
|
|
2508
|
+
}
|
|
2509
|
+
async openScopedHostToolRoute(client, identity, record, entry) {
|
|
2510
|
+
const scope = await this.hostScope(client, identity);
|
|
2511
|
+
this.assertRecordLive(record);
|
|
2512
|
+
if (scope) {
|
|
2513
|
+
try {
|
|
2514
|
+
const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, { consumerIdentity: this.consumerIdentity, scope });
|
|
2515
|
+
if (entry)
|
|
2516
|
+
entry.hostScope = scope;
|
|
2517
|
+
return route;
|
|
2518
|
+
}
|
|
2519
|
+
catch (error) {
|
|
2520
|
+
if (!(error instanceof SubcError) || !error.code || isSocketDeathError(error))
|
|
2521
|
+
throw error;
|
|
2522
|
+
// A restarting provider has not rejected the scope. Keep it for the normal reopen retry.
|
|
2523
|
+
if (isRouteOpenReloadWindowError(error))
|
|
2524
|
+
throw error;
|
|
2525
|
+
this.assertRecordLive(record);
|
|
2526
|
+
const state = this.hostScopeCache.get(identity.session);
|
|
2527
|
+
if (state && isHostScopeSyncRefusal(error.code)) {
|
|
2528
|
+
this.deferHostScopeBind(identity.session, state, error.code);
|
|
2529
|
+
}
|
|
2530
|
+
else {
|
|
2531
|
+
this.refuseHostScopeBind(identity.session, state, error.code);
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
this.assertRecordLive(record);
|
|
2536
|
+
const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
|
|
2537
|
+
consumerIdentity: this.consumerIdentity,
|
|
2538
|
+
});
|
|
2539
|
+
// Scope recovery can finish while a fallback bind waits for the provider, including bg_events.
|
|
2540
|
+
if (this.hostScopeCache.get(identity.session)?.answer?.result === "scope" &&
|
|
2541
|
+
!this.hostScopeCache.get(identity.session)?.scopeBindPending &&
|
|
2542
|
+
!this.hostScopeRefusals.has(identity.session)) {
|
|
2543
|
+
safeCloseRoute(client, route);
|
|
2544
|
+
return this.openScopedHostToolRoute(client, identity, record, entry);
|
|
2545
|
+
}
|
|
2546
|
+
return route;
|
|
2547
|
+
}
|
|
2548
|
+
invalidateHostScope(session) {
|
|
2549
|
+
const state = this.hostScopeCache.get(session);
|
|
2550
|
+
if (state) {
|
|
2551
|
+
if (state.scopeBindPending)
|
|
2552
|
+
log(`subc head scope: session=${session} retry stopped reason=scope_route_closed`);
|
|
2553
|
+
this.cancelHostScopeRetry(state);
|
|
2554
|
+
}
|
|
2555
|
+
this.hostScopeCache.delete(session);
|
|
2556
|
+
this.hostScopeRefusals.delete(session);
|
|
2557
|
+
for (const record of this.sessions.values()) {
|
|
2558
|
+
if (record.identity.session !== session || !record.routeEntry)
|
|
2559
|
+
continue;
|
|
2560
|
+
this.retireHostToolRoute(record);
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
onScopeRouteClose(push) {
|
|
2564
|
+
if (push.op !== "route.closed")
|
|
2565
|
+
return;
|
|
2566
|
+
const reason = routeCloseReason(push);
|
|
2567
|
+
if (!reason?.startsWith("scope_")) {
|
|
2568
|
+
for (const record of this.sessions.values()) {
|
|
2569
|
+
const route = record.routeEntry?.handle;
|
|
2570
|
+
if (!route)
|
|
2571
|
+
continue;
|
|
2572
|
+
if ((Array.isArray(push.body.channels) && push.body.channels.includes(route.channel)) ||
|
|
2573
|
+
(!Array.isArray(push.body.channels) && push.body.module_id === AFT_MODULE_ID))
|
|
2574
|
+
this.retireHostToolRoute(record);
|
|
2575
|
+
}
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
const sessions = new Set();
|
|
2579
|
+
for (const record of this.sessions.values()) {
|
|
2580
|
+
const routes = [record.routeEntry?.handle, record.bgSub?.attachedRoute].filter((route) => route != null);
|
|
2581
|
+
if ((Array.isArray(push.body.channels) &&
|
|
2582
|
+
routes.some((route) => push.body.channels.includes(route.channel))) ||
|
|
2583
|
+
(!Array.isArray(push.body.channels) && push.body.module_id === AFT_MODULE_ID))
|
|
2584
|
+
sessions.add(record.identity.session);
|
|
2585
|
+
}
|
|
2586
|
+
for (const session of sessions)
|
|
2587
|
+
this.invalidateHostScope(session);
|
|
2588
|
+
}
|
|
2070
2589
|
ensureBgSubscription(identity, record) {
|
|
2071
2590
|
if (this.shuttingDown || (!this.onBgEventsNudge && !this.onBgEventsNudgeRef))
|
|
2072
2591
|
return;
|
|
@@ -2106,18 +2625,32 @@ export class SubcTransportPool {
|
|
|
2106
2625
|
return;
|
|
2107
2626
|
this.logNudgeDelivery("drop-no-compatible-handler", currentRecord, `nudge dropped cause=generation-provenance-unavailable-and-root-session-handler-unwired root=${currentRecord.canonicalRoot}`);
|
|
2108
2627
|
};
|
|
2628
|
+
const binding = {};
|
|
2109
2629
|
let sub = null;
|
|
2110
2630
|
const clearDormantSubscription = () => {
|
|
2111
2631
|
if (sub && record.bgSub === sub)
|
|
2112
2632
|
record.bgSub = null;
|
|
2113
2633
|
};
|
|
2114
|
-
sub = new BgSubscription(identity, () => this.ensureClient(), (client, cause) => this.dropClient(client, cause),
|
|
2634
|
+
sub = new BgSubscription(identity, () => this.ensureClient(), (client, cause) => this.dropClient(client, cause), (client) => {
|
|
2635
|
+
binding.hostScope = undefined;
|
|
2636
|
+
return this.openHostToolRoute(client, identity, record, binding);
|
|
2637
|
+
}, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
|
|
2115
2638
|
if (!isAbsentRootRouteError(error))
|
|
2116
2639
|
return false;
|
|
2117
2640
|
this.markRootDormant(record.canonicalRoot, error);
|
|
2118
2641
|
return true;
|
|
2119
2642
|
}, 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))
|
|
2643
|
+
this.isCurrentLiveGeneration(record.canonicalRoot, record.generation), (client) => this.noteClientAlive(client), (client, cause) => this.noteUnansweredCall(client, cause), () => {
|
|
2644
|
+
const state = this.hostScopeCache.get(identity.session);
|
|
2645
|
+
if (state)
|
|
2646
|
+
this.scheduleHostScopeRetry(identity.session, state);
|
|
2647
|
+
if (state?.answer?.result === "scope" &&
|
|
2648
|
+
!state.scopeBindPending &&
|
|
2649
|
+
!binding.hostScope &&
|
|
2650
|
+
!this.hostScopeRefusals.has(identity.session)) {
|
|
2651
|
+
this.promoteHostScope(identity.session);
|
|
2652
|
+
}
|
|
2653
|
+
});
|
|
2121
2654
|
record.bgSub = sub;
|
|
2122
2655
|
if (!this.rootCanAttach(record.canonicalRoot)) {
|
|
2123
2656
|
record.bgSub = null;
|
|
@@ -2166,6 +2699,8 @@ export class SubcTransportPool {
|
|
|
2166
2699
|
record.routeEntry = null;
|
|
2167
2700
|
this.deleteSessionIfEmpty(record.identityKey, record);
|
|
2168
2701
|
}
|
|
2702
|
+
for (const session of this.hostScopeCache.keys())
|
|
2703
|
+
this.stopUnusedHostScopeRetry(session);
|
|
2169
2704
|
this.transportFailures = 0;
|
|
2170
2705
|
try {
|
|
2171
2706
|
client.close();
|
|
@@ -2337,6 +2872,13 @@ export class SubcTransportPool {
|
|
|
2337
2872
|
this.generationRejections.clear();
|
|
2338
2873
|
this.observedStarts.clear();
|
|
2339
2874
|
this.dormantRoots.clear();
|
|
2875
|
+
const scopeLookups = [...this.hostScopeCache.values()].flatMap((state) => {
|
|
2876
|
+
this.cancelHostScopeRetry(state);
|
|
2877
|
+
return state.pending ? [state.pending] : [];
|
|
2878
|
+
});
|
|
2879
|
+
this.hostScopeCache.clear();
|
|
2880
|
+
this.hostScopeRefusals.clear();
|
|
2881
|
+
this.hostScopeOutcomes.clear();
|
|
2340
2882
|
// Deregistration synchronously tombstones all registered roots and invokes
|
|
2341
2883
|
// closeProjectRoot before this method reaches its first await.
|
|
2342
2884
|
const registration = this.lifecycleRegistration;
|
|
@@ -2349,11 +2891,16 @@ export class SubcTransportPool {
|
|
|
2349
2891
|
detached.push(owner);
|
|
2350
2892
|
}
|
|
2351
2893
|
this.rootIndex.clear();
|
|
2894
|
+
this.sessionIndex.clear();
|
|
2352
2895
|
this.transports.clear();
|
|
2353
2896
|
const client = this.client;
|
|
2354
2897
|
this.client = null;
|
|
2355
2898
|
const pending = [...this.pendingRootCleanups];
|
|
2356
|
-
await Promise.allSettled([
|
|
2899
|
+
await Promise.allSettled([
|
|
2900
|
+
...detached.map((owner) => this.cleanupDetached(owner)),
|
|
2901
|
+
...pending,
|
|
2902
|
+
...scopeLookups,
|
|
2903
|
+
]);
|
|
2357
2904
|
if (client) {
|
|
2358
2905
|
try {
|
|
2359
2906
|
client.close();
|
|
@@ -2370,6 +2917,13 @@ export class SubcTransportPool {
|
|
|
2370
2917
|
session: session && session.length > 0 ? session : DEFAULT_SESSION_ID,
|
|
2371
2918
|
};
|
|
2372
2919
|
const owner = this.detachSession(identityKey(identity), "session_closed");
|
|
2920
|
+
if (!this.sessionIndex.has(identity.session)) {
|
|
2921
|
+
const state = this.hostScopeCache.get(identity.session);
|
|
2922
|
+
if (state)
|
|
2923
|
+
this.cancelHostScopeRetry(state);
|
|
2924
|
+
this.hostScopeCache.delete(identity.session);
|
|
2925
|
+
this.hostScopeRefusals.delete(identity.session);
|
|
2926
|
+
}
|
|
2373
2927
|
if (owner)
|
|
2374
2928
|
await this.cleanupDetached(owner);
|
|
2375
2929
|
}
|