@cortexkit/aft-bridge 0.49.2 → 0.49.4

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.
Files changed (40) hide show
  1. package/dist/cache-paths.d.ts +26 -0
  2. package/dist/cache-paths.d.ts.map +1 -0
  3. package/dist/cache-paths.js +37 -0
  4. package/dist/cache-paths.js.map +1 -0
  5. package/dist/downloader.d.ts +2 -3
  6. package/dist/downloader.d.ts.map +1 -1
  7. package/dist/downloader.js +5 -13
  8. package/dist/downloader.js.map +1 -1
  9. package/dist/durable-log.d.ts +3 -1
  10. package/dist/durable-log.d.ts.map +1 -1
  11. package/dist/durable-log.js +4 -2
  12. package/dist/durable-log.js.map +1 -1
  13. package/dist/error-contract.d.ts +7 -0
  14. package/dist/error-contract.d.ts.map +1 -1
  15. package/dist/error-contract.js +35 -0
  16. package/dist/error-contract.js.map +1 -1
  17. package/dist/index.d.ts +3 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +4 -3
  20. package/dist/index.js.map +1 -1
  21. package/dist/lifecycle-registry.d.ts +196 -0
  22. package/dist/lifecycle-registry.d.ts.map +1 -0
  23. package/dist/lifecycle-registry.js +471 -0
  24. package/dist/lifecycle-registry.js.map +1 -0
  25. package/dist/resolver.d.ts.map +1 -1
  26. package/dist/resolver.js +4 -11
  27. package/dist/resolver.js.map +1 -1
  28. package/dist/revivable-transport.d.ts +10 -0
  29. package/dist/revivable-transport.d.ts.map +1 -1
  30. package/dist/revivable-transport.js +23 -0
  31. package/dist/revivable-transport.js.map +1 -1
  32. package/dist/subc-transport.d.ts +202 -42
  33. package/dist/subc-transport.d.ts.map +1 -1
  34. package/dist/subc-transport.js +810 -271
  35. package/dist/subc-transport.js.map +1 -1
  36. package/dist/transport-factory.d.ts +19 -0
  37. package/dist/transport-factory.d.ts.map +1 -1
  38. package/dist/transport-factory.js +71 -2
  39. package/dist/transport-factory.js.map +1 -1
  40. package/package.json +1 -1
@@ -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). */
@@ -51,6 +54,52 @@ const DEFAULT_SESSION_ID = "__default__";
51
54
  * with a synthetic success rather than a route call.
52
55
  */
53
56
  const LOCALLY_SATISFIED_COMMANDS = new Set(["configure"]);
57
+ /** Stable classification for a request that loses its root to coordinated reap. */
58
+ export class SubcRootReapedError extends Error {
59
+ code = "root_reaped";
60
+ name = "SubcRootReapedError";
61
+ canonicalRoot;
62
+ expectedGeneration;
63
+ currentGeneration;
64
+ concretePoolId;
65
+ currentConcretePoolId;
66
+ constructor(fields) {
67
+ super(`subc root was reaped: ${fields.canonicalRoot}`);
68
+ this.canonicalRoot = fields.canonicalRoot;
69
+ this.expectedGeneration = fields.expectedGeneration;
70
+ this.currentGeneration = fields.currentGeneration;
71
+ this.concretePoolId = fields.concretePoolId;
72
+ this.currentConcretePoolId = fields.currentConcretePoolId;
73
+ }
74
+ }
75
+ /** Stable classification for an operation holding an older root generation. */
76
+ export class SubcRootGenerationExpiredError extends Error {
77
+ code = "root_generation_expired";
78
+ name = "SubcRootGenerationExpiredError";
79
+ canonicalRoot;
80
+ expectedGeneration;
81
+ currentGeneration;
82
+ concretePoolId;
83
+ currentConcretePoolId;
84
+ constructor(fields) {
85
+ super(`subc root generation expired: ${fields.canonicalRoot}`);
86
+ this.canonicalRoot = fields.canonicalRoot;
87
+ this.expectedGeneration = fields.expectedGeneration;
88
+ this.currentGeneration = fields.currentGeneration;
89
+ this.concretePoolId = fields.concretePoolId;
90
+ this.currentConcretePoolId = fields.currentConcretePoolId;
91
+ }
92
+ }
93
+ /** A synchronous lifecycle demand check could not establish that the root exists. */
94
+ export class SubcRootDemandRequiredError extends Error {
95
+ code = "root_demand_required";
96
+ canonicalRoot;
97
+ constructor(root) {
98
+ super(`positive live demand is required before creating subc root state: ${root}`);
99
+ this.name = "SubcRootDemandRequiredError";
100
+ this.canonicalRoot = root;
101
+ }
102
+ }
54
103
  function identityKey(identity) {
55
104
  return `${identity.project_root}\u0000${identity.harness}\u0000${identity.session}`;
56
105
  }
@@ -66,8 +115,8 @@ function identityKey(identity) {
66
115
  * it until acked; resubscribe + the immediate forced-drain replay it).
67
116
  *
68
117
  * 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.
118
+ * time). `stopped`, the captured root-generation check, and one instance per
119
+ * identity prevent duplicate or stale subscriptions.
71
120
  */
72
121
  class BgSubscription {
73
122
  identity;
@@ -76,17 +125,27 @@ class BgSubscription {
76
125
  consumerIdentity;
77
126
  onNudge;
78
127
  sleep;
128
+ canAttach;
129
+ onRootAttachFailure;
130
+ onDormant;
131
+ nudgeRef;
132
+ isCurrent;
79
133
  stopped = false;
80
134
  /** The live subscription handle, read by stop() to wake the loop's `await closed`. */
81
135
  current = null;
82
136
  loop;
83
- constructor(identity, acquireClient, dropClient, consumerIdentity, onNudge, sleep) {
137
+ constructor(identity, acquireClient, dropClient, consumerIdentity, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, nudgeRef, isCurrent = () => true) {
84
138
  this.identity = identity;
85
139
  this.acquireClient = acquireClient;
86
140
  this.dropClient = dropClient;
87
141
  this.consumerIdentity = consumerIdentity;
88
142
  this.onNudge = onNudge;
89
143
  this.sleep = sleep;
144
+ this.canAttach = canAttach;
145
+ this.onRootAttachFailure = onRootAttachFailure;
146
+ this.onDormant = onDormant;
147
+ this.nudgeRef = nudgeRef;
148
+ this.isCurrent = isCurrent;
90
149
  this.loop = this.run();
91
150
  }
92
151
  async stop() {
@@ -110,6 +169,12 @@ class BgSubscription {
110
169
  async run() {
111
170
  let attempt = 0;
112
171
  while (!this.stopped) {
172
+ if (!this.isCurrent())
173
+ return;
174
+ if (!this.canAttach()) {
175
+ this.onDormant();
176
+ return;
177
+ }
113
178
  let client;
114
179
  try {
115
180
  client = await this.acquireClient();
@@ -118,8 +183,12 @@ class BgSubscription {
118
183
  await this.backoff(attempt++);
119
184
  continue;
120
185
  }
121
- if (this.stopped)
186
+ if (this.stopped || !this.isCurrent())
187
+ return;
188
+ if (!this.canAttach()) {
189
+ this.onDormant();
122
190
  return;
191
+ }
123
192
  let route;
124
193
  try {
125
194
  // A second dedicated route isolates bg_events from the tool route's
@@ -127,6 +196,10 @@ class BgSubscription {
127
196
  route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, this.identity, { consumerIdentity: this.consumerIdentity });
128
197
  }
129
198
  catch (err) {
199
+ if (this.isCurrent() && this.onRootAttachFailure(err)) {
200
+ this.onDormant();
201
+ return;
202
+ }
130
203
  // routeOpen failed. If it signals a dead CONNECTION, drop the shared
131
204
  // client so the next `acquireClient` reconnects fresh — the idle-stranding
132
205
  // fix (B-#1): `acquireClient` returns the cached client, so without this an
@@ -136,7 +209,7 @@ class BgSubscription {
136
209
  await this.backoff(attempt++);
137
210
  continue;
138
211
  }
139
- if (this.stopped) {
212
+ if (this.stopped || !this.isCurrent()) {
140
213
  safeCloseRoute(client, route);
141
214
  return;
142
215
  }
@@ -146,7 +219,7 @@ class BgSubscription {
146
219
  const subscribedAt = Date.now();
147
220
  try {
148
221
  const sub = client.subscribe(route, { op: "bg_events" }, () => {
149
- if (!this.stopped)
222
+ if (!this.stopped && this.isCurrent())
150
223
  this.onNudge();
151
224
  });
152
225
  this.current = sub;
@@ -157,7 +230,7 @@ class BgSubscription {
157
230
  sub.unsubscribe();
158
231
  // Immediate forced-drain replay: a completion that landed while we were
159
232
  // disconnected is recovered now (resubscribe == the outbox replay trigger).
160
- if (!this.stopped)
233
+ if (!this.stopped && this.isCurrent())
161
234
  this.onNudge();
162
235
  await sub.closed;
163
236
  // StreamEnd = an intentional close (our unsubscribe or module teardown).
@@ -198,6 +271,20 @@ function isRouteProvenAbsentError(err) {
198
271
  return ((err instanceof SubcError && err.code === "unknown_channel") ||
199
272
  err instanceof StaleRouteHandleError);
200
273
  }
274
+ /**
275
+ * A daemon can reject a route bind after the root has been reclaimed. This
276
+ * signature is deliberately narrower than config_divergence: other divergence
277
+ * causes remain transient from the transport's point of view.
278
+ */
279
+ function isAbsentRootRouteError(err) {
280
+ return (isRecord(err) &&
281
+ err.code === "config_divergence" &&
282
+ typeof err.message === "string" &&
283
+ err.message.includes("project root does not exist"));
284
+ }
285
+ function absentRootError(root) {
286
+ return new SubcError(`invalid route project root: project root does not exist: ${root}`, "config_divergence");
287
+ }
201
288
  /**
202
289
  * Fire-and-forget route close that can never throw — neither synchronously (a
203
290
  * client that rejects/throws when closing a route on an already-dead socket) nor
@@ -227,20 +314,33 @@ class RouteTornDownError extends Error {
227
314
  * `structuredContent`, so re-lifting it makes everything downstream (status_bar,
228
315
  * bg_completions, preview_diff, code, …) byte-identical to NDJSON.
229
316
  *
230
- * Every AFT tool reply over subc carries this envelope with a boolean `success`
231
- * and string `text`. A reply missing the envelope, or whose lifted shape lacks a
232
- * boolean `success`, is a PROTOCOL VIOLATION — never a tool result — and is thrown
233
- * rather than coerced. Coercing it (the old `{success:false,text:""}` /
234
- * raw-record fallback) could let a malformed reply with `success === undefined`
235
- * read downstream as a successful tool result (audit B-#7). Surfacing it loudly is
236
- * the honest contract: a broken wire shape is a failure, not a silent empty pass.
317
+ * Every AFT tool reply over subc carries this envelope with a boolean `success`.
318
+ * During the wire transition, `structuredContent.text` may be omitted; the bridge
319
+ * synthesizes it only from exactly one outer text block, while a present field wins
320
+ * unchanged. A reply missing the envelope, lacking boolean `success`, or missing
321
+ * text without an unambiguous outer block is a PROTOCOL VIOLATION — never a tool
322
+ * result — and is thrown rather than coerced. Coercing it (the old
323
+ * `{success:false,text:""}` / raw-record fallback) could let a malformed reply with
324
+ * `success === undefined` read downstream as a successful tool result (audit B-#7).
325
+ * Surfacing it loudly is the honest contract: a broken wire shape is a failure, not
326
+ * a silent empty pass.
237
327
  */
238
328
  function reliftReply(reply) {
239
329
  if (!isRecord(reply) || !isRecord(reply.structuredContent)) {
240
330
  throw new Error("subc tool reply is missing the structuredContent envelope (protocol violation)");
241
331
  }
242
332
  const flat = reply.structuredContent;
243
- if (typeof flat.success !== "boolean" || typeof flat.text !== "string") {
333
+ if (typeof flat.success !== "boolean") {
334
+ throw new Error("subc tool reply structuredContent lacks a boolean `success` / string `text` (protocol violation)");
335
+ }
336
+ if (!Object.hasOwn(flat, "text")) {
337
+ const content = reply.content;
338
+ const block = Array.isArray(content) && content.length === 1 ? content[0] : undefined;
339
+ if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
340
+ return { ...flat, text: block.text };
341
+ }
342
+ }
343
+ if (typeof flat.text !== "string") {
244
344
  throw new Error("subc tool reply structuredContent lacks a boolean `success` / string `text` (protocol violation)");
245
345
  }
246
346
  return flat;
@@ -253,15 +353,24 @@ function reliftReply(reply) {
253
353
  class SubcTransport {
254
354
  pool;
255
355
  projectRoot;
356
+ generation;
256
357
  lastStatusBar;
257
358
  cachedStatus = null;
258
- constructor(pool, projectRoot) {
359
+ constructor(pool, projectRoot, generation) {
259
360
  this.pool = pool;
260
361
  this.projectRoot = projectRoot;
362
+ this.generation = generation;
261
363
  }
262
364
  getCwd() {
263
365
  return this.projectRoot;
264
366
  }
367
+ /** Generation provenance is intentionally observable for lifecycle tests and nudge wiring. */
368
+ getGeneration() {
369
+ return this.generation;
370
+ }
371
+ getConcretePoolId() {
372
+ return this.pool.getConcretePoolId();
373
+ }
265
374
  getStatusBar() {
266
375
  return this.lastStatusBar;
267
376
  }
@@ -283,12 +392,16 @@ class SubcTransport {
283
392
  session: session && session.length > 0 ? session : DEFAULT_SESSION_ID,
284
393
  };
285
394
  }
395
+ assertCurrent() {
396
+ this.pool.assertFacadeCurrent(this.projectRoot, this.generation, "facade_use");
397
+ }
286
398
  async toolCall(sessionId, name, rawArgs = {}, options) {
399
+ this.assertCurrent();
287
400
  const { preview, timeoutMs, onProgress } = this.splitOptions(options);
288
401
  const body = { name, arguments: rawArgs };
289
402
  if (preview === true)
290
403
  body.preview = true;
291
- const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress);
404
+ const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress, this.generation);
292
405
  const result = reliftReply(reply);
293
406
  this.captureStatusBar(result);
294
407
  return result;
@@ -304,12 +417,13 @@ class SubcTransport {
304
417
  * what scopes it. `configure` is satisfied locally (binding is the configure).
305
418
  */
306
419
  async send(command, params = {}, options) {
420
+ this.assertCurrent();
307
421
  if (LOCALLY_SATISFIED_COMMANDS.has(command)) {
308
422
  return { success: true, command, subc_local: true };
309
423
  }
310
424
  const { timeoutMs, onProgress } = this.splitOptions(options);
311
425
  const session = typeof params.session_id === "string" ? params.session_id : undefined;
312
- const reply = await this.pool.routeRequest(this.identityFor(session), { name: command, arguments: params }, timeoutMs, onProgress);
426
+ const reply = await this.pool.routeRequest(this.identityFor(session), { name: command, arguments: params }, timeoutMs, onProgress, this.generation);
313
427
  const response = reliftReply(reply);
314
428
  this.captureStatusBar(response);
315
429
  return response;
@@ -328,9 +442,9 @@ class SubcTransport {
328
442
  }
329
443
  }
330
444
  /**
331
- * Route cache over one authenticated subc client. Implements {@link AftTransportPool}
332
- * so it drops into the plugin in place of {@link BridgePool} behind the shared
333
- * interface. One client per process; routes keyed by `(root, harness, session)`.
445
+ * Route cache over one authenticated subc client. In lifecycle mode this class
446
+ * also owns the concrete pool's root index; the registry owns timer and root
447
+ * transition state, while this class owns sessions, routes, and subscriptions.
334
448
  */
335
449
  export class SubcTransportPool {
336
450
  harness;
@@ -339,21 +453,33 @@ export class SubcTransportPool {
339
453
  consumerIdentity;
340
454
  connectFn;
341
455
  onBgEventsNudge;
456
+ onBgEventsNudgeRef;
342
457
  bgBackoffSleep;
458
+ lifecycleDemandCheck;
459
+ onLifecycleEvent;
460
+ onBgNudgeRejected;
461
+ lifecycleRegistry;
462
+ registryUsesPoolEventSink = false;
463
+ lifecycleRegistration = null;
464
+ outerFacadeEvictor;
343
465
  client = null;
344
466
  /** Single-flight guard so concurrent first calls share one connect. */
345
467
  connecting = null;
346
- /** Per-session lifecycle records keyed by a string built from root, harness, and session. */
468
+ /** Per-session records keyed by the opaque identity key. */
347
469
  sessions = new Map();
348
470
  /**
349
- * Consecutive NON-transient transport throws on the current client with no
350
- * success in between. Resets to 0 on any successful request. Trips a client drop
351
- * at {@link MAX_CONSECUTIVE_TRANSPORT_FAILURES} to recover a half-open socket
352
- * whose timeouts never classify transient (B-#4).
471
+ * The sole root-scoped session enumeration authority. Keys are opaque and are
472
+ * removed by the same detacher that removes the corresponding session record.
353
473
  */
474
+ rootIndex = new Map();
475
+ /** Roots whose route binds must stay dormant until their directories return. */
476
+ dormantRoots = new Map();
477
+ /** Consecutive non-transient failures on the current pool-local client. */
354
478
  transportFailures = 0;
355
- /** Per-root transport facades returned by getBridge/getActiveBridgeForRoot. */
479
+ /** Concrete per-root facades, including their captured root generation. */
356
480
  transports = new Map();
481
+ generationRejections = new Set();
482
+ pendingRootCleanups = new Set();
357
483
  shuttingDown = false;
358
484
  constructor(options) {
359
485
  this.connectionFile = options.connectionFile;
@@ -362,205 +488,572 @@ export class SubcTransportPool {
362
488
  this.consumerIdentity = options.consumerIdentity;
363
489
  this.connectFn = options.connect ?? ((opts) => SubcClient.connect(opts));
364
490
  this.onBgEventsNudge = options.onBgEventsNudge;
491
+ this.onBgEventsNudgeRef = options.onBgEventsNudgeRef;
365
492
  this.bgBackoffSleep =
366
493
  options.bgBackoffSleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
494
+ const lifecycle = options.lifecycle;
495
+ const demandCheck = options.lifecycleDemandCheck ?? options.demandCheck ?? lifecycle?.demandCheck;
496
+ this.lifecycleDemandCheck = demandCheck;
497
+ this.onLifecycleEvent = options.onLifecycleEvent ?? lifecycle?.onEvent;
498
+ this.onBgNudgeRejected = options.onBgNudgeRejected;
499
+ this.outerFacadeEvictor =
500
+ options.evictOuterFacade ?? lifecycle?.evictOuterFacade ?? (() => undefined);
501
+ const lifecycleRegistry = options.lifecycleRegistry ?? lifecycle?.registry;
502
+ if (lifecycleRegistry || demandCheck || options.lifecycleRegistration) {
503
+ this.registryUsesPoolEventSink =
504
+ lifecycleRegistry === undefined && this.onLifecycleEvent !== undefined;
505
+ this.lifecycleRegistry =
506
+ lifecycleRegistry ??
507
+ new LifecycleRegistry({
508
+ demandCheck: demandCheck ? (root, poolId) => demandCheck(root, poolId) : undefined,
509
+ onEvent: this.registryUsesPoolEventSink ? this.onLifecycleEvent : undefined,
510
+ });
511
+ if (options.lifecycleRegistration) {
512
+ this.bindLifecycleRegistration(options.lifecycleRegistration);
513
+ }
514
+ else {
515
+ this.bindLifecycleRegistration(this.lifecycleRegistry.registerLifecyclePool(this, {
516
+ reapingEnabled: options.reapingEnabled ?? lifecycle?.reapingEnabled ?? false,
517
+ evictOuterFacade: (root, generation) => this.outerFacadeEvictor(root, generation),
518
+ }));
519
+ }
520
+ }
367
521
  }
368
522
  /**
369
- * Fail-loud presence check (memory: present-but-unconnectable must never silently
370
- * downgrade to standalone). Returns false only when the file is genuinely absent.
523
+ * Bind a wrapper-owned registration after its generation-matched eviction
524
+ * callback has been installed. The handle is the only authority that may
525
+ * deregister this concrete pool.
371
526
  */
527
+ bindLifecycleRegistration(registration) {
528
+ if (this.lifecycleRegistration && this.lifecycleRegistration !== registration) {
529
+ this.lifecycleRegistration.deregister();
530
+ }
531
+ this.lifecycleRegistration = registration;
532
+ }
533
+ /** Construction helper for wrappers that own the registration sequence. */
534
+ registerLifecyclePool(registry, options) {
535
+ this.lifecycleRegistry = registry;
536
+ this.outerFacadeEvictor = options.evictOuterFacade;
537
+ const registration = registry.registerLifecyclePool(this, options);
538
+ this.bindLifecycleRegistration(registration);
539
+ return registration;
540
+ }
541
+ /** Replace only the callback; registration identity and switch stay immutable. */
542
+ setOuterFacadeEvictor(evictOuterFacade) {
543
+ this.outerFacadeEvictor = evictOuterFacade;
544
+ }
545
+ getConcretePoolId() {
546
+ return this.lifecycleRegistration?.concretePoolId;
547
+ }
548
+ getCurrentRootGeneration(root) {
549
+ return this.currentGeneration(root);
550
+ }
551
+ recordBgNudgeRejection(ref) {
552
+ const event = {
553
+ type: "subc_bg_nudge_rejected",
554
+ canonicalRoot: ref.canonicalRoot,
555
+ session: ref.session,
556
+ expectedGeneration: ref.generation,
557
+ currentGeneration: this.currentGeneration(ref.canonicalRoot),
558
+ expectedConcretePoolId: ref.concretePoolId,
559
+ currentConcretePoolId: this.currentPoolId(),
560
+ };
561
+ this.onBgNudgeRejected?.(event);
562
+ }
563
+ getLifecycleRegistration() {
564
+ return this.lifecycleRegistration;
565
+ }
566
+ getLifecycleRegistry() {
567
+ return this.lifecycleRegistry;
568
+ }
372
569
  static async connectionAvailable(connectionFile) {
373
570
  return connectionFileExists(connectionFile);
374
571
  }
375
- getBridge(projectRoot) {
376
- const key = canonicalizeProjectRoot(projectRoot);
377
- let transport = this.transports.get(key);
378
- if (!transport) {
379
- transport = new SubcTransport(this, key);
380
- this.transports.set(key, transport);
572
+ canonicalRoot(projectRoot) {
573
+ return asCanonicalRootPath(canonicalizeProjectRoot(projectRoot));
574
+ }
575
+ /**
576
+ * Check a root immediately before opening a route. The reclaim marker is only
577
+ * an existence hint; a directory that has already returned wins over a stale
578
+ * sibling marker, so the marker contents are never parsed.
579
+ */
580
+ rootCanAttach(root) {
581
+ let directoryExists = false;
582
+ try {
583
+ directoryExists = statSync(root).isDirectory();
584
+ }
585
+ catch {
586
+ // Missing, inaccessible, or otherwise unusable roots cannot be bound.
587
+ }
588
+ if (directoryExists) {
589
+ this.dormantRoots.delete(root);
590
+ return true;
591
+ }
592
+ // Read the marker only as a latency hint. Directory absence is sufficient
593
+ // to suspend the bind, and the marker's JSON is intentionally irrelevant.
594
+ const reclaimedMarkerPresent = existsSync(`${root}.reclaimed`);
595
+ if (reclaimedMarkerPresent) {
596
+ this.markRootDormant(root);
597
+ return false;
598
+ }
599
+ this.markRootDormant(root);
600
+ return false;
601
+ }
602
+ markRootDormant(root, rejection) {
603
+ const existing = this.dormantRoots.get(root);
604
+ if (existing)
605
+ return existing;
606
+ const error = rejection instanceof SubcError ? rejection : absentRootError(root);
607
+ this.dormantRoots.set(root, error);
608
+ log(`root ${root} reclaimed/absent; suspending attach until it exists`);
609
+ return error;
610
+ }
611
+ assertRootCanAttach(root) {
612
+ if (this.rootCanAttach(root))
613
+ return;
614
+ throw this.dormantRoots.get(root) ?? absentRootError(root);
615
+ }
616
+ lifecycleEnabled() {
617
+ // A disabled registration deliberately behaves like the pre-reaper pool:
618
+ // synthetic roots may be used, no demand check is required, and no sweep can
619
+ // acquire a tombstone for the pool.
620
+ return Boolean(this.lifecycleRegistry && this.lifecycleRegistration?.reapingEnabled);
621
+ }
622
+ currentGeneration(root) {
623
+ const registration = this.lifecycleRegistration;
624
+ return registration && this.lifecycleRegistry
625
+ ? this.lifecycleRegistry.currentGeneration(registration.concretePoolId, root)
626
+ : undefined;
627
+ }
628
+ currentPoolId() {
629
+ return this.lifecycleRegistration?.concretePoolId;
630
+ }
631
+ isCurrentLiveGeneration(root, generation) {
632
+ if (!this.lifecycleEnabled())
633
+ return true;
634
+ const registry = this.lifecycleRegistry;
635
+ const poolId = this.currentPoolId();
636
+ return (registry !== undefined &&
637
+ poolId !== undefined &&
638
+ generation !== undefined &&
639
+ registry.isCurrentLiveGeneration(poolId, root, generation));
640
+ }
641
+ isRootTombstoned(root, generation) {
642
+ const registry = this.lifecycleRegistry;
643
+ const poolId = this.currentPoolId();
644
+ return (registry !== undefined &&
645
+ poolId !== undefined &&
646
+ registry.isTombstoned(poolId, root, generation));
647
+ }
648
+ recordGenerationRejection(root, expectedGeneration, boundary) {
649
+ if (!this.lifecycleEnabled())
650
+ return;
651
+ const poolId = this.currentPoolId();
652
+ const registry = this.lifecycleRegistry;
653
+ if (!poolId || !registry)
654
+ return;
655
+ const key = `${poolId}\u0000${root}\u0000${expectedGeneration}\u0000${boundary}`;
656
+ if (this.generationRejections.has(key))
657
+ return;
658
+ this.generationRejections.add(key);
659
+ registry.recordGenerationRejection(poolId, root, expectedGeneration, boundary);
660
+ if (this.registryUsesPoolEventSink)
661
+ return;
662
+ this.onLifecycleEvent?.({
663
+ type: "subc_root_generation_rejected",
664
+ realm: registry.realm,
665
+ concretePoolId: poolId,
666
+ canonicalRoot: root,
667
+ expectedGeneration,
668
+ currentGeneration: registry.currentGeneration(poolId, root),
669
+ boundary,
670
+ });
671
+ }
672
+ generationExpiredError(root, expectedGeneration) {
673
+ const currentGeneration = this.currentGeneration(root);
674
+ this.recordGenerationRejection(root, expectedGeneration, "stale_generation");
675
+ return new SubcRootGenerationExpiredError({
676
+ canonicalRoot: root,
677
+ expectedGeneration,
678
+ currentGeneration,
679
+ concretePoolId: this.currentPoolId(),
680
+ currentConcretePoolId: this.currentPoolId(),
681
+ });
682
+ }
683
+ rootReapedError(record) {
684
+ return new SubcRootReapedError({
685
+ canonicalRoot: record.canonicalRoot,
686
+ expectedGeneration: record.generation ?? asRootGeneration(1),
687
+ currentGeneration: record.generation
688
+ ? this.currentGeneration(record.canonicalRoot)
689
+ : undefined,
690
+ concretePoolId: this.currentPoolId(),
691
+ currentConcretePoolId: this.currentPoolId(),
692
+ });
693
+ }
694
+ assertFacadeCurrent(root, expectedGeneration, boundary) {
695
+ this.assertGeneration(root, expectedGeneration, boundary);
696
+ }
697
+ assertGeneration(root, expectedGeneration, boundary) {
698
+ if (!this.lifecycleEnabled() || expectedGeneration === undefined)
699
+ return;
700
+ if (this.isCurrentLiveGeneration(root, expectedGeneration))
701
+ return;
702
+ if (this.isRootTombstoned(root, expectedGeneration)) {
703
+ throw new SubcRootReapedError({
704
+ canonicalRoot: root,
705
+ expectedGeneration,
706
+ currentGeneration: this.currentGeneration(root),
707
+ concretePoolId: this.currentPoolId(),
708
+ currentConcretePoolId: this.currentPoolId(),
709
+ });
381
710
  }
711
+ this.recordGenerationRejection(root, expectedGeneration, boundary);
712
+ throw this.generationExpiredError(root, expectedGeneration);
713
+ }
714
+ assertRecordLive(record) {
715
+ if (!this.isCurrentSession(record.identityKey, record)) {
716
+ if (record.teardownReason === "root_reaped")
717
+ throw this.rootReapedError(record);
718
+ throw new RouteTornDownError("subc session closed");
719
+ }
720
+ this.assertGeneration(record.canonicalRoot, record.generation, "route_request");
721
+ }
722
+ synchronousDemand(root) {
723
+ const poolId = this.currentPoolId();
724
+ if (!this.lifecycleEnabled() || poolId === undefined)
725
+ return true;
726
+ if (!this.lifecycleDemandCheck)
727
+ throw new SubcRootDemandRequiredError(root);
728
+ const result = this.lifecycleDemandCheck(root, poolId);
729
+ if (typeof result === "boolean")
730
+ return result;
731
+ return result.exists !== false;
732
+ }
733
+ makeFacade(root, generation) {
734
+ const existing = this.transports.get(root);
735
+ if (existing && (!this.lifecycleEnabled() || existing.getGeneration() === generation)) {
736
+ return existing;
737
+ }
738
+ const transport = new SubcTransport(this, root, generation);
739
+ this.transports.set(root, transport);
382
740
  return transport;
383
741
  }
742
+ /**
743
+ * Return a live facade. Legacy construction remains synchronous. Lifecycle
744
+ * construction uses the injected synchronous demand seam so a returned facade
745
+ * always has a registered root and captured generation.
746
+ */
747
+ getBridge(projectRoot) {
748
+ const root = this.canonicalRoot(projectRoot);
749
+ if (this.shuttingDown && this.lifecycleEnabled()) {
750
+ throw new SubcCallError("terminal", "subc transport is shutting down");
751
+ }
752
+ if (!this.lifecycleEnabled())
753
+ return this.makeFacade(root);
754
+ const current = this.currentGeneration(root);
755
+ const existing = this.transports.get(root);
756
+ if (current !== undefined &&
757
+ this.isCurrentLiveGeneration(root, current) &&
758
+ existing?.getGeneration() === current) {
759
+ return existing;
760
+ }
761
+ if (!this.synchronousDemand(root))
762
+ throw new SubcRootDemandRequiredError(root);
763
+ const registration = this.lifecycleRegistration;
764
+ const registry = this.lifecycleRegistry;
765
+ if (!registration || !registry)
766
+ throw new SubcRootDemandRequiredError(root);
767
+ const generation = registry.registerRoot(registration.concretePoolId, root);
768
+ return this.makeFacade(root, generation);
769
+ }
770
+ /** Async demand entry point for registries whose existence seam is asynchronous. */
771
+ async getBridgeForDemand(projectRoot) {
772
+ const root = this.canonicalRoot(projectRoot);
773
+ if (this.shuttingDown || !this.lifecycleEnabled())
774
+ return null;
775
+ const registration = this.lifecycleRegistration;
776
+ const registry = this.lifecycleRegistry;
777
+ if (!registration || !registry)
778
+ return null;
779
+ const generation = await registry.ensureRootForDemand(registration.concretePoolId, root);
780
+ if (generation === undefined ||
781
+ !registry.isCurrentLiveGeneration(registration.concretePoolId, root, generation)) {
782
+ return null;
783
+ }
784
+ return this.makeFacade(root, generation);
785
+ }
786
+ /** Alias used by host construction code that calls the seam a demand operation. */
787
+ demandBridge(projectRoot) {
788
+ return this.getBridgeForDemand(projectRoot);
789
+ }
384
790
  getActiveBridgeForRoot(projectRoot) {
385
- const key = canonicalizeProjectRoot(projectRoot);
386
- if (!this.client)
791
+ const root = this.canonicalRoot(projectRoot);
792
+ const transport = this.transports.get(root);
793
+ if (!transport)
387
794
  return null;
388
- return this.transports.get(key) ?? null;
795
+ if (this.lifecycleEnabled()) {
796
+ return this.isCurrentLiveGeneration(root, transport.getGeneration()) ? transport : null;
797
+ }
798
+ if (!this.client || this.shuttingDown)
799
+ return null;
800
+ return transport;
801
+ }
802
+ /** Non-creating lookup requiring the complete pool/root/generation provenance. */
803
+ getActiveBridgeForRootGeneration(ref) {
804
+ const poolId = this.currentPoolId();
805
+ if (!this.lifecycleEnabled() || poolId === undefined || ref.concretePoolId !== poolId)
806
+ return null;
807
+ if (!this.isCurrentLiveGeneration(ref.canonicalRoot, ref.generation))
808
+ return null;
809
+ const transport = this.transports.get(ref.canonicalRoot);
810
+ return transport?.getGeneration() === ref.generation ? transport : null;
389
811
  }
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
812
  activeBridges() {
394
- if (!this.client || this.shuttingDown)
813
+ if (this.shuttingDown)
395
814
  return [];
396
- return [...this.transports.values()];
815
+ return [...this.transports.entries()].flatMap(([root, transport]) => !this.lifecycleEnabled() || this.isCurrentLiveGeneration(root, transport.getGeneration())
816
+ ? [transport]
817
+ : []);
397
818
  }
398
819
  async toolCall(projectRoot, runtime, name, rawArgs = {}, options) {
399
820
  return this.getBridge(projectRoot).toolCall(runtime.sessionID, name, rawArgs, options);
400
821
  }
401
- getOrCreateSession(key) {
822
+ getOrCreateSession(identity, generation) {
823
+ const key = identityKey(identity);
824
+ const root = asCanonicalRootPath(identity.project_root);
402
825
  let record = this.sessions.get(key);
403
- if (!record || record.closed) {
404
- record = { routeEntry: null, bgSub: null, closed: false, inflight: 0 };
405
- this.sessions.set(key, record);
826
+ if (record && !record.closed) {
827
+ if (this.lifecycleEnabled() && record.generation !== generation) {
828
+ throw this.generationExpiredError(root, generation ?? asRootGeneration(1));
829
+ }
830
+ return record;
406
831
  }
832
+ record = {
833
+ identity,
834
+ identityKey: key,
835
+ canonicalRoot: root,
836
+ generation,
837
+ routeEntry: null,
838
+ bgSub: null,
839
+ closed: false,
840
+ teardownReason: null,
841
+ inflight: 0,
842
+ };
843
+ this.sessions.set(key, record);
844
+ let keys = this.rootIndex.get(root);
845
+ if (!keys) {
846
+ keys = new Set();
847
+ this.rootIndex.set(root, keys);
848
+ }
849
+ keys.add(key);
407
850
  return record;
408
851
  }
409
852
  isCurrentSession(key, record) {
410
853
  return this.sessions.get(key) === record && !record.closed;
411
854
  }
412
- deleteSessionIfEmpty(key, record) {
413
- if (this.sessions.get(key) === record &&
855
+ removeIndexMembership(record) {
856
+ const keys = this.rootIndex.get(record.canonicalRoot);
857
+ if (!keys)
858
+ return;
859
+ keys.delete(record.identityKey);
860
+ if (keys.size === 0)
861
+ this.rootIndex.delete(record.canonicalRoot);
862
+ }
863
+ deleteSessionIfEmpty(_key, record) {
864
+ if (this.sessions.get(record.identityKey) === record &&
414
865
  !record.closed &&
415
866
  record.inflight === 0 &&
416
867
  record.routeEntry === null &&
417
868
  record.bgSub === null) {
418
- this.sessions.delete(key);
869
+ this.sessions.delete(record.identityKey);
870
+ this.removeIndexMembership(record);
419
871
  }
420
872
  }
421
873
  /**
422
- * Open-or-reuse a route for `identity` and send `body` as a data-plane Request.
423
- * Rd reconnect is mutation-safe by construction: transport failures surface to
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.
874
+ * Atomically detaches one opaque identity. Nothing in this method awaits: it
875
+ * is the sole owner transfer used by session close, root reap, and shutdown.
430
876
  */
431
- async routeRequest(identity, body, timeoutMs, onProgress) {
877
+ detachSession(key, reason) {
878
+ const record = this.sessions.get(key);
879
+ if (!record || record.closed)
880
+ return null;
881
+ record.closed = true;
882
+ record.teardownReason = reason;
883
+ this.sessions.delete(key);
884
+ this.removeIndexMembership(record);
885
+ const bgSub = record.bgSub;
886
+ record.bgSub = null;
887
+ const routeEntry = record.routeEntry;
888
+ record.routeEntry = null;
889
+ if (routeEntry)
890
+ routeEntry.closed = true;
891
+ return { record, bgSub, routeEntry };
892
+ }
893
+ async cleanupDetached(detached) {
894
+ const cleanup = [];
895
+ if (detached.bgSub)
896
+ cleanup.push(detached.bgSub.stop());
897
+ const routeEntry = detached.routeEntry;
898
+ const route = routeEntry?.handle;
899
+ if (routeEntry && route !== null && route !== undefined) {
900
+ try {
901
+ cleanup.push(Promise.resolve(routeEntry.client.closeRouteChannel(route)).catch(() => undefined));
902
+ }
903
+ catch {
904
+ // A client may throw synchronously when its socket is already closed.
905
+ }
906
+ }
907
+ await Promise.allSettled(cleanup);
908
+ }
909
+ isReapInduced(record) {
910
+ return record.teardownReason === "root_reaped";
911
+ }
912
+ annotateReapError(error, record) {
913
+ if (!this.isReapInduced(record))
914
+ return error;
915
+ if (error instanceof SubcRootReapedError)
916
+ return error;
917
+ if (error instanceof Error) {
918
+ try {
919
+ Object.defineProperty(error, "subcTeardownReason", {
920
+ configurable: true,
921
+ enumerable: true,
922
+ value: "root_reaped",
923
+ writable: false,
924
+ });
925
+ }
926
+ catch {
927
+ // A frozen transport error cannot be annotated; use the stable wrapper.
928
+ return this.rootReapedError(record);
929
+ }
930
+ return error;
931
+ }
932
+ return this.rootReapedError(record);
933
+ }
934
+ /** Open or reuse a route while guarding every lifecycle boundary. */
935
+ async routeRequest(identity, body, timeoutMs, onProgress, expectedGeneration) {
936
+ const root = asCanonicalRootPath(identity.project_root);
937
+ let generation = expectedGeneration;
938
+ if (this.lifecycleEnabled()) {
939
+ generation ??= this.currentGeneration(root);
940
+ if (generation === undefined)
941
+ throw new SubcRootDemandRequiredError(root);
942
+ this.assertGeneration(root, generation, "route_request");
943
+ }
944
+ this.assertRootCanAttach(root);
432
945
  const key = identityKey(identity);
433
- const record = this.getOrCreateSession(key);
946
+ const record = this.getOrCreateSession(identity, generation);
434
947
  record.inflight += 1;
435
948
  try {
436
- const client = await this.ensureClient();
949
+ let client;
950
+ try {
951
+ client = await this.ensureClient();
952
+ this.assertRecordLive(record);
953
+ }
954
+ catch (error) {
955
+ throw this.annotateReapError(error, record);
956
+ }
437
957
  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
958
  try {
959
+ this.assertRecordLive(record);
445
960
  const opened = await this.routeHandle(client, identity, record);
446
- // routeOpen may have awaited. Do not send a request after a close, even
447
- // if a stale route entry just resolved.
448
- if (!this.isCurrentSession(key, record)) {
449
- throw new RouteTornDownError("subc session closed");
450
- }
961
+ this.assertRecordLive(record);
451
962
  return opened;
452
963
  }
453
- catch (err) {
454
- // A teardown that raced the open is not a transport fault — surface it
455
- // without dropping the client or charging the failure budget.
456
- if (err instanceof RouteTornDownError)
457
- throw err;
458
- // routeOpen itself failed. Classify the error like other request failures
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) &&
964
+ catch (error) {
965
+ if (this.isReapInduced(record))
966
+ throw this.annotateReapError(error, record);
967
+ if (error instanceof RouteTornDownError)
968
+ throw error;
969
+ if (isConsumerReconnectTransient(error) &&
463
970
  this.isCurrentSession(key, record) &&
464
971
  this.client === client) {
465
972
  this.dropClient(client);
466
973
  }
467
- throw err;
974
+ throw error;
468
975
  }
469
976
  };
470
977
  const clearRouteEntry = (entry) => {
471
- if (record.routeEntry === entry) {
472
- entry.closed = true;
473
- record.routeEntry = null;
474
- if (entry.handle != null) {
475
- // Forgetting only the local handle leaves the module's route bound
476
- // when a request times out but the shared connection remains alive.
477
- safeCloseRoute(entry.client, entry.handle);
478
- }
479
- }
978
+ if (record.routeEntry !== entry)
979
+ return;
980
+ entry.closed = true;
981
+ record.routeEntry = null;
982
+ if (entry.handle != null)
983
+ safeCloseRoute(entry.client, entry.handle);
480
984
  };
481
- const handleRequestFailure = (err, entry) => {
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.
985
+ const handleRequestFailure = (error, entry) => {
491
986
  clearRouteEntry(entry);
492
- // Only a failure from the current session on the current client can charge
493
- // or drop that client. A failure caused by closeSession sees
494
- // !isCurrentSession because close marked/deleted the record before awaiting
495
- // transport cleanup.
496
- if (this.isCurrentSession(key, record) && this.client === client) {
497
- if (isConsumerReconnectTransient(err)) {
498
- this.transportFailures = 0;
499
- this.dropClient(client);
500
- }
501
- else if (!isRouteProvenAbsentError(err) &&
502
- ++this.transportFailures >= MAX_CONSECUTIVE_TRANSPORT_FAILURES) {
503
- // A run of non-transient throws (timeouts / route GOODBYEs) with no
504
- // success between them is a half-open socket: local writes succeed but
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
- }
987
+ if (!this.isCurrentSession(key, record) ||
988
+ this.client !== client ||
989
+ this.isReapInduced(record))
990
+ return;
991
+ if (isConsumerReconnectTransient(error)) {
992
+ this.transportFailures = 0;
993
+ this.dropClient(client);
994
+ return;
995
+ }
996
+ if (!isRouteProvenAbsentError(error) &&
997
+ ++this.transportFailures >= MAX_CONSECUTIVE_TRANSPORT_FAILURES) {
998
+ this.transportFailures = 0;
999
+ this.dropClient(client);
510
1000
  }
511
1001
  };
512
1002
  const requestOnRoute = async (route) => {
513
- // Forward the caller's progress callback to the subc client. Current
514
- // foreground calls return one final reply, so production does not rely on
515
- // live progress events here.
1003
+ this.assertRecordLive(record);
516
1004
  const reply = await client.request(route, body, { timeoutMs, onProgress });
517
- // The half-open failure budget belongs to the current live session on the
518
- // current client. A late success after close or client replacement must
519
- // not mutate the current client's counter.
520
- if (this.isCurrentSession(key, record) && this.client === client) {
521
- this.transportFailures = 0;
1005
+ // A legacy closeSession may intentionally let an already-delivered reply
1006
+ // settle. It must not mutate shared state or recreate a subscription.
1007
+ if (!this.isCurrentSession(key, record)) {
1008
+ if (this.isReapInduced(record))
1009
+ throw this.rootReapedError(record);
1010
+ return reply;
522
1011
  }
523
- // bg_events is a session resource, not a route-entry resource. A
524
- // successful request on an older route should still ensure the subscription
525
- // while the session remains open, and a late success after close must not
526
- // resurrect one.
1012
+ this.assertGeneration(record.canonicalRoot, record.generation, "request_completion");
1013
+ if (this.client === client)
1014
+ this.transportFailures = 0;
527
1015
  this.ensureBgSubscription(identity, record);
528
1016
  return reply;
529
1017
  };
530
- let { route, entry } = await openRoute();
1018
+ let routeAndEntry = await openRoute();
531
1019
  try {
532
- return await requestOnRoute(route);
1020
+ return await requestOnRoute(routeAndEntry.route);
533
1021
  }
534
- catch (err) {
535
- if (isRouteProvenAbsentError(err) &&
1022
+ catch (error) {
1023
+ if (this.isReapInduced(record))
1024
+ throw this.annotateReapError(error, record);
1025
+ if (isRouteProvenAbsentError(error) &&
536
1026
  this.isCurrentSession(key, record) &&
537
1027
  this.client === client) {
538
- // Either the daemon says the channel does not exist, or the client
539
- // rejected the stale handle before writing. Reopen and resend once; all
540
- // other failures keep the no-auto-retry mutation-safety rule.
541
- clearRouteEntry(entry);
542
- ({ route, entry } = await openRoute());
1028
+ clearRouteEntry(routeAndEntry.entry);
1029
+ routeAndEntry = await openRoute();
543
1030
  try {
544
- return await requestOnRoute(route);
1031
+ return await requestOnRoute(routeAndEntry.route);
545
1032
  }
546
- catch (retryErr) {
547
- handleRequestFailure(retryErr, entry);
548
- throw retryErr;
1033
+ catch (retryError) {
1034
+ if (this.isReapInduced(record))
1035
+ throw this.annotateReapError(retryError, record);
1036
+ handleRequestFailure(retryError, routeAndEntry.entry);
1037
+ throw retryError;
549
1038
  }
550
1039
  }
551
- handleRequestFailure(err, entry);
552
- throw err;
1040
+ handleRequestFailure(error, routeAndEntry.entry);
1041
+ throw error;
553
1042
  }
554
1043
  }
1044
+ catch (error) {
1045
+ if (this.isReapInduced(record))
1046
+ throw this.annotateReapError(error, record);
1047
+ throw error;
1048
+ }
555
1049
  finally {
556
1050
  record.inflight -= 1;
557
1051
  this.deleteSessionIfEmpty(key, record);
558
1052
  }
559
1053
  }
560
1054
  async ensureClient() {
561
- if (this.shuttingDown) {
1055
+ if (this.shuttingDown)
562
1056
  throw new SubcCallError("terminal", "subc transport is shutting down");
563
- }
564
1057
  if (this.client)
565
1058
  return this.client;
566
1059
  if (this.connecting)
@@ -571,57 +1064,51 @@ export class SubcTransportPool {
571
1064
  })
572
1065
  .then((client) => {
573
1066
  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
1067
  if (this.shuttingDown) {
578
1068
  try {
579
1069
  client.close();
580
1070
  }
581
1071
  catch {
582
- // best-effort
1072
+ // A late connection is owned by the failed connect and is closed here.
583
1073
  }
584
1074
  throw new SubcCallError("terminal", "subc transport is shutting down");
585
1075
  }
586
1076
  this.client = client;
587
- // Fresh client generation starts with a clean failure budget (R2-T2).
588
1077
  this.transportFailures = 0;
589
1078
  return client;
590
1079
  })
591
- .catch((err) => {
1080
+ .catch((error) => {
592
1081
  this.connecting = null;
593
- throw err;
1082
+ throw error;
594
1083
  });
595
1084
  return this.connecting;
596
1085
  }
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
1086
  async routeHandle(client, identity, record) {
604
- const key = identityKey(identity);
605
1087
  const existing = record.routeEntry;
606
- if (existing?.handle != null)
1088
+ if (existing?.handle != null && existing.client === client)
607
1089
  return { route: existing.handle, entry: existing };
608
- if (existing?.opening)
1090
+ if (existing?.opening && existing.client === client)
609
1091
  return { route: await existing.opening, entry: existing };
610
- // Singleflight: install the entry BEFORE awaiting so a concurrent caller for
611
- // the same identity awaits this same open instead of minting a second channel.
612
- const entry = { client, opening: null, handle: null, closed: false };
1092
+ const entry = {
1093
+ canonicalRoot: record.canonicalRoot,
1094
+ generation: record.generation,
1095
+ client,
1096
+ opening: null,
1097
+ handle: null,
1098
+ closed: false,
1099
+ };
1100
+ this.assertRootCanAttach(record.canonicalRoot);
613
1101
  const opening = client
614
1102
  .routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
615
1103
  consumerIdentity: this.consumerIdentity,
616
1104
  })
617
1105
  .then((route) => {
618
- // A close/shutdown may have raced this open, or this entry may have been
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) ||
1106
+ if (!this.isCurrentSession(record.identityKey, record) ||
622
1107
  record.routeEntry !== entry ||
623
1108
  entry.closed ||
624
- this.client !== client) {
1109
+ this.client !== client ||
1110
+ (this.lifecycleEnabled() &&
1111
+ !this.isCurrentLiveGeneration(record.canonicalRoot, record.generation))) {
625
1112
  safeCloseRoute(client, route);
626
1113
  if (record.routeEntry === entry)
627
1114
  record.routeEntry = null;
@@ -631,66 +1118,137 @@ export class SubcTransportPool {
631
1118
  entry.opening = null;
632
1119
  return route;
633
1120
  })
634
- .catch((err) => {
635
- const current = this.isCurrentSession(key, record);
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.
1121
+ .catch((error) => {
1122
+ const currentEntry = this.isCurrentSession(record.identityKey, record) && record.routeEntry === entry;
638
1123
  if (record.routeEntry === entry) {
639
1124
  entry.closed = true;
640
1125
  record.routeEntry = null;
641
1126
  }
642
- if (!current && !(err instanceof RouteTornDownError)) {
1127
+ if (!this.isCurrentSession(record.identityKey, record) &&
1128
+ !(error instanceof RouteTornDownError)) {
643
1129
  throw new RouteTornDownError("subc route opened after session closed");
644
1130
  }
645
- throw err;
1131
+ if (currentEntry && isAbsentRootRouteError(error)) {
1132
+ this.markRootDormant(record.canonicalRoot, error);
1133
+ }
1134
+ throw error;
646
1135
  });
647
1136
  entry.opening = opening;
648
1137
  record.routeEntry = entry;
649
1138
  return { route: await opening, entry };
650
1139
  }
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
1140
  ensureBgSubscription(identity, record) {
658
- if (this.shuttingDown || !this.onBgEventsNudge)
1141
+ if (this.shuttingDown || (!this.onBgEventsNudge && !this.onBgEventsNudgeRef))
659
1142
  return;
660
- const key = identityKey(identity);
661
- if (!this.isCurrentSession(key, record))
1143
+ if (!this.isCurrentSession(record.identityKey, record))
1144
+ return;
1145
+ if (!this.rootCanAttach(record.canonicalRoot))
662
1146
  return;
663
1147
  if (record.bgSub)
664
1148
  return;
665
- const onNudge = () => this.onBgEventsNudge?.(identity.project_root, identity.session);
666
- const sub = new BgSubscription(identity, () => this.ensureClient(), (client) => this.dropClient(client), this.consumerIdentity, onNudge, this.bgBackoffSleep);
1149
+ const poolId = this.currentPoolId();
1150
+ const generation = record.generation;
1151
+ const nudgeRef = poolId !== undefined && generation !== undefined
1152
+ ? {
1153
+ canonicalRoot: record.canonicalRoot,
1154
+ session: identity.session,
1155
+ concretePoolId: poolId,
1156
+ generation,
1157
+ }
1158
+ : undefined;
1159
+ const onNudge = () => {
1160
+ if (!this.isCurrentSession(record.identityKey, record))
1161
+ return;
1162
+ this.onBgEventsNudge?.(identity.project_root, identity.session);
1163
+ if (nudgeRef)
1164
+ this.onBgEventsNudgeRef?.(nudgeRef);
1165
+ };
1166
+ let sub = null;
1167
+ const clearDormantSubscription = () => {
1168
+ if (sub && record.bgSub === sub)
1169
+ record.bgSub = null;
1170
+ };
1171
+ sub = new BgSubscription(identity, () => this.ensureClient(), (client) => this.dropClient(client), this.consumerIdentity, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
1172
+ if (!isAbsentRootRouteError(error))
1173
+ return false;
1174
+ this.markRootDormant(record.canonicalRoot, error);
1175
+ return true;
1176
+ }, clearDormantSubscription, nudgeRef, () => this.isCurrentSession(record.identityKey, record) &&
1177
+ this.isCurrentLiveGeneration(record.canonicalRoot, record.generation));
667
1178
  record.bgSub = sub;
1179
+ if (!this.rootCanAttach(record.canonicalRoot)) {
1180
+ record.bgSub = null;
1181
+ void sub.stop();
1182
+ }
668
1183
  }
669
- /** Drop a dead client so the next call reconnects; preserves session records. */
1184
+ /**
1185
+ * Invalidate only routes owned by a dead client. Sessions remain indexed so a
1186
+ * replacement client can reconnect the same identity and its bg subscription.
1187
+ */
670
1188
  dropClient(client) {
671
- if (this.client === client) {
672
- this.client = null;
673
- for (const [key, record] of this.sessions) {
674
- const entry = record.routeEntry;
675
- if (entry?.client === client) {
676
- entry.closed = true;
677
- record.routeEntry = null;
678
- this.deleteSessionIfEmpty(key, record);
679
- }
680
- }
681
- // The half-open failure counter is per-client-generation: a dropped client
682
- // resets it so the NEXT client starts fresh (R2-T2). Without this, failures
683
- // accrued on a client dropped via another path (e.g. a bg-subscription
684
- // transient drop) would carry over and trip the backstop on a healthy new
685
- // client after a single failure.
686
- this.transportFailures = 0;
687
- try {
688
- client.close();
689
- }
690
- catch {
691
- // best-effort; the socket is already gone
1189
+ if (this.client !== client)
1190
+ return;
1191
+ this.client = null;
1192
+ for (const record of this.sessions.values()) {
1193
+ const entry = record.routeEntry;
1194
+ if (entry?.client !== client)
1195
+ continue;
1196
+ entry.closed = true;
1197
+ record.routeEntry = null;
1198
+ this.deleteSessionIfEmpty(record.identityKey, record);
1199
+ }
1200
+ this.transportFailures = 0;
1201
+ try {
1202
+ client.close();
1203
+ }
1204
+ catch {
1205
+ // A dead socket is already released by the peer.
1206
+ }
1207
+ }
1208
+ /** Synchronous concrete-facade eviction used by the registry coordinator. */
1209
+ evictConcreteFacade(root, generation) {
1210
+ const facade = this.transports.get(root);
1211
+ if (facade?.getGeneration() === generation)
1212
+ this.transports.delete(root);
1213
+ }
1214
+ /**
1215
+ * Registry-owned coordinated close. All record/index/facade mutations happen
1216
+ * before cleanup promises are created; the registry has already tombstoned the
1217
+ * matching generation and evicted the wrapper facade before this is called.
1218
+ */
1219
+ async closeProjectRoot(root, generation) {
1220
+ if (!this.lifecycleEnabled())
1221
+ return { tornDownSessionCount: 0, tornDownFacadeCount: 0 };
1222
+ if (!this.isRootTombstoned(root, generation))
1223
+ return { tornDownSessionCount: 0, tornDownFacadeCount: 0 };
1224
+ const keys = [...(this.rootIndex.get(root) ?? new Set())];
1225
+ const detached = [];
1226
+ for (const key of keys) {
1227
+ const record = this.sessions.get(key);
1228
+ if (record?.canonicalRoot === root && record.generation === generation) {
1229
+ const owner = this.detachSession(key, "root_reaped");
1230
+ if (owner)
1231
+ detached.push(owner);
692
1232
  }
693
1233
  }
1234
+ this.rootIndex.delete(root);
1235
+ const hadFacade = this.transports.get(root)?.getGeneration() === generation;
1236
+ this.evictConcreteFacade(root, generation);
1237
+ const cleanup = Promise.allSettled(detached.map((owner) => this.cleanupDetached(owner))).then(() => ({
1238
+ tornDownSessionCount: detached.length,
1239
+ tornDownFacadeCount: hadFacade ? 1 : 0,
1240
+ }));
1241
+ this.pendingRootCleanups.add(cleanup);
1242
+ void cleanup.finally(() => this.pendingRootCleanups.delete(cleanup));
1243
+ return cleanup;
1244
+ }
1245
+ /** Forward explicit lifecycle close requests to the registry-owned coordinator. */
1246
+ requestProjectRootClose(root, generation, cause = "explicit") {
1247
+ const registry = this.lifecycleRegistry;
1248
+ const registration = this.lifecycleRegistration;
1249
+ if (!registry || !registration || !registration.reapingEnabled)
1250
+ return Promise.resolve();
1251
+ return registry.requestProjectRootClose(registration.concretePoolId, root, generation, cause);
694
1252
  }
695
1253
  /** No-op over subc: config is read locally by AFT (wire tiers are ignored). */
696
1254
  setConfigureOverride(_key, _value) { }
@@ -700,87 +1258,68 @@ export class SubcTransportPool {
700
1258
  async replaceBinary(path) {
701
1259
  return path;
702
1260
  }
703
- /** A subc pool instance is terminal after shutdown; ownership revives it outside this class. */
704
1261
  isShutdown() {
705
1262
  return this.shuttingDown;
706
1263
  }
707
1264
  async shutdown() {
1265
+ if (this.shuttingDown)
1266
+ return;
708
1267
  this.shuttingDown = true;
709
- const subs = [];
710
- const entries = [];
711
- for (const record of this.sessions.values()) {
712
- record.closed = true;
713
- const sub = record.bgSub;
714
- record.bgSub = null;
715
- if (sub)
716
- subs.push(sub);
717
- const entry = record.routeEntry;
718
- record.routeEntry = null;
719
- if (entry) {
720
- entry.closed = true;
721
- entries.push(entry);
722
- }
1268
+ this.dormantRoots.clear();
1269
+ // Deregistration synchronously tombstones all registered roots and invokes
1270
+ // closeProjectRoot before this method reaches its first await.
1271
+ const registration = this.lifecycleRegistration;
1272
+ registration?.deregister();
1273
+ this.lifecycleRegistration = null;
1274
+ const detached = [];
1275
+ for (const key of [...this.sessions.keys()]) {
1276
+ const owner = this.detachSession(key, "shutdown");
1277
+ if (owner)
1278
+ detached.push(owner);
723
1279
  }
724
- this.sessions.clear();
1280
+ this.rootIndex.clear();
1281
+ this.transports.clear();
725
1282
  const client = this.client;
726
1283
  this.client = null;
727
- this.transports.clear();
728
- await Promise.allSettled(subs.map((sub) => sub.stop()));
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
- }));
1284
+ const pending = [...this.pendingRootCleanups];
1285
+ await Promise.allSettled([...detached.map((owner) => this.cleanupDetached(owner)), ...pending]);
739
1286
  if (client) {
740
1287
  try {
741
1288
  client.close();
742
1289
  }
743
1290
  catch {
744
- // best-effort
1291
+ // Best effort; route cleanup has already been attempted.
745
1292
  }
746
1293
  }
747
1294
  }
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
1295
  async closeSession(projectRoot, session) {
754
1296
  const identity = {
755
1297
  project_root: canonicalizeProjectRoot(projectRoot),
756
1298
  harness: this.harness,
757
1299
  session: session && session.length > 0 ? session : DEFAULT_SESSION_ID,
758
1300
  };
759
- const key = identityKey(identity);
760
- const record = this.sessions.get(key);
761
- if (!record)
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
- }
1301
+ const owner = this.detachSession(identityKey(identity), "session_closed");
1302
+ if (owner)
1303
+ await this.cleanupDetached(owner);
784
1304
  }
785
1305
  }
1306
+ /**
1307
+ * Resolve a background nudge exactly once by looking up the existing active
1308
+ * bridge without creating one or reviving a shut-down pool; hosts use this
1309
+ * function to acknowledge nudge handling.
1310
+ */
1311
+ export function resolveBridgeForNudge(pool, ref) {
1312
+ const candidate = pool;
1313
+ const bridge = candidate.getActiveBridgeForRootGeneration?.(ref) ?? null;
1314
+ if (bridge)
1315
+ return bridge;
1316
+ candidate.recordBgNudgeRejection?.(ref);
1317
+ throw new SubcRootGenerationExpiredError({
1318
+ canonicalRoot: ref.canonicalRoot,
1319
+ expectedGeneration: ref.generation,
1320
+ currentGeneration: candidate.getCurrentRootGeneration?.(ref.canonicalRoot),
1321
+ concretePoolId: ref.concretePoolId,
1322
+ currentConcretePoolId: candidate.getConcretePoolId?.(),
1323
+ });
1324
+ }
786
1325
  //# sourceMappingURL=subc-transport.js.map