@cortexkit/aft-bridge 0.58.2 → 0.59.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 (106) hide show
  1. package/dist/bash-format.d.ts.map +1 -1
  2. package/dist/bash-format.js +5 -0
  3. package/dist/bash-format.js.map +1 -1
  4. package/dist/bash-hints.d.ts +76 -30
  5. package/dist/bash-hints.d.ts.map +1 -1
  6. package/dist/bash-hints.js +218 -47
  7. package/dist/bash-hints.js.map +1 -1
  8. package/dist/bash-host-fallback.d.ts +27 -0
  9. package/dist/bash-host-fallback.d.ts.map +1 -1
  10. package/dist/bash-host-fallback.js +55 -16
  11. package/dist/bash-host-fallback.js.map +1 -1
  12. package/dist/binary-identity.d.ts +13 -0
  13. package/dist/binary-identity.d.ts.map +1 -1
  14. package/dist/binary-identity.js +91 -2
  15. package/dist/binary-identity.js.map +1 -1
  16. package/dist/bridge.d.ts +12 -3
  17. package/dist/bridge.d.ts.map +1 -1
  18. package/dist/bridge.js +80 -38
  19. package/dist/bridge.js.map +1 -1
  20. package/dist/callgraph-format.d.ts.map +1 -1
  21. package/dist/callgraph-format.js +7 -7
  22. package/dist/callgraph-format.js.map +1 -1
  23. package/dist/child-process.d.ts.map +1 -1
  24. package/dist/child-process.js +10 -1
  25. package/dist/child-process.js.map +1 -1
  26. package/dist/config-error.d.ts +2 -2
  27. package/dist/config-error.js +2 -2
  28. package/dist/config-watch.d.ts +2 -0
  29. package/dist/config-watch.d.ts.map +1 -1
  30. package/dist/config-watch.js +35 -0
  31. package/dist/config-watch.js.map +1 -1
  32. package/dist/durable-log.d.ts +11 -0
  33. package/dist/durable-log.d.ts.map +1 -1
  34. package/dist/durable-log.js +67 -24
  35. package/dist/durable-log.js.map +1 -1
  36. package/dist/error-contract.d.ts +14 -5
  37. package/dist/error-contract.d.ts.map +1 -1
  38. package/dist/error-contract.js +42 -9
  39. package/dist/error-contract.js.map +1 -1
  40. package/dist/feature-config.d.ts +35 -18
  41. package/dist/feature-config.d.ts.map +1 -1
  42. package/dist/feature-config.js +161 -94
  43. package/dist/feature-config.js.map +1 -1
  44. package/dist/index.d.ts +8 -7
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +8 -7
  47. package/dist/index.js.map +1 -1
  48. package/dist/location-lifecycle.d.ts.map +1 -1
  49. package/dist/location-lifecycle.js +4 -2
  50. package/dist/location-lifecycle.js.map +1 -1
  51. package/dist/message-detach.d.ts +8 -0
  52. package/dist/message-detach.d.ts.map +1 -1
  53. package/dist/message-detach.js +32 -0
  54. package/dist/message-detach.js.map +1 -1
  55. package/dist/migration-notices.d.ts.map +1 -1
  56. package/dist/migration-notices.js +12 -5
  57. package/dist/migration-notices.js.map +1 -1
  58. package/dist/migration.d.ts +1 -0
  59. package/dist/migration.d.ts.map +1 -1
  60. package/dist/migration.js +39 -12
  61. package/dist/migration.js.map +1 -1
  62. package/dist/onnx-runtime.d.ts.map +1 -1
  63. package/dist/onnx-runtime.js +37 -32
  64. package/dist/onnx-runtime.js.map +1 -1
  65. package/dist/path-display.d.ts +5 -0
  66. package/dist/path-display.d.ts.map +1 -0
  67. package/dist/path-display.js +21 -0
  68. package/dist/path-display.js.map +1 -0
  69. package/dist/paths.d.ts.map +1 -1
  70. package/dist/paths.js +7 -6
  71. package/dist/paths.js.map +1 -1
  72. package/dist/private-storage.d.ts +9 -0
  73. package/dist/private-storage.d.ts.map +1 -0
  74. package/dist/private-storage.js +75 -0
  75. package/dist/private-storage.js.map +1 -0
  76. package/dist/protocol.d.ts +4 -0
  77. package/dist/protocol.d.ts.map +1 -1
  78. package/dist/revivable-transport.d.ts +4 -2
  79. package/dist/revivable-transport.d.ts.map +1 -1
  80. package/dist/revivable-transport.js +48 -19
  81. package/dist/revivable-transport.js.map +1 -1
  82. package/dist/storage-paths.d.ts +3 -4
  83. package/dist/storage-paths.d.ts.map +1 -1
  84. package/dist/storage-paths.js +4 -5
  85. package/dist/storage-paths.js.map +1 -1
  86. package/dist/subc-transport.d.ts +61 -3
  87. package/dist/subc-transport.d.ts.map +1 -1
  88. package/dist/subc-transport.js +383 -43
  89. package/dist/subc-transport.js.map +1 -1
  90. package/dist/tar-executable.d.ts +1 -1
  91. package/dist/tar-executable.d.ts.map +1 -1
  92. package/dist/tar-executable.js +16 -2
  93. package/dist/tar-executable.js.map +1 -1
  94. package/dist/test-child-environment.d.ts +8 -0
  95. package/dist/test-child-environment.d.ts.map +1 -0
  96. package/dist/test-child-environment.js +105 -0
  97. package/dist/test-child-environment.js.map +1 -0
  98. package/dist/transport.d.ts +14 -0
  99. package/dist/transport.d.ts.map +1 -1
  100. package/dist/transport.js +17 -0
  101. package/dist/transport.js.map +1 -1
  102. package/dist/zoom-format.d.ts +1 -0
  103. package/dist/zoom-format.d.ts.map +1 -1
  104. package/dist/zoom-format.js +5 -1
  105. package/dist/zoom-format.js.map +1 -1
  106. package/package.json +2 -2
@@ -16,13 +16,13 @@
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
+ import { statSync } from "node:fs";
20
20
  import { AuthError, connectionFileExists, isConsumerReconnectTransient, StaleRouteHandleError, SubcCallError, SubcClient, SubcError, } from "@cortexkit/subc-client";
21
21
  import { log, warn } from "./active-logger.js";
22
22
  import { isRouteOpenReloadWindowError, isRouteRequestReloadRefusal } from "./error-contract.js";
23
23
  import { asCanonicalRootPath, asRootGeneration, LifecycleRegistry, } from "./lifecycle-registry.js";
24
24
  import { canonicalizeProjectRoot } from "./project-identity.js";
25
- import { WORKER_SESSION_FIELD } from "./transport.js";
25
+ import { callPresetFor, PRESET_FIELD, WORKER_SESSION_FIELD } from "./transport.js";
26
26
  /** The subc pool is closing and cannot carry another request. */
27
27
  export class SubcTransportShuttingDownError extends SubcCallError {
28
28
  constructor() {
@@ -30,6 +30,51 @@ export class SubcTransportShuttingDownError extends SubcCallError {
30
30
  this.name = "SubcTransportShuttingDownError";
31
31
  }
32
32
  }
33
+ /**
34
+ * The plugin closed one of its own routes (pool shutdown, session close,
35
+ * project-root close, or a sibling call on the same session discarding the
36
+ * shared route) while this call's request was already in flight on it.
37
+ *
38
+ * subc-client records a request as pending and starts writing its frame in the
39
+ * same synchronous step, and its `closeRoute` rejects exactly those pending
40
+ * requests. So a request rejected that way was written, or queued to be
41
+ * written, and the daemon may have run it: the outcome is unknown. The call
42
+ * must not be blind-retried, and bash must not re-run it on the host.
43
+ */
44
+ export class SubcRouteClosedMidCallError extends SubcCallError {
45
+ reason;
46
+ constructor(reason, cause) {
47
+ super("outcome_unknown", `the command may have run; the route closed mid-call: ${reason}`, "route_closed", cause);
48
+ this.name = "SubcRouteClosedMidCallError";
49
+ this.reason = reason;
50
+ }
51
+ }
52
+ /**
53
+ * subc-client's rejection of a request that was pending when the client itself
54
+ * closed the route: `closeRoute` tearing that route down, or the client closing
55
+ * its connection. The client types both with `closeReason: "closed_by_caller"`;
56
+ * a daemon GOODBYE carries the daemon's reason and a lost connection carries
57
+ * `"connection_lost"`. A route closed with no request pending produces no error
58
+ * at all, and a request started on an already-closed route fails with
59
+ * `StaleRouteHandleError`. A route closed while still opening is reported as a
60
+ * not-sent `SubcCallError`, which is not a `SubcError` and so never matches.
61
+ */
62
+ export function isLocalRouteCloseRejection(error) {
63
+ return error instanceof SubcError && error.closeReason === "closed_by_caller";
64
+ }
65
+ /** The route-close reason that matches a session's teardown, if it was torn down. */
66
+ function teardownCloseReason(teardown) {
67
+ switch (teardown) {
68
+ case "shutdown":
69
+ return "transport shutting down";
70
+ case "session_closed":
71
+ return "session closed";
72
+ case "root_reaped":
73
+ return "project root closed";
74
+ default:
75
+ return null;
76
+ }
77
+ }
33
78
  class ReconnectKeyChangedError extends Error {
34
79
  constructor() {
35
80
  super("the subc daemon restarted and its connection key changed; the reconnect did not complete within the deadline, retry");
@@ -56,6 +101,10 @@ export function isSubcClientClosedError(error) {
56
101
  * the connection has already been torn down locally.
57
102
  */
58
103
  export function isSocketDeathError(error, depth = 0) {
104
+ // Raw data-plane requests wrap socket death in a SubcError; its typed
105
+ // connection-loss reason survives even when the underlying errno does not.
106
+ if (error instanceof SubcError && error.closeReason === "connection_lost")
107
+ return true;
59
108
  if (error instanceof SubcCallError) {
60
109
  const cause = error.cause;
61
110
  return depth < 4 && cause !== undefined && cause !== error
@@ -350,6 +399,11 @@ class BgSubscription {
350
399
  const initial = client.droppedIngressFrames;
351
400
  if (typeof initial !== "number")
352
401
  return () => undefined;
402
+ const existing = dispatchProbes.get(client);
403
+ if (existing) {
404
+ existing.users += 1;
405
+ return () => releaseDispatchProbe(client);
406
+ }
353
407
  let previous = initial;
354
408
  const timer = setInterval(() => {
355
409
  const total = client.droppedIngressFrames;
@@ -359,8 +413,10 @@ class BgSubscription {
359
413
  previous = total;
360
414
  this.info("dispatch-epoch-drop", `client ingress epoch drops scope=client observed_while_channel=${routeId} delta=${delta} total=${total}`);
361
415
  }, this.dispatchProbeIntervalMs);
416
+ dispatchProbeTimers += 1;
362
417
  timer.unref?.();
363
- return () => clearInterval(timer);
418
+ dispatchProbes.set(client, { users: 1, timer });
419
+ return () => releaseDispatchProbe(client);
364
420
  }
365
421
  async run() {
366
422
  let backoffAttempt = 0;
@@ -476,7 +532,7 @@ class BgSubscription {
476
532
  if (!this.isCurrent()) {
477
533
  this.info("nudge-stale-carrier", `nudge carried by stale subscription; checking current session channel=${routeId}`);
478
534
  }
479
- this.onNudge();
535
+ this.onNudge(true);
480
536
  });
481
537
  this.current = sub;
482
538
  this.attached = client;
@@ -494,7 +550,7 @@ class BgSubscription {
494
550
  // A reconnect may have missed completions, so force a drain after every
495
551
  // successful subscribe to replay anything queued while the stream was down.
496
552
  if (!this.stopped && this.isCurrent())
497
- this.onNudge();
553
+ this.onNudge(false);
498
554
  await sub.closed;
499
555
  this.info("stream-end", `stream ended channel=${routeId}`);
500
556
  if (this.stopped) {
@@ -610,6 +666,21 @@ function abortedRequestError() {
610
666
  error.name = "AbortError";
611
667
  return error;
612
668
  }
669
+ // The metric is connection-wide, so observing it needs one timer, not one per
670
+ // session. Last unsubscribe releases the timer, including on a dead socket.
671
+ const dispatchProbes = new Map();
672
+ let dispatchProbeTimers = 0;
673
+ function releaseDispatchProbe(client) {
674
+ const probe = dispatchProbes.get(client);
675
+ if (!probe || --probe.users > 0)
676
+ return;
677
+ clearInterval(probe.timer);
678
+ dispatchProbeTimers -= 1;
679
+ dispatchProbes.delete(client);
680
+ }
681
+ export function __dispatchProbeCountForTests() {
682
+ return dispatchProbeTimers;
683
+ }
613
684
  /**
614
685
  * A route open that resolved AFTER its session was torn down (closeSession /
615
686
  * shutdown / client swap). The caller's request can't proceed, but this is an
@@ -618,6 +689,25 @@ function abortedRequestError() {
618
689
  */
619
690
  class RouteTornDownError extends Error {
620
691
  }
692
+ /**
693
+ * True when a call stopped at a teardown before its request was handed to the
694
+ * client (a closed session, or a route that opened after the teardown), so the
695
+ * request provably never reached AFT.
696
+ */
697
+ export function isSubcRouteTornDownError(error) {
698
+ return error instanceof RouteTornDownError;
699
+ }
700
+ /**
701
+ * Turn subc-client's bare rejection of a request that was in flight on a route
702
+ * the plugin closed into an outcome-unknown error that names the close reason.
703
+ * Any other error passes through unchanged.
704
+ */
705
+ function routeClosedMidCallError(error, entry, record) {
706
+ if (!isLocalRouteCloseRejection(error))
707
+ return error;
708
+ const reason = teardownCloseReason(record.teardownReason) ?? entry.closedBy ?? "route closed by the plugin";
709
+ return new SubcRouteClosedMidCallError(reason, error);
710
+ }
621
711
  /**
622
712
  * Re-lift the route reply into the flat {@link ToolCallResult} shape the standalone
623
713
  * `BinaryBridge.toolCall` returns. The Rust module wraps the full flat response
@@ -709,6 +799,7 @@ class SubcTransport {
709
799
  body.preview = true;
710
800
  if (options?.workerSession === true)
711
801
  body[WORKER_SESSION_FIELD] = true;
802
+ body[PRESET_FIELD] = callPresetFor(options?.workerSession === true);
712
803
  const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress, this.generation, abortSignal);
713
804
  return reliftReply(reply);
714
805
  }
@@ -736,6 +827,7 @@ class SubcTransport {
736
827
  const body = { name: command, arguments: args };
737
828
  if (workerSession === true)
738
829
  body[WORKER_SESSION_FIELD] = true;
830
+ body[PRESET_FIELD] = callPresetFor(workerSession === true);
739
831
  const editSlotSurvives = this.pool.getEditSlotSurvives();
740
832
  if (editSlotSurvives !== undefined)
741
833
  body.edit_slot_survives = editSlotSurvives;
@@ -791,6 +883,10 @@ export class SubcTransportPool {
791
883
  routeReopenRetryMs = null;
792
884
  /** Per-session records keyed by the opaque identity key. */
793
885
  sessions = new Map();
886
+ // Pending host creation events are bounded. Forgetting one only makes that
887
+ // session unknown (retained), never falsely eligible for eviction.
888
+ observedStarts = new Set();
889
+ sessionSweep = null;
794
890
  /**
795
891
  * The sole root-scoped session enumeration authority. Keys are opaque and are
796
892
  * removed by the same detacher that removes the corresponding session record.
@@ -912,9 +1008,10 @@ export class SubcTransportPool {
912
1008
  return asCanonicalRootPath(canonicalizeProjectRoot(projectRoot));
913
1009
  }
914
1010
  /**
915
- * Check a root immediately before opening a route. The reclaim marker is only
916
- * an existence hint; a directory that has already returned wins over a stale
917
- * sibling marker, so the marker contents are never parsed.
1011
+ * Check a root immediately before opening a route. Only the directory's
1012
+ * existence decides: a reclaim marker beside it (`<root>.reclaimed`) needs
1013
+ * no check, since an absent directory suspends the bind either way and a
1014
+ * directory that has returned wins over a stale marker.
918
1015
  */
919
1016
  rootCanAttach(root) {
920
1017
  let directoryExists = false;
@@ -928,13 +1025,6 @@ export class SubcTransportPool {
928
1025
  this.dormantRoots.delete(root);
929
1026
  return true;
930
1027
  }
931
- // Read the marker only as a latency hint. Directory absence is sufficient
932
- // to suspend the bind, and the marker's JSON is intentionally irrelevant.
933
- const reclaimedMarkerPresent = existsSync(`${root}.reclaimed`);
934
- if (reclaimedMarkerPresent) {
935
- this.markRootDormant(root);
936
- return false;
937
- }
938
1028
  this.markRootDormant(root);
939
1029
  return false;
940
1030
  }
@@ -1166,6 +1256,7 @@ export class SubcTransportPool {
1166
1256
  if (this.lifecycleEnabled() && record.generation !== generation) {
1167
1257
  throw this.generationExpiredError(root, generation ?? asRootGeneration(1));
1168
1258
  }
1259
+ record.lastUsedAt = Date.now();
1169
1260
  return record;
1170
1261
  }
1171
1262
  record = {
@@ -1174,10 +1265,18 @@ export class SubcTransportPool {
1174
1265
  canonicalRoot: root,
1175
1266
  generation,
1176
1267
  routeEntry: null,
1268
+ retiredRoutes: new Set(),
1177
1269
  bgSub: null,
1178
1270
  closed: false,
1179
1271
  teardownReason: null,
1180
1272
  inflight: 0,
1273
+ lastUsedAt: Date.now(),
1274
+ observedFromStart: this.observedStarts.delete(key),
1275
+ backgroundTasks: new Map(),
1276
+ backgroundWatches: new Map(),
1277
+ unknownBackgroundWork: false,
1278
+ pendingBgNudge: false,
1279
+ pendingDetach: false,
1181
1280
  };
1182
1281
  this.sessions.set(key, record);
1183
1282
  let keys = this.rootIndex.get(root);
@@ -1186,8 +1285,173 @@ export class SubcTransportPool {
1186
1285
  this.rootIndex.set(root, keys);
1187
1286
  }
1188
1287
  keys.add(key);
1288
+ if (!this.sessionSweep) {
1289
+ this.sessionSweep = setInterval(() => {
1290
+ void this.reapIdleSessions();
1291
+ }, 60_000);
1292
+ this.sessionSweep.unref?.();
1293
+ }
1189
1294
  return record;
1190
1295
  }
1296
+ /** Release only idle carriers, not the daemon's durable session or completion queue.
1297
+ * The next request opens a new route and subscribes again; subscription startup
1298
+ * always nudges a drain, replaying completions queued while no carrier existed.
1299
+ */
1300
+ async reapIdleSessions(now = Date.now()) {
1301
+ for (const [key, state] of this.nudgeDeliveryLogState) {
1302
+ if (now - state.lastEmittedAt >= 15 * 60_000)
1303
+ this.nudgeDeliveryLogState.delete(key);
1304
+ }
1305
+ const detached = [];
1306
+ for (const [key, record] of this.sessions) {
1307
+ if (
1308
+ // The wire has no session-wide background census. A resumed/rebound
1309
+ // session may have tasks or watches this pool never saw, so only a
1310
+ // host-observed creation can establish a complete lifetime history.
1311
+ !record.observedFromStart ||
1312
+ record.backgroundTasks.size > 0 ||
1313
+ record.backgroundWatches.size > 0 ||
1314
+ record.unknownBackgroundWork ||
1315
+ record.pendingBgNudge ||
1316
+ record.pendingDetach ||
1317
+ record.inflight > 0 ||
1318
+ record.retiredRoutes.size > 0 ||
1319
+ record.routeEntry?.opening ||
1320
+ now - record.lastUsedAt < 15 * 60_000)
1321
+ continue;
1322
+ const owner = this.detachSession(key, "session_closed");
1323
+ if (owner)
1324
+ detached.push(owner);
1325
+ }
1326
+ if (this.sessions.size === 0 && this.sessionSweep) {
1327
+ clearInterval(this.sessionSweep);
1328
+ this.sessionSweep = null;
1329
+ }
1330
+ const cleanup = Promise.allSettled(detached.map((owner) => this.cleanupDetached(owner)));
1331
+ this.pendingRootCleanups.add(cleanup);
1332
+ try {
1333
+ await cleanup;
1334
+ }
1335
+ finally {
1336
+ this.pendingRootCleanups.delete(cleanup);
1337
+ }
1338
+ }
1339
+ observeSessionStart(projectRoot, session) {
1340
+ if (this.shuttingDown || !session)
1341
+ return;
1342
+ const key = identityKey({
1343
+ project_root: canonicalizeProjectRoot(projectRoot),
1344
+ harness: this.harness,
1345
+ session,
1346
+ });
1347
+ const record = this.sessions.get(key);
1348
+ if (record) {
1349
+ record.observedFromStart = true;
1350
+ return;
1351
+ }
1352
+ if (this.observedStarts.size >= 256)
1353
+ this.observedStarts.delete(this.observedStarts.values().next().value);
1354
+ this.observedStarts.add(key);
1355
+ }
1356
+ __pendingSessionStartsForTests() {
1357
+ return this.observedStarts.size;
1358
+ }
1359
+ observeBackgroundReply(record, body, reply) {
1360
+ const name = String(body.name);
1361
+ const args = isRecord(body.arguments) ? body.arguments : {};
1362
+ const data = isRecord(reply) && isRecord(reply.structuredContent) ? reply.structuredContent : null;
1363
+ const affectsBackground = name === "bash" ||
1364
+ name === "powershell" ||
1365
+ [
1366
+ "bash_status",
1367
+ "bash_kill",
1368
+ "bash_write",
1369
+ "bash_promote",
1370
+ "bash_watch",
1371
+ "bash_notify",
1372
+ "bash_unnotify",
1373
+ "bash_wait_detach",
1374
+ "bash_drain_completions",
1375
+ "bash_ack_completions",
1376
+ ].includes(name);
1377
+ if (!data || data.success !== true) {
1378
+ if (affectsBackground) {
1379
+ if (typeof args.task_id === "string") {
1380
+ if (!record.backgroundTasks.has(args.task_id))
1381
+ record.backgroundTasks.set(args.task_id, "unknown");
1382
+ }
1383
+ else
1384
+ record.unknownBackgroundWork = true;
1385
+ }
1386
+ return;
1387
+ }
1388
+ if (typeof data.task_id === "string") {
1389
+ // Terminal tasks remain pinned until the daemon explicitly ACKs them.
1390
+ const terminal = ["completed", "failed", "killed", "timed_out"].includes(String(data.status));
1391
+ const running = ["starting", "running", "killing"].includes(String(data.status));
1392
+ record.backgroundTasks.set(data.task_id, terminal ? "terminal" : running ? "running" : "unknown");
1393
+ if (name === "bash" || name === "powershell")
1394
+ record.pendingDetach = false;
1395
+ }
1396
+ if (Array.isArray(data.bg_completions)) {
1397
+ for (const completion of data.bg_completions) {
1398
+ if (isRecord(completion) && typeof completion.task_id === "string")
1399
+ record.backgroundTasks.set(completion.task_id, "terminal");
1400
+ else
1401
+ record.unknownBackgroundWork = true;
1402
+ }
1403
+ }
1404
+ if (Array.isArray(data.pending_matches)) {
1405
+ for (const match of data.pending_matches) {
1406
+ if (isRecord(match) &&
1407
+ typeof match.watch_id === "string" &&
1408
+ typeof match.task_id === "string")
1409
+ record.backgroundWatches.set(match.watch_id, match.task_id);
1410
+ else
1411
+ record.unknownBackgroundWork = true;
1412
+ }
1413
+ }
1414
+ if (name === "bash_drain_completions" &&
1415
+ Array.isArray(data.bg_completions) &&
1416
+ Array.isArray(data.pending_matches))
1417
+ record.pendingBgNudge = false;
1418
+ if (name === "bash_notify") {
1419
+ if (typeof data.watch_id === "string" && typeof args.task_id === "string")
1420
+ record.backgroundWatches.set(data.watch_id, args.task_id);
1421
+ else
1422
+ record.unknownBackgroundWork = true;
1423
+ }
1424
+ if (name === "bash_unnotify" && data.unregistered === true && typeof args.watch_id === "string")
1425
+ record.backgroundWatches.delete(args.watch_id);
1426
+ if (name === "bash_ack_completions" && Array.isArray(data.acked_task_ids)) {
1427
+ // ACK also acknowledges mid-run pattern matches. It does not prove a
1428
+ // running/unknown task is terminal, so those must keep their carrier.
1429
+ for (const task of data.acked_task_ids)
1430
+ if (typeof task === "string" && record.backgroundTasks.get(task) === "terminal") {
1431
+ record.backgroundTasks.delete(task);
1432
+ // The daemon's terminal ACK also removes every watch on that task.
1433
+ for (const [watch, owner] of record.backgroundWatches) {
1434
+ if (owner === task)
1435
+ record.backgroundWatches.delete(watch);
1436
+ }
1437
+ }
1438
+ }
1439
+ if (name === "bash_wait_detach") {
1440
+ if (typeof data.detached === "boolean")
1441
+ record.pendingDetach = data.detached;
1442
+ else
1443
+ record.unknownBackgroundWork = true;
1444
+ }
1445
+ }
1446
+ __retainedSessionCountsForTests() {
1447
+ return {
1448
+ sessions: this.sessions.size,
1449
+ roots: this.rootIndex.size,
1450
+ routes: [...this.sessions.values()].filter((r) => r.routeEntry !== null).length,
1451
+ subscriptions: [...this.sessions.values()].filter((r) => r.bgSub !== null).length,
1452
+ sweepTimers: this.sessionSweep ? 1 : 0,
1453
+ };
1454
+ }
1191
1455
  isCurrentSession(key, record) {
1192
1456
  return this.sessions.get(key) === record && !record.closed;
1193
1457
  }
@@ -1232,6 +1496,12 @@ export class SubcTransportPool {
1232
1496
  if (this.sessions.get(record.identityKey) === record &&
1233
1497
  !record.closed &&
1234
1498
  record.inflight === 0 &&
1499
+ record.retiredRoutes.size === 0 &&
1500
+ record.backgroundTasks.size === 0 &&
1501
+ record.backgroundWatches.size === 0 &&
1502
+ !record.unknownBackgroundWork &&
1503
+ !record.pendingBgNudge &&
1504
+ !record.pendingDetach &&
1235
1505
  record.routeEntry === null &&
1236
1506
  record.bgSub === null) {
1237
1507
  this.sessions.delete(record.identityKey);
@@ -1250,6 +1520,10 @@ export class SubcTransportPool {
1250
1520
  record.teardownReason = reason;
1251
1521
  this.sessions.delete(key);
1252
1522
  this.removeIndexMembership(record);
1523
+ for (const logKey of this.nudgeDeliveryLogState.keys()) {
1524
+ if (logKey.endsWith(`\u0000${key}`))
1525
+ this.nudgeDeliveryLogState.delete(logKey);
1526
+ }
1253
1527
  const bgSub = record.bgSub;
1254
1528
  record.bgSub = null;
1255
1529
  const routeEntry = record.routeEntry;
@@ -1262,14 +1536,19 @@ export class SubcTransportPool {
1262
1536
  const cleanup = [];
1263
1537
  if (detached.bgSub)
1264
1538
  cleanup.push(detached.bgSub.stop());
1265
- const routeEntry = detached.routeEntry;
1266
- const route = routeEntry?.handle;
1267
- if (routeEntry && route !== null && route !== undefined) {
1268
- try {
1269
- cleanup.push(Promise.resolve(routeEntry.client.closeRouteChannel(route)).catch(() => undefined));
1270
- }
1271
- catch {
1272
- // A client may throw synchronously when its socket is already closed.
1539
+ const entries = new Set(detached.record.retiredRoutes);
1540
+ detached.record.retiredRoutes.clear();
1541
+ if (detached.routeEntry)
1542
+ entries.add(detached.routeEntry);
1543
+ for (const entry of entries) {
1544
+ entry.closed = true;
1545
+ if (entry.handle != null) {
1546
+ try {
1547
+ cleanup.push(Promise.resolve(entry.client.closeRouteChannel(entry.handle)).catch(() => undefined));
1548
+ }
1549
+ catch {
1550
+ // A client may throw synchronously when its socket is already closed.
1551
+ }
1273
1552
  }
1274
1553
  }
1275
1554
  await Promise.allSettled(cleanup);
@@ -1299,6 +1578,17 @@ export class SubcTransportPool {
1299
1578
  }
1300
1579
  return this.rootReapedError(record);
1301
1580
  }
1581
+ closeRetiredRoute(record, entry) {
1582
+ if (entry.inflight > 0 || !record.retiredRoutes.delete(entry))
1583
+ return;
1584
+ if (entry.handle != null)
1585
+ safeCloseRoute(entry.client, entry.handle);
1586
+ this.deleteSessionIfEmpty(record.identityKey, record);
1587
+ }
1588
+ releaseRouteRequest(record, entry) {
1589
+ entry.inflight -= 1;
1590
+ this.closeRetiredRoute(record, entry);
1591
+ }
1302
1592
  /** Open or reuse a route while guarding every lifecycle boundary. */
1303
1593
  async routeRequest(identity, body, timeoutMs, onProgress, expectedGeneration, abortSignal) {
1304
1594
  const callStartedAt = performance.now();
@@ -1471,9 +1761,10 @@ export class SubcTransportPool {
1471
1761
  if (record.routeEntry !== entry)
1472
1762
  return;
1473
1763
  entry.closed = true;
1764
+ entry.closedBy ??= "route replaced";
1474
1765
  record.routeEntry = null;
1475
- if (entry.handle != null)
1476
- safeCloseRoute(entry.client, entry.handle);
1766
+ record.retiredRoutes.add(entry);
1767
+ this.closeRetiredRoute(record, entry);
1477
1768
  };
1478
1769
  const handleRequestFailure = (error, entry) => {
1479
1770
  const aborted = error instanceof Error && error.name === "AbortError";
@@ -1489,20 +1780,30 @@ export class SubcTransportPool {
1489
1780
  route: routeLabel(entry.handle),
1490
1781
  });
1491
1782
  }
1492
- clearRouteEntry(entry);
1783
+ // A call's timeout or refusal says nothing about the route's health.
1784
+ // Retire only a cancelled carrier or one known to be gone; a slow live
1785
+ // route remains reusable, and no replacement may cut its other calls.
1786
+ if (aborted ||
1787
+ isRouteProvenAbsentError(error) ||
1788
+ (error instanceof SubcError && error.closeReason !== undefined)) {
1789
+ clearRouteEntry(entry);
1790
+ }
1493
1791
  if (aborted || !ownsClient || this.client !== client)
1494
1792
  return;
1495
1793
  if (isRouteProvenAbsentError(error))
1496
1794
  return;
1497
- // Anything else is this call's own outcome: a timeout, a GOODBYE, a
1498
- // refusal, or a managed not_sent/outcome_unknown verdict. It fails this
1499
- // call and route only; a run of them just checks the connection.
1500
- this.noteUnansweredCall(client, {
1501
- error,
1502
- trigger: "request",
1503
- session: identity.session,
1504
- route: routeLabel(entry.handle),
1505
- });
1795
+ if (isDaemonAnswerError(error)) {
1796
+ this.transportFailures = 0;
1797
+ this.noteClientAlive(client);
1798
+ }
1799
+ else if (isUnansweredCallError(error)) {
1800
+ this.noteUnansweredCall(client, {
1801
+ error,
1802
+ trigger: "request",
1803
+ session: identity.session,
1804
+ route: routeLabel(entry.handle),
1805
+ });
1806
+ }
1506
1807
  };
1507
1808
  const requestOnRoute = async (route, entry, requestTimeoutMs) => {
1508
1809
  this.assertRecordLive(record);
@@ -1520,10 +1821,22 @@ export class SubcTransportPool {
1520
1821
  };
1521
1822
  abortSignal?.addEventListener("abort", onAbort, { once: true });
1522
1823
  try {
1523
- const request = client.request(route, body, {
1524
- timeoutMs: Math.ceil(requestTimeoutMs),
1525
- onProgress,
1526
- });
1824
+ // Track the actual client promise, not the abort race. A cancelled
1825
+ // caller has no terminal from the daemon yet, so closing its carrier
1826
+ // here would still cut sibling calls and invalidate request credits.
1827
+ entry.inflight += 1;
1828
+ let request;
1829
+ try {
1830
+ request = client.request(route, body, {
1831
+ timeoutMs: Math.ceil(requestTimeoutMs),
1832
+ onProgress,
1833
+ });
1834
+ }
1835
+ catch (error) {
1836
+ this.releaseRouteRequest(record, entry);
1837
+ throw error;
1838
+ }
1839
+ request = request.finally(() => this.releaseRouteRequest(record, entry));
1527
1840
  const reply = abortSignal ? await Promise.race([request, aborted]) : await request;
1528
1841
  // A legacy closeSession may intentionally let an already-delivered reply
1529
1842
  // settle. It must not mutate shared state or recreate a subscription.
@@ -1536,6 +1849,7 @@ export class SubcTransportPool {
1536
1849
  if (this.client === client)
1537
1850
  this.transportFailures = 0;
1538
1851
  this.noteClientAlive(client);
1852
+ this.observeBackgroundReply(record, body, reply);
1539
1853
  this.ensureBgSubscription(identity, record);
1540
1854
  return reply;
1541
1855
  }
@@ -1547,6 +1861,10 @@ export class SubcTransportPool {
1547
1861
  let reopened = false;
1548
1862
  let retriedAbsentRoute = false;
1549
1863
  while (true) {
1864
+ // Another call can retire a just-acquired carrier before this caller
1865
+ // dispatches. Only already-pending requests belong on retired routes.
1866
+ if (routeAndEntry.entry.closed)
1867
+ routeAndEntry = await openRouteAfterReloadWindow();
1550
1868
  // Checked outside the try below so the retry branches never mistake
1551
1869
  // this call's own expiry for a daemon refusal worth retrying.
1552
1870
  const requestTimeoutMs = remainingMs();
@@ -1558,7 +1876,8 @@ export class SubcTransportPool {
1558
1876
  this.resetRouteReopenBackoff();
1559
1877
  return reply;
1560
1878
  }
1561
- catch (error) {
1879
+ catch (requestError) {
1880
+ const error = routeClosedMidCallError(requestError, routeAndEntry.entry, record);
1562
1881
  if (this.isReapInduced(record))
1563
1882
  throw this.annotateReapError(error, record);
1564
1883
  const ownsRoute = this.isCurrentSession(key, record) && this.client === client;
@@ -1607,10 +1926,14 @@ export class SubcTransportPool {
1607
1926
  catch (error) {
1608
1927
  if (this.isReapInduced(record))
1609
1928
  throw this.annotateReapError(error, record);
1929
+ this.observeBackgroundReply(record, body, null);
1930
+ if (record.backgroundTasks.size > 0 || record.unknownBackgroundWork)
1931
+ this.ensureBgSubscription(identity, record);
1610
1932
  throw error;
1611
1933
  }
1612
1934
  finally {
1613
1935
  record.inflight -= 1;
1936
+ record.lastUsedAt = Date.now();
1614
1937
  this.deleteSessionIfEmpty(key, record);
1615
1938
  }
1616
1939
  }
@@ -1700,6 +2023,8 @@ export class SubcTransportPool {
1700
2023
  opening: null,
1701
2024
  handle: null,
1702
2025
  closed: false,
2026
+ inflight: 0,
2027
+ closedBy: null,
1703
2028
  };
1704
2029
  this.assertRootCanAttach(record.canonicalRoot);
1705
2030
  const opening = client
@@ -1747,12 +2072,12 @@ export class SubcTransportPool {
1747
2072
  return;
1748
2073
  if (!this.isCurrentSession(record.identityKey, record))
1749
2074
  return;
1750
- if (!this.rootCanAttach(record.canonicalRoot))
1751
- return;
1752
2075
  if (record.bgSub)
1753
2076
  return;
2077
+ if (!this.rootCanAttach(record.canonicalRoot))
2078
+ return;
1754
2079
  const nudgeRef = this.nudgeRefFor(record);
1755
- const onNudge = () => {
2080
+ const onNudge = (observedEvent) => {
1756
2081
  const currentRecord = this.currentSessionForNudge(identity);
1757
2082
  if (!currentRecord) {
1758
2083
  this.logNudgeDelivery("drop-no-current-session", record, `nudge dropped cause=no-current-session root=${record.canonicalRoot}`);
@@ -1761,6 +2086,9 @@ export class SubcTransportPool {
1761
2086
  if (currentRecord !== record) {
1762
2087
  this.logNudgeDelivery("forward-superseded-carrier", currentRecord, `nudge forwarding cause=superseded-carrying-record root=${currentRecord.canonicalRoot}`);
1763
2088
  }
2089
+ currentRecord.lastUsedAt = Date.now();
2090
+ if (observedEvent)
2091
+ currentRecord.pendingBgNudge = true;
1764
2092
  const currentRef = this.nudgeRefFor(currentRecord);
1765
2093
  let delivered = false;
1766
2094
  if (currentRef && this.onBgEventsNudgeRef) {
@@ -1825,6 +2153,12 @@ export class SubcTransportPool {
1825
2153
  ` message=${JSON.stringify(message)}`);
1826
2154
  this.client = null;
1827
2155
  for (const record of this.sessions.values()) {
2156
+ for (const retired of record.retiredRoutes) {
2157
+ if (retired.client !== client)
2158
+ continue;
2159
+ retired.closed = true;
2160
+ record.retiredRoutes.delete(retired);
2161
+ }
1828
2162
  const entry = record.routeEntry;
1829
2163
  if (entry?.client !== client)
1830
2164
  continue;
@@ -1996,6 +2330,12 @@ export class SubcTransportPool {
1996
2330
  if (this.shuttingDown)
1997
2331
  return;
1998
2332
  this.shuttingDown = true;
2333
+ if (this.sessionSweep)
2334
+ clearInterval(this.sessionSweep);
2335
+ this.sessionSweep = null;
2336
+ this.nudgeDeliveryLogState.clear();
2337
+ this.generationRejections.clear();
2338
+ this.observedStarts.clear();
1999
2339
  this.dormantRoots.clear();
2000
2340
  // Deregistration synchronously tombstones all registered roots and invokes
2001
2341
  // closeProjectRoot before this method reaches its first await.