@cortexkit/aft-bridge 0.49.3 → 0.50.0

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