@cortexkit/aft-bridge 0.42.0 → 0.43.1

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.
@@ -0,0 +1,725 @@
1
+ /**
2
+ * Subconscious (subc) transport — the daemon-backed alternative to the standalone
3
+ * NDJSON {@link BinaryBridge}. Implements the SAME {@link AftProjectTransport} /
4
+ * {@link AftTransportPool} interfaces the plugins consume, so the entire tool /
5
+ * hoisting / permission / UI surface stays transport-agnostic: only the ONE
6
+ * construction site (BridgePool vs SubcTransportPool) differs.
7
+ *
8
+ * Standalone model: one `aft` child process per project root, session passed
9
+ * per call. Subc model: ONE {@link SubcClient} per process (one authenticated
10
+ * daemon connection), and a route opened+cached per `(project_root, harness,
11
+ * session)` triple — exactly subc's {@link BindIdentity}. So the "pool" here is a
12
+ * route cache over a single client, not N child processes.
13
+ *
14
+ * This module is S2 of B-FINAL: the tool-call route only. The bg_events idle-wake
15
+ * subscription (S3) and the config gate that selects this transport (S4) build on
16
+ * top of it. subc-client is a build-time path dependency bundled into the
17
+ * published plugin dist; it is never a published runtime dependency.
18
+ */
19
+ import { connectionFileExists, isConsumerReconnectTransient, SubcCallError, SubcClient, } from "@cortexkit/subc-client";
20
+ import { canonicalizeProjectRoot } from "./project-identity.js";
21
+ import { parseStatusBarCounts } from "./status-bar.js";
22
+ /** The subc module id AFT registers under (matches the daemon manifest). */
23
+ const AFT_MODULE_ID = "aft";
24
+ /**
25
+ * A run of consecutive NON-transient transport throws (timeout / route GOODBYE)
26
+ * on the SAME client is presumed a dead half-open connection (local writes
27
+ * succeed, no response ever arrives), so the client is dropped after this many.
28
+ * A single throw does not drop the client (a slow tool can legitimately time out
29
+ * once); the counter resets on any successful request. Tool-level errors never
30
+ * count — they return `success:false`, they do not throw. (Audit B-#4.)
31
+ */
32
+ const MAX_CONSECUTIVE_TRANSPORT_FAILURES = 3;
33
+ /**
34
+ * A bg subscription that stayed up at least this long before dropping is treated
35
+ * as "stable", so its reconnect backoff resets to zero. A subscription that fails
36
+ * faster than this is escalating-broken and must keep backing off toward the cap
37
+ * (otherwise a permanently-failing route resubscribes in a 100ms hot loop). (B-#2.)
38
+ */
39
+ const BG_STABLE_MS = 5_000;
40
+ /**
41
+ * Session fallback when a tool runtime carries no session id, mirroring the Rust
42
+ * `DEFAULT_SESSION_ID` (`protocol.rs`). Keeps undo/checkpoint/bash namespacing
43
+ * identical to the standalone path for session-less calls.
44
+ */
45
+ const DEFAULT_SESSION_ID = "__default__";
46
+ /**
47
+ * Commands the plugin issues via `send()` that have NO meaning over subc and must
48
+ * never hit the wire. `configure` is the prime case: under subc the RouteBind IS
49
+ * the configure (AFT reads local `.cortexkit` config and ignores wire tiers — see
50
+ * the unified-config model), so a `send("configure", …)` is satisfied locally
51
+ * with a synthetic success rather than a route call.
52
+ */
53
+ const LOCALLY_SATISFIED_COMMANDS = new Set(["configure"]);
54
+ function identityKey(identity) {
55
+ return `${identity.project_root}\u0000${identity.harness}\u0000${identity.session}`;
56
+ }
57
+ /**
58
+ * One session's held-open bg_events subscription with its OWN reconnect driver.
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).
67
+ *
68
+ * The loop is a single sequential async task (only one attempt in flight at a
69
+ * time), so no numeric generation guard is needed — `stopped` plus one-instance-
70
+ * per-identity (the pool's bgSubs map) prevents duplicate or stale subscribes.
71
+ */
72
+ class BgSubscription {
73
+ identity;
74
+ acquireClient;
75
+ dropClient;
76
+ onNudge;
77
+ sleep;
78
+ stopped = false;
79
+ /** The live subscription handle, read by stop() to wake the loop's `await closed`. */
80
+ current = null;
81
+ loop;
82
+ constructor(identity, acquireClient, dropClient, onNudge, sleep) {
83
+ this.identity = identity;
84
+ this.acquireClient = acquireClient;
85
+ this.dropClient = dropClient;
86
+ this.onNudge = onNudge;
87
+ this.sleep = sleep;
88
+ this.loop = this.run();
89
+ }
90
+ async stop() {
91
+ this.stopped = true;
92
+ // Wake a live `await sub.closed` so the loop unwinds via its StreamEnd path,
93
+ // where the `finally` is the SOLE owner of closeRouteChannel (so the channel
94
+ // is closed exactly once, never double-closed by stop() + the loop). If the
95
+ // loop is between routeOpen and subscribe, its post-subscribe `stopped`
96
+ // re-check (or the pre-subscribe check) closes/returns instead.
97
+ const sub = this.current;
98
+ if (sub) {
99
+ try {
100
+ sub.unsubscribe();
101
+ }
102
+ catch {
103
+ // best-effort; the socket may already be gone
104
+ }
105
+ }
106
+ await this.loop.catch(() => undefined);
107
+ }
108
+ async run() {
109
+ let attempt = 0;
110
+ while (!this.stopped) {
111
+ let client;
112
+ try {
113
+ client = await this.acquireClient();
114
+ }
115
+ catch {
116
+ await this.backoff(attempt++);
117
+ continue;
118
+ }
119
+ if (this.stopped)
120
+ return;
121
+ let channel;
122
+ try {
123
+ // A SECOND, dedicated routeOpen (NOT the tool route cache): the daemon
124
+ // mints a fresh channel per route.open, so the bg_events subscribe rides
125
+ // its own channel, isolated from the tool route's credit window.
126
+ channel = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, this.identity);
127
+ }
128
+ catch (err) {
129
+ // routeOpen failed. If it signals a dead CONNECTION, drop the shared
130
+ // client so the next `acquireClient` reconnects fresh — the idle-stranding
131
+ // fix (B-#1): `acquireClient` returns the cached client, so without this an
132
+ // idle bg loop would resubscribe forever onto the same dead socket.
133
+ if (isConsumerReconnectTransient(err))
134
+ this.dropClient(client);
135
+ await this.backoff(attempt++);
136
+ continue;
137
+ }
138
+ if (this.stopped) {
139
+ safeCloseRoute(client, channel);
140
+ return;
141
+ }
142
+ // Channel lifetime: the `finally` guarantees closeRouteChannel on EVERY exit
143
+ // path from here (StreamEnd return, drop+resubscribe, stopped) so the
144
+ // dedicated route never leaks (B-#2).
145
+ const subscribedAt = Date.now();
146
+ try {
147
+ const sub = client.subscribe(channel, { op: "bg_events" }, () => {
148
+ if (!this.stopped)
149
+ this.onNudge();
150
+ });
151
+ this.current = sub;
152
+ // stop() may have fired between the pre-subscribe check and here; self-
153
+ // unsubscribe so the await below resolves immediately (avoids a stop()
154
+ // that hangs awaiting a subscription nobody woke).
155
+ if (this.stopped)
156
+ sub.unsubscribe();
157
+ // Immediate forced-drain replay: a completion that landed while we were
158
+ // disconnected is recovered now (resubscribe == the outbox replay trigger).
159
+ if (!this.stopped)
160
+ this.onNudge();
161
+ await sub.closed;
162
+ // StreamEnd = an intentional close (our unsubscribe or module teardown).
163
+ // Do NOT resubscribe.
164
+ return;
165
+ }
166
+ catch (err) {
167
+ // Dropped (socket death / route GOODBYE / Error). Resubscribe — this is
168
+ // the independent reconnect driver that fixes idle-stranding.
169
+ if (this.stopped)
170
+ return;
171
+ // A dead-connection drop must replace the client (B-#1); a route-only
172
+ // GOODBYE keeps the client and just re-opens a fresh route.
173
+ if (isConsumerReconnectTransient(err))
174
+ this.dropClient(client);
175
+ // Reset backoff ONLY if the subscription was stable before dropping;
176
+ // otherwise a permanently-failing route would resubscribe in a 100ms hot
177
+ // loop and never reach the cap (B-#2).
178
+ if (Date.now() - subscribedAt >= BG_STABLE_MS)
179
+ attempt = 0;
180
+ }
181
+ finally {
182
+ this.current = null;
183
+ safeCloseRoute(client, channel);
184
+ }
185
+ await this.backoff(attempt++);
186
+ }
187
+ }
188
+ async backoff(attempt) {
189
+ const ms = Math.min(100 * 2 ** Math.min(attempt, 6), 2000);
190
+ await this.sleep(ms);
191
+ }
192
+ }
193
+ function isRecord(value) {
194
+ return typeof value === "object" && value !== null && !Array.isArray(value);
195
+ }
196
+ /**
197
+ * Fire-and-forget route close that can never throw — neither synchronously (a
198
+ * client that rejects/throws when closing a route on an already-dead socket) nor
199
+ * via an unhandled rejection. Used on every best-effort teardown path.
200
+ */
201
+ function safeCloseRoute(client, channel) {
202
+ try {
203
+ void client.closeRouteChannel(channel).catch(() => undefined);
204
+ }
205
+ catch {
206
+ // synchronous throw (e.g. closing a route on an already-closed client) — ignore
207
+ }
208
+ }
209
+ /**
210
+ * A route open that resolved AFTER its session was torn down (closeSession /
211
+ * shutdown / client swap). The caller's request can't proceed, but this is an
212
+ * intentional teardown — NOT a transport fault — so it must not drop the client
213
+ * or count toward the half-open-socket failure budget (B-#3/B-#4).
214
+ */
215
+ class RouteTornDownError extends Error {
216
+ }
217
+ /**
218
+ * Re-lift the route reply into the flat {@link ToolCallResult} shape the standalone
219
+ * `BinaryBridge.toolCall` returns. The Rust module wraps the full flat response
220
+ * (`{id, success, …data, text}`) under `structuredContent` (S1 envelope), alongside
221
+ * the MCP `{content, isError}` a generic host reads. The first-party plugin reads
222
+ * `structuredContent`, so re-lifting it makes everything downstream (status_bar,
223
+ * bg_completions, preview_diff, code, …) byte-identical to NDJSON.
224
+ *
225
+ * Every AFT tool reply over subc carries this envelope with a boolean `success`
226
+ * and string `text`. A reply missing the envelope, or whose lifted shape lacks a
227
+ * boolean `success`, is a PROTOCOL VIOLATION — never a tool result — and is thrown
228
+ * rather than coerced. Coercing it (the old `{success:false,text:""}` /
229
+ * raw-record fallback) could let a malformed reply with `success === undefined`
230
+ * read downstream as a successful tool result (audit B-#7). Surfacing it loudly is
231
+ * the honest contract: a broken wire shape is a failure, not a silent empty pass.
232
+ */
233
+ function reliftReply(reply) {
234
+ if (!isRecord(reply) || !isRecord(reply.structuredContent)) {
235
+ throw new Error("subc tool reply is missing the structuredContent envelope (protocol violation)");
236
+ }
237
+ const flat = reply.structuredContent;
238
+ if (typeof flat.success !== "boolean" || typeof flat.text !== "string") {
239
+ throw new Error("subc tool reply structuredContent lacks a boolean `success` / string `text` (protocol violation)");
240
+ }
241
+ return flat;
242
+ }
243
+ /**
244
+ * One project root's view onto the shared subc client. Holds per-root status
245
+ * caches (mirroring BinaryBridge) and routes every call through the pool's single
246
+ * client, opening+caching a route per `(root, harness, session)`.
247
+ */
248
+ class SubcTransport {
249
+ pool;
250
+ projectRoot;
251
+ lastStatusBar;
252
+ cachedStatus = null;
253
+ constructor(pool, projectRoot) {
254
+ this.pool = pool;
255
+ this.projectRoot = projectRoot;
256
+ }
257
+ getCwd() {
258
+ return this.projectRoot;
259
+ }
260
+ getStatusBar() {
261
+ return this.lastStatusBar;
262
+ }
263
+ getCachedStatus() {
264
+ return this.cachedStatus;
265
+ }
266
+ cacheStatusSnapshot(snapshot) {
267
+ this.cachedStatus = snapshot;
268
+ }
269
+ captureStatusBar(response) {
270
+ const parsed = parseStatusBarCounts(response.status_bar);
271
+ if (parsed)
272
+ this.lastStatusBar = parsed;
273
+ }
274
+ identityFor(session) {
275
+ return {
276
+ project_root: this.projectRoot,
277
+ harness: this.pool.harness,
278
+ session: session && session.length > 0 ? session : DEFAULT_SESSION_ID,
279
+ };
280
+ }
281
+ async toolCall(sessionId, name, rawArgs = {}, options) {
282
+ const { preview, timeoutMs, onProgress } = this.splitOptions(options);
283
+ const body = { name, arguments: rawArgs };
284
+ if (preview === true)
285
+ body.preview = true;
286
+ const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress);
287
+ const result = reliftReply(reply);
288
+ this.captureStatusBar(result);
289
+ return result;
290
+ }
291
+ /**
292
+ * Lifecycle / native-command path. Over subc there is no separate "native
293
+ * command" channel — every command rides the tool_provider route as a
294
+ * `{name, arguments}` Request and the module's gate decides validity (the 21
295
+ * core tools plus the `bash_drain_completions` / `bash_ack_completions` plumbing
296
+ * allowlist). The bind session is taken from `params.session_id` so a
297
+ * session-scoped command (drain/ack) reaches the matching route — the module
298
+ * re-injects the BIND session over any body session, so the route identity is
299
+ * what scopes it. `configure` is satisfied locally (binding is the configure).
300
+ */
301
+ async send(command, params = {}, options) {
302
+ if (LOCALLY_SATISFIED_COMMANDS.has(command)) {
303
+ return { success: true, command, subc_local: true };
304
+ }
305
+ const { timeoutMs, onProgress } = this.splitOptions(options);
306
+ const session = typeof params.session_id === "string" ? params.session_id : undefined;
307
+ const reply = await this.pool.routeRequest(this.identityFor(session), { name: command, arguments: params }, timeoutMs, onProgress);
308
+ const response = reliftReply(reply);
309
+ this.captureStatusBar(response);
310
+ return response;
311
+ }
312
+ splitOptions(options) {
313
+ if (!options)
314
+ return {};
315
+ const preview = options.preview;
316
+ const timeoutMs = options.timeoutMs;
317
+ const onProgress = options.onProgress;
318
+ return { preview, timeoutMs, onProgress };
319
+ }
320
+ }
321
+ /**
322
+ * Route cache over one authenticated subc client. Implements {@link AftTransportPool}
323
+ * so it drops into the plugin in place of {@link BridgePool} behind the shared
324
+ * interface. One client per process; routes keyed by `(root, harness, session)`.
325
+ */
326
+ export class SubcTransportPool {
327
+ harness;
328
+ connectionFile;
329
+ handshakeTimeoutMs;
330
+ connectFn;
331
+ onBgEventsNudge;
332
+ bgBackoffSleep;
333
+ client = null;
334
+ /** Single-flight guard so concurrent first calls share one connect. */
335
+ connecting = null;
336
+ /** Per-session lifecycle records keyed by a string built from root, harness, and session. */
337
+ sessions = new Map();
338
+ /**
339
+ * Consecutive NON-transient transport throws on the current client with no
340
+ * success in between. Resets to 0 on any successful request. Trips a client drop
341
+ * at {@link MAX_CONSECUTIVE_TRANSPORT_FAILURES} to recover a half-open socket
342
+ * whose timeouts never classify transient (B-#4).
343
+ */
344
+ transportFailures = 0;
345
+ /** Per-root transport facades returned by getBridge/getActiveBridgeForRoot. */
346
+ transports = new Map();
347
+ shuttingDown = false;
348
+ constructor(options) {
349
+ this.connectionFile = options.connectionFile;
350
+ this.harness = options.harness;
351
+ this.handshakeTimeoutMs = options.handshakeTimeoutMs;
352
+ this.connectFn = options.connect ?? ((opts) => SubcClient.connect(opts));
353
+ this.onBgEventsNudge = options.onBgEventsNudge;
354
+ this.bgBackoffSleep =
355
+ options.bgBackoffSleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
356
+ }
357
+ /**
358
+ * Fail-loud presence check (memory: present-but-unconnectable must never silently
359
+ * downgrade to standalone). Returns false only when the file is genuinely absent.
360
+ */
361
+ static async connectionAvailable(connectionFile) {
362
+ return connectionFileExists(connectionFile);
363
+ }
364
+ getBridge(projectRoot) {
365
+ const key = canonicalizeProjectRoot(projectRoot);
366
+ let transport = this.transports.get(key);
367
+ if (!transport) {
368
+ transport = new SubcTransport(this, key);
369
+ this.transports.set(key, transport);
370
+ }
371
+ return transport;
372
+ }
373
+ getActiveBridgeForRoot(projectRoot) {
374
+ const key = canonicalizeProjectRoot(projectRoot);
375
+ if (!this.client)
376
+ return null;
377
+ return this.transports.get(key) ?? null;
378
+ }
379
+ async toolCall(projectRoot, runtime, name, rawArgs = {}, options) {
380
+ return this.getBridge(projectRoot).toolCall(runtime.sessionID, name, rawArgs, options);
381
+ }
382
+ getOrCreateSession(key) {
383
+ let record = this.sessions.get(key);
384
+ if (!record || record.closed) {
385
+ record = { routeEntry: null, bgSub: null, closed: false, inflight: 0 };
386
+ this.sessions.set(key, record);
387
+ }
388
+ return record;
389
+ }
390
+ isCurrentSession(key, record) {
391
+ return this.sessions.get(key) === record && !record.closed;
392
+ }
393
+ deleteSessionIfEmpty(key, record) {
394
+ if (this.sessions.get(key) === record &&
395
+ !record.closed &&
396
+ record.inflight === 0 &&
397
+ record.routeEntry === null &&
398
+ record.bgSub === null) {
399
+ this.sessions.delete(key);
400
+ }
401
+ }
402
+ /**
403
+ * Open-or-reuse a route for `identity` and send `body` as a data-plane Request.
404
+ * Rd reconnect (mutation-safe by construction — NEVER auto-retries): on a
405
+ * transport-level {@link SubcCallError} the cached channel is discarded and the
406
+ * dead client cleared so the NEXT call re-establishes, but the failed call is
407
+ * surfaced to the agent unchanged (identical to a standalone bridge death). Only
408
+ * `SubcClient.request` transport failures throw here; a tool-level error comes
409
+ * back as a normal reply with `success:false` and is returned, not thrown.
410
+ */
411
+ async routeRequest(identity, body, timeoutMs, onProgress) {
412
+ const key = identityKey(identity);
413
+ const record = this.getOrCreateSession(key);
414
+ record.inflight += 1;
415
+ try {
416
+ const client = await this.ensureClient();
417
+ // closeSession/shutdown marks and deletes the record synchronously. A request
418
+ // that was waiting for connect must not open a route for a session that no
419
+ // longer exists.
420
+ if (!this.isCurrentSession(key, record)) {
421
+ throw new RouteTornDownError("subc session closed");
422
+ }
423
+ let channel;
424
+ let entry;
425
+ try {
426
+ ({ channel, entry } = await this.routeChannel(client, identity, record));
427
+ // routeOpen may have awaited. Do not send a request after a close, even
428
+ // if a stale route entry just resolved.
429
+ if (!this.isCurrentSession(key, record)) {
430
+ throw new RouteTornDownError("subc session closed");
431
+ }
432
+ }
433
+ catch (err) {
434
+ // A teardown that raced the open is not a transport fault — surface it
435
+ // without dropping the client or charging the failure budget.
436
+ if (err instanceof RouteTornDownError)
437
+ throw err;
438
+ // routeOpen itself failed. Classify the error like other request failures
439
+ // for transient connection death, but only while this request's session
440
+ // and client are still current. A close-induced failure must not drop a
441
+ // healthy client shared by other sessions.
442
+ if (isConsumerReconnectTransient(err) &&
443
+ this.isCurrentSession(key, record) &&
444
+ this.client === client) {
445
+ this.dropClient(client);
446
+ }
447
+ throw err;
448
+ }
449
+ try {
450
+ // Forward the caller's progress callback to the subc client. Current
451
+ // foreground calls return one final reply, so production does not rely on
452
+ // live progress events here.
453
+ const reply = await client.request(channel, body, { timeoutMs, onProgress });
454
+ // The half-open failure budget belongs to the current live session on the
455
+ // current client. A late success after close or client replacement must
456
+ // not mutate the current client's counter.
457
+ if (this.isCurrentSession(key, record) && this.client === client) {
458
+ this.transportFailures = 0;
459
+ }
460
+ // bg_events is a session resource, not a route-entry resource. A
461
+ // successful request on an older route should still ensure the subscription
462
+ // while the session remains open, and a late success after close must not
463
+ // resurrect one.
464
+ this.ensureBgSubscription(identity, record);
465
+ return reply;
466
+ }
467
+ catch (err) {
468
+ // The raw `request()` path does not turn failures into SubcCallError; it
469
+ // rejects with a base SubcError for route-level failures or a raw socket
470
+ // error for connection failures. Use `isConsumerReconnectTransient`, not
471
+ // `instanceof SubcCallError`, to distinguish a dead connection from a dead
472
+ // route.
473
+ //
474
+ // A failed request makes its own route suspect, so drop it and let the next
475
+ // call re-open. Check only the current entry; a stale failure must not
476
+ // delete a successor route in the same still-open session.
477
+ if (record.routeEntry === entry) {
478
+ entry.closed = true;
479
+ record.routeEntry = null;
480
+ }
481
+ // Only a failure from the current session on the current client can charge
482
+ // or drop that client. A failure caused by closeSession sees
483
+ // !isCurrentSession because close marked/deleted the record before awaiting
484
+ // transport cleanup.
485
+ if (this.isCurrentSession(key, record) && this.client === client) {
486
+ if (isConsumerReconnectTransient(err)) {
487
+ this.transportFailures = 0;
488
+ this.dropClient(client);
489
+ }
490
+ else if (++this.transportFailures >= MAX_CONSECUTIVE_TRANSPORT_FAILURES) {
491
+ // A run of non-transient throws (timeouts / route GOODBYEs) with no
492
+ // success between them is a half-open socket: local writes succeed but
493
+ // no response ever returns, so isConsumerReconnectTransient never fires
494
+ // and the client would otherwise be kept forever. Force reconnect.
495
+ this.transportFailures = 0;
496
+ this.dropClient(client);
497
+ }
498
+ }
499
+ throw err;
500
+ }
501
+ }
502
+ finally {
503
+ record.inflight -= 1;
504
+ this.deleteSessionIfEmpty(key, record);
505
+ }
506
+ }
507
+ async ensureClient() {
508
+ if (this.shuttingDown) {
509
+ throw new SubcCallError("terminal", "subc transport is shutting down");
510
+ }
511
+ if (this.client)
512
+ return this.client;
513
+ if (this.connecting)
514
+ return this.connecting;
515
+ this.connecting = this.connectFn({
516
+ connectionFile: this.connectionFile,
517
+ handshakeTimeoutMs: this.handshakeTimeoutMs,
518
+ })
519
+ .then((client) => {
520
+ this.connecting = null;
521
+ // shutdown() may have fired while this connect was in flight. Don't
522
+ // install a live client after teardown — close it and fail the call
523
+ // (B-#5: otherwise a socket leaks open past shutdown()).
524
+ if (this.shuttingDown) {
525
+ try {
526
+ client.close();
527
+ }
528
+ catch {
529
+ // best-effort
530
+ }
531
+ throw new SubcCallError("terminal", "subc transport is shutting down");
532
+ }
533
+ this.client = client;
534
+ // Fresh client generation starts with a clean failure budget (R2-T2).
535
+ this.transportFailures = 0;
536
+ return client;
537
+ })
538
+ .catch((err) => {
539
+ this.connecting = null;
540
+ throw err;
541
+ });
542
+ return this.connecting;
543
+ }
544
+ /**
545
+ * Resolve the route channel for `identity`, returning BOTH the channel and the
546
+ * `RouteEntry` token that owns it. The route-entry token is the route-cache
547
+ * ownership guard: stale request failures can clear only the route they used,
548
+ * never a successor route in the same still-open session.
549
+ */
550
+ async routeChannel(client, identity, record) {
551
+ const key = identityKey(identity);
552
+ const existing = record.routeEntry;
553
+ if (existing?.channel != null)
554
+ return { channel: existing.channel, entry: existing };
555
+ if (existing?.opening)
556
+ return { channel: await existing.opening, entry: existing };
557
+ // Singleflight: install the entry BEFORE awaiting so a concurrent caller for
558
+ // the same identity awaits this same open instead of minting a second channel.
559
+ const entry = { client, opening: null, channel: null, closed: false };
560
+ const opening = client
561
+ .routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity)
562
+ .then((channel) => {
563
+ // A close/shutdown may have raced this open, or this entry may have been
564
+ // replaced by a transient drop/reopen. In both cases, release the freshly
565
+ // minted channel and clear only this entry if it still owns the cache slot.
566
+ if (!this.isCurrentSession(key, record) ||
567
+ record.routeEntry !== entry ||
568
+ entry.closed ||
569
+ this.client !== client) {
570
+ safeCloseRoute(client, channel);
571
+ if (record.routeEntry === entry)
572
+ record.routeEntry = null;
573
+ throw new RouteTornDownError("subc route opened after teardown");
574
+ }
575
+ entry.channel = channel;
576
+ entry.opening = null;
577
+ return channel;
578
+ })
579
+ .catch((err) => {
580
+ const current = this.isCurrentSession(key, record);
581
+ // Failed open: drop this entry so the next call retries cleanly, but never
582
+ // delete a successor entry that replaced it while this open was in flight.
583
+ if (record.routeEntry === entry) {
584
+ entry.closed = true;
585
+ record.routeEntry = null;
586
+ }
587
+ if (!current && !(err instanceof RouteTornDownError)) {
588
+ throw new RouteTornDownError("subc route opened after session closed");
589
+ }
590
+ throw err;
591
+ });
592
+ entry.opening = opening;
593
+ record.routeEntry = entry;
594
+ return { channel: await opening, entry };
595
+ }
596
+ /**
597
+ * Open the dedicated bg_events subscription for `identity` once. Idempotent —
598
+ * a second call for the same identity is a no-op (one sub per session, the
599
+ * duplicate-sub guard from Oracle #2). No-op when no nudge handler is wired,
600
+ * during shutdown, or after the session has been closed.
601
+ */
602
+ ensureBgSubscription(identity, record) {
603
+ if (this.shuttingDown || !this.onBgEventsNudge)
604
+ return;
605
+ const key = identityKey(identity);
606
+ if (!this.isCurrentSession(key, record))
607
+ return;
608
+ if (record.bgSub)
609
+ return;
610
+ const onNudge = () => this.onBgEventsNudge?.(identity.project_root, identity.session);
611
+ const sub = new BgSubscription(identity, () => this.ensureClient(), (client) => this.dropClient(client), onNudge, this.bgBackoffSleep);
612
+ record.bgSub = sub;
613
+ }
614
+ /** Drop a dead client so the next call reconnects; preserves session records. */
615
+ dropClient(client) {
616
+ if (this.client === client) {
617
+ this.client = null;
618
+ for (const [key, record] of this.sessions) {
619
+ const entry = record.routeEntry;
620
+ if (entry?.client === client) {
621
+ entry.closed = true;
622
+ record.routeEntry = null;
623
+ this.deleteSessionIfEmpty(key, record);
624
+ }
625
+ }
626
+ // The half-open failure counter is per-client-generation: a dropped client
627
+ // resets it so the NEXT client starts fresh (R2-T2). Without this, failures
628
+ // accrued on a client dropped via another path (e.g. a bg-subscription
629
+ // transient drop) would carry over and trip the backstop on a healthy new
630
+ // client after a single failure.
631
+ this.transportFailures = 0;
632
+ try {
633
+ client.close();
634
+ }
635
+ catch {
636
+ // best-effort; the socket is already gone
637
+ }
638
+ }
639
+ }
640
+ /** No-op over subc: config is read locally by AFT (wire tiers are ignored). */
641
+ setConfigureOverride(_key, _value) { }
642
+ /** No-op over subc: the daemon supervises the binary, not the plugin. */
643
+ async replaceBinary(path) {
644
+ return path;
645
+ }
646
+ async shutdown() {
647
+ this.shuttingDown = true;
648
+ const subs = [];
649
+ const entries = [];
650
+ for (const record of this.sessions.values()) {
651
+ record.closed = true;
652
+ const sub = record.bgSub;
653
+ record.bgSub = null;
654
+ if (sub)
655
+ subs.push(sub);
656
+ const entry = record.routeEntry;
657
+ record.routeEntry = null;
658
+ if (entry) {
659
+ entry.closed = true;
660
+ entries.push(entry);
661
+ }
662
+ }
663
+ this.sessions.clear();
664
+ const client = this.client;
665
+ this.client = null;
666
+ this.transports.clear();
667
+ await Promise.allSettled(subs.map((sub) => sub.stop()));
668
+ await Promise.allSettled(entries.map(async (entry) => {
669
+ if (entry.channel == null)
670
+ return;
671
+ try {
672
+ await entry.client.closeRouteChannel(entry.channel);
673
+ }
674
+ catch {
675
+ // best-effort; a dropped connection releases the route on the other side
676
+ }
677
+ }));
678
+ if (client) {
679
+ try {
680
+ client.close();
681
+ }
682
+ catch {
683
+ // best-effort
684
+ }
685
+ }
686
+ }
687
+ /**
688
+ * Tear down a single session's bg subscription (and tool route) — the
689
+ * per-session close hook (Oracle #5). Idempotent. Wired to OpenCode session-end
690
+ * / Pi equivalent in S4; until then, shutdown() covers teardown.
691
+ */
692
+ async closeSession(projectRoot, session) {
693
+ const identity = {
694
+ project_root: canonicalizeProjectRoot(projectRoot),
695
+ harness: this.harness,
696
+ session: session && session.length > 0 ? session : DEFAULT_SESSION_ID,
697
+ };
698
+ const key = identityKey(identity);
699
+ const record = this.sessions.get(key);
700
+ if (!record)
701
+ return;
702
+ // All lifecycle mutation happens before the first await. In-flight requests
703
+ // resumed after this point observe !isCurrentSession and skip route opens,
704
+ // bg-sub resurrection, and client failure-budget mutation.
705
+ record.closed = true;
706
+ this.sessions.delete(key);
707
+ const sub = record.bgSub;
708
+ record.bgSub = null;
709
+ const entry = record.routeEntry;
710
+ record.routeEntry = null;
711
+ if (entry)
712
+ entry.closed = true;
713
+ if (sub)
714
+ await sub.stop();
715
+ if (entry?.channel != null) {
716
+ try {
717
+ await entry.client.closeRouteChannel(entry.channel);
718
+ }
719
+ catch {
720
+ // best-effort; a dropped connection releases the route on the other side
721
+ }
722
+ }
723
+ }
724
+ }
725
+ //# sourceMappingURL=subc-transport.js.map