@cortexkit/aft-bridge 0.58.1 → 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 (114) 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 +159 -20
  5. package/dist/bash-hints.d.ts.map +1 -1
  6. package/dist/bash-hints.js +343 -37
  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 +85 -40
  19. package/dist/bridge.js.map +1 -1
  20. package/dist/callgraph-format.d.ts.map +1 -1
  21. package/dist/callgraph-format.js +18 -10
  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/coerce.d.ts +27 -0
  27. package/dist/coerce.d.ts.map +1 -1
  28. package/dist/coerce.js +64 -0
  29. package/dist/coerce.js.map +1 -1
  30. package/dist/config-error.d.ts +2 -2
  31. package/dist/config-error.js +2 -2
  32. package/dist/config-watch.d.ts +2 -0
  33. package/dist/config-watch.d.ts.map +1 -1
  34. package/dist/config-watch.js +35 -0
  35. package/dist/config-watch.js.map +1 -1
  36. package/dist/durable-log.d.ts +11 -0
  37. package/dist/durable-log.d.ts.map +1 -1
  38. package/dist/durable-log.js +67 -24
  39. package/dist/durable-log.js.map +1 -1
  40. package/dist/error-contract.d.ts +14 -5
  41. package/dist/error-contract.d.ts.map +1 -1
  42. package/dist/error-contract.js +42 -9
  43. package/dist/error-contract.js.map +1 -1
  44. package/dist/feature-config.d.ts +35 -18
  45. package/dist/feature-config.d.ts.map +1 -1
  46. package/dist/feature-config.js +161 -94
  47. package/dist/feature-config.js.map +1 -1
  48. package/dist/index.d.ts +9 -7
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +9 -7
  51. package/dist/index.js.map +1 -1
  52. package/dist/location-lifecycle.d.ts.map +1 -1
  53. package/dist/location-lifecycle.js +22 -4
  54. package/dist/location-lifecycle.js.map +1 -1
  55. package/dist/message-detach.d.ts +15 -0
  56. package/dist/message-detach.d.ts.map +1 -1
  57. package/dist/message-detach.js +48 -0
  58. package/dist/message-detach.js.map +1 -1
  59. package/dist/migration-notices.d.ts.map +1 -1
  60. package/dist/migration-notices.js +12 -5
  61. package/dist/migration-notices.js.map +1 -1
  62. package/dist/migration.d.ts +1 -0
  63. package/dist/migration.d.ts.map +1 -1
  64. package/dist/migration.js +39 -12
  65. package/dist/migration.js.map +1 -1
  66. package/dist/onnx-runtime.d.ts +5 -0
  67. package/dist/onnx-runtime.d.ts.map +1 -1
  68. package/dist/onnx-runtime.js +55 -32
  69. package/dist/onnx-runtime.js.map +1 -1
  70. package/dist/path-aliases.d.ts.map +1 -1
  71. package/dist/path-aliases.js +8 -7
  72. package/dist/path-aliases.js.map +1 -1
  73. package/dist/path-display.d.ts +5 -0
  74. package/dist/path-display.d.ts.map +1 -0
  75. package/dist/path-display.js +21 -0
  76. package/dist/path-display.js.map +1 -0
  77. package/dist/paths.d.ts.map +1 -1
  78. package/dist/paths.js +7 -6
  79. package/dist/paths.js.map +1 -1
  80. package/dist/private-storage.d.ts +9 -0
  81. package/dist/private-storage.d.ts.map +1 -0
  82. package/dist/private-storage.js +75 -0
  83. package/dist/private-storage.js.map +1 -0
  84. package/dist/protocol.d.ts +4 -0
  85. package/dist/protocol.d.ts.map +1 -1
  86. package/dist/revivable-transport.d.ts +4 -2
  87. package/dist/revivable-transport.d.ts.map +1 -1
  88. package/dist/revivable-transport.js +48 -19
  89. package/dist/revivable-transport.js.map +1 -1
  90. package/dist/storage-paths.d.ts +3 -4
  91. package/dist/storage-paths.d.ts.map +1 -1
  92. package/dist/storage-paths.js +4 -5
  93. package/dist/storage-paths.js.map +1 -1
  94. package/dist/subc-transport.d.ts +62 -3
  95. package/dist/subc-transport.d.ts.map +1 -1
  96. package/dist/subc-transport.js +460 -53
  97. package/dist/subc-transport.js.map +1 -1
  98. package/dist/tar-executable.d.ts +1 -1
  99. package/dist/tar-executable.d.ts.map +1 -1
  100. package/dist/tar-executable.js +16 -2
  101. package/dist/tar-executable.js.map +1 -1
  102. package/dist/test-child-environment.d.ts +8 -0
  103. package/dist/test-child-environment.d.ts.map +1 -0
  104. package/dist/test-child-environment.js +105 -0
  105. package/dist/test-child-environment.js.map +1 -0
  106. package/dist/transport.d.ts +29 -0
  107. package/dist/transport.d.ts.map +1 -1
  108. package/dist/transport.js +26 -1
  109. package/dist/transport.js.map +1 -1
  110. package/dist/zoom-format.d.ts +1 -0
  111. package/dist/zoom-format.d.ts.map +1 -1
  112. package/dist/zoom-format.js +5 -1
  113. package/dist/zoom-format.js.map +1 -1
  114. package/package.json +2 -2
@@ -16,12 +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";
20
- import { connectionFileExists, isConsumerReconnectTransient, StaleRouteHandleError, SubcCallError, SubcClient, SubcError, } from "@cortexkit/subc-client";
19
+ import { statSync } from "node:fs";
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 { callPresetFor, PRESET_FIELD, WORKER_SESSION_FIELD } from "./transport.js";
25
26
  /** The subc pool is closing and cannot carry another request. */
26
27
  export class SubcTransportShuttingDownError extends SubcCallError {
27
28
  constructor() {
@@ -29,6 +30,57 @@ export class SubcTransportShuttingDownError extends SubcCallError {
29
30
  this.name = "SubcTransportShuttingDownError";
30
31
  }
31
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
+ }
78
+ class ReconnectKeyChangedError extends Error {
79
+ constructor() {
80
+ super("the subc daemon restarted and its connection key changed; the reconnect did not complete within the deadline, retry");
81
+ this.name = "ReconnectKeyChangedError";
82
+ }
83
+ }
32
84
  /** True when subc-client rejected locally because its connection was already closed. */
33
85
  export function isSubcClientClosedError(error) {
34
86
  return error instanceof SubcError && error.message === "client closed";
@@ -49,6 +101,10 @@ export function isSubcClientClosedError(error) {
49
101
  * the connection has already been torn down locally.
50
102
  */
51
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;
52
108
  if (error instanceof SubcCallError) {
53
109
  const cause = error.cause;
54
110
  return depth < 4 && cause !== undefined && cause !== error
@@ -343,6 +399,11 @@ class BgSubscription {
343
399
  const initial = client.droppedIngressFrames;
344
400
  if (typeof initial !== "number")
345
401
  return () => undefined;
402
+ const existing = dispatchProbes.get(client);
403
+ if (existing) {
404
+ existing.users += 1;
405
+ return () => releaseDispatchProbe(client);
406
+ }
346
407
  let previous = initial;
347
408
  const timer = setInterval(() => {
348
409
  const total = client.droppedIngressFrames;
@@ -352,8 +413,10 @@ class BgSubscription {
352
413
  previous = total;
353
414
  this.info("dispatch-epoch-drop", `client ingress epoch drops scope=client observed_while_channel=${routeId} delta=${delta} total=${total}`);
354
415
  }, this.dispatchProbeIntervalMs);
416
+ dispatchProbeTimers += 1;
355
417
  timer.unref?.();
356
- return () => clearInterval(timer);
418
+ dispatchProbes.set(client, { users: 1, timer });
419
+ return () => releaseDispatchProbe(client);
357
420
  }
358
421
  async run() {
359
422
  let backoffAttempt = 0;
@@ -469,7 +532,7 @@ class BgSubscription {
469
532
  if (!this.isCurrent()) {
470
533
  this.info("nudge-stale-carrier", `nudge carried by stale subscription; checking current session channel=${routeId}`);
471
534
  }
472
- this.onNudge();
535
+ this.onNudge(true);
473
536
  });
474
537
  this.current = sub;
475
538
  this.attached = client;
@@ -487,7 +550,7 @@ class BgSubscription {
487
550
  // A reconnect may have missed completions, so force a drain after every
488
551
  // successful subscribe to replay anything queued while the stream was down.
489
552
  if (!this.stopped && this.isCurrent())
490
- this.onNudge();
553
+ this.onNudge(false);
491
554
  await sub.closed;
492
555
  this.info("stream-end", `stream ended channel=${routeId}`);
493
556
  if (this.stopped) {
@@ -603,6 +666,21 @@ function abortedRequestError() {
603
666
  error.name = "AbortError";
604
667
  return error;
605
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
+ }
606
684
  /**
607
685
  * A route open that resolved AFTER its session was torn down (closeSession /
608
686
  * shutdown / client swap). The caller's request can't proceed, but this is an
@@ -611,6 +689,25 @@ function abortedRequestError() {
611
689
  */
612
690
  class RouteTornDownError extends Error {
613
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
+ }
614
711
  /**
615
712
  * Re-lift the route reply into the flat {@link ToolCallResult} shape the standalone
616
713
  * `BinaryBridge.toolCall` returns. The Rust module wraps the full flat response
@@ -700,6 +797,9 @@ class SubcTransport {
700
797
  body.edit_slot_survives = editSlotSurvives;
701
798
  if (preview === true)
702
799
  body.preview = true;
800
+ if (options?.workerSession === true)
801
+ body[WORKER_SESSION_FIELD] = true;
802
+ body[PRESET_FIELD] = callPresetFor(options?.workerSession === true);
703
803
  const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress, this.generation, abortSignal);
704
804
  return reliftReply(reply);
705
805
  }
@@ -720,7 +820,14 @@ class SubcTransport {
720
820
  }
721
821
  const { timeoutMs, onProgress, abortSignal } = this.splitOptions(options);
722
822
  const session = typeof params.session_id === "string" ? params.session_id : undefined;
723
- const body = { name: command, arguments: params };
823
+ // The caller's role travels beside the call, like the session (which the
824
+ // route bind carries), not inside the command's arguments: the module
825
+ // reads it from the call body.
826
+ const { [WORKER_SESSION_FIELD]: workerSession, ...args } = params;
827
+ const body = { name: command, arguments: args };
828
+ if (workerSession === true)
829
+ body[WORKER_SESSION_FIELD] = true;
830
+ body[PRESET_FIELD] = callPresetFor(workerSession === true);
724
831
  const editSlotSurvives = this.pool.getEditSlotSurvives();
725
832
  if (editSlotSurvives !== undefined)
726
833
  body.edit_slot_survives = editSlotSurvives;
@@ -765,6 +872,7 @@ export class SubcTransportPool {
765
872
  lifecycleRegistration = null;
766
873
  outerFacadeEvictor;
767
874
  client = null;
875
+ hadWorkingClient = false;
768
876
  /** Single-flight guard so concurrent first calls share one connect. */
769
877
  connecting = null;
770
878
  /** The growing delay for a safe, once-only route resend after route closure. */
@@ -775,6 +883,10 @@ export class SubcTransportPool {
775
883
  routeReopenRetryMs = null;
776
884
  /** Per-session records keyed by the opaque identity key. */
777
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;
778
890
  /**
779
891
  * The sole root-scoped session enumeration authority. Keys are opaque and are
780
892
  * removed by the same detacher that removes the corresponding session record.
@@ -896,9 +1008,10 @@ export class SubcTransportPool {
896
1008
  return asCanonicalRootPath(canonicalizeProjectRoot(projectRoot));
897
1009
  }
898
1010
  /**
899
- * Check a root immediately before opening a route. The reclaim marker is only
900
- * an existence hint; a directory that has already returned wins over a stale
901
- * 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.
902
1015
  */
903
1016
  rootCanAttach(root) {
904
1017
  let directoryExists = false;
@@ -912,13 +1025,6 @@ export class SubcTransportPool {
912
1025
  this.dormantRoots.delete(root);
913
1026
  return true;
914
1027
  }
915
- // Read the marker only as a latency hint. Directory absence is sufficient
916
- // to suspend the bind, and the marker's JSON is intentionally irrelevant.
917
- const reclaimedMarkerPresent = existsSync(`${root}.reclaimed`);
918
- if (reclaimedMarkerPresent) {
919
- this.markRootDormant(root);
920
- return false;
921
- }
922
1028
  this.markRootDormant(root);
923
1029
  return false;
924
1030
  }
@@ -1150,6 +1256,7 @@ export class SubcTransportPool {
1150
1256
  if (this.lifecycleEnabled() && record.generation !== generation) {
1151
1257
  throw this.generationExpiredError(root, generation ?? asRootGeneration(1));
1152
1258
  }
1259
+ record.lastUsedAt = Date.now();
1153
1260
  return record;
1154
1261
  }
1155
1262
  record = {
@@ -1158,10 +1265,18 @@ export class SubcTransportPool {
1158
1265
  canonicalRoot: root,
1159
1266
  generation,
1160
1267
  routeEntry: null,
1268
+ retiredRoutes: new Set(),
1161
1269
  bgSub: null,
1162
1270
  closed: false,
1163
1271
  teardownReason: null,
1164
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,
1165
1280
  };
1166
1281
  this.sessions.set(key, record);
1167
1282
  let keys = this.rootIndex.get(root);
@@ -1170,8 +1285,173 @@ export class SubcTransportPool {
1170
1285
  this.rootIndex.set(root, keys);
1171
1286
  }
1172
1287
  keys.add(key);
1288
+ if (!this.sessionSweep) {
1289
+ this.sessionSweep = setInterval(() => {
1290
+ void this.reapIdleSessions();
1291
+ }, 60_000);
1292
+ this.sessionSweep.unref?.();
1293
+ }
1173
1294
  return record;
1174
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
+ }
1175
1455
  isCurrentSession(key, record) {
1176
1456
  return this.sessions.get(key) === record && !record.closed;
1177
1457
  }
@@ -1216,6 +1496,12 @@ export class SubcTransportPool {
1216
1496
  if (this.sessions.get(record.identityKey) === record &&
1217
1497
  !record.closed &&
1218
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 &&
1219
1505
  record.routeEntry === null &&
1220
1506
  record.bgSub === null) {
1221
1507
  this.sessions.delete(record.identityKey);
@@ -1234,6 +1520,10 @@ export class SubcTransportPool {
1234
1520
  record.teardownReason = reason;
1235
1521
  this.sessions.delete(key);
1236
1522
  this.removeIndexMembership(record);
1523
+ for (const logKey of this.nudgeDeliveryLogState.keys()) {
1524
+ if (logKey.endsWith(`\u0000${key}`))
1525
+ this.nudgeDeliveryLogState.delete(logKey);
1526
+ }
1237
1527
  const bgSub = record.bgSub;
1238
1528
  record.bgSub = null;
1239
1529
  const routeEntry = record.routeEntry;
@@ -1246,14 +1536,19 @@ export class SubcTransportPool {
1246
1536
  const cleanup = [];
1247
1537
  if (detached.bgSub)
1248
1538
  cleanup.push(detached.bgSub.stop());
1249
- const routeEntry = detached.routeEntry;
1250
- const route = routeEntry?.handle;
1251
- if (routeEntry && route !== null && route !== undefined) {
1252
- try {
1253
- cleanup.push(Promise.resolve(routeEntry.client.closeRouteChannel(route)).catch(() => undefined));
1254
- }
1255
- catch {
1256
- // 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
+ }
1257
1552
  }
1258
1553
  }
1259
1554
  await Promise.allSettled(cleanup);
@@ -1283,9 +1578,25 @@ export class SubcTransportPool {
1283
1578
  }
1284
1579
  return this.rootReapedError(record);
1285
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
+ }
1286
1592
  /** Open or reuse a route while guarding every lifecycle boundary. */
1287
1593
  async routeRequest(identity, body, timeoutMs, onProgress, expectedGeneration, abortSignal) {
1288
1594
  const callStartedAt = performance.now();
1595
+ const callDeadlineMs = timeoutMs ?? SUBC_DEFAULT_REQUEST_TIMEOUT_MS;
1596
+ // Charge shared backoff delays even when an injected sleeper advances no real time.
1597
+ let scheduledDelayMs = 0;
1598
+ const spentMs = () => Math.max(performance.now() - callStartedAt, scheduledDelayMs);
1599
+ const remainingMs = () => callDeadlineMs - spentMs();
1289
1600
  const root = asCanonicalRootPath(identity.project_root);
1290
1601
  let generation = expectedGeneration;
1291
1602
  if (this.lifecycleEnabled()) {
@@ -1301,7 +1612,54 @@ export class SubcTransportPool {
1301
1612
  try {
1302
1613
  let client;
1303
1614
  try {
1304
- client = await this.ensureClient();
1615
+ let keyChanged;
1616
+ while (true) {
1617
+ const remaining = remainingMs();
1618
+ if (remaining <= 0)
1619
+ throw keyChanged ?? callDeadlinePassedError(undefined, callDeadlineMs);
1620
+ let timer;
1621
+ try {
1622
+ // Each attempt calls SubcClient.connect anew, which re-reads the connection file.
1623
+ const acquired = await Promise.race([
1624
+ this.ensureClient(),
1625
+ new Promise((resolve) => {
1626
+ timer = setTimeout(() => resolve(CALL_DEADLINE_EXPIRED), remaining);
1627
+ }),
1628
+ ]);
1629
+ if (acquired === CALL_DEADLINE_EXPIRED) {
1630
+ throw keyChanged ?? callDeadlinePassedError(undefined, callDeadlineMs);
1631
+ }
1632
+ client = acquired;
1633
+ break;
1634
+ }
1635
+ catch (error) {
1636
+ if (!(error instanceof ReconnectKeyChangedError))
1637
+ throw error;
1638
+ keyChanged = error;
1639
+ if (spentMs() + this.nextRouteReopenDelayMs() >= callDeadlineMs)
1640
+ throw error;
1641
+ }
1642
+ finally {
1643
+ clearTimeout(timer);
1644
+ }
1645
+ const { delayMs, wait } = this.waitForRouteReopenBackoff();
1646
+ scheduledDelayMs = spentMs() + delayMs;
1647
+ // A shared retry sleeper may outlive this caller's remaining budget.
1648
+ let retryTimer;
1649
+ try {
1650
+ const waited = await Promise.race([
1651
+ wait,
1652
+ new Promise((resolve) => {
1653
+ retryTimer = setTimeout(() => resolve(CALL_DEADLINE_EXPIRED), callDeadlineMs - (performance.now() - callStartedAt));
1654
+ }),
1655
+ ]);
1656
+ if (waited === CALL_DEADLINE_EXPIRED)
1657
+ throw keyChanged;
1658
+ }
1659
+ finally {
1660
+ clearTimeout(retryTimer);
1661
+ }
1662
+ }
1305
1663
  this.assertRecordLive(record);
1306
1664
  }
1307
1665
  catch (error) {
@@ -1351,18 +1709,10 @@ export class SubcTransportPool {
1351
1709
  // (a bind relay can hold one for about 12 s), retry sleeps, and the
1352
1710
  // request itself, which is sent with only the time that is left. So the
1353
1711
  // call never outlives the deadline it was given, whichever path it takes.
1354
- const callDeadlineMs = timeoutMs ?? SUBC_DEFAULT_REQUEST_TIMEOUT_MS;
1355
1712
  const reloadWaitBudgetMs = Math.min(callDeadlineMs, ROUTE_OPEN_RELOAD_WAIT_CEILING_MS);
1356
- // Retry delays this call has been charged for. Spent time never counts
1357
- // less than these: a caller that joins a shared retry timer part-way is
1358
- // still charged its full delay, as it always was, and an injected test
1359
- // sleeper may finish a delay without real time passing.
1360
- let scheduledDelayMs = 0;
1361
1713
  // The most recent route refusal, so a deadline that passes mid-open still
1362
1714
  // names the daemon's reason instead of a bare timeout.
1363
1715
  let lastRefusal;
1364
- const spentMs = () => Math.max(performance.now() - callStartedAt, scheduledDelayMs);
1365
- const remainingMs = () => callDeadlineMs - spentMs();
1366
1716
  // subc-client's routeOpen takes no deadline of its own (only its fixed
1367
1717
  // channel-0 default), so the call stops waiting for the open once its
1368
1718
  // own time is up. An open that completes later still lands in the
@@ -1411,9 +1761,10 @@ export class SubcTransportPool {
1411
1761
  if (record.routeEntry !== entry)
1412
1762
  return;
1413
1763
  entry.closed = true;
1764
+ entry.closedBy ??= "route replaced";
1414
1765
  record.routeEntry = null;
1415
- if (entry.handle != null)
1416
- safeCloseRoute(entry.client, entry.handle);
1766
+ record.retiredRoutes.add(entry);
1767
+ this.closeRetiredRoute(record, entry);
1417
1768
  };
1418
1769
  const handleRequestFailure = (error, entry) => {
1419
1770
  const aborted = error instanceof Error && error.name === "AbortError";
@@ -1429,20 +1780,30 @@ export class SubcTransportPool {
1429
1780
  route: routeLabel(entry.handle),
1430
1781
  });
1431
1782
  }
1432
- 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
+ }
1433
1791
  if (aborted || !ownsClient || this.client !== client)
1434
1792
  return;
1435
1793
  if (isRouteProvenAbsentError(error))
1436
1794
  return;
1437
- // Anything else is this call's own outcome: a timeout, a GOODBYE, a
1438
- // refusal, or a managed not_sent/outcome_unknown verdict. It fails this
1439
- // call and route only; a run of them just checks the connection.
1440
- this.noteUnansweredCall(client, {
1441
- error,
1442
- trigger: "request",
1443
- session: identity.session,
1444
- route: routeLabel(entry.handle),
1445
- });
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
+ }
1446
1807
  };
1447
1808
  const requestOnRoute = async (route, entry, requestTimeoutMs) => {
1448
1809
  this.assertRecordLive(record);
@@ -1460,10 +1821,22 @@ export class SubcTransportPool {
1460
1821
  };
1461
1822
  abortSignal?.addEventListener("abort", onAbort, { once: true });
1462
1823
  try {
1463
- const request = client.request(route, body, {
1464
- timeoutMs: Math.ceil(requestTimeoutMs),
1465
- onProgress,
1466
- });
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));
1467
1840
  const reply = abortSignal ? await Promise.race([request, aborted]) : await request;
1468
1841
  // A legacy closeSession may intentionally let an already-delivered reply
1469
1842
  // settle. It must not mutate shared state or recreate a subscription.
@@ -1476,6 +1849,7 @@ export class SubcTransportPool {
1476
1849
  if (this.client === client)
1477
1850
  this.transportFailures = 0;
1478
1851
  this.noteClientAlive(client);
1852
+ this.observeBackgroundReply(record, body, reply);
1479
1853
  this.ensureBgSubscription(identity, record);
1480
1854
  return reply;
1481
1855
  }
@@ -1487,6 +1861,10 @@ export class SubcTransportPool {
1487
1861
  let reopened = false;
1488
1862
  let retriedAbsentRoute = false;
1489
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();
1490
1868
  // Checked outside the try below so the retry branches never mistake
1491
1869
  // this call's own expiry for a daemon refusal worth retrying.
1492
1870
  const requestTimeoutMs = remainingMs();
@@ -1498,7 +1876,8 @@ export class SubcTransportPool {
1498
1876
  this.resetRouteReopenBackoff();
1499
1877
  return reply;
1500
1878
  }
1501
- catch (error) {
1879
+ catch (requestError) {
1880
+ const error = routeClosedMidCallError(requestError, routeAndEntry.entry, record);
1502
1881
  if (this.isReapInduced(record))
1503
1882
  throw this.annotateReapError(error, record);
1504
1883
  const ownsRoute = this.isCurrentSession(key, record) && this.client === client;
@@ -1547,10 +1926,14 @@ export class SubcTransportPool {
1547
1926
  catch (error) {
1548
1927
  if (this.isReapInduced(record))
1549
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);
1550
1932
  throw error;
1551
1933
  }
1552
1934
  finally {
1553
1935
  record.inflight -= 1;
1936
+ record.lastUsedAt = Date.now();
1554
1937
  this.deleteSessionIfEmpty(key, record);
1555
1938
  }
1556
1939
  }
@@ -1611,11 +1994,18 @@ export class SubcTransportPool {
1611
1994
  throw new SubcTransportShuttingDownError();
1612
1995
  }
1613
1996
  this.client = client;
1997
+ this.hadWorkingClient = true;
1614
1998
  this.transportFailures = 0;
1615
1999
  return client;
1616
2000
  })
1617
2001
  .catch((error) => {
1618
2002
  this.connecting = null;
2003
+ // A previously authenticated transport can race the daemon's key rotation.
2004
+ // Without a prior authenticated client, a proof mismatch can mean an impostor
2005
+ // or misconfiguration, so preserve the SDK's permanent authentication error.
2006
+ if (this.hadWorkingClient && error instanceof AuthError) {
2007
+ throw new ReconnectKeyChangedError();
2008
+ }
1619
2009
  throw error;
1620
2010
  });
1621
2011
  return this.connecting;
@@ -1633,6 +2023,8 @@ export class SubcTransportPool {
1633
2023
  opening: null,
1634
2024
  handle: null,
1635
2025
  closed: false,
2026
+ inflight: 0,
2027
+ closedBy: null,
1636
2028
  };
1637
2029
  this.assertRootCanAttach(record.canonicalRoot);
1638
2030
  const opening = client
@@ -1680,12 +2072,12 @@ export class SubcTransportPool {
1680
2072
  return;
1681
2073
  if (!this.isCurrentSession(record.identityKey, record))
1682
2074
  return;
1683
- if (!this.rootCanAttach(record.canonicalRoot))
1684
- return;
1685
2075
  if (record.bgSub)
1686
2076
  return;
2077
+ if (!this.rootCanAttach(record.canonicalRoot))
2078
+ return;
1687
2079
  const nudgeRef = this.nudgeRefFor(record);
1688
- const onNudge = () => {
2080
+ const onNudge = (observedEvent) => {
1689
2081
  const currentRecord = this.currentSessionForNudge(identity);
1690
2082
  if (!currentRecord) {
1691
2083
  this.logNudgeDelivery("drop-no-current-session", record, `nudge dropped cause=no-current-session root=${record.canonicalRoot}`);
@@ -1694,6 +2086,9 @@ export class SubcTransportPool {
1694
2086
  if (currentRecord !== record) {
1695
2087
  this.logNudgeDelivery("forward-superseded-carrier", currentRecord, `nudge forwarding cause=superseded-carrying-record root=${currentRecord.canonicalRoot}`);
1696
2088
  }
2089
+ currentRecord.lastUsedAt = Date.now();
2090
+ if (observedEvent)
2091
+ currentRecord.pendingBgNudge = true;
1697
2092
  const currentRef = this.nudgeRefFor(currentRecord);
1698
2093
  let delivered = false;
1699
2094
  if (currentRef && this.onBgEventsNudgeRef) {
@@ -1758,6 +2153,12 @@ export class SubcTransportPool {
1758
2153
  ` message=${JSON.stringify(message)}`);
1759
2154
  this.client = null;
1760
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
+ }
1761
2162
  const entry = record.routeEntry;
1762
2163
  if (entry?.client !== client)
1763
2164
  continue;
@@ -1929,6 +2330,12 @@ export class SubcTransportPool {
1929
2330
  if (this.shuttingDown)
1930
2331
  return;
1931
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();
1932
2339
  this.dormantRoots.clear();
1933
2340
  // Deregistration synchronously tombstones all registered roots and invokes
1934
2341
  // closeProjectRoot before this method reaches its first await.