@cortexkit/aft-bridge 0.49.3 → 0.50.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/bridge.d.ts +7 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +66 -4
- package/dist/bridge.js.map +1 -1
- package/dist/cache-paths.d.ts +26 -0
- package/dist/cache-paths.d.ts.map +1 -0
- package/dist/cache-paths.js +37 -0
- package/dist/cache-paths.js.map +1 -0
- package/dist/downloader.d.ts +2 -3
- package/dist/downloader.d.ts.map +1 -1
- package/dist/downloader.js +5 -13
- package/dist/downloader.js.map +1 -1
- package/dist/durable-log.d.ts +3 -1
- package/dist/durable-log.d.ts.map +1 -1
- package/dist/durable-log.js +4 -2
- package/dist/durable-log.js.map +1 -1
- package/dist/error-contract.d.ts +20 -0
- package/dist/error-contract.d.ts.map +1 -1
- package/dist/error-contract.js +84 -0
- package/dist/error-contract.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/lifecycle-registry.d.ts +196 -0
- package/dist/lifecycle-registry.d.ts.map +1 -0
- package/dist/lifecycle-registry.js +471 -0
- package/dist/lifecycle-registry.js.map +1 -0
- package/dist/pool.d.ts +7 -7
- package/dist/pool.d.ts.map +1 -1
- package/dist/pool.js +36 -9
- package/dist/pool.js.map +1 -1
- package/dist/resolver.d.ts.map +1 -1
- package/dist/resolver.js +4 -11
- package/dist/resolver.js.map +1 -1
- package/dist/revivable-transport.d.ts +11 -0
- package/dist/revivable-transport.d.ts.map +1 -1
- package/dist/revivable-transport.js +44 -2
- package/dist/revivable-transport.js.map +1 -1
- package/dist/subc-transport.d.ts +220 -45
- package/dist/subc-transport.d.ts.map +1 -1
- package/dist/subc-transport.js +1025 -319
- package/dist/subc-transport.js.map +1 -1
- package/dist/transport-factory.d.ts +19 -0
- package/dist/transport-factory.d.ts.map +1 -1
- package/dist/transport-factory.js +71 -2
- package/dist/transport-factory.js.map +1 -1
- package/package.json +1 -1
package/dist/subc-transport.js
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
* top of it. subc-client is a build-time path dependency bundled into the
|
|
17
17
|
* published plugin dist; it is never a published runtime dependency.
|
|
18
18
|
*/
|
|
19
|
+
import { existsSync, statSync } from "node:fs";
|
|
19
20
|
import { connectionFileExists, isConsumerReconnectTransient, StaleRouteHandleError, SubcCallError, SubcClient, SubcError, } from "@cortexkit/subc-client";
|
|
21
|
+
import { log } from "./active-logger.js";
|
|
22
|
+
import { asCanonicalRootPath, asRootGeneration, LifecycleRegistry, } from "./lifecycle-registry.js";
|
|
20
23
|
import { canonicalizeProjectRoot } from "./project-identity.js";
|
|
21
24
|
import { parseStatusBarCounts } from "./status-bar.js";
|
|
22
25
|
/** The subc module id AFT registers under (matches the daemon manifest). */
|
|
@@ -37,6 +40,8 @@ const MAX_CONSECUTIVE_TRANSPORT_FAILURES = 3;
|
|
|
37
40
|
* (otherwise a permanently-failing route resubscribes in a 100ms hot loop). (B-#2.)
|
|
38
41
|
*/
|
|
39
42
|
const BG_STABLE_MS = 5_000;
|
|
43
|
+
const BG_LIFECYCLE_LOG_INTERVAL_MS = 60_000;
|
|
44
|
+
const BG_DISPATCH_PROBE_INTERVAL_MS = 60_000;
|
|
40
45
|
/**
|
|
41
46
|
* Session fallback when a tool runtime carries no session id, mirroring the Rust
|
|
42
47
|
* `DEFAULT_SESSION_ID` (`protocol.rs`). Keeps undo/checkpoint/bash namespacing
|
|
@@ -51,23 +56,62 @@ const DEFAULT_SESSION_ID = "__default__";
|
|
|
51
56
|
* with a synthetic success rather than a route call.
|
|
52
57
|
*/
|
|
53
58
|
const LOCALLY_SATISFIED_COMMANDS = new Set(["configure"]);
|
|
59
|
+
/** Stable classification for a request that loses its root to coordinated reap. */
|
|
60
|
+
export class SubcRootReapedError extends Error {
|
|
61
|
+
code = "root_reaped";
|
|
62
|
+
name = "SubcRootReapedError";
|
|
63
|
+
canonicalRoot;
|
|
64
|
+
expectedGeneration;
|
|
65
|
+
currentGeneration;
|
|
66
|
+
concretePoolId;
|
|
67
|
+
currentConcretePoolId;
|
|
68
|
+
constructor(fields) {
|
|
69
|
+
super(`subc root was reaped: ${fields.canonicalRoot}`);
|
|
70
|
+
this.canonicalRoot = fields.canonicalRoot;
|
|
71
|
+
this.expectedGeneration = fields.expectedGeneration;
|
|
72
|
+
this.currentGeneration = fields.currentGeneration;
|
|
73
|
+
this.concretePoolId = fields.concretePoolId;
|
|
74
|
+
this.currentConcretePoolId = fields.currentConcretePoolId;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Stable classification for an operation holding an older root generation. */
|
|
78
|
+
export class SubcRootGenerationExpiredError extends Error {
|
|
79
|
+
code = "root_generation_expired";
|
|
80
|
+
name = "SubcRootGenerationExpiredError";
|
|
81
|
+
canonicalRoot;
|
|
82
|
+
expectedGeneration;
|
|
83
|
+
currentGeneration;
|
|
84
|
+
concretePoolId;
|
|
85
|
+
currentConcretePoolId;
|
|
86
|
+
constructor(fields) {
|
|
87
|
+
super(`subc root generation expired: ${fields.canonicalRoot}`);
|
|
88
|
+
this.canonicalRoot = fields.canonicalRoot;
|
|
89
|
+
this.expectedGeneration = fields.expectedGeneration;
|
|
90
|
+
this.currentGeneration = fields.currentGeneration;
|
|
91
|
+
this.concretePoolId = fields.concretePoolId;
|
|
92
|
+
this.currentConcretePoolId = fields.currentConcretePoolId;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** A synchronous lifecycle demand check could not establish that the root exists. */
|
|
96
|
+
export class SubcRootDemandRequiredError extends Error {
|
|
97
|
+
code = "root_demand_required";
|
|
98
|
+
canonicalRoot;
|
|
99
|
+
constructor(root) {
|
|
100
|
+
super(`positive live demand is required before creating subc root state: ${root}`);
|
|
101
|
+
this.name = "SubcRootDemandRequiredError";
|
|
102
|
+
this.canonicalRoot = root;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
54
105
|
function identityKey(identity) {
|
|
55
106
|
return `${identity.project_root}\u0000${identity.harness}\u0000${identity.session}`;
|
|
56
107
|
}
|
|
57
108
|
/**
|
|
58
|
-
* One session's held-open bg_events subscription with its
|
|
59
|
-
*
|
|
60
|
-
* The idle-stranding fix (Oracle bg_fc2d4119 #3): the resubscribe loop is
|
|
61
|
-
* INDEPENDENT of tool calls. When the subscription's `closed` promise rejects (a
|
|
62
|
-
* socket drop / route GOODBYE / Error), the loop itself reconnects (via the pool's
|
|
63
|
-
* shared single-flight client) and resubscribes — it never waits for a future tool
|
|
64
|
-
* call. So an idle agent (no tool traffic) whose connection drops is still woken
|
|
65
|
-
* for a completion that landed while disconnected (the durable Rust registry holds
|
|
66
|
-
* it until acked; resubscribe + the immediate forced-drain replay it).
|
|
109
|
+
* One session's held-open bg_events subscription with its own reconnect driver.
|
|
67
110
|
*
|
|
68
|
-
* The loop is
|
|
69
|
-
*
|
|
70
|
-
*
|
|
111
|
+
* The reconnect loop is independent of tool calls, including provider StreamEnd:
|
|
112
|
+
* the module emits StreamEnd when a route is replaced, so only `stop()` proves
|
|
113
|
+
* that a clean end was requested by this consumer. Reopening the dedicated route
|
|
114
|
+
* also forces an outbox drain, recovering completions queued while disconnected.
|
|
71
115
|
*/
|
|
72
116
|
class BgSubscription {
|
|
73
117
|
identity;
|
|
@@ -76,26 +120,37 @@ class BgSubscription {
|
|
|
76
120
|
consumerIdentity;
|
|
77
121
|
onNudge;
|
|
78
122
|
sleep;
|
|
123
|
+
canAttach;
|
|
124
|
+
onRootAttachFailure;
|
|
125
|
+
onDormant;
|
|
126
|
+
dispatchProbeIntervalMs;
|
|
127
|
+
nudgeRef;
|
|
128
|
+
isCurrent;
|
|
79
129
|
stopped = false;
|
|
80
130
|
/** The live subscription handle, read by stop() to wake the loop's `await closed`. */
|
|
81
131
|
current = null;
|
|
82
132
|
loop;
|
|
83
|
-
|
|
133
|
+
lifecycleLogState = new Map();
|
|
134
|
+
nudgeReceiptLogState = null;
|
|
135
|
+
constructor(identity, acquireClient, dropClient, consumerIdentity, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, dispatchProbeIntervalMs, nudgeRef, isCurrent = () => true) {
|
|
84
136
|
this.identity = identity;
|
|
85
137
|
this.acquireClient = acquireClient;
|
|
86
138
|
this.dropClient = dropClient;
|
|
87
139
|
this.consumerIdentity = consumerIdentity;
|
|
88
140
|
this.onNudge = onNudge;
|
|
89
141
|
this.sleep = sleep;
|
|
142
|
+
this.canAttach = canAttach;
|
|
143
|
+
this.onRootAttachFailure = onRootAttachFailure;
|
|
144
|
+
this.onDormant = onDormant;
|
|
145
|
+
this.dispatchProbeIntervalMs = dispatchProbeIntervalMs;
|
|
146
|
+
this.nudgeRef = nudgeRef;
|
|
147
|
+
this.isCurrent = isCurrent;
|
|
90
148
|
this.loop = this.run();
|
|
91
149
|
}
|
|
92
150
|
async stop() {
|
|
93
151
|
this.stopped = true;
|
|
94
|
-
// Wake a live `await sub.closed
|
|
95
|
-
//
|
|
96
|
-
// is closed exactly once, never double-closed by stop() + the loop). If the
|
|
97
|
-
// loop is between routeOpen and subscribe, its post-subscribe `stopped`
|
|
98
|
-
// re-check (or the pre-subscribe check) closes/returns instead.
|
|
152
|
+
// Wake a live `await sub.closed`; the loop's `finally` remains the sole
|
|
153
|
+
// owner of closeRouteChannel, avoiding a double close from stop() + run().
|
|
99
154
|
const sub = this.current;
|
|
100
155
|
if (sub) {
|
|
101
156
|
try {
|
|
@@ -107,84 +162,200 @@ class BgSubscription {
|
|
|
107
162
|
}
|
|
108
163
|
await this.loop.catch(() => undefined);
|
|
109
164
|
}
|
|
165
|
+
info(kind, message) {
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
const state = this.lifecycleLogState.get(kind);
|
|
168
|
+
if (state && now - state.lastEmittedAt < BG_LIFECYCLE_LOG_INTERVAL_MS) {
|
|
169
|
+
state.suppressed += 1;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const suppressed = state?.suppressed ?? 0;
|
|
173
|
+
this.lifecycleLogState.set(kind, { lastEmittedAt: now, suppressed: 0 });
|
|
174
|
+
const suffix = suppressed > 0 ? ` suppressed=${suppressed}` : "";
|
|
175
|
+
log(`subc bg_events: ${message}${suffix}`, { sessionId: this.identity.session });
|
|
176
|
+
}
|
|
177
|
+
routeId(route) {
|
|
178
|
+
return `${route.channel}@${route.epoch}`;
|
|
179
|
+
}
|
|
180
|
+
recordNudgeReceipt(routeId) {
|
|
181
|
+
const now = Date.now();
|
|
182
|
+
const state = this.nudgeReceiptLogState;
|
|
183
|
+
if (state && now - state.lastEmittedAt < BG_LIFECYCLE_LOG_INTERVAL_MS) {
|
|
184
|
+
state.count += 1;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const count = (state?.count ?? 0) + 1;
|
|
188
|
+
this.nudgeReceiptLogState = { lastEmittedAt: now, count: 0 };
|
|
189
|
+
log(`subc bg_events: nudge received channel=${routeId} count=${count}`, {
|
|
190
|
+
sessionId: this.identity.session,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
errorText(error) {
|
|
194
|
+
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
195
|
+
}
|
|
196
|
+
startDispatchProbe(client, routeId) {
|
|
197
|
+
const initial = client.droppedIngressFrames;
|
|
198
|
+
if (typeof initial !== "number")
|
|
199
|
+
return () => undefined;
|
|
200
|
+
let previous = initial;
|
|
201
|
+
const timer = setInterval(() => {
|
|
202
|
+
const total = client.droppedIngressFrames;
|
|
203
|
+
if (typeof total !== "number" || total <= previous)
|
|
204
|
+
return;
|
|
205
|
+
const delta = total - previous;
|
|
206
|
+
previous = total;
|
|
207
|
+
this.info("dispatch-epoch-drop", `client ingress epoch drops scope=client observed_while_channel=${routeId} delta=${delta} total=${total}`);
|
|
208
|
+
}, this.dispatchProbeIntervalMs);
|
|
209
|
+
timer.unref?.();
|
|
210
|
+
return () => clearInterval(timer);
|
|
211
|
+
}
|
|
110
212
|
async run() {
|
|
111
|
-
let
|
|
213
|
+
let backoffAttempt = 0;
|
|
214
|
+
let reconnectAttempt = 0;
|
|
215
|
+
let reconnecting = false;
|
|
216
|
+
const beginReconnect = () => {
|
|
217
|
+
reconnecting = true;
|
|
218
|
+
reconnectAttempt = reconnectAttempt === 0 ? 1 : reconnectAttempt + 1;
|
|
219
|
+
};
|
|
220
|
+
const giveUp = (reason) => {
|
|
221
|
+
this.info("reconnect-gave-up", `reconnect gave-up attempt=${reconnectAttempt} reason=${reason}`);
|
|
222
|
+
};
|
|
112
223
|
while (!this.stopped) {
|
|
224
|
+
if (!this.isCurrent()) {
|
|
225
|
+
if (reconnecting)
|
|
226
|
+
giveUp("stale-session");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!this.canAttach()) {
|
|
230
|
+
if (reconnecting)
|
|
231
|
+
giveUp("root-dormant");
|
|
232
|
+
this.onDormant();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (reconnecting) {
|
|
236
|
+
this.info("reconnect-attempt", `reconnect attempt=${reconnectAttempt}`);
|
|
237
|
+
}
|
|
113
238
|
let client;
|
|
114
239
|
try {
|
|
115
240
|
client = await this.acquireClient();
|
|
116
241
|
}
|
|
117
|
-
catch {
|
|
118
|
-
|
|
242
|
+
catch (err) {
|
|
243
|
+
if (!reconnecting)
|
|
244
|
+
beginReconnect();
|
|
245
|
+
this.info("reconnect-error", `reconnect error attempt=${reconnectAttempt} error=${this.errorText(err)}`);
|
|
246
|
+
await this.backoff(backoffAttempt++);
|
|
247
|
+
if (reconnecting)
|
|
248
|
+
reconnectAttempt += 1;
|
|
119
249
|
continue;
|
|
120
250
|
}
|
|
121
|
-
if (this.stopped)
|
|
251
|
+
if (this.stopped) {
|
|
252
|
+
if (reconnecting)
|
|
253
|
+
giveUp("stopped");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (!this.isCurrent()) {
|
|
257
|
+
if (reconnecting)
|
|
258
|
+
giveUp("stale-session");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (!this.canAttach()) {
|
|
262
|
+
if (reconnecting)
|
|
263
|
+
giveUp("root-dormant");
|
|
264
|
+
this.onDormant();
|
|
122
265
|
return;
|
|
266
|
+
}
|
|
123
267
|
let route;
|
|
124
268
|
try {
|
|
125
|
-
// A
|
|
269
|
+
// A dedicated route keeps bg_events independent of the tool route's
|
|
126
270
|
// credit window while preserving the client's connection-bound handle.
|
|
127
271
|
route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, this.identity, { consumerIdentity: this.consumerIdentity });
|
|
128
272
|
}
|
|
129
273
|
catch (err) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
274
|
+
if (this.isCurrent() && this.onRootAttachFailure(err)) {
|
|
275
|
+
if (reconnecting)
|
|
276
|
+
giveUp("root-dormant");
|
|
277
|
+
this.onDormant();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
134
280
|
if (isConsumerReconnectTransient(err))
|
|
135
281
|
this.dropClient(client);
|
|
136
|
-
|
|
282
|
+
if (!reconnecting)
|
|
283
|
+
beginReconnect();
|
|
284
|
+
this.info("reconnect-error", `reconnect error attempt=${reconnectAttempt} error=${this.errorText(err)}`);
|
|
285
|
+
await this.backoff(backoffAttempt++);
|
|
286
|
+
if (reconnecting)
|
|
287
|
+
reconnectAttempt += 1;
|
|
137
288
|
continue;
|
|
138
289
|
}
|
|
139
|
-
if (this.stopped) {
|
|
290
|
+
if (this.stopped || !this.isCurrent()) {
|
|
140
291
|
safeCloseRoute(client, route);
|
|
292
|
+
if (reconnecting)
|
|
293
|
+
giveUp(this.stopped ? "stopped" : "stale-session");
|
|
141
294
|
return;
|
|
142
295
|
}
|
|
143
|
-
// Route lifetime: the `finally` guarantees closeRouteChannel on every exit
|
|
144
|
-
// path from here (StreamEnd return, drop+resubscribe, stopped) so the
|
|
145
|
-
// dedicated route never leaks (B-#2).
|
|
146
296
|
const subscribedAt = Date.now();
|
|
297
|
+
const routeId = this.routeId(route);
|
|
298
|
+
let stopDispatchProbe = () => undefined;
|
|
147
299
|
try {
|
|
148
300
|
const sub = client.subscribe(route, { op: "bg_events" }, () => {
|
|
149
|
-
if (
|
|
150
|
-
this.
|
|
301
|
+
if (this.stopped) {
|
|
302
|
+
this.info("nudge-drop-stopped", `nudge dropped cause=subscription-stopped channel=${routeId}`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
this.recordNudgeReceipt(routeId);
|
|
306
|
+
if (!this.isCurrent()) {
|
|
307
|
+
this.info("nudge-stale-carrier", `nudge carried by stale subscription; checking current session channel=${routeId}`);
|
|
308
|
+
}
|
|
309
|
+
this.onNudge();
|
|
151
310
|
});
|
|
152
311
|
this.current = sub;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
312
|
+
stopDispatchProbe = this.startDispatchProbe(client, routeId);
|
|
313
|
+
this.info("subscription-open", `subscription open channel=${routeId}`);
|
|
314
|
+
if (reconnecting) {
|
|
315
|
+
this.info("reconnect-success", `reconnect success attempt=${reconnectAttempt} channel=${routeId}`);
|
|
316
|
+
reconnecting = false;
|
|
317
|
+
reconnectAttempt = 0;
|
|
318
|
+
}
|
|
319
|
+
// stop() may have fired between routeOpen and subscribe. Unsubscribe so
|
|
320
|
+
// the held `closed` promise resolves and the route can unwind.
|
|
156
321
|
if (this.stopped)
|
|
157
322
|
sub.unsubscribe();
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
if (!this.stopped)
|
|
323
|
+
// A reconnect may have missed completions, so force a drain after every
|
|
324
|
+
// successful subscribe to replay anything queued while the stream was down.
|
|
325
|
+
if (!this.stopped && this.isCurrent())
|
|
161
326
|
this.onNudge();
|
|
162
327
|
await sub.closed;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
328
|
+
this.info("stream-end", `stream ended channel=${routeId}`);
|
|
329
|
+
if (this.stopped) {
|
|
330
|
+
giveUp("stopped");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// Provider StreamEnd is also emitted for route replacement; without an
|
|
334
|
+
// explicit local stop it must reopen rather than strand an idle session.
|
|
335
|
+
beginReconnect();
|
|
166
336
|
}
|
|
167
337
|
catch (err) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (this.stopped)
|
|
338
|
+
const routeId = this.routeId(route);
|
|
339
|
+
this.info("stream-error", `stream error channel=${routeId} error=${this.errorText(err)}`);
|
|
340
|
+
if (this.stopped) {
|
|
341
|
+
giveUp("stopped");
|
|
171
342
|
return;
|
|
172
|
-
|
|
173
|
-
// GOODBYE keeps the client and just re-opens a fresh route.
|
|
343
|
+
}
|
|
174
344
|
if (isConsumerReconnectTransient(err))
|
|
175
345
|
this.dropClient(client);
|
|
176
|
-
// Reset backoff ONLY if the subscription was stable before dropping;
|
|
177
|
-
// otherwise a permanently-failing route would resubscribe in a 100ms hot
|
|
178
|
-
// loop and never reach the cap (B-#2).
|
|
179
346
|
if (Date.now() - subscribedAt >= BG_STABLE_MS)
|
|
180
|
-
|
|
347
|
+
backoffAttempt = 0;
|
|
348
|
+
beginReconnect();
|
|
181
349
|
}
|
|
182
350
|
finally {
|
|
351
|
+
stopDispatchProbe();
|
|
183
352
|
this.current = null;
|
|
184
353
|
safeCloseRoute(client, route);
|
|
185
354
|
}
|
|
186
|
-
await this.backoff(
|
|
355
|
+
await this.backoff(backoffAttempt++);
|
|
187
356
|
}
|
|
357
|
+
if (reconnecting)
|
|
358
|
+
giveUp("stopped");
|
|
188
359
|
}
|
|
189
360
|
async backoff(attempt) {
|
|
190
361
|
const ms = Math.min(100 * 2 ** Math.min(attempt, 6), 2000);
|
|
@@ -198,6 +369,20 @@ function isRouteProvenAbsentError(err) {
|
|
|
198
369
|
return ((err instanceof SubcError && err.code === "unknown_channel") ||
|
|
199
370
|
err instanceof StaleRouteHandleError);
|
|
200
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* A daemon can reject a route bind after the root has been reclaimed. This
|
|
374
|
+
* signature is deliberately narrower than config_divergence: other divergence
|
|
375
|
+
* causes remain transient from the transport's point of view.
|
|
376
|
+
*/
|
|
377
|
+
function isAbsentRootRouteError(err) {
|
|
378
|
+
return (isRecord(err) &&
|
|
379
|
+
err.code === "config_divergence" &&
|
|
380
|
+
typeof err.message === "string" &&
|
|
381
|
+
err.message.includes("project root does not exist"));
|
|
382
|
+
}
|
|
383
|
+
function absentRootError(root) {
|
|
384
|
+
return new SubcError(`invalid route project root: project root does not exist: ${root}`, "config_divergence");
|
|
385
|
+
}
|
|
201
386
|
/**
|
|
202
387
|
* Fire-and-forget route close that can never throw — neither synchronously (a
|
|
203
388
|
* client that rejects/throws when closing a route on an already-dead socket) nor
|
|
@@ -227,20 +412,33 @@ class RouteTornDownError extends Error {
|
|
|
227
412
|
* `structuredContent`, so re-lifting it makes everything downstream (status_bar,
|
|
228
413
|
* bg_completions, preview_diff, code, …) byte-identical to NDJSON.
|
|
229
414
|
*
|
|
230
|
-
* Every AFT tool reply over subc carries this envelope with a boolean `success
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
415
|
+
* Every AFT tool reply over subc carries this envelope with a boolean `success`.
|
|
416
|
+
* During the wire transition, `structuredContent.text` may be omitted; the bridge
|
|
417
|
+
* synthesizes it only from exactly one outer text block, while a present field wins
|
|
418
|
+
* unchanged. A reply missing the envelope, lacking boolean `success`, or missing
|
|
419
|
+
* text without an unambiguous outer block is a PROTOCOL VIOLATION — never a tool
|
|
420
|
+
* result — and is thrown rather than coerced. Coercing it (the old
|
|
421
|
+
* `{success:false,text:""}` / raw-record fallback) could let a malformed reply with
|
|
422
|
+
* `success === undefined` read downstream as a successful tool result (audit B-#7).
|
|
423
|
+
* Surfacing it loudly is the honest contract: a broken wire shape is a failure, not
|
|
424
|
+
* a silent empty pass.
|
|
237
425
|
*/
|
|
238
426
|
function reliftReply(reply) {
|
|
239
427
|
if (!isRecord(reply) || !isRecord(reply.structuredContent)) {
|
|
240
428
|
throw new Error("subc tool reply is missing the structuredContent envelope (protocol violation)");
|
|
241
429
|
}
|
|
242
430
|
const flat = reply.structuredContent;
|
|
243
|
-
if (typeof flat.success !== "boolean"
|
|
431
|
+
if (typeof flat.success !== "boolean") {
|
|
432
|
+
throw new Error("subc tool reply structuredContent lacks a boolean `success` / string `text` (protocol violation)");
|
|
433
|
+
}
|
|
434
|
+
if (!Object.hasOwn(flat, "text")) {
|
|
435
|
+
const content = reply.content;
|
|
436
|
+
const block = Array.isArray(content) && content.length === 1 ? content[0] : undefined;
|
|
437
|
+
if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
|
|
438
|
+
return { ...flat, text: block.text };
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (typeof flat.text !== "string") {
|
|
244
442
|
throw new Error("subc tool reply structuredContent lacks a boolean `success` / string `text` (protocol violation)");
|
|
245
443
|
}
|
|
246
444
|
return flat;
|
|
@@ -253,15 +451,24 @@ function reliftReply(reply) {
|
|
|
253
451
|
class SubcTransport {
|
|
254
452
|
pool;
|
|
255
453
|
projectRoot;
|
|
454
|
+
generation;
|
|
256
455
|
lastStatusBar;
|
|
257
456
|
cachedStatus = null;
|
|
258
|
-
constructor(pool, projectRoot) {
|
|
457
|
+
constructor(pool, projectRoot, generation) {
|
|
259
458
|
this.pool = pool;
|
|
260
459
|
this.projectRoot = projectRoot;
|
|
460
|
+
this.generation = generation;
|
|
261
461
|
}
|
|
262
462
|
getCwd() {
|
|
263
463
|
return this.projectRoot;
|
|
264
464
|
}
|
|
465
|
+
/** Generation provenance is intentionally observable for lifecycle tests and nudge wiring. */
|
|
466
|
+
getGeneration() {
|
|
467
|
+
return this.generation;
|
|
468
|
+
}
|
|
469
|
+
getConcretePoolId() {
|
|
470
|
+
return this.pool.getConcretePoolId();
|
|
471
|
+
}
|
|
265
472
|
getStatusBar() {
|
|
266
473
|
return this.lastStatusBar;
|
|
267
474
|
}
|
|
@@ -283,12 +490,19 @@ class SubcTransport {
|
|
|
283
490
|
session: session && session.length > 0 ? session : DEFAULT_SESSION_ID,
|
|
284
491
|
};
|
|
285
492
|
}
|
|
493
|
+
assertCurrent() {
|
|
494
|
+
this.pool.assertFacadeCurrent(this.projectRoot, this.generation, "facade_use");
|
|
495
|
+
}
|
|
286
496
|
async toolCall(sessionId, name, rawArgs = {}, options) {
|
|
497
|
+
this.assertCurrent();
|
|
287
498
|
const { preview, timeoutMs, onProgress } = this.splitOptions(options);
|
|
288
499
|
const body = { name, arguments: rawArgs };
|
|
500
|
+
const editSlotSurvives = this.pool.getEditSlotSurvives();
|
|
501
|
+
if (editSlotSurvives !== undefined)
|
|
502
|
+
body.edit_slot_survives = editSlotSurvives;
|
|
289
503
|
if (preview === true)
|
|
290
504
|
body.preview = true;
|
|
291
|
-
const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress);
|
|
505
|
+
const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress, this.generation);
|
|
292
506
|
const result = reliftReply(reply);
|
|
293
507
|
this.captureStatusBar(result);
|
|
294
508
|
return result;
|
|
@@ -304,12 +518,17 @@ class SubcTransport {
|
|
|
304
518
|
* what scopes it. `configure` is satisfied locally (binding is the configure).
|
|
305
519
|
*/
|
|
306
520
|
async send(command, params = {}, options) {
|
|
521
|
+
this.assertCurrent();
|
|
307
522
|
if (LOCALLY_SATISFIED_COMMANDS.has(command)) {
|
|
308
523
|
return { success: true, command, subc_local: true };
|
|
309
524
|
}
|
|
310
525
|
const { timeoutMs, onProgress } = this.splitOptions(options);
|
|
311
526
|
const session = typeof params.session_id === "string" ? params.session_id : undefined;
|
|
312
|
-
const
|
|
527
|
+
const body = { name: command, arguments: params };
|
|
528
|
+
const editSlotSurvives = this.pool.getEditSlotSurvives();
|
|
529
|
+
if (editSlotSurvives !== undefined)
|
|
530
|
+
body.edit_slot_survives = editSlotSurvives;
|
|
531
|
+
const reply = await this.pool.routeRequest(this.identityFor(session), body, timeoutMs, onProgress, this.generation);
|
|
313
532
|
const response = reliftReply(reply);
|
|
314
533
|
this.captureStatusBar(response);
|
|
315
534
|
return response;
|
|
@@ -328,9 +547,9 @@ class SubcTransport {
|
|
|
328
547
|
}
|
|
329
548
|
}
|
|
330
549
|
/**
|
|
331
|
-
* Route cache over one authenticated subc client.
|
|
332
|
-
*
|
|
333
|
-
*
|
|
550
|
+
* Route cache over one authenticated subc client. In lifecycle mode this class
|
|
551
|
+
* also owns the concrete pool's root index; the registry owns timer and root
|
|
552
|
+
* transition state, while this class owns sessions, routes, and subscriptions.
|
|
334
553
|
*/
|
|
335
554
|
export class SubcTransportPool {
|
|
336
555
|
harness;
|
|
@@ -339,22 +558,38 @@ export class SubcTransportPool {
|
|
|
339
558
|
consumerIdentity;
|
|
340
559
|
connectFn;
|
|
341
560
|
onBgEventsNudge;
|
|
561
|
+
onBgEventsNudgeRef;
|
|
342
562
|
bgBackoffSleep;
|
|
563
|
+
bgDispatchProbeIntervalMs;
|
|
564
|
+
lifecycleDemandCheck;
|
|
565
|
+
onLifecycleEvent;
|
|
566
|
+
onBgNudgeRejected;
|
|
567
|
+
lifecycleRegistry;
|
|
568
|
+
registryUsesPoolEventSink = false;
|
|
569
|
+
lifecycleRegistration = null;
|
|
570
|
+
outerFacadeEvictor;
|
|
343
571
|
client = null;
|
|
344
572
|
/** Single-flight guard so concurrent first calls share one connect. */
|
|
345
573
|
connecting = null;
|
|
346
|
-
/** Per-session
|
|
574
|
+
/** Per-session records keyed by the opaque identity key. */
|
|
347
575
|
sessions = new Map();
|
|
348
576
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
* at {@link MAX_CONSECUTIVE_TRANSPORT_FAILURES} to recover a half-open socket
|
|
352
|
-
* whose timeouts never classify transient (B-#4).
|
|
577
|
+
* The sole root-scoped session enumeration authority. Keys are opaque and are
|
|
578
|
+
* removed by the same detacher that removes the corresponding session record.
|
|
353
579
|
*/
|
|
580
|
+
rootIndex = new Map();
|
|
581
|
+
/** Roots whose route binds must stay dormant until their directories return. */
|
|
582
|
+
dormantRoots = new Map();
|
|
583
|
+
/** Consecutive non-transient failures on the current pool-local client. */
|
|
354
584
|
transportFailures = 0;
|
|
355
|
-
/**
|
|
585
|
+
/** Concrete per-root facades, including their captured root generation. */
|
|
356
586
|
transports = new Map();
|
|
587
|
+
generationRejections = new Set();
|
|
588
|
+
nudgeDeliveryLogState = new Map();
|
|
589
|
+
pendingRootCleanups = new Set();
|
|
357
590
|
shuttingDown = false;
|
|
591
|
+
editSlotSurvives;
|
|
592
|
+
editSlotSurvivesCaptured = false;
|
|
358
593
|
constructor(options) {
|
|
359
594
|
this.connectionFile = options.connectionFile;
|
|
360
595
|
this.harness = options.harness;
|
|
@@ -362,205 +597,603 @@ export class SubcTransportPool {
|
|
|
362
597
|
this.consumerIdentity = options.consumerIdentity;
|
|
363
598
|
this.connectFn = options.connect ?? ((opts) => SubcClient.connect(opts));
|
|
364
599
|
this.onBgEventsNudge = options.onBgEventsNudge;
|
|
600
|
+
this.onBgEventsNudgeRef = options.onBgEventsNudgeRef;
|
|
365
601
|
this.bgBackoffSleep =
|
|
366
602
|
options.bgBackoffSleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
603
|
+
this.bgDispatchProbeIntervalMs =
|
|
604
|
+
options.bgDispatchProbeIntervalMs ?? BG_DISPATCH_PROBE_INTERVAL_MS;
|
|
605
|
+
const lifecycle = options.lifecycle;
|
|
606
|
+
const demandCheck = options.lifecycleDemandCheck ?? options.demandCheck ?? lifecycle?.demandCheck;
|
|
607
|
+
this.lifecycleDemandCheck = demandCheck;
|
|
608
|
+
this.onLifecycleEvent = options.onLifecycleEvent ?? lifecycle?.onEvent;
|
|
609
|
+
this.onBgNudgeRejected = options.onBgNudgeRejected;
|
|
610
|
+
this.outerFacadeEvictor =
|
|
611
|
+
options.evictOuterFacade ?? lifecycle?.evictOuterFacade ?? (() => undefined);
|
|
612
|
+
const lifecycleRegistry = options.lifecycleRegistry ?? lifecycle?.registry;
|
|
613
|
+
if (lifecycleRegistry || demandCheck || options.lifecycleRegistration) {
|
|
614
|
+
this.registryUsesPoolEventSink =
|
|
615
|
+
lifecycleRegistry === undefined && this.onLifecycleEvent !== undefined;
|
|
616
|
+
this.lifecycleRegistry =
|
|
617
|
+
lifecycleRegistry ??
|
|
618
|
+
new LifecycleRegistry({
|
|
619
|
+
demandCheck: demandCheck ? (root, poolId) => demandCheck(root, poolId) : undefined,
|
|
620
|
+
onEvent: this.registryUsesPoolEventSink ? this.onLifecycleEvent : undefined,
|
|
621
|
+
});
|
|
622
|
+
if (options.lifecycleRegistration) {
|
|
623
|
+
this.bindLifecycleRegistration(options.lifecycleRegistration);
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
this.bindLifecycleRegistration(this.lifecycleRegistry.registerLifecyclePool(this, {
|
|
627
|
+
reapingEnabled: options.reapingEnabled ?? lifecycle?.reapingEnabled ?? false,
|
|
628
|
+
evictOuterFacade: (root, generation) => this.outerFacadeEvictor(root, generation),
|
|
629
|
+
}));
|
|
630
|
+
}
|
|
631
|
+
}
|
|
367
632
|
}
|
|
368
633
|
/**
|
|
369
|
-
*
|
|
370
|
-
*
|
|
634
|
+
* Bind a wrapper-owned registration after its generation-matched eviction
|
|
635
|
+
* callback has been installed. The handle is the only authority that may
|
|
636
|
+
* deregister this concrete pool.
|
|
371
637
|
*/
|
|
638
|
+
bindLifecycleRegistration(registration) {
|
|
639
|
+
if (this.lifecycleRegistration && this.lifecycleRegistration !== registration) {
|
|
640
|
+
this.lifecycleRegistration.deregister();
|
|
641
|
+
}
|
|
642
|
+
this.lifecycleRegistration = registration;
|
|
643
|
+
}
|
|
644
|
+
/** Construction helper for wrappers that own the registration sequence. */
|
|
645
|
+
registerLifecyclePool(registry, options) {
|
|
646
|
+
this.lifecycleRegistry = registry;
|
|
647
|
+
this.outerFacadeEvictor = options.evictOuterFacade;
|
|
648
|
+
const registration = registry.registerLifecyclePool(this, options);
|
|
649
|
+
this.bindLifecycleRegistration(registration);
|
|
650
|
+
return registration;
|
|
651
|
+
}
|
|
652
|
+
/** Replace only the callback; registration identity and switch stay immutable. */
|
|
653
|
+
setOuterFacadeEvictor(evictOuterFacade) {
|
|
654
|
+
this.outerFacadeEvictor = evictOuterFacade;
|
|
655
|
+
}
|
|
656
|
+
getConcretePoolId() {
|
|
657
|
+
return this.lifecycleRegistration?.concretePoolId;
|
|
658
|
+
}
|
|
659
|
+
getCurrentRootGeneration(root) {
|
|
660
|
+
return this.currentGeneration(root);
|
|
661
|
+
}
|
|
662
|
+
recordBgNudgeRejection(ref) {
|
|
663
|
+
const event = {
|
|
664
|
+
type: "subc_bg_nudge_rejected",
|
|
665
|
+
canonicalRoot: ref.canonicalRoot,
|
|
666
|
+
session: ref.session,
|
|
667
|
+
expectedGeneration: ref.generation,
|
|
668
|
+
currentGeneration: this.currentGeneration(ref.canonicalRoot),
|
|
669
|
+
expectedConcretePoolId: ref.concretePoolId,
|
|
670
|
+
currentConcretePoolId: this.currentPoolId(),
|
|
671
|
+
};
|
|
672
|
+
this.onBgNudgeRejected?.(event);
|
|
673
|
+
}
|
|
674
|
+
getLifecycleRegistration() {
|
|
675
|
+
return this.lifecycleRegistration;
|
|
676
|
+
}
|
|
677
|
+
getLifecycleRegistry() {
|
|
678
|
+
return this.lifecycleRegistry;
|
|
679
|
+
}
|
|
372
680
|
static async connectionAvailable(connectionFile) {
|
|
373
681
|
return connectionFileExists(connectionFile);
|
|
374
682
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
683
|
+
canonicalRoot(projectRoot) {
|
|
684
|
+
return asCanonicalRootPath(canonicalizeProjectRoot(projectRoot));
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Check a root immediately before opening a route. The reclaim marker is only
|
|
688
|
+
* an existence hint; a directory that has already returned wins over a stale
|
|
689
|
+
* sibling marker, so the marker contents are never parsed.
|
|
690
|
+
*/
|
|
691
|
+
rootCanAttach(root) {
|
|
692
|
+
let directoryExists = false;
|
|
693
|
+
try {
|
|
694
|
+
directoryExists = statSync(root).isDirectory();
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
// Missing, inaccessible, or otherwise unusable roots cannot be bound.
|
|
698
|
+
}
|
|
699
|
+
if (directoryExists) {
|
|
700
|
+
this.dormantRoots.delete(root);
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
// Read the marker only as a latency hint. Directory absence is sufficient
|
|
704
|
+
// to suspend the bind, and the marker's JSON is intentionally irrelevant.
|
|
705
|
+
const reclaimedMarkerPresent = existsSync(`${root}.reclaimed`);
|
|
706
|
+
if (reclaimedMarkerPresent) {
|
|
707
|
+
this.markRootDormant(root);
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
this.markRootDormant(root);
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
markRootDormant(root, rejection) {
|
|
714
|
+
const existing = this.dormantRoots.get(root);
|
|
715
|
+
if (existing)
|
|
716
|
+
return existing;
|
|
717
|
+
const error = rejection instanceof SubcError ? rejection : absentRootError(root);
|
|
718
|
+
this.dormantRoots.set(root, error);
|
|
719
|
+
log(`root ${root} reclaimed/absent; suspending attach until it exists`);
|
|
720
|
+
return error;
|
|
721
|
+
}
|
|
722
|
+
assertRootCanAttach(root) {
|
|
723
|
+
if (this.rootCanAttach(root))
|
|
724
|
+
return;
|
|
725
|
+
throw this.dormantRoots.get(root) ?? absentRootError(root);
|
|
726
|
+
}
|
|
727
|
+
lifecycleEnabled() {
|
|
728
|
+
// A disabled registration deliberately behaves like the pre-reaper pool:
|
|
729
|
+
// synthetic roots may be used, no demand check is required, and no sweep can
|
|
730
|
+
// acquire a tombstone for the pool.
|
|
731
|
+
return Boolean(this.lifecycleRegistry && this.lifecycleRegistration?.reapingEnabled);
|
|
732
|
+
}
|
|
733
|
+
currentGeneration(root) {
|
|
734
|
+
const registration = this.lifecycleRegistration;
|
|
735
|
+
return registration && this.lifecycleRegistry
|
|
736
|
+
? this.lifecycleRegistry.currentGeneration(registration.concretePoolId, root)
|
|
737
|
+
: undefined;
|
|
738
|
+
}
|
|
739
|
+
currentPoolId() {
|
|
740
|
+
return this.lifecycleRegistration?.concretePoolId;
|
|
741
|
+
}
|
|
742
|
+
isCurrentLiveGeneration(root, generation) {
|
|
743
|
+
if (!this.lifecycleEnabled())
|
|
744
|
+
return true;
|
|
745
|
+
const registry = this.lifecycleRegistry;
|
|
746
|
+
const poolId = this.currentPoolId();
|
|
747
|
+
return (registry !== undefined &&
|
|
748
|
+
poolId !== undefined &&
|
|
749
|
+
generation !== undefined &&
|
|
750
|
+
registry.isCurrentLiveGeneration(poolId, root, generation));
|
|
751
|
+
}
|
|
752
|
+
isRootTombstoned(root, generation) {
|
|
753
|
+
const registry = this.lifecycleRegistry;
|
|
754
|
+
const poolId = this.currentPoolId();
|
|
755
|
+
return (registry !== undefined &&
|
|
756
|
+
poolId !== undefined &&
|
|
757
|
+
registry.isTombstoned(poolId, root, generation));
|
|
758
|
+
}
|
|
759
|
+
recordGenerationRejection(root, expectedGeneration, boundary) {
|
|
760
|
+
if (!this.lifecycleEnabled())
|
|
761
|
+
return;
|
|
762
|
+
const poolId = this.currentPoolId();
|
|
763
|
+
const registry = this.lifecycleRegistry;
|
|
764
|
+
if (!poolId || !registry)
|
|
765
|
+
return;
|
|
766
|
+
const key = `${poolId}\u0000${root}\u0000${expectedGeneration}\u0000${boundary}`;
|
|
767
|
+
if (this.generationRejections.has(key))
|
|
768
|
+
return;
|
|
769
|
+
this.generationRejections.add(key);
|
|
770
|
+
registry.recordGenerationRejection(poolId, root, expectedGeneration, boundary);
|
|
771
|
+
if (this.registryUsesPoolEventSink)
|
|
772
|
+
return;
|
|
773
|
+
this.onLifecycleEvent?.({
|
|
774
|
+
type: "subc_root_generation_rejected",
|
|
775
|
+
realm: registry.realm,
|
|
776
|
+
concretePoolId: poolId,
|
|
777
|
+
canonicalRoot: root,
|
|
778
|
+
expectedGeneration,
|
|
779
|
+
currentGeneration: registry.currentGeneration(poolId, root),
|
|
780
|
+
boundary,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
generationExpiredError(root, expectedGeneration) {
|
|
784
|
+
const currentGeneration = this.currentGeneration(root);
|
|
785
|
+
this.recordGenerationRejection(root, expectedGeneration, "stale_generation");
|
|
786
|
+
return new SubcRootGenerationExpiredError({
|
|
787
|
+
canonicalRoot: root,
|
|
788
|
+
expectedGeneration,
|
|
789
|
+
currentGeneration,
|
|
790
|
+
concretePoolId: this.currentPoolId(),
|
|
791
|
+
currentConcretePoolId: this.currentPoolId(),
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
rootReapedError(record) {
|
|
795
|
+
return new SubcRootReapedError({
|
|
796
|
+
canonicalRoot: record.canonicalRoot,
|
|
797
|
+
expectedGeneration: record.generation ?? asRootGeneration(1),
|
|
798
|
+
currentGeneration: record.generation
|
|
799
|
+
? this.currentGeneration(record.canonicalRoot)
|
|
800
|
+
: undefined,
|
|
801
|
+
concretePoolId: this.currentPoolId(),
|
|
802
|
+
currentConcretePoolId: this.currentPoolId(),
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
assertFacadeCurrent(root, expectedGeneration, boundary) {
|
|
806
|
+
this.assertGeneration(root, expectedGeneration, boundary);
|
|
807
|
+
}
|
|
808
|
+
assertGeneration(root, expectedGeneration, boundary) {
|
|
809
|
+
if (!this.lifecycleEnabled() || expectedGeneration === undefined)
|
|
810
|
+
return;
|
|
811
|
+
if (this.isCurrentLiveGeneration(root, expectedGeneration))
|
|
812
|
+
return;
|
|
813
|
+
if (this.isRootTombstoned(root, expectedGeneration)) {
|
|
814
|
+
throw new SubcRootReapedError({
|
|
815
|
+
canonicalRoot: root,
|
|
816
|
+
expectedGeneration,
|
|
817
|
+
currentGeneration: this.currentGeneration(root),
|
|
818
|
+
concretePoolId: this.currentPoolId(),
|
|
819
|
+
currentConcretePoolId: this.currentPoolId(),
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
this.recordGenerationRejection(root, expectedGeneration, boundary);
|
|
823
|
+
throw this.generationExpiredError(root, expectedGeneration);
|
|
824
|
+
}
|
|
825
|
+
assertRecordLive(record) {
|
|
826
|
+
if (!this.isCurrentSession(record.identityKey, record)) {
|
|
827
|
+
if (record.teardownReason === "root_reaped")
|
|
828
|
+
throw this.rootReapedError(record);
|
|
829
|
+
throw new RouteTornDownError("subc session closed");
|
|
830
|
+
}
|
|
831
|
+
this.assertGeneration(record.canonicalRoot, record.generation, "route_request");
|
|
832
|
+
}
|
|
833
|
+
synchronousDemand(root) {
|
|
834
|
+
const poolId = this.currentPoolId();
|
|
835
|
+
if (!this.lifecycleEnabled() || poolId === undefined)
|
|
836
|
+
return true;
|
|
837
|
+
if (!this.lifecycleDemandCheck)
|
|
838
|
+
throw new SubcRootDemandRequiredError(root);
|
|
839
|
+
const result = this.lifecycleDemandCheck(root, poolId);
|
|
840
|
+
if (typeof result === "boolean")
|
|
841
|
+
return result;
|
|
842
|
+
return result.exists !== false;
|
|
843
|
+
}
|
|
844
|
+
makeFacade(root, generation) {
|
|
845
|
+
const existing = this.transports.get(root);
|
|
846
|
+
if (existing && (!this.lifecycleEnabled() || existing.getGeneration() === generation)) {
|
|
847
|
+
return existing;
|
|
381
848
|
}
|
|
849
|
+
const transport = new SubcTransport(this, root, generation);
|
|
850
|
+
this.transports.set(root, transport);
|
|
382
851
|
return transport;
|
|
383
852
|
}
|
|
853
|
+
/**
|
|
854
|
+
* Return a live facade. Legacy construction remains synchronous. Lifecycle
|
|
855
|
+
* construction uses the injected synchronous demand seam so a returned facade
|
|
856
|
+
* always has a registered root and captured generation.
|
|
857
|
+
*/
|
|
858
|
+
getBridge(projectRoot) {
|
|
859
|
+
const root = this.canonicalRoot(projectRoot);
|
|
860
|
+
if (this.shuttingDown && this.lifecycleEnabled()) {
|
|
861
|
+
throw new SubcCallError("terminal", "subc transport is shutting down");
|
|
862
|
+
}
|
|
863
|
+
if (!this.lifecycleEnabled())
|
|
864
|
+
return this.makeFacade(root);
|
|
865
|
+
const current = this.currentGeneration(root);
|
|
866
|
+
const existing = this.transports.get(root);
|
|
867
|
+
if (current !== undefined &&
|
|
868
|
+
this.isCurrentLiveGeneration(root, current) &&
|
|
869
|
+
existing?.getGeneration() === current) {
|
|
870
|
+
return existing;
|
|
871
|
+
}
|
|
872
|
+
if (!this.synchronousDemand(root))
|
|
873
|
+
throw new SubcRootDemandRequiredError(root);
|
|
874
|
+
const registration = this.lifecycleRegistration;
|
|
875
|
+
const registry = this.lifecycleRegistry;
|
|
876
|
+
if (!registration || !registry)
|
|
877
|
+
throw new SubcRootDemandRequiredError(root);
|
|
878
|
+
const generation = registry.registerRoot(registration.concretePoolId, root);
|
|
879
|
+
return this.makeFacade(root, generation);
|
|
880
|
+
}
|
|
881
|
+
/** Async demand entry point for registries whose existence seam is asynchronous. */
|
|
882
|
+
async getBridgeForDemand(projectRoot) {
|
|
883
|
+
const root = this.canonicalRoot(projectRoot);
|
|
884
|
+
if (this.shuttingDown || !this.lifecycleEnabled())
|
|
885
|
+
return null;
|
|
886
|
+
const registration = this.lifecycleRegistration;
|
|
887
|
+
const registry = this.lifecycleRegistry;
|
|
888
|
+
if (!registration || !registry)
|
|
889
|
+
return null;
|
|
890
|
+
const generation = await registry.ensureRootForDemand(registration.concretePoolId, root);
|
|
891
|
+
if (generation === undefined ||
|
|
892
|
+
!registry.isCurrentLiveGeneration(registration.concretePoolId, root, generation)) {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
return this.makeFacade(root, generation);
|
|
896
|
+
}
|
|
897
|
+
/** Alias used by host construction code that calls the seam a demand operation. */
|
|
898
|
+
demandBridge(projectRoot) {
|
|
899
|
+
return this.getBridgeForDemand(projectRoot);
|
|
900
|
+
}
|
|
384
901
|
getActiveBridgeForRoot(projectRoot) {
|
|
385
|
-
const
|
|
386
|
-
|
|
902
|
+
const root = this.canonicalRoot(projectRoot);
|
|
903
|
+
const transport = this.transports.get(root);
|
|
904
|
+
if (!transport)
|
|
905
|
+
return null;
|
|
906
|
+
if (this.lifecycleEnabled()) {
|
|
907
|
+
return this.isCurrentLiveGeneration(root, transport.getGeneration()) ? transport : null;
|
|
908
|
+
}
|
|
909
|
+
if (!this.client || this.shuttingDown)
|
|
910
|
+
return null;
|
|
911
|
+
return transport;
|
|
912
|
+
}
|
|
913
|
+
/** Non-creating lookup requiring the complete pool/root/generation provenance. */
|
|
914
|
+
getActiveBridgeForRootGeneration(ref) {
|
|
915
|
+
const poolId = this.currentPoolId();
|
|
916
|
+
if (!this.lifecycleEnabled() || poolId === undefined || ref.concretePoolId !== poolId)
|
|
917
|
+
return null;
|
|
918
|
+
if (!this.isCurrentLiveGeneration(ref.canonicalRoot, ref.generation))
|
|
387
919
|
return null;
|
|
388
|
-
|
|
920
|
+
const transport = this.transports.get(ref.canonicalRoot);
|
|
921
|
+
return transport?.getGeneration() === ref.generation ? transport : null;
|
|
389
922
|
}
|
|
390
|
-
/** All live per-root facades, for session-scoped signals that must not
|
|
391
|
-
* depend on exact root-key resolution (the command carries the session ID
|
|
392
|
-
* and the module scopes by session, so fan-out is safe). */
|
|
393
923
|
activeBridges() {
|
|
394
|
-
if (
|
|
924
|
+
if (this.shuttingDown)
|
|
395
925
|
return [];
|
|
396
|
-
return [...this.transports.
|
|
926
|
+
return [...this.transports.entries()].flatMap(([root, transport]) => !this.lifecycleEnabled() || this.isCurrentLiveGeneration(root, transport.getGeneration())
|
|
927
|
+
? [transport]
|
|
928
|
+
: []);
|
|
397
929
|
}
|
|
398
930
|
async toolCall(projectRoot, runtime, name, rawArgs = {}, options) {
|
|
399
931
|
return this.getBridge(projectRoot).toolCall(runtime.sessionID, name, rawArgs, options);
|
|
400
932
|
}
|
|
401
|
-
getOrCreateSession(
|
|
933
|
+
getOrCreateSession(identity, generation) {
|
|
934
|
+
const key = identityKey(identity);
|
|
935
|
+
const root = asCanonicalRootPath(identity.project_root);
|
|
402
936
|
let record = this.sessions.get(key);
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
937
|
+
if (record && !record.closed) {
|
|
938
|
+
if (this.lifecycleEnabled() && record.generation !== generation) {
|
|
939
|
+
throw this.generationExpiredError(root, generation ?? asRootGeneration(1));
|
|
940
|
+
}
|
|
941
|
+
return record;
|
|
942
|
+
}
|
|
943
|
+
record = {
|
|
944
|
+
identity,
|
|
945
|
+
identityKey: key,
|
|
946
|
+
canonicalRoot: root,
|
|
947
|
+
generation,
|
|
948
|
+
routeEntry: null,
|
|
949
|
+
bgSub: null,
|
|
950
|
+
closed: false,
|
|
951
|
+
teardownReason: null,
|
|
952
|
+
inflight: 0,
|
|
953
|
+
};
|
|
954
|
+
this.sessions.set(key, record);
|
|
955
|
+
let keys = this.rootIndex.get(root);
|
|
956
|
+
if (!keys) {
|
|
957
|
+
keys = new Set();
|
|
958
|
+
this.rootIndex.set(root, keys);
|
|
406
959
|
}
|
|
960
|
+
keys.add(key);
|
|
407
961
|
return record;
|
|
408
962
|
}
|
|
409
963
|
isCurrentSession(key, record) {
|
|
410
964
|
return this.sessions.get(key) === record && !record.closed;
|
|
411
965
|
}
|
|
412
|
-
|
|
413
|
-
|
|
966
|
+
currentSessionForNudge(identity) {
|
|
967
|
+
const current = this.sessions.get(identityKey(identity));
|
|
968
|
+
return current && !current.closed ? current : null;
|
|
969
|
+
}
|
|
970
|
+
nudgeRefFor(record) {
|
|
971
|
+
const poolId = this.currentPoolId();
|
|
972
|
+
const generation = record.generation;
|
|
973
|
+
if (poolId === undefined || generation === undefined)
|
|
974
|
+
return undefined;
|
|
975
|
+
return {
|
|
976
|
+
canonicalRoot: record.canonicalRoot,
|
|
977
|
+
session: record.identity.session,
|
|
978
|
+
concretePoolId: poolId,
|
|
979
|
+
generation,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
logNudgeDelivery(kind, record, message) {
|
|
983
|
+
const key = `${kind}\u0000${record.identityKey}`;
|
|
984
|
+
const now = Date.now();
|
|
985
|
+
const state = this.nudgeDeliveryLogState.get(key);
|
|
986
|
+
if (state && now - state.lastEmittedAt < BG_LIFECYCLE_LOG_INTERVAL_MS) {
|
|
987
|
+
state.suppressed += 1;
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const suppressed = state?.suppressed ?? 0;
|
|
991
|
+
this.nudgeDeliveryLogState.set(key, { lastEmittedAt: now, suppressed: 0 });
|
|
992
|
+
const suffix = suppressed > 0 ? ` suppressed=${suppressed}` : "";
|
|
993
|
+
log(`subc bg_events: ${message}${suffix}`, { sessionId: record.identity.session });
|
|
994
|
+
}
|
|
995
|
+
removeIndexMembership(record) {
|
|
996
|
+
const keys = this.rootIndex.get(record.canonicalRoot);
|
|
997
|
+
if (!keys)
|
|
998
|
+
return;
|
|
999
|
+
keys.delete(record.identityKey);
|
|
1000
|
+
if (keys.size === 0)
|
|
1001
|
+
this.rootIndex.delete(record.canonicalRoot);
|
|
1002
|
+
}
|
|
1003
|
+
deleteSessionIfEmpty(_key, record) {
|
|
1004
|
+
if (this.sessions.get(record.identityKey) === record &&
|
|
414
1005
|
!record.closed &&
|
|
415
1006
|
record.inflight === 0 &&
|
|
416
1007
|
record.routeEntry === null &&
|
|
417
1008
|
record.bgSub === null) {
|
|
418
|
-
this.sessions.delete(
|
|
1009
|
+
this.sessions.delete(record.identityKey);
|
|
1010
|
+
this.removeIndexMembership(record);
|
|
419
1011
|
}
|
|
420
1012
|
}
|
|
421
1013
|
/**
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
* the caller after clearing stale route/client state so the NEXT call
|
|
425
|
-
* re-establishes. The only in-place retries are route-absence proofs: either the
|
|
426
|
-
* daemon returns `unknown_channel`, or the client rejects a stale handle before
|
|
427
|
-
* writing. In both cases, the request was not delivered to the module.
|
|
428
|
-
* Tool-level errors come back as normal replies with `success:false` and are
|
|
429
|
-
* returned, not thrown.
|
|
1014
|
+
* Atomically detaches one opaque identity. Nothing in this method awaits: it
|
|
1015
|
+
* is the sole owner transfer used by session close, root reap, and shutdown.
|
|
430
1016
|
*/
|
|
431
|
-
|
|
1017
|
+
detachSession(key, reason) {
|
|
1018
|
+
const record = this.sessions.get(key);
|
|
1019
|
+
if (!record || record.closed)
|
|
1020
|
+
return null;
|
|
1021
|
+
record.closed = true;
|
|
1022
|
+
record.teardownReason = reason;
|
|
1023
|
+
this.sessions.delete(key);
|
|
1024
|
+
this.removeIndexMembership(record);
|
|
1025
|
+
const bgSub = record.bgSub;
|
|
1026
|
+
record.bgSub = null;
|
|
1027
|
+
const routeEntry = record.routeEntry;
|
|
1028
|
+
record.routeEntry = null;
|
|
1029
|
+
if (routeEntry)
|
|
1030
|
+
routeEntry.closed = true;
|
|
1031
|
+
return { record, bgSub, routeEntry };
|
|
1032
|
+
}
|
|
1033
|
+
async cleanupDetached(detached) {
|
|
1034
|
+
const cleanup = [];
|
|
1035
|
+
if (detached.bgSub)
|
|
1036
|
+
cleanup.push(detached.bgSub.stop());
|
|
1037
|
+
const routeEntry = detached.routeEntry;
|
|
1038
|
+
const route = routeEntry?.handle;
|
|
1039
|
+
if (routeEntry && route !== null && route !== undefined) {
|
|
1040
|
+
try {
|
|
1041
|
+
cleanup.push(Promise.resolve(routeEntry.client.closeRouteChannel(route)).catch(() => undefined));
|
|
1042
|
+
}
|
|
1043
|
+
catch {
|
|
1044
|
+
// A client may throw synchronously when its socket is already closed.
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
await Promise.allSettled(cleanup);
|
|
1048
|
+
}
|
|
1049
|
+
isReapInduced(record) {
|
|
1050
|
+
return record.teardownReason === "root_reaped";
|
|
1051
|
+
}
|
|
1052
|
+
annotateReapError(error, record) {
|
|
1053
|
+
if (!this.isReapInduced(record))
|
|
1054
|
+
return error;
|
|
1055
|
+
if (error instanceof SubcRootReapedError)
|
|
1056
|
+
return error;
|
|
1057
|
+
if (error instanceof Error) {
|
|
1058
|
+
try {
|
|
1059
|
+
Object.defineProperty(error, "subcTeardownReason", {
|
|
1060
|
+
configurable: true,
|
|
1061
|
+
enumerable: true,
|
|
1062
|
+
value: "root_reaped",
|
|
1063
|
+
writable: false,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
// A frozen transport error cannot be annotated; use the stable wrapper.
|
|
1068
|
+
return this.rootReapedError(record);
|
|
1069
|
+
}
|
|
1070
|
+
return error;
|
|
1071
|
+
}
|
|
1072
|
+
return this.rootReapedError(record);
|
|
1073
|
+
}
|
|
1074
|
+
/** Open or reuse a route while guarding every lifecycle boundary. */
|
|
1075
|
+
async routeRequest(identity, body, timeoutMs, onProgress, expectedGeneration) {
|
|
1076
|
+
const root = asCanonicalRootPath(identity.project_root);
|
|
1077
|
+
let generation = expectedGeneration;
|
|
1078
|
+
if (this.lifecycleEnabled()) {
|
|
1079
|
+
generation ??= this.currentGeneration(root);
|
|
1080
|
+
if (generation === undefined)
|
|
1081
|
+
throw new SubcRootDemandRequiredError(root);
|
|
1082
|
+
this.assertGeneration(root, generation, "route_request");
|
|
1083
|
+
}
|
|
1084
|
+
this.assertRootCanAttach(root);
|
|
432
1085
|
const key = identityKey(identity);
|
|
433
|
-
const record = this.getOrCreateSession(
|
|
1086
|
+
const record = this.getOrCreateSession(identity, generation);
|
|
434
1087
|
record.inflight += 1;
|
|
435
1088
|
try {
|
|
436
|
-
|
|
1089
|
+
let client;
|
|
1090
|
+
try {
|
|
1091
|
+
client = await this.ensureClient();
|
|
1092
|
+
this.assertRecordLive(record);
|
|
1093
|
+
}
|
|
1094
|
+
catch (error) {
|
|
1095
|
+
throw this.annotateReapError(error, record);
|
|
1096
|
+
}
|
|
437
1097
|
const openRoute = async () => {
|
|
438
|
-
// closeSession/shutdown marks and deletes the record synchronously. A request
|
|
439
|
-
// that was waiting for connect must not open a route for a session that no
|
|
440
|
-
// longer exists.
|
|
441
|
-
if (!this.isCurrentSession(key, record)) {
|
|
442
|
-
throw new RouteTornDownError("subc session closed");
|
|
443
|
-
}
|
|
444
1098
|
try {
|
|
1099
|
+
this.assertRecordLive(record);
|
|
445
1100
|
const opened = await this.routeHandle(client, identity, record);
|
|
446
|
-
|
|
447
|
-
// if a stale route entry just resolved.
|
|
448
|
-
if (!this.isCurrentSession(key, record)) {
|
|
449
|
-
throw new RouteTornDownError("subc session closed");
|
|
450
|
-
}
|
|
1101
|
+
this.assertRecordLive(record);
|
|
451
1102
|
return opened;
|
|
452
1103
|
}
|
|
453
|
-
catch (
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (
|
|
457
|
-
throw
|
|
458
|
-
|
|
459
|
-
// for transient connection death, but only while this request's session
|
|
460
|
-
// and client are still current. A close-induced failure must not drop a
|
|
461
|
-
// healthy client shared by other sessions.
|
|
462
|
-
if (isConsumerReconnectTransient(err) &&
|
|
1104
|
+
catch (error) {
|
|
1105
|
+
if (this.isReapInduced(record))
|
|
1106
|
+
throw this.annotateReapError(error, record);
|
|
1107
|
+
if (error instanceof RouteTornDownError)
|
|
1108
|
+
throw error;
|
|
1109
|
+
if (isConsumerReconnectTransient(error) &&
|
|
463
1110
|
this.isCurrentSession(key, record) &&
|
|
464
1111
|
this.client === client) {
|
|
465
1112
|
this.dropClient(client);
|
|
466
1113
|
}
|
|
467
|
-
throw
|
|
1114
|
+
throw error;
|
|
468
1115
|
}
|
|
469
1116
|
};
|
|
470
1117
|
const clearRouteEntry = (entry) => {
|
|
471
|
-
if (record.routeEntry
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
safeCloseRoute(entry.client, entry.handle);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
1118
|
+
if (record.routeEntry !== entry)
|
|
1119
|
+
return;
|
|
1120
|
+
entry.closed = true;
|
|
1121
|
+
record.routeEntry = null;
|
|
1122
|
+
if (entry.handle != null)
|
|
1123
|
+
safeCloseRoute(entry.client, entry.handle);
|
|
480
1124
|
};
|
|
481
|
-
const handleRequestFailure = (
|
|
482
|
-
// The raw `request()` path does not turn failures into SubcCallError; it
|
|
483
|
-
// rejects with a base SubcError for route-level failures or a raw socket
|
|
484
|
-
// error for connection failures. Use `isConsumerReconnectTransient`, not
|
|
485
|
-
// `instanceof SubcCallError`, to distinguish a dead connection from a dead
|
|
486
|
-
// route.
|
|
487
|
-
//
|
|
488
|
-
// A failed request makes its own route suspect, so drop it and let the next
|
|
489
|
-
// call re-open. Check only the current entry; a stale failure must not
|
|
490
|
-
// delete a successor route in the same still-open session.
|
|
1125
|
+
const handleRequestFailure = (error, entry) => {
|
|
491
1126
|
clearRouteEntry(entry);
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
if (
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
// no response ever returns, so isConsumerReconnectTransient never fires
|
|
506
|
-
// and the client would otherwise be kept forever. Force reconnect.
|
|
507
|
-
this.transportFailures = 0;
|
|
508
|
-
this.dropClient(client);
|
|
509
|
-
}
|
|
1127
|
+
if (!this.isCurrentSession(key, record) ||
|
|
1128
|
+
this.client !== client ||
|
|
1129
|
+
this.isReapInduced(record))
|
|
1130
|
+
return;
|
|
1131
|
+
if (isConsumerReconnectTransient(error)) {
|
|
1132
|
+
this.transportFailures = 0;
|
|
1133
|
+
this.dropClient(client);
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
if (!isRouteProvenAbsentError(error) &&
|
|
1137
|
+
++this.transportFailures >= MAX_CONSECUTIVE_TRANSPORT_FAILURES) {
|
|
1138
|
+
this.transportFailures = 0;
|
|
1139
|
+
this.dropClient(client);
|
|
510
1140
|
}
|
|
511
1141
|
};
|
|
512
1142
|
const requestOnRoute = async (route) => {
|
|
513
|
-
|
|
514
|
-
// foreground calls return one final reply, so production does not rely on
|
|
515
|
-
// live progress events here.
|
|
1143
|
+
this.assertRecordLive(record);
|
|
516
1144
|
const reply = await client.request(route, body, { timeoutMs, onProgress });
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
1145
|
+
// A legacy closeSession may intentionally let an already-delivered reply
|
|
1146
|
+
// settle. It must not mutate shared state or recreate a subscription.
|
|
1147
|
+
if (!this.isCurrentSession(key, record)) {
|
|
1148
|
+
if (this.isReapInduced(record))
|
|
1149
|
+
throw this.rootReapedError(record);
|
|
1150
|
+
return reply;
|
|
522
1151
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
// resurrect one.
|
|
1152
|
+
this.assertGeneration(record.canonicalRoot, record.generation, "request_completion");
|
|
1153
|
+
if (this.client === client)
|
|
1154
|
+
this.transportFailures = 0;
|
|
527
1155
|
this.ensureBgSubscription(identity, record);
|
|
528
1156
|
return reply;
|
|
529
1157
|
};
|
|
530
|
-
let
|
|
1158
|
+
let routeAndEntry = await openRoute();
|
|
531
1159
|
try {
|
|
532
|
-
return await requestOnRoute(route);
|
|
1160
|
+
return await requestOnRoute(routeAndEntry.route);
|
|
533
1161
|
}
|
|
534
|
-
catch (
|
|
535
|
-
if (
|
|
1162
|
+
catch (error) {
|
|
1163
|
+
if (this.isReapInduced(record))
|
|
1164
|
+
throw this.annotateReapError(error, record);
|
|
1165
|
+
if (isRouteProvenAbsentError(error) &&
|
|
536
1166
|
this.isCurrentSession(key, record) &&
|
|
537
1167
|
this.client === client) {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
// other failures keep the no-auto-retry mutation-safety rule.
|
|
541
|
-
clearRouteEntry(entry);
|
|
542
|
-
({ route, entry } = await openRoute());
|
|
1168
|
+
clearRouteEntry(routeAndEntry.entry);
|
|
1169
|
+
routeAndEntry = await openRoute();
|
|
543
1170
|
try {
|
|
544
|
-
return await requestOnRoute(route);
|
|
1171
|
+
return await requestOnRoute(routeAndEntry.route);
|
|
545
1172
|
}
|
|
546
|
-
catch (
|
|
547
|
-
|
|
548
|
-
|
|
1173
|
+
catch (retryError) {
|
|
1174
|
+
if (this.isReapInduced(record))
|
|
1175
|
+
throw this.annotateReapError(retryError, record);
|
|
1176
|
+
handleRequestFailure(retryError, routeAndEntry.entry);
|
|
1177
|
+
throw retryError;
|
|
549
1178
|
}
|
|
550
1179
|
}
|
|
551
|
-
handleRequestFailure(
|
|
552
|
-
throw
|
|
1180
|
+
handleRequestFailure(error, routeAndEntry.entry);
|
|
1181
|
+
throw error;
|
|
553
1182
|
}
|
|
554
1183
|
}
|
|
1184
|
+
catch (error) {
|
|
1185
|
+
if (this.isReapInduced(record))
|
|
1186
|
+
throw this.annotateReapError(error, record);
|
|
1187
|
+
throw error;
|
|
1188
|
+
}
|
|
555
1189
|
finally {
|
|
556
1190
|
record.inflight -= 1;
|
|
557
1191
|
this.deleteSessionIfEmpty(key, record);
|
|
558
1192
|
}
|
|
559
1193
|
}
|
|
560
1194
|
async ensureClient() {
|
|
561
|
-
if (this.shuttingDown)
|
|
1195
|
+
if (this.shuttingDown)
|
|
562
1196
|
throw new SubcCallError("terminal", "subc transport is shutting down");
|
|
563
|
-
}
|
|
564
1197
|
if (this.client)
|
|
565
1198
|
return this.client;
|
|
566
1199
|
if (this.connecting)
|
|
@@ -571,57 +1204,51 @@ export class SubcTransportPool {
|
|
|
571
1204
|
})
|
|
572
1205
|
.then((client) => {
|
|
573
1206
|
this.connecting = null;
|
|
574
|
-
// shutdown() may have fired while this connect was in flight. Don't
|
|
575
|
-
// install a live client after teardown — close it and fail the call
|
|
576
|
-
// (B-#5: otherwise a socket leaks open past shutdown()).
|
|
577
1207
|
if (this.shuttingDown) {
|
|
578
1208
|
try {
|
|
579
1209
|
client.close();
|
|
580
1210
|
}
|
|
581
1211
|
catch {
|
|
582
|
-
//
|
|
1212
|
+
// A late connection is owned by the failed connect and is closed here.
|
|
583
1213
|
}
|
|
584
1214
|
throw new SubcCallError("terminal", "subc transport is shutting down");
|
|
585
1215
|
}
|
|
586
1216
|
this.client = client;
|
|
587
|
-
// Fresh client generation starts with a clean failure budget (R2-T2).
|
|
588
1217
|
this.transportFailures = 0;
|
|
589
1218
|
return client;
|
|
590
1219
|
})
|
|
591
|
-
.catch((
|
|
1220
|
+
.catch((error) => {
|
|
592
1221
|
this.connecting = null;
|
|
593
|
-
throw
|
|
1222
|
+
throw error;
|
|
594
1223
|
});
|
|
595
1224
|
return this.connecting;
|
|
596
1225
|
}
|
|
597
|
-
/**
|
|
598
|
-
* Resolve the route channel for `identity`, returning BOTH the channel and the
|
|
599
|
-
* `RouteEntry` token that owns it. The route-entry token is the route-cache
|
|
600
|
-
* ownership guard: stale request failures can clear only the route they used,
|
|
601
|
-
* never a successor route in the same still-open session.
|
|
602
|
-
*/
|
|
603
1226
|
async routeHandle(client, identity, record) {
|
|
604
|
-
const key = identityKey(identity);
|
|
605
1227
|
const existing = record.routeEntry;
|
|
606
|
-
if (existing?.handle != null)
|
|
1228
|
+
if (existing?.handle != null && existing.client === client)
|
|
607
1229
|
return { route: existing.handle, entry: existing };
|
|
608
|
-
if (existing?.opening)
|
|
1230
|
+
if (existing?.opening && existing.client === client)
|
|
609
1231
|
return { route: await existing.opening, entry: existing };
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
1232
|
+
const entry = {
|
|
1233
|
+
canonicalRoot: record.canonicalRoot,
|
|
1234
|
+
generation: record.generation,
|
|
1235
|
+
client,
|
|
1236
|
+
opening: null,
|
|
1237
|
+
handle: null,
|
|
1238
|
+
closed: false,
|
|
1239
|
+
};
|
|
1240
|
+
this.assertRootCanAttach(record.canonicalRoot);
|
|
613
1241
|
const opening = client
|
|
614
1242
|
.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
|
|
615
1243
|
consumerIdentity: this.consumerIdentity,
|
|
616
1244
|
})
|
|
617
1245
|
.then((route) => {
|
|
618
|
-
|
|
619
|
-
// replaced by a transient drop/reopen. In both cases, release the freshly
|
|
620
|
-
// minted channel and clear only this entry if it still owns the cache slot.
|
|
621
|
-
if (!this.isCurrentSession(key, record) ||
|
|
1246
|
+
if (!this.isCurrentSession(record.identityKey, record) ||
|
|
622
1247
|
record.routeEntry !== entry ||
|
|
623
1248
|
entry.closed ||
|
|
624
|
-
this.client !== client
|
|
1249
|
+
this.client !== client ||
|
|
1250
|
+
(this.lifecycleEnabled() &&
|
|
1251
|
+
!this.isCurrentLiveGeneration(record.canonicalRoot, record.generation))) {
|
|
625
1252
|
safeCloseRoute(client, route);
|
|
626
1253
|
if (record.routeEntry === entry)
|
|
627
1254
|
record.routeEntry = null;
|
|
@@ -631,156 +1258,235 @@ export class SubcTransportPool {
|
|
|
631
1258
|
entry.opening = null;
|
|
632
1259
|
return route;
|
|
633
1260
|
})
|
|
634
|
-
.catch((
|
|
635
|
-
const
|
|
636
|
-
// Failed open: drop this entry so the next call retries cleanly, but never
|
|
637
|
-
// delete a successor entry that replaced it while this open was in flight.
|
|
1261
|
+
.catch((error) => {
|
|
1262
|
+
const currentEntry = this.isCurrentSession(record.identityKey, record) && record.routeEntry === entry;
|
|
638
1263
|
if (record.routeEntry === entry) {
|
|
639
1264
|
entry.closed = true;
|
|
640
1265
|
record.routeEntry = null;
|
|
641
1266
|
}
|
|
642
|
-
if (!
|
|
1267
|
+
if (!this.isCurrentSession(record.identityKey, record) &&
|
|
1268
|
+
!(error instanceof RouteTornDownError)) {
|
|
643
1269
|
throw new RouteTornDownError("subc route opened after session closed");
|
|
644
1270
|
}
|
|
645
|
-
|
|
1271
|
+
if (currentEntry && isAbsentRootRouteError(error)) {
|
|
1272
|
+
this.markRootDormant(record.canonicalRoot, error);
|
|
1273
|
+
}
|
|
1274
|
+
throw error;
|
|
646
1275
|
});
|
|
647
1276
|
entry.opening = opening;
|
|
648
1277
|
record.routeEntry = entry;
|
|
649
1278
|
return { route: await opening, entry };
|
|
650
1279
|
}
|
|
651
|
-
/**
|
|
652
|
-
* Open the dedicated bg_events subscription for `identity` once. Idempotent —
|
|
653
|
-
* a second call for the same identity is a no-op (one sub per session, the
|
|
654
|
-
* duplicate-sub guard from Oracle #2). No-op when no nudge handler is wired,
|
|
655
|
-
* during shutdown, or after the session has been closed.
|
|
656
|
-
*/
|
|
657
1280
|
ensureBgSubscription(identity, record) {
|
|
658
|
-
if (this.shuttingDown || !this.onBgEventsNudge)
|
|
1281
|
+
if (this.shuttingDown || (!this.onBgEventsNudge && !this.onBgEventsNudgeRef))
|
|
659
1282
|
return;
|
|
660
|
-
|
|
661
|
-
|
|
1283
|
+
if (!this.isCurrentSession(record.identityKey, record))
|
|
1284
|
+
return;
|
|
1285
|
+
if (!this.rootCanAttach(record.canonicalRoot))
|
|
662
1286
|
return;
|
|
663
1287
|
if (record.bgSub)
|
|
664
1288
|
return;
|
|
665
|
-
const
|
|
666
|
-
const
|
|
1289
|
+
const nudgeRef = this.nudgeRefFor(record);
|
|
1290
|
+
const onNudge = () => {
|
|
1291
|
+
const currentRecord = this.currentSessionForNudge(identity);
|
|
1292
|
+
if (!currentRecord) {
|
|
1293
|
+
this.logNudgeDelivery("drop-no-current-session", record, `nudge dropped cause=no-current-session root=${record.canonicalRoot}`);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
if (currentRecord !== record) {
|
|
1297
|
+
this.logNudgeDelivery("forward-superseded-carrier", currentRecord, `nudge forwarding cause=superseded-carrying-record root=${currentRecord.canonicalRoot}`);
|
|
1298
|
+
}
|
|
1299
|
+
const currentRef = this.nudgeRefFor(currentRecord);
|
|
1300
|
+
let delivered = false;
|
|
1301
|
+
if (currentRef && this.onBgEventsNudgeRef) {
|
|
1302
|
+
this.onBgEventsNudgeRef(currentRef);
|
|
1303
|
+
delivered = true;
|
|
1304
|
+
}
|
|
1305
|
+
if (this.onBgEventsNudge) {
|
|
1306
|
+
if (!currentRef && this.onBgEventsNudgeRef) {
|
|
1307
|
+
this.logNudgeDelivery("fallback-missing-generation", currentRecord, `nudge dispatch fallback=root-session-handler cause=generation-provenance-unavailable root=${currentRecord.canonicalRoot}`);
|
|
1308
|
+
}
|
|
1309
|
+
this.onBgEventsNudge(currentRecord.identity.project_root, currentRecord.identity.session);
|
|
1310
|
+
delivered = true;
|
|
1311
|
+
}
|
|
1312
|
+
if (delivered)
|
|
1313
|
+
return;
|
|
1314
|
+
this.logNudgeDelivery("drop-no-compatible-handler", currentRecord, `nudge dropped cause=generation-provenance-unavailable-and-root-session-handler-unwired root=${currentRecord.canonicalRoot}`);
|
|
1315
|
+
};
|
|
1316
|
+
let sub = null;
|
|
1317
|
+
const clearDormantSubscription = () => {
|
|
1318
|
+
if (sub && record.bgSub === sub)
|
|
1319
|
+
record.bgSub = null;
|
|
1320
|
+
};
|
|
1321
|
+
sub = new BgSubscription(identity, () => this.ensureClient(), (client) => this.dropClient(client), this.consumerIdentity, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
|
|
1322
|
+
if (!isAbsentRootRouteError(error))
|
|
1323
|
+
return false;
|
|
1324
|
+
this.markRootDormant(record.canonicalRoot, error);
|
|
1325
|
+
return true;
|
|
1326
|
+
}, clearDormantSubscription, this.bgDispatchProbeIntervalMs, nudgeRef, () => this.isCurrentSession(record.identityKey, record) &&
|
|
1327
|
+
this.isCurrentLiveGeneration(record.canonicalRoot, record.generation));
|
|
667
1328
|
record.bgSub = sub;
|
|
1329
|
+
if (!this.rootCanAttach(record.canonicalRoot)) {
|
|
1330
|
+
record.bgSub = null;
|
|
1331
|
+
void sub.stop();
|
|
1332
|
+
}
|
|
668
1333
|
}
|
|
669
|
-
/**
|
|
1334
|
+
/**
|
|
1335
|
+
* Invalidate only routes owned by a dead client. Sessions remain indexed so a
|
|
1336
|
+
* replacement client can reconnect the same identity and its bg subscription.
|
|
1337
|
+
*/
|
|
670
1338
|
dropClient(client) {
|
|
671
|
-
if (this.client
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1339
|
+
if (this.client !== client)
|
|
1340
|
+
return;
|
|
1341
|
+
this.client = null;
|
|
1342
|
+
for (const record of this.sessions.values()) {
|
|
1343
|
+
const entry = record.routeEntry;
|
|
1344
|
+
if (entry?.client !== client)
|
|
1345
|
+
continue;
|
|
1346
|
+
entry.closed = true;
|
|
1347
|
+
record.routeEntry = null;
|
|
1348
|
+
this.deleteSessionIfEmpty(record.identityKey, record);
|
|
1349
|
+
}
|
|
1350
|
+
this.transportFailures = 0;
|
|
1351
|
+
try {
|
|
1352
|
+
client.close();
|
|
1353
|
+
}
|
|
1354
|
+
catch {
|
|
1355
|
+
// A dead socket is already released by the peer.
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
/** Synchronous concrete-facade eviction used by the registry coordinator. */
|
|
1359
|
+
evictConcreteFacade(root, generation) {
|
|
1360
|
+
const facade = this.transports.get(root);
|
|
1361
|
+
if (facade?.getGeneration() === generation)
|
|
1362
|
+
this.transports.delete(root);
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Registry-owned coordinated close. All record/index/facade mutations happen
|
|
1366
|
+
* before cleanup promises are created; the registry has already tombstoned the
|
|
1367
|
+
* matching generation and evicted the wrapper facade before this is called.
|
|
1368
|
+
*/
|
|
1369
|
+
async closeProjectRoot(root, generation) {
|
|
1370
|
+
if (!this.lifecycleEnabled())
|
|
1371
|
+
return { tornDownSessionCount: 0, tornDownFacadeCount: 0 };
|
|
1372
|
+
if (!this.isRootTombstoned(root, generation))
|
|
1373
|
+
return { tornDownSessionCount: 0, tornDownFacadeCount: 0 };
|
|
1374
|
+
const keys = [...(this.rootIndex.get(root) ?? new Set())];
|
|
1375
|
+
const detached = [];
|
|
1376
|
+
for (const key of keys) {
|
|
1377
|
+
const record = this.sessions.get(key);
|
|
1378
|
+
if (record?.canonicalRoot === root && record.generation === generation) {
|
|
1379
|
+
const owner = this.detachSession(key, "root_reaped");
|
|
1380
|
+
if (owner)
|
|
1381
|
+
detached.push(owner);
|
|
692
1382
|
}
|
|
693
1383
|
}
|
|
1384
|
+
this.rootIndex.delete(root);
|
|
1385
|
+
const hadFacade = this.transports.get(root)?.getGeneration() === generation;
|
|
1386
|
+
this.evictConcreteFacade(root, generation);
|
|
1387
|
+
const cleanup = Promise.allSettled(detached.map((owner) => this.cleanupDetached(owner))).then(() => ({
|
|
1388
|
+
tornDownSessionCount: detached.length,
|
|
1389
|
+
tornDownFacadeCount: hadFacade ? 1 : 0,
|
|
1390
|
+
}));
|
|
1391
|
+
this.pendingRootCleanups.add(cleanup);
|
|
1392
|
+
void cleanup.finally(() => this.pendingRootCleanups.delete(cleanup));
|
|
1393
|
+
return cleanup;
|
|
1394
|
+
}
|
|
1395
|
+
/** Forward explicit lifecycle close requests to the registry-owned coordinator. */
|
|
1396
|
+
requestProjectRootClose(root, generation, cause = "explicit") {
|
|
1397
|
+
const registry = this.lifecycleRegistry;
|
|
1398
|
+
const registration = this.lifecycleRegistration;
|
|
1399
|
+
if (!registry || !registration || !registration.reapingEnabled)
|
|
1400
|
+
return Promise.resolve();
|
|
1401
|
+
return registry.requestProjectRootClose(registration.concretePoolId, root, generation, cause);
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* Subc reads config locally, but plugin registration facts are process state
|
|
1405
|
+
* and must accompany route requests because RouteBind has no such field.
|
|
1406
|
+
*/
|
|
1407
|
+
setConfigureOverride(key, value) {
|
|
1408
|
+
if (key !== "edit_slot_survives")
|
|
1409
|
+
return;
|
|
1410
|
+
if (typeof value !== "boolean") {
|
|
1411
|
+
throw new Error("edit_slot_survives must be set once to a boolean");
|
|
1412
|
+
}
|
|
1413
|
+
if (this.editSlotSurvivesCaptured) {
|
|
1414
|
+
throw new Error("edit_slot_survives is write-once and was already captured");
|
|
1415
|
+
}
|
|
1416
|
+
this.editSlotSurvives = value;
|
|
1417
|
+
this.editSlotSurvivesCaptured = true;
|
|
1418
|
+
}
|
|
1419
|
+
getEditSlotSurvives() {
|
|
1420
|
+
return this.editSlotSurvives;
|
|
694
1421
|
}
|
|
695
|
-
/** No-op over subc: config is read locally by AFT (wire tiers are ignored). */
|
|
696
|
-
setConfigureOverride(_key, _value) { }
|
|
697
1422
|
/** No-op over subc: the daemon owns the live module's configure lifecycle. */
|
|
698
1423
|
async reconfigure(_projectRoot, _overrides) { }
|
|
699
1424
|
/** No-op over subc: the daemon supervises the binary, not the plugin. */
|
|
700
1425
|
async replaceBinary(path) {
|
|
701
1426
|
return path;
|
|
702
1427
|
}
|
|
703
|
-
/** A subc pool instance is terminal after shutdown; ownership revives it outside this class. */
|
|
704
1428
|
isShutdown() {
|
|
705
1429
|
return this.shuttingDown;
|
|
706
1430
|
}
|
|
707
1431
|
async shutdown() {
|
|
1432
|
+
if (this.shuttingDown)
|
|
1433
|
+
return;
|
|
708
1434
|
this.shuttingDown = true;
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
entry.closed = true;
|
|
721
|
-
entries.push(entry);
|
|
722
|
-
}
|
|
1435
|
+
this.dormantRoots.clear();
|
|
1436
|
+
// Deregistration synchronously tombstones all registered roots and invokes
|
|
1437
|
+
// closeProjectRoot before this method reaches its first await.
|
|
1438
|
+
const registration = this.lifecycleRegistration;
|
|
1439
|
+
registration?.deregister();
|
|
1440
|
+
this.lifecycleRegistration = null;
|
|
1441
|
+
const detached = [];
|
|
1442
|
+
for (const key of [...this.sessions.keys()]) {
|
|
1443
|
+
const owner = this.detachSession(key, "shutdown");
|
|
1444
|
+
if (owner)
|
|
1445
|
+
detached.push(owner);
|
|
723
1446
|
}
|
|
724
|
-
this.
|
|
1447
|
+
this.rootIndex.clear();
|
|
1448
|
+
this.transports.clear();
|
|
725
1449
|
const client = this.client;
|
|
726
1450
|
this.client = null;
|
|
727
|
-
this.
|
|
728
|
-
await Promise.allSettled(
|
|
729
|
-
await Promise.allSettled(entries.map(async (entry) => {
|
|
730
|
-
if (entry.handle == null)
|
|
731
|
-
return;
|
|
732
|
-
try {
|
|
733
|
-
await entry.client.closeRouteChannel(entry.handle);
|
|
734
|
-
}
|
|
735
|
-
catch {
|
|
736
|
-
// best-effort; a dropped connection releases the route on the other side
|
|
737
|
-
}
|
|
738
|
-
}));
|
|
1451
|
+
const pending = [...this.pendingRootCleanups];
|
|
1452
|
+
await Promise.allSettled([...detached.map((owner) => this.cleanupDetached(owner)), ...pending]);
|
|
739
1453
|
if (client) {
|
|
740
1454
|
try {
|
|
741
1455
|
client.close();
|
|
742
1456
|
}
|
|
743
1457
|
catch {
|
|
744
|
-
//
|
|
1458
|
+
// Best effort; route cleanup has already been attempted.
|
|
745
1459
|
}
|
|
746
1460
|
}
|
|
747
1461
|
}
|
|
748
|
-
/**
|
|
749
|
-
* Tear down a single session's bg subscription (and tool route) — the
|
|
750
|
-
* per-session close hook (Oracle #5). Idempotent. Wired to OpenCode session-end
|
|
751
|
-
* / Pi equivalent in S4; until then, shutdown() covers teardown.
|
|
752
|
-
*/
|
|
753
1462
|
async closeSession(projectRoot, session) {
|
|
754
1463
|
const identity = {
|
|
755
1464
|
project_root: canonicalizeProjectRoot(projectRoot),
|
|
756
1465
|
harness: this.harness,
|
|
757
1466
|
session: session && session.length > 0 ? session : DEFAULT_SESSION_ID,
|
|
758
1467
|
};
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
return;
|
|
763
|
-
// All lifecycle mutation happens before the first await. In-flight requests
|
|
764
|
-
// resumed after this point observe !isCurrentSession and skip route opens,
|
|
765
|
-
// bg-sub resurrection, and client failure-budget mutation.
|
|
766
|
-
record.closed = true;
|
|
767
|
-
this.sessions.delete(key);
|
|
768
|
-
const sub = record.bgSub;
|
|
769
|
-
record.bgSub = null;
|
|
770
|
-
const entry = record.routeEntry;
|
|
771
|
-
record.routeEntry = null;
|
|
772
|
-
if (entry)
|
|
773
|
-
entry.closed = true;
|
|
774
|
-
if (sub)
|
|
775
|
-
await sub.stop();
|
|
776
|
-
if (entry?.handle != null) {
|
|
777
|
-
try {
|
|
778
|
-
await entry.client.closeRouteChannel(entry.handle);
|
|
779
|
-
}
|
|
780
|
-
catch {
|
|
781
|
-
// best-effort; a dropped connection releases the route on the other side
|
|
782
|
-
}
|
|
783
|
-
}
|
|
1468
|
+
const owner = this.detachSession(identityKey(identity), "session_closed");
|
|
1469
|
+
if (owner)
|
|
1470
|
+
await this.cleanupDetached(owner);
|
|
784
1471
|
}
|
|
785
1472
|
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Resolve a background nudge exactly once by looking up the existing active
|
|
1475
|
+
* bridge without creating one or reviving a shut-down pool; hosts use this
|
|
1476
|
+
* function to acknowledge nudge handling.
|
|
1477
|
+
*/
|
|
1478
|
+
export function resolveBridgeForNudge(pool, ref) {
|
|
1479
|
+
const candidate = pool;
|
|
1480
|
+
const bridge = candidate.getActiveBridgeForRootGeneration?.(ref) ?? null;
|
|
1481
|
+
if (bridge)
|
|
1482
|
+
return bridge;
|
|
1483
|
+
candidate.recordBgNudgeRejection?.(ref);
|
|
1484
|
+
throw new SubcRootGenerationExpiredError({
|
|
1485
|
+
canonicalRoot: ref.canonicalRoot,
|
|
1486
|
+
expectedGeneration: ref.generation,
|
|
1487
|
+
currentGeneration: candidate.getCurrentRootGeneration?.(ref.canonicalRoot),
|
|
1488
|
+
concretePoolId: ref.concretePoolId,
|
|
1489
|
+
currentConcretePoolId: candidate.getConcretePoolId?.(),
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
786
1492
|
//# sourceMappingURL=subc-transport.js.map
|